diff --git a/.github/workflows/pr-project-assigner.yml b/.github/workflows/pr-project-assigner.yml index 4058fcaadee5d..ca5d0b9864f99 100644 --- a/.github/workflows/pr-project-assigner.yml +++ b/.github/workflows/pr-project-assigner.yml @@ -8,7 +8,7 @@ jobs: name: Assign a PR to project based on label steps: - name: Assign to project - uses: elastic/github-actions/project-assigner@v1.0.3 + uses: elastic/github-actions/project-assigner@v2.0.0 id: project_assigner with: issue-mappings: | @@ -16,6 +16,6 @@ jobs: ] ghToken: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} -# { "label": "Team:AppArch", "projectName": "kibana-app-arch", "columnId": 6173897 }, -# { "label": "Feature:Lens", "projectName": "Lens", "columnId": 6219362 }, -# { "label": "Team:Canvas", "projectName": "canvas", "columnId": 6187580 } +# { "label": "Team:AppArch", "projectNumber": 37, "columnName": "Review in progress" }, +# { "label": "Feature:Lens", "projectNumber": 32, "columnName": "In progress" }, +# { "label": "Team:Canvas", "projectNumber": 38, "columnName": "Review in progress" } \ No newline at end of file diff --git a/.github/workflows/project-assigner.yml b/.github/workflows/project-assigner.yml index 08be831e0664c..eb5827e121c74 100644 --- a/.github/workflows/project-assigner.yml +++ b/.github/workflows/project-assigner.yml @@ -8,10 +8,10 @@ jobs: name: Assign issue or PR to project based on label steps: - name: Assign to project - uses: elastic/github-actions/project-assigner@v1.0.3 + uses: elastic/github-actions/project-assigner@v2.0.0 id: project_assigner with: - issue-mappings: '[{"label": "Team:AppArch", "projectName": "kibana-app-arch", "columnId": 6173895}, {"label": "Feature:Lens", "projectName": "Lens", "columnId": 6219363}, {"label": "Team:Canvas", "projectName": "canvas", "columnId": 6187593}]' + issue-mappings: '[{"label": "Team:AppArch", "projectNumber": 37, "columnName": "To triage"}, {"label": "Feature:Lens", "projectNumber": 32, "columnName": "Long-term goals"}, {"label": "Team:Canvas", "projectNumber": 38, "columnName": "Inbox"}]' ghToken: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} diff --git a/docs/api/upgrade-assistant.asciidoc b/docs/api/upgrade-assistant.asciidoc index b524307c0f273..3e9c416b292cf 100644 --- a/docs/api/upgrade-assistant.asciidoc +++ b/docs/api/upgrade-assistant.asciidoc @@ -10,11 +10,17 @@ The following upgrade assistant APIs are available: * <> to start a new reindex or resume a paused reindex +* <> to start or resume multiple reindex tasks + +* <> to check the current reindex batch queue + * <> to check the status of the reindex operation * <> to cancel reindexes that are waiting for the Elasticsearch reindex task to complete include::upgrade-assistant/status.asciidoc[] include::upgrade-assistant/reindexing.asciidoc[] +include::upgrade-assistant/batch_reindexing.asciidoc[] +include::upgrade-assistant/batch_queue.asciidoc[] include::upgrade-assistant/check_reindex_status.asciidoc[] include::upgrade-assistant/cancel_reindex.asciidoc[] diff --git a/docs/api/upgrade-assistant/batch_queue.asciidoc b/docs/api/upgrade-assistant/batch_queue.asciidoc new file mode 100644 index 0000000000000..dcb9b465e4ddc --- /dev/null +++ b/docs/api/upgrade-assistant/batch_queue.asciidoc @@ -0,0 +1,68 @@ +[[batch-reindex-queue]] +=== Batch reindex queue API +++++ +Batch reindex queue +++++ + +experimental["The underlying Upgrade Assistant concepts are stable, but the APIs for managing Upgrade Assistant are experimental."] + +Check the current reindex batch queue. + +[[batch-reindex-queue-request]] +==== Request + +`GET /api/upgrade_assistant/reindex/batch/queue` + +[[batch-reindex-queue-request-codes]] +==== Response code + +`200`:: + Indicates a successful call. + +[[batch-reindex-queue-example]] +==== Example + +The API returns the following: + +[source,js] +-------------------------------------------------- +{ + "queue": [ <1> + { + "indexName": "index1", + "newIndexName": "reindexed-v8-index2", + "status": 3, + "lastCompletedStep": 0, + "locked": null, + "reindexTaskId": null, + "reindexTaskPercComplete": null, + "errorMessage": null, + "runningReindexCount": null, + "reindexOptions": { + "queueSettings": { + "queuedAt": 1583406985489 + } + } + }, + { + "indexName": "index2", + "newIndexName": "reindexed-v8-index2", + "status": 3, + "lastCompletedStep": 0, + "locked": null, + "reindexTaskId": null, + "reindexTaskPercComplete": null, + "errorMessage": null, + "runningReindexCount": null, + "reindexOptions": { + "queueSettings": { + "queuedAt": 1583406987334 + } + } + } + ] +} +-------------------------------------------------- + +<1> Items in this array indicate reindex tasks at a given point in time and the order in which they will be executed. + diff --git a/docs/api/upgrade-assistant/batch_reindexing.asciidoc b/docs/api/upgrade-assistant/batch_reindexing.asciidoc new file mode 100644 index 0000000000000..40b6d9c816d5c --- /dev/null +++ b/docs/api/upgrade-assistant/batch_reindexing.asciidoc @@ -0,0 +1,81 @@ +[[batch-start-resume-reindex]] +=== Batch start or resume reindex API +++++ +Batch start or resume reindex +++++ + +experimental["The underlying Upgrade Assistant concepts are stable, but the APIs for managing Upgrade Assistant are experimental."] + +Start or resume multiple reindexing tasks in one request. Additionally, reindexing tasks started or resumed +via the batch endpoint will be placed on a queue and executed one-by-one, which ensures that minimal cluster resources +are consumed over time. + +[[batch-start-resume-reindex-request]] +==== Request + +`POST /api/upgrade_assistant/reindex/batch` + +[[batch-start-resume-reindex-request-body]] +==== Request body + +`indexNames`:: + (Required, array) The list of index names to be reindexed. + +[[batch-start-resume-reindex-codes]] +==== Response code + +`200`:: + Indicates a successful call. + +[[batch-start-resume-example]] +==== Example + +[source,js] +-------------------------------------------------- +POST /api/upgrade_assistant/reindex/batch +{ + "indexNames": [ <1> + "index1", + "index2" + ] +} +-------------------------------------------------- +<1> The order in which the indices are provided here determines the order in which the reindex tasks will be executed. + +Similar to the <>, the API returns the following: + +[source,js] +-------------------------------------------------- +{ + "enqueued": [ <1> + { + "indexName": "index1", + "newIndexName": "reindexed-v8-index1", + "status": 3, + "lastCompletedStep": 0, + "locked": null, + "reindexTaskId": null, + "reindexTaskPercComplete": null, + "errorMessage": null, + "runningReindexCount": null, + "reindexOptions": { <2> + "queueSettings": { + "queuedAt": 1583406985489 <3> + } + } + } + ], + "errors": [ <4> + { + "indexName": "index2", + "message": "Something went wrong!" + } + ] +} +-------------------------------------------------- + +<1> A list of reindex operations created, the order in the array indicates the order in which tasks will be executed. +<2> Presence of this key indicates that the reindex job will occur in the batch. +<3> A Unix timestamp of when the reindex task was placed in the queue. +<4> A list of errors that may have occurred preventing the reindex task from being created. + diff --git a/docs/api/upgrade-assistant/cancel_reindex.asciidoc b/docs/api/upgrade-assistant/cancel_reindex.asciidoc index 8951f235c9265..d31894cd06a05 100644 --- a/docs/api/upgrade-assistant/cancel_reindex.asciidoc +++ b/docs/api/upgrade-assistant/cancel_reindex.asciidoc @@ -4,10 +4,10 @@ Cancel reindex ++++ -Cancel reindexes that are waiting for the Elasticsearch reindex task to complete. For example, `lastCompletedStep` set to `40`. - experimental["The underlying Upgrade Assistant concepts are stable, but the APIs for managing Upgrade Assistant are experimental."] +Cancel reindexes that are waiting for the Elasticsearch reindex task to complete. For example, `lastCompletedStep` set to `40`. + [[cancel-reindex-request]] ==== Request diff --git a/docs/api/upgrade-assistant/check_reindex_status.asciidoc b/docs/api/upgrade-assistant/check_reindex_status.asciidoc index cb4664baf96b2..c422e5764c69f 100644 --- a/docs/api/upgrade-assistant/check_reindex_status.asciidoc +++ b/docs/api/upgrade-assistant/check_reindex_status.asciidoc @@ -4,10 +4,10 @@ Check reindex status ++++ -Check the status of the reindex operation. - experimental["The underlying Upgrade Assistant concepts are stable, but the APIs for managing Upgrade Assistant are experimental."] +Check the status of the reindex operation. + [[check-reindex-status-request]] ==== Request diff --git a/docs/api/upgrade-assistant/reindexing.asciidoc b/docs/api/upgrade-assistant/reindexing.asciidoc index a6d5d9d0c16ac..51e7b917b67ac 100644 --- a/docs/api/upgrade-assistant/reindexing.asciidoc +++ b/docs/api/upgrade-assistant/reindexing.asciidoc @@ -4,10 +4,10 @@ Start or resume reindex ++++ -Start a new reindex or resume a paused reindex. - experimental["The underlying Upgrade Assistant concepts are stable, but the APIs for managing Upgrade Assistant are experimental."] +Start a new reindex or resume a paused reindex. + [[start-resume-reindex-request]] ==== Request diff --git a/docs/api/upgrade-assistant/status.asciidoc b/docs/api/upgrade-assistant/status.asciidoc index 9ad77bcabff73..b087a66fa3bcd 100644 --- a/docs/api/upgrade-assistant/status.asciidoc +++ b/docs/api/upgrade-assistant/status.asciidoc @@ -4,10 +4,10 @@ Upgrade readiness status ++++ -Check the status of your cluster. - experimental["The underlying Upgrade Assistant concepts are stable, but the APIs for managing Upgrade Assistant are experimental."] +Check the status of your cluster. + [[upgrade-assistant-api-status-request]] ==== Request diff --git a/docs/development/core/public/kibana-plugin-core-public.errortoastoptions.md b/docs/development/core/public/kibana-plugin-core-public.errortoastoptions.md index cda64018c3f69..dc256e6f5bc06 100644 --- a/docs/development/core/public/kibana-plugin-core-public.errortoastoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.errortoastoptions.md @@ -4,12 +4,12 @@ ## ErrorToastOptions interface -Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) APIs. +Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) error APIs. Signature: ```typescript -export interface ErrorToastOptions +export interface ErrorToastOptions extends ToastOptions ``` ## Properties diff --git a/docs/development/core/public/kibana-plugin-core-public.itoasts.md b/docs/development/core/public/kibana-plugin-core-public.itoasts.md index 305ed82ea5693..e009c77fe23bc 100644 --- a/docs/development/core/public/kibana-plugin-core-public.itoasts.md +++ b/docs/development/core/public/kibana-plugin-core-public.itoasts.md @@ -9,5 +9,5 @@ Methods for adding and removing global toast messages. See [ToastsApi](./kibana- Signature: ```typescript -export declare type IToasts = Pick; +export declare type IToasts = Pick; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index a9fbaa25ea150..b8aa56eb2941b 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -57,7 +57,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [CoreStart](./kibana-plugin-core-public.corestart.md) | Core services exposed to the Plugin start lifecycle | | [DocLinksStart](./kibana-plugin-core-public.doclinksstart.md) | | | [EnvironmentMode](./kibana-plugin-core-public.environmentmode.md) | | -| [ErrorToastOptions](./kibana-plugin-core-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) APIs. | +| [ErrorToastOptions](./kibana-plugin-core-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) error APIs. | | [FatalErrorInfo](./kibana-plugin-core-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | | [FatalErrorsSetup](./kibana-plugin-core-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | | [HttpFetchOptions](./kibana-plugin-core-public.httpfetchoptions.md) | All options that may be used with a [HttpHandler](./kibana-plugin-core-public.httphandler.md). | @@ -115,6 +115,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsUpdateOptions](./kibana-plugin-core-public.savedobjectsupdateoptions.md) | | | [StringValidationRegex](./kibana-plugin-core-public.stringvalidationregex.md) | StringValidation with regex object | | [StringValidationRegexString](./kibana-plugin-core-public.stringvalidationregexstring.md) | StringValidation as regex string | +| [ToastOptions](./kibana-plugin-core-public.toastoptions.md) | Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) APIs. | | [UiSettingsParams](./kibana-plugin-core-public.uisettingsparams.md) | UiSettings parameters defined by the plugins. | | [UiSettingsState](./kibana-plugin-core-public.uisettingsstate.md) | | | [UserProvidedValues](./kibana-plugin-core-public.userprovidedvalues.md) | Describes the values explicitly set by user. | diff --git a/docs/development/core/public/kibana-plugin-core-public.toastoptions.md b/docs/development/core/public/kibana-plugin-core-public.toastoptions.md new file mode 100644 index 0000000000000..0d85c482c2288 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.toastoptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ToastOptions](./kibana-plugin-core-public.toastoptions.md) + +## ToastOptions interface + +Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) APIs. + +Signature: + +```typescript +export interface ToastOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [toastLifeTimeMs](./kibana-plugin-core-public.toastoptions.toastlifetimems.md) | number | How long should the toast remain on screen. | + diff --git a/docs/development/core/public/kibana-plugin-core-public.toastoptions.toastlifetimems.md b/docs/development/core/public/kibana-plugin-core-public.toastoptions.toastlifetimems.md new file mode 100644 index 0000000000000..bb0e2f9afc83b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.toastoptions.toastlifetimems.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ToastOptions](./kibana-plugin-core-public.toastoptions.md) > [toastLifeTimeMs](./kibana-plugin-core-public.toastoptions.toastlifetimems.md) + +## ToastOptions.toastLifeTimeMs property + +How long should the toast remain on screen. + +Signature: + +```typescript +toastLifeTimeMs?: number; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.toastsapi.adddanger.md b/docs/development/core/public/kibana-plugin-core-public.toastsapi.adddanger.md index e8cc9ff74e0c4..420100a1209ab 100644 --- a/docs/development/core/public/kibana-plugin-core-public.toastsapi.adddanger.md +++ b/docs/development/core/public/kibana-plugin-core-public.toastsapi.adddanger.md @@ -9,7 +9,7 @@ Adds a new toast pre-configured with the danger color and alert icon. Signature: ```typescript -addDanger(toastOrTitle: ToastInput): Toast; +addDanger(toastOrTitle: ToastInput, options?: ToastOptions): Toast; ``` ## Parameters @@ -17,6 +17,7 @@ addDanger(toastOrTitle: ToastInput): Toast; | Parameter | Type | Description | | --- | --- | --- | | toastOrTitle | ToastInput | a [ToastInput](./kibana-plugin-core-public.toastinput.md) | +| options | ToastOptions | a [ToastOptions](./kibana-plugin-core-public.toastoptions.md) | Returns: diff --git a/docs/development/core/public/kibana-plugin-core-public.toastsapi.addinfo.md b/docs/development/core/public/kibana-plugin-core-public.toastsapi.addinfo.md new file mode 100644 index 0000000000000..76508d26b4ae9 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.toastsapi.addinfo.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ToastsApi](./kibana-plugin-core-public.toastsapi.md) > [addInfo](./kibana-plugin-core-public.toastsapi.addinfo.md) + +## ToastsApi.addInfo() method + +Adds a new toast pre-configured with the info color and info icon. + +Signature: + +```typescript +addInfo(toastOrTitle: ToastInput, options?: ToastOptions): Toast; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| toastOrTitle | ToastInput | a [ToastInput](./kibana-plugin-core-public.toastinput.md) | +| options | ToastOptions | a [ToastOptions](./kibana-plugin-core-public.toastoptions.md) | + +Returns: + +`Toast` + +a [Toast](./kibana-plugin-core-public.toast.md) + diff --git a/docs/development/core/public/kibana-plugin-core-public.toastsapi.addsuccess.md b/docs/development/core/public/kibana-plugin-core-public.toastsapi.addsuccess.md index 160cbd4bf6b29..c79f48042514a 100644 --- a/docs/development/core/public/kibana-plugin-core-public.toastsapi.addsuccess.md +++ b/docs/development/core/public/kibana-plugin-core-public.toastsapi.addsuccess.md @@ -9,7 +9,7 @@ Adds a new toast pre-configured with the success color and check icon. Signature: ```typescript -addSuccess(toastOrTitle: ToastInput): Toast; +addSuccess(toastOrTitle: ToastInput, options?: ToastOptions): Toast; ``` ## Parameters @@ -17,6 +17,7 @@ addSuccess(toastOrTitle: ToastInput): Toast; | Parameter | Type | Description | | --- | --- | --- | | toastOrTitle | ToastInput | a [ToastInput](./kibana-plugin-core-public.toastinput.md) | +| options | ToastOptions | a [ToastOptions](./kibana-plugin-core-public.toastoptions.md) | Returns: diff --git a/docs/development/core/public/kibana-plugin-core-public.toastsapi.addwarning.md b/docs/development/core/public/kibana-plugin-core-public.toastsapi.addwarning.md index 17f94cc5b4553..6154af148332d 100644 --- a/docs/development/core/public/kibana-plugin-core-public.toastsapi.addwarning.md +++ b/docs/development/core/public/kibana-plugin-core-public.toastsapi.addwarning.md @@ -9,7 +9,7 @@ Adds a new toast pre-configured with the warning color and help icon. Signature: ```typescript -addWarning(toastOrTitle: ToastInput): Toast; +addWarning(toastOrTitle: ToastInput, options?: ToastOptions): Toast; ``` ## Parameters @@ -17,6 +17,7 @@ addWarning(toastOrTitle: ToastInput): Toast; | Parameter | Type | Description | | --- | --- | --- | | toastOrTitle | ToastInput | a [ToastInput](./kibana-plugin-core-public.toastinput.md) | +| options | ToastOptions | a [ToastOptions](./kibana-plugin-core-public.toastoptions.md) | Returns: diff --git a/docs/development/core/public/kibana-plugin-core-public.toastsapi.md b/docs/development/core/public/kibana-plugin-core-public.toastsapi.md index 4aa240fba0061..ca4c08989128a 100644 --- a/docs/development/core/public/kibana-plugin-core-public.toastsapi.md +++ b/docs/development/core/public/kibana-plugin-core-public.toastsapi.md @@ -23,10 +23,11 @@ export declare class ToastsApi implements IToasts | Method | Modifiers | Description | | --- | --- | --- | | [add(toastOrTitle)](./kibana-plugin-core-public.toastsapi.add.md) | | Adds a new toast to current array of toast. | -| [addDanger(toastOrTitle)](./kibana-plugin-core-public.toastsapi.adddanger.md) | | Adds a new toast pre-configured with the danger color and alert icon. | +| [addDanger(toastOrTitle, options)](./kibana-plugin-core-public.toastsapi.adddanger.md) | | Adds a new toast pre-configured with the danger color and alert icon. | | [addError(error, options)](./kibana-plugin-core-public.toastsapi.adderror.md) | | Adds a new toast that displays an exception message with a button to open the full stacktrace in a modal. | -| [addSuccess(toastOrTitle)](./kibana-plugin-core-public.toastsapi.addsuccess.md) | | Adds a new toast pre-configured with the success color and check icon. | -| [addWarning(toastOrTitle)](./kibana-plugin-core-public.toastsapi.addwarning.md) | | Adds a new toast pre-configured with the warning color and help icon. | +| [addInfo(toastOrTitle, options)](./kibana-plugin-core-public.toastsapi.addinfo.md) | | Adds a new toast pre-configured with the info color and info icon. | +| [addSuccess(toastOrTitle, options)](./kibana-plugin-core-public.toastsapi.addsuccess.md) | | Adds a new toast pre-configured with the success color and check icon. | +| [addWarning(toastOrTitle, options)](./kibana-plugin-core-public.toastsapi.addwarning.md) | | Adds a new toast pre-configured with the warning color and help icon. | | [get$()](./kibana-plugin-core-public.toastsapi.get_.md) | | Observable of the toast messages to show to the user. | | [remove(toastOrId)](./kibana-plugin-core-public.toastsapi.remove.md) | | Removes a toast from the current array of toasts if present. | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md index e03072f9a41c3..7fd65e5db35f3 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md @@ -44,8 +44,8 @@ esFilters: { getPhraseFilterField: (filter: import("../common").PhraseFilter) => string; getPhraseFilterValue: (filter: import("../common").PhraseFilter) => string | number | boolean; getDisplayValueFromFilter: typeof getDisplayValueFromFilter; - compareFilters: (first: import("../common").Filter | import("../common").Filter[], second: import("../common").Filter | import("../common").Filter[], comparatorOptions?: import("./query/filter_manager/lib/compare_filters").FilterCompareOptions) => boolean; - COMPARE_ALL_OPTIONS: import("./query/filter_manager/lib/compare_filters").FilterCompareOptions; + compareFilters: (first: import("../common").Filter | import("../common").Filter[], second: import("../common").Filter | import("../common").Filter[], comparatorOptions?: import("../common").FilterCompareOptions) => boolean; + COMPARE_ALL_OPTIONS: import("../common").FilterCompareOptions; generateFilters: typeof generateFilters; onlyDisabledFiltersChanged: (newFilters?: import("../common").Filter[] | undefined, oldFilters?: import("../common").Filter[] | undefined) => boolean; changeTimeFilter: typeof changeTimeFilter; diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md index e756eb9b72905..d179b9d9dcd82 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md @@ -60,7 +60,6 @@ | [esQuery](./kibana-plugin-plugins-data-server.esquery.md) | | | [fieldFormats](./kibana-plugin-plugins-data-server.fieldformats.md) | | | [indexPatterns](./kibana-plugin-plugins-data-server.indexpatterns.md) | | -| [search](./kibana-plugin-plugins-data-server.search.md) | | ## Type Aliases @@ -70,6 +69,5 @@ | [IFieldFormatsRegistry](./kibana-plugin-plugins-data-server.ifieldformatsregistry.md) | | | [ISearch](./kibana-plugin-plugins-data-server.isearch.md) | | | [ISearchCancel](./kibana-plugin-plugins-data-server.isearchcancel.md) | | -| [ParsedInterval](./kibana-plugin-plugins-data-server.parsedinterval.md) | | | [TSearchStrategyProvider](./kibana-plugin-plugins-data-server.tsearchstrategyprovider.md) | Search strategy provider creates an instance of a search strategy with the request handler context bound to it. This way every search strategy can use whatever information they require from the request context. | diff --git a/examples/alerting_example/README.md b/examples/alerting_example/README.md new file mode 100644 index 0000000000000..bf963c64586d3 --- /dev/null +++ b/examples/alerting_example/README.md @@ -0,0 +1,5 @@ +## Alerting Example + +This example plugin shows you how to create a custom Alert Type, create alerts based on that type and corresponding UI for viewing the details of all the alerts within the custom plugin. + +To run this example, use the command `yarn start --run-examples`. \ No newline at end of file diff --git a/examples/alerting_example/common/constants.ts b/examples/alerting_example/common/constants.ts new file mode 100644 index 0000000000000..5884eb3220519 --- /dev/null +++ b/examples/alerting_example/common/constants.ts @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const ALERTING_EXAMPLE_APP_ID = 'AlertingExample'; + +// always firing +export const DEFAULT_INSTANCES_TO_GENERATE = 5; + +// Astros +export enum Craft { + OuterSpace = 'Outer Space', + ISS = 'ISS', +} +export enum Operator { + AreAbove = 'Are above', + AreBelow = 'Are below', + AreExactly = 'Are exactly', +} diff --git a/examples/alerting_example/kibana.json b/examples/alerting_example/kibana.json new file mode 100644 index 0000000000000..bcdb7c2f14a9c --- /dev/null +++ b/examples/alerting_example/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "alertingExample", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["alerting_example"], + "server": true, + "ui": true, + "requiredPlugins": ["triggers_actions_ui", "charts", "data", "alerting", "actions"], + "optionalPlugins": [] +} diff --git a/examples/alerting_example/package.json b/examples/alerting_example/package.json new file mode 100644 index 0000000000000..96187d847c1c4 --- /dev/null +++ b/examples/alerting_example/package.json @@ -0,0 +1,17 @@ +{ + "name": "alerting_example", + "version": "1.0.0", + "main": "target/examples/alerting_example", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.7.2" + } +} diff --git a/examples/alerting_example/public/alert_types/always_firing.tsx b/examples/alerting_example/public/alert_types/always_firing.tsx new file mode 100644 index 0000000000000..a62a24365ea3f --- /dev/null +++ b/examples/alerting_example/public/alert_types/always_firing.tsx @@ -0,0 +1,82 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { Fragment } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiFieldNumber, EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { AlertTypeModel } from '../../../../x-pack/plugins/triggers_actions_ui/public'; +import { DEFAULT_INSTANCES_TO_GENERATE } from '../../common/constants'; + +interface AlwaysFiringParamsProps { + alertParams: { instances?: number }; + setAlertParams: (property: string, value: any) => void; + errors: { [key: string]: string[] }; +} + +export function getAlertType(): AlertTypeModel { + return { + id: 'example.always-firing', + name: 'Always Fires', + iconClass: 'bolt', + alertParamsExpression: AlwaysFiringExpression, + validate: (alertParams: AlwaysFiringParamsProps['alertParams']) => { + const { instances } = alertParams; + const validationResult = { + errors: { + instances: new Array(), + }, + }; + if (instances && instances < 0) { + validationResult.errors.instances.push( + i18n.translate('AlertingExample.addAlert.error.invalidRandomInstances', { + defaultMessage: 'instances must be equal or greater than zero.', + }) + ); + } + return validationResult; + }, + }; +} + +export const AlwaysFiringExpression: React.FunctionComponent = ({ + alertParams, + setAlertParams, +}) => { + const { instances = DEFAULT_INSTANCES_TO_GENERATE } = alertParams; + return ( + + + + + { + setAlertParams('instances', event.target.valueAsNumber); + }} + /> + + + + + ); +}; diff --git a/examples/alerting_example/public/alert_types/astros.tsx b/examples/alerting_example/public/alert_types/astros.tsx new file mode 100644 index 0000000000000..9bda7da6f140d --- /dev/null +++ b/examples/alerting_example/public/alert_types/astros.tsx @@ -0,0 +1,277 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState, useEffect, Fragment } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiFieldNumber, + EuiPopoverTitle, + EuiSelect, + EuiCallOut, + EuiExpression, + EuiTextColor, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { flatten } from 'lodash'; +import { ALERTING_EXAMPLE_APP_ID, Craft, Operator } from '../../common/constants'; +import { SanitizedAlert } from '../../../../x-pack/plugins/alerting/common'; +import { PluginSetupContract as AlertingSetup } from '../../../../x-pack/plugins/alerting/public'; +import { AlertTypeModel } from '../../../../x-pack/plugins/triggers_actions_ui/public'; + +export function registerNavigation(alerting: AlertingSetup) { + alerting.registerNavigation( + ALERTING_EXAMPLE_APP_ID, + 'example.people-in-space', + (alert: SanitizedAlert) => `/astros/${alert.id}` + ); +} + +interface PeopleinSpaceParamsProps { + alertParams: { outerSpaceCapacity?: number; craft?: string; op?: string }; + setAlertParams: (property: string, value: any) => void; + errors: { [key: string]: string[] }; +} + +function isValueInEnum(enumeratin: Record, value: any): boolean { + return !!Object.values(enumeratin).find(enumVal => enumVal === value); +} + +export function getAlertType(): AlertTypeModel { + return { + id: 'example.people-in-space', + name: 'People Are In Space Right Now', + iconClass: 'globe', + alertParamsExpression: PeopleinSpaceExpression, + validate: (alertParams: PeopleinSpaceParamsProps['alertParams']) => { + const { outerSpaceCapacity, craft, op } = alertParams; + + const validationResult = { + errors: { + outerSpaceCapacity: new Array(), + craft: new Array(), + }, + }; + if (!isValueInEnum(Craft, craft)) { + validationResult.errors.craft.push( + i18n.translate('AlertingExample.addAlert.error.invalidCraft', { + defaultMessage: 'You must choose one of the following Craft: {crafts}', + values: { + crafts: Object.values(Craft).join(', '), + }, + }) + ); + } + if (!(typeof outerSpaceCapacity === 'number' && outerSpaceCapacity >= 0)) { + validationResult.errors.outerSpaceCapacity.push( + i18n.translate('AlertingExample.addAlert.error.invalidOuterSpaceCapacity', { + defaultMessage: 'outerSpaceCapacity must be a number greater than or equal to zero.', + }) + ); + } + if (!isValueInEnum(Operator, op)) { + validationResult.errors.outerSpaceCapacity.push( + i18n.translate('AlertingExample.addAlert.error.invalidCraft', { + defaultMessage: 'You must choose one of the following Operator: {crafts}', + values: { + crafts: Object.values(Operator).join(', '), + }, + }) + ); + } + + return validationResult; + }, + }; +} + +export const PeopleinSpaceExpression: React.FunctionComponent = ({ + alertParams, + setAlertParams, + errors, +}) => { + const { outerSpaceCapacity = 0, craft = Craft.OuterSpace, op = Operator.AreAbove } = alertParams; + + // store defaults + useEffect(() => { + if (outerSpaceCapacity !== alertParams.outerSpaceCapacity) { + setAlertParams('outerSpaceCapacity', outerSpaceCapacity); + } + if (craft !== alertParams.craft) { + setAlertParams('craft', craft); + } + if (op !== alertParams.op) { + setAlertParams('op', op); + } + }, [alertParams, craft, op, outerSpaceCapacity, setAlertParams]); + + const [craftTrigger, setCraftTrigger] = useState<{ craft: string; isOpen: boolean }>({ + craft, + isOpen: false, + }); + const [outerSpaceCapacityTrigger, setOuterSpaceCapacity] = useState<{ + outerSpaceCapacity: number; + op: string; + isOpen: boolean; + }>({ + outerSpaceCapacity, + op, + isOpen: false, + }); + + const errorsCallout = flatten( + Object.entries(errors).map(([field, errs]: [string, string[]]) => + errs.map(e => ( +

+ {field}:`: ${errs}` +

+ )) + ) + ); + + return ( + + {errorsCallout.length ? ( + + {errorsCallout} + + ) : ( + + )} + + + { + setCraftTrigger({ + ...craftTrigger, + isOpen: true, + }); + }} + /> + } + isOpen={craftTrigger.isOpen} + closePopover={() => { + setCraftTrigger({ + ...craftTrigger, + isOpen: false, + }); + }} + ownFocus + panelPaddingSize="s" + anchorPosition="downLeft" + > +
+ When the People in + { + setAlertParams('craft', event.target.value); + setCraftTrigger({ + craft: event.target.value, + isOpen: false, + }); + }} + options={[ + { value: Craft.OuterSpace, text: 'Outer Space' }, + { value: Craft.ISS, text: 'the International Space Station' }, + ]} + /> +
+
+
+ + + { + setOuterSpaceCapacity({ + ...outerSpaceCapacityTrigger, + isOpen: true, + }); + }} + /> + } + isOpen={outerSpaceCapacityTrigger.isOpen} + closePopover={() => { + setOuterSpaceCapacity({ + ...outerSpaceCapacityTrigger, + isOpen: false, + }); + }} + ownFocus + panelPaddingSize="s" + anchorPosition="downLeft" + > +
+ + + { + setAlertParams('op', event.target.value); + setOuterSpaceCapacity({ + ...outerSpaceCapacityTrigger, + op: event.target.value, + isOpen: false, + }); + }} + options={[ + { value: Operator.AreAbove, text: 'Are above' }, + { value: Operator.AreBelow, text: 'Are below' }, + { value: Operator.AreExactly, text: 'Are exactly' }, + ]} + /> + + + + { + setAlertParams('outerSpaceCapacity', event.target.valueAsNumber); + setOuterSpaceCapacity({ + ...outerSpaceCapacityTrigger, + outerSpaceCapacity: event.target.valueAsNumber, + isOpen: false, + }); + }} + /> + + +
+
+
+
+
+ ); +}; diff --git a/src/plugins/console/server/lib/spec_definitions/index.d.ts b/examples/alerting_example/public/alert_types/index.ts similarity index 57% rename from src/plugins/console/server/lib/spec_definitions/index.d.ts rename to examples/alerting_example/public/alert_types/index.ts index da0125a186c15..96d9c09d15836 100644 --- a/src/plugins/console/server/lib/spec_definitions/index.d.ts +++ b/examples/alerting_example/public/alert_types/index.ts @@ -17,15 +17,17 @@ * under the License. */ -export declare function addProcessorDefinition(...args: any[]): any; +import { registerNavigation as registerPeopleInSpaceNavigation } from './astros'; +import { ALERTING_EXAMPLE_APP_ID } from '../../common/constants'; +import { SanitizedAlert } from '../../../../x-pack/plugins/alerting/common'; +import { PluginSetupContract as AlertingSetup } from '../../../../x-pack/plugins/alerting/public'; -export declare function resolveApi(): object; +export function registerNavigation(alerting: AlertingSetup) { + // register default navigation + alerting.registerDefaultNavigation( + ALERTING_EXAMPLE_APP_ID, + (alert: SanitizedAlert) => `/alert/${alert.id}` + ); -export declare function addExtensionSpecFilePath(...args: any[]): any; - -/** - * A function that synchronously reads files JSON from disk and builds - * the autocomplete structures served to the client. This must be called - * after any extensions have been loaded. - */ -export declare function loadSpec(): any; + registerPeopleInSpaceNavigation(alerting); +} diff --git a/examples/alerting_example/public/application.tsx b/examples/alerting_example/public/application.tsx new file mode 100644 index 0000000000000..d71db92d3d421 --- /dev/null +++ b/examples/alerting_example/public/application.tsx @@ -0,0 +1,108 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { BrowserRouter as Router, Route, RouteComponentProps } from 'react-router-dom'; +import { EuiPage } from '@elastic/eui'; +import { + AppMountParameters, + CoreStart, + IUiSettingsClient, + ToastsSetup, +} from '../../../src/core/public'; +import { DataPublicPluginStart } from '../../../src/plugins/data/public'; +import { ChartsPluginStart } from '../../../src/plugins/charts/public'; + +import { Page } from './components/page'; +import { DocumentationPage } from './components/documentation'; +import { ViewAlertPage } from './components/view_alert'; +import { TriggersAndActionsUIPublicPluginStart } from '../../../x-pack/plugins/triggers_actions_ui/public'; +import { AlertingExamplePublicStartDeps } from './plugin'; +import { ViewPeopleInSpaceAlertPage } from './components/view_astros_alert'; + +export interface AlertingExampleComponentParams { + application: CoreStart['application']; + http: CoreStart['http']; + basename: string; + triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; + data: DataPublicPluginStart; + charts: ChartsPluginStart; + uiSettings: IUiSettingsClient; + toastNotifications: ToastsSetup; +} + +const AlertingExampleApp = (deps: AlertingExampleComponentParams) => { + const { basename, http } = deps; + return ( + + + ( + + + + )} + /> + ) => { + return ( + + + + ); + }} + /> + ) => { + return ( + + + + ); + }} + /> + + + ); +}; + +export const renderApp = ( + { application, notifications, http, uiSettings }: CoreStart, + deps: AlertingExamplePublicStartDeps, + { appBasePath, element }: AppMountParameters +) => { + ReactDOM.render( + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/examples/alerting_example/public/components/create_alert.tsx b/examples/alerting_example/public/components/create_alert.tsx new file mode 100644 index 0000000000000..65b8a9412dcda --- /dev/null +++ b/examples/alerting_example/public/components/create_alert.tsx @@ -0,0 +1,72 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState } from 'react'; + +import { EuiIcon, EuiFlexItem, EuiCard, EuiFlexGroup } from '@elastic/eui'; + +import { + AlertsContextProvider, + AlertAdd, +} from '../../../../x-pack/plugins/triggers_actions_ui/public'; +import { AlertingExampleComponentParams } from '../application'; +import { ALERTING_EXAMPLE_APP_ID } from '../../common/constants'; + +export const CreateAlert = ({ + http, + triggers_actions_ui, + charts, + uiSettings, + data, + toastNotifications, +}: AlertingExampleComponentParams) => { + const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false); + + return ( + + + } + title={`Create Alert`} + description="Create an new Alert based on one of our example Alert Types ." + onClick={() => setAlertFlyoutVisibility(true)} + /> + + + + + + + + ); +}; diff --git a/examples/alerting_example/public/components/documentation.tsx b/examples/alerting_example/public/components/documentation.tsx new file mode 100644 index 0000000000000..17cc34959b010 --- /dev/null +++ b/examples/alerting_example/public/components/documentation.tsx @@ -0,0 +1,67 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; + +import { + EuiText, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, + EuiSpacer, +} from '@elastic/eui'; +import { CreateAlert } from './create_alert'; +import { AlertingExampleComponentParams } from '../application'; + +export const DocumentationPage = (deps: AlertingExampleComponentParams) => ( + + + + +

Welcome to the Alerting plugin example

+
+
+
+ + + + +

Documentation links

+
+
+
+ + +

Plugin Structure

+

+ This example solution has both `server` and a `public` plugins. The `server` handles + registration of example the AlertTypes, while the `public` handles creation of, and + navigation for, these alert types. +

+
+ + +
+
+
+); diff --git a/examples/alerting_example/public/components/page.tsx b/examples/alerting_example/public/components/page.tsx new file mode 100644 index 0000000000000..99076c7ddcedf --- /dev/null +++ b/examples/alerting_example/public/components/page.tsx @@ -0,0 +1,74 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; + +import { + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, + EuiBreadcrumbs, + EuiSpacer, +} from '@elastic/eui'; + +type PageProps = RouteComponentProps & { + title: string; + children: React.ReactNode; + crumb?: string; + isHome?: boolean; +}; + +export const Page = withRouter(({ title, crumb, children, isHome = false, history }: PageProps) => { + const breadcrumbs: Array<{ + text: string; + onClick?: () => void; + }> = [ + { + text: crumb ?? title, + }, + ]; + if (!isHome) { + breadcrumbs.splice(0, 0, { + text: 'Home', + onClick: () => { + history.push(`/`); + }, + }); + } + return ( + + + + +

{title}

+
+
+
+ + + + {children} + +
+ ); +}); diff --git a/examples/alerting_example/public/components/view_alert.tsx b/examples/alerting_example/public/components/view_alert.tsx new file mode 100644 index 0000000000000..c1b65eb92edc5 --- /dev/null +++ b/examples/alerting_example/public/components/view_alert.tsx @@ -0,0 +1,116 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useState, useEffect, Fragment } from 'react'; + +import { + EuiText, + EuiLoadingKibana, + EuiCallOut, + EuiTextColor, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiCodeBlock, + EuiSpacer, +} from '@elastic/eui'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { CoreStart } from 'kibana/public'; +import { isEmpty } from 'lodash'; +import { Alert, AlertTaskState } from '../../../../x-pack/plugins/alerting/common'; +import { ALERTING_EXAMPLE_APP_ID } from '../../common/constants'; + +type Props = RouteComponentProps & { + http: CoreStart['http']; + id: string; +}; +export const ViewAlertPage = withRouter(({ http, id }: Props) => { + const [alert, setAlert] = useState(null); + const [alertState, setAlertState] = useState(null); + + useEffect(() => { + if (!alert) { + http.get(`/api/alert/${id}`).then(setAlert); + } + if (!alertState) { + http.get(`/api/alert/${id}/state`).then(setAlertState); + } + }, [alert, alertState, http, id]); + + return alert && alertState ? ( + + +

+ This is a generic view for all Alerts created by the + {ALERTING_EXAMPLE_APP_ID} + plugin. +

+

+ You are now viewing the {`${alert.name}`} + Alert, whose ID is {`${alert.id}`}. +

+

+ Its AlertType is {`${alert.alertTypeId}`} and + its scheduled to run at an interval of + {`${alert.schedule.interval}`}. +

+
+ + +

Alert Instances

+
+ {isEmpty(alertState.alertInstances) ? ( + +

This Alert doesn't have any active alert instances at the moment.

+
+ ) : ( + + +

+ Bellow are the active Alert Instances which were activated on the alerts last run. +
+ For each instance id you can see its current state in JSON format. +

+
+ + + {Object.entries(alertState.alertInstances ?? {}).map(([instance, { state }]) => ( + + {instance} + + + {`${JSON.stringify(state)}`} + + + + ))} + +
+ )} +
+ ) : ( + + ); +}); diff --git a/examples/alerting_example/public/components/view_astros_alert.tsx b/examples/alerting_example/public/components/view_astros_alert.tsx new file mode 100644 index 0000000000000..db93d8f54924d --- /dev/null +++ b/examples/alerting_example/public/components/view_astros_alert.tsx @@ -0,0 +1,123 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useState, useEffect, Fragment } from 'react'; + +import { + EuiText, + EuiLoadingKibana, + EuiCallOut, + EuiTextColor, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiStat, +} from '@elastic/eui'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { CoreStart } from 'kibana/public'; +import { isEmpty } from 'lodash'; +import { Alert, AlertTaskState } from '../../../../x-pack/plugins/alerting/common'; +import { ALERTING_EXAMPLE_APP_ID } from '../../common/constants'; + +type Props = RouteComponentProps & { + http: CoreStart['http']; + id: string; +}; + +function hasCraft(state: any): state is { craft: string } { + return state && state.craft; +} +export const ViewPeopleInSpaceAlertPage = withRouter(({ http, id }: Props) => { + const [alert, setAlert] = useState(null); + const [alertState, setAlertState] = useState(null); + + useEffect(() => { + if (!alert) { + http.get(`/api/alert/${id}`).then(setAlert); + } + if (!alertState) { + http.get(`/api/alert/${id}/state`).then(setAlertState); + } + }, [alert, alertState, http, id]); + + return alert && alertState ? ( + + +

+ This is a specific view for all + example.people-in-space Alerts created by + the + {ALERTING_EXAMPLE_APP_ID} + plugin. +

+
+ + +

Alert Instances

+
+ {isEmpty(alertState.alertInstances) ? ( + +

+ The people in {alert.params.craft} at the moment are not {alert.params.op}{' '} + {alert.params.outerSpaceCapacity} +

+
+ ) : ( + + +

+ The alert has been triggered because the people in {alert.params.craft} at the moment{' '} + {alert.params.op} {alert.params.outerSpaceCapacity} +

+
+ +
+ + + + + + + {Object.entries(alertState.alertInstances ?? {}).map( + ([instance, { state }], index) => ( + + {instance} + + {hasCraft(state) ? state.craft : 'Unknown Craft'} + + + ) + )} + + + +
+
+ )} +
+ ) : ( + + ); +}); diff --git a/src/plugins/console/server/lib/spec_definitions/index.js b/examples/alerting_example/public/index.ts similarity index 81% rename from src/plugins/console/server/lib/spec_definitions/index.js rename to examples/alerting_example/public/index.ts index abf55639fbee8..4a2bfc79903c3 100644 --- a/src/plugins/console/server/lib/spec_definitions/index.js +++ b/examples/alerting_example/public/index.ts @@ -17,10 +17,6 @@ * under the License. */ -export { addProcessorDefinition } from './js/ingest'; +import { AlertingExamplePlugin } from './plugin'; -export { addExtensionSpecFilePath } from './json'; - -export { loadSpec } from './es'; - -export { resolveApi } from './server'; +export const plugin = () => new AlertingExamplePlugin(); diff --git a/examples/alerting_example/public/plugin.tsx b/examples/alerting_example/public/plugin.tsx new file mode 100644 index 0000000000000..299806d393446 --- /dev/null +++ b/examples/alerting_example/public/plugin.tsx @@ -0,0 +1,70 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Plugin, CoreSetup, AppMountParameters, CoreStart } from 'kibana/public'; +import { PluginSetupContract as AlertingSetup } from '../../../x-pack/plugins/alerting/public'; +import { ChartsPluginStart } from '../../../src/plugins/charts/public'; +import { TriggersAndActionsUIPublicPluginSetup } from '../../../x-pack/plugins/triggers_actions_ui/public'; +import { DataPublicPluginStart } from '../../../src/plugins/data/public'; +import { getAlertType as getAlwaysFiringAlertType } from './alert_types/always_firing'; +import { getAlertType as getPeopleInSpaceAlertType } from './alert_types/astros'; +import { registerNavigation } from './alert_types'; + +export type Setup = void; +export type Start = void; + +export interface AlertingExamplePublicSetupDeps { + alerting: AlertingSetup; + triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; +} + +export interface AlertingExamplePublicStartDeps { + alerting: AlertingSetup; + triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; + charts: ChartsPluginStart; + data: DataPublicPluginStart; +} + +export class AlertingExamplePlugin implements Plugin { + public setup( + core: CoreSetup, + { alerting, triggers_actions_ui }: AlertingExamplePublicSetupDeps + ) { + core.application.register({ + id: 'AlertingExample', + title: 'Alerting Example', + async mount(params: AppMountParameters) { + const [coreStart, depsStart]: [ + CoreStart, + AlertingExamplePublicStartDeps + ] = await core.getStartServices(); + const { renderApp } = await import('./application'); + return renderApp(coreStart, depsStart, params); + }, + }); + + triggers_actions_ui.alertTypeRegistry.register(getAlwaysFiringAlertType()); + triggers_actions_ui.alertTypeRegistry.register(getPeopleInSpaceAlertType()); + + registerNavigation(alerting); + } + + public start() {} + public stop() {} +} diff --git a/examples/alerting_example/server/alert_types/always_firing.ts b/examples/alerting_example/server/alert_types/always_firing.ts new file mode 100644 index 0000000000000..f0553ad5ebebd --- /dev/null +++ b/examples/alerting_example/server/alert_types/always_firing.ts @@ -0,0 +1,46 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import uuid from 'uuid'; +import { range } from 'lodash'; +import { AlertType } from '../../../../x-pack/plugins/alerting/server'; +import { DEFAULT_INSTANCES_TO_GENERATE } from '../../common/constants'; + +export const alertType: AlertType = { + id: 'example.always-firing', + name: 'Always firing', + actionGroups: [{ id: 'default', name: 'default' }], + defaultActionGroupId: 'default', + async executor({ services, params: { instances = DEFAULT_INSTANCES_TO_GENERATE }, state }) { + const count = (state.count ?? 0) + 1; + + range(instances) + .map(() => ({ id: uuid.v4() })) + .forEach((instance: { id: string }) => { + services + .alertInstanceFactory(instance.id) + .replaceState({ triggerdOnCycle: count }) + .scheduleActions('default'); + }); + + return { + count, + }; + }, +}; diff --git a/examples/alerting_example/server/alert_types/astros.ts b/examples/alerting_example/server/alert_types/astros.ts new file mode 100644 index 0000000000000..3a53f85e6a266 --- /dev/null +++ b/examples/alerting_example/server/alert_types/astros.ts @@ -0,0 +1,82 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import axios from 'axios'; +import { AlertType } from '../../../../x-pack/plugins/alerting/server'; +import { Operator, Craft } from '../../common/constants'; + +interface PeopleInSpace { + people: Array<{ + craft: string; + name: string; + }>; + number: number; +} + +function getOperator(op: string) { + switch (op) { + case Operator.AreAbove: + return (left: number, right: number) => left > right; + case Operator.AreBelow: + return (left: number, right: number) => left < right; + case Operator.AreExactly: + return (left: number, right: number) => left === right; + default: + return () => { + throw new Error( + `Invalid Operator "${op}" [${Operator.AreAbove},${Operator.AreBelow},${Operator.AreExactly}]` + ); + }; + } +} + +function getCraftFilter(craft: string) { + return (person: { craft: string; name: string }) => + craft === Craft.OuterSpace ? true : craft === person.craft; +} + +export const alertType: AlertType = { + id: 'example.people-in-space', + name: 'People In Space Right Now', + actionGroups: [{ id: 'default', name: 'default' }], + defaultActionGroupId: 'default', + async executor({ services, params }) { + const { outerSpaceCapacity, craft: craftToTriggerBy, op } = params; + + const response = await axios.get('http://api.open-notify.org/astros.json'); + const { + data: { number: peopleInSpace, people = [] }, + } = response; + + const peopleInCraft = people.filter(getCraftFilter(craftToTriggerBy)); + + if (getOperator(op)(peopleInCraft.length, outerSpaceCapacity)) { + peopleInCraft.forEach(({ craft, name }) => { + services + .alertInstanceFactory(name) + .replaceState({ craft }) + .scheduleActions('default'); + }); + } + + return { + peopleInSpace, + }; + }, +}; diff --git a/src/legacy/ui/public/indices/index.js b/examples/alerting_example/server/index.ts similarity index 80% rename from src/legacy/ui/public/indices/index.js rename to examples/alerting_example/server/index.ts index c1646bd66e367..32e9b181ebb54 100644 --- a/src/legacy/ui/public/indices/index.js +++ b/examples/alerting_example/server/index.ts @@ -17,10 +17,7 @@ * under the License. */ -export { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from './constants'; +import { PluginInitializer } from 'kibana/server'; +import { AlertingExamplePlugin } from './plugin'; -export { - indexNameBeginsWithPeriod, - findIllegalCharactersInIndexName, - indexNameContainsSpaces, -} from './validate'; +export const plugin: PluginInitializer = () => new AlertingExamplePlugin(); diff --git a/examples/alerting_example/server/plugin.ts b/examples/alerting_example/server/plugin.ts new file mode 100644 index 0000000000000..b5dabe51e8685 --- /dev/null +++ b/examples/alerting_example/server/plugin.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Plugin, CoreSetup } from 'kibana/server'; +import { PluginSetupContract as AlertingSetup } from '../../../x-pack/plugins/alerting/server'; + +import { alertType as alwaysFiringAlert } from './alert_types/always_firing'; +import { alertType as peopleInSpaceAlert } from './alert_types/astros'; + +// this plugin's dependendencies +export interface AlertingExampleDeps { + alerting: AlertingSetup; +} + +export class AlertingExamplePlugin implements Plugin { + public setup(core: CoreSetup, { alerting }: AlertingExampleDeps) { + alerting.registerType(alwaysFiringAlert); + alerting.registerType(peopleInSpaceAlert); + } + + public start() {} + public stop() {} +} diff --git a/examples/alerting_example/tsconfig.json b/examples/alerting_example/tsconfig.json new file mode 100644 index 0000000000000..078522b36cb12 --- /dev/null +++ b/examples/alerting_example/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "common/**/*.ts", + "../../typings/**/*", + ], + "exclude": [] +} diff --git a/package.json b/package.json index 583e99158da72..70d064fa2a8eb 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "url": "https://github.com/elastic/kibana.git" }, "resolutions": { - "**/@types/node": "10.12.27", + "**/@types/node": ">=10.17.17 <10.20.0", "**/@types/react": "^16.9.19", "**/@types/react-router": "^5.1.3", "**/@types/hapi": "^17.0.18", @@ -103,7 +103,8 @@ "x-pack/legacy/plugins/*", "examples/*", "test/plugin_functional/plugins/*", - "test/interpreter_functional/plugins/*" + "test/interpreter_functional/plugins/*", + "x-pack/test/functional_with_es_ssl/fixtures/plugins/*" ], "nohoist": [ "**/@types/*", @@ -171,7 +172,7 @@ "elastic-apm-node": "^3.2.0", "elasticsearch": "^16.5.0", "elasticsearch-browser": "^16.5.0", - "execa": "^3.2.0", + "execa": "^4.0.0", "expiry-js": "0.1.7", "fast-deep-equal": "^3.1.1", "file-loader": "4.2.0", @@ -350,7 +351,7 @@ "@types/mocha": "^5.2.7", "@types/moment-timezone": "^0.5.12", "@types/mustache": "^0.8.31", - "@types/node": "^10.12.27", + "@types/node": ">=10.17.17 <10.20.0", "@types/node-forge": "^0.9.0", "@types/normalize-path": "^3.0.0", "@types/numeral": "^0.0.26", @@ -460,7 +461,7 @@ "multistream": "^2.1.1", "murmurhash3js": "3.0.1", "mutation-observer": "^1.0.3", - "nock": "10.0.6", + "nock": "12.0.3", "node-sass": "^4.13.1", "normalize-path": "^3.0.0", "nyc": "^14.1.1", diff --git a/packages/kbn-config-schema/src/internals/index.ts b/packages/kbn-config-schema/src/internals/index.ts index 8f5d09e5b8b49..f84e14d2f741d 100644 --- a/packages/kbn-config-schema/src/internals/index.ts +++ b/packages/kbn-config-schema/src/internals/index.ts @@ -314,7 +314,8 @@ export const internals = Joi.extend([ for (const [entryKey, entryValue] of value) { const { value: validatedEntryKey, error: keyError } = Joi.validate( entryKey, - params.key + params.key, + { presence: 'required' } ); if (keyError) { @@ -323,7 +324,8 @@ export const internals = Joi.extend([ const { value: validatedEntryValue, error: valueError } = Joi.validate( entryValue, - params.value + params.value, + { presence: 'required' } ); if (valueError) { @@ -374,7 +376,8 @@ export const internals = Joi.extend([ for (const [entryKey, entryValue] of Object.entries(value)) { const { value: validatedEntryKey, error: keyError } = Joi.validate( entryKey, - params.key + params.key, + { presence: 'required' } ); if (keyError) { @@ -383,7 +386,8 @@ export const internals = Joi.extend([ const { value: validatedEntryValue, error: valueError } = Joi.validate( entryValue, - params.value + params.value, + { presence: 'required' } ); if (valueError) { diff --git a/packages/kbn-config-schema/src/types/map_of_type.test.ts b/packages/kbn-config-schema/src/types/map_of_type.test.ts index b015f51bdc8ad..1c5a227ef0fac 100644 --- a/packages/kbn-config-schema/src/types/map_of_type.test.ts +++ b/packages/kbn-config-schema/src/types/map_of_type.test.ts @@ -159,6 +159,24 @@ test('object within mapOf', () => { expect(type.validate(value)).toEqual(expected); }); +test('enforces required object fields within mapOf', () => { + const type = schema.mapOf( + schema.string(), + schema.object({ + bar: schema.object({ + baz: schema.number(), + }), + }) + ); + const value = { + foo: {}, + }; + + expect(() => type.validate(value)).toThrowErrorMatchingInlineSnapshot( + `"[foo.bar.baz]: expected value of type [number] but got [undefined]"` + ); +}); + test('error preserves full path', () => { const type = schema.object({ grandParentKey: schema.object({ diff --git a/packages/kbn-config-schema/src/types/map_type.ts b/packages/kbn-config-schema/src/types/map_type.ts index 231c3726ae9d5..6da664bf95616 100644 --- a/packages/kbn-config-schema/src/types/map_type.ts +++ b/packages/kbn-config-schema/src/types/map_type.ts @@ -57,7 +57,10 @@ export class MapOfType extends Type> { path.length, 0, // If `key` validation failed, let's stress that to make error more obvious. - type === 'map.key' ? `key("${entryKey}")` : entryKey.toString() + type === 'map.key' ? `key("${entryKey}")` : entryKey.toString(), + // Error could have happened deep inside value/key schema and error message should + // include full path. + ...(reason instanceof SchemaTypeError ? reason.path : []) ); return reason instanceof SchemaTypesError diff --git a/packages/kbn-config-schema/src/types/record_of_type.test.ts b/packages/kbn-config-schema/src/types/record_of_type.test.ts index ef15e7b0f6ad6..aee7dde71c3e4 100644 --- a/packages/kbn-config-schema/src/types/record_of_type.test.ts +++ b/packages/kbn-config-schema/src/types/record_of_type.test.ts @@ -159,6 +159,24 @@ test('object within recordOf', () => { expect(type.validate(value)).toEqual({ foo: { bar: 123 } }); }); +test('enforces required object fields within recordOf', () => { + const type = schema.recordOf( + schema.string(), + schema.object({ + bar: schema.object({ + baz: schema.number(), + }), + }) + ); + const value = { + foo: {}, + }; + + expect(() => type.validate(value)).toThrowErrorMatchingInlineSnapshot( + `"[foo.bar.baz]: expected value of type [number] but got [undefined]"` + ); +}); + test('error preserves full path', () => { const type = schema.object({ grandParentKey: schema.object({ diff --git a/packages/kbn-config-schema/src/types/record_type.ts b/packages/kbn-config-schema/src/types/record_type.ts index c6d4b4d71b4f1..ef9e70cbabc08 100644 --- a/packages/kbn-config-schema/src/types/record_type.ts +++ b/packages/kbn-config-schema/src/types/record_type.ts @@ -49,7 +49,10 @@ export class RecordOfType extends Type> { path.length, 0, // If `key` validation failed, let's stress that to make error more obvious. - type === 'record.key' ? `key("${entryKey}")` : entryKey.toString() + type === 'record.key' ? `key("${entryKey}")` : entryKey.toString(), + // Error could have happened deep inside value/key schema and error message should + // include full path. + ...(reason instanceof SchemaTypeError ? reason.path : []) ); return reason instanceof SchemaTypesError diff --git a/packages/kbn-dev-utils/package.json b/packages/kbn-dev-utils/package.json index bea153d0a672b..ee9f349f49051 100644 --- a/packages/kbn-dev-utils/package.json +++ b/packages/kbn-dev-utils/package.json @@ -12,7 +12,7 @@ "dependencies": { "chalk": "^2.4.2", "dedent": "^0.7.0", - "execa": "^3.2.0", + "execa": "^4.0.0", "exit-hook": "^2.2.0", "getopts": "^2.2.5", "load-json-file": "^6.2.0", diff --git a/packages/kbn-dev-utils/src/run/run.ts b/packages/kbn-dev-utils/src/run/run.ts index e185f86cc3bf7..35477e988d837 100644 --- a/packages/kbn-dev-utils/src/run/run.ts +++ b/packages/kbn-dev-utils/src/run/run.ts @@ -17,6 +17,8 @@ * under the License. */ +import { inspect } from 'util'; + // @ts-ignore @types are outdated and module is super simple import exitHook from 'exit-hook'; @@ -62,7 +64,11 @@ export async function run(fn: RunFn, options: Options = {}) { process.on('unhandledRejection', error => { log.error('UNHANDLED PROMISE REJECTION'); - log.error(error); + log.error( + error instanceof Error + ? error + : new Error(`non-Error type rejection value: ${inspect(error)}`) + ); process.exit(1); }); diff --git a/packages/kbn-es/package.json b/packages/kbn-es/package.json index f9d7bffed1e22..8b964d8399904 100644 --- a/packages/kbn-es/package.json +++ b/packages/kbn-es/package.json @@ -11,7 +11,7 @@ "chalk": "^2.4.2", "dedent": "^0.7.0", "del": "^5.1.0", - "execa": "^3.2.0", + "execa": "^4.0.0", "getopts": "^2.2.4", "glob": "^7.1.2", "node-fetch": "^2.6.0", diff --git a/packages/kbn-plugin-generator/package.json b/packages/kbn-plugin-generator/package.json index ac98a0e675fb1..b3b1eff41e4b5 100644 --- a/packages/kbn-plugin-generator/package.json +++ b/packages/kbn-plugin-generator/package.json @@ -6,7 +6,7 @@ "dependencies": { "chalk": "^2.4.2", "dedent": "^0.7.0", - "execa": "^3.2.0", + "execa": "^4.0.0", "getopts": "^2.2.4", "lodash.camelcase": "^4.3.0", "lodash.kebabcase": "^4.1.1", diff --git a/packages/kbn-plugin-helpers/package.json b/packages/kbn-plugin-helpers/package.json index 3b358c03b8053..c348aa43789d1 100644 --- a/packages/kbn-plugin-helpers/package.json +++ b/packages/kbn-plugin-helpers/package.json @@ -17,7 +17,7 @@ "argv-split": "^2.0.1", "commander": "^3.0.0", "del": "^5.1.0", - "execa": "^3.2.0", + "execa": "^4.0.0", "globby": "^8.0.1", "gulp-babel": "^8.0.0", "gulp-rename": "1.4.0", diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 16fc0d891185f..62b12e8e38c87 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -94,21 +94,21 @@ __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__(705); +/* harmony import */ var _production__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(704); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildProductionProjects", function() { return _production__WEBPACK_IMPORTED_MODULE_1__["buildProductionProjects"]; }); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "prepareExternalProjectDependencies", function() { return _production__WEBPACK_IMPORTED_MODULE_1__["prepareExternalProjectDependencies"]; }); -/* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(501); +/* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(500); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "getProjects", function() { return _utils_projects__WEBPACK_IMPORTED_MODULE_2__["getProjects"]; }); -/* harmony import */ var _utils_project__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(516); +/* harmony import */ var _utils_project__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(515); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Project", function() { return _utils_project__WEBPACK_IMPORTED_MODULE_3__["Project"]; }); -/* harmony import */ var _utils_workspaces__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(578); +/* harmony import */ var _utils_workspaces__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(577); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "copyWorkspacePackages", function() { return _utils_workspaces__WEBPACK_IMPORTED_MODULE_4__["copyWorkspacePackages"]; }); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(579); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(578); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "getProjectPaths", function() { return _config__WEBPACK_IMPORTED_MODULE_5__["getProjectPaths"]; }); /* @@ -152,7 +152,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(16); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_3__); /* harmony import */ var _commands__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(17); -/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(689); +/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(688); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(34); /* * Licensed to Elasticsearch B.V. under one or more contributor @@ -2506,9 +2506,9 @@ module.exports = require("path"); __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "commands", function() { return commands; }); /* harmony import */ var _bootstrap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(18); -/* harmony import */ var _clean__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(586); -/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(686); -/* harmony import */ var _watch__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(687); +/* harmony import */ var _clean__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(585); +/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(685); +/* harmony import */ var _watch__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(686); /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with @@ -2549,10 +2549,10 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(chalk__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var _utils_link_project_executables__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(19); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(34); -/* harmony import */ var _utils_parallelize__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(500); -/* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(501); -/* harmony import */ var _utils_project_checksums__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(580); -/* harmony import */ var _utils_bootstrap_cache_file__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(585); +/* harmony import */ var _utils_parallelize__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(499); +/* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(500); +/* harmony import */ var _utils_project_checksums__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(579); +/* harmony import */ var _utils_bootstrap_cache_file__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(584); /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with @@ -4490,10 +4490,10 @@ const tslib_1 = __webpack_require__(36); var proc_runner_1 = __webpack_require__(37); exports.withProcRunner = proc_runner_1.withProcRunner; exports.ProcRunner = proc_runner_1.ProcRunner; -tslib_1.__exportStar(__webpack_require__(415), exports); -var serializers_1 = __webpack_require__(420); +tslib_1.__exportStar(__webpack_require__(414), exports); +var serializers_1 = __webpack_require__(419); exports.createAbsolutePathSerializer = serializers_1.createAbsolutePathSerializer; -var certs_1 = __webpack_require__(445); +var certs_1 = __webpack_require__(444); exports.CA_CERT_PATH = certs_1.CA_CERT_PATH; exports.ES_KEY_PATH = certs_1.ES_KEY_PATH; exports.ES_CERT_PATH = certs_1.ES_CERT_PATH; @@ -4505,17 +4505,17 @@ exports.KBN_KEY_PATH = certs_1.KBN_KEY_PATH; exports.KBN_CERT_PATH = certs_1.KBN_CERT_PATH; exports.KBN_P12_PATH = certs_1.KBN_P12_PATH; exports.KBN_P12_PASSWORD = certs_1.KBN_P12_PASSWORD; -var run_1 = __webpack_require__(446); +var run_1 = __webpack_require__(445); exports.run = run_1.run; exports.createFailError = run_1.createFailError; exports.createFlagError = run_1.createFlagError; exports.combineErrors = run_1.combineErrors; exports.isFailError = run_1.isFailError; -var repo_root_1 = __webpack_require__(422); +var repo_root_1 = __webpack_require__(421); exports.REPO_ROOT = repo_root_1.REPO_ROOT; -var kbn_client_1 = __webpack_require__(451); +var kbn_client_1 = __webpack_require__(450); exports.KbnClient = kbn_client_1.KbnClient; -tslib_1.__exportStar(__webpack_require__(493), exports); +tslib_1.__exportStar(__webpack_require__(492), exports); /***/ }), @@ -32149,13 +32149,13 @@ Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = __webpack_require__(36); const execa_1 = tslib_1.__importDefault(__webpack_require__(351)); const fs_1 = __webpack_require__(23); -const Rx = tslib_1.__importStar(__webpack_require__(392)); +const Rx = tslib_1.__importStar(__webpack_require__(391)); const operators_1 = __webpack_require__(169); const chalk_1 = tslib_1.__importDefault(__webpack_require__(2)); -const tree_kill_1 = tslib_1.__importDefault(__webpack_require__(412)); +const tree_kill_1 = tslib_1.__importDefault(__webpack_require__(411)); const util_1 = __webpack_require__(29); const treeKillAsync = util_1.promisify((...args) => tree_kill_1.default(...args)); -const observe_lines_1 = __webpack_require__(413); +const observe_lines_1 = __webpack_require__(412); const errors_1 = __webpack_require__(349); const SECOND = 1000; const STOP_TIMEOUT = 30 * SECOND; @@ -32271,9 +32271,9 @@ const onetime = __webpack_require__(368); const makeError = __webpack_require__(370); const normalizeStdio = __webpack_require__(375); const {spawnedKill, spawnedCancel, setupTimeout, setExitHandler} = __webpack_require__(376); -const {handleInput, getSpawnedResult, makeAllStream, validateInputSync} = __webpack_require__(381); -const {mergePromise, getSpawnedPromise} = __webpack_require__(390); -const {joinCommand, parseCommand} = __webpack_require__(391); +const {handleInput, getSpawnedResult, makeAllStream, validateInputSync} = __webpack_require__(380); +const {mergePromise, getSpawnedPromise} = __webpack_require__(389); +const {joinCommand, parseCommand} = __webpack_require__(390); const DEFAULT_MAX_BUFFER = 1000 * 1000 * 100; @@ -32305,8 +32305,8 @@ const handleArgs = (file, args, options = {}) => { reject: true, cleanup: true, all: false, - ...options, - windowsHide: true + windowsHide: true, + ...options }; options.env = getEnv(options); @@ -33430,15 +33430,18 @@ const makeError = ({ const errorCode = error && error.code; const prefix = getErrorPrefix({timedOut, timeout, errorCode, signal, signalDescription, exitCode, isCanceled}); - const message = `Command ${prefix}: ${command}`; + const execaMessage = `Command ${prefix}: ${command}`; + const shortMessage = error instanceof Error ? `${execaMessage}\n${error.message}` : execaMessage; + const message = [shortMessage, stderr, stdout].filter(Boolean).join('\n'); if (error instanceof Error) { error.originalMessage = error.message; - error.message = `${message}\n${error.message}`; + error.message = message; } else { error = new Error(message); } + error.shortMessage = shortMessage; error.command = command; error.exitCode = exitCode; error.signal = signal; @@ -33954,7 +33957,6 @@ module.exports.node = opts => { const os = __webpack_require__(11); const onExit = __webpack_require__(377); -const pFinally = __webpack_require__(380); const DEFAULT_FORCE_KILL_TIMEOUT = 1000 * 5; @@ -33971,9 +33973,17 @@ const setKillTimeout = (kill, signal, options, killResult) => { } const timeout = getForceKillAfterTimeout(options); - setTimeout(() => { + const t = setTimeout(() => { kill('SIGKILL'); - }, timeout).unref(); + }, timeout); + + // Guarded because there's no `.unref()` when `execa` is used in the renderer + // process in Electron. This cannot be tested since we don't run tests in + // Electron. + // istanbul ignore else + if (t.unref) { + t.unref(); + } }; const shouldForceKill = (signal, {forceKillAfterTimeout}, killResult) => { @@ -34028,7 +34038,7 @@ const setupTimeout = (spawned, {timeout, killSignal = 'SIGTERM'}, spawnedPromise }, timeout); }); - const safeSpawnedPromise = pFinally(spawnedPromise, () => { + const safeSpawnedPromise = spawnedPromise.finally(() => { clearTimeout(timeoutId); }); @@ -34036,7 +34046,7 @@ const setupTimeout = (spawned, {timeout, killSignal = 'SIGTERM'}, spawnedPromise }; // `cleanup` option handling -const setExitHandler = (spawned, {cleanup, detached}, timedPromise) => { +const setExitHandler = async (spawned, {cleanup, detached}, timedPromise) => { if (!cleanup || detached) { return timedPromise; } @@ -34045,8 +34055,9 @@ const setExitHandler = (spawned, {cleanup, detached}, timedPromise) => { spawned.kill(); }); - // TODO: Use native "finally" syntax when targeting Node.js 10 - return pFinally(timedPromise, removeExitHandler); + return timedPromise.finally(() => { + removeExitHandler(); + }); }; module.exports = { @@ -34291,33 +34302,9 @@ module.exports = require("events"); "use strict"; - -module.exports = async ( - promise, - onFinally = (() => {}) -) => { - let value; - try { - value = await promise; - } catch (error) { - await onFinally(); - throw error; - } - - await onFinally(); - return value; -}; - - -/***/ }), -/* 381 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - -const isStream = __webpack_require__(382); -const getStream = __webpack_require__(383); -const mergeStream = __webpack_require__(389); +const isStream = __webpack_require__(381); +const getStream = __webpack_require__(382); +const mergeStream = __webpack_require__(388); // `input` option const handleInput = (spawned, input) => { @@ -34414,7 +34401,7 @@ module.exports = { /***/ }), -/* 382 */ +/* 381 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -34450,13 +34437,13 @@ module.exports = isStream; /***/ }), -/* 383 */ +/* 382 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pump = __webpack_require__(384); -const bufferStream = __webpack_require__(388); +const pump = __webpack_require__(383); +const bufferStream = __webpack_require__(387); class MaxBufferError extends Error { constructor() { @@ -34515,11 +34502,11 @@ module.exports.MaxBufferError = MaxBufferError; /***/ }), -/* 384 */ +/* 383 */ /***/ (function(module, exports, __webpack_require__) { -var once = __webpack_require__(385) -var eos = __webpack_require__(387) +var once = __webpack_require__(384) +var eos = __webpack_require__(386) var fs = __webpack_require__(23) // we only need fs to get the ReadStream and WriteStream prototypes var noop = function () {} @@ -34603,10 +34590,10 @@ module.exports = pump /***/ }), -/* 385 */ +/* 384 */ /***/ (function(module, exports, __webpack_require__) { -var wrappy = __webpack_require__(386) +var wrappy = __webpack_require__(385) module.exports = wrappy(once) module.exports.strict = wrappy(onceStrict) @@ -34651,7 +34638,7 @@ function onceStrict (fn) { /***/ }), -/* 386 */ +/* 385 */ /***/ (function(module, exports) { // Returns a wrapper function that returns a wrapped callback @@ -34690,10 +34677,10 @@ function wrappy (fn, cb) { /***/ }), -/* 387 */ +/* 386 */ /***/ (function(module, exports, __webpack_require__) { -var once = __webpack_require__(385); +var once = __webpack_require__(384); var noop = function() {}; @@ -34783,7 +34770,7 @@ module.exports = eos; /***/ }), -/* 388 */ +/* 387 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -34842,7 +34829,7 @@ module.exports = options => { /***/ }), -/* 389 */ +/* 388 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -34890,7 +34877,7 @@ module.exports = function (/*streams...*/) { /***/ }), -/* 390 */ +/* 389 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -34913,12 +34900,7 @@ const mergePromiseProperty = (spawned, promise, property) => { const mergePromise = (spawned, promise) => { mergePromiseProperty(spawned, promise, 'then'); mergePromiseProperty(spawned, promise, 'catch'); - - // TODO: Remove the `if`-guard when targeting Node.js 10 - if (Promise.prototype.finally) { - mergePromiseProperty(spawned, promise, 'finally'); - } - + mergePromiseProperty(spawned, promise, 'finally'); return spawned; }; @@ -34949,7 +34931,7 @@ module.exports = { /***/ }), -/* 391 */ +/* 390 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -34994,7 +34976,7 @@ module.exports = { /***/ }), -/* 392 */ +/* 391 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -35032,10 +35014,10 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _internal_scheduler_queue__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(298); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "queueScheduler", function() { return _internal_scheduler_queue__WEBPACK_IMPORTED_MODULE_10__["queue"]; }); -/* harmony import */ var _internal_scheduler_animationFrame__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(393); +/* harmony import */ var _internal_scheduler_animationFrame__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(392); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "animationFrameScheduler", function() { return _internal_scheduler_animationFrame__WEBPACK_IMPORTED_MODULE_11__["animationFrame"]; }); -/* harmony import */ var _internal_scheduler_VirtualTimeScheduler__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(396); +/* harmony import */ var _internal_scheduler_VirtualTimeScheduler__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(395); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "VirtualTimeScheduler", function() { return _internal_scheduler_VirtualTimeScheduler__WEBPACK_IMPORTED_MODULE_12__["VirtualTimeScheduler"]; }); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "VirtualAction", function() { return _internal_scheduler_VirtualTimeScheduler__WEBPACK_IMPORTED_MODULE_12__["VirtualAction"]; }); @@ -35063,7 +35045,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _internal_util_identity__WEBPACK_IMPORTED_MODULE_19__ = __webpack_require__(232); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "identity", function() { return _internal_util_identity__WEBPACK_IMPORTED_MODULE_19__["identity"]; }); -/* harmony import */ var _internal_util_isObservable__WEBPACK_IMPORTED_MODULE_20__ = __webpack_require__(397); +/* harmony import */ var _internal_util_isObservable__WEBPACK_IMPORTED_MODULE_20__ = __webpack_require__(396); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "isObservable", function() { return _internal_util_isObservable__WEBPACK_IMPORTED_MODULE_20__["isObservable"]; }); /* harmony import */ var _internal_util_ArgumentOutOfRangeError__WEBPACK_IMPORTED_MODULE_21__ = __webpack_require__(250); @@ -35081,10 +35063,10 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _internal_util_TimeoutError__WEBPACK_IMPORTED_MODULE_25__ = __webpack_require__(335); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "TimeoutError", function() { return _internal_util_TimeoutError__WEBPACK_IMPORTED_MODULE_25__["TimeoutError"]; }); -/* harmony import */ var _internal_observable_bindCallback__WEBPACK_IMPORTED_MODULE_26__ = __webpack_require__(398); +/* harmony import */ var _internal_observable_bindCallback__WEBPACK_IMPORTED_MODULE_26__ = __webpack_require__(397); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "bindCallback", function() { return _internal_observable_bindCallback__WEBPACK_IMPORTED_MODULE_26__["bindCallback"]; }); -/* harmony import */ var _internal_observable_bindNodeCallback__WEBPACK_IMPORTED_MODULE_27__ = __webpack_require__(399); +/* harmony import */ var _internal_observable_bindNodeCallback__WEBPACK_IMPORTED_MODULE_27__ = __webpack_require__(398); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "bindNodeCallback", function() { return _internal_observable_bindNodeCallback__WEBPACK_IMPORTED_MODULE_27__["bindNodeCallback"]; }); /* harmony import */ var _internal_observable_combineLatest__WEBPACK_IMPORTED_MODULE_28__ = __webpack_require__(214); @@ -35099,49 +35081,49 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _internal_observable_empty__WEBPACK_IMPORTED_MODULE_31__ = __webpack_require__(242); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "empty", function() { return _internal_observable_empty__WEBPACK_IMPORTED_MODULE_31__["empty"]; }); -/* harmony import */ var _internal_observable_forkJoin__WEBPACK_IMPORTED_MODULE_32__ = __webpack_require__(400); +/* harmony import */ var _internal_observable_forkJoin__WEBPACK_IMPORTED_MODULE_32__ = __webpack_require__(399); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "forkJoin", function() { return _internal_observable_forkJoin__WEBPACK_IMPORTED_MODULE_32__["forkJoin"]; }); /* harmony import */ var _internal_observable_from__WEBPACK_IMPORTED_MODULE_33__ = __webpack_require__(218); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "from", function() { return _internal_observable_from__WEBPACK_IMPORTED_MODULE_33__["from"]; }); -/* harmony import */ var _internal_observable_fromEvent__WEBPACK_IMPORTED_MODULE_34__ = __webpack_require__(401); +/* harmony import */ var _internal_observable_fromEvent__WEBPACK_IMPORTED_MODULE_34__ = __webpack_require__(400); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "fromEvent", function() { return _internal_observable_fromEvent__WEBPACK_IMPORTED_MODULE_34__["fromEvent"]; }); -/* harmony import */ var _internal_observable_fromEventPattern__WEBPACK_IMPORTED_MODULE_35__ = __webpack_require__(402); +/* harmony import */ var _internal_observable_fromEventPattern__WEBPACK_IMPORTED_MODULE_35__ = __webpack_require__(401); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "fromEventPattern", function() { return _internal_observable_fromEventPattern__WEBPACK_IMPORTED_MODULE_35__["fromEventPattern"]; }); -/* harmony import */ var _internal_observable_generate__WEBPACK_IMPORTED_MODULE_36__ = __webpack_require__(403); +/* harmony import */ var _internal_observable_generate__WEBPACK_IMPORTED_MODULE_36__ = __webpack_require__(402); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "generate", function() { return _internal_observable_generate__WEBPACK_IMPORTED_MODULE_36__["generate"]; }); -/* harmony import */ var _internal_observable_iif__WEBPACK_IMPORTED_MODULE_37__ = __webpack_require__(404); +/* harmony import */ var _internal_observable_iif__WEBPACK_IMPORTED_MODULE_37__ = __webpack_require__(403); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "iif", function() { return _internal_observable_iif__WEBPACK_IMPORTED_MODULE_37__["iif"]; }); -/* harmony import */ var _internal_observable_interval__WEBPACK_IMPORTED_MODULE_38__ = __webpack_require__(405); +/* harmony import */ var _internal_observable_interval__WEBPACK_IMPORTED_MODULE_38__ = __webpack_require__(404); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "interval", function() { return _internal_observable_interval__WEBPACK_IMPORTED_MODULE_38__["interval"]; }); /* harmony import */ var _internal_observable_merge__WEBPACK_IMPORTED_MODULE_39__ = __webpack_require__(278); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "merge", function() { return _internal_observable_merge__WEBPACK_IMPORTED_MODULE_39__["merge"]; }); -/* harmony import */ var _internal_observable_never__WEBPACK_IMPORTED_MODULE_40__ = __webpack_require__(406); +/* harmony import */ var _internal_observable_never__WEBPACK_IMPORTED_MODULE_40__ = __webpack_require__(405); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "never", function() { return _internal_observable_never__WEBPACK_IMPORTED_MODULE_40__["never"]; }); /* harmony import */ var _internal_observable_of__WEBPACK_IMPORTED_MODULE_41__ = __webpack_require__(227); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "of", function() { return _internal_observable_of__WEBPACK_IMPORTED_MODULE_41__["of"]; }); -/* harmony import */ var _internal_observable_onErrorResumeNext__WEBPACK_IMPORTED_MODULE_42__ = __webpack_require__(407); +/* harmony import */ var _internal_observable_onErrorResumeNext__WEBPACK_IMPORTED_MODULE_42__ = __webpack_require__(406); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "onErrorResumeNext", function() { return _internal_observable_onErrorResumeNext__WEBPACK_IMPORTED_MODULE_42__["onErrorResumeNext"]; }); -/* harmony import */ var _internal_observable_pairs__WEBPACK_IMPORTED_MODULE_43__ = __webpack_require__(408); +/* harmony import */ var _internal_observable_pairs__WEBPACK_IMPORTED_MODULE_43__ = __webpack_require__(407); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "pairs", function() { return _internal_observable_pairs__WEBPACK_IMPORTED_MODULE_43__["pairs"]; }); -/* harmony import */ var _internal_observable_partition__WEBPACK_IMPORTED_MODULE_44__ = __webpack_require__(409); +/* harmony import */ var _internal_observable_partition__WEBPACK_IMPORTED_MODULE_44__ = __webpack_require__(408); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "partition", function() { return _internal_observable_partition__WEBPACK_IMPORTED_MODULE_44__["partition"]; }); /* harmony import */ var _internal_observable_race__WEBPACK_IMPORTED_MODULE_45__ = __webpack_require__(302); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "race", function() { return _internal_observable_race__WEBPACK_IMPORTED_MODULE_45__["race"]; }); -/* harmony import */ var _internal_observable_range__WEBPACK_IMPORTED_MODULE_46__ = __webpack_require__(410); +/* harmony import */ var _internal_observable_range__WEBPACK_IMPORTED_MODULE_46__ = __webpack_require__(409); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "range", function() { return _internal_observable_range__WEBPACK_IMPORTED_MODULE_46__["range"]; }); /* harmony import */ var _internal_observable_throwError__WEBPACK_IMPORTED_MODULE_47__ = __webpack_require__(243); @@ -35150,7 +35132,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _internal_observable_timer__WEBPACK_IMPORTED_MODULE_48__ = __webpack_require__(204); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "timer", function() { return _internal_observable_timer__WEBPACK_IMPORTED_MODULE_48__["timer"]; }); -/* harmony import */ var _internal_observable_using__WEBPACK_IMPORTED_MODULE_49__ = __webpack_require__(411); +/* harmony import */ var _internal_observable_using__WEBPACK_IMPORTED_MODULE_49__ = __webpack_require__(410); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "using", function() { return _internal_observable_using__WEBPACK_IMPORTED_MODULE_49__["using"]; }); /* harmony import */ var _internal_observable_zip__WEBPACK_IMPORTED_MODULE_50__ = __webpack_require__(346); @@ -35226,14 +35208,14 @@ __webpack_require__.r(__webpack_exports__); /***/ }), -/* 393 */ +/* 392 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "animationFrame", function() { return animationFrame; }); -/* harmony import */ var _AnimationFrameAction__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(394); -/* harmony import */ var _AnimationFrameScheduler__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(395); +/* harmony import */ var _AnimationFrameAction__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(393); +/* harmony import */ var _AnimationFrameScheduler__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(394); /** PURE_IMPORTS_START _AnimationFrameAction,_AnimationFrameScheduler PURE_IMPORTS_END */ @@ -35242,7 +35224,7 @@ var animationFrame = /*@__PURE__*/ new _AnimationFrameScheduler__WEBPACK_IMPORTE /***/ }), -/* 394 */ +/* 393 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -35291,7 +35273,7 @@ var AnimationFrameAction = /*@__PURE__*/ (function (_super) { /***/ }), -/* 395 */ +/* 394 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -35335,7 +35317,7 @@ var AnimationFrameScheduler = /*@__PURE__*/ (function (_super) { /***/ }), -/* 396 */ +/* 395 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -35458,7 +35440,7 @@ var VirtualAction = /*@__PURE__*/ (function (_super) { /***/ }), -/* 397 */ +/* 396 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -35474,7 +35456,7 @@ function isObservable(obj) { /***/ }), -/* 398 */ +/* 397 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -35594,7 +35576,7 @@ function dispatchError(state) { /***/ }), -/* 399 */ +/* 398 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -35722,7 +35704,7 @@ function dispatchError(arg) { /***/ }), -/* 400 */ +/* 399 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -35805,7 +35787,7 @@ function forkJoinInternal(sources, keys) { /***/ }), -/* 401 */ +/* 400 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -35881,7 +35863,7 @@ function isEventTarget(sourceObj) { /***/ }), -/* 402 */ +/* 401 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -35926,7 +35908,7 @@ function fromEventPattern(addHandler, removeHandler, resultSelector) { /***/ }), -/* 403 */ +/* 402 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -36063,7 +36045,7 @@ function dispatch(state) { /***/ }), -/* 404 */ +/* 403 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -36087,7 +36069,7 @@ function iif(condition, trueResult, falseResult) { /***/ }), -/* 405 */ +/* 404 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -36127,7 +36109,7 @@ function dispatch(state) { /***/ }), -/* 406 */ +/* 405 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -36147,7 +36129,7 @@ function never() { /***/ }), -/* 407 */ +/* 406 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -36187,7 +36169,7 @@ function onErrorResumeNext() { /***/ }), -/* 408 */ +/* 407 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -36238,7 +36220,7 @@ function dispatch(state) { /***/ }), -/* 409 */ +/* 408 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -36263,7 +36245,7 @@ function partition(source, predicate, thisArg) { /***/ }), -/* 410 */ +/* 409 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -36322,7 +36304,7 @@ function dispatch(state) { /***/ }), -/* 411 */ +/* 410 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -36367,7 +36349,7 @@ function using(resourceFactory, observableFactory) { /***/ }), -/* 412 */ +/* 411 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -36492,7 +36474,7 @@ function buildProcessTree (parentPid, tree, pidsToProcess, spawnChildProcessesLi /***/ }), -/* 413 */ +/* 412 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -36517,10 +36499,10 @@ function buildProcessTree (parentPid, tree, pidsToProcess, spawnChildProcessesLi */ Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = __webpack_require__(36); -const Rx = tslib_1.__importStar(__webpack_require__(392)); +const Rx = tslib_1.__importStar(__webpack_require__(391)); const operators_1 = __webpack_require__(169); const SEP = /\r?\n/; -const observe_readable_1 = __webpack_require__(414); +const observe_readable_1 = __webpack_require__(413); /** * Creates an Observable from a Readable Stream that: * - splits data from `readable` into lines @@ -36561,7 +36543,7 @@ exports.observeLines = observeLines; /***/ }), -/* 414 */ +/* 413 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -36586,7 +36568,7 @@ exports.observeLines = observeLines; */ Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = __webpack_require__(36); -const Rx = tslib_1.__importStar(__webpack_require__(392)); +const Rx = tslib_1.__importStar(__webpack_require__(391)); const operators_1 = __webpack_require__(169); /** * Produces an Observable from a ReadableSteam that: @@ -36600,7 +36582,7 @@ exports.observeReadable = observeReadable; /***/ }), -/* 415 */ +/* 414 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -36624,19 +36606,19 @@ exports.observeReadable = observeReadable; * under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); -var tooling_log_1 = __webpack_require__(416); +var tooling_log_1 = __webpack_require__(415); exports.ToolingLog = tooling_log_1.ToolingLog; -var tooling_log_text_writer_1 = __webpack_require__(417); +var tooling_log_text_writer_1 = __webpack_require__(416); exports.ToolingLogTextWriter = tooling_log_text_writer_1.ToolingLogTextWriter; -var log_levels_1 = __webpack_require__(418); +var log_levels_1 = __webpack_require__(417); exports.pickLevelFromFlags = log_levels_1.pickLevelFromFlags; exports.parseLogLevel = log_levels_1.parseLogLevel; -var tooling_log_collecting_writer_1 = __webpack_require__(419); +var tooling_log_collecting_writer_1 = __webpack_require__(418); exports.ToolingLogCollectingWriter = tooling_log_collecting_writer_1.ToolingLogCollectingWriter; /***/ }), -/* 416 */ +/* 415 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -36661,8 +36643,8 @@ exports.ToolingLogCollectingWriter = tooling_log_collecting_writer_1.ToolingLogC */ Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = __webpack_require__(36); -const Rx = tslib_1.__importStar(__webpack_require__(392)); -const tooling_log_text_writer_1 = __webpack_require__(417); +const Rx = tslib_1.__importStar(__webpack_require__(391)); +const tooling_log_text_writer_1 = __webpack_require__(416); class ToolingLog { constructor(writerConfig) { this.identWidth = 0; @@ -36724,7 +36706,7 @@ exports.ToolingLog = ToolingLog; /***/ }), -/* 417 */ +/* 416 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -36751,7 +36733,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = __webpack_require__(36); const util_1 = __webpack_require__(29); const chalk_1 = tslib_1.__importDefault(__webpack_require__(2)); -const log_levels_1 = __webpack_require__(418); +const log_levels_1 = __webpack_require__(417); const { magentaBright, yellow, red, blue, green, dim } = chalk_1.default; const PREFIX_INDENT = ' '.repeat(6); const MSG_PREFIXES = { @@ -36818,7 +36800,7 @@ exports.ToolingLogTextWriter = ToolingLogTextWriter; /***/ }), -/* 418 */ +/* 417 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -36874,7 +36856,7 @@ exports.parseLogLevel = parseLogLevel; /***/ }), -/* 419 */ +/* 418 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -36898,7 +36880,7 @@ exports.parseLogLevel = parseLogLevel; * under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); -const tooling_log_text_writer_1 = __webpack_require__(417); +const tooling_log_text_writer_1 = __webpack_require__(416); class ToolingLogCollectingWriter extends tooling_log_text_writer_1.ToolingLogTextWriter { constructor() { super({ @@ -36917,7 +36899,7 @@ exports.ToolingLogCollectingWriter = ToolingLogCollectingWriter; /***/ }), -/* 420 */ +/* 419 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -36941,12 +36923,12 @@ exports.ToolingLogCollectingWriter = ToolingLogCollectingWriter; * under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); -var absolute_path_serializer_1 = __webpack_require__(421); +var absolute_path_serializer_1 = __webpack_require__(420); exports.createAbsolutePathSerializer = absolute_path_serializer_1.createAbsolutePathSerializer; /***/ }), -/* 421 */ +/* 420 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -36970,7 +36952,7 @@ exports.createAbsolutePathSerializer = absolute_path_serializer_1.createAbsolute * under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); -const repo_root_1 = __webpack_require__(422); +const repo_root_1 = __webpack_require__(421); function createAbsolutePathSerializer(rootPath = repo_root_1.REPO_ROOT) { return { print: (value) => value.replace(rootPath, '').replace(/\\/g, '/'), @@ -36981,7 +36963,7 @@ exports.createAbsolutePathSerializer = createAbsolutePathSerializer; /***/ }), -/* 422 */ +/* 421 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -37008,7 +36990,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = __webpack_require__(36); const path_1 = tslib_1.__importDefault(__webpack_require__(16)); const fs_1 = tslib_1.__importDefault(__webpack_require__(23)); -const load_json_file_1 = tslib_1.__importDefault(__webpack_require__(423)); +const load_json_file_1 = tslib_1.__importDefault(__webpack_require__(422)); const isKibanaDir = (dir) => { try { const path = path_1.default.resolve(dir, 'package.json'); @@ -37044,16 +37026,16 @@ exports.REPO_ROOT = cursor; /***/ }), -/* 423 */ +/* 422 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(16); const {promisify} = __webpack_require__(29); -const fs = __webpack_require__(424); -const stripBom = __webpack_require__(428); -const parseJson = __webpack_require__(429); +const fs = __webpack_require__(423); +const stripBom = __webpack_require__(427); +const parseJson = __webpack_require__(428); const parse = (data, filePath, options = {}) => { data = stripBom(data); @@ -37070,13 +37052,13 @@ module.exports.sync = (filePath, options) => parse(fs.readFileSync(filePath, 'ut /***/ }), -/* 424 */ +/* 423 */ /***/ (function(module, exports, __webpack_require__) { var fs = __webpack_require__(23) -var polyfills = __webpack_require__(425) -var legacy = __webpack_require__(426) -var clone = __webpack_require__(427) +var polyfills = __webpack_require__(424) +var legacy = __webpack_require__(425) +var clone = __webpack_require__(426) var queue = [] @@ -37355,7 +37337,7 @@ function retry () { /***/ }), -/* 425 */ +/* 424 */ /***/ (function(module, exports, __webpack_require__) { var constants = __webpack_require__(25) @@ -37690,7 +37672,7 @@ function patch (fs) { /***/ }), -/* 426 */ +/* 425 */ /***/ (function(module, exports, __webpack_require__) { var Stream = __webpack_require__(27).Stream @@ -37814,7 +37796,7 @@ function legacy (fs) { /***/ }), -/* 427 */ +/* 426 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -37840,7 +37822,7 @@ function clone (obj) { /***/ }), -/* 428 */ +/* 427 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -37862,15 +37844,15 @@ module.exports = string => { /***/ }), -/* 429 */ +/* 428 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const errorEx = __webpack_require__(430); -const fallback = __webpack_require__(432); -const {default: LinesAndColumns} = __webpack_require__(433); -const {codeFrameColumns} = __webpack_require__(434); +const errorEx = __webpack_require__(429); +const fallback = __webpack_require__(431); +const {default: LinesAndColumns} = __webpack_require__(432); +const {codeFrameColumns} = __webpack_require__(433); const JSONError = errorEx('JSONError', { fileName: errorEx.append('in %s'), @@ -37919,14 +37901,14 @@ module.exports = (string, reviver, filename) => { /***/ }), -/* 430 */ +/* 429 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(29); -var isArrayish = __webpack_require__(431); +var isArrayish = __webpack_require__(430); var errorEx = function errorEx(name, properties) { if (!name || name.constructor !== String) { @@ -38059,7 +38041,7 @@ module.exports = errorEx; /***/ }), -/* 431 */ +/* 430 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -38076,7 +38058,7 @@ module.exports = function isArrayish(obj) { /***/ }), -/* 432 */ +/* 431 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -38115,7 +38097,7 @@ function parseJson (txt, reviver, context) { /***/ }), -/* 433 */ +/* 432 */ /***/ (function(__webpack_module__, __webpack_exports__, __webpack_require__) { "use strict"; @@ -38179,7 +38161,7 @@ var LinesAndColumns = (function () { /***/ }), -/* 434 */ +/* 433 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -38192,7 +38174,7 @@ exports.codeFrameColumns = codeFrameColumns; exports.default = _default; function _highlight() { - const data = _interopRequireWildcard(__webpack_require__(435)); + const data = _interopRequireWildcard(__webpack_require__(434)); _highlight = function () { return data; @@ -38358,7 +38340,7 @@ function _default(rawLines, lineNumber, colNumber, opts = {}) { } /***/ }), -/* 435 */ +/* 434 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -38372,7 +38354,7 @@ exports.getChalk = getChalk; exports.default = highlight; function _jsTokens() { - const data = _interopRequireWildcard(__webpack_require__(436)); + const data = _interopRequireWildcard(__webpack_require__(435)); _jsTokens = function () { return data; @@ -38382,7 +38364,7 @@ function _jsTokens() { } function _esutils() { - const data = _interopRequireDefault(__webpack_require__(437)); + const data = _interopRequireDefault(__webpack_require__(436)); _esutils = function () { return data; @@ -38392,7 +38374,7 @@ function _esutils() { } function _chalk() { - const data = _interopRequireDefault(__webpack_require__(441)); + const data = _interopRequireDefault(__webpack_require__(440)); _chalk = function () { return data; @@ -38493,7 +38475,7 @@ function highlight(code, options = {}) { } /***/ }), -/* 436 */ +/* 435 */ /***/ (function(module, exports) { // Copyright 2014, 2015, 2016, 2017, 2018 Simon Lydell @@ -38522,7 +38504,7 @@ exports.matchToToken = function(match) { /***/ }), -/* 437 */ +/* 436 */ /***/ (function(module, exports, __webpack_require__) { /* @@ -38553,15 +38535,15 @@ exports.matchToToken = function(match) { (function () { 'use strict'; - exports.ast = __webpack_require__(438); - exports.code = __webpack_require__(439); - exports.keyword = __webpack_require__(440); + exports.ast = __webpack_require__(437); + exports.code = __webpack_require__(438); + exports.keyword = __webpack_require__(439); }()); /* vim: set sw=4 ts=4 et tw=80 : */ /***/ }), -/* 438 */ +/* 437 */ /***/ (function(module, exports) { /* @@ -38711,7 +38693,7 @@ exports.matchToToken = function(match) { /***/ }), -/* 439 */ +/* 438 */ /***/ (function(module, exports) { /* @@ -38852,7 +38834,7 @@ exports.matchToToken = function(match) { /***/ }), -/* 440 */ +/* 439 */ /***/ (function(module, exports, __webpack_require__) { /* @@ -38882,7 +38864,7 @@ exports.matchToToken = function(match) { (function () { 'use strict'; - var code = __webpack_require__(439); + var code = __webpack_require__(438); function isStrictModeReservedWordES6(id) { switch (id) { @@ -39023,16 +39005,16 @@ exports.matchToToken = function(match) { /***/ }), -/* 441 */ +/* 440 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const escapeStringRegexp = __webpack_require__(3); -const ansiStyles = __webpack_require__(442); -const stdoutColor = __webpack_require__(443).stdout; +const ansiStyles = __webpack_require__(441); +const stdoutColor = __webpack_require__(442).stdout; -const template = __webpack_require__(444); +const template = __webpack_require__(443); const isSimpleWindowsTerm = process.platform === 'win32' && !(process.env.TERM || '').toLowerCase().startsWith('xterm'); @@ -39258,7 +39240,7 @@ module.exports.default = module.exports; // For TypeScript /***/ }), -/* 442 */ +/* 441 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -39431,7 +39413,7 @@ Object.defineProperty(module, 'exports', { /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(5)(module))) /***/ }), -/* 443 */ +/* 442 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -39573,7 +39555,7 @@ module.exports = { /***/ }), -/* 444 */ +/* 443 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -39708,7 +39690,7 @@ module.exports = (chalk, tmp) => { /***/ }), -/* 445 */ +/* 444 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -39747,7 +39729,7 @@ exports.KBN_P12_PASSWORD = 'storepass'; /***/ }), -/* 446 */ +/* 445 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -39771,9 +39753,9 @@ exports.KBN_P12_PASSWORD = 'storepass'; * under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); -var run_1 = __webpack_require__(447); +var run_1 = __webpack_require__(446); exports.run = run_1.run; -var fail_1 = __webpack_require__(448); +var fail_1 = __webpack_require__(447); exports.createFailError = fail_1.createFailError; exports.createFlagError = fail_1.createFlagError; exports.combineErrors = fail_1.combineErrors; @@ -39781,7 +39763,7 @@ exports.isFailError = fail_1.isFailError; /***/ }), -/* 447 */ +/* 446 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -39806,11 +39788,12 @@ exports.isFailError = fail_1.isFailError; */ Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = __webpack_require__(36); +const util_1 = __webpack_require__(29); // @ts-ignore @types are outdated and module is super simple const exit_hook_1 = tslib_1.__importDefault(__webpack_require__(348)); -const tooling_log_1 = __webpack_require__(415); -const fail_1 = __webpack_require__(448); -const flags_1 = __webpack_require__(449); +const tooling_log_1 = __webpack_require__(414); +const fail_1 = __webpack_require__(447); +const flags_1 = __webpack_require__(448); const proc_runner_1 = __webpack_require__(37); async function run(fn, options = {}) { var _a; @@ -39825,7 +39808,9 @@ async function run(fn, options = {}) { }); process.on('unhandledRejection', error => { log.error('UNHANDLED PROMISE REJECTION'); - log.error(error); + log.error(error instanceof Error + ? error + : new Error(`non-Error type rejection value: ${util_1.inspect(error)}`)); process.exit(1); }); const handleErrorWithoutExit = (error) => { @@ -39883,7 +39868,7 @@ exports.run = run; /***/ }), -/* 448 */ +/* 447 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -39951,7 +39936,7 @@ exports.combineErrors = combineErrors; /***/ }), -/* 449 */ +/* 448 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -39978,7 +39963,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = __webpack_require__(36); const path_1 = __webpack_require__(16); const dedent_1 = tslib_1.__importDefault(__webpack_require__(14)); -const getopts_1 = tslib_1.__importDefault(__webpack_require__(450)); +const getopts_1 = tslib_1.__importDefault(__webpack_require__(449)); function getFlags(argv, options) { const unexpectedNames = new Set(); const flagOpts = options.flags || {}; @@ -40081,7 +40066,7 @@ exports.getHelp = getHelp; /***/ }), -/* 450 */ +/* 449 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -40293,7 +40278,7 @@ module.exports = getopts /***/ }), -/* 451 */ +/* 450 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -40317,14 +40302,14 @@ module.exports = getopts * under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); -var kbn_client_1 = __webpack_require__(452); +var kbn_client_1 = __webpack_require__(451); exports.KbnClient = kbn_client_1.KbnClient; -var kbn_client_requester_1 = __webpack_require__(453); +var kbn_client_requester_1 = __webpack_require__(452); exports.uriencode = kbn_client_requester_1.uriencode; /***/ }), -/* 452 */ +/* 451 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -40348,12 +40333,12 @@ exports.uriencode = kbn_client_requester_1.uriencode; * under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); -const kbn_client_requester_1 = __webpack_require__(453); -const kbn_client_status_1 = __webpack_require__(495); -const kbn_client_plugins_1 = __webpack_require__(496); -const kbn_client_version_1 = __webpack_require__(497); -const kbn_client_saved_objects_1 = __webpack_require__(498); -const kbn_client_ui_settings_1 = __webpack_require__(499); +const kbn_client_requester_1 = __webpack_require__(452); +const kbn_client_status_1 = __webpack_require__(494); +const kbn_client_plugins_1 = __webpack_require__(495); +const kbn_client_version_1 = __webpack_require__(496); +const kbn_client_saved_objects_1 = __webpack_require__(497); +const kbn_client_ui_settings_1 = __webpack_require__(498); class KbnClient { /** * Basic Kibana server client that implements common behaviors for talking @@ -40391,7 +40376,7 @@ exports.KbnClient = KbnClient; /***/ }), -/* 453 */ +/* 452 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -40416,9 +40401,9 @@ exports.KbnClient = KbnClient; */ Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = __webpack_require__(36); -const url_1 = tslib_1.__importDefault(__webpack_require__(454)); -const axios_1 = tslib_1.__importDefault(__webpack_require__(455)); -const axios_2 = __webpack_require__(493); +const url_1 = tslib_1.__importDefault(__webpack_require__(453)); +const axios_1 = tslib_1.__importDefault(__webpack_require__(454)); +const axios_2 = __webpack_require__(492); const isConcliftOnGetError = (error) => { return (axios_2.isAxiosResponseError(error) && error.config.method === 'GET' && error.response.status === 409); }; @@ -40502,28 +40487,28 @@ exports.KbnClientRequester = KbnClientRequester; /***/ }), -/* 454 */ +/* 453 */ /***/ (function(module, exports) { module.exports = require("url"); /***/ }), -/* 455 */ +/* 454 */ /***/ (function(module, exports, __webpack_require__) { -module.exports = __webpack_require__(456); +module.exports = __webpack_require__(455); /***/ }), -/* 456 */ +/* 455 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(457); -var bind = __webpack_require__(458); -var Axios = __webpack_require__(460); -var defaults = __webpack_require__(461); +var utils = __webpack_require__(456); +var bind = __webpack_require__(457); +var Axios = __webpack_require__(459); +var defaults = __webpack_require__(460); /** * Create an instance of Axios @@ -40556,15 +40541,15 @@ axios.create = function create(instanceConfig) { }; // Expose Cancel & CancelToken -axios.Cancel = __webpack_require__(490); -axios.CancelToken = __webpack_require__(491); -axios.isCancel = __webpack_require__(487); +axios.Cancel = __webpack_require__(489); +axios.CancelToken = __webpack_require__(490); +axios.isCancel = __webpack_require__(486); // Expose all/spread axios.all = function all(promises) { return Promise.all(promises); }; -axios.spread = __webpack_require__(492); +axios.spread = __webpack_require__(491); module.exports = axios; @@ -40573,14 +40558,14 @@ module.exports.default = axios; /***/ }), -/* 457 */ +/* 456 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var bind = __webpack_require__(458); -var isBuffer = __webpack_require__(459); +var bind = __webpack_require__(457); +var isBuffer = __webpack_require__(458); /*global toString:true*/ @@ -40883,7 +40868,7 @@ module.exports = { /***/ }), -/* 458 */ +/* 457 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -40901,7 +40886,7 @@ module.exports = function bind(fn, thisArg) { /***/ }), -/* 459 */ +/* 458 */ /***/ (function(module, exports) { /*! @@ -40918,16 +40903,16 @@ module.exports = function isBuffer (obj) { /***/ }), -/* 460 */ +/* 459 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var defaults = __webpack_require__(461); -var utils = __webpack_require__(457); -var InterceptorManager = __webpack_require__(484); -var dispatchRequest = __webpack_require__(485); +var defaults = __webpack_require__(460); +var utils = __webpack_require__(456); +var InterceptorManager = __webpack_require__(483); +var dispatchRequest = __webpack_require__(484); /** * Create a new instance of Axios @@ -41004,14 +40989,14 @@ module.exports = Axios; /***/ }), -/* 461 */ +/* 460 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(457); -var normalizeHeaderName = __webpack_require__(462); +var utils = __webpack_require__(456); +var normalizeHeaderName = __webpack_require__(461); var DEFAULT_CONTENT_TYPE = { 'Content-Type': 'application/x-www-form-urlencoded' @@ -41027,10 +41012,10 @@ function getDefaultAdapter() { var adapter; if (typeof XMLHttpRequest !== 'undefined') { // For browsers use XHR adapter - adapter = __webpack_require__(463); + adapter = __webpack_require__(462); } else if (typeof process !== 'undefined') { // For node use HTTP adapter - adapter = __webpack_require__(471); + adapter = __webpack_require__(470); } return adapter; } @@ -41107,13 +41092,13 @@ module.exports = defaults; /***/ }), -/* 462 */ +/* 461 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(457); +var utils = __webpack_require__(456); module.exports = function normalizeHeaderName(headers, normalizedName) { utils.forEach(headers, function processHeader(value, name) { @@ -41126,18 +41111,18 @@ module.exports = function normalizeHeaderName(headers, normalizedName) { /***/ }), -/* 463 */ +/* 462 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(457); -var settle = __webpack_require__(464); -var buildURL = __webpack_require__(467); -var parseHeaders = __webpack_require__(468); -var isURLSameOrigin = __webpack_require__(469); -var createError = __webpack_require__(465); +var utils = __webpack_require__(456); +var settle = __webpack_require__(463); +var buildURL = __webpack_require__(466); +var parseHeaders = __webpack_require__(467); +var isURLSameOrigin = __webpack_require__(468); +var createError = __webpack_require__(464); module.exports = function xhrAdapter(config) { return new Promise(function dispatchXhrRequest(resolve, reject) { @@ -41217,7 +41202,7 @@ module.exports = function xhrAdapter(config) { // This is only done if running in a standard browser environment. // Specifically not if we're in a web worker, or react-native. if (utils.isStandardBrowserEnv()) { - var cookies = __webpack_require__(470); + var cookies = __webpack_require__(469); // Add xsrf header var xsrfValue = (config.withCredentials || isURLSameOrigin(config.url)) && config.xsrfCookieName ? @@ -41295,13 +41280,13 @@ module.exports = function xhrAdapter(config) { /***/ }), -/* 464 */ +/* 463 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var createError = __webpack_require__(465); +var createError = __webpack_require__(464); /** * Resolve or reject a Promise based on response status. @@ -41328,13 +41313,13 @@ module.exports = function settle(resolve, reject, response) { /***/ }), -/* 465 */ +/* 464 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var enhanceError = __webpack_require__(466); +var enhanceError = __webpack_require__(465); /** * Create an Error with the specified message, config, error code, request and response. @@ -41353,7 +41338,7 @@ module.exports = function createError(message, config, code, request, response) /***/ }), -/* 466 */ +/* 465 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -41381,13 +41366,13 @@ module.exports = function enhanceError(error, config, code, request, response) { /***/ }), -/* 467 */ +/* 466 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(457); +var utils = __webpack_require__(456); function encode(val) { return encodeURIComponent(val). @@ -41454,13 +41439,13 @@ module.exports = function buildURL(url, params, paramsSerializer) { /***/ }), -/* 468 */ +/* 467 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(457); +var utils = __webpack_require__(456); // Headers whose duplicates are ignored by node // c.f. https://nodejs.org/api/http.html#http_message_headers @@ -41514,13 +41499,13 @@ module.exports = function parseHeaders(headers) { /***/ }), -/* 469 */ +/* 468 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(457); +var utils = __webpack_require__(456); module.exports = ( utils.isStandardBrowserEnv() ? @@ -41589,13 +41574,13 @@ module.exports = ( /***/ }), -/* 470 */ +/* 469 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(457); +var utils = __webpack_require__(456); module.exports = ( utils.isStandardBrowserEnv() ? @@ -41649,24 +41634,24 @@ module.exports = ( /***/ }), -/* 471 */ +/* 470 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(457); -var settle = __webpack_require__(464); -var buildURL = __webpack_require__(467); -var http = __webpack_require__(472); -var https = __webpack_require__(473); -var httpFollow = __webpack_require__(474).http; -var httpsFollow = __webpack_require__(474).https; -var url = __webpack_require__(454); -var zlib = __webpack_require__(482); -var pkg = __webpack_require__(483); -var createError = __webpack_require__(465); -var enhanceError = __webpack_require__(466); +var utils = __webpack_require__(456); +var settle = __webpack_require__(463); +var buildURL = __webpack_require__(466); +var http = __webpack_require__(471); +var https = __webpack_require__(472); +var httpFollow = __webpack_require__(473).http; +var httpsFollow = __webpack_require__(473).https; +var url = __webpack_require__(453); +var zlib = __webpack_require__(481); +var pkg = __webpack_require__(482); +var createError = __webpack_require__(464); +var enhanceError = __webpack_require__(465); /*eslint consistent-return:0*/ module.exports = function httpAdapter(config) { @@ -41894,27 +41879,27 @@ module.exports = function httpAdapter(config) { /***/ }), -/* 472 */ +/* 471 */ /***/ (function(module, exports) { module.exports = require("http"); /***/ }), -/* 473 */ +/* 472 */ /***/ (function(module, exports) { module.exports = require("https"); /***/ }), -/* 474 */ +/* 473 */ /***/ (function(module, exports, __webpack_require__) { -var url = __webpack_require__(454); -var http = __webpack_require__(472); -var https = __webpack_require__(473); +var url = __webpack_require__(453); +var http = __webpack_require__(471); +var https = __webpack_require__(472); var assert = __webpack_require__(30); var Writable = __webpack_require__(27).Writable; -var debug = __webpack_require__(475)("follow-redirects"); +var debug = __webpack_require__(474)("follow-redirects"); // RFC7231§4.2.1: Of the request methods defined by this specification, // the GET, HEAD, OPTIONS, and TRACE methods are defined to be safe. @@ -42234,7 +42219,7 @@ module.exports.wrap = wrap; /***/ }), -/* 475 */ +/* 474 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -42243,14 +42228,14 @@ module.exports.wrap = wrap; */ if (typeof process === 'undefined' || process.type === 'renderer') { - module.exports = __webpack_require__(476); + module.exports = __webpack_require__(475); } else { - module.exports = __webpack_require__(479); + module.exports = __webpack_require__(478); } /***/ }), -/* 476 */ +/* 475 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -42259,7 +42244,7 @@ if (typeof process === 'undefined' || process.type === 'renderer') { * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(477); +exports = module.exports = __webpack_require__(476); exports.log = log; exports.formatArgs = formatArgs; exports.save = save; @@ -42451,7 +42436,7 @@ function localstorage() { /***/ }), -/* 477 */ +/* 476 */ /***/ (function(module, exports, __webpack_require__) { @@ -42467,7 +42452,7 @@ exports.coerce = coerce; exports.disable = disable; exports.enable = enable; exports.enabled = enabled; -exports.humanize = __webpack_require__(478); +exports.humanize = __webpack_require__(477); /** * Active `debug` instances. @@ -42682,7 +42667,7 @@ function coerce(val) { /***/ }), -/* 478 */ +/* 477 */ /***/ (function(module, exports) { /** @@ -42840,14 +42825,14 @@ function plural(ms, n, name) { /***/ }), -/* 479 */ +/* 478 */ /***/ (function(module, exports, __webpack_require__) { /** * Module dependencies. */ -var tty = __webpack_require__(480); +var tty = __webpack_require__(479); var util = __webpack_require__(29); /** @@ -42856,7 +42841,7 @@ var util = __webpack_require__(29); * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(477); +exports = module.exports = __webpack_require__(476); exports.init = init; exports.log = log; exports.formatArgs = formatArgs; @@ -42871,7 +42856,7 @@ exports.useColors = useColors; exports.colors = [ 6, 2, 3, 4, 5, 1 ]; try { - var supportsColor = __webpack_require__(481); + var supportsColor = __webpack_require__(480); if (supportsColor && supportsColor.level >= 2) { exports.colors = [ 20, 21, 26, 27, 32, 33, 38, 39, 40, 41, 42, 43, 44, 45, 56, 57, 62, 63, 68, @@ -43032,13 +43017,13 @@ exports.enable(load()); /***/ }), -/* 480 */ +/* 479 */ /***/ (function(module, exports) { module.exports = require("tty"); /***/ }), -/* 481 */ +/* 480 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -43183,25 +43168,25 @@ module.exports = { /***/ }), -/* 482 */ +/* 481 */ /***/ (function(module, exports) { module.exports = require("zlib"); /***/ }), -/* 483 */ +/* 482 */ /***/ (function(module) { module.exports = JSON.parse("{\"name\":\"axios\",\"version\":\"0.18.1\",\"description\":\"Promise based HTTP client for the browser and node.js\",\"main\":\"index.js\",\"scripts\":{\"test\":\"grunt test && bundlesize\",\"start\":\"node ./sandbox/server.js\",\"build\":\"NODE_ENV=production grunt build\",\"preversion\":\"npm test\",\"version\":\"npm run build && grunt version && git add -A dist && git add CHANGELOG.md bower.json package.json\",\"postversion\":\"git push && git push --tags\",\"examples\":\"node ./examples/server.js\",\"coveralls\":\"cat coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js\"},\"repository\":{\"type\":\"git\",\"url\":\"https://github.com/axios/axios.git\"},\"keywords\":[\"xhr\",\"http\",\"ajax\",\"promise\",\"node\"],\"author\":\"Matt Zabriskie\",\"license\":\"MIT\",\"bugs\":{\"url\":\"https://github.com/axios/axios/issues\"},\"homepage\":\"https://github.com/axios/axios\",\"devDependencies\":{\"bundlesize\":\"^0.5.7\",\"coveralls\":\"^2.11.9\",\"es6-promise\":\"^4.0.5\",\"grunt\":\"^1.0.1\",\"grunt-banner\":\"^0.6.0\",\"grunt-cli\":\"^1.2.0\",\"grunt-contrib-clean\":\"^1.0.0\",\"grunt-contrib-nodeunit\":\"^1.0.0\",\"grunt-contrib-watch\":\"^1.0.0\",\"grunt-eslint\":\"^19.0.0\",\"grunt-karma\":\"^2.0.0\",\"grunt-ts\":\"^6.0.0-beta.3\",\"grunt-webpack\":\"^1.0.18\",\"istanbul-instrumenter-loader\":\"^1.0.0\",\"jasmine-core\":\"^2.4.1\",\"karma\":\"^1.3.0\",\"karma-chrome-launcher\":\"^2.0.0\",\"karma-coverage\":\"^1.0.0\",\"karma-firefox-launcher\":\"^1.0.0\",\"karma-jasmine\":\"^1.0.2\",\"karma-jasmine-ajax\":\"^0.1.13\",\"karma-opera-launcher\":\"^1.0.0\",\"karma-safari-launcher\":\"^1.0.0\",\"karma-sauce-launcher\":\"^1.1.0\",\"karma-sinon\":\"^1.0.5\",\"karma-sourcemap-loader\":\"^0.3.7\",\"karma-webpack\":\"^1.7.0\",\"load-grunt-tasks\":\"^3.5.2\",\"minimist\":\"^1.2.0\",\"sinon\":\"^1.17.4\",\"webpack\":\"^1.13.1\",\"webpack-dev-server\":\"^1.14.1\",\"url-search-params\":\"^0.6.1\",\"typescript\":\"^2.0.3\"},\"browser\":{\"./lib/adapters/http.js\":\"./lib/adapters/xhr.js\"},\"typings\":\"./index.d.ts\",\"dependencies\":{\"follow-redirects\":\"1.5.10\",\"is-buffer\":\"^2.0.2\"},\"bundlesize\":[{\"path\":\"./dist/axios.min.js\",\"threshold\":\"5kB\"}]}"); /***/ }), -/* 484 */ +/* 483 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(457); +var utils = __webpack_require__(456); function InterceptorManager() { this.handlers = []; @@ -43254,18 +43239,18 @@ module.exports = InterceptorManager; /***/ }), -/* 485 */ +/* 484 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(457); -var transformData = __webpack_require__(486); -var isCancel = __webpack_require__(487); -var defaults = __webpack_require__(461); -var isAbsoluteURL = __webpack_require__(488); -var combineURLs = __webpack_require__(489); +var utils = __webpack_require__(456); +var transformData = __webpack_require__(485); +var isCancel = __webpack_require__(486); +var defaults = __webpack_require__(460); +var isAbsoluteURL = __webpack_require__(487); +var combineURLs = __webpack_require__(488); /** * Throws a `Cancel` if cancellation has been requested. @@ -43347,13 +43332,13 @@ module.exports = function dispatchRequest(config) { /***/ }), -/* 486 */ +/* 485 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(457); +var utils = __webpack_require__(456); /** * Transform the data for a request or a response @@ -43374,7 +43359,7 @@ module.exports = function transformData(data, headers, fns) { /***/ }), -/* 487 */ +/* 486 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -43386,7 +43371,7 @@ module.exports = function isCancel(value) { /***/ }), -/* 488 */ +/* 487 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -43407,7 +43392,7 @@ module.exports = function isAbsoluteURL(url) { /***/ }), -/* 489 */ +/* 488 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -43428,7 +43413,7 @@ module.exports = function combineURLs(baseURL, relativeURL) { /***/ }), -/* 490 */ +/* 489 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -43454,13 +43439,13 @@ module.exports = Cancel; /***/ }), -/* 491 */ +/* 490 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var Cancel = __webpack_require__(490); +var Cancel = __webpack_require__(489); /** * A `CancelToken` is an object that can be used to request cancellation of an operation. @@ -43518,7 +43503,7 @@ module.exports = CancelToken; /***/ }), -/* 492 */ +/* 491 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -43552,7 +43537,7 @@ module.exports = function spread(callback) { /***/ }), -/* 493 */ +/* 492 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -43577,11 +43562,11 @@ module.exports = function spread(callback) { */ Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = __webpack_require__(36); -tslib_1.__exportStar(__webpack_require__(494), exports); +tslib_1.__exportStar(__webpack_require__(493), exports); /***/ }), -/* 494 */ +/* 493 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -43614,7 +43599,7 @@ exports.isAxiosResponseError = (error) => { /***/ }), -/* 495 */ +/* 494 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -43663,7 +43648,7 @@ exports.KbnClientStatus = KbnClientStatus; /***/ }), -/* 496 */ +/* 495 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -43713,7 +43698,7 @@ exports.KbnClientPlugins = KbnClientPlugins; /***/ }), -/* 497 */ +/* 496 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -43754,7 +43739,7 @@ exports.KbnClientVersion = KbnClientVersion; /***/ }), -/* 498 */ +/* 497 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -43778,7 +43763,7 @@ exports.KbnClientVersion = KbnClientVersion; * under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); -const kbn_client_requester_1 = __webpack_require__(453); +const kbn_client_requester_1 = __webpack_require__(452); class KbnClientSavedObjects { constructor(log, requester) { this.log = log; @@ -43863,7 +43848,7 @@ exports.KbnClientSavedObjects = KbnClientSavedObjects; /***/ }), -/* 499 */ +/* 498 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -43887,7 +43872,7 @@ exports.KbnClientSavedObjects = KbnClientSavedObjects; * under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); -const kbn_client_requester_1 = __webpack_require__(453); +const kbn_client_requester_1 = __webpack_require__(452); class KbnClientUiSettings { constructor(log, requester, defaults) { this.log = log; @@ -43963,7 +43948,7 @@ exports.KbnClientUiSettings = KbnClientUiSettings; /***/ }), -/* 500 */ +/* 499 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -44029,7 +44014,7 @@ async function parallelize(items, fn, concurrency = 4) { } /***/ }), -/* 501 */ +/* 500 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -44038,15 +44023,15 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "buildProjectGraph", function() { return buildProjectGraph; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "topologicallyBatchProjects", function() { return topologicallyBatchProjects; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "includeTransitiveProjects", function() { return includeTransitiveProjects; }); -/* harmony import */ var glob__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(502); +/* harmony import */ var glob__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(501); /* harmony import */ var glob__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(glob__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(16); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var util__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(29); /* harmony import */ var util__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(util__WEBPACK_IMPORTED_MODULE_2__); -/* harmony import */ var _errors__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(515); -/* harmony import */ var _project__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(516); -/* harmony import */ var _workspaces__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(578); +/* harmony import */ var _errors__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(514); +/* harmony import */ var _project__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(515); +/* harmony import */ var _workspaces__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(577); /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with @@ -44245,7 +44230,7 @@ function includeTransitiveProjects(subsetOfProjects, allProjects, { } /***/ }), -/* 502 */ +/* 501 */ /***/ (function(module, exports, __webpack_require__) { // Approach: @@ -44291,26 +44276,26 @@ function includeTransitiveProjects(subsetOfProjects, allProjects, { module.exports = glob var fs = __webpack_require__(23) -var rp = __webpack_require__(503) -var minimatch = __webpack_require__(505) +var rp = __webpack_require__(502) +var minimatch = __webpack_require__(504) var Minimatch = minimatch.Minimatch -var inherits = __webpack_require__(509) +var inherits = __webpack_require__(508) var EE = __webpack_require__(379).EventEmitter var path = __webpack_require__(16) var assert = __webpack_require__(30) -var isAbsolute = __webpack_require__(511) -var globSync = __webpack_require__(512) -var common = __webpack_require__(513) +var isAbsolute = __webpack_require__(510) +var globSync = __webpack_require__(511) +var common = __webpack_require__(512) var alphasort = common.alphasort var alphasorti = common.alphasorti var setopts = common.setopts var ownProp = common.ownProp -var inflight = __webpack_require__(514) +var inflight = __webpack_require__(513) var util = __webpack_require__(29) var childrenIgnored = common.childrenIgnored var isIgnored = common.isIgnored -var once = __webpack_require__(385) +var once = __webpack_require__(384) function glob (pattern, options, cb) { if (typeof options === 'function') cb = options, options = {} @@ -45041,7 +45026,7 @@ Glob.prototype._stat2 = function (f, abs, er, stat, cb) { /***/ }), -/* 503 */ +/* 502 */ /***/ (function(module, exports, __webpack_require__) { module.exports = realpath @@ -45057,7 +45042,7 @@ var origRealpathSync = fs.realpathSync var version = process.version var ok = /^v[0-5]\./.test(version) -var old = __webpack_require__(504) +var old = __webpack_require__(503) function newError (er) { return er && er.syscall === 'realpath' && ( @@ -45113,7 +45098,7 @@ function unmonkeypatch () { /***/ }), -/* 504 */ +/* 503 */ /***/ (function(module, exports, __webpack_require__) { // Copyright Joyent, Inc. and other Node contributors. @@ -45422,7 +45407,7 @@ exports.realpath = function realpath(p, cache, cb) { /***/ }), -/* 505 */ +/* 504 */ /***/ (function(module, exports, __webpack_require__) { module.exports = minimatch @@ -45434,7 +45419,7 @@ try { } catch (er) {} var GLOBSTAR = minimatch.GLOBSTAR = Minimatch.GLOBSTAR = {} -var expand = __webpack_require__(506) +var expand = __webpack_require__(505) var plTypes = { '!': { open: '(?:(?!(?:', close: '))[^/]*?)'}, @@ -46351,11 +46336,11 @@ function regExpEscape (s) { /***/ }), -/* 506 */ +/* 505 */ /***/ (function(module, exports, __webpack_require__) { -var concatMap = __webpack_require__(507); -var balanced = __webpack_require__(508); +var concatMap = __webpack_require__(506); +var balanced = __webpack_require__(507); module.exports = expandTop; @@ -46558,7 +46543,7 @@ function expand(str, isTop) { /***/ }), -/* 507 */ +/* 506 */ /***/ (function(module, exports) { module.exports = function (xs, fn) { @@ -46577,7 +46562,7 @@ var isArray = Array.isArray || function (xs) { /***/ }), -/* 508 */ +/* 507 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -46643,7 +46628,7 @@ function range(a, b, str) { /***/ }), -/* 509 */ +/* 508 */ /***/ (function(module, exports, __webpack_require__) { try { @@ -46653,12 +46638,12 @@ try { module.exports = util.inherits; } catch (e) { /* istanbul ignore next */ - module.exports = __webpack_require__(510); + module.exports = __webpack_require__(509); } /***/ }), -/* 510 */ +/* 509 */ /***/ (function(module, exports) { if (typeof Object.create === 'function') { @@ -46691,7 +46676,7 @@ if (typeof Object.create === 'function') { /***/ }), -/* 511 */ +/* 510 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -46718,22 +46703,22 @@ module.exports.win32 = win32; /***/ }), -/* 512 */ +/* 511 */ /***/ (function(module, exports, __webpack_require__) { module.exports = globSync globSync.GlobSync = GlobSync var fs = __webpack_require__(23) -var rp = __webpack_require__(503) -var minimatch = __webpack_require__(505) +var rp = __webpack_require__(502) +var minimatch = __webpack_require__(504) var Minimatch = minimatch.Minimatch -var Glob = __webpack_require__(502).Glob +var Glob = __webpack_require__(501).Glob var util = __webpack_require__(29) var path = __webpack_require__(16) var assert = __webpack_require__(30) -var isAbsolute = __webpack_require__(511) -var common = __webpack_require__(513) +var isAbsolute = __webpack_require__(510) +var common = __webpack_require__(512) var alphasort = common.alphasort var alphasorti = common.alphasorti var setopts = common.setopts @@ -47210,7 +47195,7 @@ GlobSync.prototype._makeAbs = function (f) { /***/ }), -/* 513 */ +/* 512 */ /***/ (function(module, exports, __webpack_require__) { exports.alphasort = alphasort @@ -47228,8 +47213,8 @@ function ownProp (obj, field) { } var path = __webpack_require__(16) -var minimatch = __webpack_require__(505) -var isAbsolute = __webpack_require__(511) +var minimatch = __webpack_require__(504) +var isAbsolute = __webpack_require__(510) var Minimatch = minimatch.Minimatch function alphasorti (a, b) { @@ -47456,12 +47441,12 @@ function childrenIgnored (self, path) { /***/ }), -/* 514 */ +/* 513 */ /***/ (function(module, exports, __webpack_require__) { -var wrappy = __webpack_require__(386) +var wrappy = __webpack_require__(385) var reqs = Object.create(null) -var once = __webpack_require__(385) +var once = __webpack_require__(384) module.exports = wrappy(inflight) @@ -47516,7 +47501,7 @@ function slice (args) { /***/ }), -/* 515 */ +/* 514 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -47549,7 +47534,7 @@ class CliError extends Error { } /***/ }), -/* 516 */ +/* 515 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -47563,10 +47548,10 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var util__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(29); /* harmony import */ var util__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(util__WEBPACK_IMPORTED_MODULE_3__); -/* harmony import */ var _errors__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(515); +/* harmony import */ var _errors__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(514); /* harmony import */ var _log__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(34); -/* harmony import */ var _package_json__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(517); -/* harmony import */ var _scripts__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(563); +/* harmony import */ var _package_json__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(516); +/* harmony import */ var _scripts__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(562); 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(source, true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(source).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } @@ -47797,7 +47782,7 @@ function normalizePath(path) { } /***/ }), -/* 517 */ +/* 516 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -47805,9 +47790,9 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "readPackageJson", function() { return readPackageJson; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "writePackageJson", function() { return writePackageJson; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "isLinkDependency", function() { return isLinkDependency; }); -/* harmony import */ var read_pkg__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(518); +/* harmony import */ var read_pkg__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(517); /* harmony import */ var read_pkg__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(read_pkg__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var write_pkg__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(544); +/* harmony import */ var write_pkg__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(543); /* harmony import */ var write_pkg__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(write_pkg__WEBPACK_IMPORTED_MODULE_1__); /* * Licensed to Elasticsearch B.V. under one or more contributor @@ -47841,7 +47826,7 @@ function writePackageJson(path, json) { const isLinkDependency = depVersion => depVersion.startsWith('link:'); /***/ }), -/* 518 */ +/* 517 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -47849,7 +47834,7 @@ const isLinkDependency = depVersion => depVersion.startsWith('link:'); const {promisify} = __webpack_require__(29); const fs = __webpack_require__(23); const path = __webpack_require__(16); -const parseJson = __webpack_require__(519); +const parseJson = __webpack_require__(518); const readFileAsync = promisify(fs.readFile); @@ -47864,7 +47849,7 @@ module.exports = async options => { const json = parseJson(await readFileAsync(filePath, 'utf8')); if (options.normalize) { - __webpack_require__(520)(json); + __webpack_require__(519)(json); } return json; @@ -47881,7 +47866,7 @@ module.exports.sync = options => { const json = parseJson(fs.readFileSync(filePath, 'utf8')); if (options.normalize) { - __webpack_require__(520)(json); + __webpack_require__(519)(json); } return json; @@ -47889,15 +47874,15 @@ module.exports.sync = options => { /***/ }), -/* 519 */ +/* 518 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const errorEx = __webpack_require__(430); -const fallback = __webpack_require__(432); -const {default: LinesAndColumns} = __webpack_require__(433); -const {codeFrameColumns} = __webpack_require__(434); +const errorEx = __webpack_require__(429); +const fallback = __webpack_require__(431); +const {default: LinesAndColumns} = __webpack_require__(432); +const {codeFrameColumns} = __webpack_require__(433); const JSONError = errorEx('JSONError', { fileName: errorEx.append('in %s'), @@ -47946,15 +47931,15 @@ module.exports = (string, reviver, filename) => { /***/ }), -/* 520 */ +/* 519 */ /***/ (function(module, exports, __webpack_require__) { module.exports = normalize -var fixer = __webpack_require__(521) +var fixer = __webpack_require__(520) normalize.fixer = fixer -var makeWarning = __webpack_require__(542) +var makeWarning = __webpack_require__(541) var fieldsToFix = ['name','version','description','repository','modules','scripts' ,'files','bin','man','bugs','keywords','readme','homepage','license'] @@ -47991,17 +47976,17 @@ function ucFirst (string) { /***/ }), -/* 521 */ +/* 520 */ /***/ (function(module, exports, __webpack_require__) { -var semver = __webpack_require__(522) -var validateLicense = __webpack_require__(523); -var hostedGitInfo = __webpack_require__(528) -var isBuiltinModule = __webpack_require__(531).isCore +var semver = __webpack_require__(521) +var validateLicense = __webpack_require__(522); +var hostedGitInfo = __webpack_require__(527) +var isBuiltinModule = __webpack_require__(530).isCore var depTypes = ["dependencies","devDependencies","optionalDependencies"] -var extractDescription = __webpack_require__(540) -var url = __webpack_require__(454) -var typos = __webpack_require__(541) +var extractDescription = __webpack_require__(539) +var url = __webpack_require__(453) +var typos = __webpack_require__(540) var fixer = module.exports = { // default warning function @@ -48415,7 +48400,7 @@ function bugsTypos(bugs, warn) { /***/ }), -/* 522 */ +/* 521 */ /***/ (function(module, exports) { exports = module.exports = SemVer @@ -49904,11 +49889,11 @@ function coerce (version) { /***/ }), -/* 523 */ +/* 522 */ /***/ (function(module, exports, __webpack_require__) { -var parse = __webpack_require__(524); -var correct = __webpack_require__(526); +var parse = __webpack_require__(523); +var correct = __webpack_require__(525); var genericWarning = ( 'license should be ' + @@ -49994,10 +49979,10 @@ module.exports = function(argument) { /***/ }), -/* 524 */ +/* 523 */ /***/ (function(module, exports, __webpack_require__) { -var parser = __webpack_require__(525).parser +var parser = __webpack_require__(524).parser module.exports = function (argument) { return parser.parse(argument) @@ -50005,7 +49990,7 @@ module.exports = function (argument) { /***/ }), -/* 525 */ +/* 524 */ /***/ (function(module, exports, __webpack_require__) { /* WEBPACK VAR INJECTION */(function(module) {/* parser generated by jison 0.4.17 */ @@ -51369,10 +51354,10 @@ if ( true && __webpack_require__.c[__webpack_require__.s] === module) { /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(5)(module))) /***/ }), -/* 526 */ +/* 525 */ /***/ (function(module, exports, __webpack_require__) { -var licenseIDs = __webpack_require__(527); +var licenseIDs = __webpack_require__(526); function valid(string) { return licenseIDs.indexOf(string) > -1; @@ -51612,20 +51597,20 @@ module.exports = function(identifier) { /***/ }), -/* 527 */ +/* 526 */ /***/ (function(module) { module.exports = JSON.parse("[\"Glide\",\"Abstyles\",\"AFL-1.1\",\"AFL-1.2\",\"AFL-2.0\",\"AFL-2.1\",\"AFL-3.0\",\"AMPAS\",\"APL-1.0\",\"Adobe-Glyph\",\"APAFML\",\"Adobe-2006\",\"AGPL-1.0\",\"Afmparse\",\"Aladdin\",\"ADSL\",\"AMDPLPA\",\"ANTLR-PD\",\"Apache-1.0\",\"Apache-1.1\",\"Apache-2.0\",\"AML\",\"APSL-1.0\",\"APSL-1.1\",\"APSL-1.2\",\"APSL-2.0\",\"Artistic-1.0\",\"Artistic-1.0-Perl\",\"Artistic-1.0-cl8\",\"Artistic-2.0\",\"AAL\",\"Bahyph\",\"Barr\",\"Beerware\",\"BitTorrent-1.0\",\"BitTorrent-1.1\",\"BSL-1.0\",\"Borceux\",\"BSD-2-Clause\",\"BSD-2-Clause-FreeBSD\",\"BSD-2-Clause-NetBSD\",\"BSD-3-Clause\",\"BSD-3-Clause-Clear\",\"BSD-4-Clause\",\"BSD-Protection\",\"BSD-Source-Code\",\"BSD-3-Clause-Attribution\",\"0BSD\",\"BSD-4-Clause-UC\",\"bzip2-1.0.5\",\"bzip2-1.0.6\",\"Caldera\",\"CECILL-1.0\",\"CECILL-1.1\",\"CECILL-2.0\",\"CECILL-2.1\",\"CECILL-B\",\"CECILL-C\",\"ClArtistic\",\"MIT-CMU\",\"CNRI-Jython\",\"CNRI-Python\",\"CNRI-Python-GPL-Compatible\",\"CPOL-1.02\",\"CDDL-1.0\",\"CDDL-1.1\",\"CPAL-1.0\",\"CPL-1.0\",\"CATOSL-1.1\",\"Condor-1.1\",\"CC-BY-1.0\",\"CC-BY-2.0\",\"CC-BY-2.5\",\"CC-BY-3.0\",\"CC-BY-4.0\",\"CC-BY-ND-1.0\",\"CC-BY-ND-2.0\",\"CC-BY-ND-2.5\",\"CC-BY-ND-3.0\",\"CC-BY-ND-4.0\",\"CC-BY-NC-1.0\",\"CC-BY-NC-2.0\",\"CC-BY-NC-2.5\",\"CC-BY-NC-3.0\",\"CC-BY-NC-4.0\",\"CC-BY-NC-ND-1.0\",\"CC-BY-NC-ND-2.0\",\"CC-BY-NC-ND-2.5\",\"CC-BY-NC-ND-3.0\",\"CC-BY-NC-ND-4.0\",\"CC-BY-NC-SA-1.0\",\"CC-BY-NC-SA-2.0\",\"CC-BY-NC-SA-2.5\",\"CC-BY-NC-SA-3.0\",\"CC-BY-NC-SA-4.0\",\"CC-BY-SA-1.0\",\"CC-BY-SA-2.0\",\"CC-BY-SA-2.5\",\"CC-BY-SA-3.0\",\"CC-BY-SA-4.0\",\"CC0-1.0\",\"Crossword\",\"CrystalStacker\",\"CUA-OPL-1.0\",\"Cube\",\"curl\",\"D-FSL-1.0\",\"diffmark\",\"WTFPL\",\"DOC\",\"Dotseqn\",\"DSDP\",\"dvipdfm\",\"EPL-1.0\",\"ECL-1.0\",\"ECL-2.0\",\"eGenix\",\"EFL-1.0\",\"EFL-2.0\",\"MIT-advertising\",\"MIT-enna\",\"Entessa\",\"ErlPL-1.1\",\"EUDatagrid\",\"EUPL-1.0\",\"EUPL-1.1\",\"Eurosym\",\"Fair\",\"MIT-feh\",\"Frameworx-1.0\",\"FreeImage\",\"FTL\",\"FSFAP\",\"FSFUL\",\"FSFULLR\",\"Giftware\",\"GL2PS\",\"Glulxe\",\"AGPL-3.0\",\"GFDL-1.1\",\"GFDL-1.2\",\"GFDL-1.3\",\"GPL-1.0\",\"GPL-2.0\",\"GPL-3.0\",\"LGPL-2.1\",\"LGPL-3.0\",\"LGPL-2.0\",\"gnuplot\",\"gSOAP-1.3b\",\"HaskellReport\",\"HPND\",\"IBM-pibs\",\"IPL-1.0\",\"ICU\",\"ImageMagick\",\"iMatix\",\"Imlib2\",\"IJG\",\"Info-ZIP\",\"Intel-ACPI\",\"Intel\",\"Interbase-1.0\",\"IPA\",\"ISC\",\"JasPer-2.0\",\"JSON\",\"LPPL-1.0\",\"LPPL-1.1\",\"LPPL-1.2\",\"LPPL-1.3a\",\"LPPL-1.3c\",\"Latex2e\",\"BSD-3-Clause-LBNL\",\"Leptonica\",\"LGPLLR\",\"Libpng\",\"libtiff\",\"LAL-1.2\",\"LAL-1.3\",\"LiLiQ-P-1.1\",\"LiLiQ-Rplus-1.1\",\"LiLiQ-R-1.1\",\"LPL-1.02\",\"LPL-1.0\",\"MakeIndex\",\"MTLL\",\"MS-PL\",\"MS-RL\",\"MirOS\",\"MITNFA\",\"MIT\",\"Motosoto\",\"MPL-1.0\",\"MPL-1.1\",\"MPL-2.0\",\"MPL-2.0-no-copyleft-exception\",\"mpich2\",\"Multics\",\"Mup\",\"NASA-1.3\",\"Naumen\",\"NBPL-1.0\",\"NetCDF\",\"NGPL\",\"NOSL\",\"NPL-1.0\",\"NPL-1.1\",\"Newsletr\",\"NLPL\",\"Nokia\",\"NPOSL-3.0\",\"NLOD-1.0\",\"Noweb\",\"NRL\",\"NTP\",\"Nunit\",\"OCLC-2.0\",\"ODbL-1.0\",\"PDDL-1.0\",\"OCCT-PL\",\"OGTSL\",\"OLDAP-2.2.2\",\"OLDAP-1.1\",\"OLDAP-1.2\",\"OLDAP-1.3\",\"OLDAP-1.4\",\"OLDAP-2.0\",\"OLDAP-2.0.1\",\"OLDAP-2.1\",\"OLDAP-2.2\",\"OLDAP-2.2.1\",\"OLDAP-2.3\",\"OLDAP-2.4\",\"OLDAP-2.5\",\"OLDAP-2.6\",\"OLDAP-2.7\",\"OLDAP-2.8\",\"OML\",\"OPL-1.0\",\"OSL-1.0\",\"OSL-1.1\",\"OSL-2.0\",\"OSL-2.1\",\"OSL-3.0\",\"OpenSSL\",\"OSET-PL-2.1\",\"PHP-3.0\",\"PHP-3.01\",\"Plexus\",\"PostgreSQL\",\"psfrag\",\"psutils\",\"Python-2.0\",\"QPL-1.0\",\"Qhull\",\"Rdisc\",\"RPSL-1.0\",\"RPL-1.1\",\"RPL-1.5\",\"RHeCos-1.1\",\"RSCPL\",\"RSA-MD\",\"Ruby\",\"SAX-PD\",\"Saxpath\",\"SCEA\",\"SWL\",\"SMPPL\",\"Sendmail\",\"SGI-B-1.0\",\"SGI-B-1.1\",\"SGI-B-2.0\",\"OFL-1.0\",\"OFL-1.1\",\"SimPL-2.0\",\"Sleepycat\",\"SNIA\",\"Spencer-86\",\"Spencer-94\",\"Spencer-99\",\"SMLNJ\",\"SugarCRM-1.1.3\",\"SISSL\",\"SISSL-1.2\",\"SPL-1.0\",\"Watcom-1.0\",\"TCL\",\"Unlicense\",\"TMate\",\"TORQUE-1.1\",\"TOSL\",\"Unicode-TOU\",\"UPL-1.0\",\"NCSA\",\"Vim\",\"VOSTROM\",\"VSL-1.0\",\"W3C-19980720\",\"W3C\",\"Wsuipa\",\"Xnet\",\"X11\",\"Xerox\",\"XFree86-1.1\",\"xinetd\",\"xpp\",\"XSkat\",\"YPL-1.0\",\"YPL-1.1\",\"Zed\",\"Zend-2.0\",\"Zimbra-1.3\",\"Zimbra-1.4\",\"Zlib\",\"zlib-acknowledgement\",\"ZPL-1.1\",\"ZPL-2.0\",\"ZPL-2.1\",\"BSD-3-Clause-No-Nuclear-License\",\"BSD-3-Clause-No-Nuclear-Warranty\",\"BSD-3-Clause-No-Nuclear-License-2014\",\"eCos-2.0\",\"GPL-2.0-with-autoconf-exception\",\"GPL-2.0-with-bison-exception\",\"GPL-2.0-with-classpath-exception\",\"GPL-2.0-with-font-exception\",\"GPL-2.0-with-GCC-exception\",\"GPL-3.0-with-autoconf-exception\",\"GPL-3.0-with-GCC-exception\",\"StandardML-NJ\",\"WXwindows\"]"); /***/ }), -/* 528 */ +/* 527 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var url = __webpack_require__(454) -var gitHosts = __webpack_require__(529) -var GitHost = module.exports = __webpack_require__(530) +var url = __webpack_require__(453) +var gitHosts = __webpack_require__(528) +var GitHost = module.exports = __webpack_require__(529) var protocolToRepresentationMap = { 'git+ssh': 'sshurl', @@ -51746,7 +51731,7 @@ function parseGitUrl (giturl) { /***/ }), -/* 529 */ +/* 528 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -51821,12 +51806,12 @@ Object.keys(gitHosts).forEach(function (name) { /***/ }), -/* 530 */ +/* 529 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var gitHosts = __webpack_require__(529) +var gitHosts = __webpack_require__(528) var extend = Object.assign || __webpack_require__(29)._extend var GitHost = module.exports = function (type, user, auth, project, committish, defaultRepresentation, opts) { @@ -51942,21 +51927,21 @@ GitHost.prototype.toString = function (opts) { /***/ }), -/* 531 */ +/* 530 */ /***/ (function(module, exports, __webpack_require__) { -var core = __webpack_require__(532); -var async = __webpack_require__(534); +var core = __webpack_require__(531); +var async = __webpack_require__(533); async.core = core; async.isCore = function isCore(x) { return core[x]; }; -async.sync = __webpack_require__(539); +async.sync = __webpack_require__(538); exports = async; module.exports = async; /***/ }), -/* 532 */ +/* 531 */ /***/ (function(module, exports, __webpack_require__) { var current = (process.versions && process.versions.node && process.versions.node.split('.')) || []; @@ -52003,7 +51988,7 @@ function versionIncluded(specifierValue) { return matchesRange(specifierValue); } -var data = __webpack_require__(533); +var data = __webpack_require__(532); var core = {}; for (var mod in data) { // eslint-disable-line no-restricted-syntax @@ -52015,21 +52000,21 @@ module.exports = core; /***/ }), -/* 533 */ +/* 532 */ /***/ (function(module) { module.exports = JSON.parse("{\"assert\":true,\"async_hooks\":\">= 8\",\"buffer_ieee754\":\"< 0.9.7\",\"buffer\":true,\"child_process\":true,\"cluster\":true,\"console\":true,\"constants\":true,\"crypto\":true,\"_debugger\":\"< 8\",\"dgram\":true,\"dns\":true,\"domain\":true,\"events\":true,\"freelist\":\"< 6\",\"fs\":true,\"fs/promises\":\">= 10 && < 10.1\",\"_http_agent\":\">= 0.11.1\",\"_http_client\":\">= 0.11.1\",\"_http_common\":\">= 0.11.1\",\"_http_incoming\":\">= 0.11.1\",\"_http_outgoing\":\">= 0.11.1\",\"_http_server\":\">= 0.11.1\",\"http\":true,\"http2\":\">= 8.8\",\"https\":true,\"inspector\":\">= 8.0.0\",\"_linklist\":\"< 8\",\"module\":true,\"net\":true,\"node-inspect/lib/_inspect\":\">= 7.6.0\",\"node-inspect/lib/internal/inspect_client\":\">= 7.6.0\",\"node-inspect/lib/internal/inspect_repl\":\">= 7.6.0\",\"os\":true,\"path\":true,\"perf_hooks\":\">= 8.5\",\"process\":\">= 1\",\"punycode\":true,\"querystring\":true,\"readline\":true,\"repl\":true,\"smalloc\":\">= 0.11.5 && < 3\",\"_stream_duplex\":\">= 0.9.4\",\"_stream_transform\":\">= 0.9.4\",\"_stream_wrap\":\">= 1.4.1\",\"_stream_passthrough\":\">= 0.9.4\",\"_stream_readable\":\">= 0.9.4\",\"_stream_writable\":\">= 0.9.4\",\"stream\":true,\"string_decoder\":true,\"sys\":true,\"timers\":true,\"_tls_common\":\">= 0.11.13\",\"_tls_legacy\":\">= 0.11.3 && < 10\",\"_tls_wrap\":\">= 0.11.3\",\"tls\":true,\"trace_events\":\">= 10\",\"tty\":true,\"url\":true,\"util\":true,\"v8/tools/arguments\":\">= 10\",\"v8/tools/codemap\":[\">= 4.4.0 && < 5\",\">= 5.2.0\"],\"v8/tools/consarray\":[\">= 4.4.0 && < 5\",\">= 5.2.0\"],\"v8/tools/csvparser\":[\">= 4.4.0 && < 5\",\">= 5.2.0\"],\"v8/tools/logreader\":[\">= 4.4.0 && < 5\",\">= 5.2.0\"],\"v8/tools/profile_view\":[\">= 4.4.0 && < 5\",\">= 5.2.0\"],\"v8/tools/splaytree\":[\">= 4.4.0 && < 5\",\">= 5.2.0\"],\"v8\":\">= 1\",\"vm\":true,\"worker_threads\":\">= 11.7\",\"zlib\":true}"); /***/ }), -/* 534 */ +/* 533 */ /***/ (function(module, exports, __webpack_require__) { -var core = __webpack_require__(532); +var core = __webpack_require__(531); var fs = __webpack_require__(23); var path = __webpack_require__(16); -var caller = __webpack_require__(535); -var nodeModulesPaths = __webpack_require__(536); -var normalizeOptions = __webpack_require__(538); +var caller = __webpack_require__(534); +var nodeModulesPaths = __webpack_require__(535); +var normalizeOptions = __webpack_require__(537); var defaultIsFile = function isFile(file, cb) { fs.stat(file, function (err, stat) { @@ -52256,7 +52241,7 @@ module.exports = function resolve(x, options, callback) { /***/ }), -/* 535 */ +/* 534 */ /***/ (function(module, exports) { module.exports = function () { @@ -52270,11 +52255,11 @@ module.exports = function () { /***/ }), -/* 536 */ +/* 535 */ /***/ (function(module, exports, __webpack_require__) { var path = __webpack_require__(16); -var parse = path.parse || __webpack_require__(537); +var parse = path.parse || __webpack_require__(536); var getNodeModulesDirs = function getNodeModulesDirs(absoluteStart, modules) { var prefix = '/'; @@ -52318,7 +52303,7 @@ module.exports = function nodeModulesPaths(start, opts, request) { /***/ }), -/* 537 */ +/* 536 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -52418,7 +52403,7 @@ module.exports.win32 = win32.parse; /***/ }), -/* 538 */ +/* 537 */ /***/ (function(module, exports) { module.exports = function (x, opts) { @@ -52434,15 +52419,15 @@ module.exports = function (x, opts) { /***/ }), -/* 539 */ +/* 538 */ /***/ (function(module, exports, __webpack_require__) { -var core = __webpack_require__(532); +var core = __webpack_require__(531); var fs = __webpack_require__(23); var path = __webpack_require__(16); -var caller = __webpack_require__(535); -var nodeModulesPaths = __webpack_require__(536); -var normalizeOptions = __webpack_require__(538); +var caller = __webpack_require__(534); +var nodeModulesPaths = __webpack_require__(535); +var normalizeOptions = __webpack_require__(537); var defaultIsFile = function isFile(file) { try { @@ -52594,7 +52579,7 @@ module.exports = function (x, options) { /***/ }), -/* 540 */ +/* 539 */ /***/ (function(module, exports) { module.exports = extractDescription @@ -52614,17 +52599,17 @@ function extractDescription (d) { /***/ }), -/* 541 */ +/* 540 */ /***/ (function(module) { module.exports = JSON.parse("{\"topLevel\":{\"dependancies\":\"dependencies\",\"dependecies\":\"dependencies\",\"depdenencies\":\"dependencies\",\"devEependencies\":\"devDependencies\",\"depends\":\"dependencies\",\"dev-dependencies\":\"devDependencies\",\"devDependences\":\"devDependencies\",\"devDepenencies\":\"devDependencies\",\"devdependencies\":\"devDependencies\",\"repostitory\":\"repository\",\"repo\":\"repository\",\"prefereGlobal\":\"preferGlobal\",\"hompage\":\"homepage\",\"hampage\":\"homepage\",\"autohr\":\"author\",\"autor\":\"author\",\"contributers\":\"contributors\",\"publicationConfig\":\"publishConfig\",\"script\":\"scripts\"},\"bugs\":{\"web\":\"url\",\"name\":\"url\"},\"script\":{\"server\":\"start\",\"tests\":\"test\"}}"); /***/ }), -/* 542 */ +/* 541 */ /***/ (function(module, exports, __webpack_require__) { var util = __webpack_require__(29) -var messages = __webpack_require__(543) +var messages = __webpack_require__(542) module.exports = function() { var args = Array.prototype.slice.call(arguments, 0) @@ -52649,20 +52634,20 @@ function makeTypoWarning (providedName, probableName, field) { /***/ }), -/* 543 */ +/* 542 */ /***/ (function(module) { module.exports = JSON.parse("{\"repositories\":\"'repositories' (plural) Not supported. Please pick one as the 'repository' field\",\"missingRepository\":\"No repository field.\",\"brokenGitUrl\":\"Probably broken git url: %s\",\"nonObjectScripts\":\"scripts must be an object\",\"nonStringScript\":\"script values must be string commands\",\"nonArrayFiles\":\"Invalid 'files' member\",\"invalidFilename\":\"Invalid filename in 'files' list: %s\",\"nonArrayBundleDependencies\":\"Invalid 'bundleDependencies' list. Must be array of package names\",\"nonStringBundleDependency\":\"Invalid bundleDependencies member: %s\",\"nonDependencyBundleDependency\":\"Non-dependency in bundleDependencies: %s\",\"nonObjectDependencies\":\"%s field must be an object\",\"nonStringDependency\":\"Invalid dependency: %s %s\",\"deprecatedArrayDependencies\":\"specifying %s as array is deprecated\",\"deprecatedModules\":\"modules field is deprecated\",\"nonArrayKeywords\":\"keywords should be an array of strings\",\"nonStringKeyword\":\"keywords should be an array of strings\",\"conflictingName\":\"%s is also the name of a node core module.\",\"nonStringDescription\":\"'description' field should be a string\",\"missingDescription\":\"No description\",\"missingReadme\":\"No README data\",\"missingLicense\":\"No license field.\",\"nonEmailUrlBugsString\":\"Bug string field must be url, email, or {email,url}\",\"nonUrlBugsUrlField\":\"bugs.url field must be a string url. Deleted.\",\"nonEmailBugsEmailField\":\"bugs.email field must be a string email. Deleted.\",\"emptyNormalizedBugs\":\"Normalized value of bugs field is an empty object. Deleted.\",\"nonUrlHomepage\":\"homepage field must be a string url. Deleted.\",\"invalidLicense\":\"license should be a valid SPDX license expression\",\"typo\":\"%s should probably be %s.\"}"); /***/ }), -/* 544 */ +/* 543 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(16); -const writeJsonFile = __webpack_require__(545); -const sortKeys = __webpack_require__(557); +const writeJsonFile = __webpack_require__(544); +const sortKeys = __webpack_require__(556); const dependencyKeys = new Set([ 'dependencies', @@ -52727,18 +52712,18 @@ module.exports.sync = (filePath, data, options) => { /***/ }), -/* 545 */ +/* 544 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(16); -const fs = __webpack_require__(546); -const writeFileAtomic = __webpack_require__(550); -const sortKeys = __webpack_require__(557); -const makeDir = __webpack_require__(559); -const pify = __webpack_require__(561); -const detectIndent = __webpack_require__(562); +const fs = __webpack_require__(545); +const writeFileAtomic = __webpack_require__(549); +const sortKeys = __webpack_require__(556); +const makeDir = __webpack_require__(558); +const pify = __webpack_require__(560); +const detectIndent = __webpack_require__(561); const init = (fn, filePath, data, options) => { if (!filePath) { @@ -52810,13 +52795,13 @@ module.exports.sync = (filePath, data, options) => { /***/ }), -/* 546 */ +/* 545 */ /***/ (function(module, exports, __webpack_require__) { var fs = __webpack_require__(23) -var polyfills = __webpack_require__(547) -var legacy = __webpack_require__(548) -var clone = __webpack_require__(549) +var polyfills = __webpack_require__(546) +var legacy = __webpack_require__(547) +var clone = __webpack_require__(548) var queue = [] @@ -53095,7 +53080,7 @@ function retry () { /***/ }), -/* 547 */ +/* 546 */ /***/ (function(module, exports, __webpack_require__) { var constants = __webpack_require__(25) @@ -53430,7 +53415,7 @@ function patch (fs) { /***/ }), -/* 548 */ +/* 547 */ /***/ (function(module, exports, __webpack_require__) { var Stream = __webpack_require__(27).Stream @@ -53554,7 +53539,7 @@ function legacy (fs) { /***/ }), -/* 549 */ +/* 548 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -53580,7 +53565,7 @@ function clone (obj) { /***/ }), -/* 550 */ +/* 549 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -53590,8 +53575,8 @@ module.exports.sync = writeFileSync module.exports._getTmpname = getTmpname // for testing module.exports._cleanupOnExit = cleanupOnExit -var fs = __webpack_require__(551) -var MurmurHash3 = __webpack_require__(555) +var fs = __webpack_require__(550) +var MurmurHash3 = __webpack_require__(554) var onExit = __webpack_require__(377) var path = __webpack_require__(16) var activeFiles = {} @@ -53600,7 +53585,7 @@ var activeFiles = {} /* istanbul ignore next */ var threadId = (function getId () { try { - var workerThreads = __webpack_require__(556) + var workerThreads = __webpack_require__(555) /// if we are in main thread, this is set to `0` return workerThreads.threadId @@ -53825,12 +53810,12 @@ function writeFileSync (filename, data, options) { /***/ }), -/* 551 */ +/* 550 */ /***/ (function(module, exports, __webpack_require__) { var fs = __webpack_require__(23) -var polyfills = __webpack_require__(552) -var legacy = __webpack_require__(554) +var polyfills = __webpack_require__(551) +var legacy = __webpack_require__(553) var queue = [] var util = __webpack_require__(29) @@ -53854,7 +53839,7 @@ if (/\bgfs4\b/i.test(process.env.NODE_DEBUG || '')) { }) } -module.exports = patch(__webpack_require__(553)) +module.exports = patch(__webpack_require__(552)) if (process.env.TEST_GRACEFUL_FS_GLOBAL_PATCH) { module.exports = patch(fs) } @@ -54093,10 +54078,10 @@ function retry () { /***/ }), -/* 552 */ +/* 551 */ /***/ (function(module, exports, __webpack_require__) { -var fs = __webpack_require__(553) +var fs = __webpack_require__(552) var constants = __webpack_require__(25) var origCwd = process.cwd @@ -54429,7 +54414,7 @@ function chownErOk (er) { /***/ }), -/* 553 */ +/* 552 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -54457,7 +54442,7 @@ function clone (obj) { /***/ }), -/* 554 */ +/* 553 */ /***/ (function(module, exports, __webpack_require__) { var Stream = __webpack_require__(27).Stream @@ -54581,7 +54566,7 @@ function legacy (fs) { /***/ }), -/* 555 */ +/* 554 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -54723,18 +54708,18 @@ function legacy (fs) { /***/ }), -/* 556 */ +/* 555 */ /***/ (function(module, exports) { module.exports = require(undefined); /***/ }), -/* 557 */ +/* 556 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const isPlainObj = __webpack_require__(558); +const isPlainObj = __webpack_require__(557); module.exports = (obj, opts) => { if (!isPlainObj(obj)) { @@ -54791,7 +54776,7 @@ module.exports = (obj, opts) => { /***/ }), -/* 558 */ +/* 557 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -54805,15 +54790,15 @@ module.exports = function (x) { /***/ }), -/* 559 */ +/* 558 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(23); const path = __webpack_require__(16); -const pify = __webpack_require__(560); -const semver = __webpack_require__(522); +const pify = __webpack_require__(559); +const semver = __webpack_require__(521); const defaults = { mode: 0o777 & (~process.umask()), @@ -54951,7 +54936,7 @@ module.exports.sync = (input, options) => { /***/ }), -/* 560 */ +/* 559 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -55026,7 +55011,7 @@ module.exports = (input, options) => { /***/ }), -/* 561 */ +/* 560 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -55101,7 +55086,7 @@ module.exports = (input, options) => { /***/ }), -/* 562 */ +/* 561 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -55230,7 +55215,7 @@ module.exports = str => { /***/ }), -/* 563 */ +/* 562 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55239,7 +55224,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "runScriptInPackage", function() { return runScriptInPackage; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "runScriptInPackageStreaming", function() { return runScriptInPackageStreaming; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "yarnWorkspacesInfo", function() { return yarnWorkspacesInfo; }); -/* harmony import */ var _child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(564); +/* harmony import */ var _child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(563); /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with @@ -55309,7 +55294,7 @@ async function yarnWorkspacesInfo(directory) { } /***/ }), -/* 564 */ +/* 563 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55320,9 +55305,9 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(chalk__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var execa__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(351); /* harmony import */ var execa__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(execa__WEBPACK_IMPORTED_MODULE_1__); -/* harmony import */ var log_symbols__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(565); +/* harmony import */ var log_symbols__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(564); /* harmony import */ var log_symbols__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(log_symbols__WEBPACK_IMPORTED_MODULE_2__); -/* harmony import */ var strong_log_transformer__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(570); +/* harmony import */ var strong_log_transformer__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(569); /* harmony import */ var strong_log_transformer__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(strong_log_transformer__WEBPACK_IMPORTED_MODULE_3__); 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; } @@ -55388,12 +55373,12 @@ function spawnStreaming(command, args, opts, { } /***/ }), -/* 565 */ +/* 564 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const chalk = __webpack_require__(566); +const chalk = __webpack_require__(565); const isSupported = process.platform !== 'win32' || process.env.CI || process.env.TERM === 'xterm-256color'; @@ -55415,16 +55400,16 @@ module.exports = isSupported ? main : fallbacks; /***/ }), -/* 566 */ +/* 565 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const escapeStringRegexp = __webpack_require__(3); -const ansiStyles = __webpack_require__(567); -const stdoutColor = __webpack_require__(568).stdout; +const ansiStyles = __webpack_require__(566); +const stdoutColor = __webpack_require__(567).stdout; -const template = __webpack_require__(569); +const template = __webpack_require__(568); const isSimpleWindowsTerm = process.platform === 'win32' && !(process.env.TERM || '').toLowerCase().startsWith('xterm'); @@ -55650,7 +55635,7 @@ module.exports.default = module.exports; // For TypeScript /***/ }), -/* 567 */ +/* 566 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -55823,7 +55808,7 @@ Object.defineProperty(module, 'exports', { /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(5)(module))) /***/ }), -/* 568 */ +/* 567 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -55965,7 +55950,7 @@ module.exports = { /***/ }), -/* 569 */ +/* 568 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -56100,7 +56085,7 @@ module.exports = (chalk, tmp) => { /***/ }), -/* 570 */ +/* 569 */ /***/ (function(module, exports, __webpack_require__) { // Copyright IBM Corp. 2014,2018. All Rights Reserved. @@ -56108,12 +56093,12 @@ module.exports = (chalk, tmp) => { // This file is licensed under the Apache License 2.0. // License text available at https://opensource.org/licenses/Apache-2.0 -module.exports = __webpack_require__(571); -module.exports.cli = __webpack_require__(575); +module.exports = __webpack_require__(570); +module.exports.cli = __webpack_require__(574); /***/ }), -/* 571 */ +/* 570 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -56128,9 +56113,9 @@ var stream = __webpack_require__(27); var util = __webpack_require__(29); var fs = __webpack_require__(23); -var through = __webpack_require__(572); -var duplexer = __webpack_require__(573); -var StringDecoder = __webpack_require__(574).StringDecoder; +var through = __webpack_require__(571); +var duplexer = __webpack_require__(572); +var StringDecoder = __webpack_require__(573).StringDecoder; module.exports = Logger; @@ -56319,7 +56304,7 @@ function lineMerger(host) { /***/ }), -/* 572 */ +/* 571 */ /***/ (function(module, exports, __webpack_require__) { var Stream = __webpack_require__(27) @@ -56433,7 +56418,7 @@ function through (write, end, opts) { /***/ }), -/* 573 */ +/* 572 */ /***/ (function(module, exports, __webpack_require__) { var Stream = __webpack_require__(27) @@ -56526,13 +56511,13 @@ function duplex(writer, reader) { /***/ }), -/* 574 */ +/* 573 */ /***/ (function(module, exports) { module.exports = require("string_decoder"); /***/ }), -/* 575 */ +/* 574 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -56543,11 +56528,11 @@ module.exports = require("string_decoder"); -var minimist = __webpack_require__(576); +var minimist = __webpack_require__(575); var path = __webpack_require__(16); -var Logger = __webpack_require__(571); -var pkg = __webpack_require__(577); +var Logger = __webpack_require__(570); +var pkg = __webpack_require__(576); module.exports = cli; @@ -56601,7 +56586,7 @@ function usage($0, p) { /***/ }), -/* 576 */ +/* 575 */ /***/ (function(module, exports) { module.exports = function (args, opts) { @@ -56843,29 +56828,29 @@ function isNumber (x) { /***/ }), -/* 577 */ +/* 576 */ /***/ (function(module) { module.exports = JSON.parse("{\"name\":\"strong-log-transformer\",\"version\":\"2.1.0\",\"description\":\"Stream transformer that prefixes lines with timestamps and other things.\",\"author\":\"Ryan Graham \",\"license\":\"Apache-2.0\",\"repository\":{\"type\":\"git\",\"url\":\"git://github.com/strongloop/strong-log-transformer\"},\"keywords\":[\"logging\",\"streams\"],\"bugs\":{\"url\":\"https://github.com/strongloop/strong-log-transformer/issues\"},\"homepage\":\"https://github.com/strongloop/strong-log-transformer\",\"directories\":{\"test\":\"test\"},\"bin\":{\"sl-log-transformer\":\"bin/sl-log-transformer.js\"},\"main\":\"index.js\",\"scripts\":{\"test\":\"tap --100 test/test-*\"},\"dependencies\":{\"duplexer\":\"^0.1.1\",\"minimist\":\"^1.2.0\",\"through\":\"^2.3.4\"},\"devDependencies\":{\"tap\":\"^12.0.1\"},\"engines\":{\"node\":\">=4\"}}"); /***/ }), -/* 578 */ +/* 577 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "workspacePackagePaths", function() { return workspacePackagePaths; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "copyWorkspacePackages", function() { return copyWorkspacePackages; }); -/* harmony import */ var glob__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(502); +/* harmony import */ var glob__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(501); /* harmony import */ var glob__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(glob__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(16); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var util__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(29); /* harmony import */ var util__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(util__WEBPACK_IMPORTED_MODULE_2__); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(579); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(578); /* harmony import */ var _fs__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(20); -/* harmony import */ var _package_json__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(517); -/* harmony import */ var _projects__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(501); +/* harmony import */ var _package_json__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(516); +/* harmony import */ var _projects__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(500); /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with @@ -56957,7 +56942,7 @@ function packagesFromGlobPattern({ } /***/ }), -/* 579 */ +/* 578 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57011,6 +56996,7 @@ function getProjectPaths({ projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'x-pack')); projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'x-pack/plugins/*')); projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'x-pack/legacy/plugins/*')); + projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'x-pack/test/functional_with_es_ssl/fixtures/plugins/*')); } if (!skipKibanaPlugins) { @@ -57026,7 +57012,7 @@ function getProjectPaths({ } /***/ }), -/* 580 */ +/* 579 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57034,13 +57020,13 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "getAllChecksums", function() { return getAllChecksums; }); /* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(23); /* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var crypto__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(581); +/* harmony import */ var crypto__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(580); /* harmony import */ var crypto__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(crypto__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var util__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(29); /* harmony import */ var util__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(util__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var execa__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(351); /* harmony import */ var execa__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(execa__WEBPACK_IMPORTED_MODULE_3__); -/* harmony import */ var _yarn_lock__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(582); +/* harmony import */ var _yarn_lock__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(581); /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with @@ -57266,19 +57252,19 @@ async function getAllChecksums(kbn, log) { } /***/ }), -/* 581 */ +/* 580 */ /***/ (function(module, exports) { module.exports = require("crypto"); /***/ }), -/* 582 */ +/* 581 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "readYarnLock", function() { return readYarnLock; }); -/* harmony import */ var _yarnpkg_lockfile__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(583); +/* harmony import */ var _yarnpkg_lockfile__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(582); /* harmony import */ var _yarnpkg_lockfile__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_yarnpkg_lockfile__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var _utils_fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(20); /* @@ -57322,7 +57308,7 @@ async function readYarnLock(kbn) { } /***/ }), -/* 583 */ +/* 582 */ /***/ (function(module, exports, __webpack_require__) { module.exports = @@ -58881,7 +58867,7 @@ module.exports = invariant; /* 9 */ /***/ (function(module, exports) { -module.exports = __webpack_require__(581); +module.exports = __webpack_require__(580); /***/ }), /* 10 */, @@ -61205,7 +61191,7 @@ function onceStrict (fn) { /* 63 */ /***/ (function(module, exports) { -module.exports = __webpack_require__(584); +module.exports = __webpack_require__(583); /***/ }), /* 64 */, @@ -62143,7 +62129,7 @@ module.exports.win32 = win32; /* 79 */ /***/ (function(module, exports) { -module.exports = __webpack_require__(480); +module.exports = __webpack_require__(479); /***/ }), /* 80 */, @@ -67600,13 +67586,13 @@ module.exports = process && support(supportLevel); /******/ ]); /***/ }), -/* 584 */ +/* 583 */ /***/ (function(module, exports) { module.exports = require("buffer"); /***/ }), -/* 585 */ +/* 584 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -67703,7 +67689,7 @@ class BootstrapCacheFile { } /***/ }), -/* 586 */ +/* 585 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -67711,9 +67697,9 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "CleanCommand", function() { return CleanCommand; }); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(chalk__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(587); +/* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(586); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(del__WEBPACK_IMPORTED_MODULE_1__); -/* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(675); +/* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(674); /* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(ora__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(16); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_3__); @@ -67813,21 +67799,21 @@ const CleanCommand = { }; /***/ }), -/* 587 */ +/* 586 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const {promisify} = __webpack_require__(29); const path = __webpack_require__(16); -const globby = __webpack_require__(588); -const isGlob = __webpack_require__(605); -const slash = __webpack_require__(666); +const globby = __webpack_require__(587); +const isGlob = __webpack_require__(604); +const slash = __webpack_require__(665); const gracefulFs = __webpack_require__(22); -const isPathCwd = __webpack_require__(668); -const isPathInside = __webpack_require__(669); -const rimraf = __webpack_require__(670); -const pMap = __webpack_require__(671); +const isPathCwd = __webpack_require__(667); +const isPathInside = __webpack_require__(668); +const rimraf = __webpack_require__(669); +const pMap = __webpack_require__(670); const rimrafP = promisify(rimraf); @@ -67941,19 +67927,19 @@ module.exports.sync = (patterns, {force, dryRun, cwd = process.cwd(), ...options /***/ }), -/* 588 */ +/* 587 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(23); -const arrayUnion = __webpack_require__(589); -const merge2 = __webpack_require__(590); -const glob = __webpack_require__(591); -const fastGlob = __webpack_require__(596); -const dirGlob = __webpack_require__(662); -const gitignore = __webpack_require__(664); -const {FilterStream, UniqueStream} = __webpack_require__(667); +const arrayUnion = __webpack_require__(588); +const merge2 = __webpack_require__(589); +const glob = __webpack_require__(590); +const fastGlob = __webpack_require__(595); +const dirGlob = __webpack_require__(661); +const gitignore = __webpack_require__(663); +const {FilterStream, UniqueStream} = __webpack_require__(666); const DEFAULT_FILTER = () => false; @@ -68126,7 +68112,7 @@ module.exports.gitignore = gitignore; /***/ }), -/* 589 */ +/* 588 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68138,7 +68124,7 @@ module.exports = (...arguments_) => { /***/ }), -/* 590 */ +/* 589 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68252,7 +68238,7 @@ function pauseStreams (streams, options) { /***/ }), -/* 591 */ +/* 590 */ /***/ (function(module, exports, __webpack_require__) { // Approach: @@ -68298,26 +68284,26 @@ function pauseStreams (streams, options) { module.exports = glob var fs = __webpack_require__(23) -var rp = __webpack_require__(503) -var minimatch = __webpack_require__(505) +var rp = __webpack_require__(502) +var minimatch = __webpack_require__(504) var Minimatch = minimatch.Minimatch -var inherits = __webpack_require__(592) +var inherits = __webpack_require__(591) var EE = __webpack_require__(379).EventEmitter var path = __webpack_require__(16) var assert = __webpack_require__(30) -var isAbsolute = __webpack_require__(511) -var globSync = __webpack_require__(594) -var common = __webpack_require__(595) +var isAbsolute = __webpack_require__(510) +var globSync = __webpack_require__(593) +var common = __webpack_require__(594) var alphasort = common.alphasort var alphasorti = common.alphasorti var setopts = common.setopts var ownProp = common.ownProp -var inflight = __webpack_require__(514) +var inflight = __webpack_require__(513) var util = __webpack_require__(29) var childrenIgnored = common.childrenIgnored var isIgnored = common.isIgnored -var once = __webpack_require__(385) +var once = __webpack_require__(384) function glob (pattern, options, cb) { if (typeof options === 'function') cb = options, options = {} @@ -69048,7 +69034,7 @@ Glob.prototype._stat2 = function (f, abs, er, stat, cb) { /***/ }), -/* 592 */ +/* 591 */ /***/ (function(module, exports, __webpack_require__) { try { @@ -69058,12 +69044,12 @@ try { module.exports = util.inherits; } catch (e) { /* istanbul ignore next */ - module.exports = __webpack_require__(593); + module.exports = __webpack_require__(592); } /***/ }), -/* 593 */ +/* 592 */ /***/ (function(module, exports) { if (typeof Object.create === 'function') { @@ -69096,22 +69082,22 @@ if (typeof Object.create === 'function') { /***/ }), -/* 594 */ +/* 593 */ /***/ (function(module, exports, __webpack_require__) { module.exports = globSync globSync.GlobSync = GlobSync var fs = __webpack_require__(23) -var rp = __webpack_require__(503) -var minimatch = __webpack_require__(505) +var rp = __webpack_require__(502) +var minimatch = __webpack_require__(504) var Minimatch = minimatch.Minimatch -var Glob = __webpack_require__(591).Glob +var Glob = __webpack_require__(590).Glob var util = __webpack_require__(29) var path = __webpack_require__(16) var assert = __webpack_require__(30) -var isAbsolute = __webpack_require__(511) -var common = __webpack_require__(595) +var isAbsolute = __webpack_require__(510) +var common = __webpack_require__(594) var alphasort = common.alphasort var alphasorti = common.alphasorti var setopts = common.setopts @@ -69588,7 +69574,7 @@ GlobSync.prototype._makeAbs = function (f) { /***/ }), -/* 595 */ +/* 594 */ /***/ (function(module, exports, __webpack_require__) { exports.alphasort = alphasort @@ -69606,8 +69592,8 @@ function ownProp (obj, field) { } var path = __webpack_require__(16) -var minimatch = __webpack_require__(505) -var isAbsolute = __webpack_require__(511) +var minimatch = __webpack_require__(504) +var isAbsolute = __webpack_require__(510) var Minimatch = minimatch.Minimatch function alphasorti (a, b) { @@ -69834,17 +69820,17 @@ function childrenIgnored (self, path) { /***/ }), -/* 596 */ +/* 595 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const taskManager = __webpack_require__(597); -const async_1 = __webpack_require__(625); -const stream_1 = __webpack_require__(658); -const sync_1 = __webpack_require__(659); -const settings_1 = __webpack_require__(661); -const utils = __webpack_require__(598); +const taskManager = __webpack_require__(596); +const async_1 = __webpack_require__(624); +const stream_1 = __webpack_require__(657); +const sync_1 = __webpack_require__(658); +const settings_1 = __webpack_require__(660); +const utils = __webpack_require__(597); function FastGlob(source, options) { try { assertPatternsInput(source); @@ -69902,13 +69888,13 @@ module.exports = FastGlob; /***/ }), -/* 597 */ +/* 596 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const utils = __webpack_require__(598); +const utils = __webpack_require__(597); function generate(patterns, settings) { const positivePatterns = getPositivePatterns(patterns); const negativePatterns = getNegativePatternsAsPositive(patterns, settings.ignore); @@ -69976,28 +69962,28 @@ exports.convertPatternGroupToTask = convertPatternGroupToTask; /***/ }), -/* 598 */ +/* 597 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const array = __webpack_require__(599); +const array = __webpack_require__(598); exports.array = array; -const errno = __webpack_require__(600); +const errno = __webpack_require__(599); exports.errno = errno; -const fs = __webpack_require__(601); +const fs = __webpack_require__(600); exports.fs = fs; -const path = __webpack_require__(602); +const path = __webpack_require__(601); exports.path = path; -const pattern = __webpack_require__(603); +const pattern = __webpack_require__(602); exports.pattern = pattern; -const stream = __webpack_require__(624); +const stream = __webpack_require__(623); exports.stream = stream; /***/ }), -/* 599 */ +/* 598 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70010,7 +69996,7 @@ exports.flatten = flatten; /***/ }), -/* 600 */ +/* 599 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70023,7 +70009,7 @@ exports.isEnoentCodeError = isEnoentCodeError; /***/ }), -/* 601 */ +/* 600 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70048,7 +70034,7 @@ exports.createDirentFromStats = createDirentFromStats; /***/ }), -/* 602 */ +/* 601 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70069,16 +70055,16 @@ exports.makeAbsolute = makeAbsolute; /***/ }), -/* 603 */ +/* 602 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const path = __webpack_require__(16); -const globParent = __webpack_require__(604); -const isGlob = __webpack_require__(605); -const micromatch = __webpack_require__(607); +const globParent = __webpack_require__(603); +const isGlob = __webpack_require__(604); +const micromatch = __webpack_require__(606); const GLOBSTAR = '**'; function isStaticPattern(pattern) { return !isDynamicPattern(pattern); @@ -70167,13 +70153,13 @@ exports.matchAny = matchAny; /***/ }), -/* 604 */ +/* 603 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isGlob = __webpack_require__(605); +var isGlob = __webpack_require__(604); var pathPosixDirname = __webpack_require__(16).posix.dirname; var isWin32 = __webpack_require__(11).platform() === 'win32'; @@ -70208,7 +70194,7 @@ module.exports = function globParent(str) { /***/ }), -/* 605 */ +/* 604 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -70218,7 +70204,7 @@ module.exports = function globParent(str) { * Released under the MIT License. */ -var isExtglob = __webpack_require__(606); +var isExtglob = __webpack_require__(605); var chars = { '{': '}', '(': ')', '[': ']'}; var strictRegex = /\\(.)|(^!|\*|[\].+)]\?|\[[^\\\]]+\]|\{[^\\}]+\}|\(\?[:!=][^\\)]+\)|\([^|]+\|[^\\)]+\))/; var relaxedRegex = /\\(.)|(^!|[*?{}()[\]]|\(\?)/; @@ -70262,7 +70248,7 @@ module.exports = function isGlob(str, options) { /***/ }), -/* 606 */ +/* 605 */ /***/ (function(module, exports) { /*! @@ -70288,16 +70274,16 @@ module.exports = function isExtglob(str) { /***/ }), -/* 607 */ +/* 606 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const util = __webpack_require__(29); -const braces = __webpack_require__(608); -const picomatch = __webpack_require__(618); -const utils = __webpack_require__(621); +const braces = __webpack_require__(607); +const picomatch = __webpack_require__(617); +const utils = __webpack_require__(620); const isEmptyString = val => typeof val === 'string' && (val === '' || val === './'); /** @@ -70762,16 +70748,16 @@ module.exports = micromatch; /***/ }), -/* 608 */ +/* 607 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const stringify = __webpack_require__(609); -const compile = __webpack_require__(611); -const expand = __webpack_require__(615); -const parse = __webpack_require__(616); +const stringify = __webpack_require__(608); +const compile = __webpack_require__(610); +const expand = __webpack_require__(614); +const parse = __webpack_require__(615); /** * Expand the given pattern or create a regex-compatible string. @@ -70939,13 +70925,13 @@ module.exports = braces; /***/ }), -/* 609 */ +/* 608 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const utils = __webpack_require__(610); +const utils = __webpack_require__(609); module.exports = (ast, options = {}) => { let stringify = (node, parent = {}) => { @@ -70978,7 +70964,7 @@ module.exports = (ast, options = {}) => { /***/ }), -/* 610 */ +/* 609 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -71097,14 +71083,14 @@ exports.flatten = (...args) => { /***/ }), -/* 611 */ +/* 610 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const fill = __webpack_require__(612); -const utils = __webpack_require__(610); +const fill = __webpack_require__(611); +const utils = __webpack_require__(609); const compile = (ast, options = {}) => { let walk = (node, parent = {}) => { @@ -71161,7 +71147,7 @@ module.exports = compile; /***/ }), -/* 612 */ +/* 611 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -71175,7 +71161,7 @@ module.exports = compile; const util = __webpack_require__(29); -const toRegexRange = __webpack_require__(613); +const toRegexRange = __webpack_require__(612); const isObject = val => val !== null && typeof val === 'object' && !Array.isArray(val); @@ -71417,7 +71403,7 @@ module.exports = fill; /***/ }), -/* 613 */ +/* 612 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -71430,7 +71416,7 @@ module.exports = fill; -const isNumber = __webpack_require__(614); +const isNumber = __webpack_require__(613); const toRegexRange = (min, max, options) => { if (isNumber(min) === false) { @@ -71712,7 +71698,7 @@ module.exports = toRegexRange; /***/ }), -/* 614 */ +/* 613 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -71737,15 +71723,15 @@ module.exports = function(num) { /***/ }), -/* 615 */ +/* 614 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const fill = __webpack_require__(612); -const stringify = __webpack_require__(609); -const utils = __webpack_require__(610); +const fill = __webpack_require__(611); +const stringify = __webpack_require__(608); +const utils = __webpack_require__(609); const append = (queue = '', stash = '', enclose = false) => { let result = []; @@ -71857,13 +71843,13 @@ module.exports = expand; /***/ }), -/* 616 */ +/* 615 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const stringify = __webpack_require__(609); +const stringify = __webpack_require__(608); /** * Constants @@ -71885,7 +71871,7 @@ const { CHAR_SINGLE_QUOTE, /* ' */ CHAR_NO_BREAK_SPACE, CHAR_ZERO_WIDTH_NOBREAK_SPACE -} = __webpack_require__(617); +} = __webpack_require__(616); /** * parse @@ -72197,7 +72183,7 @@ module.exports = parse; /***/ }), -/* 617 */ +/* 616 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -72261,26 +72247,26 @@ module.exports = { /***/ }), -/* 618 */ +/* 617 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -module.exports = __webpack_require__(619); +module.exports = __webpack_require__(618); /***/ }), -/* 619 */ +/* 618 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(16); -const scan = __webpack_require__(620); -const parse = __webpack_require__(623); -const utils = __webpack_require__(621); +const scan = __webpack_require__(619); +const parse = __webpack_require__(622); +const utils = __webpack_require__(620); /** * Creates a matcher function from one or more glob patterns. The @@ -72583,7 +72569,7 @@ picomatch.toRegex = (source, options) => { * @return {Object} */ -picomatch.constants = __webpack_require__(622); +picomatch.constants = __webpack_require__(621); /** * Expose "picomatch" @@ -72593,13 +72579,13 @@ module.exports = picomatch; /***/ }), -/* 620 */ +/* 619 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const utils = __webpack_require__(621); +const utils = __webpack_require__(620); const { CHAR_ASTERISK, /* * */ @@ -72617,7 +72603,7 @@ const { CHAR_RIGHT_CURLY_BRACE, /* } */ CHAR_RIGHT_PARENTHESES, /* ) */ CHAR_RIGHT_SQUARE_BRACKET /* ] */ -} = __webpack_require__(622); +} = __webpack_require__(621); const isPathSeparator = code => { return code === CHAR_FORWARD_SLASH || code === CHAR_BACKWARD_SLASH; @@ -72819,7 +72805,7 @@ module.exports = (input, options) => { /***/ }), -/* 621 */ +/* 620 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -72831,7 +72817,7 @@ const { REGEX_SPECIAL_CHARS, REGEX_SPECIAL_CHARS_GLOBAL, REGEX_REMOVE_BACKSLASH -} = __webpack_require__(622); +} = __webpack_require__(621); exports.isObject = val => val !== null && typeof val === 'object' && !Array.isArray(val); exports.hasRegexChars = str => REGEX_SPECIAL_CHARS.test(str); @@ -72869,7 +72855,7 @@ exports.escapeLast = (input, char, lastIdx) => { /***/ }), -/* 622 */ +/* 621 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -73055,14 +73041,14 @@ module.exports = { /***/ }), -/* 623 */ +/* 622 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const utils = __webpack_require__(621); -const constants = __webpack_require__(622); +const utils = __webpack_require__(620); +const constants = __webpack_require__(621); /** * Constants @@ -74073,13 +74059,13 @@ module.exports = parse; /***/ }), -/* 624 */ +/* 623 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const merge2 = __webpack_require__(590); +const merge2 = __webpack_require__(589); function merge(streams) { const mergedStream = merge2(streams); streams.forEach((stream) => { @@ -74091,14 +74077,14 @@ exports.merge = merge; /***/ }), -/* 625 */ +/* 624 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const stream_1 = __webpack_require__(626); -const provider_1 = __webpack_require__(653); +const stream_1 = __webpack_require__(625); +const provider_1 = __webpack_require__(652); class ProviderAsync extends provider_1.default { constructor() { super(...arguments); @@ -74126,16 +74112,16 @@ exports.default = ProviderAsync; /***/ }), -/* 626 */ +/* 625 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const stream_1 = __webpack_require__(27); -const fsStat = __webpack_require__(627); -const fsWalk = __webpack_require__(632); -const reader_1 = __webpack_require__(652); +const fsStat = __webpack_require__(626); +const fsWalk = __webpack_require__(631); +const reader_1 = __webpack_require__(651); class ReaderStream extends reader_1.default { constructor() { super(...arguments); @@ -74188,15 +74174,15 @@ exports.default = ReaderStream; /***/ }), -/* 627 */ +/* 626 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const async = __webpack_require__(628); -const sync = __webpack_require__(629); -const settings_1 = __webpack_require__(630); +const async = __webpack_require__(627); +const sync = __webpack_require__(628); +const settings_1 = __webpack_require__(629); exports.Settings = settings_1.default; function stat(path, optionsOrSettingsOrCallback, callback) { if (typeof optionsOrSettingsOrCallback === 'function') { @@ -74219,7 +74205,7 @@ function getSettings(settingsOrOptions = {}) { /***/ }), -/* 628 */ +/* 627 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74257,7 +74243,7 @@ function callSuccessCallback(callback, result) { /***/ }), -/* 629 */ +/* 628 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74286,13 +74272,13 @@ exports.read = read; /***/ }), -/* 630 */ +/* 629 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fs = __webpack_require__(631); +const fs = __webpack_require__(630); class Settings { constructor(_options = {}) { this._options = _options; @@ -74309,7 +74295,7 @@ exports.default = Settings; /***/ }), -/* 631 */ +/* 630 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74332,16 +74318,16 @@ exports.createFileSystemAdapter = createFileSystemAdapter; /***/ }), -/* 632 */ +/* 631 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const async_1 = __webpack_require__(633); -const stream_1 = __webpack_require__(648); -const sync_1 = __webpack_require__(649); -const settings_1 = __webpack_require__(651); +const async_1 = __webpack_require__(632); +const stream_1 = __webpack_require__(647); +const sync_1 = __webpack_require__(648); +const settings_1 = __webpack_require__(650); exports.Settings = settings_1.default; function walk(dir, optionsOrSettingsOrCallback, callback) { if (typeof optionsOrSettingsOrCallback === 'function') { @@ -74371,13 +74357,13 @@ function getSettings(settingsOrOptions = {}) { /***/ }), -/* 633 */ +/* 632 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const async_1 = __webpack_require__(634); +const async_1 = __webpack_require__(633); class AsyncProvider { constructor(_root, _settings) { this._root = _root; @@ -74408,17 +74394,17 @@ function callSuccessCallback(callback, entries) { /***/ }), -/* 634 */ +/* 633 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const events_1 = __webpack_require__(379); -const fsScandir = __webpack_require__(635); -const fastq = __webpack_require__(644); -const common = __webpack_require__(646); -const reader_1 = __webpack_require__(647); +const fsScandir = __webpack_require__(634); +const fastq = __webpack_require__(643); +const common = __webpack_require__(645); +const reader_1 = __webpack_require__(646); class AsyncReader extends reader_1.default { constructor(_root, _settings) { super(_root, _settings); @@ -74508,15 +74494,15 @@ exports.default = AsyncReader; /***/ }), -/* 635 */ +/* 634 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const async = __webpack_require__(636); -const sync = __webpack_require__(641); -const settings_1 = __webpack_require__(642); +const async = __webpack_require__(635); +const sync = __webpack_require__(640); +const settings_1 = __webpack_require__(641); exports.Settings = settings_1.default; function scandir(path, optionsOrSettingsOrCallback, callback) { if (typeof optionsOrSettingsOrCallback === 'function') { @@ -74539,16 +74525,16 @@ function getSettings(settingsOrOptions = {}) { /***/ }), -/* 636 */ +/* 635 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fsStat = __webpack_require__(627); -const rpl = __webpack_require__(637); -const constants_1 = __webpack_require__(638); -const utils = __webpack_require__(639); +const fsStat = __webpack_require__(626); +const rpl = __webpack_require__(636); +const constants_1 = __webpack_require__(637); +const utils = __webpack_require__(638); function read(dir, settings, callback) { if (!settings.stats && constants_1.IS_SUPPORT_READDIR_WITH_FILE_TYPES) { return readdirWithFileTypes(dir, settings, callback); @@ -74637,7 +74623,7 @@ function callSuccessCallback(callback, result) { /***/ }), -/* 637 */ +/* 636 */ /***/ (function(module, exports) { module.exports = runParallel @@ -74691,7 +74677,7 @@ function runParallel (tasks, cb) { /***/ }), -/* 638 */ +/* 637 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74707,18 +74693,18 @@ exports.IS_SUPPORT_READDIR_WITH_FILE_TYPES = MAJOR_VERSION > 10 || (MAJOR_VERSIO /***/ }), -/* 639 */ +/* 638 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fs = __webpack_require__(640); +const fs = __webpack_require__(639); exports.fs = fs; /***/ }), -/* 640 */ +/* 639 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74743,15 +74729,15 @@ exports.createDirentFromStats = createDirentFromStats; /***/ }), -/* 641 */ +/* 640 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fsStat = __webpack_require__(627); -const constants_1 = __webpack_require__(638); -const utils = __webpack_require__(639); +const fsStat = __webpack_require__(626); +const constants_1 = __webpack_require__(637); +const utils = __webpack_require__(638); function read(dir, settings) { if (!settings.stats && constants_1.IS_SUPPORT_READDIR_WITH_FILE_TYPES) { return readdirWithFileTypes(dir, settings); @@ -74802,15 +74788,15 @@ exports.readdir = readdir; /***/ }), -/* 642 */ +/* 641 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const path = __webpack_require__(16); -const fsStat = __webpack_require__(627); -const fs = __webpack_require__(643); +const fsStat = __webpack_require__(626); +const fs = __webpack_require__(642); class Settings { constructor(_options = {}) { this._options = _options; @@ -74833,7 +74819,7 @@ exports.default = Settings; /***/ }), -/* 643 */ +/* 642 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -74858,13 +74844,13 @@ exports.createFileSystemAdapter = createFileSystemAdapter; /***/ }), -/* 644 */ +/* 643 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var reusify = __webpack_require__(645) +var reusify = __webpack_require__(644) function fastqueue (context, worker, concurrency) { if (typeof context === 'function') { @@ -75038,7 +75024,7 @@ module.exports = fastqueue /***/ }), -/* 645 */ +/* 644 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75078,7 +75064,7 @@ module.exports = reusify /***/ }), -/* 646 */ +/* 645 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75109,13 +75095,13 @@ exports.joinPathSegments = joinPathSegments; /***/ }), -/* 647 */ +/* 646 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const common = __webpack_require__(646); +const common = __webpack_require__(645); class Reader { constructor(_root, _settings) { this._root = _root; @@ -75127,14 +75113,14 @@ exports.default = Reader; /***/ }), -/* 648 */ +/* 647 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const stream_1 = __webpack_require__(27); -const async_1 = __webpack_require__(634); +const async_1 = __webpack_require__(633); class StreamProvider { constructor(_root, _settings) { this._root = _root; @@ -75164,13 +75150,13 @@ exports.default = StreamProvider; /***/ }), -/* 649 */ +/* 648 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const sync_1 = __webpack_require__(650); +const sync_1 = __webpack_require__(649); class SyncProvider { constructor(_root, _settings) { this._root = _root; @@ -75185,15 +75171,15 @@ exports.default = SyncProvider; /***/ }), -/* 650 */ +/* 649 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fsScandir = __webpack_require__(635); -const common = __webpack_require__(646); -const reader_1 = __webpack_require__(647); +const fsScandir = __webpack_require__(634); +const common = __webpack_require__(645); +const reader_1 = __webpack_require__(646); class SyncReader extends reader_1.default { constructor() { super(...arguments); @@ -75251,14 +75237,14 @@ exports.default = SyncReader; /***/ }), -/* 651 */ +/* 650 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const path = __webpack_require__(16); -const fsScandir = __webpack_require__(635); +const fsScandir = __webpack_require__(634); class Settings { constructor(_options = {}) { this._options = _options; @@ -75284,15 +75270,15 @@ exports.default = Settings; /***/ }), -/* 652 */ +/* 651 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const path = __webpack_require__(16); -const fsStat = __webpack_require__(627); -const utils = __webpack_require__(598); +const fsStat = __webpack_require__(626); +const utils = __webpack_require__(597); class Reader { constructor(_settings) { this._settings = _settings; @@ -75324,17 +75310,17 @@ exports.default = Reader; /***/ }), -/* 653 */ +/* 652 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const path = __webpack_require__(16); -const deep_1 = __webpack_require__(654); -const entry_1 = __webpack_require__(655); -const error_1 = __webpack_require__(656); -const entry_2 = __webpack_require__(657); +const deep_1 = __webpack_require__(653); +const entry_1 = __webpack_require__(654); +const error_1 = __webpack_require__(655); +const entry_2 = __webpack_require__(656); class Provider { constructor(_settings) { this._settings = _settings; @@ -75379,13 +75365,13 @@ exports.default = Provider; /***/ }), -/* 654 */ +/* 653 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const utils = __webpack_require__(598); +const utils = __webpack_require__(597); class DeepFilter { constructor(_settings, _micromatchOptions) { this._settings = _settings; @@ -75445,13 +75431,13 @@ exports.default = DeepFilter; /***/ }), -/* 655 */ +/* 654 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const utils = __webpack_require__(598); +const utils = __webpack_require__(597); class EntryFilter { constructor(_settings, _micromatchOptions) { this._settings = _settings; @@ -75506,13 +75492,13 @@ exports.default = EntryFilter; /***/ }), -/* 656 */ +/* 655 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const utils = __webpack_require__(598); +const utils = __webpack_require__(597); class ErrorFilter { constructor(_settings) { this._settings = _settings; @@ -75528,13 +75514,13 @@ exports.default = ErrorFilter; /***/ }), -/* 657 */ +/* 656 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const utils = __webpack_require__(598); +const utils = __webpack_require__(597); class EntryTransformer { constructor(_settings) { this._settings = _settings; @@ -75561,15 +75547,15 @@ exports.default = EntryTransformer; /***/ }), -/* 658 */ +/* 657 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const stream_1 = __webpack_require__(27); -const stream_2 = __webpack_require__(626); -const provider_1 = __webpack_require__(653); +const stream_2 = __webpack_require__(625); +const provider_1 = __webpack_require__(652); class ProviderStream extends provider_1.default { constructor() { super(...arguments); @@ -75597,14 +75583,14 @@ exports.default = ProviderStream; /***/ }), -/* 659 */ +/* 658 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const sync_1 = __webpack_require__(660); -const provider_1 = __webpack_require__(653); +const sync_1 = __webpack_require__(659); +const provider_1 = __webpack_require__(652); class ProviderSync extends provider_1.default { constructor() { super(...arguments); @@ -75627,15 +75613,15 @@ exports.default = ProviderSync; /***/ }), -/* 660 */ +/* 659 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fsStat = __webpack_require__(627); -const fsWalk = __webpack_require__(632); -const reader_1 = __webpack_require__(652); +const fsStat = __webpack_require__(626); +const fsWalk = __webpack_require__(631); +const reader_1 = __webpack_require__(651); class ReaderSync extends reader_1.default { constructor() { super(...arguments); @@ -75677,7 +75663,7 @@ exports.default = ReaderSync; /***/ }), -/* 661 */ +/* 660 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75737,13 +75723,13 @@ exports.default = Settings; /***/ }), -/* 662 */ +/* 661 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(16); -const pathType = __webpack_require__(663); +const pathType = __webpack_require__(662); const getExtensions = extensions => extensions.length > 1 ? `{${extensions.join(',')}}` : extensions[0]; @@ -75819,7 +75805,7 @@ module.exports.sync = (input, options) => { /***/ }), -/* 663 */ +/* 662 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75869,7 +75855,7 @@ exports.isSymlinkSync = isTypeSync.bind(null, 'lstatSync', 'isSymbolicLink'); /***/ }), -/* 664 */ +/* 663 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75877,9 +75863,9 @@ exports.isSymlinkSync = isTypeSync.bind(null, 'lstatSync', 'isSymbolicLink'); const {promisify} = __webpack_require__(29); const fs = __webpack_require__(23); const path = __webpack_require__(16); -const fastGlob = __webpack_require__(596); -const gitIgnore = __webpack_require__(665); -const slash = __webpack_require__(666); +const fastGlob = __webpack_require__(595); +const gitIgnore = __webpack_require__(664); +const slash = __webpack_require__(665); const DEFAULT_IGNORE = [ '**/node_modules/**', @@ -75993,7 +75979,7 @@ module.exports.sync = options => { /***/ }), -/* 665 */ +/* 664 */ /***/ (function(module, exports) { // A simple implementation of make-array @@ -76584,7 +76570,7 @@ if ( /***/ }), -/* 666 */ +/* 665 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76602,7 +76588,7 @@ module.exports = path => { /***/ }), -/* 667 */ +/* 666 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76655,7 +76641,7 @@ module.exports = { /***/ }), -/* 668 */ +/* 667 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76677,7 +76663,7 @@ module.exports = path_ => { /***/ }), -/* 669 */ +/* 668 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76705,7 +76691,7 @@ module.exports = (childPath, parentPath) => { /***/ }), -/* 670 */ +/* 669 */ /***/ (function(module, exports, __webpack_require__) { const assert = __webpack_require__(30) @@ -76713,7 +76699,7 @@ const path = __webpack_require__(16) const fs = __webpack_require__(23) let glob = undefined try { - glob = __webpack_require__(591) + glob = __webpack_require__(590) } catch (_err) { // treat glob as optional. } @@ -77079,12 +77065,12 @@ rimraf.sync = rimrafSync /***/ }), -/* 671 */ +/* 670 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const AggregateError = __webpack_require__(672); +const AggregateError = __webpack_require__(671); module.exports = async ( iterable, @@ -77167,13 +77153,13 @@ module.exports = async ( /***/ }), -/* 672 */ +/* 671 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const indentString = __webpack_require__(673); -const cleanStack = __webpack_require__(674); +const indentString = __webpack_require__(672); +const cleanStack = __webpack_require__(673); const cleanInternalStack = stack => stack.replace(/\s+at .*aggregate-error\/index.js:\d+:\d+\)?/g, ''); @@ -77221,7 +77207,7 @@ module.exports = AggregateError; /***/ }), -/* 673 */ +/* 672 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -77263,7 +77249,7 @@ module.exports = (string, count = 1, options) => { /***/ }), -/* 674 */ +/* 673 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -77310,15 +77296,15 @@ module.exports = (stack, options) => { /***/ }), -/* 675 */ +/* 674 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const chalk = __webpack_require__(676); -const cliCursor = __webpack_require__(680); -const cliSpinners = __webpack_require__(684); -const logSymbols = __webpack_require__(565); +const chalk = __webpack_require__(675); +const cliCursor = __webpack_require__(679); +const cliSpinners = __webpack_require__(683); +const logSymbols = __webpack_require__(564); class Ora { constructor(options) { @@ -77465,16 +77451,16 @@ module.exports.promise = (action, options) => { /***/ }), -/* 676 */ +/* 675 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const escapeStringRegexp = __webpack_require__(3); -const ansiStyles = __webpack_require__(677); -const stdoutColor = __webpack_require__(678).stdout; +const ansiStyles = __webpack_require__(676); +const stdoutColor = __webpack_require__(677).stdout; -const template = __webpack_require__(679); +const template = __webpack_require__(678); const isSimpleWindowsTerm = process.platform === 'win32' && !(process.env.TERM || '').toLowerCase().startsWith('xterm'); @@ -77700,7 +77686,7 @@ module.exports.default = module.exports; // For TypeScript /***/ }), -/* 677 */ +/* 676 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -77873,7 +77859,7 @@ Object.defineProperty(module, 'exports', { /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(5)(module))) /***/ }), -/* 678 */ +/* 677 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78015,7 +78001,7 @@ module.exports = { /***/ }), -/* 679 */ +/* 678 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78150,12 +78136,12 @@ module.exports = (chalk, tmp) => { /***/ }), -/* 680 */ +/* 679 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const restoreCursor = __webpack_require__(681); +const restoreCursor = __webpack_require__(680); let hidden = false; @@ -78196,12 +78182,12 @@ exports.toggle = (force, stream) => { /***/ }), -/* 681 */ +/* 680 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const onetime = __webpack_require__(682); +const onetime = __webpack_require__(681); const signalExit = __webpack_require__(377); module.exports = onetime(() => { @@ -78212,12 +78198,12 @@ module.exports = onetime(() => { /***/ }), -/* 682 */ +/* 681 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const mimicFn = __webpack_require__(683); +const mimicFn = __webpack_require__(682); module.exports = (fn, opts) => { // TODO: Remove this in v3 @@ -78258,7 +78244,7 @@ module.exports = (fn, opts) => { /***/ }), -/* 683 */ +/* 682 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78274,22 +78260,22 @@ module.exports = (to, from) => { /***/ }), -/* 684 */ +/* 683 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -module.exports = __webpack_require__(685); +module.exports = __webpack_require__(684); /***/ }), -/* 685 */ +/* 684 */ /***/ (function(module) { module.exports = JSON.parse("{\"dots\":{\"interval\":80,\"frames\":[\"⠋\",\"⠙\",\"⠹\",\"⠸\",\"⠼\",\"⠴\",\"⠦\",\"⠧\",\"⠇\",\"⠏\"]},\"dots2\":{\"interval\":80,\"frames\":[\"⣾\",\"⣽\",\"⣻\",\"⢿\",\"⡿\",\"⣟\",\"⣯\",\"⣷\"]},\"dots3\":{\"interval\":80,\"frames\":[\"⠋\",\"⠙\",\"⠚\",\"⠞\",\"⠖\",\"⠦\",\"⠴\",\"⠲\",\"⠳\",\"⠓\"]},\"dots4\":{\"interval\":80,\"frames\":[\"⠄\",\"⠆\",\"⠇\",\"⠋\",\"⠙\",\"⠸\",\"⠰\",\"⠠\",\"⠰\",\"⠸\",\"⠙\",\"⠋\",\"⠇\",\"⠆\"]},\"dots5\":{\"interval\":80,\"frames\":[\"⠋\",\"⠙\",\"⠚\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠲\",\"⠴\",\"⠦\",\"⠖\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠓\",\"⠋\"]},\"dots6\":{\"interval\":80,\"frames\":[\"⠁\",\"⠉\",\"⠙\",\"⠚\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠲\",\"⠴\",\"⠤\",\"⠄\",\"⠄\",\"⠤\",\"⠴\",\"⠲\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠚\",\"⠙\",\"⠉\",\"⠁\"]},\"dots7\":{\"interval\":80,\"frames\":[\"⠈\",\"⠉\",\"⠋\",\"⠓\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠖\",\"⠦\",\"⠤\",\"⠠\",\"⠠\",\"⠤\",\"⠦\",\"⠖\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠓\",\"⠋\",\"⠉\",\"⠈\"]},\"dots8\":{\"interval\":80,\"frames\":[\"⠁\",\"⠁\",\"⠉\",\"⠙\",\"⠚\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠲\",\"⠴\",\"⠤\",\"⠄\",\"⠄\",\"⠤\",\"⠠\",\"⠠\",\"⠤\",\"⠦\",\"⠖\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠓\",\"⠋\",\"⠉\",\"⠈\",\"⠈\"]},\"dots9\":{\"interval\":80,\"frames\":[\"⢹\",\"⢺\",\"⢼\",\"⣸\",\"⣇\",\"⡧\",\"⡗\",\"⡏\"]},\"dots10\":{\"interval\":80,\"frames\":[\"⢄\",\"⢂\",\"⢁\",\"⡁\",\"⡈\",\"⡐\",\"⡠\"]},\"dots11\":{\"interval\":100,\"frames\":[\"⠁\",\"⠂\",\"⠄\",\"⡀\",\"⢀\",\"⠠\",\"⠐\",\"⠈\"]},\"dots12\":{\"interval\":80,\"frames\":[\"⢀⠀\",\"⡀⠀\",\"⠄⠀\",\"⢂⠀\",\"⡂⠀\",\"⠅⠀\",\"⢃⠀\",\"⡃⠀\",\"⠍⠀\",\"⢋⠀\",\"⡋⠀\",\"⠍⠁\",\"⢋⠁\",\"⡋⠁\",\"⠍⠉\",\"⠋⠉\",\"⠋⠉\",\"⠉⠙\",\"⠉⠙\",\"⠉⠩\",\"⠈⢙\",\"⠈⡙\",\"⢈⠩\",\"⡀⢙\",\"⠄⡙\",\"⢂⠩\",\"⡂⢘\",\"⠅⡘\",\"⢃⠨\",\"⡃⢐\",\"⠍⡐\",\"⢋⠠\",\"⡋⢀\",\"⠍⡁\",\"⢋⠁\",\"⡋⠁\",\"⠍⠉\",\"⠋⠉\",\"⠋⠉\",\"⠉⠙\",\"⠉⠙\",\"⠉⠩\",\"⠈⢙\",\"⠈⡙\",\"⠈⠩\",\"⠀⢙\",\"⠀⡙\",\"⠀⠩\",\"⠀⢘\",\"⠀⡘\",\"⠀⠨\",\"⠀⢐\",\"⠀⡐\",\"⠀⠠\",\"⠀⢀\",\"⠀⡀\"]},\"line\":{\"interval\":130,\"frames\":[\"-\",\"\\\\\",\"|\",\"/\"]},\"line2\":{\"interval\":100,\"frames\":[\"⠂\",\"-\",\"–\",\"—\",\"–\",\"-\"]},\"pipe\":{\"interval\":100,\"frames\":[\"┤\",\"┘\",\"┴\",\"└\",\"├\",\"┌\",\"┬\",\"┐\"]},\"simpleDots\":{\"interval\":400,\"frames\":[\". \",\".. \",\"...\",\" \"]},\"simpleDotsScrolling\":{\"interval\":200,\"frames\":[\". \",\".. \",\"...\",\" ..\",\" .\",\" \"]},\"star\":{\"interval\":70,\"frames\":[\"✶\",\"✸\",\"✹\",\"✺\",\"✹\",\"✷\"]},\"star2\":{\"interval\":80,\"frames\":[\"+\",\"x\",\"*\"]},\"flip\":{\"interval\":70,\"frames\":[\"_\",\"_\",\"_\",\"-\",\"`\",\"`\",\"'\",\"´\",\"-\",\"_\",\"_\",\"_\"]},\"hamburger\":{\"interval\":100,\"frames\":[\"☱\",\"☲\",\"☴\"]},\"growVertical\":{\"interval\":120,\"frames\":[\"▁\",\"▃\",\"▄\",\"▅\",\"▆\",\"▇\",\"▆\",\"▅\",\"▄\",\"▃\"]},\"growHorizontal\":{\"interval\":120,\"frames\":[\"▏\",\"▎\",\"▍\",\"▌\",\"▋\",\"▊\",\"▉\",\"▊\",\"▋\",\"▌\",\"▍\",\"▎\"]},\"balloon\":{\"interval\":140,\"frames\":[\" \",\".\",\"o\",\"O\",\"@\",\"*\",\" \"]},\"balloon2\":{\"interval\":120,\"frames\":[\".\",\"o\",\"O\",\"°\",\"O\",\"o\",\".\"]},\"noise\":{\"interval\":100,\"frames\":[\"▓\",\"▒\",\"░\"]},\"bounce\":{\"interval\":120,\"frames\":[\"⠁\",\"⠂\",\"⠄\",\"⠂\"]},\"boxBounce\":{\"interval\":120,\"frames\":[\"▖\",\"▘\",\"▝\",\"▗\"]},\"boxBounce2\":{\"interval\":100,\"frames\":[\"▌\",\"▀\",\"▐\",\"▄\"]},\"triangle\":{\"interval\":50,\"frames\":[\"◢\",\"◣\",\"◤\",\"◥\"]},\"arc\":{\"interval\":100,\"frames\":[\"◜\",\"◠\",\"◝\",\"◞\",\"◡\",\"◟\"]},\"circle\":{\"interval\":120,\"frames\":[\"◡\",\"⊙\",\"◠\"]},\"squareCorners\":{\"interval\":180,\"frames\":[\"◰\",\"◳\",\"◲\",\"◱\"]},\"circleQuarters\":{\"interval\":120,\"frames\":[\"◴\",\"◷\",\"◶\",\"◵\"]},\"circleHalves\":{\"interval\":50,\"frames\":[\"◐\",\"◓\",\"◑\",\"◒\"]},\"squish\":{\"interval\":100,\"frames\":[\"╫\",\"╪\"]},\"toggle\":{\"interval\":250,\"frames\":[\"⊶\",\"⊷\"]},\"toggle2\":{\"interval\":80,\"frames\":[\"▫\",\"▪\"]},\"toggle3\":{\"interval\":120,\"frames\":[\"□\",\"■\"]},\"toggle4\":{\"interval\":100,\"frames\":[\"■\",\"□\",\"▪\",\"▫\"]},\"toggle5\":{\"interval\":100,\"frames\":[\"▮\",\"▯\"]},\"toggle6\":{\"interval\":300,\"frames\":[\"ဝ\",\"၀\"]},\"toggle7\":{\"interval\":80,\"frames\":[\"⦾\",\"⦿\"]},\"toggle8\":{\"interval\":100,\"frames\":[\"◍\",\"◌\"]},\"toggle9\":{\"interval\":100,\"frames\":[\"◉\",\"◎\"]},\"toggle10\":{\"interval\":100,\"frames\":[\"㊂\",\"㊀\",\"㊁\"]},\"toggle11\":{\"interval\":50,\"frames\":[\"⧇\",\"⧆\"]},\"toggle12\":{\"interval\":120,\"frames\":[\"☗\",\"☖\"]},\"toggle13\":{\"interval\":80,\"frames\":[\"=\",\"*\",\"-\"]},\"arrow\":{\"interval\":100,\"frames\":[\"←\",\"↖\",\"↑\",\"↗\",\"→\",\"↘\",\"↓\",\"↙\"]},\"arrow2\":{\"interval\":80,\"frames\":[\"⬆️ \",\"↗️ \",\"➡️ \",\"↘️ \",\"⬇️ \",\"↙️ \",\"⬅️ \",\"↖️ \"]},\"arrow3\":{\"interval\":120,\"frames\":[\"▹▹▹▹▹\",\"▸▹▹▹▹\",\"▹▸▹▹▹\",\"▹▹▸▹▹\",\"▹▹▹▸▹\",\"▹▹▹▹▸\"]},\"bouncingBar\":{\"interval\":80,\"frames\":[\"[ ]\",\"[= ]\",\"[== ]\",\"[=== ]\",\"[ ===]\",\"[ ==]\",\"[ =]\",\"[ ]\",\"[ =]\",\"[ ==]\",\"[ ===]\",\"[====]\",\"[=== ]\",\"[== ]\",\"[= ]\"]},\"bouncingBall\":{\"interval\":80,\"frames\":[\"( ● )\",\"( ● )\",\"( ● )\",\"( ● )\",\"( ●)\",\"( ● )\",\"( ● )\",\"( ● )\",\"( ● )\",\"(● )\"]},\"smiley\":{\"interval\":200,\"frames\":[\"😄 \",\"😝 \"]},\"monkey\":{\"interval\":300,\"frames\":[\"🙈 \",\"🙈 \",\"🙉 \",\"🙊 \"]},\"hearts\":{\"interval\":100,\"frames\":[\"💛 \",\"💙 \",\"💜 \",\"💚 \",\"❤️ \"]},\"clock\":{\"interval\":100,\"frames\":[\"🕐 \",\"🕑 \",\"🕒 \",\"🕓 \",\"🕔 \",\"🕕 \",\"🕖 \",\"🕗 \",\"🕘 \",\"🕙 \",\"🕚 \"]},\"earth\":{\"interval\":180,\"frames\":[\"🌍 \",\"🌎 \",\"🌏 \"]},\"moon\":{\"interval\":80,\"frames\":[\"🌑 \",\"🌒 \",\"🌓 \",\"🌔 \",\"🌕 \",\"🌖 \",\"🌗 \",\"🌘 \"]},\"runner\":{\"interval\":140,\"frames\":[\"🚶 \",\"🏃 \"]},\"pong\":{\"interval\":80,\"frames\":[\"▐⠂ ▌\",\"▐⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂▌\",\"▐ ⠠▌\",\"▐ ⡀▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐⠠ ▌\"]},\"shark\":{\"interval\":120,\"frames\":[\"▐|\\\\____________▌\",\"▐_|\\\\___________▌\",\"▐__|\\\\__________▌\",\"▐___|\\\\_________▌\",\"▐____|\\\\________▌\",\"▐_____|\\\\_______▌\",\"▐______|\\\\______▌\",\"▐_______|\\\\_____▌\",\"▐________|\\\\____▌\",\"▐_________|\\\\___▌\",\"▐__________|\\\\__▌\",\"▐___________|\\\\_▌\",\"▐____________|\\\\▌\",\"▐____________/|▌\",\"▐___________/|_▌\",\"▐__________/|__▌\",\"▐_________/|___▌\",\"▐________/|____▌\",\"▐_______/|_____▌\",\"▐______/|______▌\",\"▐_____/|_______▌\",\"▐____/|________▌\",\"▐___/|_________▌\",\"▐__/|__________▌\",\"▐_/|___________▌\",\"▐/|____________▌\"]},\"dqpb\":{\"interval\":100,\"frames\":[\"d\",\"q\",\"p\",\"b\"]},\"weather\":{\"interval\":100,\"frames\":[\"☀️ \",\"☀️ \",\"☀️ \",\"🌤 \",\"⛅️ \",\"🌥 \",\"☁️ \",\"🌧 \",\"🌨 \",\"🌧 \",\"🌨 \",\"🌧 \",\"🌨 \",\"⛈ \",\"🌨 \",\"🌧 \",\"🌨 \",\"☁️ \",\"🌥 \",\"⛅️ \",\"🌤 \",\"☀️ \",\"☀️ \"]},\"christmas\":{\"interval\":400,\"frames\":[\"🌲\",\"🎄\"]}}"); /***/ }), -/* 686 */ +/* 685 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -78298,8 +78284,8 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(chalk__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(34); -/* harmony import */ var _utils_parallelize__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(500); -/* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(501); +/* harmony import */ var _utils_parallelize__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(499); +/* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(500); /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with @@ -78349,7 +78335,7 @@ const RunCommand = { }; /***/ }), -/* 687 */ +/* 686 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -78358,9 +78344,9 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(chalk__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(34); -/* harmony import */ var _utils_parallelize__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(500); -/* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(501); -/* harmony import */ var _utils_watch__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(688); +/* harmony import */ var _utils_parallelize__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(499); +/* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(500); +/* harmony import */ var _utils_watch__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(687); /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with @@ -78444,13 +78430,13 @@ const WatchCommand = { }; /***/ }), -/* 688 */ +/* 687 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "waitUntilWatchIsReady", function() { return waitUntilWatchIsReady; }); -/* harmony import */ var rxjs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(392); +/* harmony import */ var rxjs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(391); /* harmony import */ var rxjs_operators__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(169); /* * Licensed to Elasticsearch B.V. under one or more contributor @@ -78518,7 +78504,7 @@ function waitUntilWatchIsReady(stream, opts = {}) { } /***/ }), -/* 689 */ +/* 688 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -78526,15 +78512,15 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "runCommand", function() { return runCommand; }); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2); /* harmony import */ var chalk__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(chalk__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var indent_string__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(690); +/* harmony import */ var indent_string__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(689); /* harmony import */ var indent_string__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(indent_string__WEBPACK_IMPORTED_MODULE_1__); -/* harmony import */ var wrap_ansi__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(691); +/* harmony import */ var wrap_ansi__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(690); /* harmony import */ var wrap_ansi__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(wrap_ansi__WEBPACK_IMPORTED_MODULE_2__); -/* harmony import */ var _utils_errors__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(515); +/* harmony import */ var _utils_errors__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(514); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(34); -/* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(501); -/* harmony import */ var _utils_projects_tree__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(698); -/* harmony import */ var _utils_kibana__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(699); +/* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(500); +/* harmony import */ var _utils_projects_tree__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(697); +/* harmony import */ var _utils_kibana__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(698); 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(source, true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(source).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } @@ -78622,7 +78608,7 @@ function toArray(value) { } /***/ }), -/* 690 */ +/* 689 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78656,13 +78642,13 @@ module.exports = (str, count, opts) => { /***/ }), -/* 691 */ +/* 690 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const stringWidth = __webpack_require__(692); -const stripAnsi = __webpack_require__(696); +const stringWidth = __webpack_require__(691); +const stripAnsi = __webpack_require__(695); const ESCAPES = new Set([ '\u001B', @@ -78856,13 +78842,13 @@ module.exports = (str, cols, opts) => { /***/ }), -/* 692 */ +/* 691 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const stripAnsi = __webpack_require__(693); -const isFullwidthCodePoint = __webpack_require__(695); +const stripAnsi = __webpack_require__(692); +const isFullwidthCodePoint = __webpack_require__(694); module.exports = str => { if (typeof str !== 'string' || str.length === 0) { @@ -78899,18 +78885,18 @@ module.exports = str => { /***/ }), -/* 693 */ +/* 692 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const ansiRegex = __webpack_require__(694); +const ansiRegex = __webpack_require__(693); module.exports = input => typeof input === 'string' ? input.replace(ansiRegex(), '') : input; /***/ }), -/* 694 */ +/* 693 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78927,7 +78913,7 @@ module.exports = () => { /***/ }), -/* 695 */ +/* 694 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78980,18 +78966,18 @@ module.exports = x => { /***/ }), -/* 696 */ +/* 695 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const ansiRegex = __webpack_require__(697); +const ansiRegex = __webpack_require__(696); module.exports = input => typeof input === 'string' ? input.replace(ansiRegex(), '') : input; /***/ }), -/* 697 */ +/* 696 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79008,7 +78994,7 @@ module.exports = () => { /***/ }), -/* 698 */ +/* 697 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -79161,7 +79147,7 @@ function addProjectToTree(tree, pathParts, project) { } /***/ }), -/* 699 */ +/* 698 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -79169,12 +79155,12 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Kibana", function() { return Kibana; }); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(16); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var multimatch__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(700); +/* harmony import */ var multimatch__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(699); /* harmony import */ var multimatch__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(multimatch__WEBPACK_IMPORTED_MODULE_1__); -/* harmony import */ var is_path_inside__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(704); +/* harmony import */ var is_path_inside__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(703); /* 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 _projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(501); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(579); +/* harmony import */ var _projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(500); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(578); 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(source, true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(source).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } @@ -79315,15 +79301,15 @@ class Kibana { } /***/ }), -/* 700 */ +/* 699 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const minimatch = __webpack_require__(505); -const arrayUnion = __webpack_require__(701); -const arrayDiffer = __webpack_require__(702); -const arrify = __webpack_require__(703); +const minimatch = __webpack_require__(504); +const arrayUnion = __webpack_require__(700); +const arrayDiffer = __webpack_require__(701); +const arrify = __webpack_require__(702); module.exports = (list, patterns, options = {}) => { list = arrify(list); @@ -79347,7 +79333,7 @@ module.exports = (list, patterns, options = {}) => { /***/ }), -/* 701 */ +/* 700 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79359,7 +79345,7 @@ module.exports = (...arguments_) => { /***/ }), -/* 702 */ +/* 701 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79374,7 +79360,7 @@ module.exports = arrayDiffer; /***/ }), -/* 703 */ +/* 702 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79404,7 +79390,7 @@ module.exports = arrify; /***/ }), -/* 704 */ +/* 703 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79432,15 +79418,15 @@ module.exports = (childPath, parentPath) => { /***/ }), -/* 705 */ +/* 704 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); -/* harmony import */ var _build_production_projects__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(706); +/* harmony import */ var _build_production_projects__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(705); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildProductionProjects", function() { return _build_production_projects__WEBPACK_IMPORTED_MODULE_0__["buildProductionProjects"]; }); -/* harmony import */ var _prepare_project_dependencies__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(929); +/* harmony import */ var _prepare_project_dependencies__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(928); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "prepareExternalProjectDependencies", function() { return _prepare_project_dependencies__WEBPACK_IMPORTED_MODULE_1__["prepareExternalProjectDependencies"]; }); /* @@ -79465,23 +79451,23 @@ __webpack_require__.r(__webpack_exports__); /***/ }), -/* 706 */ +/* 705 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "buildProductionProjects", function() { return buildProductionProjects; }); -/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(707); +/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(706); /* 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__(587); +/* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(586); /* 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__(16); /* 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__(579); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(578); /* harmony import */ var _utils_fs__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(20); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(34); -/* harmony import */ var _utils_package_json__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(517); -/* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(501); +/* harmony import */ var _utils_package_json__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(516); +/* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(500); /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with @@ -79613,7 +79599,7 @@ async function copyToBuild(project, kibanaRoot, buildRoot) { } /***/ }), -/* 707 */ +/* 706 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79621,13 +79607,13 @@ async function copyToBuild(project, kibanaRoot, buildRoot) { const EventEmitter = __webpack_require__(379); const path = __webpack_require__(16); const os = __webpack_require__(11); -const pAll = __webpack_require__(708); -const arrify = __webpack_require__(710); -const globby = __webpack_require__(711); -const isGlob = __webpack_require__(605); -const cpFile = __webpack_require__(914); -const junk = __webpack_require__(926); -const CpyError = __webpack_require__(927); +const pAll = __webpack_require__(707); +const arrify = __webpack_require__(709); +const globby = __webpack_require__(710); +const isGlob = __webpack_require__(604); +const cpFile = __webpack_require__(913); +const junk = __webpack_require__(925); +const CpyError = __webpack_require__(926); const defaultOptions = { ignoreJunk: true @@ -79746,12 +79732,12 @@ module.exports = (source, destination, { /***/ }), -/* 708 */ +/* 707 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pMap = __webpack_require__(709); +const pMap = __webpack_require__(708); module.exports = (iterable, options) => pMap(iterable, element => element(), options); // TODO: Remove this for the next major release @@ -79759,7 +79745,7 @@ module.exports.default = module.exports; /***/ }), -/* 709 */ +/* 708 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79838,7 +79824,7 @@ module.exports.default = pMap; /***/ }), -/* 710 */ +/* 709 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79868,17 +79854,17 @@ module.exports = arrify; /***/ }), -/* 711 */ +/* 710 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(23); -const arrayUnion = __webpack_require__(712); -const glob = __webpack_require__(714); -const fastGlob = __webpack_require__(719); -const dirGlob = __webpack_require__(907); -const gitignore = __webpack_require__(910); +const arrayUnion = __webpack_require__(711); +const glob = __webpack_require__(713); +const fastGlob = __webpack_require__(718); +const dirGlob = __webpack_require__(906); +const gitignore = __webpack_require__(909); const DEFAULT_FILTER = () => false; @@ -80023,12 +80009,12 @@ module.exports.gitignore = gitignore; /***/ }), -/* 712 */ +/* 711 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var arrayUniq = __webpack_require__(713); +var arrayUniq = __webpack_require__(712); module.exports = function () { return arrayUniq([].concat.apply([], arguments)); @@ -80036,7 +80022,7 @@ module.exports = function () { /***/ }), -/* 713 */ +/* 712 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80105,7 +80091,7 @@ if ('Set' in global) { /***/ }), -/* 714 */ +/* 713 */ /***/ (function(module, exports, __webpack_require__) { // Approach: @@ -80151,26 +80137,26 @@ if ('Set' in global) { module.exports = glob var fs = __webpack_require__(23) -var rp = __webpack_require__(503) -var minimatch = __webpack_require__(505) +var rp = __webpack_require__(502) +var minimatch = __webpack_require__(504) var Minimatch = minimatch.Minimatch -var inherits = __webpack_require__(715) +var inherits = __webpack_require__(714) var EE = __webpack_require__(379).EventEmitter var path = __webpack_require__(16) var assert = __webpack_require__(30) -var isAbsolute = __webpack_require__(511) -var globSync = __webpack_require__(717) -var common = __webpack_require__(718) +var isAbsolute = __webpack_require__(510) +var globSync = __webpack_require__(716) +var common = __webpack_require__(717) var alphasort = common.alphasort var alphasorti = common.alphasorti var setopts = common.setopts var ownProp = common.ownProp -var inflight = __webpack_require__(514) +var inflight = __webpack_require__(513) var util = __webpack_require__(29) var childrenIgnored = common.childrenIgnored var isIgnored = common.isIgnored -var once = __webpack_require__(385) +var once = __webpack_require__(384) function glob (pattern, options, cb) { if (typeof options === 'function') cb = options, options = {} @@ -80901,7 +80887,7 @@ Glob.prototype._stat2 = function (f, abs, er, stat, cb) { /***/ }), -/* 715 */ +/* 714 */ /***/ (function(module, exports, __webpack_require__) { try { @@ -80911,12 +80897,12 @@ try { module.exports = util.inherits; } catch (e) { /* istanbul ignore next */ - module.exports = __webpack_require__(716); + module.exports = __webpack_require__(715); } /***/ }), -/* 716 */ +/* 715 */ /***/ (function(module, exports) { if (typeof Object.create === 'function') { @@ -80949,22 +80935,22 @@ if (typeof Object.create === 'function') { /***/ }), -/* 717 */ +/* 716 */ /***/ (function(module, exports, __webpack_require__) { module.exports = globSync globSync.GlobSync = GlobSync var fs = __webpack_require__(23) -var rp = __webpack_require__(503) -var minimatch = __webpack_require__(505) +var rp = __webpack_require__(502) +var minimatch = __webpack_require__(504) var Minimatch = minimatch.Minimatch -var Glob = __webpack_require__(714).Glob +var Glob = __webpack_require__(713).Glob var util = __webpack_require__(29) var path = __webpack_require__(16) var assert = __webpack_require__(30) -var isAbsolute = __webpack_require__(511) -var common = __webpack_require__(718) +var isAbsolute = __webpack_require__(510) +var common = __webpack_require__(717) var alphasort = common.alphasort var alphasorti = common.alphasorti var setopts = common.setopts @@ -81441,7 +81427,7 @@ GlobSync.prototype._makeAbs = function (f) { /***/ }), -/* 718 */ +/* 717 */ /***/ (function(module, exports, __webpack_require__) { exports.alphasort = alphasort @@ -81459,8 +81445,8 @@ function ownProp (obj, field) { } var path = __webpack_require__(16) -var minimatch = __webpack_require__(505) -var isAbsolute = __webpack_require__(511) +var minimatch = __webpack_require__(504) +var isAbsolute = __webpack_require__(510) var Minimatch = minimatch.Minimatch function alphasorti (a, b) { @@ -81687,10 +81673,10 @@ function childrenIgnored (self, path) { /***/ }), -/* 719 */ +/* 718 */ /***/ (function(module, exports, __webpack_require__) { -const pkg = __webpack_require__(720); +const pkg = __webpack_require__(719); module.exports = pkg.async; module.exports.default = pkg.async; @@ -81703,19 +81689,19 @@ module.exports.generateTasks = pkg.generateTasks; /***/ }), -/* 720 */ +/* 719 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var optionsManager = __webpack_require__(721); -var taskManager = __webpack_require__(722); -var reader_async_1 = __webpack_require__(878); -var reader_stream_1 = __webpack_require__(902); -var reader_sync_1 = __webpack_require__(903); -var arrayUtils = __webpack_require__(905); -var streamUtils = __webpack_require__(906); +var optionsManager = __webpack_require__(720); +var taskManager = __webpack_require__(721); +var reader_async_1 = __webpack_require__(877); +var reader_stream_1 = __webpack_require__(901); +var reader_sync_1 = __webpack_require__(902); +var arrayUtils = __webpack_require__(904); +var streamUtils = __webpack_require__(905); /** * Synchronous API. */ @@ -81781,7 +81767,7 @@ function isString(source) { /***/ }), -/* 721 */ +/* 720 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81819,13 +81805,13 @@ exports.prepare = prepare; /***/ }), -/* 722 */ +/* 721 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var patternUtils = __webpack_require__(723); +var patternUtils = __webpack_require__(722); /** * Generate tasks based on parent directory of each pattern. */ @@ -81916,16 +81902,16 @@ exports.convertPatternGroupToTask = convertPatternGroupToTask; /***/ }), -/* 723 */ +/* 722 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var path = __webpack_require__(16); -var globParent = __webpack_require__(724); -var isGlob = __webpack_require__(727); -var micromatch = __webpack_require__(728); +var globParent = __webpack_require__(723); +var isGlob = __webpack_require__(726); +var micromatch = __webpack_require__(727); var GLOBSTAR = '**'; /** * Return true for static pattern. @@ -82071,15 +82057,15 @@ exports.matchAny = matchAny; /***/ }), -/* 724 */ +/* 723 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var path = __webpack_require__(16); -var isglob = __webpack_require__(725); -var pathDirname = __webpack_require__(726); +var isglob = __webpack_require__(724); +var pathDirname = __webpack_require__(725); var isWin32 = __webpack_require__(11).platform() === 'win32'; module.exports = function globParent(str) { @@ -82102,7 +82088,7 @@ module.exports = function globParent(str) { /***/ }), -/* 725 */ +/* 724 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -82112,7 +82098,7 @@ module.exports = function globParent(str) { * Licensed under the MIT License. */ -var isExtglob = __webpack_require__(606); +var isExtglob = __webpack_require__(605); module.exports = function isGlob(str) { if (typeof str !== 'string' || str === '') { @@ -82133,7 +82119,7 @@ module.exports = function isGlob(str) { /***/ }), -/* 726 */ +/* 725 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82283,7 +82269,7 @@ module.exports.win32 = win32; /***/ }), -/* 727 */ +/* 726 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -82293,7 +82279,7 @@ module.exports.win32 = win32; * Released under the MIT License. */ -var isExtglob = __webpack_require__(606); +var isExtglob = __webpack_require__(605); var chars = { '{': '}', '(': ')', '[': ']'}; module.exports = function isGlob(str, options) { @@ -82335,7 +82321,7 @@ module.exports = function isGlob(str, options) { /***/ }), -/* 728 */ +/* 727 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82346,18 +82332,18 @@ module.exports = function isGlob(str, options) { */ var util = __webpack_require__(29); -var braces = __webpack_require__(729); -var toRegex = __webpack_require__(831); -var extend = __webpack_require__(839); +var braces = __webpack_require__(728); +var toRegex = __webpack_require__(830); +var extend = __webpack_require__(838); /** * Local dependencies */ -var compilers = __webpack_require__(842); -var parsers = __webpack_require__(874); -var cache = __webpack_require__(875); -var utils = __webpack_require__(876); +var compilers = __webpack_require__(841); +var parsers = __webpack_require__(873); +var cache = __webpack_require__(874); +var utils = __webpack_require__(875); var MAX_LENGTH = 1024 * 64; /** @@ -83219,7 +83205,7 @@ module.exports = micromatch; /***/ }), -/* 729 */ +/* 728 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83229,18 +83215,18 @@ module.exports = micromatch; * Module dependencies */ -var toRegex = __webpack_require__(730); -var unique = __webpack_require__(742); -var extend = __webpack_require__(739); +var toRegex = __webpack_require__(729); +var unique = __webpack_require__(741); +var extend = __webpack_require__(738); /** * Local dependencies */ -var compilers = __webpack_require__(743); -var parsers = __webpack_require__(758); -var Braces = __webpack_require__(768); -var utils = __webpack_require__(744); +var compilers = __webpack_require__(742); +var parsers = __webpack_require__(757); +var Braces = __webpack_require__(767); +var utils = __webpack_require__(743); var MAX_LENGTH = 1024 * 64; var cache = {}; @@ -83544,15 +83530,15 @@ module.exports = braces; /***/ }), -/* 730 */ +/* 729 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var define = __webpack_require__(731); -var extend = __webpack_require__(739); -var not = __webpack_require__(741); +var define = __webpack_require__(730); +var extend = __webpack_require__(738); +var not = __webpack_require__(740); var MAX_LENGTH = 1024 * 64; /** @@ -83699,7 +83685,7 @@ module.exports.makeRe = makeRe; /***/ }), -/* 731 */ +/* 730 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83712,7 +83698,7 @@ module.exports.makeRe = makeRe; -var isDescriptor = __webpack_require__(732); +var isDescriptor = __webpack_require__(731); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -83737,7 +83723,7 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 732 */ +/* 731 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83750,9 +83736,9 @@ module.exports = function defineProperty(obj, prop, val) { -var typeOf = __webpack_require__(733); -var isAccessor = __webpack_require__(734); -var isData = __webpack_require__(737); +var typeOf = __webpack_require__(732); +var isAccessor = __webpack_require__(733); +var isData = __webpack_require__(736); module.exports = function isDescriptor(obj, key) { if (typeOf(obj) !== 'object') { @@ -83766,7 +83752,7 @@ module.exports = function isDescriptor(obj, key) { /***/ }), -/* 733 */ +/* 732 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -83919,7 +83905,7 @@ function isBuffer(val) { /***/ }), -/* 734 */ +/* 733 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83932,7 +83918,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(735); +var typeOf = __webpack_require__(734); // accessor descriptor properties var accessor = { @@ -83995,10 +83981,10 @@ module.exports = isAccessorDescriptor; /***/ }), -/* 735 */ +/* 734 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(736); +var isBuffer = __webpack_require__(735); var toString = Object.prototype.toString; /** @@ -84117,7 +84103,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 736 */ +/* 735 */ /***/ (function(module, exports) { /*! @@ -84144,7 +84130,7 @@ function isSlowBuffer (obj) { /***/ }), -/* 737 */ +/* 736 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84157,7 +84143,7 @@ function isSlowBuffer (obj) { -var typeOf = __webpack_require__(738); +var typeOf = __webpack_require__(737); // data descriptor properties var data = { @@ -84206,10 +84192,10 @@ module.exports = isDataDescriptor; /***/ }), -/* 738 */ +/* 737 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(736); +var isBuffer = __webpack_require__(735); var toString = Object.prototype.toString; /** @@ -84328,13 +84314,13 @@ module.exports = function kindOf(val) { /***/ }), -/* 739 */ +/* 738 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(740); +var isObject = __webpack_require__(739); module.exports = function extend(o/*, objects*/) { if (!isObject(o)) { o = {}; } @@ -84368,7 +84354,7 @@ function hasOwn(obj, key) { /***/ }), -/* 740 */ +/* 739 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84388,13 +84374,13 @@ module.exports = function isExtendable(val) { /***/ }), -/* 741 */ +/* 740 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extend = __webpack_require__(739); +var extend = __webpack_require__(738); /** * The main export is a function that takes a `pattern` string and an `options` object. @@ -84461,7 +84447,7 @@ module.exports = toRegex; /***/ }), -/* 742 */ +/* 741 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84511,13 +84497,13 @@ module.exports.immutable = function uniqueImmutable(arr) { /***/ }), -/* 743 */ +/* 742 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(744); +var utils = __webpack_require__(743); module.exports = function(braces, options) { braces.compiler @@ -84800,25 +84786,25 @@ function hasQueue(node) { /***/ }), -/* 744 */ +/* 743 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var splitString = __webpack_require__(745); +var splitString = __webpack_require__(744); var utils = module.exports; /** * Module dependencies */ -utils.extend = __webpack_require__(739); -utils.flatten = __webpack_require__(751); -utils.isObject = __webpack_require__(749); -utils.fillRange = __webpack_require__(752); -utils.repeat = __webpack_require__(757); -utils.unique = __webpack_require__(742); +utils.extend = __webpack_require__(738); +utils.flatten = __webpack_require__(750); +utils.isObject = __webpack_require__(748); +utils.fillRange = __webpack_require__(751); +utils.repeat = __webpack_require__(756); +utils.unique = __webpack_require__(741); utils.define = function(obj, key, val) { Object.defineProperty(obj, key, { @@ -85150,7 +85136,7 @@ utils.escapeRegex = function(str) { /***/ }), -/* 745 */ +/* 744 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85163,7 +85149,7 @@ utils.escapeRegex = function(str) { -var extend = __webpack_require__(746); +var extend = __webpack_require__(745); module.exports = function(str, options, fn) { if (typeof str !== 'string') { @@ -85328,14 +85314,14 @@ function keepEscaping(opts, str, idx) { /***/ }), -/* 746 */ +/* 745 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(747); -var assignSymbols = __webpack_require__(750); +var isExtendable = __webpack_require__(746); +var assignSymbols = __webpack_require__(749); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -85395,7 +85381,7 @@ function isEnum(obj, key) { /***/ }), -/* 747 */ +/* 746 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85408,7 +85394,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(748); +var isPlainObject = __webpack_require__(747); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -85416,7 +85402,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 748 */ +/* 747 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85429,7 +85415,7 @@ module.exports = function isExtendable(val) { -var isObject = __webpack_require__(749); +var isObject = __webpack_require__(748); function isObjectObject(o) { return isObject(o) === true @@ -85460,7 +85446,7 @@ module.exports = function isPlainObject(o) { /***/ }), -/* 749 */ +/* 748 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85479,7 +85465,7 @@ module.exports = function isObject(val) { /***/ }), -/* 750 */ +/* 749 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85526,7 +85512,7 @@ module.exports = function(receiver, objects) { /***/ }), -/* 751 */ +/* 750 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85555,7 +85541,7 @@ function flat(arr, res) { /***/ }), -/* 752 */ +/* 751 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85569,10 +85555,10 @@ function flat(arr, res) { var util = __webpack_require__(29); -var isNumber = __webpack_require__(753); -var extend = __webpack_require__(739); -var repeat = __webpack_require__(755); -var toRegex = __webpack_require__(756); +var isNumber = __webpack_require__(752); +var extend = __webpack_require__(738); +var repeat = __webpack_require__(754); +var toRegex = __webpack_require__(755); /** * Return a range of numbers or letters. @@ -85770,7 +85756,7 @@ module.exports = fillRange; /***/ }), -/* 753 */ +/* 752 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85783,7 +85769,7 @@ module.exports = fillRange; -var typeOf = __webpack_require__(754); +var typeOf = __webpack_require__(753); module.exports = function isNumber(num) { var type = typeOf(num); @@ -85799,10 +85785,10 @@ module.exports = function isNumber(num) { /***/ }), -/* 754 */ +/* 753 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(736); +var isBuffer = __webpack_require__(735); var toString = Object.prototype.toString; /** @@ -85921,7 +85907,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 755 */ +/* 754 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -85998,7 +85984,7 @@ function repeat(str, num) { /***/ }), -/* 756 */ +/* 755 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86011,8 +85997,8 @@ function repeat(str, num) { -var repeat = __webpack_require__(755); -var isNumber = __webpack_require__(753); +var repeat = __webpack_require__(754); +var isNumber = __webpack_require__(752); var cache = {}; function toRegexRange(min, max, options) { @@ -86299,7 +86285,7 @@ module.exports = toRegexRange; /***/ }), -/* 757 */ +/* 756 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86324,14 +86310,14 @@ module.exports = function repeat(ele, num) { /***/ }), -/* 758 */ +/* 757 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var Node = __webpack_require__(759); -var utils = __webpack_require__(744); +var Node = __webpack_require__(758); +var utils = __webpack_require__(743); /** * Braces parsers @@ -86691,15 +86677,15 @@ function concatNodes(pos, node, parent, options) { /***/ }), -/* 759 */ +/* 758 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(749); -var define = __webpack_require__(760); -var utils = __webpack_require__(767); +var isObject = __webpack_require__(748); +var define = __webpack_require__(759); +var utils = __webpack_require__(766); var ownNames; /** @@ -87190,7 +87176,7 @@ exports = module.exports = Node; /***/ }), -/* 760 */ +/* 759 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87203,7 +87189,7 @@ exports = module.exports = Node; -var isDescriptor = __webpack_require__(761); +var isDescriptor = __webpack_require__(760); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -87228,7 +87214,7 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 761 */ +/* 760 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87241,9 +87227,9 @@ module.exports = function defineProperty(obj, prop, val) { -var typeOf = __webpack_require__(762); -var isAccessor = __webpack_require__(763); -var isData = __webpack_require__(765); +var typeOf = __webpack_require__(761); +var isAccessor = __webpack_require__(762); +var isData = __webpack_require__(764); module.exports = function isDescriptor(obj, key) { if (typeOf(obj) !== 'object') { @@ -87257,7 +87243,7 @@ module.exports = function isDescriptor(obj, key) { /***/ }), -/* 762 */ +/* 761 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -87392,7 +87378,7 @@ function isBuffer(val) { /***/ }), -/* 763 */ +/* 762 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87405,7 +87391,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(764); +var typeOf = __webpack_require__(763); // accessor descriptor properties var accessor = { @@ -87468,7 +87454,7 @@ module.exports = isAccessorDescriptor; /***/ }), -/* 764 */ +/* 763 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -87603,7 +87589,7 @@ function isBuffer(val) { /***/ }), -/* 765 */ +/* 764 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -87616,7 +87602,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(766); +var typeOf = __webpack_require__(765); module.exports = function isDataDescriptor(obj, prop) { // data descriptor properties @@ -87659,7 +87645,7 @@ module.exports = function isDataDescriptor(obj, prop) { /***/ }), -/* 766 */ +/* 765 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -87794,13 +87780,13 @@ function isBuffer(val) { /***/ }), -/* 767 */ +/* 766 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var typeOf = __webpack_require__(754); +var typeOf = __webpack_require__(753); var utils = module.exports; /** @@ -88820,17 +88806,17 @@ function assert(val, message) { /***/ }), -/* 768 */ +/* 767 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extend = __webpack_require__(739); -var Snapdragon = __webpack_require__(769); -var compilers = __webpack_require__(743); -var parsers = __webpack_require__(758); -var utils = __webpack_require__(744); +var extend = __webpack_require__(738); +var Snapdragon = __webpack_require__(768); +var compilers = __webpack_require__(742); +var parsers = __webpack_require__(757); +var utils = __webpack_require__(743); /** * Customize Snapdragon parser and renderer @@ -88931,17 +88917,17 @@ module.exports = Braces; /***/ }), -/* 769 */ +/* 768 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var Base = __webpack_require__(770); -var define = __webpack_require__(731); -var Compiler = __webpack_require__(799); -var Parser = __webpack_require__(828); -var utils = __webpack_require__(808); +var Base = __webpack_require__(769); +var define = __webpack_require__(730); +var Compiler = __webpack_require__(798); +var Parser = __webpack_require__(827); +var utils = __webpack_require__(807); var regexCache = {}; var cache = {}; @@ -89112,20 +89098,20 @@ module.exports.Parser = Parser; /***/ }), -/* 770 */ +/* 769 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(29); -var define = __webpack_require__(771); -var CacheBase = __webpack_require__(772); -var Emitter = __webpack_require__(773); -var isObject = __webpack_require__(749); -var merge = __webpack_require__(790); -var pascal = __webpack_require__(793); -var cu = __webpack_require__(794); +var define = __webpack_require__(770); +var CacheBase = __webpack_require__(771); +var Emitter = __webpack_require__(772); +var isObject = __webpack_require__(748); +var merge = __webpack_require__(789); +var pascal = __webpack_require__(792); +var cu = __webpack_require__(793); /** * Optionally define a custom `cache` namespace to use. @@ -89554,7 +89540,7 @@ module.exports.namespace = namespace; /***/ }), -/* 771 */ +/* 770 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -89567,7 +89553,7 @@ module.exports.namespace = namespace; -var isDescriptor = __webpack_require__(761); +var isDescriptor = __webpack_require__(760); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -89592,21 +89578,21 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 772 */ +/* 771 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(749); -var Emitter = __webpack_require__(773); -var visit = __webpack_require__(774); -var toPath = __webpack_require__(777); -var union = __webpack_require__(778); -var del = __webpack_require__(782); -var get = __webpack_require__(780); -var has = __webpack_require__(787); -var set = __webpack_require__(781); +var isObject = __webpack_require__(748); +var Emitter = __webpack_require__(772); +var visit = __webpack_require__(773); +var toPath = __webpack_require__(776); +var union = __webpack_require__(777); +var del = __webpack_require__(781); +var get = __webpack_require__(779); +var has = __webpack_require__(786); +var set = __webpack_require__(780); /** * Create a `Cache` constructor that when instantiated will @@ -89860,7 +89846,7 @@ module.exports.namespace = namespace; /***/ }), -/* 773 */ +/* 772 */ /***/ (function(module, exports, __webpack_require__) { @@ -90029,7 +90015,7 @@ Emitter.prototype.hasListeners = function(event){ /***/ }), -/* 774 */ +/* 773 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90042,8 +90028,8 @@ Emitter.prototype.hasListeners = function(event){ -var visit = __webpack_require__(775); -var mapVisit = __webpack_require__(776); +var visit = __webpack_require__(774); +var mapVisit = __webpack_require__(775); module.exports = function(collection, method, val) { var result; @@ -90066,7 +90052,7 @@ module.exports = function(collection, method, val) { /***/ }), -/* 775 */ +/* 774 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90079,7 +90065,7 @@ module.exports = function(collection, method, val) { -var isObject = __webpack_require__(749); +var isObject = __webpack_require__(748); module.exports = function visit(thisArg, method, target, val) { if (!isObject(thisArg) && typeof thisArg !== 'function') { @@ -90106,14 +90092,14 @@ module.exports = function visit(thisArg, method, target, val) { /***/ }), -/* 776 */ +/* 775 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(29); -var visit = __webpack_require__(775); +var visit = __webpack_require__(774); /** * Map `visit` over an array of objects. @@ -90150,7 +90136,7 @@ function isObject(val) { /***/ }), -/* 777 */ +/* 776 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90163,7 +90149,7 @@ function isObject(val) { -var typeOf = __webpack_require__(754); +var typeOf = __webpack_require__(753); module.exports = function toPath(args) { if (typeOf(args) !== 'arguments') { @@ -90190,16 +90176,16 @@ function filter(arr) { /***/ }), -/* 778 */ +/* 777 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(740); -var union = __webpack_require__(779); -var get = __webpack_require__(780); -var set = __webpack_require__(781); +var isObject = __webpack_require__(739); +var union = __webpack_require__(778); +var get = __webpack_require__(779); +var set = __webpack_require__(780); module.exports = function unionValue(obj, prop, value) { if (!isObject(obj)) { @@ -90227,7 +90213,7 @@ function arrayify(val) { /***/ }), -/* 779 */ +/* 778 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90263,7 +90249,7 @@ module.exports = function union(init) { /***/ }), -/* 780 */ +/* 779 */ /***/ (function(module, exports) { /*! @@ -90319,7 +90305,7 @@ function toString(val) { /***/ }), -/* 781 */ +/* 780 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90332,10 +90318,10 @@ function toString(val) { -var split = __webpack_require__(745); -var extend = __webpack_require__(739); -var isPlainObject = __webpack_require__(748); -var isObject = __webpack_require__(740); +var split = __webpack_require__(744); +var extend = __webpack_require__(738); +var isPlainObject = __webpack_require__(747); +var isObject = __webpack_require__(739); module.exports = function(obj, prop, val) { if (!isObject(obj)) { @@ -90381,7 +90367,7 @@ function isValidKey(key) { /***/ }), -/* 782 */ +/* 781 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90394,8 +90380,8 @@ function isValidKey(key) { -var isObject = __webpack_require__(749); -var has = __webpack_require__(783); +var isObject = __webpack_require__(748); +var has = __webpack_require__(782); module.exports = function unset(obj, prop) { if (!isObject(obj)) { @@ -90420,7 +90406,7 @@ module.exports = function unset(obj, prop) { /***/ }), -/* 783 */ +/* 782 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90433,9 +90419,9 @@ module.exports = function unset(obj, prop) { -var isObject = __webpack_require__(784); -var hasValues = __webpack_require__(786); -var get = __webpack_require__(780); +var isObject = __webpack_require__(783); +var hasValues = __webpack_require__(785); +var get = __webpack_require__(779); module.exports = function(obj, prop, noZero) { if (isObject(obj)) { @@ -90446,7 +90432,7 @@ module.exports = function(obj, prop, noZero) { /***/ }), -/* 784 */ +/* 783 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90459,7 +90445,7 @@ module.exports = function(obj, prop, noZero) { -var isArray = __webpack_require__(785); +var isArray = __webpack_require__(784); module.exports = function isObject(val) { return val != null && typeof val === 'object' && isArray(val) === false; @@ -90467,7 +90453,7 @@ module.exports = function isObject(val) { /***/ }), -/* 785 */ +/* 784 */ /***/ (function(module, exports) { var toString = {}.toString; @@ -90478,7 +90464,7 @@ module.exports = Array.isArray || function (arr) { /***/ }), -/* 786 */ +/* 785 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90521,7 +90507,7 @@ module.exports = function hasValue(o, noZero) { /***/ }), -/* 787 */ +/* 786 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90534,9 +90520,9 @@ module.exports = function hasValue(o, noZero) { -var isObject = __webpack_require__(749); -var hasValues = __webpack_require__(788); -var get = __webpack_require__(780); +var isObject = __webpack_require__(748); +var hasValues = __webpack_require__(787); +var get = __webpack_require__(779); module.exports = function(val, prop) { return hasValues(isObject(val) && prop ? get(val, prop) : val); @@ -90544,7 +90530,7 @@ module.exports = function(val, prop) { /***/ }), -/* 788 */ +/* 787 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90557,8 +90543,8 @@ module.exports = function(val, prop) { -var typeOf = __webpack_require__(789); -var isNumber = __webpack_require__(753); +var typeOf = __webpack_require__(788); +var isNumber = __webpack_require__(752); module.exports = function hasValue(val) { // is-number checks for NaN and other edge cases @@ -90611,10 +90597,10 @@ module.exports = function hasValue(val) { /***/ }), -/* 789 */ +/* 788 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(736); +var isBuffer = __webpack_require__(735); var toString = Object.prototype.toString; /** @@ -90736,14 +90722,14 @@ module.exports = function kindOf(val) { /***/ }), -/* 790 */ +/* 789 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(791); -var forIn = __webpack_require__(792); +var isExtendable = __webpack_require__(790); +var forIn = __webpack_require__(791); function mixinDeep(target, objects) { var len = arguments.length, i = 0; @@ -90807,7 +90793,7 @@ module.exports = mixinDeep; /***/ }), -/* 791 */ +/* 790 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90820,7 +90806,7 @@ module.exports = mixinDeep; -var isPlainObject = __webpack_require__(748); +var isPlainObject = __webpack_require__(747); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -90828,7 +90814,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 792 */ +/* 791 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -90851,7 +90837,7 @@ module.exports = function forIn(obj, fn, thisArg) { /***/ }), -/* 793 */ +/* 792 */ /***/ (function(module, exports) { /*! @@ -90878,14 +90864,14 @@ module.exports = pascalcase; /***/ }), -/* 794 */ +/* 793 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(29); -var utils = __webpack_require__(795); +var utils = __webpack_require__(794); /** * Expose class utils @@ -91250,7 +91236,7 @@ cu.bubble = function(Parent, events) { /***/ }), -/* 795 */ +/* 794 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -91264,10 +91250,10 @@ var utils = {}; * Lazily required module dependencies */ -utils.union = __webpack_require__(779); -utils.define = __webpack_require__(731); -utils.isObj = __webpack_require__(749); -utils.staticExtend = __webpack_require__(796); +utils.union = __webpack_require__(778); +utils.define = __webpack_require__(730); +utils.isObj = __webpack_require__(748); +utils.staticExtend = __webpack_require__(795); /** @@ -91278,7 +91264,7 @@ module.exports = utils; /***/ }), -/* 796 */ +/* 795 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -91291,8 +91277,8 @@ module.exports = utils; -var copy = __webpack_require__(797); -var define = __webpack_require__(731); +var copy = __webpack_require__(796); +var define = __webpack_require__(730); var util = __webpack_require__(29); /** @@ -91375,15 +91361,15 @@ module.exports = extend; /***/ }), -/* 797 */ +/* 796 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var typeOf = __webpack_require__(754); -var copyDescriptor = __webpack_require__(798); -var define = __webpack_require__(731); +var typeOf = __webpack_require__(753); +var copyDescriptor = __webpack_require__(797); +var define = __webpack_require__(730); /** * Copy static properties, prototype properties, and descriptors from one object to another. @@ -91556,7 +91542,7 @@ module.exports.has = has; /***/ }), -/* 798 */ +/* 797 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -91644,16 +91630,16 @@ function isObject(val) { /***/ }), -/* 799 */ +/* 798 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var use = __webpack_require__(800); -var define = __webpack_require__(731); -var debug = __webpack_require__(802)('snapdragon:compiler'); -var utils = __webpack_require__(808); +var use = __webpack_require__(799); +var define = __webpack_require__(730); +var debug = __webpack_require__(801)('snapdragon:compiler'); +var utils = __webpack_require__(807); /** * Create a new `Compiler` with the given `options`. @@ -91807,7 +91793,7 @@ Compiler.prototype = { // source map support if (opts.sourcemap) { - var sourcemaps = __webpack_require__(827); + var sourcemaps = __webpack_require__(826); sourcemaps(this); this.mapVisit(this.ast.nodes); this.applySourceMaps(); @@ -91828,7 +91814,7 @@ module.exports = Compiler; /***/ }), -/* 800 */ +/* 799 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -91841,7 +91827,7 @@ module.exports = Compiler; -var utils = __webpack_require__(801); +var utils = __webpack_require__(800); module.exports = function base(app, opts) { if (!utils.isObject(app) && typeof app !== 'function') { @@ -91956,7 +91942,7 @@ module.exports = function base(app, opts) { /***/ }), -/* 801 */ +/* 800 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -91970,8 +91956,8 @@ var utils = {}; * Lazily required module dependencies */ -utils.define = __webpack_require__(731); -utils.isObject = __webpack_require__(749); +utils.define = __webpack_require__(730); +utils.isObject = __webpack_require__(748); utils.isString = function(val) { @@ -91986,7 +91972,7 @@ module.exports = utils; /***/ }), -/* 802 */ +/* 801 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -91995,14 +91981,14 @@ module.exports = utils; */ if (typeof process !== 'undefined' && process.type === 'renderer') { - module.exports = __webpack_require__(803); + module.exports = __webpack_require__(802); } else { - module.exports = __webpack_require__(806); + module.exports = __webpack_require__(805); } /***/ }), -/* 803 */ +/* 802 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -92011,7 +91997,7 @@ if (typeof process !== 'undefined' && process.type === 'renderer') { * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(804); +exports = module.exports = __webpack_require__(803); exports.log = log; exports.formatArgs = formatArgs; exports.save = save; @@ -92193,7 +92179,7 @@ function localstorage() { /***/ }), -/* 804 */ +/* 803 */ /***/ (function(module, exports, __webpack_require__) { @@ -92209,7 +92195,7 @@ exports.coerce = coerce; exports.disable = disable; exports.enable = enable; exports.enabled = enabled; -exports.humanize = __webpack_require__(805); +exports.humanize = __webpack_require__(804); /** * The currently active debug mode names, and names to skip. @@ -92401,7 +92387,7 @@ function coerce(val) { /***/ }), -/* 805 */ +/* 804 */ /***/ (function(module, exports) { /** @@ -92559,14 +92545,14 @@ function plural(ms, n, name) { /***/ }), -/* 806 */ +/* 805 */ /***/ (function(module, exports, __webpack_require__) { /** * Module dependencies. */ -var tty = __webpack_require__(480); +var tty = __webpack_require__(479); var util = __webpack_require__(29); /** @@ -92575,7 +92561,7 @@ var util = __webpack_require__(29); * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(804); +exports = module.exports = __webpack_require__(803); exports.init = init; exports.log = log; exports.formatArgs = formatArgs; @@ -92754,7 +92740,7 @@ function createWritableStdioStream (fd) { case 'PIPE': case 'TCP': - var net = __webpack_require__(807); + var net = __webpack_require__(806); stream = new net.Socket({ fd: fd, readable: false, @@ -92813,13 +92799,13 @@ exports.enable(load()); /***/ }), -/* 807 */ +/* 806 */ /***/ (function(module, exports) { module.exports = require("net"); /***/ }), -/* 808 */ +/* 807 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -92829,9 +92815,9 @@ module.exports = require("net"); * Module dependencies */ -exports.extend = __webpack_require__(739); -exports.SourceMap = __webpack_require__(809); -exports.sourceMapResolve = __webpack_require__(820); +exports.extend = __webpack_require__(738); +exports.SourceMap = __webpack_require__(808); +exports.sourceMapResolve = __webpack_require__(819); /** * Convert backslash in the given string to forward slashes @@ -92874,7 +92860,7 @@ exports.last = function(arr, n) { /***/ }), -/* 809 */ +/* 808 */ /***/ (function(module, exports, __webpack_require__) { /* @@ -92882,13 +92868,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__(810).SourceMapGenerator; -exports.SourceMapConsumer = __webpack_require__(816).SourceMapConsumer; -exports.SourceNode = __webpack_require__(819).SourceNode; +exports.SourceMapGenerator = __webpack_require__(809).SourceMapGenerator; +exports.SourceMapConsumer = __webpack_require__(815).SourceMapConsumer; +exports.SourceNode = __webpack_require__(818).SourceNode; /***/ }), -/* 810 */ +/* 809 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -92898,10 +92884,10 @@ exports.SourceNode = __webpack_require__(819).SourceNode; * http://opensource.org/licenses/BSD-3-Clause */ -var base64VLQ = __webpack_require__(811); -var util = __webpack_require__(813); -var ArraySet = __webpack_require__(814).ArraySet; -var MappingList = __webpack_require__(815).MappingList; +var base64VLQ = __webpack_require__(810); +var util = __webpack_require__(812); +var ArraySet = __webpack_require__(813).ArraySet; +var MappingList = __webpack_require__(814).MappingList; /** * An instance of the SourceMapGenerator represents a source map which is @@ -93310,7 +93296,7 @@ exports.SourceMapGenerator = SourceMapGenerator; /***/ }), -/* 811 */ +/* 810 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -93350,7 +93336,7 @@ exports.SourceMapGenerator = SourceMapGenerator; * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -var base64 = __webpack_require__(812); +var base64 = __webpack_require__(811); // 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, @@ -93456,7 +93442,7 @@ exports.decode = function base64VLQ_decode(aStr, aIndex, aOutParam) { /***/ }), -/* 812 */ +/* 811 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -93529,7 +93515,7 @@ exports.decode = function (charCode) { /***/ }), -/* 813 */ +/* 812 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -93952,7 +93938,7 @@ exports.compareByGeneratedPositionsInflated = compareByGeneratedPositionsInflate /***/ }), -/* 814 */ +/* 813 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -93962,7 +93948,7 @@ exports.compareByGeneratedPositionsInflated = compareByGeneratedPositionsInflate * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(813); +var util = __webpack_require__(812); var has = Object.prototype.hasOwnProperty; var hasNativeMap = typeof Map !== "undefined"; @@ -94079,7 +94065,7 @@ exports.ArraySet = ArraySet; /***/ }), -/* 815 */ +/* 814 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -94089,7 +94075,7 @@ exports.ArraySet = ArraySet; * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(813); +var util = __webpack_require__(812); /** * Determine whether mappingB is after mappingA with respect to generated @@ -94164,7 +94150,7 @@ exports.MappingList = MappingList; /***/ }), -/* 816 */ +/* 815 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -94174,11 +94160,11 @@ exports.MappingList = MappingList; * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(813); -var binarySearch = __webpack_require__(817); -var ArraySet = __webpack_require__(814).ArraySet; -var base64VLQ = __webpack_require__(811); -var quickSort = __webpack_require__(818).quickSort; +var util = __webpack_require__(812); +var binarySearch = __webpack_require__(816); +var ArraySet = __webpack_require__(813).ArraySet; +var base64VLQ = __webpack_require__(810); +var quickSort = __webpack_require__(817).quickSort; function SourceMapConsumer(aSourceMap) { var sourceMap = aSourceMap; @@ -95252,7 +95238,7 @@ exports.IndexedSourceMapConsumer = IndexedSourceMapConsumer; /***/ }), -/* 817 */ +/* 816 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -95369,7 +95355,7 @@ exports.search = function search(aNeedle, aHaystack, aCompare, aBias) { /***/ }), -/* 818 */ +/* 817 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -95489,7 +95475,7 @@ exports.quickSort = function (ary, comparator) { /***/ }), -/* 819 */ +/* 818 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -95499,8 +95485,8 @@ exports.quickSort = function (ary, comparator) { * http://opensource.org/licenses/BSD-3-Clause */ -var SourceMapGenerator = __webpack_require__(810).SourceMapGenerator; -var util = __webpack_require__(813); +var SourceMapGenerator = __webpack_require__(809).SourceMapGenerator; +var util = __webpack_require__(812); // Matches a Windows-style `\r\n` newline or a `\n` newline used by all other // operating systems these days (capturing the result). @@ -95908,17 +95894,17 @@ exports.SourceNode = SourceNode; /***/ }), -/* 820 */ +/* 819 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014, 2015, 2016, 2017 Simon Lydell // X11 (“MIT”) Licensed. (See LICENSE.) -var sourceMappingURL = __webpack_require__(821) -var resolveUrl = __webpack_require__(822) -var decodeUriComponent = __webpack_require__(823) -var urix = __webpack_require__(825) -var atob = __webpack_require__(826) +var sourceMappingURL = __webpack_require__(820) +var resolveUrl = __webpack_require__(821) +var decodeUriComponent = __webpack_require__(822) +var urix = __webpack_require__(824) +var atob = __webpack_require__(825) @@ -96216,7 +96202,7 @@ module.exports = { /***/ }), -/* 821 */ +/* 820 */ /***/ (function(module, exports, __webpack_require__) { var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_RESULT__;// Copyright 2014 Simon Lydell @@ -96279,13 +96265,13 @@ void (function(root, factory) { /***/ }), -/* 822 */ +/* 821 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014 Simon Lydell // X11 (“MIT”) Licensed. (See LICENSE.) -var url = __webpack_require__(454) +var url = __webpack_require__(453) function resolveUrl(/* ...urls */) { return Array.prototype.reduce.call(arguments, function(resolved, nextUrl) { @@ -96297,13 +96283,13 @@ module.exports = resolveUrl /***/ }), -/* 823 */ +/* 822 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2017 Simon Lydell // X11 (“MIT”) Licensed. (See LICENSE.) -var decodeUriComponent = __webpack_require__(824) +var decodeUriComponent = __webpack_require__(823) function customDecodeUriComponent(string) { // `decodeUriComponent` turns `+` into ` `, but that's not wanted. @@ -96314,7 +96300,7 @@ module.exports = customDecodeUriComponent /***/ }), -/* 824 */ +/* 823 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -96415,7 +96401,7 @@ module.exports = function (encodedURI) { /***/ }), -/* 825 */ +/* 824 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014 Simon Lydell @@ -96438,7 +96424,7 @@ module.exports = urix /***/ }), -/* 826 */ +/* 825 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -96452,7 +96438,7 @@ module.exports = atob.atob = atob; /***/ }), -/* 827 */ +/* 826 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -96460,8 +96446,8 @@ module.exports = atob.atob = atob; var fs = __webpack_require__(23); var path = __webpack_require__(16); -var define = __webpack_require__(731); -var utils = __webpack_require__(808); +var define = __webpack_require__(730); +var utils = __webpack_require__(807); /** * Expose `mixin()`. @@ -96604,19 +96590,19 @@ exports.comment = function(node) { /***/ }), -/* 828 */ +/* 827 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var use = __webpack_require__(800); +var use = __webpack_require__(799); var util = __webpack_require__(29); -var Cache = __webpack_require__(829); -var define = __webpack_require__(731); -var debug = __webpack_require__(802)('snapdragon:parser'); -var Position = __webpack_require__(830); -var utils = __webpack_require__(808); +var Cache = __webpack_require__(828); +var define = __webpack_require__(730); +var debug = __webpack_require__(801)('snapdragon:parser'); +var Position = __webpack_require__(829); +var utils = __webpack_require__(807); /** * Create a new `Parser` with the given `input` and `options`. @@ -97144,7 +97130,7 @@ module.exports = Parser; /***/ }), -/* 829 */ +/* 828 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -97251,13 +97237,13 @@ MapCache.prototype.del = function mapDelete(key) { /***/ }), -/* 830 */ +/* 829 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var define = __webpack_require__(731); +var define = __webpack_require__(730); /** * Store position for a node @@ -97272,16 +97258,16 @@ module.exports = function Position(start, parser) { /***/ }), -/* 831 */ +/* 830 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var safe = __webpack_require__(832); -var define = __webpack_require__(838); -var extend = __webpack_require__(839); -var not = __webpack_require__(841); +var safe = __webpack_require__(831); +var define = __webpack_require__(837); +var extend = __webpack_require__(838); +var not = __webpack_require__(840); var MAX_LENGTH = 1024 * 64; /** @@ -97434,10 +97420,10 @@ module.exports.makeRe = makeRe; /***/ }), -/* 832 */ +/* 831 */ /***/ (function(module, exports, __webpack_require__) { -var parse = __webpack_require__(833); +var parse = __webpack_require__(832); var types = parse.types; module.exports = function (re, opts) { @@ -97483,13 +97469,13 @@ function isRegExp (x) { /***/ }), -/* 833 */ +/* 832 */ /***/ (function(module, exports, __webpack_require__) { -var util = __webpack_require__(834); -var types = __webpack_require__(835); -var sets = __webpack_require__(836); -var positions = __webpack_require__(837); +var util = __webpack_require__(833); +var types = __webpack_require__(834); +var sets = __webpack_require__(835); +var positions = __webpack_require__(836); module.exports = function(regexpStr) { @@ -97771,11 +97757,11 @@ module.exports.types = types; /***/ }), -/* 834 */ +/* 833 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(835); -var sets = __webpack_require__(836); +var types = __webpack_require__(834); +var sets = __webpack_require__(835); // All of these are private and only used by randexp. @@ -97888,7 +97874,7 @@ exports.error = function(regexp, msg) { /***/ }), -/* 835 */ +/* 834 */ /***/ (function(module, exports) { module.exports = { @@ -97904,10 +97890,10 @@ module.exports = { /***/ }), -/* 836 */ +/* 835 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(835); +var types = __webpack_require__(834); var INTS = function() { return [{ type: types.RANGE , from: 48, to: 57 }]; @@ -97992,10 +97978,10 @@ exports.anyChar = function() { /***/ }), -/* 837 */ +/* 836 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(835); +var types = __webpack_require__(834); exports.wordBoundary = function() { return { type: types.POSITION, value: 'b' }; @@ -98015,7 +98001,7 @@ exports.end = function() { /***/ }), -/* 838 */ +/* 837 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -98028,8 +98014,8 @@ exports.end = function() { -var isobject = __webpack_require__(749); -var isDescriptor = __webpack_require__(761); +var isobject = __webpack_require__(748); +var isDescriptor = __webpack_require__(760); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -98060,14 +98046,14 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 839 */ +/* 838 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(840); -var assignSymbols = __webpack_require__(750); +var isExtendable = __webpack_require__(839); +var assignSymbols = __webpack_require__(749); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -98127,7 +98113,7 @@ function isEnum(obj, key) { /***/ }), -/* 840 */ +/* 839 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -98140,7 +98126,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(748); +var isPlainObject = __webpack_require__(747); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -98148,14 +98134,14 @@ module.exports = function isExtendable(val) { /***/ }), -/* 841 */ +/* 840 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extend = __webpack_require__(839); -var safe = __webpack_require__(832); +var extend = __webpack_require__(838); +var safe = __webpack_require__(831); /** * The main export is a function that takes a `pattern` string and an `options` object. @@ -98227,14 +98213,14 @@ module.exports = toRegex; /***/ }), -/* 842 */ +/* 841 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var nanomatch = __webpack_require__(843); -var extglob = __webpack_require__(858); +var nanomatch = __webpack_require__(842); +var extglob = __webpack_require__(857); module.exports = function(snapdragon) { var compilers = snapdragon.compiler.compilers; @@ -98311,7 +98297,7 @@ function escapeExtglobs(compiler) { /***/ }), -/* 843 */ +/* 842 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -98322,17 +98308,17 @@ function escapeExtglobs(compiler) { */ var util = __webpack_require__(29); -var toRegex = __webpack_require__(730); -var extend = __webpack_require__(844); +var toRegex = __webpack_require__(729); +var extend = __webpack_require__(843); /** * Local dependencies */ -var compilers = __webpack_require__(846); -var parsers = __webpack_require__(847); -var cache = __webpack_require__(850); -var utils = __webpack_require__(852); +var compilers = __webpack_require__(845); +var parsers = __webpack_require__(846); +var cache = __webpack_require__(849); +var utils = __webpack_require__(851); var MAX_LENGTH = 1024 * 64; /** @@ -99156,14 +99142,14 @@ module.exports = nanomatch; /***/ }), -/* 844 */ +/* 843 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(845); -var assignSymbols = __webpack_require__(750); +var isExtendable = __webpack_require__(844); +var assignSymbols = __webpack_require__(749); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -99223,7 +99209,7 @@ function isEnum(obj, key) { /***/ }), -/* 845 */ +/* 844 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -99236,7 +99222,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(748); +var isPlainObject = __webpack_require__(747); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -99244,7 +99230,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 846 */ +/* 845 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -99590,15 +99576,15 @@ module.exports = function(nanomatch, options) { /***/ }), -/* 847 */ +/* 846 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var regexNot = __webpack_require__(741); -var toRegex = __webpack_require__(730); -var isOdd = __webpack_require__(848); +var regexNot = __webpack_require__(740); +var toRegex = __webpack_require__(729); +var isOdd = __webpack_require__(847); /** * Characters to use in negation regex (we want to "not" match @@ -99984,7 +99970,7 @@ module.exports.not = NOT_REGEX; /***/ }), -/* 848 */ +/* 847 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -99997,7 +99983,7 @@ module.exports.not = NOT_REGEX; -var isNumber = __webpack_require__(849); +var isNumber = __webpack_require__(848); module.exports = function isOdd(i) { if (!isNumber(i)) { @@ -100011,7 +99997,7 @@ module.exports = function isOdd(i) { /***/ }), -/* 849 */ +/* 848 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -100039,14 +100025,14 @@ module.exports = function isNumber(num) { /***/ }), -/* 850 */ +/* 849 */ /***/ (function(module, exports, __webpack_require__) { -module.exports = new (__webpack_require__(851))(); +module.exports = new (__webpack_require__(850))(); /***/ }), -/* 851 */ +/* 850 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -100059,7 +100045,7 @@ module.exports = new (__webpack_require__(851))(); -var MapCache = __webpack_require__(829); +var MapCache = __webpack_require__(828); /** * Create a new `FragmentCache` with an optional object to use for `caches`. @@ -100181,7 +100167,7 @@ exports = module.exports = FragmentCache; /***/ }), -/* 852 */ +/* 851 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -100194,14 +100180,14 @@ var path = __webpack_require__(16); * Module dependencies */ -var isWindows = __webpack_require__(853)(); -var Snapdragon = __webpack_require__(769); -utils.define = __webpack_require__(854); -utils.diff = __webpack_require__(855); -utils.extend = __webpack_require__(844); -utils.pick = __webpack_require__(856); -utils.typeOf = __webpack_require__(857); -utils.unique = __webpack_require__(742); +var isWindows = __webpack_require__(852)(); +var Snapdragon = __webpack_require__(768); +utils.define = __webpack_require__(853); +utils.diff = __webpack_require__(854); +utils.extend = __webpack_require__(843); +utils.pick = __webpack_require__(855); +utils.typeOf = __webpack_require__(856); +utils.unique = __webpack_require__(741); /** * Returns true if the given value is effectively an empty string @@ -100567,7 +100553,7 @@ utils.unixify = function(options) { /***/ }), -/* 853 */ +/* 852 */ /***/ (function(module, exports, __webpack_require__) { var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_DEFINE_RESULT__;/*! @@ -100595,7 +100581,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_ /***/ }), -/* 854 */ +/* 853 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -100608,8 +100594,8 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_ -var isobject = __webpack_require__(749); -var isDescriptor = __webpack_require__(761); +var isobject = __webpack_require__(748); +var isDescriptor = __webpack_require__(760); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -100640,7 +100626,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 855 */ +/* 854 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -100694,7 +100680,7 @@ function diffArray(one, two) { /***/ }), -/* 856 */ +/* 855 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -100707,7 +100693,7 @@ function diffArray(one, two) { -var isObject = __webpack_require__(749); +var isObject = __webpack_require__(748); module.exports = function pick(obj, keys) { if (!isObject(obj) && typeof obj !== 'function') { @@ -100736,7 +100722,7 @@ module.exports = function pick(obj, keys) { /***/ }), -/* 857 */ +/* 856 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -100871,7 +100857,7 @@ function isBuffer(val) { /***/ }), -/* 858 */ +/* 857 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -100881,18 +100867,18 @@ function isBuffer(val) { * Module dependencies */ -var extend = __webpack_require__(739); -var unique = __webpack_require__(742); -var toRegex = __webpack_require__(730); +var extend = __webpack_require__(738); +var unique = __webpack_require__(741); +var toRegex = __webpack_require__(729); /** * Local dependencies */ -var compilers = __webpack_require__(859); -var parsers = __webpack_require__(870); -var Extglob = __webpack_require__(873); -var utils = __webpack_require__(872); +var compilers = __webpack_require__(858); +var parsers = __webpack_require__(869); +var Extglob = __webpack_require__(872); +var utils = __webpack_require__(871); var MAX_LENGTH = 1024 * 64; /** @@ -101209,13 +101195,13 @@ module.exports = extglob; /***/ }), -/* 859 */ +/* 858 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var brackets = __webpack_require__(860); +var brackets = __webpack_require__(859); /** * Extglob compilers @@ -101385,7 +101371,7 @@ module.exports = function(extglob) { /***/ }), -/* 860 */ +/* 859 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -101395,17 +101381,17 @@ module.exports = function(extglob) { * Local dependencies */ -var compilers = __webpack_require__(861); -var parsers = __webpack_require__(863); +var compilers = __webpack_require__(860); +var parsers = __webpack_require__(862); /** * Module dependencies */ -var debug = __webpack_require__(865)('expand-brackets'); -var extend = __webpack_require__(739); -var Snapdragon = __webpack_require__(769); -var toRegex = __webpack_require__(730); +var debug = __webpack_require__(864)('expand-brackets'); +var extend = __webpack_require__(738); +var Snapdragon = __webpack_require__(768); +var toRegex = __webpack_require__(729); /** * Parses the given POSIX character class `pattern` and returns a @@ -101603,13 +101589,13 @@ module.exports = brackets; /***/ }), -/* 861 */ +/* 860 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var posix = __webpack_require__(862); +var posix = __webpack_require__(861); module.exports = function(brackets) { brackets.compiler @@ -101697,7 +101683,7 @@ module.exports = function(brackets) { /***/ }), -/* 862 */ +/* 861 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -101726,14 +101712,14 @@ module.exports = { /***/ }), -/* 863 */ +/* 862 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(864); -var define = __webpack_require__(731); +var utils = __webpack_require__(863); +var define = __webpack_require__(730); /** * Text regex @@ -101952,14 +101938,14 @@ module.exports.TEXT_REGEX = TEXT_REGEX; /***/ }), -/* 864 */ +/* 863 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var toRegex = __webpack_require__(730); -var regexNot = __webpack_require__(741); +var toRegex = __webpack_require__(729); +var regexNot = __webpack_require__(740); var cached; /** @@ -101993,7 +101979,7 @@ exports.createRegex = function(pattern, include) { /***/ }), -/* 865 */ +/* 864 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -102002,14 +101988,14 @@ exports.createRegex = function(pattern, include) { */ if (typeof process !== 'undefined' && process.type === 'renderer') { - module.exports = __webpack_require__(866); + module.exports = __webpack_require__(865); } else { - module.exports = __webpack_require__(869); + module.exports = __webpack_require__(868); } /***/ }), -/* 866 */ +/* 865 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -102018,7 +102004,7 @@ if (typeof process !== 'undefined' && process.type === 'renderer') { * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(867); +exports = module.exports = __webpack_require__(866); exports.log = log; exports.formatArgs = formatArgs; exports.save = save; @@ -102200,7 +102186,7 @@ function localstorage() { /***/ }), -/* 867 */ +/* 866 */ /***/ (function(module, exports, __webpack_require__) { @@ -102216,7 +102202,7 @@ exports.coerce = coerce; exports.disable = disable; exports.enable = enable; exports.enabled = enabled; -exports.humanize = __webpack_require__(868); +exports.humanize = __webpack_require__(867); /** * The currently active debug mode names, and names to skip. @@ -102408,7 +102394,7 @@ function coerce(val) { /***/ }), -/* 868 */ +/* 867 */ /***/ (function(module, exports) { /** @@ -102566,14 +102552,14 @@ function plural(ms, n, name) { /***/ }), -/* 869 */ +/* 868 */ /***/ (function(module, exports, __webpack_require__) { /** * Module dependencies. */ -var tty = __webpack_require__(480); +var tty = __webpack_require__(479); var util = __webpack_require__(29); /** @@ -102582,7 +102568,7 @@ var util = __webpack_require__(29); * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(867); +exports = module.exports = __webpack_require__(866); exports.init = init; exports.log = log; exports.formatArgs = formatArgs; @@ -102761,7 +102747,7 @@ function createWritableStdioStream (fd) { case 'PIPE': case 'TCP': - var net = __webpack_require__(807); + var net = __webpack_require__(806); stream = new net.Socket({ fd: fd, readable: false, @@ -102820,15 +102806,15 @@ exports.enable(load()); /***/ }), -/* 870 */ +/* 869 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var brackets = __webpack_require__(860); -var define = __webpack_require__(871); -var utils = __webpack_require__(872); +var brackets = __webpack_require__(859); +var define = __webpack_require__(870); +var utils = __webpack_require__(871); /** * Characters to use in text regex (we want to "not" match @@ -102983,7 +102969,7 @@ module.exports = parsers; /***/ }), -/* 871 */ +/* 870 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -102996,7 +102982,7 @@ module.exports = parsers; -var isDescriptor = __webpack_require__(761); +var isDescriptor = __webpack_require__(760); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -103021,14 +103007,14 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 872 */ +/* 871 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var regex = __webpack_require__(741); -var Cache = __webpack_require__(851); +var regex = __webpack_require__(740); +var Cache = __webpack_require__(850); /** * Utils @@ -103097,7 +103083,7 @@ utils.createRegex = function(str) { /***/ }), -/* 873 */ +/* 872 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -103107,16 +103093,16 @@ utils.createRegex = function(str) { * Module dependencies */ -var Snapdragon = __webpack_require__(769); -var define = __webpack_require__(871); -var extend = __webpack_require__(739); +var Snapdragon = __webpack_require__(768); +var define = __webpack_require__(870); +var extend = __webpack_require__(738); /** * Local dependencies */ -var compilers = __webpack_require__(859); -var parsers = __webpack_require__(870); +var compilers = __webpack_require__(858); +var parsers = __webpack_require__(869); /** * Customize Snapdragon parser and renderer @@ -103182,16 +103168,16 @@ module.exports = Extglob; /***/ }), -/* 874 */ +/* 873 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extglob = __webpack_require__(858); -var nanomatch = __webpack_require__(843); -var regexNot = __webpack_require__(741); -var toRegex = __webpack_require__(831); +var extglob = __webpack_require__(857); +var nanomatch = __webpack_require__(842); +var regexNot = __webpack_require__(740); +var toRegex = __webpack_require__(830); var not; /** @@ -103272,14 +103258,14 @@ function textRegex(pattern) { /***/ }), -/* 875 */ +/* 874 */ /***/ (function(module, exports, __webpack_require__) { -module.exports = new (__webpack_require__(851))(); +module.exports = new (__webpack_require__(850))(); /***/ }), -/* 876 */ +/* 875 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -103292,13 +103278,13 @@ var path = __webpack_require__(16); * Module dependencies */ -var Snapdragon = __webpack_require__(769); -utils.define = __webpack_require__(838); -utils.diff = __webpack_require__(855); -utils.extend = __webpack_require__(839); -utils.pick = __webpack_require__(856); -utils.typeOf = __webpack_require__(877); -utils.unique = __webpack_require__(742); +var Snapdragon = __webpack_require__(768); +utils.define = __webpack_require__(837); +utils.diff = __webpack_require__(854); +utils.extend = __webpack_require__(838); +utils.pick = __webpack_require__(855); +utils.typeOf = __webpack_require__(876); +utils.unique = __webpack_require__(741); /** * Returns true if the platform is windows, or `path.sep` is `\\`. @@ -103595,7 +103581,7 @@ utils.unixify = function(options) { /***/ }), -/* 877 */ +/* 876 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -103730,7 +103716,7 @@ function isBuffer(val) { /***/ }), -/* 878 */ +/* 877 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -103749,9 +103735,9 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var readdir = __webpack_require__(879); -var reader_1 = __webpack_require__(892); -var fs_stream_1 = __webpack_require__(896); +var readdir = __webpack_require__(878); +var reader_1 = __webpack_require__(891); +var fs_stream_1 = __webpack_require__(895); var ReaderAsync = /** @class */ (function (_super) { __extends(ReaderAsync, _super); function ReaderAsync() { @@ -103812,15 +103798,15 @@ exports.default = ReaderAsync; /***/ }), -/* 879 */ +/* 878 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const readdirSync = __webpack_require__(880); -const readdirAsync = __webpack_require__(888); -const readdirStream = __webpack_require__(891); +const readdirSync = __webpack_require__(879); +const readdirAsync = __webpack_require__(887); +const readdirStream = __webpack_require__(890); module.exports = exports = readdirAsyncPath; exports.readdir = exports.readdirAsync = exports.async = readdirAsyncPath; @@ -103904,7 +103890,7 @@ function readdirStreamStat (dir, options) { /***/ }), -/* 880 */ +/* 879 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -103912,11 +103898,11 @@ function readdirStreamStat (dir, options) { module.exports = readdirSync; -const DirectoryReader = __webpack_require__(881); +const DirectoryReader = __webpack_require__(880); let syncFacade = { - fs: __webpack_require__(886), - forEach: __webpack_require__(887), + fs: __webpack_require__(885), + forEach: __webpack_require__(886), sync: true }; @@ -103945,7 +103931,7 @@ function readdirSync (dir, options, internalOptions) { /***/ }), -/* 881 */ +/* 880 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -103954,9 +103940,9 @@ function readdirSync (dir, options, internalOptions) { const Readable = __webpack_require__(27).Readable; const EventEmitter = __webpack_require__(379).EventEmitter; const path = __webpack_require__(16); -const normalizeOptions = __webpack_require__(882); -const stat = __webpack_require__(884); -const call = __webpack_require__(885); +const normalizeOptions = __webpack_require__(881); +const stat = __webpack_require__(883); +const call = __webpack_require__(884); /** * Asynchronously reads the contents of a directory and streams the results @@ -104332,14 +104318,14 @@ module.exports = DirectoryReader; /***/ }), -/* 882 */ +/* 881 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(16); -const globToRegExp = __webpack_require__(883); +const globToRegExp = __webpack_require__(882); module.exports = normalizeOptions; @@ -104516,7 +104502,7 @@ function normalizeOptions (options, internalOptions) { /***/ }), -/* 883 */ +/* 882 */ /***/ (function(module, exports) { module.exports = function (glob, opts) { @@ -104653,13 +104639,13 @@ module.exports = function (glob, opts) { /***/ }), -/* 884 */ +/* 883 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const call = __webpack_require__(885); +const call = __webpack_require__(884); module.exports = stat; @@ -104734,7 +104720,7 @@ function symlinkStat (fs, path, lstats, callback) { /***/ }), -/* 885 */ +/* 884 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -104795,14 +104781,14 @@ function callOnce (fn) { /***/ }), -/* 886 */ +/* 885 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(23); -const call = __webpack_require__(885); +const call = __webpack_require__(884); /** * A facade around {@link fs.readdirSync} that allows it to be called @@ -104866,7 +104852,7 @@ exports.lstat = function (path, callback) { /***/ }), -/* 887 */ +/* 886 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -104895,7 +104881,7 @@ function syncForEach (array, iterator, done) { /***/ }), -/* 888 */ +/* 887 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -104903,12 +104889,12 @@ function syncForEach (array, iterator, done) { module.exports = readdirAsync; -const maybe = __webpack_require__(889); -const DirectoryReader = __webpack_require__(881); +const maybe = __webpack_require__(888); +const DirectoryReader = __webpack_require__(880); let asyncFacade = { fs: __webpack_require__(23), - forEach: __webpack_require__(890), + forEach: __webpack_require__(889), async: true }; @@ -104950,7 +104936,7 @@ function readdirAsync (dir, options, callback, internalOptions) { /***/ }), -/* 889 */ +/* 888 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -104977,7 +104963,7 @@ module.exports = function maybe (cb, promise) { /***/ }), -/* 890 */ +/* 889 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -105013,7 +104999,7 @@ function asyncForEach (array, iterator, done) { /***/ }), -/* 891 */ +/* 890 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -105021,11 +105007,11 @@ function asyncForEach (array, iterator, done) { module.exports = readdirStream; -const DirectoryReader = __webpack_require__(881); +const DirectoryReader = __webpack_require__(880); let streamFacade = { fs: __webpack_require__(23), - forEach: __webpack_require__(890), + forEach: __webpack_require__(889), async: true }; @@ -105045,16 +105031,16 @@ function readdirStream (dir, options, internalOptions) { /***/ }), -/* 892 */ +/* 891 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var path = __webpack_require__(16); -var deep_1 = __webpack_require__(893); -var entry_1 = __webpack_require__(895); -var pathUtil = __webpack_require__(894); +var deep_1 = __webpack_require__(892); +var entry_1 = __webpack_require__(894); +var pathUtil = __webpack_require__(893); var Reader = /** @class */ (function () { function Reader(options) { this.options = options; @@ -105120,14 +105106,14 @@ exports.default = Reader; /***/ }), -/* 893 */ +/* 892 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var pathUtils = __webpack_require__(894); -var patternUtils = __webpack_require__(723); +var pathUtils = __webpack_require__(893); +var patternUtils = __webpack_require__(722); var DeepFilter = /** @class */ (function () { function DeepFilter(options, micromatchOptions) { this.options = options; @@ -105210,7 +105196,7 @@ exports.default = DeepFilter; /***/ }), -/* 894 */ +/* 893 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -105241,14 +105227,14 @@ exports.makeAbsolute = makeAbsolute; /***/ }), -/* 895 */ +/* 894 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var pathUtils = __webpack_require__(894); -var patternUtils = __webpack_require__(723); +var pathUtils = __webpack_require__(893); +var patternUtils = __webpack_require__(722); var EntryFilter = /** @class */ (function () { function EntryFilter(options, micromatchOptions) { this.options = options; @@ -105333,7 +105319,7 @@ exports.default = EntryFilter; /***/ }), -/* 896 */ +/* 895 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -105353,8 +105339,8 @@ var __extends = (this && this.__extends) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); var stream = __webpack_require__(27); -var fsStat = __webpack_require__(897); -var fs_1 = __webpack_require__(901); +var fsStat = __webpack_require__(896); +var fs_1 = __webpack_require__(900); var FileSystemStream = /** @class */ (function (_super) { __extends(FileSystemStream, _super); function FileSystemStream() { @@ -105404,14 +105390,14 @@ exports.default = FileSystemStream; /***/ }), -/* 897 */ +/* 896 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const optionsManager = __webpack_require__(898); -const statProvider = __webpack_require__(900); +const optionsManager = __webpack_require__(897); +const statProvider = __webpack_require__(899); /** * Asynchronous API. */ @@ -105442,13 +105428,13 @@ exports.statSync = statSync; /***/ }), -/* 898 */ +/* 897 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fsAdapter = __webpack_require__(899); +const fsAdapter = __webpack_require__(898); function prepare(opts) { const options = Object.assign({ fs: fsAdapter.getFileSystemAdapter(opts ? opts.fs : undefined), @@ -105461,7 +105447,7 @@ exports.prepare = prepare; /***/ }), -/* 899 */ +/* 898 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -105484,7 +105470,7 @@ exports.getFileSystemAdapter = getFileSystemAdapter; /***/ }), -/* 900 */ +/* 899 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -105536,7 +105522,7 @@ exports.isFollowedSymlink = isFollowedSymlink; /***/ }), -/* 901 */ +/* 900 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -105567,7 +105553,7 @@ exports.default = FileSystem; /***/ }), -/* 902 */ +/* 901 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -105587,9 +105573,9 @@ var __extends = (this && this.__extends) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); var stream = __webpack_require__(27); -var readdir = __webpack_require__(879); -var reader_1 = __webpack_require__(892); -var fs_stream_1 = __webpack_require__(896); +var readdir = __webpack_require__(878); +var reader_1 = __webpack_require__(891); +var fs_stream_1 = __webpack_require__(895); var TransformStream = /** @class */ (function (_super) { __extends(TransformStream, _super); function TransformStream(reader) { @@ -105657,7 +105643,7 @@ exports.default = ReaderStream; /***/ }), -/* 903 */ +/* 902 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -105676,9 +105662,9 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var readdir = __webpack_require__(879); -var reader_1 = __webpack_require__(892); -var fs_sync_1 = __webpack_require__(904); +var readdir = __webpack_require__(878); +var reader_1 = __webpack_require__(891); +var fs_sync_1 = __webpack_require__(903); var ReaderSync = /** @class */ (function (_super) { __extends(ReaderSync, _super); function ReaderSync() { @@ -105738,7 +105724,7 @@ exports.default = ReaderSync; /***/ }), -/* 904 */ +/* 903 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -105757,8 +105743,8 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var fsStat = __webpack_require__(897); -var fs_1 = __webpack_require__(901); +var fsStat = __webpack_require__(896); +var fs_1 = __webpack_require__(900); var FileSystemSync = /** @class */ (function (_super) { __extends(FileSystemSync, _super); function FileSystemSync() { @@ -105804,7 +105790,7 @@ exports.default = FileSystemSync; /***/ }), -/* 905 */ +/* 904 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -105820,13 +105806,13 @@ exports.flatten = flatten; /***/ }), -/* 906 */ +/* 905 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var merge2 = __webpack_require__(590); +var merge2 = __webpack_require__(589); /** * Merge multiple streams and propagate their errors into one stream in parallel. */ @@ -105841,13 +105827,13 @@ exports.merge = merge; /***/ }), -/* 907 */ +/* 906 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(16); -const pathType = __webpack_require__(908); +const pathType = __webpack_require__(907); const getExtensions = extensions => extensions.length > 1 ? `{${extensions.join(',')}}` : extensions[0]; @@ -105913,13 +105899,13 @@ module.exports.sync = (input, opts) => { /***/ }), -/* 908 */ +/* 907 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(23); -const pify = __webpack_require__(909); +const pify = __webpack_require__(908); function type(fn, fn2, fp) { if (typeof fp !== 'string') { @@ -105962,7 +105948,7 @@ exports.symlinkSync = typeSync.bind(null, 'lstatSync', 'isSymbolicLink'); /***/ }), -/* 909 */ +/* 908 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -106053,17 +106039,17 @@ module.exports = (obj, opts) => { /***/ }), -/* 910 */ +/* 909 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(23); const path = __webpack_require__(16); -const fastGlob = __webpack_require__(719); -const gitIgnore = __webpack_require__(911); -const pify = __webpack_require__(912); -const slash = __webpack_require__(913); +const fastGlob = __webpack_require__(718); +const gitIgnore = __webpack_require__(910); +const pify = __webpack_require__(911); +const slash = __webpack_require__(912); const DEFAULT_IGNORE = [ '**/node_modules/**', @@ -106161,7 +106147,7 @@ module.exports.sync = options => { /***/ }), -/* 911 */ +/* 910 */ /***/ (function(module, exports) { // A simple implementation of make-array @@ -106630,7 +106616,7 @@ module.exports = options => new IgnoreBase(options) /***/ }), -/* 912 */ +/* 911 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -106705,7 +106691,7 @@ module.exports = (input, options) => { /***/ }), -/* 913 */ +/* 912 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -106723,17 +106709,17 @@ module.exports = input => { /***/ }), -/* 914 */ +/* 913 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(16); const {constants: fsConstants} = __webpack_require__(23); -const pEvent = __webpack_require__(915); -const CpFileError = __webpack_require__(918); -const fs = __webpack_require__(922); -const ProgressEmitter = __webpack_require__(925); +const pEvent = __webpack_require__(914); +const CpFileError = __webpack_require__(917); +const fs = __webpack_require__(921); +const ProgressEmitter = __webpack_require__(924); const cpFileAsync = async (source, destination, options, progressEmitter) => { let readError; @@ -106847,12 +106833,12 @@ module.exports.sync = (source, destination, options) => { /***/ }), -/* 915 */ +/* 914 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pTimeout = __webpack_require__(916); +const pTimeout = __webpack_require__(915); const symbolAsyncIterator = Symbol.asyncIterator || '@@asyncIterator'; @@ -107143,12 +107129,12 @@ module.exports.iterator = (emitter, event, options) => { /***/ }), -/* 916 */ +/* 915 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pFinally = __webpack_require__(917); +const pFinally = __webpack_require__(916); class TimeoutError extends Error { constructor(message) { @@ -107194,7 +107180,7 @@ module.exports.TimeoutError = TimeoutError; /***/ }), -/* 917 */ +/* 916 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -107216,12 +107202,12 @@ module.exports = (promise, onFinally) => { /***/ }), -/* 918 */ +/* 917 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const NestedError = __webpack_require__(919); +const NestedError = __webpack_require__(918); class CpFileError extends NestedError { constructor(message, nested) { @@ -107235,10 +107221,10 @@ module.exports = CpFileError; /***/ }), -/* 919 */ +/* 918 */ /***/ (function(module, exports, __webpack_require__) { -var inherits = __webpack_require__(920); +var inherits = __webpack_require__(919); var NestedError = function (message, nested) { this.nested = nested; @@ -107289,7 +107275,7 @@ module.exports = NestedError; /***/ }), -/* 920 */ +/* 919 */ /***/ (function(module, exports, __webpack_require__) { try { @@ -107297,12 +107283,12 @@ try { if (typeof util.inherits !== 'function') throw ''; module.exports = util.inherits; } catch (e) { - module.exports = __webpack_require__(921); + module.exports = __webpack_require__(920); } /***/ }), -/* 921 */ +/* 920 */ /***/ (function(module, exports) { if (typeof Object.create === 'function') { @@ -107331,16 +107317,16 @@ if (typeof Object.create === 'function') { /***/ }), -/* 922 */ +/* 921 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const {promisify} = __webpack_require__(29); const fs = __webpack_require__(22); -const makeDir = __webpack_require__(923); -const pEvent = __webpack_require__(915); -const CpFileError = __webpack_require__(918); +const makeDir = __webpack_require__(922); +const pEvent = __webpack_require__(914); +const CpFileError = __webpack_require__(917); const stat = promisify(fs.stat); const lstat = promisify(fs.lstat); @@ -107437,7 +107423,7 @@ exports.copyFileSync = (source, destination, flags) => { /***/ }), -/* 923 */ +/* 922 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -107445,7 +107431,7 @@ exports.copyFileSync = (source, destination, flags) => { const fs = __webpack_require__(23); const path = __webpack_require__(16); const {promisify} = __webpack_require__(29); -const semver = __webpack_require__(924); +const semver = __webpack_require__(923); const defaults = { mode: 0o777 & (~process.umask()), @@ -107594,7 +107580,7 @@ module.exports.sync = (input, options) => { /***/ }), -/* 924 */ +/* 923 */ /***/ (function(module, exports) { exports = module.exports = SemVer @@ -109196,7 +109182,7 @@ function coerce (version, options) { /***/ }), -/* 925 */ +/* 924 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -109237,7 +109223,7 @@ module.exports = ProgressEmitter; /***/ }), -/* 926 */ +/* 925 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -109283,12 +109269,12 @@ exports.default = module.exports; /***/ }), -/* 927 */ +/* 926 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const NestedError = __webpack_require__(928); +const NestedError = __webpack_require__(927); class CpyError extends NestedError { constructor(message, nested) { @@ -109302,7 +109288,7 @@ module.exports = CpyError; /***/ }), -/* 928 */ +/* 927 */ /***/ (function(module, exports, __webpack_require__) { var inherits = __webpack_require__(29).inherits; @@ -109358,14 +109344,14 @@ module.exports = NestedError; /***/ }), -/* 929 */ +/* 928 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "prepareExternalProjectDependencies", function() { return prepareExternalProjectDependencies; }); -/* harmony import */ var _utils_package_json__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(517); -/* harmony import */ var _utils_project__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(516); +/* harmony import */ var _utils_package_json__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(516); +/* harmony import */ var _utils_project__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(515); /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with diff --git a/packages/kbn-pm/package.json b/packages/kbn-pm/package.json index 278fdbd2bc9a4..a236db9eee18a 100644 --- a/packages/kbn-pm/package.json +++ b/packages/kbn-pm/package.json @@ -26,7 +26,7 @@ "@types/lodash.clonedeepwith": "^4.5.3", "@types/log-symbols": "^2.0.0", "@types/ncp": "^2.0.1", - "@types/node": "^10.12.27", + "@types/node": ">=10.17.17 <10.20.0", "@types/ora": "^1.3.5", "@types/read-pkg": "^4.0.0", "@types/strip-ansi": "^3.0.0", @@ -42,7 +42,7 @@ "cpy": "^8.0.0", "dedent": "^0.7.0", "del": "^5.1.0", - "execa": "^3.2.0", + "execa": "^4.0.0", "getopts": "^2.2.4", "glob": "^7.1.2", "globby": "^8.0.1", diff --git a/packages/kbn-pm/src/config.ts b/packages/kbn-pm/src/config.ts index 6ba8d58a26f88..59b43b230e603 100644 --- a/packages/kbn-pm/src/config.ts +++ b/packages/kbn-pm/src/config.ts @@ -48,6 +48,7 @@ export function getProjectPaths({ rootPath, ossOnly, skipKibanaPlugins }: Option projectPaths.push(resolve(rootPath, 'x-pack')); projectPaths.push(resolve(rootPath, 'x-pack/plugins/*')); projectPaths.push(resolve(rootPath, 'x-pack/legacy/plugins/*')); + projectPaths.push(resolve(rootPath, 'x-pack/test/functional_with_es_ssl/fixtures/plugins/*')); } if (!skipKibanaPlugins) { diff --git a/packages/kbn-storybook/package.json b/packages/kbn-storybook/package.json index 73deadba0a619..0b38554f7806c 100644 --- a/packages/kbn-storybook/package.json +++ b/packages/kbn-storybook/package.json @@ -15,7 +15,6 @@ "@storybook/react": "^5.2.8", "@storybook/theming": "^5.2.8", "copy-webpack-plugin": "5.0.3", - "execa": "1.0.0", "fast-glob": "2.2.7", "glob-watcher": "5.0.3", "jest-specific-snapshot": "2.0.0", diff --git a/packages/kbn-test/src/functional_test_runner/cli.ts b/packages/kbn-test/src/functional_test_runner/cli.ts index 3aaaa47ead5b6..276a51c3a6a99 100644 --- a/packages/kbn-test/src/functional_test_runner/cli.ts +++ b/packages/kbn-test/src/functional_test_runner/cli.ts @@ -18,6 +18,7 @@ */ import { resolve } from 'path'; +import { inspect } from 'util'; import { run, createFlagError, Flags } from '@kbn/dev-utils'; import { FunctionalTestRunner } from './functional_test_runner'; @@ -86,7 +87,11 @@ export function runFtrCli() { } }; - process.on('unhandledRejection', err => teardown(err)); + process.on('unhandledRejection', err => + teardown( + err instanceof Error ? err : new Error(`non-Error type rejection value: ${inspect(err)}`) + ) + ); process.on('SIGTERM', () => teardown()); process.on('SIGINT', () => teardown()); diff --git a/packages/kbn-test/src/mocha/__tests__/junit_report_generation.js b/packages/kbn-test/src/mocha/__tests__/junit_report_generation.js index 7472e271bd1e9..6edd0a551ebd0 100644 --- a/packages/kbn-test/src/mocha/__tests__/junit_report_generation.js +++ b/packages/kbn-test/src/mocha/__tests__/junit_report_generation.js @@ -129,7 +129,7 @@ describe('dev/mocha/junit report generation', () => { name: 'SUITE SUB_SUITE never runs', 'metadata-json': '{}', }, - 'system-out': testFail['system-out'], + 'system-out': ['-- logs are only reported for failed tests --'], skipped: [''], }); }); diff --git a/packages/kbn-test/src/mocha/junit_report_generation.js b/packages/kbn-test/src/mocha/junit_report_generation.js index 95e84117106a4..b56741b48d367 100644 --- a/packages/kbn-test/src/mocha/junit_report_generation.js +++ b/packages/kbn-test/src/mocha/junit_report_generation.js @@ -126,13 +126,15 @@ export function setupJUnitReportGeneration(runner, options = {}) { [...results, ...skippedResults].forEach(result => { const el = addTestcaseEl(result.node); - el.ele('system-out').dat(escapeCdata(getSnapshotOfRunnableLogs(result.node) || '')); if (result.failed) { + el.ele('system-out').dat(escapeCdata(getSnapshotOfRunnableLogs(result.node) || '')); el.ele('failure').dat(escapeCdata(inspect(result.error))); return; } + el.ele('system-out').dat('-- logs are only reported for failed tests --'); + if (result.skipped) { el.ele('skipped'); } diff --git a/src/core/public/chrome/ui/_loading_indicator.scss b/src/core/public/chrome/ui/_loading_indicator.scss index 80694347393ce..026c23b93b040 100644 --- a/src/core/public/chrome/ui/_loading_indicator.scss +++ b/src/core/public/chrome/ui/_loading_indicator.scss @@ -22,29 +22,34 @@ $kbnLoadingIndicatorColor2: tint($euiColorAccent, 60%); } } - .kbnLoadingIndicator__bar { - top: 0; - left: 0; - right: 0; - bottom: 0; - position: absolute; - z-index: $euiZLevel1 + 1; - visibility: visible; - display: block; - animation: kbn-animate-loading-indicator 2s linear infinite; - background-color: $kbnLoadingIndicatorColor2; - background-image: linear-gradient(to right, - $kbnLoadingIndicatorColor1 0%, - $kbnLoadingIndicatorColor1 50%, - $kbnLoadingIndicatorColor2 50%, - $kbnLoadingIndicatorColor2 100% - ); - background-repeat: repeat-x; - background-size: $kbnLoadingIndicatorBackgroundSize $kbnLoadingIndicatorBackgroundSize; - width: 200%; - } +.kbnLoadingIndicator__bar { + top: 0; + left: 0; + right: 0; + bottom: 0; + position: absolute; + z-index: $euiZLevel1 + 1; + visibility: visible; + display: block; + animation: kbn-animate-loading-indicator 2s linear infinite; + background-color: $kbnLoadingIndicatorColor2; + background-image: linear-gradient( + to right, + $kbnLoadingIndicatorColor1 0%, + $kbnLoadingIndicatorColor1 50%, + $kbnLoadingIndicatorColor2 50%, + $kbnLoadingIndicatorColor2 100% + ); + background-repeat: repeat-x; + background-size: $kbnLoadingIndicatorBackgroundSize $kbnLoadingIndicatorBackgroundSize; + width: 200%; +} - @keyframes kbn-animate-loading-indicator { - from { transform: translateX(0); } - to { transform: translateX(-$kbnLoadingIndicatorBackgroundSize); } +@keyframes kbn-animate-loading-indicator { + from { + transform: translateX(0); } + to { + transform: translateX(-$kbnLoadingIndicatorBackgroundSize); + } +} diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 0ff044878afa9..b91afa3ae7dc0 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -168,6 +168,7 @@ export { ToastInputFields, ToastsSetup, ToastsStart, + ToastOptions, ErrorToastOptions, } from './notifications'; diff --git a/src/core/public/notifications/index.ts b/src/core/public/notifications/index.ts index 55b64ac375f08..1a5c2cee7ced6 100644 --- a/src/core/public/notifications/index.ts +++ b/src/core/public/notifications/index.ts @@ -19,6 +19,7 @@ export { ErrorToastOptions, + ToastOptions, Toast, ToastInput, IToasts, diff --git a/src/core/public/notifications/toasts/index.ts b/src/core/public/notifications/toasts/index.ts index 6e9de11683364..b259258b8a335 100644 --- a/src/core/public/notifications/toasts/index.ts +++ b/src/core/public/notifications/toasts/index.ts @@ -20,6 +20,7 @@ export { ToastsService, ToastsSetup, ToastsStart } from './toasts_service'; export { ErrorToastOptions, + ToastOptions, ToastsApi, ToastInput, IToasts, diff --git a/src/core/public/notifications/toasts/toasts_api.test.ts b/src/core/public/notifications/toasts/toasts_api.test.ts index a0e419e989657..7c0ef5576256a 100644 --- a/src/core/public/notifications/toasts/toasts_api.test.ts +++ b/src/core/public/notifications/toasts/toasts_api.test.ts @@ -146,6 +146,21 @@ describe('#remove()', () => { }); }); +describe('#addInfo()', () => { + it('adds a info toast', async () => { + const toasts = new ToastsApi(toastDeps()); + expect(toasts.addInfo({})).toHaveProperty('color', 'primary'); + }); + + it('returns the created toast', async () => { + const toasts = new ToastsApi(toastDeps()); + const toast = toasts.addInfo({}, { toastLifeTimeMs: 1 }); + const currentToasts = await getCurrentToasts(toasts); + expect(currentToasts[0].toastLifeTimeMs).toBe(1); + expect(currentToasts[0]).toBe(toast); + }); +}); + describe('#addSuccess()', () => { it('adds a success toast', async () => { const toasts = new ToastsApi(toastDeps()); diff --git a/src/core/public/notifications/toasts/toasts_api.tsx b/src/core/public/notifications/toasts/toasts_api.tsx index 8b1850ff9508f..53717b9c2e174 100644 --- a/src/core/public/notifications/toasts/toasts_api.tsx +++ b/src/core/public/notifications/toasts/toasts_api.tsx @@ -55,7 +55,18 @@ export type ToastInput = string | ToastInputFields; * Options available for {@link IToasts} APIs. * @public */ -export interface ErrorToastOptions { +export interface ToastOptions { + /** + * How long should the toast remain on screen. + */ + toastLifeTimeMs?: number; +} + +/** + * Options available for {@link IToasts} error APIs. + * @public + */ +export interface ErrorToastOptions extends ToastOptions { /** * The title of the toast and the dialog when expanding the message. */ @@ -84,7 +95,7 @@ const normalizeToast = (toastOrTitle: ToastInput): ToastInputFields => { */ export type IToasts = Pick< ToastsApi, - 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' | 'addInfo' >; /** @@ -145,17 +156,35 @@ export class ToastsApi implements IToasts { } } + /** + * Adds a new toast pre-configured with the info color and info icon. + * + * @param toastOrTitle - a {@link ToastInput} + * @param options - a {@link ToastOptions} + * @returns a {@link Toast} + */ + public addInfo(toastOrTitle: ToastInput, options?: ToastOptions) { + return this.add({ + color: 'primary', + iconType: 'iInCircle', + ...normalizeToast(toastOrTitle), + ...options, + }); + } + /** * Adds a new toast pre-configured with the success color and check icon. * * @param toastOrTitle - a {@link ToastInput} + * @param options - a {@link ToastOptions} * @returns a {@link Toast} */ - public addSuccess(toastOrTitle: ToastInput) { + public addSuccess(toastOrTitle: ToastInput, options?: ToastOptions) { return this.add({ color: 'success', iconType: 'check', ...normalizeToast(toastOrTitle), + ...options, }); } @@ -163,14 +192,16 @@ export class ToastsApi implements IToasts { * Adds a new toast pre-configured with the warning color and help icon. * * @param toastOrTitle - a {@link ToastInput} + * @param options - a {@link ToastOptions} * @returns a {@link Toast} */ - public addWarning(toastOrTitle: ToastInput) { + public addWarning(toastOrTitle: ToastInput, options?: ToastOptions) { return this.add({ color: 'warning', iconType: 'help', toastLifeTimeMs: this.uiSettings.get('notifications:lifetime:warning'), ...normalizeToast(toastOrTitle), + ...options, }); } @@ -178,14 +209,16 @@ export class ToastsApi implements IToasts { * Adds a new toast pre-configured with the danger color and alert icon. * * @param toastOrTitle - a {@link ToastInput} + * @param options - a {@link ToastOptions} * @returns a {@link Toast} */ - public addDanger(toastOrTitle: ToastInput) { + public addDanger(toastOrTitle: ToastInput, options?: ToastOptions) { return this.add({ color: 'danger', iconType: 'alert', toastLifeTimeMs: this.uiSettings.get('notifications:lifetime:warning'), ...normalizeToast(toastOrTitle), + ...options, }); } @@ -201,7 +234,6 @@ export class ToastsApi implements IToasts { return this.add({ color: 'danger', iconType: 'alert', - title: options.title, toastLifeTimeMs: this.uiSettings.get('notifications:lifetime:error'), text: mountReactNode( this.i18n!.Context} /> ), + ...options, }); } diff --git a/src/core/public/notifications/toasts/toasts_service.mock.ts b/src/core/public/notifications/toasts/toasts_service.mock.ts index f44bd3253048d..2eb9cea7eb5c3 100644 --- a/src/core/public/notifications/toasts/toasts_service.mock.ts +++ b/src/core/public/notifications/toasts/toasts_service.mock.ts @@ -25,6 +25,7 @@ const createToastsApiMock = () => { get$: jest.fn(() => new Observable()), add: jest.fn(), remove: jest.fn(), + addInfo: jest.fn(), addSuccess: jest.fn(), addWarning: jest.fn(), addDanger: jest.fn(), diff --git a/src/core/public/overlays/modal/modal_service.tsx b/src/core/public/overlays/modal/modal_service.tsx index 3cf1fe745be8e..f3bbd5c94bdb4 100644 --- a/src/core/public/overlays/modal/modal_service.tsx +++ b/src/core/public/overlays/modal/modal_service.tsx @@ -69,6 +69,7 @@ export interface OverlayModalConfirmOptions { closeButtonAriaLabel?: string; 'data-test-subj'?: string; defaultFocusedButton?: EuiConfirmModalProps['defaultFocusedButton']; + buttonColor?: EuiConfirmModalProps['buttonColor']; } /** diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 7428280b2dccb..37212a07ee631 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -561,7 +561,7 @@ export interface EnvironmentMode { } // @public -export interface ErrorToastOptions { +export interface ErrorToastOptions extends ToastOptions { title: string; toastMessage?: string; } @@ -778,7 +778,7 @@ export interface ImageValidation { } // @public -export type IToasts = Pick; +export type IToasts = Pick; // @public export interface IUiSettingsClient { @@ -1270,16 +1270,22 @@ export type ToastInputFields = Pick; remove(toastOrId: Toast | string): void; // @internal (undocumented) diff --git a/src/core/server/rendering/views/styles.tsx b/src/core/server/rendering/views/styles.tsx index 9ab9f2ad0d6b8..71b42e3464118 100644 --- a/src/core/server/rendering/views/styles.tsx +++ b/src/core/server/rendering/views/styles.tsx @@ -53,7 +53,7 @@ export const Styles: FunctionComponent = ({ darkMode }) => { .kbnWelcomeView { line-height: 1.5; - background-color: #FFF; + background-color: ${darkMode ? '#1D1E24' : '#FFF'}; height: 100%; display: -webkit-box; display: -webkit-flex; @@ -97,6 +97,7 @@ export const Styles: FunctionComponent = ({ darkMode }) => { line-height: 40px !important; height: 40px !important; color: #98a2b3; + color: ${darkMode ? '#98A2B3' : '#69707D'}; } .kbnLoaderWrap { @@ -128,7 +129,7 @@ export const Styles: FunctionComponent = ({ darkMode }) => { width: 32px; height: 4px; overflow: hidden; - background-color: #D3DAE6; + background-color: ${darkMode ? '#25262E' : '#F5F7FA'}; line-height: 1; } @@ -142,7 +143,7 @@ export const Styles: FunctionComponent = ({ darkMode }) => { left: 0; transform: scaleX(0) translateX(0%); animation: kbnProgress 1s cubic-bezier(.694, .0482, .335, 1) infinite; - background-color: #006DE4; + background-color: ${darkMode ? '#1BA9F5' : '#006DE4'}; } @keyframes kbnProgress { diff --git a/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts b/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts index b497f73f3df2a..3f81bfe5aadf2 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts @@ -33,7 +33,6 @@ export { IInjector } from 'ui/chrome'; export { absoluteToParsedUrl } from 'ui/url/absolute_to_parsed_url'; export { configureAppAngularModule, - ensureDefaultIndexPattern, IPrivate, migrateLegacyQuery, PrivateProvider, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/legacy_app.js b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/legacy_app.js index f7baba663da75..64abbdfb87d58 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/legacy_app.js +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/legacy_app.js @@ -23,11 +23,11 @@ import dashboardTemplate from './dashboard_app.html'; import dashboardListingTemplate from './listing/dashboard_listing_ng_wrapper.html'; import { createHashHistory } from 'history'; -import { ensureDefaultIndexPattern } from '../legacy_imports'; import { initDashboardAppDirective } from './dashboard_app'; import { createDashboardEditUrl, DashboardConstants } from './dashboard_constants'; import { createKbnUrlStateStorage, + ensureDefaultIndexPattern, redirectWhenMissing, InvalidJSONProperty, SavedObjectNotFound, @@ -137,8 +137,8 @@ export function initDashboardApp(app, deps) { }); }, resolve: { - dash: function($rootScope, $route, kbnUrl, history) { - return ensureDefaultIndexPattern(deps.core, deps.data, $rootScope, kbnUrl).then(() => { + dash: function($route, history) { + return ensureDefaultIndexPattern(deps.core, deps.data, history).then(() => { const savedObjectsClient = deps.savedObjectsClient; const title = $route.current.params.title; if (title) { @@ -172,11 +172,9 @@ export function initDashboardApp(app, deps) { controller: createNewDashboardCtrl, requireUICapability: 'dashboard.createNew', resolve: { - dash: function($rootScope, kbnUrl, history) { - return ensureDefaultIndexPattern(deps.core, deps.data, $rootScope, kbnUrl) - .then(() => { - return deps.savedDashboards.get(); - }) + dash: history => + ensureDefaultIndexPattern(deps.core, deps.data, history) + .then(() => deps.savedDashboards.get()) .catch( redirectWhenMissing({ history, @@ -185,8 +183,7 @@ export function initDashboardApp(app, deps) { }, toastNotifications: deps.core.notifications.toasts, }) - ); - }, + ), }, }) .when(createDashboardEditUrl(':id'), { @@ -194,13 +191,11 @@ export function initDashboardApp(app, deps) { template: dashboardTemplate, controller: createNewDashboardCtrl, resolve: { - dash: function($rootScope, $route, kbnUrl, history) { + dash: function($route, kbnUrl, history) { const id = $route.current.params.id; - return ensureDefaultIndexPattern(deps.core, deps.data, $rootScope, kbnUrl) - .then(() => { - return deps.savedDashboards.get(id); - }) + return ensureDefaultIndexPattern(deps.core, deps.data, history) + .then(() => deps.savedDashboards.get(id)) .then(savedDashboard => { deps.chrome.recentlyAccessed.add( savedDashboard.getFullPath(), diff --git a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts index 5f3dbb65fd8ff..725e94f16e2e8 100644 --- a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts @@ -54,14 +54,17 @@ import { search } from '../../../../../plugins/data/public'; export const { getRequestInspectorStats, getResponseInspectorStats, tabifyAggResponse } = search; // @ts-ignore export { intervalOptions } from 'ui/agg_types'; -export { subscribeWithScope } from '../../../../../plugins/kibana_legacy/public'; // @ts-ignore export { timezoneProvider } from 'ui/vis/lib/timezone'; -export { unhashUrl, redirectWhenMissing } from '../../../../../plugins/kibana_utils/public'; export { + unhashUrl, + redirectWhenMissing, ensureDefaultIndexPattern, +} from '../../../../../plugins/kibana_utils/public'; +export { formatMsg, formatStack, + subscribeWithScope, } from '../../../../../plugins/kibana_legacy/public'; // EXPORT types diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js index 6978781fe6696..9a383565f4f43 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js @@ -115,9 +115,9 @@ app.config($routeProvider => { template: indexTemplate, reloadOnSearch: false, resolve: { - savedObjects: function($route, kbnUrl, Promise, $rootScope) { + savedObjects: function($route, Promise) { const savedSearchId = $route.current.params.id; - return ensureDefaultIndexPattern(core, data, $rootScope, kbnUrl).then(() => { + return ensureDefaultIndexPattern(core, data, history).then(() => { const { appStateContainer } = getState({ history }); const { index } = appStateContainer.getState(); return Promise.props({ diff --git a/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts b/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts index 8e73a09480c41..cb9ac0e01bb7f 100644 --- a/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts +++ b/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts @@ -35,9 +35,15 @@ interface SavedObjectRegistryEntry { title: string; } +export interface ISavedObjectsManagementRegistry { + register(service: SavedObjectRegistryEntry): void; + all(): SavedObjectRegistryEntry[]; + get(id: string): SavedObjectRegistryEntry | undefined; +} + const registry: SavedObjectRegistryEntry[] = []; -export const savedObjectManagementRegistry = { +export const savedObjectManagementRegistry: ISavedObjectsManagementRegistry = { register: (service: SavedObjectRegistryEntry) => { registry.push(service); }, diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/_objects.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/_objects.js index e3ab862cd84b7..c5901ca6ee6bf 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/_objects.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/_objects.js @@ -28,7 +28,6 @@ import { ObjectsTable } from './components/objects_table'; import { I18nContext } from 'ui/i18n'; import { get } from 'lodash'; import { npStart } from 'ui/new_platform'; - import { getIndexBreadcrumbs } from './breadcrumbs'; const REACT_OBJECTS_TABLE_DOM_ELEMENT_ID = 'reactSavedObjectsTable'; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.html b/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.html index 6efef7b48fa0e..8bce0aabcd64a 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.html +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.html @@ -1,203 +1,5 @@ - - - -
-
-

- -

-
- -
- - - - - - - - -
-
- - -
-
-
- - -
- -
-
- -
- -
- -
-
-
-
- - -
-
-
- - -
- -
-
-
-
-
-
- -
- -
-
- - - - - - - - -
-
-
- - -
- - - -
-
+ + +
diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js index d1a8d6a1b14af..a847055b40015 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js @@ -17,26 +17,20 @@ * under the License. */ -import _ from 'lodash'; -import { i18n } from '@kbn/i18n'; -import angular from 'angular'; +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import 'angular'; import 'angular-elastic/elastic'; -import rison from 'rison-node'; -import { savedObjectManagementRegistry } from '../../saved_object_registry'; -import objectViewHTML from './_view.html'; import uiRoutes from 'ui/routes'; import { uiModules } from 'ui/modules'; -import { fatalError, toastNotifications } from 'ui/notify'; -import 'ui/accessibility/kbn_ui_ace_keyboard_mode'; -import { isNumeric } from './lib/numeric'; -import { canViewInApp } from './lib/in_app_url'; +import { I18nContext } from 'ui/i18n'; import { npStart } from 'ui/new_platform'; - -import { castEsToKbnFieldTypeName } from '../../../../../../../plugins/data/public'; - +import objectViewHTML from './_view.html'; import { getViewBreadcrumbs } from './breadcrumbs'; +import { savedObjectManagementRegistry } from '../../saved_object_registry'; +import { SavedObjectEdition } from './saved_object_view'; -const location = 'SavedObject view'; +const REACT_OBJECTS_VIEW_DOM_ELEMENT_ID = 'reactSavedObjectsView'; uiRoutes.when('/management/kibana/objects/:service/:id', { template: objectViewHTML, @@ -44,261 +38,48 @@ uiRoutes.when('/management/kibana/objects/:service/:id', { requireUICapability: 'management.kibana.objects', }); +function createReactView($scope, $routeParams) { + const { service: serviceName, id: objectId, notFound } = $routeParams; + + const { savedObjects, overlays, notifications, application } = npStart.core; + + $scope.$$postDigest(() => { + const node = document.getElementById(REACT_OBJECTS_VIEW_DOM_ELEMENT_ID); + if (!node) { + return; + } + + render( + + + , + node + ); + }); +} + +function destroyReactView() { + const node = document.getElementById(REACT_OBJECTS_VIEW_DOM_ELEMENT_ID); + node && unmountComponentAtNode(node); +} + uiModules .get('apps/management', ['monospaced.elastic']) .directive('kbnManagementObjectsView', function() { return { restrict: 'E', - controller: function($scope, $routeParams, $location, $window, $rootScope, uiCapabilities) { - const serviceObj = savedObjectManagementRegistry.get($routeParams.service); - const service = serviceObj.service; - const savedObjectsClient = npStart.core.savedObjects.client; - const { overlays } = npStart.core; - - /** - * Creates a field definition and pushes it to the memo stack. This function - * is designed to be used in conjunction with _.reduce(). If the - * values is plain object it will recurse through all the keys till it hits - * a string, number or an array. - * - * @param {array} memo The stack of fields - * @param {mixed} value The value of the field - * @param {string} key The key of the field - * @param {object} collection This is a reference the collection being reduced - * @param {array} parents The parent keys to the field - * @returns {array} - */ - const createField = function(memo, val, key, collection, parents) { - if (Array.isArray(parents)) { - parents.push(key); - } else { - parents = [key]; - } - - const field = { type: 'text', name: parents.join('.'), value: val }; - - if (_.isString(field.value)) { - try { - field.value = angular.toJson(JSON.parse(field.value), true); - field.type = 'json'; - } catch (err) { - field.value = field.value; - } - } else if (isNumeric(field.value)) { - field.type = 'number'; - } else if (Array.isArray(field.value)) { - field.type = 'array'; - field.value = angular.toJson(field.value, true); - } else if (_.isBoolean(field.value)) { - field.type = 'boolean'; - field.value = field.value; - } else if (_.isPlainObject(field.value)) { - // do something recursive - return _.reduce(field.value, _.partialRight(createField, parents), memo); - } - - memo.push(field); - - // once the field is added to the object you need to pop the parents - // to remove it since we've hit the end of the branch. - parents.pop(); - return memo; - }; - - const readObjectClass = function(fields, Class) { - const fieldMap = _.indexBy(fields, 'name'); - - _.forOwn(Class.mapping, function(esType, name) { - if (fieldMap[name]) return; - - fields.push({ - name: name, - type: (function() { - switch (castEsToKbnFieldTypeName(esType)) { - case 'string': - return 'text'; - case 'number': - return 'number'; - case 'boolean': - return 'boolean'; - default: - return 'json'; - } - })(), - }); - }); - - if (Class.searchSource && !fieldMap['kibanaSavedObjectMeta.searchSourceJSON']) { - fields.push({ - name: 'kibanaSavedObjectMeta.searchSourceJSON', - type: 'json', - value: '{}', - }); - } - - if (!fieldMap.references) { - fields.push({ - name: 'references', - type: 'array', - value: '[]', - }); - } - }; - - const { edit: canEdit, delete: canDelete } = uiCapabilities.savedObjectsManagement; - $scope.canEdit = canEdit; - $scope.canDelete = canDelete; - $scope.canViewInApp = canViewInApp(uiCapabilities, service.type); - - $scope.notFound = $routeParams.notFound; - - $scope.title = service.type; - - savedObjectsClient - .get(service.type, $routeParams.id) - .then(function(obj) { - $scope.obj = obj; - $scope.link = service.urlFor(obj.id); - - const fields = _.reduce(obj.attributes, createField, []); - // Special handling for references which isn't within "attributes" - createField(fields, obj.references, 'references'); - - if (service.Class) readObjectClass(fields, service.Class); - - // sorts twice since we want numerical sort to prioritize over name, - // and sortBy will do string comparison if trying to match against strings - const nameSortedFields = _.sortBy(fields, 'name'); - $scope.$evalAsync(() => { - $scope.fields = _.sortBy(nameSortedFields, field => { - const orderIndex = service.Class.fieldOrder - ? service.Class.fieldOrder.indexOf(field.name) - : -1; - return orderIndex > -1 ? orderIndex : Infinity; - }); - }); - $scope.$digest(); - }) - .catch(error => fatalError(error, location)); - - // This handles the validation of the Ace Editor. Since we don't have any - // other hooks into the editors to tell us if the content is valid or not - // we need to use the annotations to see if they have any errors. If they - // do then we push the field.name to aceInvalidEditor variable. - // Otherwise we remove it. - const loadedEditors = []; - $scope.aceInvalidEditors = []; - - $scope.aceLoaded = function(editor) { - if (_.contains(loadedEditors, editor)) return; - loadedEditors.push(editor); - - editor.$blockScrolling = Infinity; - - const session = editor.getSession(); - const fieldName = editor.container.id; - - session.setTabSize(2); - session.setUseSoftTabs(true); - session.on('changeAnnotation', function() { - const annotations = session.getAnnotations(); - if (_.some(annotations, { type: 'error' })) { - if (!_.contains($scope.aceInvalidEditors, fieldName)) { - $scope.aceInvalidEditors.push(fieldName); - } - } else { - $scope.aceInvalidEditors = _.without($scope.aceInvalidEditors, fieldName); - } - - if (!$rootScope.$$phase) $scope.$apply(); - }); - }; - - $scope.cancel = function() { - $window.history.back(); - return false; - }; - - /** - * Deletes an object and sets the notification - * @param {type} name description - * @returns {type} description - */ - $scope.delete = function() { - function doDelete() { - savedObjectsClient - .delete(service.type, $routeParams.id) - .then(function() { - return redirectHandler('deleted'); - }) - .catch(error => fatalError(error, location)); - } - const confirmModalOptions = { - confirmButtonText: i18n.translate( - 'kbn.management.objects.confirmModalOptions.deleteButtonLabel', - { - defaultMessage: 'Delete', - } - ), - title: i18n.translate('kbn.management.objects.confirmModalOptions.modalTitle', { - defaultMessage: 'Delete saved Kibana object?', - }), - }; - - overlays - .openConfirm( - i18n.translate('kbn.management.objects.confirmModalOptions.modalDescription', { - defaultMessage: "You can't recover deleted objects", - }), - confirmModalOptions - ) - .then(isConfirmed => { - if (isConfirmed) { - doDelete(); - } - }); - }; - - $scope.submit = function() { - const source = _.cloneDeep($scope.obj.attributes); - - _.each($scope.fields, function(field) { - let value = field.value; - - if (field.type === 'number') { - value = Number(field.value); - } - - if (field.type === 'array') { - value = JSON.parse(field.value); - } - - _.set(source, field.name, value); - }); - - const { references, ...attributes } = source; - - savedObjectsClient - .update(service.type, $routeParams.id, attributes, { references }) - .then(function() { - return redirectHandler('updated'); - }) - .catch(error => fatalError(error, location)); - }; - - function redirectHandler(action) { - $location.path('/management/kibana/objects').search({ - _a: rison.encode({ - tab: serviceObj.title, - }), - }); - - toastNotifications.addSuccess( - `${_.capitalize(action)} '${ - $scope.obj.attributes.title - }' ${$scope.title.toLowerCase()} object` - ); - } + controller: function($scope, $routeParams) { + createReactView($scope, $routeParams); + $scope.$on('$destroy', destroyReactView); }, }; }); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/__snapshots__/header.test.tsx.snap b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/__snapshots__/header.test.tsx.snap new file mode 100644 index 0000000000000..7e1f7ea12b014 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/__snapshots__/header.test.tsx.snap @@ -0,0 +1,165 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Intro component renders correctly 1`] = ` +
+ +
+ +
+ +

+ + Edit search + +

+
+
+
+ +
+ +
+ +
+ + + + + + +
+ + + +
+
+
+ +
+ +
+ +
+`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/__snapshots__/intro.test.tsx.snap b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/__snapshots__/intro.test.tsx.snap new file mode 100644 index 0000000000000..812031b4b363c --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/__snapshots__/intro.test.tsx.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Intro component renders correctly 1`] = ` + + + } + > +
+
+
+ + +`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/__snapshots__/not_found_errors.test.tsx.snap b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/__snapshots__/not_found_errors.test.tsx.snap new file mode 100644 index 0000000000000..ac565a000813e --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/object_view/__snapshots__/not_found_errors.test.tsx.snap @@ -0,0 +1,301 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NotFoundErrors component renders correctly for index-pattern type 1`] = ` + + + } + > +
+
+
+ + +`; + +exports[`NotFoundErrors component renders correctly for index-pattern-field type 1`] = ` + + + } + > +
+
+
+ + +`; + +exports[`NotFoundErrors component renders correctly for search type 1`] = ` + + + } + > +
+
+
+ + +`; + +exports[`NotFoundErrors component renders correctly for unknown type 1`] = ` + + + } + > +
+
+
diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/create_field_list.test.ts b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/create_field_list.test.ts new file mode 100644 index 0000000000000..345716f91ea88 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/create_field_list.test.ts @@ -0,0 +1,132 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SimpleSavedObject, SavedObjectReference } from '../../../../../../../../core/public'; +import { savedObjectsServiceMock } from '../../../../../../../../core/public/mocks'; +import { createFieldList } from './create_field_list'; + +const savedObjectClientMock = savedObjectsServiceMock.createStartContract().client; + +const createObject = ( + attributes: T, + references: SavedObjectReference[] = [] +): SimpleSavedObject => + new SimpleSavedObject(savedObjectClientMock, { + id: 'id', + type: 'type', + migrationVersion: {}, + attributes, + references, + }); + +describe('createFieldList', () => { + it('generate fields based on the object attributes', () => { + const obj = createObject({ + textField: 'some text', + numberField: 12, + boolField: true, + }); + expect(createFieldList(obj)).toMatchInlineSnapshot(` + Array [ + Object { + "name": "textField", + "type": "text", + "value": "some text", + }, + Object { + "name": "numberField", + "type": "number", + "value": 12, + }, + Object { + "name": "boolField", + "type": "boolean", + "value": true, + }, + ] + `); + }); + + it('detects json fields', () => { + const obj = createObject({ + jsonField: `{"data": "value"}`, + }); + expect(createFieldList(obj)).toMatchInlineSnapshot(` + Array [ + Object { + "name": "jsonField", + "type": "json", + "value": "{ + \\"data\\": \\"value\\" + }", + }, + ] + `); + }); + + it('handles array fields', () => { + const obj = createObject({ + someArray: [1, 2, 3], + }); + expect(createFieldList(obj)).toMatchInlineSnapshot(` + Array [ + Object { + "name": "someArray", + "type": "array", + "value": "[ + 1, + 2, + 3 + ]", + }, + ] + `); + }); + + it('recursively collect nested fields', () => { + const obj = createObject({ + firstLevel: { + firstLevelField: 'foo', + secondLevel: { + secondLevelFieldA: 'A', + secondLevelFieldB: 'B', + }, + }, + }); + expect(createFieldList(obj)).toMatchInlineSnapshot(` + Array [ + Object { + "name": "firstLevel.firstLevelField", + "type": "text", + "value": "foo", + }, + Object { + "name": "firstLevel.secondLevel.secondLevelFieldA", + "type": "text", + "value": "A", + }, + Object { + "name": "firstLevel.secondLevel.secondLevelFieldB", + "type": "text", + "value": "B", + }, + ] + `); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/create_field_list.ts b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/create_field_list.ts new file mode 100644 index 0000000000000..88a1184d5d70f --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/create_field_list.ts @@ -0,0 +1,135 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { forOwn, indexBy, isNumber, isBoolean, isPlainObject, isString } from 'lodash'; +import { SimpleSavedObject } from '../../../../../../../../core/public'; +import { castEsToKbnFieldTypeName } from '../../../../../../../../plugins/data/public'; +import { ObjectField } from '../types'; +import { SavedObjectLoader } from '../../../../../../../../plugins/saved_objects/public'; + +const maxRecursiveIterations = 20; + +export function createFieldList( + object: SimpleSavedObject, + service?: SavedObjectLoader +): ObjectField[] { + const fields = Object.entries(object.attributes as Record).reduce( + (objFields, [key, value]) => { + return [...objFields, ...recursiveCreateFields(key, value)]; + }, + [] as ObjectField[] + ); + if (service && (service as any).Class) { + addFieldsFromClass((service as any).Class, fields); + } + + return fields; +} + +/** + * Creates a field definition and pushes it to the memo stack. This function + * is designed to be used in conjunction with _.reduce(). If the + * values is plain object it will recurse through all the keys till it hits + * a string, number or an array. + * + * @param {string} key The key of the field + * @param {mixed} value The value of the field + * @param {array} parents The parent keys to the field + * @returns {array} + */ +const recursiveCreateFields = (key: string, value: any, parents: string[] = []): ObjectField[] => { + const path = [...parents, key]; + if (path.length > maxRecursiveIterations) { + return []; + } + + const field: ObjectField = { type: 'text', name: path.join('.'), value }; + + if (isString(field.value)) { + try { + field.value = JSON.stringify(JSON.parse(field.value), undefined, 2); + field.type = 'json'; + } catch (err) { + field.type = 'text'; + } + } else if (isNumber(field.value)) { + field.type = 'number'; + } else if (Array.isArray(field.value)) { + field.type = 'array'; + field.value = JSON.stringify(field.value, undefined, 2); + } else if (isBoolean(field.value)) { + field.type = 'boolean'; + } else if (isPlainObject(field.value)) { + let fields: ObjectField[] = []; + forOwn(field.value, (childValue, childKey) => { + fields = [...fields, ...recursiveCreateFields(childKey as string, childValue, path)]; + }); + return fields; + } + + return [field]; +}; + +const addFieldsFromClass = function( + Class: { mapping: Record; searchSource: any }, + fields: ObjectField[] +) { + const fieldMap = indexBy(fields, 'name'); + + _.forOwn(Class.mapping, (esType, name) => { + if (!name || fieldMap[name]) { + return; + } + + const getFieldTypeFromEsType = () => { + switch (castEsToKbnFieldTypeName(esType)) { + case 'string': + return 'text'; + case 'number': + return 'number'; + case 'boolean': + return 'boolean'; + default: + return 'json'; + } + }; + + fields.push({ + name, + type: getFieldTypeFromEsType(), + value: undefined, + }); + }); + + if (Class.searchSource && !fieldMap['kibanaSavedObjectMeta.searchSourceJSON']) { + fields.push({ + name: 'kibanaSavedObjectMeta.searchSourceJSON', + type: 'json', + value: '{}', + }); + } + + if (!fieldMap.references) { + fields.push({ + name: 'references', + type: 'array', + value: '[]', + }); + } +}; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/in_app_url.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/in_app_url.ts similarity index 71% rename from src/legacy/core_plugins/kibana/public/management/sections/objects/lib/in_app_url.js rename to src/legacy/core_plugins/kibana/public/management/sections/objects/lib/in_app_url.ts index 0026154848693..82146554afa6f 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/in_app_url.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/in_app_url.ts @@ -17,22 +17,24 @@ * under the License. */ -export function canViewInApp(uiCapabilities, type) { +import { Capabilities } from 'src/core/public'; + +export function canViewInApp(uiCapabilities: Capabilities, type: string): boolean { switch (type) { case 'search': case 'searches': - return uiCapabilities.discover.show; + return uiCapabilities.discover.show as boolean; case 'visualization': case 'visualizations': - return uiCapabilities.visualize.show; + return uiCapabilities.visualize.show as boolean; case 'index-pattern': case 'index-patterns': case 'indexPatterns': - return uiCapabilities.management.kibana.index_patterns; + return uiCapabilities.management.kibana.index_patterns as boolean; case 'dashboard': case 'dashboards': - return uiCapabilities.dashboard.show; + return uiCapabilities.dashboard.show as boolean; default: - return uiCapabilities[type].show; + return uiCapabilities[type].show as boolean; } } diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/saved_object_view.tsx b/src/legacy/core_plugins/kibana/public/management/sections/objects/saved_object_view.tsx new file mode 100644 index 0000000000000..4984fe3e6d6b8 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/saved_object_view.tsx @@ -0,0 +1,176 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { Component } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSpacer, EuiPageContent } from '@elastic/eui'; +import { + Capabilities, + SavedObjectsClientContract, + OverlayStart, + NotificationsStart, + SimpleSavedObject, +} from '../../../../../../../core/public'; +import { ISavedObjectsManagementRegistry } from '../../saved_object_registry'; +import { Header, NotFoundErrors, Intro, Form } from './components/object_view'; +import { canViewInApp } from './lib/in_app_url'; +import { SubmittedFormData } from './types'; + +interface SavedObjectEditionProps { + id: string; + serviceName: string; + serviceRegistry: ISavedObjectsManagementRegistry; + capabilities: Capabilities; + overlays: OverlayStart; + notifications: NotificationsStart; + notFoundType?: string; + savedObjectsClient: SavedObjectsClientContract; +} + +interface SavedObjectEditionState { + type: string; + object?: SimpleSavedObject; +} + +export class SavedObjectEdition extends Component< + SavedObjectEditionProps, + SavedObjectEditionState +> { + constructor(props: SavedObjectEditionProps) { + super(props); + + const { serviceRegistry, serviceName } = props; + const type = serviceRegistry.get(serviceName)!.service.type; + + this.state = { + object: undefined, + type, + }; + } + + componentDidMount() { + const { id, savedObjectsClient } = this.props; + const { type } = this.state; + savedObjectsClient.get(type, id).then(object => { + this.setState({ + object, + }); + }); + } + + render() { + const { + capabilities, + notFoundType, + serviceRegistry, + id, + serviceName, + savedObjectsClient, + } = this.props; + const { type } = this.state; + const { object } = this.state; + const { edit: canEdit, delete: canDelete } = capabilities.savedObjectsManagement as Record< + string, + boolean + >; + const canView = canViewInApp(capabilities, type); + const service = serviceRegistry.get(serviceName)!.service; + + return ( + +
this.delete()} + viewUrl={service.urlFor(id)} + /> + {notFoundType && ( + <> + + + + )} + {canEdit && ( + <> + + + + )} + {object && ( + <> + +
+ + )} + + ); + } + + async delete() { + const { id, savedObjectsClient, overlays, notifications } = this.props; + const { type, object } = this.state; + + const confirmed = await overlays.openConfirm( + i18n.translate('kbn.management.objects.confirmModalOptions.modalDescription', { + defaultMessage: 'This action permanently removes the object from Kibana.', + }), + { + confirmButtonText: i18n.translate( + 'kbn.management.objects.confirmModalOptions.deleteButtonLabel', + { + defaultMessage: 'Delete', + } + ), + title: i18n.translate('kbn.management.objects.confirmModalOptions.modalTitle', { + defaultMessage: `Delete '{title}'?`, + values: { + title: object?.attributes?.title || 'saved Kibana object', + }, + }), + buttonColor: 'danger', + } + ); + if (confirmed) { + await savedObjectsClient.delete(type, id); + notifications.toasts.addSuccess(`Deleted '${object!.attributes.title}' ${type} object`); + this.redirectToListing(); + } + } + + saveChanges = async ({ attributes, references }: SubmittedFormData) => { + const { savedObjectsClient, notifications } = this.props; + const { object, type } = this.state; + + await savedObjectsClient.update(object!.type, object!.id, attributes, { references }); + notifications.toasts.addSuccess(`Updated '${attributes.title}' ${type} object`); + this.redirectToListing(); + }; + + redirectToListing() { + window.location.hash = '/management/kibana/objects'; + } +} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/types.ts b/src/legacy/core_plugins/kibana/public/management/sections/objects/types.ts new file mode 100644 index 0000000000000..32436e96d4829 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/types.ts @@ -0,0 +1,38 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectReference } from 'src/core/public'; + +export interface ObjectField { + type: FieldType; + name: string; + value: any; +} + +export type FieldType = 'text' | 'number' | 'boolean' | 'array' | 'json'; + +export interface FieldState { + value?: any; + invalid?: boolean; +} + +export interface SubmittedFormData { + attributes: any; + references: SavedObjectReference[]; +} diff --git a/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts b/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts index 69af466a03729..e6b7a29e28d89 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts @@ -33,7 +33,6 @@ export { DashboardConstants } from '../dashboard/np_ready/dashboard_constants'; export { VisSavedObject, VISUALIZE_EMBEDDABLE_TYPE } from '../../../visualizations/public/'; export { configureAppAngularModule, - ensureDefaultIndexPattern, IPrivate, migrateLegacyQuery, PrivateProvider, diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/legacy_app.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/legacy_app.js index 1002f401706cd..0f1d50b149cd9 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/legacy_app.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/legacy_app.js @@ -24,6 +24,7 @@ import { createHashHistory } from 'history'; import { createKbnUrlStateStorage, redirectWhenMissing, + ensureDefaultIndexPattern, } from '../../../../../../plugins/kibana_utils/public'; import editorTemplate from './editor/editor.html'; @@ -32,7 +33,6 @@ import visualizeListingTemplate from './listing/visualize_listing.html'; import { initVisualizeAppDirective } from './visualize_app'; import { VisualizeConstants } from './visualize_constants'; import { VisualizeListingController } from './listing/visualize_listing'; -import { ensureDefaultIndexPattern } from '../legacy_imports'; import { getLandingBreadcrumbs, @@ -82,8 +82,7 @@ export function initVisualizeApp(app, deps) { controllerAs: 'listingController', resolve: { createNewVis: () => false, - hasDefaultIndex: ($rootScope, kbnUrl) => - ensureDefaultIndexPattern(deps.core, deps.data, $rootScope, kbnUrl), + hasDefaultIndex: history => ensureDefaultIndexPattern(deps.core, deps.data, history), }, }) .when(VisualizeConstants.WIZARD_STEP_1_PAGE_PATH, { @@ -94,8 +93,7 @@ export function initVisualizeApp(app, deps) { controllerAs: 'listingController', resolve: { createNewVis: () => true, - hasDefaultIndex: ($rootScope, kbnUrl) => - ensureDefaultIndexPattern(deps.core, deps.data, $rootScope, kbnUrl), + hasDefaultIndex: history => ensureDefaultIndexPattern(deps.core, deps.data, history), }, }) .when(VisualizeConstants.CREATE_PATH, { @@ -103,7 +101,7 @@ export function initVisualizeApp(app, deps) { template: editorTemplate, k7Breadcrumbs: getCreateBreadcrumbs, resolve: { - savedVis: function($route, $rootScope, kbnUrl, history) { + savedVis: function($route, history) { const { core, data, savedVisualizations, visualizations, toastNotifications } = deps; const visTypes = visualizations.all(); const visType = find(visTypes, { name: $route.current.params.type }); @@ -121,7 +119,7 @@ export function initVisualizeApp(app, deps) { ); } - return ensureDefaultIndexPattern(core, data, $rootScope, kbnUrl) + return ensureDefaultIndexPattern(core, data, history) .then(() => savedVisualizations.get($route.current.params)) .then(savedVis => { if (savedVis.vis.type.setup) { @@ -144,9 +142,9 @@ export function initVisualizeApp(app, deps) { template: editorTemplate, k7Breadcrumbs: getEditBreadcrumbs, resolve: { - savedVis: function($route, $rootScope, kbnUrl, history) { + savedVis: function($route, history) { const { chrome, core, data, savedVisualizations, toastNotifications } = deps; - return ensureDefaultIndexPattern(core, data, $rootScope, kbnUrl) + return ensureDefaultIndexPattern(core, data, history) .then(() => savedVisualizations.get($route.current.params.id)) .then(savedVis => { chrome.recentlyAccessed.add(savedVis.getFullPath(), savedVis.title, savedVis.id); diff --git a/src/legacy/ui/public/exit_full_screen/__snapshots__/exit_full_screen_button.test.js.snap b/src/legacy/ui/public/exit_full_screen/__snapshots__/exit_full_screen_button.test.js.snap index 27226eb010ba2..365f3afdab395 100644 --- a/src/legacy/ui/public/exit_full_screen/__snapshots__/exit_full_screen_button.test.js.snap +++ b/src/legacy/ui/public/exit_full_screen/__snapshots__/exit_full_screen_button.test.js.snap @@ -12,19 +12,45 @@ exports[`is rendered 1`] = `
diff --git a/src/legacy/ui/public/legacy_compat/index.ts b/src/legacy/ui/public/legacy_compat/index.ts index 3b700c8d59399..2067fa6489304 100644 --- a/src/legacy/ui/public/legacy_compat/index.ts +++ b/src/legacy/ui/public/legacy_compat/index.ts @@ -17,7 +17,4 @@ * under the License. */ -export { - configureAppAngularModule, - ensureDefaultIndexPattern, -} from '../../../../plugins/kibana_legacy/public'; +export { configureAppAngularModule } from '../../../../plugins/kibana_legacy/public'; diff --git a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js index ea84ba1ad2838..c58a7d2fbb5cd 100644 --- a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js +++ b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js @@ -21,13 +21,15 @@ import sinon from 'sinon'; import { getFieldFormatsRegistry } from '../../../../test_utils/public/stub_field_formats'; import { METRIC_TYPE } from '@kbn/analytics'; import { + setFieldFormats, setIndexPatterns, - setQueryService, - setUiSettings, setInjectedMetadata, - setFieldFormats, - setSearchService, + setHttp, + setNotifications, setOverlays, + setQueryService, + setSearchService, + setUiSettings, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../plugins/data/public/services'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths @@ -477,11 +479,13 @@ export function __start__(coreStart) { // Services that need to be set in the legacy platform since the legacy data plugin // which previously provided them has been removed. + setHttp(npStart.core.http); + setNotifications(npStart.core.notifications); + setOverlays(npStart.core.overlays); setUiSettings(npStart.core.uiSettings); - setQueryService(npStart.plugins.data.query); - setIndexPatterns(npStart.plugins.data.indexPatterns); setFieldFormats(npStart.plugins.data.fieldFormats); + setIndexPatterns(npStart.plugins.data.indexPatterns); + setQueryService(npStart.plugins.data.query); setSearchService(npStart.plugins.data.search); setAggs(npStart.plugins.data.search.aggs); - setOverlays(npStart.core.overlays); } diff --git a/src/legacy/ui/public/new_platform/new_platform.test.ts b/src/legacy/ui/public/new_platform/new_platform.test.ts index 498f05457bba9..dd41093f3a1f0 100644 --- a/src/legacy/ui/public/new_platform/new_platform.test.ts +++ b/src/legacy/ui/public/new_platform/new_platform.test.ts @@ -20,8 +20,19 @@ jest.mock('history'); import { setRootControllerMock, historyMock } from './new_platform.test.mocks'; -import { legacyAppRegister, __reset__, __setup__ } from './new_platform'; +import { + legacyAppRegister, + __reset__, + __setup__, + __start__, + PluginsSetup, + PluginsStart, +} from './new_platform'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import * as dataServices from '../../../../plugins/data/public/services'; +import { LegacyCoreSetup, LegacyCoreStart } from '../../../../core/public'; import { coreMock } from '../../../../core/public/mocks'; +import { npSetup, npStart } from './__mocks__'; describe('ui/new_platform', () => { describe('legacyAppRegister', () => { @@ -108,4 +119,25 @@ describe('ui/new_platform', () => { expect(unmountMock).toHaveBeenCalled(); }); }); + + describe('service getters', () => { + const services: Record = dataServices; + const getters = Object.keys(services).filter(k => k.substring(0, 3) === 'get'); + + getters.forEach(g => { + it(`sets a value for ${g}`, () => { + __reset__(); + __setup__( + (coreMock.createSetup() as unknown) as LegacyCoreSetup, + (npSetup.plugins as unknown) as PluginsSetup + ); + __start__( + (coreMock.createStart() as unknown) as LegacyCoreStart, + (npStart.plugins as unknown) as PluginsStart + ); + + expect(services[g]()).toBeDefined(); + }); + }); + }); }); diff --git a/src/legacy/ui/public/new_platform/new_platform.ts b/src/legacy/ui/public/new_platform/new_platform.ts index 07e17ad562291..deb8387fee29c 100644 --- a/src/legacy/ui/public/new_platform/new_platform.ts +++ b/src/legacy/ui/public/new_platform/new_platform.ts @@ -31,13 +31,15 @@ import { } from '../../../../core/public'; import { Plugin as DataPlugin } from '../../../../plugins/data/public'; import { + setFieldFormats, setIndexPatterns, - setQueryService, - setUiSettings, setInjectedMetadata, - setFieldFormats, - setSearchService, + setHttp, + setNotifications, setOverlays, + setQueryService, + setSearchService, + setUiSettings, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../plugins/data/public/services'; import { Plugin as ExpressionsPlugin } from '../../../../plugins/expressions/public'; @@ -141,12 +143,14 @@ export function __start__(coreStart: LegacyCoreStart, plugins: PluginsStart) { // Services that need to be set in the legacy platform since the legacy data plugin // which previously provided them has been removed. + setHttp(npStart.core.http); + setNotifications(npStart.core.notifications); + setOverlays(npStart.core.overlays); setUiSettings(npStart.core.uiSettings); - setQueryService(npStart.plugins.data.query); - setIndexPatterns(npStart.plugins.data.indexPatterns); setFieldFormats(npStart.plugins.data.fieldFormats); + setIndexPatterns(npStart.plugins.data.indexPatterns); + setQueryService(npStart.plugins.data.query); setSearchService(npStart.plugins.data.search); - setOverlays(npStart.core.overlays); } /** Flag used to ensure `legacyAppRegister` is only called once. */ diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx index 170024c192e7f..cf62de82bcf4b 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx @@ -22,6 +22,7 @@ import { i18n } from '@kbn/i18n'; import { debounce } from 'lodash'; import { parse } from 'query-string'; import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'; +import { useUIAceKeyboardMode } from '../../../../../../../es_ui_shared/public'; // @ts-ignore import mappings from '../../../../../lib/mappings/mappings'; import { ConsoleMenu } from '../../../../components'; @@ -34,7 +35,6 @@ import { import * as senseEditor from '../../../../models/sense_editor'; import { autoIndent, getDocumentation } from '../console_menu_actions'; import { subscribeResizeChecker } from '../subscribe_console_resize_checker'; -import { useUIAceKeyboardMode } from '../use_ui_ace_keyboard_mode'; import { applyCurrentSettings } from './apply_editor_settings'; import { registerCommands } from './keyboard_shortcuts'; diff --git a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts index 47947e985092b..fc419b0f10dca 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts +++ b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts @@ -18,9 +18,17 @@ */ import ace from 'brace'; -import { Editor as IAceEditor } from 'brace'; +import { Editor as IAceEditor, IEditSession as IAceEditSession } from 'brace'; import $ from 'jquery'; -import { CoreEditor, Position, Range, Token, TokensProvider, EditorEvent } from '../../../types'; +import { + CoreEditor, + Position, + Range, + Token, + TokensProvider, + EditorEvent, + AutoCompleterFunction, +} from '../../../types'; import { AceTokensProvider } from '../../../lib/ace_token_provider'; import * as curl from '../sense_editor/curl'; import smartResize from './smart_resize'; @@ -354,4 +362,48 @@ export class LegacyCoreEditor implements CoreEditor { } } } + + registerAutocompleter(autocompleter: AutoCompleterFunction): void { + // Hook into Ace + + // disable standard context based autocompletion. + // @ts-ignore + ace.define('ace/autocomplete/text_completer', ['require', 'exports', 'module'], function( + require: any, + exports: any + ) { + exports.getCompletions = function( + innerEditor: any, + session: any, + pos: any, + prefix: any, + callback: any + ) { + callback(null, []); + }; + }); + + const langTools = ace.acequire('ace/ext/language_tools'); + + langTools.setCompleters([ + { + identifierRegexps: [ + /[a-zA-Z_0-9\.\$\-\u00A2-\uFFFF]/, // adds support for dot character + ], + getCompletions: ( + DO_NOT_USE_1: IAceEditor, + DO_NOT_USE_2: IAceEditSession, + pos: { row: number; column: number }, + prefix: string, + callback: (...args: any[]) => void + ) => { + const position: Position = { + lineNumber: pos.row + 1, + column: pos.column + 1, + }; + autocompleter(position, prefix, callback); + }, + }, + ]); + } } diff --git a/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js b/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js index 1a09b6b00da9c..c5a0c2ebddf71 100644 --- a/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js +++ b/src/plugins/console/public/application/models/sense_editor/__tests__/integration.test.js @@ -84,93 +84,90 @@ describe('Integration', () => { changeListener: function() {}, }; // mimic auto complete - senseEditor.autocomplete._test.getCompletions( - senseEditor, - null, - { row: cursor.lineNumber - 1, column: cursor.column - 1 }, - '', - function(err, terms) { - if (testToRun.assertThrows) { - done(); - return; - } + senseEditor.autocomplete._test.getCompletions(senseEditor, null, cursor, '', function( + err, + terms + ) { + if (testToRun.assertThrows) { + done(); + return; + } - if (err) { - throw err; - } + if (err) { + throw err; + } - if (testToRun.no_context) { - expect(!terms || terms.length === 0).toBeTruthy(); - } else { - expect(terms).not.toBeNull(); - expect(terms.length).toBeGreaterThan(0); - } + if (testToRun.no_context) { + expect(!terms || terms.length === 0).toBeTruthy(); + } else { + expect(terms).not.toBeNull(); + expect(terms.length).toBeGreaterThan(0); + } - if (!terms || terms.length === 0) { - done(); - return; - } + if (!terms || terms.length === 0) { + done(); + return; + } - if (testToRun.autoCompleteSet) { - const expectedTerms = _.map(testToRun.autoCompleteSet, function(t) { - if (typeof t !== 'object') { - t = { name: t }; - } - return t; - }); - if (terms.length !== expectedTerms.length) { - expect(_.pluck(terms, 'name')).toEqual(_.pluck(expectedTerms, 'name')); - } else { - const filteredActualTerms = _.map(terms, function(actualTerm, i) { - const expectedTerm = expectedTerms[i]; - const filteredTerm = {}; - _.each(expectedTerm, function(v, p) { - filteredTerm[p] = actualTerm[p]; - }); - return filteredTerm; - }); - expect(filteredActualTerms).toEqual(expectedTerms); + if (testToRun.autoCompleteSet) { + const expectedTerms = _.map(testToRun.autoCompleteSet, function(t) { + if (typeof t !== 'object') { + t = { name: t }; } + return t; + }); + if (terms.length !== expectedTerms.length) { + expect(_.pluck(terms, 'name')).toEqual(_.pluck(expectedTerms, 'name')); + } else { + const filteredActualTerms = _.map(terms, function(actualTerm, i) { + const expectedTerm = expectedTerms[i]; + const filteredTerm = {}; + _.each(expectedTerm, function(v, p) { + filteredTerm[p] = actualTerm[p]; + }); + return filteredTerm; + }); + expect(filteredActualTerms).toEqual(expectedTerms); } + } - const context = terms[0].context; - const { - cursor: { lineNumber, column }, - } = testToRun; - senseEditor.autocomplete._test.addReplacementInfoToContext( - context, - { lineNumber, column }, - terms[0].value - ); + const context = terms[0].context; + const { + cursor: { lineNumber, column }, + } = testToRun; + senseEditor.autocomplete._test.addReplacementInfoToContext( + context, + { lineNumber, column }, + terms[0].value + ); - function ac(prop, propTest) { - if (typeof testToRun[prop] !== 'undefined') { - if (propTest) { - propTest(context[prop], testToRun[prop], prop); - } else { - expect(context[prop]).toEqual(testToRun[prop]); - } + function ac(prop, propTest) { + if (typeof testToRun[prop] !== 'undefined') { + if (propTest) { + propTest(context[prop], testToRun[prop], prop); + } else { + expect(context[prop]).toEqual(testToRun[prop]); } } + } - function posCompare(actual, expected) { - expect(actual.lineNumber).toEqual(expected.lineNumber + lineOffset); - expect(actual.column).toEqual(expected.column); - } - - function rangeCompare(actual, expected, name) { - posCompare(actual.start, expected.start, name + '.start'); - posCompare(actual.end, expected.end, name + '.end'); - } + function posCompare(actual, expected) { + expect(actual.lineNumber).toEqual(expected.lineNumber + lineOffset); + expect(actual.column).toEqual(expected.column); + } - ac('prefixToAdd'); - ac('suffixToAdd'); - ac('addTemplate'); - ac('textBoxPosition', posCompare); - ac('rangeToReplace', rangeCompare); - done(); + function rangeCompare(actual, expected, name) { + posCompare(actual.start, expected.start, name + '.start'); + posCompare(actual.end, expected.end, name + '.end'); } - ); + + ac('prefixToAdd'); + ac('suffixToAdd'); + ac('addTemplate'); + ac('textBoxPosition', posCompare); + ac('rangeToReplace', rangeCompare); + done(); + }); }); } diff --git a/src/plugins/console/public/application/models/sense_editor/sense_editor.ts b/src/plugins/console/public/application/models/sense_editor/sense_editor.ts index f559f5dfcd707..b1444bdf2bbab 100644 --- a/src/plugins/console/public/application/models/sense_editor/sense_editor.ts +++ b/src/plugins/console/public/application/models/sense_editor/sense_editor.ts @@ -44,6 +44,7 @@ export class SenseEditor { coreEditor, parser: this.parser, }); + this.coreEditor.registerAutocompleter(this.autocomplete.getCompletions); this.coreEditor.on( 'tokenizerUpdate', this.highlightCurrentRequestsAndUpdateActionBar.bind(this) diff --git a/src/plugins/console/public/lib/autocomplete/__tests__/url_autocomplete.test.js b/src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js similarity index 99% rename from src/plugins/console/public/lib/autocomplete/__tests__/url_autocomplete.test.js rename to src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js index 40fcd551fb6f7..0758a75695566 100644 --- a/src/plugins/console/public/lib/autocomplete/__tests__/url_autocomplete.test.js +++ b/src/plugins/console/public/lib/autocomplete/__jest__/url_autocomplete.test.js @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import '../../../application/models/sense_editor/sense_editor.test.mocks'; const _ = require('lodash'); import { diff --git a/src/plugins/console/public/lib/autocomplete/__tests__/url_params.test.js b/src/plugins/console/public/lib/autocomplete/__jest__/url_params.test.js similarity index 95% rename from src/plugins/console/public/lib/autocomplete/__tests__/url_params.test.js rename to src/plugins/console/public/lib/autocomplete/__jest__/url_params.test.js index ce2a2553b19ee..72fce53c4f1fe 100644 --- a/src/plugins/console/public/lib/autocomplete/__tests__/url_params.test.js +++ b/src/plugins/console/public/lib/autocomplete/__jest__/url_params.test.js @@ -16,10 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import '../../../application/models/sense_editor/sense_editor.test.mocks'; -import 'brace'; -import 'brace/mode/javascript'; -import 'brace/mode/json'; const _ = require('lodash'); import { UrlParams } from '../../autocomplete/url_params'; import { populateContext } from '../../autocomplete/engine'; diff --git a/src/plugins/console/public/lib/autocomplete/autocomplete.ts b/src/plugins/console/public/lib/autocomplete/autocomplete.ts index e09024ccfc859..d4f10ff4e4277 100644 --- a/src/plugins/console/public/lib/autocomplete/autocomplete.ts +++ b/src/plugins/console/public/lib/autocomplete/autocomplete.ts @@ -18,9 +18,9 @@ */ import _ from 'lodash'; -import ace, { Editor as AceEditor, IEditSession } from 'brace'; import { i18n } from '@kbn/i18n'; +// TODO: All of these imports need to be moved to the core editor so that it can inject components from there. import { getTopLevelUrlCompleteComponents, getEndpointBodyCompleteComponents, @@ -39,7 +39,7 @@ import { createTokenIterator } from '../../application/factories'; import { Position, Token, Range, CoreEditor } from '../../types'; -let LAST_EVALUATED_TOKEN: any = null; +let lastEvaluatedToken: any = null; function isUrlParamsToken(token: any) { switch ((token || {}).type) { @@ -889,7 +889,7 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor if (!currentToken) { if (pos.lineNumber === 1) { - LAST_EVALUATED_TOKEN = null; + lastEvaluatedToken = null; return; } currentToken = { position: { column: 0, lineNumber: 0 }, value: '', type: '' }; // empty row @@ -902,26 +902,26 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor if (parser.isEmptyToken(nextToken)) { // Empty line, or we're not on the edge of current token. Save the current position as base currentToken.position.column = pos.column; - LAST_EVALUATED_TOKEN = currentToken; + lastEvaluatedToken = currentToken; } else { nextToken.position.lineNumber = pos.lineNumber; - LAST_EVALUATED_TOKEN = nextToken; + lastEvaluatedToken = nextToken; } return; } - if (!LAST_EVALUATED_TOKEN) { - LAST_EVALUATED_TOKEN = currentToken; + if (!lastEvaluatedToken) { + lastEvaluatedToken = currentToken; return; // wait for the next typing. } if ( - LAST_EVALUATED_TOKEN.position.column !== currentToken.position.column || - LAST_EVALUATED_TOKEN.position.lineNumber !== currentToken.position.lineNumber || - LAST_EVALUATED_TOKEN.value === currentToken.value + lastEvaluatedToken.position.column !== currentToken.position.column || + lastEvaluatedToken.position.lineNumber !== currentToken.position.lineNumber || + lastEvaluatedToken.value === currentToken.value ) { // not on the same place or nothing changed, cache and wait for the next time - LAST_EVALUATED_TOKEN = currentToken; + lastEvaluatedToken = currentToken; return; } @@ -935,7 +935,7 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor return; } - LAST_EVALUATED_TOKEN = currentToken; + lastEvaluatedToken = currentToken; editor.execCommand('startAutocomplete'); }, 100); @@ -947,17 +947,7 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor } } - function getCompletions( - DO_NOT_USE: AceEditor, - DO_NOT_USE_SESSION: IEditSession, - pos: { row: number; column: number }, - prefix: string, - callback: (...args: any[]) => void - ) { - const position: Position = { - lineNumber: pos.row + 1, - column: pos.column + 1, - }; + function getCompletions(position: Position, prefix: string, callback: (...args: any[]) => void) { try { const context = getAutoCompleteContext(editor, position); if (!context) { @@ -1028,39 +1018,12 @@ export default function({ coreEditor: editor, parser }: { coreEditor: CoreEditor editor.on('changeSelection', editorChangeListener); - // Hook into Ace - - // disable standard context based autocompletion. - // @ts-ignore - ace.define('ace/autocomplete/text_completer', ['require', 'exports', 'module'], function( - require: any, - exports: any - ) { - exports.getCompletions = function( - innerEditor: any, - session: any, - pos: any, - prefix: any, - callback: any - ) { - callback(null, []); - }; - }); - - const langTools = ace.acequire('ace/ext/language_tools'); - - langTools.setCompleters([ - { - identifierRegexps: [ - /[a-zA-Z_0-9\.\$\-\u00A2-\uFFFF]/, // adds support for dot character - ], - getCompletions, - }, - ]); - return { + getCompletions, + // TODO: This needs to be cleaned up _test: { - getCompletions, + getCompletions: (_editor: any, _editSession: any, pos: any, prefix: any, callback: any) => + getCompletions(pos, prefix, callback), addReplacementInfoToContext, addChangeListener: () => editor.on('changeSelection', editorChangeListener), removeChangeListener: () => editor.off('changeSelection', editorChangeListener), diff --git a/src/plugins/console/public/lib/autocomplete/body_completer.js b/src/plugins/console/public/lib/autocomplete/body_completer.js index e23a58780a362..1aa315c50b9bf 100644 --- a/src/plugins/console/public/lib/autocomplete/body_completer.js +++ b/src/plugins/console/public/lib/autocomplete/body_completer.js @@ -115,7 +115,6 @@ class ScopeResolver extends SharedComponent { next: [], }; const components = this.resolveLinkToComponents(context, editor); - _.each(components, function(component) { const componentResult = component.match(token, context, editor); if (componentResult && componentResult.next) { diff --git a/src/plugins/console/public/lib/autocomplete/engine.js b/src/plugins/console/public/lib/autocomplete/engine.js index f4df8af871eba..7b64d91c95374 100644 --- a/src/plugins/console/public/lib/autocomplete/engine.js +++ b/src/plugins/console/public/lib/autocomplete/engine.js @@ -43,7 +43,7 @@ export function wrapComponentWithDefaults(component, defaults) { const tracer = function() { if (window.engine_trace) { - console.log.call(console, arguments); + console.log.call(console, ...arguments); } }; diff --git a/src/plugins/console/public/lib/kb/kb.js b/src/plugins/console/public/lib/kb/kb.js index 053b82bd81d0a..ef921fa7f476e 100644 --- a/src/plugins/console/public/lib/kb/kb.js +++ b/src/plugins/console/public/lib/kb/kb.js @@ -146,6 +146,10 @@ function loadApisFromJson( return api; } +// TODO: clean up setting up of active API and use of jQuery. +// This function should be attached to a class that holds the current state, not setup +// when the file is required. Also, jQuery should not be used to make network requests +// like this, it looks like a minor security issue. export function setActiveApi(api) { if (!api) { $.ajax({ diff --git a/src/plugins/console/public/types/core_editor.ts b/src/plugins/console/public/types/core_editor.ts index 79dc3ca74200b..b71f4fff44ca5 100644 --- a/src/plugins/console/public/types/core_editor.ts +++ b/src/plugins/console/public/types/core_editor.ts @@ -29,6 +29,12 @@ export type EditorEvent = | 'change' | 'changeSelection'; +export type AutoCompleterFunction = ( + pos: Position, + prefix: string, + callback: (...args: any[]) => void +) => void; + export interface Position { /** * The line number, not zero-indexed. @@ -256,4 +262,10 @@ export interface CoreEditor { * Register a keyboard shortcut and provide a function to be called. */ registerKeyboardShortcut(opts: { keys: any; fn: () => void; name: string }): void; + + /** + * Register a completions function that will be called when the editor + * detects a change + */ + registerAutocompleter(autocompleter: AutoCompleterFunction): void; } diff --git a/src/plugins/console/server/index.ts b/src/plugins/console/server/index.ts index b603deee12e23..62e5bd6bf8d95 100644 --- a/src/plugins/console/server/index.ts +++ b/src/plugins/console/server/index.ts @@ -21,7 +21,7 @@ import { PluginConfigDescriptor, PluginInitializerContext } from 'kibana/server' import { ConfigType, config as configSchema } from './config'; import { ConsoleServerPlugin } from './plugin'; -export { ConsoleSetup } from './types'; +export { ConsoleSetup, ConsoleStart } from './types'; export const plugin = (ctx: PluginInitializerContext) => new ConsoleServerPlugin(ctx); diff --git a/src/plugins/console/server/lib/index.ts b/src/plugins/console/server/lib/index.ts index 2347084b73a66..0c8fc125874cf 100644 --- a/src/plugins/console/server/lib/index.ts +++ b/src/plugins/console/server/lib/index.ts @@ -22,4 +22,4 @@ export { ProxyConfigCollection } from './proxy_config_collection'; export { proxyRequest } from './proxy_request'; export { getElasticsearchProxyConfig } from './elasticsearch_proxy_config'; export { setHeaders } from './set_headers'; -export { addProcessorDefinition, addExtensionSpecFilePath, loadSpec } from './spec_definitions'; +export { jsSpecLoaders } from './spec_definitions'; diff --git a/src/plugins/console/server/lib/spec_definitions/api.js b/src/plugins/console/server/lib/spec_definitions/api.js deleted file mode 100644 index 9c3835013bce9..0000000000000 --- a/src/plugins/console/server/lib/spec_definitions/api.js +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; - -class Api { - constructor(name) { - this.globalRules = {}; - this.endpoints = {}; - this.name = name; - } - - addGlobalAutocompleteRules = (parentNode, rules) => { - this.globalRules[parentNode] = rules; - }; - - addEndpointDescription = (endpoint, description = {}) => { - let copiedDescription = {}; - if (this.endpoints[endpoint]) { - copiedDescription = { ...this.endpoints[endpoint] }; - } - let urlParamsDef; - _.each(description.patterns || [], function(p) { - if (p.indexOf('{indices}') >= 0) { - urlParamsDef = urlParamsDef || {}; - urlParamsDef.ignore_unavailable = '__flag__'; - urlParamsDef.allow_no_indices = '__flag__'; - urlParamsDef.expand_wildcards = ['open', 'closed']; - } - }); - - if (urlParamsDef) { - description.url_params = _.extend(description.url_params || {}, copiedDescription.url_params); - _.defaults(description.url_params, urlParamsDef); - } - - _.extend(copiedDescription, description); - _.defaults(copiedDescription, { - id: endpoint, - patterns: [endpoint], - methods: ['GET'], - }); - - this.endpoints[endpoint] = copiedDescription; - }; - - asJson() { - return { - name: this.name, - globals: this.globalRules, - endpoints: this.endpoints, - }; - } -} - -export default Api; diff --git a/src/plugins/console/server/lib/spec_definitions/es.js b/src/plugins/console/server/lib/spec_definitions/es.js deleted file mode 100644 index fc24a64f8a6f4..0000000000000 --- a/src/plugins/console/server/lib/spec_definitions/es.js +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Api from './api'; -import { getSpec } from './json'; -import { register } from './js/ingest'; -const ES = new Api('es'); - -export const loadSpec = () => { - const spec = getSpec(); - - // adding generated specs - Object.keys(spec).forEach(endpoint => { - ES.addEndpointDescription(endpoint, spec[endpoint]); - }); - - // adding globals and custom API definitions - require('./js/aliases')(ES); - require('./js/aggregations')(ES); - require('./js/document')(ES); - require('./js/filter')(ES); - require('./js/globals')(ES); - register(ES); - require('./js/mappings')(ES); - require('./js/settings')(ES); - require('./js/query')(ES); - require('./js/reindex')(ES); - require('./js/search')(ES); -}; - -export default ES; diff --git a/src/plugins/console/server/lib/spec_definitions/js/query/index.js b/src/plugins/console/server/lib/spec_definitions/index.ts similarity index 94% rename from src/plugins/console/server/lib/spec_definitions/js/query/index.js rename to src/plugins/console/server/lib/spec_definitions/index.ts index cbe4e7ed2dd5f..7c70c406d8c22 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/query/index.js +++ b/src/plugins/console/server/lib/spec_definitions/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { queryDsl as default } from './dsl'; +export { jsSpecLoaders } from './js'; diff --git a/src/plugins/console/server/lib/spec_definitions/js/aggregations.js b/src/plugins/console/server/lib/spec_definitions/js/aggregations.ts similarity index 95% rename from src/plugins/console/server/lib/spec_definitions/js/aggregations.js rename to src/plugins/console/server/lib/spec_definitions/js/aggregations.ts index 629e143aa2b43..1170c9edd2366 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/aggregations.js +++ b/src/plugins/console/server/lib/spec_definitions/js/aggregations.ts @@ -16,8 +16,9 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -/*eslint camelcase: 0*/ +/* eslint-disable @typescript-eslint/camelcase */ const significantTermsArgs = { __template: { field: '', @@ -77,7 +78,7 @@ const simple_pipeline = { }, buckets_path: '', format: '', - gap_policy: gap_policy, + gap_policy, }; const rules = { '*': { @@ -461,7 +462,7 @@ const rules = { }, buckets_path: '', format: '', - gap_policy: gap_policy, + gap_policy, window: 5, model: { __one_of: ['simple', 'linear', 'ewma', 'holt', 'holt_winters'] }, settings: { @@ -485,7 +486,7 @@ const rules = { lag: 7, }, lag: 7, - gap_policy: gap_policy, + gap_policy, buckets_path: '', format: '', }, @@ -496,7 +497,7 @@ const rules = { }, buckets_path: {}, format: '', - gap_policy: gap_policy, + gap_policy, script: '', }, bucket_selector: { @@ -505,7 +506,7 @@ const rules = { script: '', }, buckets_path: {}, - gap_policy: gap_policy, + gap_policy, script: '', }, bucket_sort: { @@ -515,7 +516,7 @@ const rules = { sort: ['{field}'], from: 0, size: 0, - gap_policy: gap_policy, + gap_policy, }, matrix_stats: { __template: { @@ -526,8 +527,11 @@ const rules = { }, }; const { terms, histogram, date_histogram } = rules['*']; -export default function(api) { - api.addGlobalAutocompleteRules('aggregations', rules); - api.addGlobalAutocompleteRules('aggs', rules); - api.addGlobalAutocompleteRules('groupByAggs', { '*': { terms, histogram, date_histogram } }); -} + +export const aggs = (specService: SpecDefinitionsService) => { + specService.addGlobalAutocompleteRules('aggregations', rules); + specService.addGlobalAutocompleteRules('aggs', rules); + specService.addGlobalAutocompleteRules('groupByAggs', { + '*': { terms, histogram, date_histogram }, + }); +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/aliases.js b/src/plugins/console/server/lib/spec_definitions/js/aliases.ts similarity index 80% rename from src/plugins/console/server/lib/spec_definitions/js/aliases.js rename to src/plugins/console/server/lib/spec_definitions/js/aliases.ts index f46713fb8dd3f..c7d51b70ab3e3 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/aliases.js +++ b/src/plugins/console/server/lib/spec_definitions/js/aliases.ts @@ -16,15 +16,17 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -export default function(api) { +/* eslint-disable @typescript-eslint/camelcase */ +export const aliases = (specService: SpecDefinitionsService) => { const aliasRules = { filter: {}, routing: '1', search_routing: '1,2', index_routing: '1', }; - api.addGlobalAutocompleteRules('aliases', { + specService.addGlobalAutocompleteRules('aliases', { '*': aliasRules, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/document.js b/src/plugins/console/server/lib/spec_definitions/js/document.ts similarity index 85% rename from src/plugins/console/server/lib/spec_definitions/js/document.js rename to src/plugins/console/server/lib/spec_definitions/js/document.ts index 2bdaa2ec2af9b..f8214faab2681 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/document.js +++ b/src/plugins/console/server/lib/spec_definitions/js/document.ts @@ -16,9 +16,11 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -export default function(api) { - api.addEndpointDescription('update', { +/* eslint-disable @typescript-eslint/camelcase */ +export const document = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('update', { data_autocomplete_rules: { script: { // populated by a global rule @@ -29,7 +31,7 @@ export default function(api) { }, }); - api.addEndpointDescription('put_script', { + specService.addEndpointDescription('put_script', { methods: ['POST', 'PUT'], patterns: ['_scripts/{lang}/{id}', '_scripts/{lang}/{id}/_create'], url_components: { @@ -40,7 +42,7 @@ export default function(api) { }, }); - api.addEndpointDescription('termvectors', { + specService.addEndpointDescription('termvectors', { data_autocomplete_rules: { fields: ['{field}'], offsets: { __one_of: [false, true] }, @@ -68,4 +70,4 @@ export default function(api) { }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/filter.js b/src/plugins/console/server/lib/spec_definitions/js/filter.ts similarity index 94% rename from src/plugins/console/server/lib/spec_definitions/js/filter.js rename to src/plugins/console/server/lib/spec_definitions/js/filter.ts index bf669cff788e8..27e02f7cf1837 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/filter.js +++ b/src/plugins/console/server/lib/spec_definitions/js/filter.ts @@ -16,8 +16,10 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -const filters = {}; +/* eslint-disable @typescript-eslint/camelcase */ +const filters: Record = {}; filters.and = { __template: { @@ -324,6 +326,6 @@ filters.nested = { _name: '', }; -export default function(api) { - api.addGlobalAutocompleteRules('filter', filters); -} +export const filter = (specService: SpecDefinitionsService) => { + specService.addGlobalAutocompleteRules('filter', filters); +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/globals.js b/src/plugins/console/server/lib/spec_definitions/js/globals.ts similarity index 85% rename from src/plugins/console/server/lib/spec_definitions/js/globals.js rename to src/plugins/console/server/lib/spec_definitions/js/globals.ts index 316a76c8c9434..32e1957f74d0b 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/globals.js +++ b/src/plugins/console/server/lib/spec_definitions/js/globals.ts @@ -16,7 +16,9 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; +/* eslint-disable @typescript-eslint/camelcase */ const highlightOptions = { boundary_chars: {}, boundary_max_scan: 20, @@ -48,8 +50,9 @@ const highlightOptions = { }, tags_schema: {}, }; -export default function(api) { - api.addGlobalAutocompleteRules('highlight', { + +export const globals = (specService: SpecDefinitionsService) => { + specService.addGlobalAutocompleteRules('highlight', { ...highlightOptions, fields: { '{field}': { @@ -60,7 +63,7 @@ export default function(api) { }, }); - api.addGlobalAutocompleteRules('script', { + specService.addGlobalAutocompleteRules('script', { __template: { source: 'SCRIPT', }, @@ -70,4 +73,4 @@ export default function(api) { lang: '', params: {}, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/index.ts b/src/plugins/console/server/lib/spec_definitions/js/index.ts new file mode 100644 index 0000000000000..234ccd22aaa8b --- /dev/null +++ b/src/plugins/console/server/lib/spec_definitions/js/index.ts @@ -0,0 +1,46 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SpecDefinitionsService } from '../../../services'; + +import { aggs } from './aggregations'; +import { aliases } from './aliases'; +import { document } from './document'; +import { filter } from './filter'; +import { globals } from './globals'; +import { ingest } from './ingest'; +import { mappings } from './mappings'; +import { settings } from './settings'; +import { query } from './query'; +import { reindex } from './reindex'; +import { search } from './search'; + +export const jsSpecLoaders: Array<(registry: SpecDefinitionsService) => void> = [ + aggs, + aliases, + document, + filter, + globals, + ingest, + mappings, + settings, + query, + reindex, + search, +]; diff --git a/src/plugins/console/server/lib/spec_definitions/js/ingest.js b/src/plugins/console/server/lib/spec_definitions/js/ingest.ts similarity index 96% rename from src/plugins/console/server/lib/spec_definitions/js/ingest.js rename to src/plugins/console/server/lib/spec_definitions/js/ingest.ts index edc9cc7b3e45c..1182dc075f42f 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/ingest.js +++ b/src/plugins/console/server/lib/spec_definitions/js/ingest.ts @@ -17,6 +17,9 @@ * under the License. */ +import { SpecDefinitionsService } from '../../../services'; + +/* eslint-disable @typescript-eslint/camelcase */ const commonPipelineParams = { on_failure: [], ignore_failure: { @@ -427,27 +430,23 @@ const pipelineDefinition = { version: 123, }; -export const register = api => { +export const ingest = (specService: SpecDefinitionsService) => { // Note: this isn't an actual API endpoint. It exists so the forEach processor's "processor" field // may recursively use the autocomplete rules for any processor. - api.addEndpointDescription('_processor', { + specService.addEndpointDescription('_processor', { data_autocomplete_rules: processorDefinition, }); - api.addEndpointDescription('ingest.put_pipeline', { + specService.addEndpointDescription('ingest.put_pipeline', { methods: ['PUT'], patterns: ['_ingest/pipeline/{id}'], data_autocomplete_rules: pipelineDefinition, }); - api.addEndpointDescription('ingest.simulate', { + specService.addEndpointDescription('ingest.simulate', { data_autocomplete_rules: { pipeline: pipelineDefinition, docs: [], }, }); }; - -export const addProcessorDefinition = processor => { - processorDefinition.__one_of.push(processor); -}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/mappings.js b/src/plugins/console/server/lib/spec_definitions/js/mappings.ts similarity index 96% rename from src/plugins/console/server/lib/spec_definitions/js/mappings.js rename to src/plugins/console/server/lib/spec_definitions/js/mappings.ts index 5884d14d4dc8b..8491bc17a2ff6 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/mappings.js +++ b/src/plugins/console/server/lib/spec_definitions/js/mappings.ts @@ -17,12 +17,15 @@ * under the License. */ -const _ = require('lodash'); +import _ from 'lodash'; + +import { SpecDefinitionsService } from '../../../services'; import { BOOLEAN } from './shared'; -export default function(api) { - api.addEndpointDescription('put_mapping', { +/* eslint-disable @typescript-eslint/camelcase */ +export const mappings = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('put_mapping', { priority: 10, // collides with put doc by id data_autocomplete_rules: { __template: { @@ -249,4 +252,4 @@ export default function(api) { }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/query/dsl.js b/src/plugins/console/server/lib/spec_definitions/js/query/dsl.ts similarity index 96% rename from src/plugins/console/server/lib/spec_definitions/js/query/dsl.js rename to src/plugins/console/server/lib/spec_definitions/js/query/dsl.ts index a5f0d15dee0e9..d6e5030fb6928 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/query/dsl.js +++ b/src/plugins/console/server/lib/spec_definitions/js/query/dsl.ts @@ -18,6 +18,9 @@ */ import _ from 'lodash'; + +import { SpecDefinitionsService } from '../../../../services'; + import { spanFirstTemplate, spanNearTemplate, @@ -32,6 +35,8 @@ import { rangeTemplate, regexpTemplate, } from './templates'; + +/* eslint-disable @typescript-eslint/camelcase */ const matchOptions = { cutoff_frequency: 0.001, query: '', @@ -57,6 +62,7 @@ const matchOptions = { prefix_length: 1, minimum_should_match: 1, }; + const innerHits = { docvalue_fields: ['FIELD'], from: {}, @@ -84,6 +90,7 @@ const innerHits = { __one_of: ['true', 'false'], }, }; + const SPAN_QUERIES_NO_FIELD_MASK = { // TODO add one_of for objects span_first: { @@ -115,6 +122,7 @@ const SPAN_QUERIES_NO_FIELD_MASK = { __scope_link: '.span_within', }, }; + const SPAN_QUERIES = { ...SPAN_QUERIES_NO_FIELD_MASK, field_masking_span: { @@ -165,13 +173,14 @@ const DECAY_FUNC_DESC = { decay: 0.5, }, }; + const SCORING_FUNCS = { script_score: { __template: { script: "_score * doc['f'].value", }, script: { - //populated by a global rule + // populated by a global rule }, }, boost_factor: 2.0, @@ -204,8 +213,8 @@ const SCORING_FUNCS = { }, }; -export function queryDsl(api) { - api.addGlobalAutocompleteRules('query', { +export const query = (specService: SpecDefinitionsService) => { + specService.addGlobalAutocompleteRules('query', { match: { __template: { FIELD: 'TEXT', @@ -281,9 +290,11 @@ export function queryDsl(api) { __scope_link: '.', }, ], - filter: { - __scope_link: 'GLOBAL.filter', - }, + filter: [ + { + __scope_link: 'GLOBAL.filter', + }, + ], minimum_should_match: 1, boost: 1.0, }, @@ -629,7 +640,7 @@ export function queryDsl(api) { filter: {}, boost: 2.0, script: { - //populated by a global rule + // populated by a global rule }, }, ], @@ -693,7 +704,7 @@ export function queryDsl(api) { script: "_score * doc['f'].value", }, script: { - //populated by a global rule + // populated by a global rule }, }, wrapper: { @@ -703,4 +714,4 @@ export function queryDsl(api) { query: '', }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/server.js b/src/plugins/console/server/lib/spec_definitions/js/query/index.ts similarity index 89% rename from src/plugins/console/server/lib/spec_definitions/server.js rename to src/plugins/console/server/lib/spec_definitions/js/query/index.ts index cb855958d403a..f4f896fd7814c 100644 --- a/src/plugins/console/server/lib/spec_definitions/server.js +++ b/src/plugins/console/server/lib/spec_definitions/js/query/index.ts @@ -17,10 +17,4 @@ * under the License. */ -import es from './es'; - -export function resolveApi() { - return { - es: es.asJson(), - }; -} +export { query } from './dsl'; diff --git a/src/plugins/console/server/lib/spec_definitions/js/query/templates.js b/src/plugins/console/server/lib/spec_definitions/js/query/templates.ts similarity index 97% rename from src/plugins/console/server/lib/spec_definitions/js/query/templates.js rename to src/plugins/console/server/lib/spec_definitions/js/query/templates.ts index 9b6311bf5712e..60192f81fec80 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/query/templates.js +++ b/src/plugins/console/server/lib/spec_definitions/js/query/templates.ts @@ -17,23 +17,28 @@ * under the License. */ +/* eslint-disable @typescript-eslint/camelcase */ export const regexpTemplate = { FIELD: 'REGEXP', }; + export const fuzzyTemplate = { FIELD: {}, }; + export const prefixTemplate = { FIELD: { value: '', }, }; + export const rangeTemplate = { FIELD: { gte: 10, lte: 20, }, }; + export const spanFirstTemplate = { match: { span_term: { @@ -42,6 +47,7 @@ export const spanFirstTemplate = { }, end: 3, }; + export const spanNearTemplate = { clauses: [ { @@ -55,11 +61,13 @@ export const spanNearTemplate = { slop: 12, in_order: false, }; + export const spanTermTemplate = { FIELD: { value: 'VALUE', }, }; + export const spanNotTemplate = { include: { span_term: { @@ -76,6 +84,7 @@ export const spanNotTemplate = { }, }, }; + export const spanOrTemplate = { clauses: [ { @@ -87,6 +96,7 @@ export const spanOrTemplate = { }, ], }; + export const spanContainingTemplate = { little: { span_term: { @@ -118,6 +128,7 @@ export const spanContainingTemplate = { }, }, }; + export const spanWithinTemplate = { little: { span_term: { @@ -149,6 +160,7 @@ export const spanWithinTemplate = { }, }, }; + export const wildcardTemplate = { FIELD: { value: 'VALUE', diff --git a/src/plugins/console/server/lib/spec_definitions/js/reindex.js b/src/plugins/console/server/lib/spec_definitions/js/reindex.ts similarity index 88% rename from src/plugins/console/server/lib/spec_definitions/js/reindex.js rename to src/plugins/console/server/lib/spec_definitions/js/reindex.ts index 45163d2b3c4c3..862a4323f7bf3 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/reindex.js +++ b/src/plugins/console/server/lib/spec_definitions/js/reindex.ts @@ -17,8 +17,11 @@ * under the License. */ -export default function(api) { - api.addEndpointDescription('reindex', { +import { SpecDefinitionsService } from '../../../services'; + +/* eslint-disable @typescript-eslint/camelcase */ +export const reindex = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('reindex', { methods: ['POST'], patterns: ['_reindex'], data_autocomplete_rules: { @@ -62,4 +65,4 @@ export default function(api) { script: { __scope_link: 'GLOBAL.script' }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/search.js b/src/plugins/console/server/lib/spec_definitions/js/search.ts similarity index 92% rename from src/plugins/console/server/lib/spec_definitions/js/search.js rename to src/plugins/console/server/lib/spec_definitions/js/search.ts index 19ce30d9929a5..e319870d7be5c 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/search.js +++ b/src/plugins/console/server/lib/spec_definitions/js/search.ts @@ -16,9 +16,11 @@ * specific language governing permissions and limitations * under the License. */ +import { SpecDefinitionsService } from '../../../services'; -export default function(api) { - api.addEndpointDescription('search', { +/* eslint-disable @typescript-eslint/camelcase */ +export const search = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('search', { priority: 10, // collides with get doc by id data_autocomplete_rules: { query: { @@ -191,7 +193,7 @@ export default function(api) { }, }); - api.addEndpointDescription('search_template', { + specService.addEndpointDescription('search_template', { data_autocomplete_rules: { template: { __one_of: [{ __scope_link: 'search' }, { __scope_link: 'GLOBAL.script' }], @@ -200,18 +202,18 @@ export default function(api) { }, }); - api.addEndpointDescription('render_search_template', { + specService.addEndpointDescription('render_search_template', { data_autocomplete_rules: { __one_of: [{ source: { __scope_link: 'search' } }, { __scope_link: 'GLOBAL.script' }], params: {}, }, }); - api.addEndpointDescription('_search/template/{id}', { + specService.addEndpointDescription('_search/template/{id}', { data_autocomplete_rules: { template: { __scope_link: 'search', }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/settings.js b/src/plugins/console/server/lib/spec_definitions/js/settings.ts similarity index 90% rename from src/plugins/console/server/lib/spec_definitions/js/settings.js rename to src/plugins/console/server/lib/spec_definitions/js/settings.ts index 26cd0987c34a5..88c58e618533b 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/settings.js +++ b/src/plugins/console/server/lib/spec_definitions/js/settings.ts @@ -16,11 +16,12 @@ * specific language governing permissions and limitations * under the License. */ - +import { SpecDefinitionsService } from '../../../services'; import { BOOLEAN } from './shared'; -export default function(api) { - api.addEndpointDescription('put_settings', { +/* eslint-disable @typescript-eslint/camelcase */ +export const settings = (specService: SpecDefinitionsService) => { + specService.addEndpointDescription('put_settings', { data_autocomplete_rules: { refresh_interval: '1s', number_of_shards: 1, @@ -71,4 +72,4 @@ export default function(api) { }, }, }); -} +}; diff --git a/src/plugins/console/server/lib/spec_definitions/js/shared.js b/src/plugins/console/server/lib/spec_definitions/js/shared.ts similarity index 94% rename from src/plugins/console/server/lib/spec_definitions/js/shared.js rename to src/plugins/console/server/lib/spec_definitions/js/shared.ts index ace189e2d0913..a884e1aebe2e7 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/shared.js +++ b/src/plugins/console/server/lib/spec_definitions/js/shared.ts @@ -17,6 +17,7 @@ * under the License. */ +/* eslint-disable @typescript-eslint/camelcase */ export const BOOLEAN = Object.freeze({ __one_of: [true, false], }); diff --git a/src/plugins/console/server/lib/spec_definitions/json/index.js b/src/plugins/console/server/lib/spec_definitions/json/index.js deleted file mode 100644 index 19f075e897dbb..0000000000000 --- a/src/plugins/console/server/lib/spec_definitions/json/index.js +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import glob from 'glob'; -import { join, basename } from 'path'; -import { readFileSync } from 'fs'; -import { merge } from 'lodash'; - -const extensionSpecFilePaths = []; -function _getSpec(dirname = __dirname) { - const generatedFiles = glob.sync(join(dirname, 'generated', '*.json')); - const overrideFiles = glob.sync(join(dirname, 'overrides', '*.json')); - - return generatedFiles.reduce((acc, file) => { - const overrideFile = overrideFiles.find(f => basename(f) === basename(file)); - const loadedSpec = JSON.parse(readFileSync(file, 'utf8')); - if (overrideFile) { - merge(loadedSpec, JSON.parse(readFileSync(overrideFile, 'utf8'))); - } - const spec = {}; - Object.entries(loadedSpec).forEach(([key, value]) => { - if (acc[key]) { - // add time to remove key collision - spec[`${key}${Date.now()}`] = value; - } else { - spec[key] = value; - } - }); - - return { ...acc, ...spec }; - }, {}); -} -export function getSpec() { - const result = _getSpec(); - extensionSpecFilePaths.forEach(extensionSpecFilePath => { - merge(result, _getSpec(extensionSpecFilePath)); - }); - return result; -} - -export function addExtensionSpecFilePath(extensionSpecFilePath) { - extensionSpecFilePaths.push(extensionSpecFilePath); -} diff --git a/src/plugins/console/server/plugin.ts b/src/plugins/console/server/plugin.ts index 1954918f4d74f..85b728ea83891 100644 --- a/src/plugins/console/server/plugin.ts +++ b/src/plugins/console/server/plugin.ts @@ -21,20 +21,18 @@ import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/serv import { readLegacyEsConfig } from '../../../legacy/core_plugins/console_legacy'; -import { - ProxyConfigCollection, - addExtensionSpecFilePath, - addProcessorDefinition, - loadSpec, -} from './lib'; +import { ProxyConfigCollection } from './lib'; +import { SpecDefinitionsService } from './services'; import { ConfigType } from './config'; import { registerProxyRoute } from './routes/api/console/proxy'; import { registerSpecDefinitionsRoute } from './routes/api/console/spec_definitions'; -import { ESConfigForProxy, ConsoleSetup } from './types'; +import { ESConfigForProxy, ConsoleSetup, ConsoleStart } from './types'; -export class ConsoleServerPlugin implements Plugin { +export class ConsoleServerPlugin implements Plugin { log: Logger; + specDefinitionsService = new SpecDefinitionsService(); + constructor(private readonly ctx: PluginInitializerContext) { this.log = this.ctx.logger.get(); } @@ -72,15 +70,19 @@ export class ConsoleServerPlugin implements Plugin { router, }); - registerSpecDefinitionsRoute({ router }); + registerSpecDefinitionsRoute({ + router, + services: { specDefinitions: this.specDefinitionsService }, + }); return { - addExtensionSpecFilePath, - addProcessorDefinition, + ...this.specDefinitionsService.setup(), }; } start() { - loadSpec(); + return { + ...this.specDefinitionsService.start(), + }; } } diff --git a/src/plugins/console/server/routes/api/console/spec_definitions/index.ts b/src/plugins/console/server/routes/api/console/spec_definitions/index.ts index 88bc250bbfce6..5c7e679cd0d35 100644 --- a/src/plugins/console/server/routes/api/console/spec_definitions/index.ts +++ b/src/plugins/console/server/routes/api/console/spec_definitions/index.ts @@ -17,12 +17,30 @@ * under the License. */ import { IRouter, RequestHandler } from 'kibana/server'; -import { resolveApi } from '../../../../lib/spec_definitions'; +import { SpecDefinitionsService } from '../../../../services'; -export const registerSpecDefinitionsRoute = ({ router }: { router: IRouter }) => { +interface SpecDefinitionsRouteResponse { + es: { + name: string; + globals: Record; + endpoints: Record; + }; +} + +export const registerSpecDefinitionsRoute = ({ + router, + services, +}: { + router: IRouter; + services: { specDefinitions: SpecDefinitionsService }; +}) => { const handler: RequestHandler = async (ctx, request, response) => { + const specResponse: SpecDefinitionsRouteResponse = { + es: services.specDefinitions.asJson(), + }; + return response.ok({ - body: resolveApi(), + body: specResponse, headers: { 'Content-Type': 'application/json', }, diff --git a/src/plugins/console/server/services/index.ts b/src/plugins/console/server/services/index.ts new file mode 100644 index 0000000000000..c8dfeccd23070 --- /dev/null +++ b/src/plugins/console/server/services/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { SpecDefinitionsService } from './spec_definitions_service'; diff --git a/src/plugins/console/server/services/spec_definitions_service.ts b/src/plugins/console/server/services/spec_definitions_service.ts new file mode 100644 index 0000000000000..39a8d5094bd5c --- /dev/null +++ b/src/plugins/console/server/services/spec_definitions_service.ts @@ -0,0 +1,150 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import _, { merge } from 'lodash'; +import glob from 'glob'; +import { basename, join, resolve } from 'path'; +import { readFileSync } from 'fs'; + +import { jsSpecLoaders } from '../lib'; + +const PATH_TO_OSS_JSON_SPEC = resolve(__dirname, '../lib/spec_definitions/json'); + +export class SpecDefinitionsService { + private readonly name = 'es'; + + private readonly globalRules: Record = {}; + private readonly endpoints: Record = {}; + private readonly extensionSpecFilePaths: string[] = []; + + private hasLoadedSpec = false; + + public addGlobalAutocompleteRules(parentNode: string, rules: any) { + this.globalRules[parentNode] = rules; + } + + public addEndpointDescription(endpoint: string, description: any = {}) { + let copiedDescription: any = {}; + if (this.endpoints[endpoint]) { + copiedDescription = { ...this.endpoints[endpoint] }; + } + let urlParamsDef: any; + _.each(description.patterns || [], function(p) { + if (p.indexOf('{indices}') >= 0) { + urlParamsDef = urlParamsDef || {}; + urlParamsDef.ignore_unavailable = '__flag__'; + urlParamsDef.allow_no_indices = '__flag__'; + urlParamsDef.expand_wildcards = ['open', 'closed']; + } + }); + + if (urlParamsDef) { + description.url_params = _.extend(description.url_params || {}, copiedDescription.url_params); + _.defaults(description.url_params, urlParamsDef); + } + + _.extend(copiedDescription, description); + _.defaults(copiedDescription, { + id: endpoint, + patterns: [endpoint], + methods: ['GET'], + }); + + this.endpoints[endpoint] = copiedDescription; + } + + public asJson() { + return { + name: this.name, + globals: this.globalRules, + endpoints: this.endpoints, + }; + } + + public addExtensionSpecFilePath(path: string) { + this.extensionSpecFilePaths.push(path); + } + + public addProcessorDefinition(processor: any) { + if (!this.hasLoadedSpec) { + throw new Error( + 'Cannot add a processor definition because spec definitions have not loaded!' + ); + } + this.endpoints._processor!.data_autocomplete_rules.__one_of.push(processor); + } + + public setup() { + return { + addExtensionSpecFilePath: this.addExtensionSpecFilePath.bind(this), + }; + } + + public start() { + if (!this.hasLoadedSpec) { + this.loadJsonSpec(); + this.loadJSSpec(); + this.hasLoadedSpec = true; + return { + addProcessorDefinition: this.addProcessorDefinition.bind(this), + }; + } else { + throw new Error('Service has already started!'); + } + } + + private loadJSONSpecInDir(dirname: string) { + const generatedFiles = glob.sync(join(dirname, 'generated', '*.json')); + const overrideFiles = glob.sync(join(dirname, 'overrides', '*.json')); + + return generatedFiles.reduce((acc, file) => { + const overrideFile = overrideFiles.find(f => basename(f) === basename(file)); + const loadedSpec = JSON.parse(readFileSync(file, 'utf8')); + if (overrideFile) { + merge(loadedSpec, JSON.parse(readFileSync(overrideFile, 'utf8'))); + } + const spec: any = {}; + Object.entries(loadedSpec).forEach(([key, value]) => { + if (acc[key]) { + // add time to remove key collision + spec[`${key}${Date.now()}`] = value; + } else { + spec[key] = value; + } + }); + + return { ...acc, ...spec }; + }, {} as any); + } + + private loadJsonSpec() { + const result = this.loadJSONSpecInDir(PATH_TO_OSS_JSON_SPEC); + this.extensionSpecFilePaths.forEach(extensionSpecFilePath => { + merge(result, this.loadJSONSpecInDir(extensionSpecFilePath)); + }); + + Object.keys(result).forEach(endpoint => { + this.addEndpointDescription(endpoint, result[endpoint]); + }); + } + + private loadJSSpec() { + jsSpecLoaders.forEach(addJsSpec => addJsSpec(this)); + } +} diff --git a/src/plugins/console/server/types.ts b/src/plugins/console/server/types.ts index adafcd4d30526..4f026555ada7b 100644 --- a/src/plugins/console/server/types.ts +++ b/src/plugins/console/server/types.ts @@ -25,6 +25,11 @@ export type ConsoleSetup = ReturnType extends Prom ? U : ReturnType; +/** @public */ +export type ConsoleStart = ReturnType extends Promise + ? U + : ReturnType; + /** @internal */ export interface ESConfigForProxy { hosts: string[]; diff --git a/src/plugins/dashboard/public/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/embeddable/dashboard_container.tsx index 86a6e374d3e25..d29ce2e4f38f5 100644 --- a/src/plugins/dashboard/public/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/embeddable/dashboard_container.tsx @@ -57,6 +57,7 @@ export interface DashboardContainerInput extends ContainerInput { panels: { [panelId: string]: DashboardPanelState; }; + isEmptyState?: boolean; } interface IndexSignature { diff --git a/src/plugins/data/README.md b/src/plugins/data/README.md index 53618ec049e7c..da0b71122fd9e 100644 --- a/src/plugins/data/README.md +++ b/src/plugins/data/README.md @@ -6,4 +6,4 @@ - `filter` - `index_patterns` - `query` -- `search` +- `search`: Elasticsearch API service and strategies \ No newline at end of file diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts index cf8c0bfe3d434..e4a663a1599f1 100644 --- a/src/plugins/data/common/index.ts +++ b/src/plugins/data/common/index.ts @@ -26,3 +26,4 @@ export * from './query'; export * from './search'; export * from './search/aggs'; export * from './types'; +export * from './utils'; diff --git a/src/plugins/data/public/query/filter_manager/lib/compare_filters.test.ts b/src/plugins/data/common/query/filter_manager/compare_filters.test.ts similarity index 99% rename from src/plugins/data/public/query/filter_manager/lib/compare_filters.test.ts rename to src/plugins/data/common/query/filter_manager/compare_filters.test.ts index da8f5b3564948..b0bb2f754d6cf 100644 --- a/src/plugins/data/public/query/filter_manager/lib/compare_filters.test.ts +++ b/src/plugins/data/common/query/filter_manager/compare_filters.test.ts @@ -18,7 +18,7 @@ */ import { compareFilters, COMPARE_ALL_OPTIONS } from './compare_filters'; -import { buildEmptyFilter, buildQueryFilter, FilterStateStore } from '../../../../common'; +import { buildEmptyFilter, buildQueryFilter, FilterStateStore } from '../../es_query'; describe('filter manager utilities', () => { describe('compare filters', () => { diff --git a/src/plugins/data/public/query/filter_manager/lib/compare_filters.ts b/src/plugins/data/common/query/filter_manager/compare_filters.ts similarity index 98% rename from src/plugins/data/public/query/filter_manager/lib/compare_filters.ts rename to src/plugins/data/common/query/filter_manager/compare_filters.ts index a2105fdc1d3ef..e047d5e0665d5 100644 --- a/src/plugins/data/public/query/filter_manager/lib/compare_filters.ts +++ b/src/plugins/data/common/query/filter_manager/compare_filters.ts @@ -18,7 +18,7 @@ */ import { defaults, isEqual, omit, map } from 'lodash'; -import { FilterMeta, Filter } from '../../../../common'; +import { FilterMeta, Filter } from '../../es_query'; export interface FilterCompareOptions { disabled?: boolean; diff --git a/src/plugins/data/public/query/filter_manager/lib/dedup_filters.test.ts b/src/plugins/data/common/query/filter_manager/dedup_filters.test.ts similarity index 95% rename from src/plugins/data/public/query/filter_manager/lib/dedup_filters.test.ts rename to src/plugins/data/common/query/filter_manager/dedup_filters.test.ts index ecc0ec94e07c8..228489de37daa 100644 --- a/src/plugins/data/public/query/filter_manager/lib/dedup_filters.test.ts +++ b/src/plugins/data/common/query/filter_manager/dedup_filters.test.ts @@ -18,14 +18,8 @@ */ import { dedupFilters } from './dedup_filters'; -import { - Filter, - IIndexPattern, - IFieldType, - buildRangeFilter, - buildQueryFilter, - FilterStateStore, -} from '../../../../common'; +import { Filter, buildRangeFilter, buildQueryFilter, FilterStateStore } from '../../es_query'; +import { IIndexPattern, IFieldType } from '../../index_patterns'; describe('filter manager utilities', () => { let indexPattern: IIndexPattern; diff --git a/src/plugins/data/public/query/filter_manager/lib/dedup_filters.ts b/src/plugins/data/common/query/filter_manager/dedup_filters.ts similarity index 97% rename from src/plugins/data/public/query/filter_manager/lib/dedup_filters.ts rename to src/plugins/data/common/query/filter_manager/dedup_filters.ts index d5d0e70504b41..7d1b00ac10c0d 100644 --- a/src/plugins/data/public/query/filter_manager/lib/dedup_filters.ts +++ b/src/plugins/data/common/query/filter_manager/dedup_filters.ts @@ -19,7 +19,7 @@ import { filter, find } from 'lodash'; import { compareFilters, FilterCompareOptions } from './compare_filters'; -import { Filter } from '../../../../common'; +import { Filter } from '../../es_query'; /** * Combine 2 filter collections, removing duplicates diff --git a/src/plugins/data/common/query/filter_manager/index.ts b/src/plugins/data/common/query/filter_manager/index.ts new file mode 100644 index 0000000000000..315c124f083a8 --- /dev/null +++ b/src/plugins/data/common/query/filter_manager/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { dedupFilters } from './dedup_filters'; +export { uniqFilters } from './uniq_filters'; +export { compareFilters, COMPARE_ALL_OPTIONS, FilterCompareOptions } from './compare_filters'; diff --git a/src/plugins/data/public/query/filter_manager/lib/uniq_filters.test.ts b/src/plugins/data/common/query/filter_manager/uniq_filters.test.ts similarity index 99% rename from src/plugins/data/public/query/filter_manager/lib/uniq_filters.test.ts rename to src/plugins/data/common/query/filter_manager/uniq_filters.test.ts index 8b525a3d2a2e4..5a35e85c95eaa 100644 --- a/src/plugins/data/public/query/filter_manager/lib/uniq_filters.test.ts +++ b/src/plugins/data/common/query/filter_manager/uniq_filters.test.ts @@ -18,7 +18,7 @@ */ import { uniqFilters } from './uniq_filters'; -import { buildQueryFilter, Filter, FilterStateStore } from '../../../../common'; +import { buildQueryFilter, Filter, FilterStateStore } from '../../es_query'; describe('filter manager utilities', () => { describe('niqFilter', () => { diff --git a/src/plugins/data/public/query/filter_manager/lib/uniq_filters.ts b/src/plugins/data/common/query/filter_manager/uniq_filters.ts similarity index 96% rename from src/plugins/data/public/query/filter_manager/lib/uniq_filters.ts rename to src/plugins/data/common/query/filter_manager/uniq_filters.ts index 44c102d7ab15d..683cbf7c78a89 100644 --- a/src/plugins/data/public/query/filter_manager/lib/uniq_filters.ts +++ b/src/plugins/data/common/query/filter_manager/uniq_filters.ts @@ -17,8 +17,8 @@ * under the License. */ import { each, union } from 'lodash'; +import { Filter } from '../../es_query'; import { dedupFilters } from './dedup_filters'; -import { Filter } from '../../../../common'; /** * Remove duplicate filters from an array of filters diff --git a/src/plugins/data/common/query/index.ts b/src/plugins/data/common/query/index.ts index d8f7b5091eb8f..421cc4f63e4ef 100644 --- a/src/plugins/data/common/query/index.ts +++ b/src/plugins/data/common/query/index.ts @@ -17,4 +17,5 @@ * under the License. */ +export * from './filter_manager'; export * from './types'; diff --git a/src/plugins/data/common/utils/abort_utils.test.ts b/src/plugins/data/common/utils/abort_utils.test.ts new file mode 100644 index 0000000000000..d2a25f2c2dd52 --- /dev/null +++ b/src/plugins/data/common/utils/abort_utils.test.ts @@ -0,0 +1,114 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { AbortError, toPromise, getCombinedSignal } from './abort_utils'; + +jest.useFakeTimers(); + +const flushPromises = () => new Promise(resolve => setImmediate(resolve)); + +describe('AbortUtils', () => { + describe('AbortError', () => { + test('should preserve `message`', () => { + const message = 'my error message'; + const error = new AbortError(message); + expect(error.message).toBe(message); + }); + + test('should have a name of "AbortError"', () => { + const error = new AbortError(); + expect(error.name).toBe('AbortError'); + }); + }); + + describe('toPromise', () => { + describe('resolves', () => { + test('should not resolve if the signal does not abort', async () => { + const controller = new AbortController(); + const promise = toPromise(controller.signal); + const whenResolved = jest.fn(); + promise.then(whenResolved); + await flushPromises(); + expect(whenResolved).not.toBeCalled(); + }); + + test('should resolve if the signal does abort', async () => { + const controller = new AbortController(); + const promise = toPromise(controller.signal); + const whenResolved = jest.fn(); + promise.then(whenResolved); + controller.abort(); + await flushPromises(); + expect(whenResolved).toBeCalled(); + }); + }); + + describe('rejects', () => { + test('should not reject if the signal does not abort', async () => { + const controller = new AbortController(); + const promise = toPromise(controller.signal, true); + const whenRejected = jest.fn(); + promise.catch(whenRejected); + await flushPromises(); + expect(whenRejected).not.toBeCalled(); + }); + + test('should reject if the signal does abort', async () => { + const controller = new AbortController(); + const promise = toPromise(controller.signal, true); + const whenRejected = jest.fn(); + promise.catch(whenRejected); + controller.abort(); + await flushPromises(); + expect(whenRejected).toBeCalled(); + }); + }); + }); + + describe('getCombinedSignal', () => { + test('should return an AbortSignal', () => { + const signal = getCombinedSignal([]); + expect(signal instanceof AbortSignal).toBe(true); + }); + + test('should not abort if none of the signals abort', async () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + setTimeout(() => controller1.abort(), 2000); + setTimeout(() => controller2.abort(), 1000); + const signal = getCombinedSignal([controller1.signal, controller2.signal]); + expect(signal.aborted).toBe(false); + jest.advanceTimersByTime(500); + await flushPromises(); + expect(signal.aborted).toBe(false); + }); + + test('should abort when the first signal aborts', async () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + setTimeout(() => controller1.abort(), 2000); + setTimeout(() => controller2.abort(), 1000); + const signal = getCombinedSignal([controller1.signal, controller2.signal]); + expect(signal.aborted).toBe(false); + jest.advanceTimersByTime(1000); + await flushPromises(); + expect(signal.aborted).toBe(true); + }); + }); +}); diff --git a/src/plugins/data/common/utils/abort_utils.ts b/src/plugins/data/common/utils/abort_utils.ts new file mode 100644 index 0000000000000..5051515f3a826 --- /dev/null +++ b/src/plugins/data/common/utils/abort_utils.ts @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Class used to signify that something was aborted. Useful for applications to conditionally handle + * this type of error differently than other errors. + */ +export class AbortError extends Error { + constructor(message = 'Aborted') { + super(message); + this.message = message; + this.name = 'AbortError'; + } +} + +/** + * Returns a `Promise` corresponding with when the given `AbortSignal` is aborted. Useful for + * situations when you might need to `Promise.race` multiple `AbortSignal`s, or an `AbortSignal` + * with any other expected errors (or completions). + * @param signal The `AbortSignal` to generate the `Promise` from + * @param shouldReject If `false`, the promise will be resolved, otherwise it will be rejected + */ +export function toPromise(signal: AbortSignal, shouldReject = false) { + return new Promise((resolve, reject) => { + const action = shouldReject ? reject : resolve; + if (signal.aborted) action(); + signal.addEventListener('abort', action); + }); +} + +/** + * Returns an `AbortSignal` that will be aborted when the first of the given signals aborts. + * @param signals + */ +export function getCombinedSignal(signals: AbortSignal[]) { + const promises = signals.map(signal => toPromise(signal)); + const controller = new AbortController(); + Promise.race(promises).then(() => controller.abort()); + return controller.signal; +} diff --git a/src/plugins/data/common/utils/index.ts b/src/plugins/data/common/utils/index.ts index 8b8686c51b9c1..33989f3ad50a7 100644 --- a/src/plugins/data/common/utils/index.ts +++ b/src/plugins/data/common/utils/index.ts @@ -19,3 +19,4 @@ /** @internal */ export { shortenDottedString } from './shorten_dotted_string'; +export { AbortError, toPromise, getCombinedSignal } from './abort_utils'; diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 58bd9a5ab05d7..339a5fea91c5f 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -47,13 +47,13 @@ import { isQueryStringFilter, isRangeFilter, toggleFilterNegated, + compareFilters, + COMPARE_ALL_OPTIONS, } from '../common'; import { FilterLabel } from './ui/filter_bar'; import { - compareFilters, - COMPARE_ALL_OPTIONS, generateFilters, onlyDisabledFiltersChanged, changeTimeFilter, diff --git a/src/plugins/data/public/mocks.ts b/src/plugins/data/public/mocks.ts index c5cff1c5c68d9..e3fc0e97af09b 100644 --- a/src/plugins/data/public/mocks.ts +++ b/src/plugins/data/public/mocks.ts @@ -19,9 +19,7 @@ import { Plugin, DataPublicPluginSetup, DataPublicPluginStart, IndexPatternsContract } from '.'; import { fieldFormatsMock } from '../common/field_formats/mocks'; -import { searchSetupMock } from './search/mocks'; -import { AggTypeFieldFilters } from './search/aggs'; -import { searchAggsStartMock } from './search/aggs/mocks'; +import { searchSetupMock, searchStartMock } from './search/mocks'; import { queryServiceMock } from './query/mocks'; export type Setup = jest.Mocked>; @@ -35,59 +33,28 @@ const autocompleteMock: any = { const createSetupContract = (): Setup => { const querySetupMock = queryServiceMock.createSetupContract(); - const setupContract = { + return { autocomplete: autocompleteMock, search: searchSetupMock, fieldFormats: fieldFormatsMock as DataPublicPluginSetup['fieldFormats'], query: querySetupMock, - __LEGACY: { - esClient: { - search: jest.fn(), - msearch: jest.fn(), - }, - }, }; - - return setupContract; }; const createStartContract = (): Start => { const queryStartMock = queryServiceMock.createStartContract(); - const startContract = { + return { actions: { createFiltersFromEvent: jest.fn().mockResolvedValue(['yes']), }, autocomplete: autocompleteMock, - getSuggestions: jest.fn(), - search: { - aggs: searchAggsStartMock(), - search: jest.fn(), - __LEGACY: { - AggConfig: jest.fn() as any, - AggType: jest.fn(), - aggTypeFieldFilters: new AggTypeFieldFilters(), - FieldParamType: jest.fn(), - MetricAggType: jest.fn(), - parentPipelineAggHelper: jest.fn() as any, - siblingPipelineAggHelper: jest.fn() as any, - esClient: { - search: jest.fn(), - msearch: jest.fn(), - }, - }, - }, + search: searchStartMock, fieldFormats: fieldFormatsMock as DataPublicPluginStart['fieldFormats'], query: queryStartMock, ui: { IndexPatternSelect: jest.fn(), SearchBar: jest.fn(), }, - __LEGACY: { - esClient: { - search: jest.fn(), - msearch: jest.fn(), - }, - }, indexPatterns: ({ make: () => ({ fieldsFetcher: { @@ -97,7 +64,6 @@ const createStartContract = (): Start => { get: jest.fn().mockReturnValue(Promise.resolve({})), } as unknown) as IndexPatternsContract, }; - return startContract; }; export { searchSourceMock } from './search/mocks'; diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index a01c133712206..fc5dde94fa851 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -39,6 +39,7 @@ import { createIndexPatternSelect } from './ui/index_pattern_select'; import { IndexPatternsService } from './index_patterns'; import { setFieldFormats, + setHttp, setIndexPatterns, setInjectedMetadata, setNotifications, @@ -128,6 +129,7 @@ export class DataPublicPlugin implements Plugin string; getPhraseFilterValue: (filter: import("../common").PhraseFilter) => string | number | boolean; getDisplayValueFromFilter: typeof getDisplayValueFromFilter; - compareFilters: (first: import("../common").Filter | import("../common").Filter[], second: import("../common").Filter | import("../common").Filter[], comparatorOptions?: import("./query/filter_manager/lib/compare_filters").FilterCompareOptions) => boolean; - COMPARE_ALL_OPTIONS: import("./query/filter_manager/lib/compare_filters").FilterCompareOptions; + compareFilters: (first: import("../common").Filter | import("../common").Filter[], second: import("../common").Filter | import("../common").Filter[], comparatorOptions?: import("../common").FilterCompareOptions) => boolean; + COMPARE_ALL_OPTIONS: import("../common").FilterCompareOptions; generateFilters: typeof generateFilters; onlyDisabledFiltersChanged: (newFilters?: import("../common").Filter[] | undefined, oldFilters?: import("../common").Filter[] | undefined) => boolean; changeTimeFilter: typeof changeTimeFilter; @@ -1843,8 +1843,8 @@ export type TSearchStrategyProvider = (context: ISearc // src/plugins/data/public/index.ts:405:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:406:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/query/state_sync/connect_to_query_state.ts:34:33 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/query/state_sync/connect_to_query_state.ts:38:1 - (ae-forgotten-export) The symbol "QueryStateChange" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/query/state_sync/connect_to_query_state.ts:33:33 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/query/state_sync/connect_to_query_state.ts:37:1 - (ae-forgotten-export) The symbol "QueryStateChange" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:52:5 - (ae-forgotten-export) The symbol "createFiltersFromEvent" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:60:5 - (ae-forgotten-export) The symbol "IndexPatternSelectProps" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/query/filter_manager/filter_manager.ts b/src/plugins/data/public/query/filter_manager/filter_manager.ts index c951953b26555..fba1866ebd615 100644 --- a/src/plugins/data/public/query/filter_manager/filter_manager.ts +++ b/src/plugins/data/public/query/filter_manager/filter_manager.ts @@ -22,13 +22,19 @@ import { Subject } from 'rxjs'; import { IUiSettingsClient } from 'src/core/public'; -import { COMPARE_ALL_OPTIONS, compareFilters } from './lib/compare_filters'; import { sortFilters } from './lib/sort_filters'; import { mapAndFlattenFilters } from './lib/map_and_flatten_filters'; -import { uniqFilters } from './lib/uniq_filters'; import { onlyDisabledFiltersChanged } from './lib/only_disabled'; import { PartitionedFilters } from './types'; -import { FilterStateStore, Filter, isFilterPinned } from '../../../common'; + +import { + FilterStateStore, + Filter, + uniqFilters, + isFilterPinned, + compareFilters, + COMPARE_ALL_OPTIONS, +} from '../../../common'; export class FilterManager { private filters: Filter[] = []; diff --git a/src/plugins/data/public/query/filter_manager/index.ts b/src/plugins/data/public/query/filter_manager/index.ts index 09990adacde45..be512c503d531 100644 --- a/src/plugins/data/public/query/filter_manager/index.ts +++ b/src/plugins/data/public/query/filter_manager/index.ts @@ -19,8 +19,6 @@ export { FilterManager } from './filter_manager'; -export { uniqFilters } from './lib/uniq_filters'; export { mapAndFlattenFilters } from './lib/map_and_flatten_filters'; export { onlyDisabledFiltersChanged } from './lib/only_disabled'; export { generateFilters } from './lib/generate_filters'; -export { compareFilters, COMPARE_ALL_OPTIONS } from './lib/compare_filters'; diff --git a/src/plugins/data/public/query/filter_manager/lib/only_disabled.ts b/src/plugins/data/public/query/filter_manager/lib/only_disabled.ts index 34e1ac38ae95f..18c51ebeabe54 100644 --- a/src/plugins/data/public/query/filter_manager/lib/only_disabled.ts +++ b/src/plugins/data/public/query/filter_manager/lib/only_disabled.ts @@ -18,8 +18,7 @@ */ import { filter } from 'lodash'; -import { Filter } from '../../../../common'; -import { compareFilters, COMPARE_ALL_OPTIONS } from './compare_filters'; +import { Filter, compareFilters, COMPARE_ALL_OPTIONS } from '../../../../common'; const isEnabled = (f: Filter) => f && f.meta && !f.meta.disabled; diff --git a/src/plugins/data/public/query/state_sync/connect_to_query_state.ts b/src/plugins/data/public/query/state_sync/connect_to_query_state.ts index a22e66860c765..331d8969f2483 100644 --- a/src/plugins/data/public/query/state_sync/connect_to_query_state.ts +++ b/src/plugins/data/public/query/state_sync/connect_to_query_state.ts @@ -21,10 +21,9 @@ import { Subscription } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import _ from 'lodash'; import { BaseStateContainer } from '../../../../kibana_utils/public'; -import { COMPARE_ALL_OPTIONS, compareFilters } from '../filter_manager/lib/compare_filters'; import { QuerySetup, QueryStart } from '../query_service'; import { QueryState, QueryStateChange } from './types'; -import { FilterStateStore } from '../../../common/es_query/filters'; +import { FilterStateStore, COMPARE_ALL_OPTIONS, compareFilters } from '../../../common'; /** * Helper to setup two-way syncing of global data and a state container diff --git a/src/plugins/data/public/query/state_sync/create_global_query_observable.ts b/src/plugins/data/public/query/state_sync/create_global_query_observable.ts index d0d97bfaaeb36..dd075f9be7d94 100644 --- a/src/plugins/data/public/query/state_sync/create_global_query_observable.ts +++ b/src/plugins/data/public/query/state_sync/create_global_query_observable.ts @@ -20,10 +20,10 @@ import { Observable, Subscription } from 'rxjs'; import { map, tap } from 'rxjs/operators'; import { TimefilterSetup } from '../timefilter'; -import { COMPARE_ALL_OPTIONS, compareFilters, FilterManager } from '../filter_manager'; +import { FilterManager } from '../filter_manager'; import { QueryState, QueryStateChange } from './index'; import { createStateContainer } from '../../../../kibana_utils/public'; -import { isFilterPinned } from '../../../common/es_query/filters'; +import { isFilterPinned, compareFilters, COMPARE_ALL_OPTIONS } from '../../../common'; export function createQueryStateObservable({ timefilter: { timefilter }, diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index ac72cfd6f62ca..f3d2d99af5998 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -56,4 +56,6 @@ export { SortDirection, } from './search_source'; +export { SearchInterceptor } from './search_interceptor'; + export { FetchOptions } from './fetch'; diff --git a/src/plugins/data/public/search/mocks.ts b/src/plugins/data/public/search/mocks.ts index 71b4eece91cef..12cf258759a99 100644 --- a/src/plugins/data/public/search/mocks.ts +++ b/src/plugins/data/public/search/mocks.ts @@ -17,7 +17,9 @@ * under the License. */ -import { searchAggsSetupMock } from './aggs/mocks'; +import { searchAggsSetupMock, searchAggsStartMock } from './aggs/mocks'; +import { AggTypeFieldFilters } from './aggs/param_types/filter'; +import { ISearchStart } from './types'; export * from './search_source/mocks'; @@ -26,3 +28,24 @@ export const searchSetupMock = { registerSearchStrategyContext: jest.fn(), registerSearchStrategyProvider: jest.fn(), }; + +export const searchStartMock: jest.Mocked = { + aggs: searchAggsStartMock(), + search: jest.fn(), + cancel: jest.fn(), + getPendingCount$: jest.fn(), + runBeyondTimeout: jest.fn(), + __LEGACY: { + AggConfig: jest.fn() as any, + AggType: jest.fn(), + aggTypeFieldFilters: new AggTypeFieldFilters(), + FieldParamType: jest.fn(), + MetricAggType: jest.fn(), + parentPipelineAggHelper: jest.fn() as any, + siblingPipelineAggHelper: jest.fn() as any, + esClient: { + search: jest.fn(), + msearch: jest.fn(), + }, + }, +}; diff --git a/src/plugins/data/public/search/request_timeout_error.ts b/src/plugins/data/public/search/request_timeout_error.ts new file mode 100644 index 0000000000000..92894deb4f0ff --- /dev/null +++ b/src/plugins/data/public/search/request_timeout_error.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Class used to signify that a request timed out. Useful for applications to conditionally handle + * this type of error differently than other errors. + */ +export class RequestTimeoutError extends Error { + constructor(message = 'Request timed out') { + super(message); + this.message = message; + this.name = 'RequestTimeoutError'; + } +} diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts new file mode 100644 index 0000000000000..a89d17464b9e0 --- /dev/null +++ b/src/plugins/data/public/search/search_interceptor.test.ts @@ -0,0 +1,157 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable, Subject } from 'rxjs'; +import { IKibanaSearchRequest } from '../../common/search'; +import { RequestTimeoutError } from './request_timeout_error'; +import { SearchInterceptor } from './search_interceptor'; + +jest.useFakeTimers(); + +const flushPromises = () => new Promise(resolve => setImmediate(resolve)); +const mockSearch = jest.fn(); +let searchInterceptor: SearchInterceptor; + +describe('SearchInterceptor', () => { + beforeEach(() => { + mockSearch.mockClear(); + searchInterceptor = new SearchInterceptor(1000); + }); + + describe('search', () => { + test('should invoke `search` with the request', () => { + mockSearch.mockReturnValue(new Observable()); + const mockRequest: IKibanaSearchRequest = {}; + searchInterceptor.search(mockSearch, mockRequest); + expect(mockSearch.mock.calls[0][0]).toBe(mockRequest); + }); + + test('should mirror the observable to completion if the request does not time out', () => { + const mockResponse = new Subject(); + mockSearch.mockReturnValue(mockResponse.asObservable()); + const response = searchInterceptor.search(mockSearch, {}); + + setTimeout(() => mockResponse.next('hi'), 250); + setTimeout(() => mockResponse.complete(), 500); + + const next = jest.fn(); + const complete = jest.fn(); + response.subscribe({ next, complete }); + + jest.advanceTimersByTime(1000); + + expect(next).toHaveBeenCalledWith('hi'); + expect(complete).toHaveBeenCalled(); + }); + + test('should mirror the observable to error if the request does not time out', () => { + const mockResponse = new Subject(); + mockSearch.mockReturnValue(mockResponse.asObservable()); + const response = searchInterceptor.search(mockSearch, {}); + + setTimeout(() => mockResponse.next('hi'), 250); + setTimeout(() => mockResponse.error('error'), 500); + + const next = jest.fn(); + const error = jest.fn(); + response.subscribe({ next, error }); + + jest.advanceTimersByTime(1000); + + expect(next).toHaveBeenCalledWith('hi'); + expect(error).toHaveBeenCalledWith('error'); + }); + + test('should return a `RequestTimeoutError` if the request times out', () => { + mockSearch.mockReturnValue(new Observable()); + const response = searchInterceptor.search(mockSearch, {}); + + const error = jest.fn(); + response.subscribe({ error }); + + jest.advanceTimersByTime(1000); + + expect(error).toHaveBeenCalled(); + expect(error.mock.calls[0][0] instanceof RequestTimeoutError).toBe(true); + }); + }); + + describe('cancelPending', () => { + test('should abort all pending requests', async () => { + mockSearch.mockReturnValue(new Observable()); + + searchInterceptor.search(mockSearch, {}); + searchInterceptor.search(mockSearch, {}); + searchInterceptor.cancelPending(); + + await flushPromises(); + + const areAllRequestsAborted = mockSearch.mock.calls.every(([, { signal }]) => signal.aborted); + expect(areAllRequestsAborted).toBe(true); + }); + }); + + describe('runBeyondTimeout', () => { + test('should prevent the request from timing out', () => { + const mockResponse = new Subject(); + mockSearch.mockReturnValue(mockResponse.asObservable()); + const response = searchInterceptor.search(mockSearch, {}); + + setTimeout(searchInterceptor.runBeyondTimeout, 500); + setTimeout(() => mockResponse.next('hi'), 250); + setTimeout(() => mockResponse.complete(), 2000); + + const next = jest.fn(); + const complete = jest.fn(); + const error = jest.fn(); + response.subscribe({ next, error, complete }); + + jest.advanceTimersByTime(2000); + + expect(next).toHaveBeenCalledWith('hi'); + expect(error).not.toHaveBeenCalled(); + expect(complete).toHaveBeenCalled(); + }); + }); + + describe('getPendingCount$', () => { + test('should observe the number of pending requests', () => { + let i = 0; + const mockResponses = [new Subject(), new Subject()]; + mockSearch.mockImplementation(() => mockResponses[i++]); + + const pendingCount$ = searchInterceptor.getPendingCount$(); + + const next = jest.fn(); + pendingCount$.subscribe(next); + + const error = jest.fn(); + searchInterceptor.search(mockSearch, {}).subscribe({ error }); + searchInterceptor.search(mockSearch, {}).subscribe({ error }); + + setTimeout(() => mockResponses[0].complete(), 250); + setTimeout(() => mockResponses[1].error('error'), 500); + + jest.advanceTimersByTime(500); + + expect(next).toHaveBeenCalled(); + expect(next.mock.calls).toEqual([[0], [1], [2], [1], [0]]); + }); + }); +}); diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts new file mode 100644 index 0000000000000..3f83214f6050c --- /dev/null +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -0,0 +1,121 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BehaviorSubject, fromEvent, throwError } from 'rxjs'; +import { mergeMap, takeUntil, finalize } from 'rxjs/operators'; +import { getCombinedSignal } from '../../common/utils'; +import { IKibanaSearchRequest } from '../../common/search'; +import { ISearchGeneric, ISearchOptions } from './i_search'; +import { RequestTimeoutError } from './request_timeout_error'; + +export class SearchInterceptor { + /** + * `abortController` used to signal all searches to abort. + */ + private abortController = new AbortController(); + + /** + * Observable that emits when the number of pending requests changes. + */ + private pendingCount$ = new BehaviorSubject(0); + + /** + * The IDs from `setTimeout` when scheduling the automatic timeout for each request. + */ + private timeoutIds: Set = new Set(); + + /** + * This class should be instantiated with a `requestTimeout` corresponding with how many ms after + * requests are initiated that they should automatically cancel. + * @param requestTimeout Usually config value `elasticsearch.requestTimeout` + */ + constructor(private readonly requestTimeout?: number) {} + + /** + * Abort our `AbortController`, which in turn aborts any intercepted searches. + */ + public cancelPending = () => { + this.abortController.abort(); + this.abortController = new AbortController(); + }; + + /** + * Un-schedule timing out all of the searches intercepted. + */ + public runBeyondTimeout = () => { + this.timeoutIds.forEach(clearTimeout); + this.timeoutIds.clear(); + }; + + /** + * Returns an `Observable` over the current number of pending searches. This could mean that one + * of the search requests is still in flight, or that it has only received partial responses. + */ + public getPendingCount$ = () => { + return this.pendingCount$.asObservable(); + }; + + /** + * Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort + * either when `cancelPending` is called, when the request times out, or when the original + * `AbortSignal` is aborted. Updates the `pendingCount` when the request is started/finalized. + */ + public search = ( + search: ISearchGeneric, + request: IKibanaSearchRequest, + options?: ISearchOptions + ) => { + // Schedule this request to automatically timeout after some interval + const timeoutController = new AbortController(); + const { signal: timeoutSignal } = timeoutController; + const timeoutId = window.setTimeout(() => { + timeoutController.abort(); + }, this.requestTimeout); + this.addTimeoutId(timeoutId); + + // Get a combined `AbortSignal` that will be aborted whenever the first of the following occurs: + // 1. The user manually aborts (via `cancelPending`) + // 2. The request times out + // 3. The passed-in signal aborts (e.g. when re-fetching, or whenever the app determines) + const signals = [this.abortController.signal, timeoutSignal, options?.signal].filter( + Boolean + ) as AbortSignal[]; + const combinedSignal = getCombinedSignal(signals); + + // If the request timed out, throw a `RequestTimeoutError` + const timeoutError$ = fromEvent(timeoutSignal, 'abort').pipe( + mergeMap(() => throwError(new RequestTimeoutError())) + ); + + return search(request as any, { ...options, signal: combinedSignal }).pipe( + takeUntil(timeoutError$), + finalize(() => this.removeTimeoutId(timeoutId)) + ); + }; + + private addTimeoutId(id: number) { + this.timeoutIds.add(id); + this.pendingCount$.next(this.timeoutIds.size); + } + + private removeTimeoutId(id: number) { + this.timeoutIds.delete(id); + this.pendingCount$.next(this.timeoutIds.size); + } +} diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index 691c8aa0e984d..62c7e0468bb88 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -25,6 +25,7 @@ import { TStrategyTypes } from './strategy_types'; import { getEsClient, LegacyApiCaller } from './es_client'; import { ES_SEARCH_STRATEGY, DEFAULT_SEARCH_STRATEGY } from '../../common/search'; import { esSearchStrategyProvider } from './es_search/es_search_strategy'; +import { SearchInterceptor } from './search_interceptor'; import { getAggTypes, AggType, @@ -91,6 +92,16 @@ export class SearchService implements Plugin { } public start(core: CoreStart): ISearchStart { + /** + * A global object that intercepts all searches and provides convenience methods for cancelling + * all pending search requests, as well as getting the number of pending search requests. + * TODO: Make this modular so that apps can opt in/out of search collection, or even provide + * their own search collector instances + */ + const searchInterceptor = new SearchInterceptor( + core.injectedMetadata.getInjectedVar('esRequestTimeout') as number + ); + const aggTypesStart = this.aggTypesRegistry.start(); return { @@ -103,13 +114,16 @@ export class SearchService implements Plugin { }, types: aggTypesStart, }, + cancel: () => searchInterceptor.cancelPending(), + getPendingCount$: () => searchInterceptor.getPendingCount$(), + runBeyondTimeout: () => searchInterceptor.runBeyondTimeout(), search: (request, options, strategyName) => { const strategyProvider = this.getSearchStrategy(strategyName || DEFAULT_SEARCH_STRATEGY); const { search } = strategyProvider({ core, getSearchStrategy: this.getSearchStrategy, }); - return search(request as any, options); + return searchInterceptor.search(search as any, request, options); }, __LEGACY: { esClient: this.esClient!, diff --git a/src/plugins/data/public/search/search_source/search_source.test.ts b/src/plugins/data/public/search/search_source/search_source.test.ts index d2b8308bfb258..fcd116a3f4121 100644 --- a/src/plugins/data/public/search/search_source/search_source.test.ts +++ b/src/plugins/data/public/search/search_source/search_source.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { SearchSource } from '../search_source'; +import { SearchSource } from './search_source'; import { IndexPattern } from '../..'; import { mockDataServices } from '../aggs/test_helpers'; diff --git a/src/plugins/data/public/search/search_strategy/default_search_strategy.test.ts b/src/plugins/data/public/search/search_strategy/default_search_strategy.test.ts index e4f492c89e0ef..210a0e5fd1ac7 100644 --- a/src/plugins/data/public/search/search_strategy/default_search_strategy.test.ts +++ b/src/plugins/data/public/search/search_strategy/default_search_strategy.test.ts @@ -18,9 +18,9 @@ */ import { IUiSettingsClient } from '../../../../../core/public'; -import { ISearchStart } from '../types'; import { SearchStrategySearchParams } from './types'; import { defaultSearchStrategy } from './default_search_strategy'; +import { searchStartMock } from '../mocks'; const { search } = defaultSearchStrategy; @@ -56,6 +56,12 @@ describe('defaultSearchStrategy', function() { searchMockResponse.abort.mockClear(); searchMock.mockClear(); + const searchService = searchStartMock; + searchService.aggs.calculateAutoTimeExpression = jest.fn().mockReturnValue('1d'); + searchService.search = newSearchMock; + searchService.__LEGACY.esClient.search = searchMock; + searchService.__LEGACY.esClient.msearch = msearchMock; + searchArgs = { searchRequests: [ { @@ -63,15 +69,7 @@ describe('defaultSearchStrategy', function() { }, ], esShardTimeout: 0, - searchService: ({ - search: newSearchMock, - __LEGACY: { - esClient: { - search: searchMock, - msearch: msearchMock, - }, - }, - } as unknown) as jest.Mocked, + searchService, }; es = searchArgs.searchService.__LEGACY.esClient; diff --git a/src/plugins/data/public/search/types.ts b/src/plugins/data/public/search/types.ts index 1732c384b1a85..1b551f978b971 100644 --- a/src/plugins/data/public/search/types.ts +++ b/src/plugins/data/public/search/types.ts @@ -17,6 +17,7 @@ * under the License. */ +import { Observable } from 'rxjs'; import { CoreStart } from 'kibana/public'; import { SearchAggsSetup, SearchAggsStart, SearchAggsStartLegacy } from './aggs'; import { ISearch, ISearchGeneric } from './i_search'; @@ -86,6 +87,9 @@ export interface ISearchSetup { export interface ISearchStart { aggs: SearchAggsStart; + cancel: () => void; + getPendingCount$: () => Observable; + runBeyondTimeout: () => void; search: ISearchGeneric; __LEGACY: ISearchStartLegacy & SearchAggsStartLegacy; } diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 5ce79537ccaf3..4ab74e1883917 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -240,7 +240,6 @@ export abstract class Container< ...this.input.panels, [panelState.explicitInput.id]: panelState, }, - isEmptyState: false, } as Partial); return await this.untilEmbeddableLoaded(panelState.explicitInput.id); diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index 7fef80edde85f..6345c34b0dda2 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -29,7 +29,6 @@ export interface EmbeddableInput { id: string; lastReloadRequestTime?: number; hidePanelTitles?: boolean; - isEmptyState?: boolean; /** * Reserved key for `ui_actions` events. diff --git a/src/plugins/es_ui_shared/public/index.ts b/src/plugins/es_ui_shared/public/index.ts index 2925e5e16458e..5935c7cc38d03 100644 --- a/src/plugins/es_ui_shared/public/index.ts +++ b/src/plugins/es_ui_shared/public/index.ts @@ -27,3 +27,7 @@ export { sendRequest, useRequest, } from './request/np_ready_request'; + +export { indices } from './indices'; + +export { useUIAceKeyboardMode } from './use_ui_ace_keyboard_mode'; diff --git a/src/legacy/ui/public/indices/constants/index.js b/src/plugins/es_ui_shared/public/indices/constants/index.ts similarity index 94% rename from src/legacy/ui/public/indices/constants/index.js rename to src/plugins/es_ui_shared/public/indices/constants/index.ts index 72ecc2e4c87de..825975fa161b5 100644 --- a/src/legacy/ui/public/indices/constants/index.js +++ b/src/plugins/es_ui_shared/public/indices/constants/index.ts @@ -17,7 +17,7 @@ * under the License. */ -import { indexPatterns } from '../../../../../plugins/data/public'; +import { indexPatterns } from '../../../../data/public'; export const INDEX_ILLEGAL_CHARACTERS_VISIBLE = [...indexPatterns.ILLEGAL_CHARACTERS_VISIBLE, '*']; diff --git a/src/plugins/es_ui_shared/public/indices/index.ts b/src/plugins/es_ui_shared/public/indices/index.ts new file mode 100644 index 0000000000000..a6d279a5c2b4f --- /dev/null +++ b/src/plugins/es_ui_shared/public/indices/index.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from './constants'; + +import { + indexNameBeginsWithPeriod, + findIllegalCharactersInIndexName, + indexNameContainsSpaces, +} from './validate'; + +export const indices = { + INDEX_ILLEGAL_CHARACTERS_VISIBLE, + indexNameBeginsWithPeriod, + findIllegalCharactersInIndexName, + indexNameContainsSpaces, +}; diff --git a/src/legacy/ui/public/indices/validate/index.js b/src/plugins/es_ui_shared/public/indices/validate/index.ts similarity index 100% rename from src/legacy/ui/public/indices/validate/index.js rename to src/plugins/es_ui_shared/public/indices/validate/index.ts diff --git a/src/legacy/ui/public/indices/validate/validate_index.test.js b/src/plugins/es_ui_shared/public/indices/validate/validate_index.test.ts similarity index 100% rename from src/legacy/ui/public/indices/validate/validate_index.test.js rename to src/plugins/es_ui_shared/public/indices/validate/validate_index.test.ts diff --git a/src/legacy/ui/public/indices/validate/validate_index.js b/src/plugins/es_ui_shared/public/indices/validate/validate_index.ts similarity index 67% rename from src/legacy/ui/public/indices/validate/validate_index.js rename to src/plugins/es_ui_shared/public/indices/validate/validate_index.ts index 5deaa83a807d9..00ac1342400ac 100644 --- a/src/legacy/ui/public/indices/validate/validate_index.js +++ b/src/plugins/es_ui_shared/public/indices/validate/validate_index.ts @@ -19,23 +19,29 @@ import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from '../constants'; -// Names beginning with periods are reserved for system indices. -export function indexNameBeginsWithPeriod(indexName = '') { +// Names beginning with periods are reserved for hidden indices. +export function indexNameBeginsWithPeriod(indexName?: string): boolean { + if (indexName === undefined) { + return false; + } return indexName[0] === '.'; } -export function findIllegalCharactersInIndexName(indexName) { - const illegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.reduce((chars, char) => { - if (indexName.includes(char)) { - chars.push(char); - } +export function findIllegalCharactersInIndexName(indexName: string): string[] { + const illegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.reduce( + (chars: string[], char: string): string[] => { + if (indexName.includes(char)) { + chars.push(char); + } - return chars; - }, []); + return chars; + }, + [] + ); return illegalCharacters; } -export function indexNameContainsSpaces(indexName) { +export function indexNameContainsSpaces(indexName: string): boolean { return indexName.includes(' '); } diff --git a/src/plugins/console/public/application/containers/editor/legacy/use_ui_ace_keyboard_mode.tsx b/src/plugins/es_ui_shared/public/use_ui_ace_keyboard_mode.tsx similarity index 99% rename from src/plugins/console/public/application/containers/editor/legacy/use_ui_ace_keyboard_mode.tsx rename to src/plugins/es_ui_shared/public/use_ui_ace_keyboard_mode.tsx index ca74b19b76f16..a93906d50b64a 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/use_ui_ace_keyboard_mode.tsx +++ b/src/plugins/es_ui_shared/public/use_ui_ace_keyboard_mode.tsx @@ -96,7 +96,7 @@ export function useUIAceKeyboardMode(aceTextAreaElement: HTMLTextAreaElement | n } return () => { if (aceTextAreaElement) { - document.removeEventListener('keydown', documentKeyDownListener); + document.removeEventListener('keydown', documentKeyDownListener, { capture: true }); aceTextAreaElement.removeEventListener('keydown', aceKeydownListener); const textAreaContainer = aceTextAreaElement.parentElement; if (textAreaContainer && textAreaContainer.contains(overlayMountNode.current!)) { diff --git a/src/plugins/es_ui_shared/static/forms/helpers/field_validators/index_name.ts b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/index_name.ts index 524cac27341ab..5e969fa715172 100644 --- a/src/plugins/es_ui_shared/static/forms/helpers/field_validators/index_name.ts +++ b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/index_name.ts @@ -17,14 +17,11 @@ * under the License. */ -// Note: we can't import from "ui/indices" as the TS Type definition don't exist -// import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; +import { indices } from '../../../../public'; import { ValidationFunc } from '../../hook_form_lib'; import { startsWith, containsChars } from '../../../validators/string'; import { ERROR_CODE } from './types'; -const INDEX_ILLEGAL_CHARACTERS = ['\\', '/', '?', '"', '<', '>', '|', '*']; - export const indexNameField = (i18n: any) => ( ...args: Parameters ): ReturnType> => { @@ -51,7 +48,9 @@ export const indexNameField = (i18n: any) => ( }; } - const { charsFound, doesContain } = containsChars(INDEX_ILLEGAL_CHARACTERS)(value as string); + const { charsFound, doesContain } = containsChars(indices.INDEX_ILLEGAL_CHARACTERS_VISIBLE)( + value as string + ); if (doesContain) { return { message: i18n.translate('esUi.forms.fieldValidation.indexNameInvalidCharactersError', { diff --git a/src/plugins/expressions/common/execution/execution.ts b/src/plugins/expressions/common/execution/execution.ts index f70a32f2f09c1..d0ab178296408 100644 --- a/src/plugins/expressions/common/execution/execution.ts +++ b/src/plugins/expressions/common/execution/execution.ts @@ -22,6 +22,7 @@ import { Executor } from '../executor'; import { createExecutionContainer, ExecutionContainer } from './container'; import { createError } from '../util'; import { Defer, now } from '../../../kibana_utils/common'; +import { AbortError } from '../../../data/common'; import { RequestAdapter, DataAdapter } from '../../../inspector/common'; import { isExpressionValueError, ExpressionValueError } from '../expression_types/specs/error'; import { @@ -190,10 +191,7 @@ export class Execution< for (const link of chainArr) { // if execution was aborted return error if (this.context.abortSignal && this.context.abortSignal.aborted) { - return createError({ - message: 'The expression was aborted.', - name: 'AbortError', - }); + return createError(new AbortError('The expression was aborted.')); } const { function: fnName, arguments: fnArgs } = link; diff --git a/src/plugins/expressions/common/expression_functions/specs/kibana_context.ts b/src/plugins/expressions/common/expression_functions/specs/kibana_context.ts index 4092dfbba00d5..b8be273d7bbd3 100644 --- a/src/plugins/expressions/common/expression_functions/specs/kibana_context.ts +++ b/src/plugins/expressions/common/expression_functions/specs/kibana_context.ts @@ -16,10 +16,11 @@ * specific language governing permissions and limitations * under the License. */ - +import { uniq } from 'lodash'; import { i18n } from '@kbn/i18n'; import { ExpressionFunctionDefinition } from '../../expression_functions'; import { KibanaContext } from '../../expression_types'; +import { Query, uniqFilters } from '../../../../data/common'; interface Arguments { q?: string | null; @@ -35,6 +36,15 @@ export type ExpressionFunctionKibanaContext = ExpressionFunctionDefinition< Promise >; +const getParsedValue = (data: any, defaultValue: any) => + typeof data === 'string' && data.length ? JSON.parse(data) || defaultValue : defaultValue; + +const mergeQueries = (first: Query | Query[] = [], second: Query | Query[]) => + uniq( + [...(Array.isArray(first) ? first : [first]), ...(Array.isArray(second) ? second : [second])], + (n: any) => JSON.stringify(n.query) + ); + export const kibanaContextFunction: ExpressionFunctionKibanaContext = { name: 'kibana_context', type: 'kibana_context', @@ -75,9 +85,9 @@ export const kibanaContextFunction: ExpressionFunctionKibanaContext = { }, async fn(input, args, { getSavedObject }) { - const queryArg = args.q ? JSON.parse(args.q) : []; - let queries = Array.isArray(queryArg) ? queryArg : [queryArg]; - let filters = args.filters ? JSON.parse(args.filters) : []; + const timeRange = getParsedValue(args.timeRange, input?.timeRange); + let queries = mergeQueries(input?.query, getParsedValue(args?.q, [])); + let filters = [...(input?.filters || []), ...getParsedValue(args?.filters, [])]; if (args.savedSearchId) { if (typeof getSavedObject !== 'function') { @@ -89,29 +99,20 @@ export const kibanaContextFunction: ExpressionFunctionKibanaContext = { } const obj = await getSavedObject('search', args.savedSearchId); const search = obj.attributes.kibanaSavedObjectMeta as { searchSourceJSON: string }; - const data = JSON.parse(search.searchSourceJSON) as { query: string; filter: any[] }; - queries = queries.concat(data.query); - filters = filters.concat(data.filter); - } + const { query, filter } = getParsedValue(search.searchSourceJSON, {}); - if (input && input.query) { - queries = queries.concat(input.query); - } - - if (input && input.filters) { - filters = filters.concat(input.filters).filter((f: any) => !f.meta.disabled); + if (query) { + queries = mergeQueries(queries, query); + } + if (filter) { + filters = [...filters, ...(Array.isArray(filter) ? filter : [filter])]; + } } - const timeRange = args.timeRange - ? JSON.parse(args.timeRange) - : input - ? input.timeRange - : undefined; - return { type: 'kibana_context', query: queries, - filters, + filters: uniqFilters(filters).filter((f: any) => !f.meta?.disabled), timeRange, }; }, diff --git a/src/plugins/kibana_legacy/public/angular/index.ts b/src/plugins/kibana_legacy/public/angular/index.ts index 5fc37ac39612a..16bae6c4cffe0 100644 --- a/src/plugins/kibana_legacy/public/angular/index.ts +++ b/src/plugins/kibana_legacy/public/angular/index.ts @@ -21,7 +21,6 @@ export { PromiseServiceCreator } from './promises'; // @ts-ignore export { watchMultiDecorator } from './watch_multi'; export * from './angular_config'; -export { ensureDefaultIndexPattern } from './ensure_default_index_pattern'; // @ts-ignore export { createTopNavDirective, createTopNavHelper, loadKbnTopNavDirectives } from './kbn_top_nav'; export { subscribeWithScope } from './subscribe_with_scope'; diff --git a/src/plugins/kibana_react/public/exit_full_screen_button/__snapshots__/exit_full_screen_button.test.tsx.snap b/src/plugins/kibana_react/public/exit_full_screen_button/__snapshots__/exit_full_screen_button.test.tsx.snap index 39bd66ff71c61..ee97a5acfd3d2 100644 --- a/src/plugins/kibana_react/public/exit_full_screen_button/__snapshots__/exit_full_screen_button.test.tsx.snap +++ b/src/plugins/kibana_react/public/exit_full_screen_button/__snapshots__/exit_full_screen_button.test.tsx.snap @@ -17,27 +17,88 @@ exports[`is rendered 1`] = `
diff --git a/src/plugins/kibana_react/public/exit_full_screen_button/_exit_full_screen_button.scss b/src/plugins/kibana_react/public/exit_full_screen_button/_exit_full_screen_button.scss index e810fe0ccdba6..a2e951cb5b775 100644 --- a/src/plugins/kibana_react/public/exit_full_screen_button/_exit_full_screen_button.scss +++ b/src/plugins/kibana_react/public/exit_full_screen_button/_exit_full_screen_button.scss @@ -4,66 +4,40 @@ */ .dshExitFullScreenButton { - height: $euiSizeXXL; - left: 0; - bottom: 0; + @include euiBottomShadow; + + left: $euiSizeS; + bottom: $euiSizeS; position: fixed; display: block; padding: 0; border: none; background: none; z-index: 5; + background: $euiColorFullShade; + padding: $euiSizeXS; + border-radius: $euiBorderRadius; + text-align: left; - &:hover, - &:focus { - transition: all $euiAnimSpeedExtraSlow $euiAnimSlightResistance; - z-index: 10 !important; /* 1 */ + &:hover { + background: $euiColorFullShade; - .dshExitFullScreenButton__text { - transition: all $euiAnimSpeedNormal $euiAnimSlightResistance; - transform: translateX(-$euiSize); + .dshExitFullScreenButton__icon { + color: $euiColorEmptyShade; } } } -.dshExitFullScreenButton__logo { - display: block; - // Just darken the background for all themes because the logo is always white - background-color: shade($euiColorPrimary, 25%); - height: $euiSizeXXL; - - // These numbers are very specific to the Kibana logo size - width: 92px; - background-image: url('ui/assets/images/kibana.svg'); - background-position: 8px 5px; - background-size: 72px 30px; - background-repeat: no-repeat; - - z-index: $euiZLevel1; +.dshExitFullScreenButton__title { + line-height: 1.2; + color: $euiColorEmptyShade; } -/** - * 1. Calc made to allow caret in text to peek out / animate. - */ - .dshExitFullScreenButton__text { - background: $euiColorPrimary; - color: $euiColorEmptyShade; - line-height: $euiSizeXXL; - display: inline-block; - font-size: $euiFontSizeS; - height: $euiSizeXXL; - position: absolute; - left: calc(100% + #{$euiSize}); /* 1 */ - top: 0px; - bottom: 0px; - white-space: nowrap; - padding: 0px $euiSizeXS 0px $euiSizeM; - transition: all .2s ease; - transform: translateX(-100%); - z-index: -1; - - .euiIcon { - margin-left: $euiSizeXS; - } + line-height: 1.2; + color: makeHighContrastColor($euiColorMediumShade, $euiColorFullShade); +} + +.dshExitFullScreenButton__icon { + color: makeHighContrastColor($euiColorMediumShade, $euiColorFullShade); } diff --git a/src/plugins/kibana_react/public/exit_full_screen_button/exit_full_screen_button.tsx b/src/plugins/kibana_react/public/exit_full_screen_button/exit_full_screen_button.tsx index 5ce508ec1ed5b..97fc02ac64e12 100644 --- a/src/plugins/kibana_react/public/exit_full_screen_button/exit_full_screen_button.tsx +++ b/src/plugins/kibana_react/public/exit_full_screen_button/exit_full_screen_button.tsx @@ -20,7 +20,7 @@ import { i18n } from '@kbn/i18n'; import React, { PureComponent } from 'react'; import { EuiScreenReaderOnly, keyCodes } from '@elastic/eui'; -import { EuiIcon } from '@elastic/eui'; +import { EuiIcon, EuiTitle, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; export interface ExitFullScreenButtonProps { onExitFullScreenMode: () => void; @@ -61,17 +61,40 @@ class ExitFullScreenButtonUi extends PureComponent { )} className="dshExitFullScreenButton" onClick={this.props.onExitFullScreenMode} + data-test-subj="exitFullScreenModeLogo" > - - - {i18n.translate('kibana-react.exitFullScreenButton.exitFullScreenModeButtonLabel', { - defaultMessage: 'Exit full screen', - })} - - + + + + + +
+ +

+ {i18n.translate( + 'kibana-react.exitFullScreenButton.exitFullScreenModeButtonTitle', + { + defaultMessage: 'Elastic Kibana', + } + )} +

+
+ +

+ {i18n.translate( + 'kibana-react.exitFullScreenButton.exitFullScreenModeButtonText', + { + defaultMessage: 'Exit full screen', + } + )} +

+
+
+
+ + + +
diff --git a/src/plugins/kibana_react/public/validated_range/validated_dual_range.tsx b/src/plugins/kibana_react/public/validated_range/validated_dual_range.tsx index e7392eeba3830..ce583236e7c81 100644 --- a/src/plugins/kibana_react/public/validated_range/validated_dual_range.tsx +++ b/src/plugins/kibana_react/public/validated_range/validated_dual_range.tsx @@ -47,7 +47,11 @@ interface State { } export class ValidatedDualRange extends Component { - static defaultProps: { fullWidth: boolean; allowEmptyRange: boolean; compressed: boolean }; + static defaultProps: { fullWidth: boolean; allowEmptyRange: boolean; compressed: boolean } = { + allowEmptyRange: true, + fullWidth: false, + compressed: false, + }; static getDerivedStateFromProps(nextProps: Props, prevState: State) { if (nextProps.value !== prevState.prevValue) { @@ -125,9 +129,3 @@ export class ValidatedDualRange extends Component { ); } } - -ValidatedDualRange.defaultProps = { - allowEmptyRange: true, - fullWidth: false, - compressed: false, -}; diff --git a/src/plugins/kibana_legacy/public/angular/ensure_default_index_pattern.tsx b/src/plugins/kibana_utils/public/history/ensure_default_index_pattern.tsx similarity index 67% rename from src/plugins/kibana_legacy/public/angular/ensure_default_index_pattern.tsx rename to src/plugins/kibana_utils/public/history/ensure_default_index_pattern.tsx index 1a3bb84ae7575..7992f650cb372 100644 --- a/src/plugins/kibana_legacy/public/angular/ensure_default_index_pattern.tsx +++ b/src/plugins/kibana_utils/public/history/ensure_default_index_pattern.tsx @@ -18,14 +18,13 @@ */ import { contains } from 'lodash'; -import { IRootScopeService } from 'angular'; import React from 'react'; -import ReactDOM from 'react-dom'; +import { History } from 'history'; import { i18n } from '@kbn/i18n'; -import { I18nProvider } from '@kbn/i18n/react'; import { EuiCallOut } from '@elastic/eui'; import { CoreStart } from 'kibana/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { toMountPoint } from '../../../kibana_react/public'; let bannerId: string; let timeoutId: NodeJS.Timeout | undefined; @@ -39,18 +38,17 @@ let timeoutId: NodeJS.Timeout | undefined; * resolve to wait for the URL change to happen. */ export async function ensureDefaultIndexPattern( - newPlatform: CoreStart, + core: CoreStart, data: DataPublicPluginStart, - $rootScope: IRootScopeService, - kbnUrl: any + history: History ) { const patterns = await data.indexPatterns.getIds(); - let defaultId = newPlatform.uiSettings.get('defaultIndex'); + let defaultId = core.uiSettings.get('defaultIndex'); let defined = !!defaultId; const exists = contains(patterns, defaultId); if (defined && !exists) { - newPlatform.uiSettings.remove('defaultIndex'); + core.uiSettings.remove('defaultIndex'); defaultId = defined = false; } @@ -61,10 +59,9 @@ export async function ensureDefaultIndexPattern( // If there is any index pattern created, set the first as default if (patterns.length >= 1) { defaultId = patterns[0]; - newPlatform.uiSettings.set('defaultIndex', defaultId); + core.uiSettings.set('defaultIndex', defaultId); } else { - const canManageIndexPatterns = - newPlatform.application.capabilities.management.kibana.index_patterns; + const canManageIndexPatterns = core.application.capabilities.management.kibana.index_patterns; const redirectTarget = canManageIndexPatterns ? '/management/kibana/index_pattern' : '/home'; if (timeoutId) { @@ -73,31 +70,27 @@ export async function ensureDefaultIndexPattern( // Avoid being hostile to new users who don't have an index pattern setup yet // give them a friendly info message instead of a terse error message - bannerId = newPlatform.overlays.banners.replace(bannerId, (element: HTMLElement) => { - ReactDOM.render( - - - , - element - ); - return () => ReactDOM.unmountComponentAtNode(element); - }); + bannerId = core.overlays.banners.replace( + bannerId, + toMountPoint( + + ) + ); // hide the message after the user has had a chance to acknowledge it -- so it doesn't permanently stick around timeoutId = setTimeout(() => { - newPlatform.overlays.banners.remove(bannerId); + core.overlays.banners.remove(bannerId); timeoutId = undefined; }, 15000); - kbnUrl.change(redirectTarget); - $rootScope.$digest(); + history.push(redirectTarget); // return never-resolving promise to stop resolving and wait for the url change return new Promise(() => {}); diff --git a/src/plugins/kibana_utils/public/history/index.ts b/src/plugins/kibana_utils/public/history/index.ts index bb13ea09f928a..1a73bbb6b04a1 100644 --- a/src/plugins/kibana_utils/public/history/index.ts +++ b/src/plugins/kibana_utils/public/history/index.ts @@ -19,3 +19,4 @@ export { removeQueryParam } from './remove_query_param'; export { redirectWhenMissing } from './redirect_when_missing'; +export { ensureDefaultIndexPattern } from './ensure_default_index_pattern'; diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts index 47f90cbe2a627..1876e688c989a 100644 --- a/src/plugins/kibana_utils/public/index.ts +++ b/src/plugins/kibana_utils/public/index.ts @@ -73,5 +73,5 @@ export { StartSyncStateFnType, StopSyncStateFnType, } from './state_sync'; -export { removeQueryParam, redirectWhenMissing } from './history'; +export { removeQueryParam, redirectWhenMissing, ensureDefaultIndexPattern } from './history'; export { applyDiff } from './state_management/utils/diff_object'; diff --git a/test/functional/apps/saved_objects_management/edit_saved_object.ts b/test/functional/apps/saved_objects_management/edit_saved_object.ts new file mode 100644 index 0000000000000..6af91ac9c5c94 --- /dev/null +++ b/test/functional/apps/saved_objects_management/edit_saved_object.ts @@ -0,0 +1,103 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +export default function({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common', 'settings']); + + const setFieldValue = async (fieldName: string, value: string) => { + return testSubjects.setValue(`savedObjects-editField-${fieldName}`, value); + }; + + const getFieldValue = async (fieldName: string) => { + return testSubjects.getAttribute(`savedObjects-editField-${fieldName}`, 'value'); + }; + + const focusAndClickButton = async (buttonSubject: string) => { + const button = await testSubjects.find(buttonSubject); + await button.scrollIntoViewIfNecessary(); + await delay(10); + await button.focus(); + await delay(10); + await button.click(); + }; + + describe('saved objects edition page', () => { + beforeEach(async () => { + await esArchiver.load('saved_objects_management/edit_saved_object'); + }); + + afterEach(async () => { + await esArchiver.unload('saved_objects_management/edit_saved_object'); + }); + + it('allows to update the saved object when submitting', async () => { + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaSavedObjects(); + + let objects = await PageObjects.settings.getSavedObjectsInTable(); + expect(objects.includes('A Dashboard')).to.be(true); + + await PageObjects.common.navigateToActualUrl( + 'kibana', + '/management/kibana/objects/savedDashboards/i-exist' + ); + + await testSubjects.existOrFail('savedObjectEditSave'); + + expect(await getFieldValue('title')).to.eql('A Dashboard'); + + await setFieldValue('title', 'Edited Dashboard'); + await setFieldValue('description', 'Some description'); + + await focusAndClickButton('savedObjectEditSave'); + + objects = await PageObjects.settings.getSavedObjectsInTable(); + expect(objects.includes('A Dashboard')).to.be(false); + expect(objects.includes('Edited Dashboard')).to.be(true); + + await PageObjects.common.navigateToActualUrl( + 'kibana', + '/management/kibana/objects/savedDashboards/i-exist' + ); + + expect(await getFieldValue('title')).to.eql('Edited Dashboard'); + expect(await getFieldValue('description')).to.eql('Some description'); + }); + + it('allows to delete a saved object', async () => { + await PageObjects.common.navigateToActualUrl( + 'kibana', + '/management/kibana/objects/savedDashboards/i-exist' + ); + + await focusAndClickButton('savedObjectEditDelete'); + await PageObjects.common.clickConfirmOnModal(); + + const objects = await PageObjects.settings.getSavedObjectsInTable(); + expect(objects.includes('A Dashboard')).to.be(false); + }); + }); +} diff --git a/test/functional/apps/saved_objects_management/index.ts b/test/functional/apps/saved_objects_management/index.ts new file mode 100644 index 0000000000000..ab43e18735ee7 --- /dev/null +++ b/test/functional/apps/saved_objects_management/index.ts @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function savedObjectsManagementApp({ loadTestFile }: FtrProviderContext) { + describe('saved objects management', function savedObjectsManagementAppTestSuite() { + this.tags('ciGroup7'); + loadTestFile(require.resolve('./edit_saved_object')); + }); +} diff --git a/test/functional/config.js b/test/functional/config.js index 11399bd6187c8..0fbde95afe12c 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -32,6 +32,7 @@ export default async function({ readConfigFile }) { require.resolve('./apps/discover'), require.resolve('./apps/home'), require.resolve('./apps/management'), + require.resolve('./apps/saved_objects_management'), require.resolve('./apps/status_page'), require.resolve('./apps/timelion'), require.resolve('./apps/visualize'), diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/edit_saved_object/data.json b/test/functional/fixtures/es_archiver/saved_objects_management/edit_saved_object/data.json new file mode 100644 index 0000000000000..f085bad4c507e --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/edit_saved_object/data.json @@ -0,0 +1,85 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "index-pattern:logstash-*", + "source": { + "index-pattern": { + "title": "logstash-*", + "timeFieldName": "@timestamp", + "fields": "[{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]" + }, + "type": "index-pattern", + "migrationVersion": { + "index-pattern": "6.5.0" + }, + "updated_at": "2018-12-21T00:43:07.096Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "visualization:75c3e060-1e7c-11e9-8488-65449e65d0ed", + "source": { + "visualization": { + "title": "A Pie", + "visState": "{\"title\":\"A Pie\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":false,\"values\":true,\"last_level\":true,\"truncate\":100},\"dimensions\":{\"metric\":{\"accessor\":0,\"format\":{\"id\":\"number\"},\"params\":{},\"aggType\":\"count\"}}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"geo.src\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\"}}]}", + "uiStateJSON": "{}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[]}" + } + }, + "type": "visualization", + "updated_at": "2019-01-22T19:32:31.206Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "dashboard:i-exist", + "source": { + "dashboard": { + "title": "A Dashboard", + "hits": 0, + "description": "", + "panelsJSON": "[{\"gridData\":{\"w\":24,\"h\":15,\"x\":0,\"y\":0,\"i\":\"1\"},\"version\":\"7.0.0\",\"panelIndex\":\"1\",\"type\":\"visualization\",\"id\":\"75c3e060-1e7c-11e9-8488-65449e65d0ed\",\"embeddableConfig\":{}}]", + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "version": 1, + "timeRestore": false, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[]}" + } + }, + "type": "dashboard", + "updated_at": "2019-01-22T19:32:47.232Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "config:6.0.0", + "source": { + "config": { + "buildNum": 9007199254740991, + "defaultIndex": "logstash-*" + }, + "type": "config", + "updated_at": "2019-01-22T19:32:02.235Z" + } + } +} diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/edit_saved_object/mappings.json b/test/functional/fixtures/es_archiver/saved_objects_management/edit_saved_object/mappings.json new file mode 100644 index 0000000000000..96e6b7c0a19f1 --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/edit_saved_object/mappings.json @@ -0,0 +1,459 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "auto_expand_replicas": "0-1", + "number_of_replicas": "0" + } + }, + "mappings": { + "dynamic": "strict", + "properties": { + "apm-telemetry": { + "properties": { + "has_any_services": { + "type": "boolean" + }, + "services_per_agent": { + "properties": { + "go": { + "type": "long", + "null_value": 0 + }, + "java": { + "type": "long", + "null_value": 0 + }, + "js-base": { + "type": "long", + "null_value": 0 + }, + "nodejs": { + "type": "long", + "null_value": 0 + }, + "python": { + "type": "long", + "null_value": 0 + }, + "ruby": { + "type": "long", + "null_value": 0 + } + } + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "id": { + "type": "text", + "index": false + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "accessibility:disableAnimations": { + "type": "boolean" + }, + "buildNum": { + "type": "keyword" + }, + "dateFormat:tz": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "defaultIndex": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "telemetry:optIn": { + "type": "boolean" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "map": { + "properties": { + "bounds": { + "type": "geo_shape", + "tree": "quadtree" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "index-pattern": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "space": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "spaceId": { + "type": "keyword" + }, + "telemetry": { + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index 5ee3726ddb44f..6895034f22ed5 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -514,6 +514,12 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo } }); } + + async setFileInputPath(path: string) { + log.debug(`Setting the path '${path}' on the file input`); + const input = await find.byCssSelector('.euiFilePicker__input'); + await input.type(path); + } } return new CommonPage(); diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index c244deba5f17e..0ad1a1dc51321 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -612,9 +612,7 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider log.debug(`Clicking importObjects`); await testSubjects.click('importObjects'); - log.debug(`Setting the path on the file input`); - const input = await find.byCssSelector('.euiFilePicker__input'); - await input.type(path); + await PageObjects.common.setFileInputPath(path); if (!overwriteAll) { log.debug(`Toggling overwriteAll`); diff --git a/test/functional/screenshots/baseline/area_chart.png b/test/functional/screenshots/baseline/area_chart.png index 2c2d599139100..1a381d61dd9f1 100644 Binary files a/test/functional/screenshots/baseline/area_chart.png and b/test/functional/screenshots/baseline/area_chart.png differ diff --git a/test/functional/screenshots/baseline/tsvb_dashboard.png b/test/functional/screenshots/baseline/tsvb_dashboard.png index d703be89b7460..f5ebccbcb96c6 100644 Binary files a/test/functional/screenshots/baseline/tsvb_dashboard.png and b/test/functional/screenshots/baseline/tsvb_dashboard.png differ diff --git a/test/scripts/jenkins_xpack_build_kibana.sh b/test/scripts/jenkins_xpack_build_kibana.sh index f87d6e1102c45..2bf9d2d9c158b 100755 --- a/test/scripts/jenkins_xpack_build_kibana.sh +++ b/test/scripts/jenkins_xpack_build_kibana.sh @@ -6,6 +6,7 @@ source src/dev/ci_setup/setup_env.sh echo " -> building kibana platform plugins" node scripts/build_kibana_platform_plugins \ --scan-dir "$XPACK_DIR/test/plugin_functional/plugins" \ + --scan-dir "$XPACK_DIR/test/functional_with_es_ssl/fixtures/plugins" \ --verbose; # doesn't persist, also set in kibanaPipeline.groovy diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index cb5508642711a..6252a103d2881 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -98,6 +98,7 @@ def withGcsArtifactUpload(workerName, closure) { def uploadPrefix = "kibana-ci-artifacts/jobs/${env.JOB_NAME}/${BUILD_NUMBER}/${workerName}" def ARTIFACT_PATTERNS = [ 'target/kibana-*', + 'target/kibana-siem/**/*.png', 'target/junit/**/*', 'test/**/screenshots/**/*.png', 'test/functional/failure_debug/html/*.html', diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 60a8d1fcbf229..d568e9b951d28 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -22,7 +22,7 @@ "xpack.infra": "plugins/infra", "xpack.ingestManager": "plugins/ingest_manager", "xpack.lens": "legacy/plugins/lens", - "xpack.licenseMgmt": "legacy/plugins/license_management", + "xpack.licenseMgmt": "plugins/license_management", "xpack.licensing": "plugins/licensing", "xpack.logstash": "legacy/plugins/logstash", "xpack.main": "legacy/plugins/xpack_main", @@ -42,7 +42,7 @@ "xpack.transform": "plugins/transform", "xpack.triggersActionsUI": "plugins/triggers_actions_ui", "xpack.upgradeAssistant": "plugins/upgrade_assistant", - "xpack.uptime": "legacy/plugins/uptime", + "xpack.uptime": ["plugins/uptime", "legacy/plugins/uptime"], "xpack.watcher": "plugins/watcher" }, "translations": [ diff --git a/x-pack/index.js b/x-pack/index.js index ab31d40c5d718..fb14b3dc10a4d 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -16,7 +16,6 @@ import { logstash } from './legacy/plugins/logstash'; import { beats } from './legacy/plugins/beats_management'; import { apm } from './legacy/plugins/apm'; import { maps } from './legacy/plugins/maps'; -import { licenseManagement } from './legacy/plugins/license_management'; import { indexManagement } from './legacy/plugins/index_management'; import { indexLifecycleManagement } from './legacy/plugins/index_lifecycle_management'; import { spaces } from './legacy/plugins/spaces'; @@ -52,7 +51,6 @@ module.exports = function(kibana) { apm(kibana), maps(kibana), canvas(kibana), - licenseManagement(kibana), indexManagement(kibana), indexLifecycleManagement(kibana), infra(kibana), diff --git a/x-pack/legacy/plugins/actions/server/mappings.json b/x-pack/legacy/plugins/actions/server/mappings.json index a9c4d80b00af1..ef6a0c9919920 100644 --- a/x-pack/legacy/plugins/actions/server/mappings.json +++ b/x-pack/legacy/plugins/actions/server/mappings.json @@ -2,7 +2,12 @@ "action": { "properties": { "name": { - "type": "text" + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } }, "actionTypeId": { "type": "keyword" diff --git a/x-pack/legacy/plugins/alerting/server/mappings.json b/x-pack/legacy/plugins/alerting/server/mappings.json index 31733f44e7ce6..a7e85febf2446 100644 --- a/x-pack/legacy/plugins/alerting/server/mappings.json +++ b/x-pack/legacy/plugins/alerting/server/mappings.json @@ -5,7 +5,12 @@ "type": "boolean" }, "name": { - "type": "text" + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } }, "tags": { "type": "keyword" diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx index 6f7b743d8b779..b18f462b54171 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx @@ -13,7 +13,7 @@ import { getCytoscapeElements } from './get_cytoscape_elements'; import serviceMapResponse from './cytoscape-layout-test-response.json'; import { iconForNode } from './icons'; -const elementsFromResponses = getCytoscapeElements([serviceMapResponse], ''); +const elementsFromResponses = getCytoscapeElements(serviceMapResponse, ''); storiesOf('app/ServiceMap/Cytoscape', module).add( 'example', diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.test.tsx new file mode 100644 index 0000000000000..d61dea80666a0 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, render, wait } from '@testing-library/react'; +import cytoscape from 'cytoscape'; +import React, { FunctionComponent } from 'react'; +import { MockApmPluginContextWrapper } from '../../../utils/testHelpers'; +import { CytoscapeContext } from './Cytoscape'; +import { EmptyBanner } from './EmptyBanner'; + +const cy = cytoscape({}); + +const wrapper: FunctionComponent = ({ children }) => ( + + {children} + +); + +describe('EmptyBanner', () => { + describe('when cy is undefined', () => { + it('renders null', () => { + const noCytoscapeWrapper: FunctionComponent = ({ children }) => ( + + + {children} + + + ); + const component = render(, { + wrapper: noCytoscapeWrapper + }); + + expect(component.container.children).toHaveLength(0); + }); + }); + + describe('with no nodes', () => { + it('renders null', () => { + const component = render(, { + wrapper + }); + + expect(component.container.children).toHaveLength(0); + }); + }); + + describe('with one node', () => { + it('does not render null', async () => { + const component = render(, { wrapper }); + + await act(async () => { + cy.add({ data: { id: 'test id' } }); + await wait(() => { + expect(component.container.children.length).toBeGreaterThan(0); + }); + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx index 418430e37b21e..464bf166eb80f 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx @@ -7,37 +7,70 @@ import { EuiCallOut } from '@elastic/eui'; import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; -import React from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import styled from 'styled-components'; import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; +import { CytoscapeContext } from './Cytoscape'; -const EmptyBannerCallOut = styled(EuiCallOut)` +const EmptyBannerContainer = styled.div` margin: ${lightTheme.gutterTypes.gutterSmall}; /* Add some extra margin so it displays to the right of the controls. */ - margin-left: calc( - ${lightTheme.gutterTypes.gutterLarge} + - ${lightTheme.gutterTypes.gutterExtraLarge} + left: calc( + ${lightTheme.gutterTypes.gutterExtraLarge} + + ${lightTheme.gutterTypes.gutterSmall} ); position: absolute; z-index: 1; `; export function EmptyBanner() { + const cy = useContext(CytoscapeContext); + const [nodeCount, setNodeCount] = useState(0); + + useEffect(() => { + const handler: cytoscape.EventHandler = event => + setNodeCount(event.cy.nodes().length); + + if (cy) { + cy.on('add remove', 'node', handler); + } + + return () => { + if (cy) { + cy.removeListener('add remove', 'node', handler); + } + }; + }, [cy]); + + // Only show if there's a single node. + if (!cy || nodeCount !== 1) { + return null; + } + + // Since we're absolutely positioned, we need to get the full width and + // subtract the space for controls and margins. + const width = + cy.width() - + parseInt(lightTheme.gutterTypes.gutterExtraLarge, 10) - + parseInt(lightTheme.gutterTypes.gutterLarge, 10); + return ( - - {i18n.translate('xpack.apm.serviceMap.emptyBanner.message', { - defaultMessage: - "We will map out connected services and external requests if we can detect them. Please make sure you're running the latest version of the APM agent." - })}{' '} - - {i18n.translate('xpack.apm.serviceMap.emptyBanner.docsLink', { - defaultMessage: 'Learn more in the docs' + + - + > + {i18n.translate('xpack.apm.serviceMap.emptyBanner.message', { + defaultMessage: + "We will map out connected services and external requests if we can detect them. Please make sure you're running the latest version of the APM agent." + })}{' '} + + {i18n.translate('xpack.apm.serviceMap.emptyBanner.docsLink', { + defaultMessage: 'Learn more in the docs' + })} + + + ); } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/PlatinumLicensePrompt.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/PlatinumLicensePrompt.tsx index 9213349a1492b..77f0b64ba0fb1 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/PlatinumLicensePrompt.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/PlatinumLicensePrompt.tsx @@ -6,10 +6,12 @@ import { EuiButton, - EuiEmptyPrompt, + EuiPanel, EuiFlexGroup, EuiFlexItem, - EuiPanel + EuiTitle, + EuiText, + EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; @@ -18,7 +20,8 @@ import { useKibanaUrl } from '../../../hooks/useKibanaUrl'; export function PlatinumLicensePrompt() { // Set the height to give it some top margin - const style = { height: '60vh' }; + const flexGroupStyle = { height: '60vh' }; + const flexItemStyle = { width: 600, textAlign: 'center' as const }; const licensePageUrl = useKibanaUrl( '/app/kibana', @@ -29,30 +32,41 @@ export function PlatinumLicensePrompt() { - - - - {i18n.translate( - 'xpack.apm.serviceMap.licensePromptButtonText', - { - defaultMessage: 'Start 30-day Platinum trial' - } - )} - - ]} - body={

{invalidLicenseMessage}

} - title={ + + + + +

{i18n.translate('xpack.apm.serviceMap.licensePromptTitle', { defaultMessage: 'Service maps is available in Platinum.' })}

- } - /> +
+ + +

{invalidLicenseMessage}

+
+ + + {i18n.translate('xpack.apm.serviceMap.licensePromptButtonText', { + defaultMessage: 'Start 30-day Platinum trial' + })} + +
diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts index 9ba70646598fc..4017aa2e3cdd9 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts @@ -4,166 +4,63 @@ * you may not use this file except in compliance with the Elastic License. */ import { ValuesType } from 'utility-types'; -import { sortBy, isEqual } from 'lodash'; -import { - Connection, - ConnectionNode -} from '../../../../../../../plugins/apm/common/service_map'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ServiceMapAPIResponse } from '../../../../../../../plugins/apm/server/lib/service_map/get_service_map'; import { getAPMHref } from '../../shared/Links/apm/APMLink'; -function getConnectionNodeId(node: ConnectionNode): string { - if ('destination.address' in node) { - // use a prefix to distinguish exernal destination ids from services - return `>${node['destination.address']}`; - } - return node['service.name']; -} - -function getConnectionId(connection: Connection) { - return `${getConnectionNodeId(connection.source)}~${getConnectionNodeId( - connection.destination - )}`; -} export function getCytoscapeElements( - responses: ServiceMapAPIResponse[], + response: ServiceMapAPIResponse, search: string ) { - const discoveredServices = responses.flatMap( - response => response.discoveredServices - ); - - const serviceNodes = responses - .flatMap(response => response.services) - .map(service => ({ - ...service, - id: service['service.name'] - })); - - // maps destination.address to service.name if possible - function getConnectionNode(node: ConnectionNode) { - let mappedNode: ConnectionNode | undefined; - - if ('destination.address' in node) { - mappedNode = discoveredServices.find(map => isEqual(map.from, node))?.to; - } - - if (!mappedNode) { - mappedNode = node; - } - - return { - ...mappedNode, - id: getConnectionNodeId(mappedNode) - }; - } - - // build connections with mapped nodes - const connections = responses - .flatMap(response => response.connections) - .map(connection => { - const source = getConnectionNode(connection.source); - const destination = getConnectionNode(connection.destination); - - return { - source, - destination, - id: getConnectionId({ source, destination }) - }; - }) - .filter(connection => connection.source.id !== connection.destination.id); - - const nodes = connections - .flatMap(connection => [connection.source, connection.destination]) - .concat(serviceNodes); - - type ConnectionWithId = ValuesType; - type ConnectionNodeWithId = ValuesType; - - const connectionsById = connections.reduce((connectionMap, connection) => { - return { - ...connectionMap, - [connection.id]: connection - }; - }, {} as Record); + const { nodes, connections } = response; const nodesById = nodes.reduce((nodeMap, node) => { return { ...nodeMap, [node.id]: node }; - }, {} as Record); - - const cyNodes = (Object.values(nodesById) as ConnectionNodeWithId[]).map( - node => { - let data = {}; - - if ('service.name' in node) { - data = { - href: getAPMHref( - `/services/${node['service.name']}/service-map`, - search - ), - agentName: node['agent.name'], - frameworkName: node['service.framework.name'], - type: 'service' - }; - } - - if ('span.type' in node) { - data = { - // For nodes with span.type "db", convert it to "database". Otherwise leave it as-is. - type: node['span.type'] === 'db' ? 'database' : node['span.type'], - // Externals should not have a subtype so make it undefined if the type is external. - subtype: node['span.type'] !== 'external' && node['span.subtype'] - }; - } - - return { - group: 'nodes' as const, - data: { - id: node.id, - label: - 'service.name' in node - ? node['service.name'] - : node['destination.address'], - ...data - } + }, {} as Record>); + + const cyNodes = (Object.values(nodesById) as Array< + ValuesType + >).map(node => { + let data = {}; + + if ('service.name' in node) { + data = { + href: getAPMHref( + `/services/${node['service.name']}/service-map`, + search + ), + agentName: node['agent.name'], + frameworkName: node['service.framework.name'], + type: 'service' }; } - ); - - // instead of adding connections in two directions, - // we add a `bidirectional` flag to use in styling - // and hide the inverse edge when rendering - const dedupedConnections = (sortBy( - Object.values(connectionsById), - // make sure that order is stable - 'id' - ) as ConnectionWithId[]).reduce< - Array< - ConnectionWithId & { bidirectional?: boolean; isInverseEdge?: boolean } - > - >((prev, connection) => { - const reversedConnection = prev.find( - c => - c.destination.id === connection.source.id && - c.source.id === connection.destination.id - ); - if (reversedConnection) { - reversedConnection.bidirectional = true; - return prev.concat({ - ...connection, - isInverseEdge: true - }); + if ('span.type' in node) { + data = { + // For nodes with span.type "db", convert it to "database". Otherwise leave it as-is. + type: node['span.type'] === 'db' ? 'database' : node['span.type'], + // Externals should not have a subtype so make it undefined if the type is external. + subtype: node['span.type'] !== 'external' && node['span.subtype'] + }; } - return prev.concat(connection); - }, []); + return { + group: 'nodes' as const, + data: { + id: node.id, + label: + 'service.name' in node + ? node['service.name'] + : node['destination.address'], + ...data + } + }; + }); - const cyEdges = dedupedConnections.map(connection => { + const cyEdges = connections.map(connection => { return { group: 'edges' as const, classes: connection.isInverseEdge ? 'invisible' : undefined, diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg index 9f7427f0e1001..da7f1a8fde45d 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg @@ -1,127 +1,3 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx index 7bbb77a49c84b..6222a00a9e888 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -4,24 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiBetaBadge } from '@elastic/eui'; import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; -import { ElementDefinition } from 'cytoscape'; -import { find, isEqual } from 'lodash'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState -} from 'react'; +import React, { useMemo } from 'react'; +import styled from 'styled-components'; import { isValidPlatinumLicense } from '../../../../../../../plugins/apm/common/service_map'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ServiceMapAPIResponse } from '../../../../../../../plugins/apm/server/lib/service_map/get_service_map'; -import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; import { useDeepObjectIdentity } from '../../../hooks/useDeepObjectIdentity'; +import { useFetcher } from '../../../hooks/useFetcher'; import { useLicense } from '../../../hooks/useLicense'; -import { useLoadingIndicator } from '../../../hooks/useLoadingIndicator'; import { useLocation } from '../../../hooks/useLocation'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { callApmApi } from '../../../services/rest/createCallApmApi'; @@ -56,14 +47,17 @@ ${theme.euiColorLightShade}`, margin: `-${theme.gutterTypes.gutterLarge}`, marginTop: 0 }; - -const MAX_REQUESTS = 5; +const BetaBadgeContainer = styled.div` + right: ${theme.gutterTypes.gutterMedium}; + position: absolute; + top: ${theme.gutterTypes.gutterSmall}; + z-index: 1; /* The element containing the cytoscape canvas has z-index = 0. */ +`; export function ServiceMap({ serviceName }: ServiceMapProps) { const license = useLicense(); const { search } = useLocation(); const { urlParams, uiFilters } = useUrlParams(); - const { notifications } = useApmPluginContext().core; const params = useDeepObjectIdentity({ start: urlParams.start, end: urlParams.end, @@ -75,95 +69,28 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { } }); - const renderedElements = useRef([]); - - const [responses, setResponses] = useState([]); - - const { setIsLoading } = useLoadingIndicator(); - - const [, _setUnusedState] = useState(false); - - const elements = useMemo(() => getCytoscapeElements(responses, search), [ - responses, - search - ]); - - const forceUpdate = useCallback(() => _setUnusedState(value => !value), []); - - const getNext = useCallback( - async (input: { reset?: boolean; after?: string | undefined }) => { - const { start, end, uiFilters: strippedUiFilters, ...query } = params; - - if (input.reset) { - renderedElements.current = []; - setResponses([]); - } - - if (start && end) { - setIsLoading(true); - try { - const data = await callApmApi({ - pathname: '/api/apm/service-map', - params: { - query: { - ...query, - start, - end, - uiFilters: JSON.stringify(strippedUiFilters), - after: input.after - } - } - }); - setResponses(resp => resp.concat(data)); - - const shouldGetNext = - responses.length + 1 < MAX_REQUESTS && data.after; - - if (shouldGetNext) { - await getNext({ after: data.after }); - } else { - setIsLoading(false); + const { data } = useFetcher(() => { + const { start, end } = params; + if (start && end) { + return callApmApi({ + pathname: '/api/apm/service-map', + params: { + query: { + ...params, + start, + end, + uiFilters: JSON.stringify(params.uiFilters) } - } catch (error) { - setIsLoading(false); - notifications.toasts.addError(error, { - title: i18n.translate('xpack.apm.errorServiceMapData', { - defaultMessage: `Error loading service connections` - }) - }); } - } - }, - [params, setIsLoading, responses.length, notifications.toasts] - ); - - useEffect(() => { - const loadServiceMaps = async () => { - await getNext({ reset: true }); - }; - - loadServiceMaps(); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [params]); - - useEffect(() => { - if (renderedElements.current.length === 0) { - renderedElements.current = elements; - return; + }); } + }, [params]); - const newElements = elements.filter(element => { - return !find(renderedElements.current, el => isEqual(el, element)); - }); + const elements = useMemo(() => { + return data ? getCytoscapeElements(data as any, search) : []; + }, [data, search]); - if (newElements.length > 0 && renderedElements.current.length > 0) { - renderedElements.current = elements; - forceUpdate(); - } - }, [elements, forceUpdate]); - - const { ref: wrapperRef, width, height } = useRefDimensions(); + const { ref, height, width } = useRefDimensions(); if (!license) { return null; @@ -172,20 +99,32 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { return isValidPlatinumLicense(license) ? (
- {serviceName && renderedElements.current.length === 1 && ( - - )} + {serviceName && } + + +
) : ( diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts index d9e841092be56..538aa9f74e2a6 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts @@ -7,9 +7,16 @@ // @ts-ignore import { MAP_SAVED_OBJECT_TYPE } from '../../../maps/common/constants'; import { VISUALIZE_EMBEDDABLE_TYPE } from '../../../../../../src/legacy/core_plugins/visualizations/public'; +import { LENS_EMBEDDABLE_TYPE } from '../../../../../plugins/lens/common/constants'; import { SEARCH_EMBEDDABLE_TYPE } from '../../../../../../src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/constants'; -export const EmbeddableTypes: { map: string; search: string; visualization: string } = { +export const EmbeddableTypes: { + lens: string; + map: string; + search: string; + visualization: string; +} = { + lens: LENS_EMBEDDABLE_TYPE, map: MAP_SAVED_OBJECT_TYPE, search: SEARCH_EMBEDDABLE_TYPE, visualization: VISUALIZE_EMBEDDABLE_TYPE, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts index 48b50930d563e..36fa6497ab6f3 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts @@ -48,6 +48,7 @@ import { rounddate } from './rounddate'; import { rowCount } from './rowCount'; import { repeatImage } from './repeatImage'; import { revealImage } from './revealImage'; +import { savedLens } from './saved_lens'; import { savedMap } from './saved_map'; import { savedSearch } from './saved_search'; import { savedVisualization } from './saved_visualization'; @@ -109,6 +110,7 @@ export const functions = [ revealImage, rounddate, rowCount, + savedLens, savedMap, savedSearch, savedVisualization, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts new file mode 100644 index 0000000000000..6b197148e6373 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +jest.mock('ui/new_platform'); +import { savedLens } from './saved_lens'; +import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; + +const filterContext = { + and: [ + { and: [], value: 'filter-value', column: 'filter-column', type: 'exactly' }, + { + and: [], + column: 'time-column', + type: 'time', + from: '2019-06-04T04:00:00.000Z', + to: '2019-06-05T04:00:00.000Z', + }, + ], +}; + +describe('savedLens', () => { + const fn = savedLens().fn; + const args = { + id: 'some-id', + title: null, + timerange: null, + }; + + it('accepts null context', () => { + const expression = fn(null, args, {} as any); + + expect(expression.input.filters).toEqual([]); + }); + + it('accepts filter context', () => { + const expression = fn(filterContext, args, {} as any); + const embeddableFilters = getQueryFilters(filterContext.and); + + expect(expression.input.filters).toEqual(embeddableFilters); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts new file mode 100644 index 0000000000000..60026adc0998a --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { TimeRange } from 'src/plugins/data/public'; +import { EmbeddableInput } from 'src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; +import { Filter, TimeRange as TimeRangeArg } from '../../../types'; +import { + EmbeddableTypes, + EmbeddableExpressionType, + EmbeddableExpression, +} from '../../expression_types'; +import { getFunctionHelp } from '../../../i18n'; +import { Filter as DataFilter } from '../../../../../../../src/plugins/data/public'; + +interface Arguments { + id: string; + title: string | null; + timerange: TimeRangeArg | null; +} + +export type SavedLensInput = EmbeddableInput & { + id: string; + timeRange?: TimeRange; + filters: DataFilter[]; +}; + +const defaultTimeRange = { + from: 'now-15m', + to: 'now', +}; + +type Return = EmbeddableExpression; + +export function savedLens(): ExpressionFunctionDefinition< + 'savedLens', + Filter | null, + Arguments, + Return +> { + const { help, args: argHelp } = getFunctionHelp().savedLens; + return { + name: 'savedLens', + help, + args: { + id: { + types: ['string'], + required: false, + help: argHelp.id, + }, + timerange: { + types: ['timerange'], + help: argHelp.timerange, + required: false, + }, + title: { + types: ['string'], + help: argHelp.title, + required: false, + }, + }, + type: EmbeddableExpressionType, + fn: (context, args) => { + const filters = context ? context.and : []; + + return { + type: EmbeddableExpressionType, + input: { + id: args.id, + filters: getQueryFilters(filters), + timeRange: args.timerange || defaultTimeRange, + title: args.title ? args.title : undefined, + disableTriggers: true, + }, + embeddableType: EmbeddableTypes.lens, + }; + }, + }; +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss new file mode 100644 index 0000000000000..04f2f393d1e80 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss @@ -0,0 +1,33 @@ +.canvasEmbeddable { + .embPanel { + border: none; + background: none; + + .embPanel__title { + margin-bottom: $euiSizeXS; + } + + .embPanel__optionsMenuButton { + border-radius: $euiBorderRadius; + } + + .canvas-isFullscreen & { + .embPanel__optionsMenuButton { + opacity: 0; + } + + &:focus .embPanel__optionsMenuButton, + &:hover .embPanel__optionsMenuButton { + opacity: 1; + } + } + } + + .euiTable { + background: none; + } + + .lnsExpressionRenderer { + @include euiScrollBar; + } +} \ No newline at end of file diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx index 549e69e57e921..d91e70e43bfd5 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx @@ -18,11 +18,12 @@ import { start } from '../../../../../../../src/legacy/core_plugins/embeddable_a import { EmbeddableExpression } from '../../expression_types/embeddable'; import { RendererStrings } from '../../../i18n'; import { getSavedObjectFinder } from '../../../../../../../src/plugins/saved_objects/public'; - -const { embeddable: strings } = RendererStrings; import { embeddableInputToExpression } from './embeddable_input_to_expression'; import { EmbeddableInput } from '../../expression_types'; import { RendererHandlers } from '../../../types'; +import { CANVAS_EMBEDDABLE_CLASSNAME } from '../../../common/lib'; + +const { embeddable: strings } = RendererStrings; const embeddablesRegistry: { [key: string]: IEmbeddable; @@ -31,7 +32,7 @@ const embeddablesRegistry: { const renderEmbeddable = (embeddableObject: IEmbeddable, domNode: HTMLElement) => { return (
diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts index 8694c0e2c7f9f..4c622b0c247fa 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts @@ -7,12 +7,17 @@ jest.mock('ui/new_platform'); import { embeddableInputToExpression } from './embeddable_input_to_expression'; import { SavedMapInput } from '../../functions/common/saved_map'; +import { SavedLensInput } from '../../functions/common/saved_lens'; import { EmbeddableTypes } from '../../expression_types'; import { fromExpression, Ast } from '@kbn/interpreter/common'; -const baseSavedMapInput = { +const baseEmbeddableInput = { id: 'embeddableId', filters: [], +}; + +const baseSavedMapInput = { + ...baseEmbeddableInput, isLayerTOCOpen: false, refreshConfig: { isPaused: true, @@ -73,4 +78,45 @@ describe('input to expression', () => { expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); }); }); + + describe('Lens Embeddable', () => { + it('converts to a savedLens expression', () => { + const input: SavedLensInput = { + ...baseEmbeddableInput, + }; + + const expression = embeddableInputToExpression(input, EmbeddableTypes.lens); + const ast = fromExpression(expression); + + expect(ast.type).toBe('expression'); + expect(ast.chain[0].function).toBe('savedLens'); + + expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); + + expect(ast.chain[0].arguments).not.toHaveProperty('title'); + expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); + }); + + it('includes optional input values', () => { + const input: SavedLensInput = { + ...baseEmbeddableInput, + title: 'title', + timeRange: { + from: 'now-1h', + to: 'now', + }, + }; + + const expression = embeddableInputToExpression(input, EmbeddableTypes.map); + const ast = fromExpression(expression); + + expect(ast.chain[0].arguments).toHaveProperty('title', [input.title]); + expect(ast.chain[0].arguments).toHaveProperty('timerange'); + + const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; + expect(timerangeExpression.chain[0].function).toBe('timerange'); + expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); + expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); + }); + }); }); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts index a3cb53acebed2..6428507b16a0c 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts @@ -6,6 +6,7 @@ import { EmbeddableTypes, EmbeddableInput } from '../../expression_types'; import { SavedMapInput } from '../../functions/common/saved_map'; +import { SavedLensInput } from '../../functions/common/saved_lens'; /* Take the input from an embeddable and the type of embeddable and convert it into an expression @@ -46,5 +47,23 @@ export function embeddableInputToExpression( } } + if (embeddableType === EmbeddableTypes.lens) { + const lensInput = input as SavedLensInput; + + expressionParts.push('savedLens'); + + expressionParts.push(`id="${input.id}"`); + + if (input.title) { + expressionParts.push(`title="${input.title}"`); + } + + if (lensInput.timeRange) { + expressionParts.push( + `timerange={timerange from="${lensInput.timeRange.from}" to="${lensInput.timeRange.to}"}` + ); + } + } + return expressionParts.join(' '); } diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js index 193d99e1c9533..faadfd4bb26d7 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js @@ -21,6 +21,6 @@ export const demodata = () => ({ name: 'demodata', displayName: strings.getDisplayName(), help: strings.getHelp(), - image: 'logoElasticStack', + image: 'training', template: templateFromReactComponent(DemodataDatasource), }); diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/datasources/esdocs.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/esdocs.js similarity index 58% rename from x-pack/legacy/plugins/canvas/public/expression_types/datasources/esdocs.js rename to x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/esdocs.js index eacb7e891b482..282ec17e94c9b 100644 --- a/x-pack/legacy/plugins/canvas/public/expression_types/datasources/esdocs.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/esdocs.js @@ -6,15 +6,24 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { EuiFormRow, EuiSelect, EuiTextArea, EuiCallOut, EuiSpacer } from '@elastic/eui'; -import { getSimpleArg, setSimpleArg } from '../../lib/arg_helpers'; -import { ESFieldsSelect } from '../../components/es_fields_select'; -import { ESFieldSelect } from '../../components/es_field_select'; -import { ESIndexSelect } from '../../components/es_index_select'; -import { templateFromReactComponent } from '../../lib/template_from_react_component'; -import { ExpressionDataSourceStrings } from '../../../i18n'; - -const { ESDocs: strings } = ExpressionDataSourceStrings; +import { + EuiFormRow, + EuiAccordion, + EuiSelect, + EuiTextArea, + EuiCallOut, + EuiSpacer, + EuiLink, + EuiText, +} from '@elastic/eui'; +import { getSimpleArg, setSimpleArg } from '../../../public/lib/arg_helpers'; +import { ESFieldsSelect } from '../../../public/components/es_fields_select'; +import { ESFieldSelect } from '../../../public/components/es_field_select'; +import { ESIndexSelect } from '../../../public/components/es_index_select'; +import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; +import { DataSourceStrings, LUCENE_QUERY_URL } from '../../../i18n'; + +const { ESDocs: strings } = DataSourceStrings; const EsdocsDatasource = ({ args, updateArgs, defaultIndex }) => { const setArg = (name, value) => { @@ -74,12 +83,6 @@ const EsdocsDatasource = ({ args, updateArgs, defaultIndex }) => { return (
- -

{strings.getWarning()}

-
- - - { setArg('index', index)} /> - - setArg(getArgName(), e.target.value)} - compressed - /> - - { /> - + - setArg('sort', [field, sortOrder].join(', '))} - /> - + + + setArg('sort', [field, sortOrder].join(', '))} + /> + + + + setArg('sort', [sortField, e.target.value].join(', '))} + options={sortOptions} + compressed + /> + + + + + {strings.getQueryLabel()} + + + } + display="rowCompressed" + > + setArg(getArgName(), e.target.value)} + compressed + /> + + - - setArg('sort', [sortField, e.target.value].join(', '))} - options={sortOptions} - compressed - /> - + + + +

{strings.getWarning()}

+
); }; @@ -150,6 +165,6 @@ export const esdocs = () => ({ name: 'esdocs', displayName: strings.getDisplayName(), help: strings.getHelp(), - image: 'logoElasticsearch', + image: 'documents', template: templateFromReactComponent(EsdocsDatasource), }); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/essql.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/essql.js index 707f2305e1368..44e335dd7b41f 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/essql.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/essql.js @@ -95,7 +95,6 @@ export const essql = () => ({ name: 'essql', displayName: strings.getDisplayName(), help: strings.getHelp(), - // Replace this with a SQL logo when we have one in EUI - image: 'logoElasticsearch', + image: 'database', template: templateFromReactComponent(EssqlDatasource), }); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/index.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/index.js index 13aa2a06306a0..5bddf1d3f4b6b 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/index.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/index.js @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { demodata } from './demodata'; import { essql } from './essql'; +import { esdocs } from './esdocs'; +import { demodata } from './demodata'; import { timelion } from './timelion'; -export const datasourceSpecs = [demodata, essql, timelion]; +export const datasourceSpecs = [essql, esdocs, demodata, timelion]; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/timelion.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/timelion.js index b30e43c1c3c57..b36f1a747f120 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/timelion.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/datasources/timelion.js @@ -13,12 +13,13 @@ import { EuiSpacer, EuiCode, EuiTextArea, + EuiText, + EuiLink, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { getSimpleArg, setSimpleArg } from '../../../public/lib/arg_helpers'; import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; -import { DataSourceStrings, TIMELION, CANVAS } from '../../../i18n'; -import { TooltipIcon } from '../../../public/components/tooltip_icon'; +import { DataSourceStrings, TIMELION_QUERY_URL, TIMELION, CANVAS } from '../../../i18n'; const { Timelion: strings } = DataSourceStrings; @@ -86,8 +87,14 @@ const TimelionDatasource = ({ args, updateArgs, defaultIndex }) => { } + labelAppend={ + + + {strings.queryLabel()} + + + } + display="rowCompressed" > { rows={15} /> + { // TODO: Time timelion interval picker should be a drop down } @@ -124,6 +132,6 @@ export const timelion = () => ({ name: 'timelion', displayName: TIMELION, help: strings.getHelp(), - image: 'timelionApp', + image: 'visTimelion', template: templateFromReactComponent(TimelionDatasource), }); diff --git a/x-pack/legacy/plugins/canvas/common/lib/constants.ts b/x-pack/legacy/plugins/canvas/common/lib/constants.ts index 40e143b9ec589..ac8e80b8d7b89 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/constants.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/constants.ts @@ -39,3 +39,4 @@ export const API_ROUTE_SHAREABLE_BASE = '/public/canvas'; export const API_ROUTE_SHAREABLE_ZIP = '/public/canvas/zip'; export const API_ROUTE_SHAREABLE_RUNTIME = '/public/canvas/runtime'; export const API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD = `/public/canvas/${SHAREABLE_RUNTIME_NAME}.js`; +export const CANVAS_EMBEDDABLE_CLASSNAME = `canvasEmbeddable`; diff --git a/x-pack/legacy/plugins/canvas/i18n/constants.ts b/x-pack/legacy/plugins/canvas/i18n/constants.ts index 4cb05b0426fa1..099effc697fc5 100644 --- a/x-pack/legacy/plugins/canvas/i18n/constants.ts +++ b/x-pack/legacy/plugins/canvas/i18n/constants.ts @@ -25,6 +25,7 @@ export const JS = 'JavaScript'; export const JSON = 'JSON'; export const KIBANA = 'Kibana'; export const LUCENE = 'Lucene'; +export const LUCENE_QUERY_URL = 'https://www.elastic.co/guide/en/kibana/current/lucene-query.html'; export const MARKDOWN = 'Markdown'; export const MOMENTJS = 'MomentJS'; export const MOMENTJS_TIMEZONE_URL = 'https://momentjs.com/timezone/'; @@ -37,6 +38,7 @@ export const SQL_URL = 'https://www.elastic.co/guide/en/elasticsearch/reference/current/sql-spec.html'; export const SVG = 'SVG'; export const TIMELION = 'Timelion'; +export const TIMELION_QUERY_URL = 'https://www.elastic.co/blog/timelion-tutorial-from-zero-to-hero'; export const TINYMATH = '`TinyMath`'; export const TINYMATH_URL = 'https://www.elastic.co/guide/en/kibana/current/canvas-tinymath-functions.html'; diff --git a/x-pack/legacy/plugins/canvas/i18n/expression_types.ts b/x-pack/legacy/plugins/canvas/i18n/expression_types.ts index bdd190f26c97a..5d3a3cd742bb4 100644 --- a/x-pack/legacy/plugins/canvas/i18n/expression_types.ts +++ b/x-pack/legacy/plugins/canvas/i18n/expression_types.ts @@ -5,7 +5,6 @@ */ import { i18n } from '@kbn/i18n'; -import { LUCENE, ELASTICSEARCH } from './constants'; export const ArgTypesStrings = { Color: { @@ -143,86 +142,3 @@ export const ArgTypesStrings = { }), }, }; - -export const ExpressionDataSourceStrings = { - ESDocs: { - getDisplayName: () => - i18n.translate('xpack.canvas.expressionTypes.datasources.esdocsTitle', { - defaultMessage: 'Elasticsearch raw documents', - }), - getHelp: () => - i18n.translate('xpack.canvas.expressionTypes.datasources.esdocsLabel', { - defaultMessage: 'Pull back raw documents from elasticsearch', - }), - getWarningTitle: () => - i18n.translate('xpack.canvas.expressionTypes.datasources.esdocs.warningTitle', { - defaultMessage: 'Query with caution', - }), - getWarning: () => - i18n.translate('xpack.canvas.expressionTypes.datasources.esdocs.warningDescription', { - defaultMessage: ` - This datasource pulls directly from {elasticsearch} - without the use of aggregations. It is best used with low volume datasets and in - situations where you need to view raw documents or plot exact, non-aggregated values on a - chart.`, - values: { - elasticsearch: ELASTICSEARCH, - }, - }), - getIndexTitle: () => - i18n.translate('xpack.canvas.expressionTypes.datasources.esdocs.indexTitle', { - defaultMessage: 'Index', - }), - getIndexLabel: () => - i18n.translate('xpack.canvas.expressionTypes.datasources.esdocs.indexLabel', { - defaultMessage: 'Enter an index name or select an index pattern', - }), - getQueryTitle: () => - i18n.translate('xpack.canvas.expressionTypes.datasources.esdocs.queryTitle', { - defaultMessage: 'Query', - }), - getQueryLabel: () => - i18n.translate('xpack.canvas.expressionTypes.datasources.esdocs.queryLabel', { - defaultMessage: '{lucene} query string syntax', - values: { - lucene: LUCENE, - }, - }), - getSortFieldTitle: () => - i18n.translate('xpack.canvas.expressionTypes.datasources.esdocs.sortFieldTitle', { - defaultMessage: 'Sort Field', - }), - getSortFieldLabel: () => - i18n.translate('xpack.canvas.expressionTypes.datasources.esdocs.sortFieldLabel', { - defaultMessage: 'Document sort field', - }), - getSortOrderTitle: () => - i18n.translate('xpack.canvas.expressionTypes.datasources.esdocs.sortOrderTitle', { - defaultMessage: 'Sort Order', - }), - getSortOrderLabel: () => - i18n.translate('xpack.canvas.expressionTypes.datasources.esdocs.sortOrderLabel', { - defaultMessage: 'Document sort order', - }), - getFieldsTitle: () => - i18n.translate('xpack.canvas.expressionTypes.datasources.esdocs.fieldsTitle', { - defaultMessage: 'Fields', - }), - getFieldsLabel: () => - i18n.translate('xpack.canvas.expressionTypes.datasources.esdocs.fieldsLabel', { - defaultMessage: 'The fields to extract. Kibana scripted fields are not currently available', - }), - getFieldsWarningLabel: () => - i18n.translate('xpack.canvas.expressionTypes.datasources.esdocs.fieldsWarningLabel', { - defaultMessage: 'This datasource performs best with 10 or fewer fields', - }), - getAscendingOption: () => - i18n.translate('xpack.canvas.expressionTypes.datasources.esdocs.ascendingDropDown', { - defaultMessage: 'Ascending', - }), - getDescendingOption: () => - i18n.translate('xpack.canvas.expressionTypes.datasources.esdocs.descendingDropDown', { - defaultMessage: 'Descending', - }), - }, -}; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/demodata.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/demodata.ts index 20c7a88ea4f4d..caedbfdec5be4 100644 --- a/x-pack/legacy/plugins/canvas/i18n/functions/dict/demodata.ts +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/demodata.ts @@ -13,7 +13,7 @@ import { DemoRows } from '../../../canvas_plugin_src/functions/server/demodata/g export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.demodataHelpText', { defaultMessage: - 'A mock data set that includes project {ci} times with usernames, countries, and run phases.', + 'A sample data set that includes project {ci} times with usernames, countries, and run phases.', values: { ci: 'CI', }, diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_lens.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_lens.ts new file mode 100644 index 0000000000000..1efcbc9d3a18e --- /dev/null +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_lens.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { savedLens } from '../../../canvas_plugin_src/functions/common/saved_lens'; +import { FunctionHelp } from '../function_help'; +import { FunctionFactory } from '../../../types'; + +export const help: FunctionHelp> = { + help: i18n.translate('xpack.canvas.functions.savedLensHelpText', { + defaultMessage: `Returns an embeddable for a saved lens object`, + }), + args: { + id: i18n.translate('xpack.canvas.functions.savedLens.args.idHelpText', { + defaultMessage: `The ID of the Saved Lens Object`, + }), + timerange: i18n.translate('xpack.canvas.functions.savedLens.args.timerangeHelpText', { + defaultMessage: `The timerange of data that should be included`, + }), + title: i18n.translate('xpack.canvas.functions.savedLens.args.titleHelpText', { + defaultMessage: `The title for the lens emebeddable`, + }), + }, +}; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts b/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts index dbdadd09df67f..e7d7b4ca4321b 100644 --- a/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts +++ b/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts @@ -62,6 +62,7 @@ import { help as replace } from './dict/replace'; import { help as revealImage } from './dict/reveal_image'; import { help as rounddate } from './dict/rounddate'; import { help as rowCount } from './dict/row_count'; +import { help as savedLens } from './dict/saved_lens'; import { help as savedMap } from './dict/saved_map'; import { help as savedSearch } from './dict/saved_search'; import { help as savedVisualization } from './dict/saved_visualization'; @@ -216,6 +217,7 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ revealImage, rounddate, rowCount, + savedLens, savedMap, savedSearch, savedVisualization, diff --git a/x-pack/legacy/plugins/canvas/i18n/ui.ts b/x-pack/legacy/plugins/canvas/i18n/ui.ts index 5b94cb0435b31..1abe56c99dc89 100644 --- a/x-pack/legacy/plugins/canvas/i18n/ui.ts +++ b/x-pack/legacy/plugins/canvas/i18n/ui.ts @@ -308,6 +308,7 @@ export const ArgumentStrings = { }; export const DataSourceStrings = { + // Demo data source DemoData: { getDisplayName: () => i18n.translate('xpack.canvas.uis.dataSources.demoDataTitle', { @@ -319,7 +320,7 @@ export const DataSourceStrings = { }), getHelp: () => i18n.translate('xpack.canvas.uis.dataSources.demoDataLabel', { - defaultMessage: 'Mock data set with usernames, prices, projects, countries, and phases', + defaultMessage: 'Sample data set used to populate default elements', }), getDescription: () => i18n.translate('xpack.canvas.uis.dataSources.demoDataDescription', { @@ -330,6 +331,88 @@ export const DataSourceStrings = { }, }), }, + // Elasticsearch documents datasource + ESDocs: { + getDisplayName: () => + i18n.translate('xpack.canvas.uis.dataSources.esdocsTitle', { + defaultMessage: '{elasticsearch} documents', + values: { + elasticsearch: ELASTICSEARCH, + }, + }), + getHelp: () => + i18n.translate('xpack.canvas.uis.dataSources.esdocsLabel', { + defaultMessage: 'Pull data directly from {elasticsearch} without the use of aggregations', + values: { + elasticsearch: ELASTICSEARCH, + }, + }), + getWarningTitle: () => + i18n.translate('xpack.canvas.uis.dataSources.esdocs.warningTitle', { + defaultMessage: 'Query with caution', + }), + getWarning: () => + i18n.translate('xpack.canvas.uis.dataSources.esdocs.warningDescription', { + defaultMessage: ` + Using this data source with larger data sets can result in slower performance. Use this source only when you need exact values.`, + }), + getIndexTitle: () => + i18n.translate('xpack.canvas.uis.dataSources.esdocs.indexTitle', { + defaultMessage: 'Index', + }), + getIndexLabel: () => + i18n.translate('xpack.canvas.uis.dataSources.esdocs.indexLabel', { + defaultMessage: 'Enter an index name or select an index pattern', + }), + getQueryTitle: () => + i18n.translate('xpack.canvas.uis.dataSources.esdocs.queryTitle', { + defaultMessage: 'Query', + }), + getQueryLabel: () => + i18n.translate('xpack.canvas.uis.dataSources.esdocs.queryLabel', { + defaultMessage: '{lucene} query string syntax', + values: { + lucene: LUCENE, + }, + }), + getSortFieldTitle: () => + i18n.translate('xpack.canvas.uis.dataSources.esdocs.sortFieldTitle', { + defaultMessage: 'Sort field', + }), + getSortFieldLabel: () => + i18n.translate('xpack.canvas.uis.dataSources.esdocs.sortFieldLabel', { + defaultMessage: 'Document sort field', + }), + getSortOrderTitle: () => + i18n.translate('xpack.canvas.uis.dataSources.esdocs.sortOrderTitle', { + defaultMessage: 'Sort order', + }), + getSortOrderLabel: () => + i18n.translate('xpack.canvas.uis.dataSources.esdocs.sortOrderLabel', { + defaultMessage: 'Document sort order', + }), + getFieldsTitle: () => + i18n.translate('xpack.canvas.uis.dataSources.esdocs.fieldsTitle', { + defaultMessage: 'Fields', + }), + getFieldsLabel: () => + i18n.translate('xpack.canvas.uis.dataSources.esdocs.fieldsLabel', { + defaultMessage: 'Scripted fields are unavailable', + }), + getFieldsWarningLabel: () => + i18n.translate('xpack.canvas.uis.dataSources.esdocs.fieldsWarningLabel', { + defaultMessage: 'This datasource performs best with 10 or fewer fields', + }), + getAscendingOption: () => + i18n.translate('xpack.canvas.uis.dataSources.esdocs.ascendingDropDown', { + defaultMessage: 'Ascending', + }), + getDescendingOption: () => + i18n.translate('xpack.canvas.uis.dataSources.esdocs.descendingDropDown', { + defaultMessage: 'Descending', + }), + }, + // Elasticsearch SQL data source Essql: { getDisplayName: () => i18n.translate('xpack.canvas.uis.dataSources.essqlTitle', { @@ -341,7 +424,7 @@ export const DataSourceStrings = { }), getHelp: () => i18n.translate('xpack.canvas.uis.dataSources.essqlLabel', { - defaultMessage: 'Use {elasticsearch} {sql} to get a data table', + defaultMessage: 'Write an {elasticsearch} {sql} query to retrieve data', values: { elasticsearch: ELASTICSEARCH, sql: SQL, @@ -353,18 +436,18 @@ export const DataSourceStrings = { }), getLabelAppend: () => i18n.translate('xpack.canvas.uis.dataSources.essql.queryTitleAppend', { - defaultMessage: 'Learn {elasticsearchShort} {sql} syntax', + defaultMessage: 'Learn {elasticsearchShort} {sql} query syntax', values: { elasticsearchShort: ELASTICSEARCH_SHORT, sql: SQL, }, }), }, + // Timelion datasource Timelion: { getAbout: () => i18n.translate('xpack.canvas.uis.dataSources.timelion.aboutDetail', { - defaultMessage: - 'Use {timelion} queries to pull back timeseries data that can be used with {canvas} elements.', + defaultMessage: 'Use {timelion} syntax in {canvas} to retrieve timeseries data', values: { timelion: TIMELION, canvas: CANVAS, @@ -372,7 +455,7 @@ export const DataSourceStrings = { }), getHelp: () => i18n.translate('xpack.canvas.uis.dataSources.timelionLabel', { - defaultMessage: 'Use {timelion} syntax to retrieve a timeseries', + defaultMessage: 'Use {timelion} syntax to retrieve timeseries data', values: { timelion: TIMELION, }, @@ -392,11 +475,11 @@ export const DataSourceStrings = { i18n.translate('xpack.canvas.uis.dataSources.timelion.intervalTitle', { defaultMessage: 'Interval', }), - getQueryHelp: () => + queryLabel: () => i18n.translate('xpack.canvas.uis.dataSources.timelion.queryLabel', { - defaultMessage: '{lucene} Query String syntax', + defaultMessage: '{timelion} Query String syntax', values: { - lucene: LUCENE, + timelion: TIMELION, }, }), getQueryLabel: () => diff --git a/x-pack/legacy/plugins/canvas/public/components/datasource/datasource.scss b/x-pack/legacy/plugins/canvas/public/components/datasource/datasource.scss index 2407dcbbce593..52c473ac2dd38 100644 --- a/x-pack/legacy/plugins/canvas/public/components/datasource/datasource.scss +++ b/x-pack/legacy/plugins/canvas/public/components/datasource/datasource.scss @@ -6,8 +6,13 @@ padding: 0 $euiSizeS; } -.canvasDataSource__section { - padding: $euiSizeM; +.canvasDataSource__section, +.canvasDataSource__list { + padding: $euiSizeM $euiSizeM 0; +} + +.canvasDataSource__sectionFooter { + padding: 0 $euiSizeM; } .canvasDataSource__triggerButton { @@ -19,10 +24,6 @@ margin-right: $euiSizeS; } -.canvasDataSource__list { - padding: $euiSizeM; -} - .canvasDataSource__card .euiCard__content { padding-top: 0 !important; // sass-lint:disable-line no-important } diff --git a/x-pack/legacy/plugins/canvas/public/components/datasource/datasource_component.js b/x-pack/legacy/plugins/canvas/public/components/datasource/datasource_component.js index 8b0061e047f33..285b69f057cd8 100644 --- a/x-pack/legacy/plugins/canvas/public/components/datasource/datasource_component.js +++ b/x-pack/legacy/plugins/canvas/public/components/datasource/datasource_component.js @@ -153,7 +153,7 @@ export class DatasourceComponent extends PureComponent { flush="left" size="s" > - + {stateDatasource.displayName} diff --git a/x-pack/legacy/plugins/canvas/public/components/datasource/datasource_selector.js b/x-pack/legacy/plugins/canvas/public/components/datasource/datasource_selector.js index 92f9b92cb1f06..153a8a7ef75e6 100644 --- a/x-pack/legacy/plugins/canvas/public/components/datasource/datasource_selector.js +++ b/x-pack/legacy/plugins/canvas/public/components/datasource/datasource_selector.js @@ -15,6 +15,7 @@ export const DatasourceSelector = ({ onSelect, datasources, current }) => ( key={d.name} title={d.displayName} titleElement="h5" + titleSize="xs" icon={} description={d.help} layout="horizontal" diff --git a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx index 565ca5fa5bbd6..353a59397d6b6 100644 --- a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx @@ -21,6 +21,9 @@ const allowedEmbeddables = { [EmbeddableTypes.map]: (id: string) => { return `savedMap id="${id}" | render`; }, + [EmbeddableTypes.lens]: (id: string) => { + return `savedLens id="${id}" | render`; + }, // FIX: Only currently allow Map embeddables /* [EmbeddableTypes.visualization]: (id: string) => { return `filters | savedVisualization id="${id}" | render`; diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js b/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js index b775524acf639..2500a412c0fac 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js @@ -19,6 +19,7 @@ import { } from '../../../state/actions/elements'; import { selectToplevelNodes } from '../../../state/actions/transient'; import { crawlTree, globalStateUpdater, shapesForNodes } from '../integration_utils'; +import { CANVAS_EMBEDDABLE_CLASSNAME } from '../../../../common/lib'; import { InteractiveWorkpadPage as InteractiveComponent } from './interactive_workpad_page'; import { eventHandlers } from './event_handlers'; @@ -79,9 +80,14 @@ const isEmbeddableBody = element => { const hasClosest = typeof element.closest === 'function'; if (hasClosest) { - return element.closest('.embeddable') && !element.closest('.embPanel__header'); + return ( + element.closest(`.${CANVAS_EMBEDDABLE_CLASSNAME}`) && !element.closest('.embPanel__header') + ); } else { - return closest.call(element, '.embeddable') && !closest.call(element, '.embPanel__header'); + return ( + closest.call(element, `.${CANVAS_EMBEDDABLE_CLASSNAME}`) && + !closest.call(element, '.embPanel__header') + ); } }; diff --git a/x-pack/legacy/plugins/canvas/public/lib/find_expression_type.js b/x-pack/legacy/plugins/canvas/public/lib/find_expression_type.js index d6d395feade8b..2cd7c5efb74e9 100644 --- a/x-pack/legacy/plugins/canvas/public/lib/find_expression_type.js +++ b/x-pack/legacy/plugins/canvas/public/lib/find_expression_type.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { datasourceRegistry } from '../expression_types/datasource'; +//import { datasourceRegistry } from '../expression_types/datasource'; import { transformRegistry } from '../expression_types/transform'; import { modelRegistry } from '../expression_types/model'; import { viewRegistry } from '../expression_types/view'; @@ -28,9 +28,6 @@ export function findExpressionType(name, type) { case 'transform': expression = transformRegistry.get(name); return !expression ? acc : acc.concat(expression); - case 'datasource': - expression = datasourceRegistry.get(name); - return !expression ? acc : acc.concat(expression); default: return acc; } diff --git a/x-pack/legacy/plugins/canvas/public/lib/load_expression_types.js b/x-pack/legacy/plugins/canvas/public/lib/load_expression_types.js index fb23f9459d30b..82699eb5b88fa 100644 --- a/x-pack/legacy/plugins/canvas/public/lib/load_expression_types.js +++ b/x-pack/legacy/plugins/canvas/public/lib/load_expression_types.js @@ -5,11 +5,9 @@ */ import { argTypeSpecs } from '../expression_types/arg_types'; -import { datasourceSpecs } from '../expression_types/datasources'; -import { argTypeRegistry, datasourceRegistry } from '../expression_types'; +import { argTypeRegistry } from '../expression_types'; export function loadExpressionTypes() { // register default args, arg types, and expression types argTypeSpecs.forEach(expFn => argTypeRegistry.register(expFn)); - datasourceSpecs.forEach(expFn => datasourceRegistry.register(expFn)); } diff --git a/x-pack/legacy/plugins/canvas/public/plugin.tsx b/x-pack/legacy/plugins/canvas/public/plugin.tsx index 0a3faca1a2522..f4a3aed28a0a4 100644 --- a/x-pack/legacy/plugins/canvas/public/plugin.tsx +++ b/x-pack/legacy/plugins/canvas/public/plugin.tsx @@ -11,8 +11,6 @@ import { initLoadingIndicator } from './lib/loading_indicator'; import { featureCatalogueEntry } from './feature_catalogue_entry'; import { ExpressionsSetup, ExpressionsStart } from '../../../../../src/plugins/expressions/public'; // @ts-ignore untyped local -import { datasourceSpecs } from './expression_types/datasources'; -// @ts-ignore untyped local import { argTypeSpecs } from './expression_types/arg_types'; import { transitions } from './transitions'; import { legacyRegistries } from './legacy_plugin_support'; @@ -90,7 +88,6 @@ export class CanvasPlugin // Register core canvas stuff canvasApi.addFunctions(initFunctions({ typesRegistry: plugins.expressions.__LEGACY.types })); - canvasApi.addDatasourceUIs(datasourceSpecs); canvasApi.addArgumentUIs(argTypeSpecs); canvasApi.addTransitions(transitions); diff --git a/x-pack/legacy/plugins/canvas/public/style/index.scss b/x-pack/legacy/plugins/canvas/public/style/index.scss index 4b85620863692..39e5903ff1d96 100644 --- a/x-pack/legacy/plugins/canvas/public/style/index.scss +++ b/x-pack/legacy/plugins/canvas/public/style/index.scss @@ -61,6 +61,7 @@ @import '../../canvas_plugin_src/renderers/advanced_filter/component/advanced_filter.scss'; @import '../../canvas_plugin_src/renderers/dropdown_filter/component/dropdown_filter.scss'; +@import '../../canvas_plugin_src/renderers/embeddable/embeddable.scss'; @import '../../canvas_plugin_src/renderers/plot/plot.scss'; @import '../../canvas_plugin_src/renderers/reveal_image/reveal_image.scss'; @import '../../canvas_plugin_src/renderers/time_filter/components/datetime_calendar/datetime_calendar.scss'; diff --git a/x-pack/legacy/plugins/canvas/shareable_runtime/webpack.config.js b/x-pack/legacy/plugins/canvas/shareable_runtime/webpack.config.js index 0ce722eb90d43..66b0a7bc558cb 100644 --- a/x-pack/legacy/plugins/canvas/shareable_runtime/webpack.config.js +++ b/x-pack/legacy/plugins/canvas/shareable_runtime/webpack.config.js @@ -6,6 +6,7 @@ const path = require('path'); const webpack = require('webpack'); +const { stringifyRequest } = require('loader-utils'); // eslint-disable-line const { KIBANA_ROOT, @@ -140,19 +141,63 @@ module.exports = { }, { test: /\.scss$/, - exclude: /\.module.(s(a|c)ss)$/, + exclude: [/node_modules/, /\.module\.s(a|c)ss$/], use: [ - { loader: 'style-loader' }, - { loader: 'css-loader', options: { importLoaders: 2 } }, + { + loader: 'style-loader', + }, + { + loader: 'css-loader', + options: { + sourceMap: !isProd, + }, + }, { loader: 'postcss-loader', options: { + sourceMap: !isProd, config: { - path: require.resolve('./postcss.config.js'), + path: require.resolve('./postcss.config'), + }, + }, + }, + { + loader: 'resolve-url-loader', + options: { + // eslint-disable-next-line no-unused-vars + join: (_, __) => (uri, base) => { + if (!base) { + return null; + } + + // manually force ui/* urls in legacy styles to resolve to ui/legacy/public + if (uri.startsWith('ui/') && base.split(path.sep).includes('legacy')) { + return path.resolve(KIBANA_ROOT, 'src/legacy/ui/public', uri.replace('ui/', '')); + } + + return null; + }, + }, + }, + { + loader: 'sass-loader', + options: { + // must always be enabled as long as we're using the `resolve-url-loader` to + // rewrite `ui/*` urls. They're dropped by subsequent loaders though + sourceMap: true, + prependData(loaderContext) { + return `@import ${stringifyRequest( + loaderContext, + path.resolve(KIBANA_ROOT, 'src/legacy/ui/public/styles/_styling_constants.scss') + )};\n`; + }, + webpackImporter: false, + sassOptions: { + outputStyle: 'nested', + includePaths: [path.resolve(KIBANA_ROOT, 'node_modules')], }, }, }, - { loader: 'sass-loader' }, ], }, { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_add.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_add.test.js index 06a7c2f1ec45e..2be00e70f6f84 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_add.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_add.test.js @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import '../../public/np_ready/app/services/breadcrumbs.mock'; import { setupEnvironment, pageHelpers, nextTick, getRandomString } from './helpers'; import { indexPatterns } from '../../../../../../src/plugins/data/public'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_edit.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_edit.test.js index 04e80deaf8276..abc3e5dc9def2 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_edit.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_edit.test.js @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AutoFollowPatternForm } from '../../public/app/components/auto_follow_pattern_form'; +import '../../public/np_ready/app/services/breadcrumbs.mock'; +import { AutoFollowPatternForm } from '../../public/np_ready/app/components/auto_follow_pattern_form'; import { setupEnvironment, pageHelpers, nextTick } from './helpers'; import { AUTO_FOLLOW_PATTERN_EDIT } from './helpers/constants'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_list.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_list.test.js index 88d8f98b973bd..20e982856dc19 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_list.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_list.test.js @@ -4,21 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../public/np_ready/app/services/breadcrumbs.mock'; import { setupEnvironment, pageHelpers, nextTick, getRandomString } from './helpers'; import { getAutoFollowPatternClientMock } from '../../fixtures/auto_follow_pattern'; jest.mock('ui/new_platform'); -jest.mock('ui/chrome', () => ({ - addBasePath: () => 'api/cross_cluster_replication', - breadcrumbs: { set: () => {} }, - getUiSettingsClient: () => ({ - get: x => x, - getUpdate$: () => ({ subscribe: jest.fn() }), - }), -})); - const { setup } = pageHelpers.autoFollowPatternList; describe('', () => { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_add.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_add.test.js index 8d4523ca26de2..7680be9d858a4 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_add.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_add.test.js @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../public/np_ready/app/services/breadcrumbs.mock'; import { setupEnvironment, pageHelpers, nextTick } from './helpers'; -import { RemoteClustersFormField } from '../../public/app/components'; +import { RemoteClustersFormField } from '../../public/np_ready/app/components'; import { indexPatterns } from '../../../../../../src/plugins/data/public'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js index 5e2810ae882fb..cfa37ff2e0358 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../public/np_ready/app/services/breadcrumbs.mock'; import { setupEnvironment, pageHelpers, nextTick } from './helpers'; -import { FollowerIndexForm } from '../../public/app/components/follower_index_form/follower_index_form'; +import { FollowerIndexForm } from '../../public/np_ready/app/components/follower_index_form/follower_index_form'; import { FOLLOWER_INDEX_EDIT } from './helpers/constants'; jest.mock('ui/new_platform'); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_indices_list.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_indices_list.test.js index 9fd5756a7febf..dde31d1d166f9 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_indices_list.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_indices_list.test.js @@ -10,15 +10,6 @@ import { getFollowerIndexMock } from '../../fixtures/follower_index'; jest.mock('ui/new_platform'); -jest.mock('ui/chrome', () => ({ - addBasePath: () => 'api/cross_cluster_replication', - breadcrumbs: { set: () => {} }, - getUiSettingsClient: () => ({ - get: x => x, - getUpdate$: () => ({ subscribe: jest.fn() }), - }), -})); - const { setup } = pageHelpers.followerIndexList; describe('', () => { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js index 3eb195bac7ed1..1f64e589bc4c1 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed } from '../../../../../../test_utils'; -import { AutoFollowPatternAdd } from '../../../public/app/sections/auto_follow_pattern_add'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { AutoFollowPatternAdd } from '../../../public/np_ready/app/sections/auto_follow_pattern_add'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; const testBedConfig = { store: ccrStore, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js index 94a94554b9105..2b110c6552072 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed } from '../../../../../../test_utils'; -import { AutoFollowPatternEdit } from '../../../public/app/sections/auto_follow_pattern_edit'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { AutoFollowPatternEdit } from '../../../public/np_ready/app/sections/auto_follow_pattern_edit'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; import { AUTO_FOLLOW_PATTERN_EDIT_NAME } from './constants'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js index c0d29e8af2549..1d3e8ad6dff83 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed, findTestSubject } from '../../../../../../test_utils'; -import { AutoFollowPatternList } from '../../../public/app/sections/home/auto_follow_pattern_list'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { AutoFollowPatternList } from '../../../public/np_ready/app/sections/home/auto_follow_pattern_list'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; const testBedConfig = { store: ccrStore, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_add.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_add.helpers.js index 785330049cb0c..f74baa1b2ad0a 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_add.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_add.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed } from '../../../../../../test_utils'; -import { FollowerIndexAdd } from '../../../public/app/sections/follower_index_add'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { FollowerIndexAdd } from '../../../public/np_ready/app/sections/follower_index_add'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; const testBedConfig = { store: ccrStore, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_edit.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_edit.helpers.js index 56cbe5b47229c..47f8539bb593b 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_edit.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_edit.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed } from '../../../../../../test_utils'; -import { FollowerIndexEdit } from '../../../public/app/sections/follower_index_edit'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { FollowerIndexEdit } from '../../../public/np_ready/app/sections/follower_index_edit'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; import { FOLLOWER_INDEX_EDIT_NAME } from './constants'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_list.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_list.helpers.js index 02b64cd7f306c..2154e11e17b1f 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_list.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_list.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed, findTestSubject } from '../../../../../../test_utils'; -import { FollowerIndicesList } from '../../../public/app/sections/home/follower_indices_list'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { FollowerIndicesList } from '../../../public/np_ready/app/sections/home/follower_indices_list'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; const testBedConfig = { store: ccrStore, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/home.helpers.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/home.helpers.js index db30e4fe1dbe7..664ad909ba8e7 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/home.helpers.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/home.helpers.js @@ -5,9 +5,9 @@ */ import { registerTestBed } from '../../../../../../test_utils'; -import { CrossClusterReplicationHome } from '../../../public/app/sections/home/home'; -import { ccrStore } from '../../../public/app/store'; -import routing from '../../../public/app/services/routing'; +import { CrossClusterReplicationHome } from '../../../public/np_ready/app/sections/home/home'; +import { ccrStore } from '../../../public/np_ready/app/store'; +import routing from '../../../public/np_ready/app/services/routing'; import { BASE_PATH } from '../../../common/constants'; const testBedConfig = { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/http_requests.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/http_requests.js index 9bd88a08a5a61..e2bd54a92a1f1 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/http_requests.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/http_requests.js @@ -19,7 +19,7 @@ const registerHttpRequestMockHelpers = server => { server.respondWith( 'GET', - 'api/cross_cluster_replication/follower_indices', + '/api/cross_cluster_replication/follower_indices', mockResponse(defaultResponse, response) ); }; @@ -29,7 +29,7 @@ const registerHttpRequestMockHelpers = server => { server.respondWith( 'GET', - 'api/cross_cluster_replication/auto_follow_patterns', + '/api/cross_cluster_replication/auto_follow_patterns', mockResponse(defaultResponse, response) ); }; @@ -39,7 +39,7 @@ const registerHttpRequestMockHelpers = server => { server.respondWith( 'DELETE', - /api\/cross_cluster_replication\/auto_follow_patterns/, + /\/api\/cross_cluster_replication\/auto_follow_patterns/, mockResponse(defaultResponse, response) ); }; @@ -61,7 +61,7 @@ const registerHttpRequestMockHelpers = server => { server.respondWith( 'GET', - 'api/cross_cluster_replication/stats/auto_follow', + '/api/cross_cluster_replication/stats/auto_follow', mockResponse(defaultResponse, response) ); }; @@ -87,7 +87,7 @@ const registerHttpRequestMockHelpers = server => { server.respondWith( 'GET', - /api\/cross_cluster_replication\/auto_follow_patterns\/.+/, + /\/api\/cross_cluster_replication\/auto_follow_patterns\/.+/, mockResponse(defaultResponse, response) ); }; @@ -105,7 +105,7 @@ const registerHttpRequestMockHelpers = server => { server.respondWith( 'GET', - /api\/cross_cluster_replication\/follower_indices\/.+/, + /\/api\/cross_cluster_replication\/follower_indices\/.+/, mockResponse(defaultResponse, response) ); }; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/setup_environment.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/setup_environment.js index 8bd86067d8513..3562ad0df5b51 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/setup_environment.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/helpers/setup_environment.js @@ -7,14 +7,15 @@ import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; -import { setHttpClient } from '../../../public/app/services/api'; +import { setHttpClient } from '../../../public/np_ready/app/services/api'; import { init as initHttpRequests } from './http_requests'; export const setupEnvironment = () => { - // Mock Angular $q - const $q = { defer: () => ({ resolve() {} }) }; - // axios has a $http like interface so using it to simulate $http - setHttpClient(axios.create({ adapter: axiosXhrAdapter }), $q); + // axios has a similar interface to HttpSetup, but we + // flatten out the response. + const client = axios.create({ adapter: axiosXhrAdapter }); + client.interceptors.response.use(({ data }) => data); + setHttpClient(client); const { server, httpRequestsMockHelpers } = initHttpRequests(); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/home.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/home.test.js index 2afa9c44a7b1c..2c536d069ef53 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/home.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/home.test.js @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../public/np_ready/app/services/breadcrumbs.mock'; import { setupEnvironment, pageHelpers, nextTick } from './helpers'; jest.mock('ui/new_platform'); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/common/constants/app.js b/x-pack/legacy/plugins/cross_cluster_replication/common/constants/app.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/common/constants/app.js rename to x-pack/legacy/plugins/cross_cluster_replication/common/constants/app.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/common/constants/base_path.js b/x-pack/legacy/plugins/cross_cluster_replication/common/constants/base_path.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/common/constants/base_path.js rename to x-pack/legacy/plugins/cross_cluster_replication/common/constants/base_path.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/common/constants/index.js b/x-pack/legacy/plugins/cross_cluster_replication/common/constants/index.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/common/constants/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/common/constants/index.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/common/constants/plugin.js b/x-pack/legacy/plugins/cross_cluster_replication/common/constants/plugin.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/common/constants/plugin.js rename to x-pack/legacy/plugins/cross_cluster_replication/common/constants/plugin.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/common/constants/settings.js b/x-pack/legacy/plugins/cross_cluster_replication/common/constants/settings.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/common/constants/settings.js rename to x-pack/legacy/plugins/cross_cluster_replication/common/constants/settings.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/index.js b/x-pack/legacy/plugins/cross_cluster_replication/index.js index cdb867972fcf5..aff4cc5b56738 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/index.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/index.js @@ -6,9 +6,7 @@ import { resolve } from 'path'; import { PLUGIN } from './common/constants'; -import { registerLicenseChecker } from './server/lib/register_license_checker'; -import { registerRoutes } from './server/routes/register_routes'; -import { ccrDataEnricher } from './cross_cluster_replication_data'; +import { plugin } from './server/np_ready'; export function crossClusterReplication(kibana) { return new kibana.Plugin({ @@ -47,15 +45,13 @@ export function crossClusterReplication(kibana) { ); }, init: function initCcrPlugin(server) { - registerLicenseChecker(server); - registerRoutes(server); - if ( - server.config().get('xpack.ccr.ui.enabled') && - server.newPlatform.setup.plugins.indexManagement && - server.newPlatform.setup.plugins.indexManagement.indexDataEnricher - ) { - server.newPlatform.setup.plugins.indexManagement.indexDataEnricher.add(ccrDataEnricher); - } + plugin({}).setup(server.newPlatform.setup.core, { + indexManagement: server.newPlatform.setup.plugins.indexManagement, + __LEGACY: { + server, + ccrUIEnabled: server.config().get('xpack.ccr.ui.enabled'), + }, + }); }, }); } diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/documentation_links.js b/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/documentation_links.js deleted file mode 100644 index 585ca7e0f5cf1..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/documentation_links.js +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; - -const esBase = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; - -export const autoFollowPatternUrl = `${esBase}/ccr-put-auto-follow-pattern.html`; -export const followerIndexUrl = `${esBase}/ccr-put-follow.html`; -export const byteUnitsUrl = `${esBase}/common-options.html#byte-units`; -export const timeUnitsUrl = `${esBase}/common-options.html#time-units`; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/index.js index 4ec268f0de7f2..e92c44da34474 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/index.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/index.js @@ -5,4 +5,3 @@ */ import './register_routes'; -import './extend_index_management'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/index.scss b/x-pack/legacy/plugins/cross_cluster_replication/public/index.scss index 6f65dc04d4427..31317e16e3e9f 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/index.scss +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/index.scss @@ -10,4 +10,4 @@ // ccrChart__legend--small // ccrChart__legend-isLoading -@import 'app/app'; +@import 'np_ready/app/app'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/_app.scss b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/_app.scss similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/_app.scss rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/_app.scss diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/app.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/app.js similarity index 97% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/app.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/app.js index 31626750a7f37..968646a4bd1b0 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/app.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/app.js @@ -7,7 +7,6 @@ import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; import { Route, Switch, Redirect, withRouter } from 'react-router-dom'; -import { fatalError } from 'ui/notify'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -21,7 +20,8 @@ import { EuiTitle, } from '@elastic/eui'; -import { BASE_PATH } from '../../common/constants'; +import { BASE_PATH } from '../../../common/constants'; +import { getFatalErrors } from './services/notifications'; import { SectionError } from './components'; import routing from './services/routing'; import { loadPermissions } from './services/api'; @@ -81,7 +81,7 @@ class AppComponent extends Component { }); } catch (error) { // Expect an error in the shape provided by Angular's $http service. - if (error && error.data) { + if (error && error.body) { return this.setState({ isFetchingPermissions: false, fetchPermissionError: error, @@ -90,7 +90,7 @@ class AppComponent extends Component { // This error isn't an HTTP error, so let the fatal error screen tell the user something // unexpected happened. - fatalError( + getFatalErrors().add( error, i18n.translate('xpack.crossClusterReplication.app.checkPermissionsFatalErrorTitle', { defaultMessage: 'Cross-Cluster Replication app', diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/__snapshots__/auto_follow_pattern_form.test.js.snap b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/__snapshots__/auto_follow_pattern_form.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/__snapshots__/auto_follow_pattern_form.test.js.snap rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/__snapshots__/auto_follow_pattern_form.test.js.snap diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.container.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.container.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.container.ts rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.container.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.tsx b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.tsx similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.tsx rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.tsx diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/index.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_action_menu/index.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/index.ts rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_action_menu/index.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.d.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_delete_provider.d.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.d.ts rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_delete_provider.d.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_delete_provider.js similarity index 98% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_delete_provider.js index f9c03165dcf97..7803b329e6258 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_delete_provider.js @@ -11,7 +11,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; import { deleteAutoFollowPattern } from '../store/actions'; -import { arrify } from '../../../common/services/utils'; +import { arrify } from '../../../../common/services/utils'; class AutoFollowPatternDeleteProviderUi extends PureComponent { state = { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_form.js similarity index 98% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_form.js index ebb731a1b1aca..5bc5d8ba6e402 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_form.js @@ -29,7 +29,8 @@ import { EuiTitle, } from '@elastic/eui'; -import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; +import { indices } from '../../../../../../../../src/plugins/es_ui_shared/public'; +import { indexPatterns } from '../../../../../../../../src/plugins/data/public'; import routing from '../services/routing'; import { extractQueryParams } from '../services/query_params'; @@ -44,10 +45,9 @@ import { } from '../services/auto_follow_pattern_validators'; import { AutoFollowPatternRequestFlyout } from './auto_follow_pattern_request_flyout'; -import { indexPatterns } from '../../../../../../../src/plugins/data/public'; const indexPatternIllegalCharacters = indexPatterns.ILLEGAL_CHARACTERS_VISIBLE.join(' '); -const indexNameIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); +const indexNameIllegalCharacters = indices.INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); const getEmptyAutoFollowPattern = (remoteClusterName = '') => ({ name: '', diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_form.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_form.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_indices_preview.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_indices_preview.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_indices_preview.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_indices_preview.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_page_title.js similarity index 92% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_page_title.js index 43cc0a39e6e57..9880e8c983a8e 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/auto_follow_pattern_page_title.js @@ -17,7 +17,7 @@ import { EuiTitle, } from '@elastic/eui'; -import { autoFollowPatternUrl } from '../services/documentation_links'; +import { getAutoFollowPatternUrl } from '../services/documentation_links'; export const AutoFollowPatternPageTitle = ({ title }) => ( @@ -35,7 +35,7 @@ export const AutoFollowPatternPageTitle = ({ title }) => ( + + { @@ -138,7 +138,7 @@ export class FollowerIndexForm extends PureComponent { }; this.cachedAdvancedSettings = {}; - this.validateIndexName = debounce(this.validateIndexName, 500); + this.validateIndexName = debounce(this.validateIndexName, 500, { trailing: true }); } toggleRequest = () => { @@ -223,18 +223,24 @@ export class FollowerIndexForm extends PureComponent { isValidatingIndexName: false, }); } catch (error) { - // Expect an error in the shape provided by Angular's $http service. - if (error && error.data) { - // All validation does is check for a name collision, so we can just let the user attempt - // to save the follower index and get an error back from the API. - return this.setState({ - isValidatingIndexName: false, - }); + if (error) { + if (error.name === 'AbortError') { + // Ignore aborted requests + return; + } + // This could be an HTTP error + if (error.body) { + // All validation does is check for a name collision, so we can just let the user attempt + // to save the follower index and get an error back from the API. + return this.setState({ + isValidatingIndexName: false, + }); + } } // This error isn't an HTTP error, so let the fatal error screen tell the user something // unexpected happened. - fatalError( + getFatalErrors().add( error, i18n.translate( 'xpack.crossClusterReplication.followerIndexForm.indexNameValidationFatalErrorTitle', diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/follower_index_form.test.js similarity index 92% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/follower_index_form.test.js index aac0427098813..93da20a8ed93c 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/follower_index_form.test.js @@ -7,9 +7,6 @@ import { updateFields, updateFormErrors } from './follower_index_form'; jest.mock('ui/new_platform'); -jest.mock('ui/indices', () => ({ - INDEX_ILLEGAL_CHARACTERS_VISIBLE: [], -})); describe(' state transitions', () => { it('updateFormErrors() should merge errors with existing fieldsErrors', () => { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_request_flyout.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/follower_index_request_flyout.js similarity index 96% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_request_flyout.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/follower_index_request_flyout.js index cba1c104e45d9..cb02a929b16f8 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_request_flyout.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/follower_index_request_flyout.js @@ -26,7 +26,7 @@ import { EuiTitle, } from '@elastic/eui'; -import { serializeFollowerIndex } from '../../../../common/services/follower_index_serialization'; +import { serializeFollowerIndex } from '../../../../../common/services/follower_index_serialization'; export class FollowerIndexRequestFlyout extends PureComponent { static propTypes = { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_form/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_form/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_page_title.js similarity index 92% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_page_title.js index a77059b5fe084..d72038096b72a 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/follower_index_page_title.js @@ -17,7 +17,7 @@ import { EuiTitle, } from '@elastic/eui'; -import { followerIndexUrl } from '../services/documentation_links'; +import { getFollowerIndexUrl } from '../services/documentation_links'; export const FollowerIndexPageTitle = ({ title }) => ( @@ -35,7 +35,7 @@ export const FollowerIndexPageTitle = ({ title }) => ( ( diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/remote_clusters_provider.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/remote_clusters_provider.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/remote_clusters_provider.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/remote_clusters_provider.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_error.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_error.js similarity index 79% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_error.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_error.js index 8aaf89b30f0e7..a2c782a0e8e58 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_error.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_error.js @@ -9,21 +9,21 @@ import { EuiCallOut, EuiSpacer } from '@elastic/eui'; export function SectionError(props) { const { title, error, ...rest } = props; - const data = error.data ? error.data : error; + const data = error.body ? error.body : error; const { error: errorString, - cause, // wrapEsError() on the server add a "cause" array + attributes, // wrapEsError() on the server add a "cause" array message, } = data; return (
{message || errorString}
- {cause && ( + {attributes && attributes.cause && (
    - {cause.map((message, i) => ( + {attributes.cause.map((message, i) => (
  • {message}
  • ))}
diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_loading.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_loading.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_loading.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_loading.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_unauthorized.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_unauthorized.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/components/section_unauthorized.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/components/section_unauthorized.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/api.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/api.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/api.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/api.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/sections.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/sections.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/sections.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/sections.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/ui_metric.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/ui_metric.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/constants/ui_metric.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/constants/ui_metric.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/index.js similarity index 88% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/index.js index 928d37558adb7..cc81fce4eebe7 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/index.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/index.js @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { I18nContext } from 'ui/i18n'; import React from 'react'; import { render } from 'react-dom'; import { Provider } from 'react-redux'; @@ -12,7 +11,7 @@ import { HashRouter } from 'react-router-dom'; import { App } from './app'; import { ccrStore } from './store'; -export const renderReact = async elem => { +export const renderReact = async (elem, I18nContext) => { render( diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js similarity index 91% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js index f55b9e4bceb0b..60a6cc79376e5 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js @@ -7,12 +7,10 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; -import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiPageContent } from '@elastic/eui'; -import { listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs'; +import { listBreadcrumb, addBreadcrumb, setBreadcrumbs } from '../../services/breadcrumbs'; import { AutoFollowPatternForm, AutoFollowPatternPageTitle, @@ -29,7 +27,7 @@ export class AutoFollowPatternAdd extends PureComponent { }; componentDidMount() { - chrome.breadcrumbs.set([MANAGEMENT_BREADCRUMB, listBreadcrumb, addBreadcrumb]); + setBreadcrumbs([listBreadcrumb, addBreadcrumb]); } componentWillUnmount() { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_add/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js similarity index 96% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js index a64c9566502f1..4cd3617abd989 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js @@ -8,12 +8,10 @@ import React, { PureComponent, Fragment } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; -import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPageContent, EuiSpacer } from '@elastic/eui'; -import { listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; +import { listBreadcrumb, editBreadcrumb, setBreadcrumbs } from '../../services/breadcrumbs'; import routing from '../../services/routing'; import { AutoFollowPatternForm, @@ -56,7 +54,7 @@ export class AutoFollowPatternEdit extends PureComponent { selectAutoFollowPattern(decodedId); - chrome.breadcrumbs.set([MANAGEMENT_BREADCRUMB, listBreadcrumb, editBreadcrumb]); + setBreadcrumbs([listBreadcrumb, editBreadcrumb]); } componentDidUpdate(prevProps, prevState) { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/auto_follow_pattern_edit/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/follower_index_add.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/follower_index_add.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/follower_index_add.js similarity index 91% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/follower_index_add.js index 26b5d8d6bb880..003e27777652b 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/follower_index_add.js @@ -7,12 +7,10 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; -import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiPageContent } from '@elastic/eui'; -import { listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs'; +import { setBreadcrumbs, listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs'; import { FollowerIndexForm, FollowerIndexPageTitle, @@ -29,7 +27,7 @@ export class FollowerIndexAdd extends PureComponent { }; componentDidMount() { - chrome.breadcrumbs.set([MANAGEMENT_BREADCRUMB, listBreadcrumb, addBreadcrumb]); + setBreadcrumbs([listBreadcrumb, addBreadcrumb]); } componentWillUnmount() { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_add/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_add/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/follower_index_edit.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/follower_index_edit.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/follower_index_edit.js similarity index 97% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/follower_index_edit.js index 7dc45e88f4106..21493602c12a7 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/follower_index_edit.js @@ -8,8 +8,6 @@ import React, { PureComponent, Fragment } from 'react'; import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; -import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiButtonEmpty, @@ -21,7 +19,7 @@ import { EuiSpacer, } from '@elastic/eui'; -import { listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; +import { setBreadcrumbs, listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; import routing from '../../services/routing'; import { FollowerIndexForm, @@ -76,7 +74,7 @@ export class FollowerIndexEdit extends PureComponent { selectFollowerIndex(decodedId); - chrome.breadcrumbs.set([MANAGEMENT_BREADCRUMB, listBreadcrumb, editBreadcrumb]); + setBreadcrumbs([listBreadcrumb, editBreadcrumb]); } componentDidUpdate(prevProps, prevState) { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/follower_index_edit/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js similarity index 99% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js index 7b31ffa5024b7..1a6d5e6efe35a 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js @@ -7,7 +7,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; -import { getIndexListUri } from '../../../../../../../../../../plugins/index_management/public'; +import { getIndexListUri } from '../../../../../../../../../../../plugins/index_management/public'; import { EuiButtonEmpty, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/detail_panel/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/components/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/auto_follow_pattern_list/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/context_menu/context_menu.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/context_menu/context_menu.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/context_menu/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/context_menu/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js similarity index 99% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js index 2ad118d28f38d..3e8cf6d3e2f78 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js @@ -31,7 +31,7 @@ import { } from '@elastic/eui'; import 'brace/theme/textmate'; -import { getIndexListUri } from '../../../../../../../../../../plugins/index_management/public'; +import { getIndexListUri } from '../../../../../../../../../../../plugins/index_management/public'; import { API_STATUS } from '../../../../../constants'; import { ContextMenu } from '../context_menu'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/detail_panel/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/follower_indices_table/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/follower_indices_table/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/components/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/follower_indices_list.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/follower_indices_list.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/follower_indices_list.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/follower_indices_list.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/follower_indices_list/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/home.container.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/home.container.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/home.container.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/home.container.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/home.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/home.js similarity index 91% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/home.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/home.js index f89d287540ebd..88db909612245 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/home.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/home.js @@ -7,13 +7,11 @@ import React, { PureComponent } from 'react'; import { Route, Switch } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; -import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTab, EuiTabs, EuiTitle } from '@elastic/eui'; -import { BASE_PATH } from '../../../../common/constants'; -import { listBreadcrumb } from '../../services/breadcrumbs'; +import { BASE_PATH } from '../../../../../common/constants'; +import { setBreadcrumbs, listBreadcrumb } from '../../services/breadcrumbs'; import routing from '../../services/routing'; import { AutoFollowPatternList } from './auto_follow_pattern_list'; import { FollowerIndicesList } from './follower_indices_list'; @@ -47,7 +45,7 @@ export class CrossClusterReplicationHome extends PureComponent { ]; componentDidMount() { - chrome.breadcrumbs.set([MANAGEMENT_BREADCRUMB, listBreadcrumb]); + setBreadcrumbs([listBreadcrumb]); } static getDerivedStateFromProps(props) { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/home/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/home/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/sections/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/sections/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/__snapshots__/auto_follow_pattern_validators.test.js.snap b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/__snapshots__/auto_follow_pattern_validators.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/__snapshots__/auto_follow_pattern_validators.test.js.snap rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/__snapshots__/auto_follow_pattern_validators.test.js.snap diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/api.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/api.js similarity index 52% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/api.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/api.js index 52576387444fd..b50c36aa8df9f 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/api.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/api.js @@ -3,14 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import chrome from 'ui/chrome'; import { API_BASE_PATH, API_REMOTE_CLUSTERS_BASE_PATH, API_INDEX_MANAGEMENT_BASE_PATH, -} from '../../../common/constants'; -import { arrify } from '../../../common/services/utils'; +} from '../../../../common/constants'; +import { arrify } from '../../../../common/services/utils'; import { UIM_FOLLOWER_INDEX_CREATE, UIM_FOLLOWER_INDEX_UPDATE, @@ -33,22 +31,10 @@ import { import { trackUserRequest } from './track_ui_metric'; import { areAllSettingsDefault } from './follower_index_default_settings'; -const apiPrefix = chrome.addBasePath(API_BASE_PATH); -const apiPrefixRemoteClusters = chrome.addBasePath(API_REMOTE_CLUSTERS_BASE_PATH); -const apiPrefixIndexManagement = chrome.addBasePath(API_INDEX_MANAGEMENT_BASE_PATH); - -// This is an Angular service, which is why we use this provider pattern -// to access it within our React app. let httpClient; -// The deferred AngularJS api allows us to create a deferred promise -// to be resolved later. This allows us to cancel in-flight http Requests. -// https://docs.angularjs.org/api/ng/service/$q#the-deferred-api -let $q; - -export function setHttpClient(client, $deffered) { +export function setHttpClient(client) { httpClient = client; - $q = $deffered; } export const getHttpClient = () => { @@ -57,67 +43,65 @@ export const getHttpClient = () => { // --- -const extractData = response => response.data; - const createIdString = ids => ids.map(id => encodeURIComponent(id)).join(','); /* Auto Follow Pattern */ -export const loadAutoFollowPatterns = () => - httpClient.get(`${apiPrefix}/auto_follow_patterns`).then(extractData); +export const loadAutoFollowPatterns = () => httpClient.get(`${API_BASE_PATH}/auto_follow_patterns`); export const getAutoFollowPattern = id => - httpClient.get(`${apiPrefix}/auto_follow_patterns/${encodeURIComponent(id)}`).then(extractData); + httpClient.get(`${API_BASE_PATH}/auto_follow_patterns/${encodeURIComponent(id)}`); -export const loadRemoteClusters = () => httpClient.get(apiPrefixRemoteClusters).then(extractData); +export const loadRemoteClusters = () => httpClient.get(API_REMOTE_CLUSTERS_BASE_PATH); export const createAutoFollowPattern = autoFollowPattern => { - const request = httpClient.post(`${apiPrefix}/auto_follow_patterns`, autoFollowPattern); - return trackUserRequest(request, UIM_AUTO_FOLLOW_PATTERN_CREATE).then(extractData); + const request = httpClient.post(`${API_BASE_PATH}/auto_follow_patterns`, { + body: JSON.stringify(autoFollowPattern), + }); + return trackUserRequest(request, UIM_AUTO_FOLLOW_PATTERN_CREATE); }; export const updateAutoFollowPattern = (id, autoFollowPattern) => { const request = httpClient.put( - `${apiPrefix}/auto_follow_patterns/${encodeURIComponent(id)}`, - autoFollowPattern + `${API_BASE_PATH}/auto_follow_patterns/${encodeURIComponent(id)}`, + { body: JSON.stringify(autoFollowPattern) } ); - return trackUserRequest(request, UIM_AUTO_FOLLOW_PATTERN_UPDATE).then(extractData); + return trackUserRequest(request, UIM_AUTO_FOLLOW_PATTERN_UPDATE); }; export const deleteAutoFollowPattern = id => { const ids = arrify(id); const idString = ids.map(_id => encodeURIComponent(_id)).join(','); - const request = httpClient.delete(`${apiPrefix}/auto_follow_patterns/${idString}`); + const request = httpClient.delete(`${API_BASE_PATH}/auto_follow_patterns/${idString}`); const uiMetric = ids.length > 1 ? UIM_AUTO_FOLLOW_PATTERN_DELETE_MANY : UIM_AUTO_FOLLOW_PATTERN_DELETE; - return trackUserRequest(request, uiMetric).then(extractData); + return trackUserRequest(request, uiMetric); }; export const pauseAutoFollowPattern = id => { const ids = arrify(id); const idString = ids.map(encodeURIComponent).join(','); - const request = httpClient.post(`${apiPrefix}/auto_follow_patterns/${idString}/pause`); + const request = httpClient.post(`${API_BASE_PATH}/auto_follow_patterns/${idString}/pause`); const uiMetric = ids.length > 1 ? UIM_AUTO_FOLLOW_PATTERN_PAUSE_MANY : UIM_AUTO_FOLLOW_PATTERN_PAUSE; - return trackUserRequest(request, uiMetric).then(extractData); + return trackUserRequest(request, uiMetric); }; export const resumeAutoFollowPattern = id => { const ids = arrify(id); const idString = ids.map(encodeURIComponent).join(','); - const request = httpClient.post(`${apiPrefix}/auto_follow_patterns/${idString}/resume`); + const request = httpClient.post(`${API_BASE_PATH}/auto_follow_patterns/${idString}/resume`); const uiMetric = ids.length > 1 ? UIM_AUTO_FOLLOW_PATTERN_RESUME_MANY : UIM_AUTO_FOLLOW_PATTERN_RESUME; - return trackUserRequest(request, uiMetric).then(extractData); + return trackUserRequest(request, uiMetric); }; /* Follower Index */ -export const loadFollowerIndices = () => - httpClient.get(`${apiPrefix}/follower_indices`).then(extractData); +export const loadFollowerIndices = () => httpClient.get(`${API_BASE_PATH}/follower_indices`); export const getFollowerIndex = id => - httpClient.get(`${apiPrefix}/follower_indices/${encodeURIComponent(id)}`).then(extractData); + httpClient.get(`${API_BASE_PATH}/follower_indices/${encodeURIComponent(id)}`); export const createFollowerIndex = followerIndex => { const uiMetrics = [UIM_FOLLOWER_INDEX_CREATE]; @@ -125,32 +109,34 @@ export const createFollowerIndex = followerIndex => { if (isUsingAdvancedSettings) { uiMetrics.push(UIM_FOLLOWER_INDEX_USE_ADVANCED_OPTIONS); } - const request = httpClient.post(`${apiPrefix}/follower_indices`, followerIndex); - return trackUserRequest(request, uiMetrics).then(extractData); + const request = httpClient.post(`${API_BASE_PATH}/follower_indices`, { + body: JSON.stringify(followerIndex), + }); + return trackUserRequest(request, uiMetrics); }; export const pauseFollowerIndex = id => { const ids = arrify(id); const idString = createIdString(ids); - const request = httpClient.put(`${apiPrefix}/follower_indices/${idString}/pause`); + const request = httpClient.put(`${API_BASE_PATH}/follower_indices/${idString}/pause`); const uiMetric = ids.length > 1 ? UIM_FOLLOWER_INDEX_PAUSE_MANY : UIM_FOLLOWER_INDEX_PAUSE; - return trackUserRequest(request, uiMetric).then(extractData); + return trackUserRequest(request, uiMetric); }; export const resumeFollowerIndex = id => { const ids = arrify(id); const idString = createIdString(ids); - const request = httpClient.put(`${apiPrefix}/follower_indices/${idString}/resume`); + const request = httpClient.put(`${API_BASE_PATH}/follower_indices/${idString}/resume`); const uiMetric = ids.length > 1 ? UIM_FOLLOWER_INDEX_RESUME_MANY : UIM_FOLLOWER_INDEX_RESUME; - return trackUserRequest(request, uiMetric).then(extractData); + return trackUserRequest(request, uiMetric); }; export const unfollowLeaderIndex = id => { const ids = arrify(id); const idString = createIdString(ids); - const request = httpClient.put(`${apiPrefix}/follower_indices/${idString}/unfollow`); + const request = httpClient.put(`${API_BASE_PATH}/follower_indices/${idString}/unfollow`); const uiMetric = ids.length > 1 ? UIM_FOLLOWER_INDEX_UNFOLLOW_MANY : UIM_FOLLOWER_INDEX_UNFOLLOW; - return trackUserRequest(request, uiMetric).then(extractData); + return trackUserRequest(request, uiMetric); }; export const updateFollowerIndex = (id, followerIndex) => { @@ -159,31 +145,28 @@ export const updateFollowerIndex = (id, followerIndex) => { if (isUsingAdvancedSettings) { uiMetrics.push(UIM_FOLLOWER_INDEX_USE_ADVANCED_OPTIONS); } - const request = httpClient.put( - `${apiPrefix}/follower_indices/${encodeURIComponent(id)}`, - followerIndex - ); - return trackUserRequest(request, uiMetrics).then(extractData); + const request = httpClient.put(`${API_BASE_PATH}/follower_indices/${encodeURIComponent(id)}`, { + body: JSON.stringify(followerIndex), + }); + return trackUserRequest(request, uiMetrics); }; /* Stats */ -export const loadAutoFollowStats = () => - httpClient.get(`${apiPrefix}/stats/auto_follow`).then(extractData); +export const loadAutoFollowStats = () => httpClient.get(`${API_BASE_PATH}/stats/auto_follow`); /* Indices */ -let canceler = null; +let abortController = null; export const loadIndices = () => { - if (canceler) { - // If there is a previous request in flight we cancel it by resolving the canceler - canceler.resolve(); + if (abortController) { + abortController.abort(); + abortController = null; } - canceler = $q.defer(); - return httpClient - .get(`${apiPrefixIndexManagement}/indices`, { timeout: canceler.promise }) - .then(response => { - canceler = null; - return extractData(response); - }); + abortController = new AbortController(); + const { signal } = abortController; + return httpClient.get(`${API_INDEX_MANAGEMENT_BASE_PATH}/indices`, { signal }).then(response => { + abortController = null; + return response; + }); }; -export const loadPermissions = () => httpClient.get(`${apiPrefix}/permissions`).then(extractData); +export const loadPermissions = () => httpClient.get(`${API_BASE_PATH}/permissions`); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_errors.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_errors.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_errors.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_errors.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_errors.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_errors.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_errors.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_errors.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern_validators.js similarity index 97% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern_validators.js index 18610c87c0a51..1b5a39658ee46 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern_validators.js @@ -8,13 +8,14 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { +import { indices } from '../../../../../../../../src/plugins/es_ui_shared/public'; +import { indexPatterns } from '../../../../../../../../src/plugins/data/public'; + +const { indexNameBeginsWithPeriod, findIllegalCharactersInIndexName, indexNameContainsSpaces, -} from 'ui/indices'; - -import { indexPatterns } from '../../../../../../../src/plugins/data/public'; +} = indices; export const validateName = (name = '') => { let errorMsg = null; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern_validators.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/auto_follow_pattern_validators.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.mock.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.mock.ts new file mode 100644 index 0000000000000..b7c75108d4ef0 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.mock.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('./breadcrumbs', () => ({ + ...jest.requireActual('./breadcrumbs'), + setBreadcrumbs: jest.fn(), +})); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/breadcrumbs.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.ts similarity index 56% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/breadcrumbs.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.ts index f8c8cc710964a..dc64cdee07f7d 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/breadcrumbs.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/breadcrumbs.ts @@ -3,9 +3,27 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { i18n } from '@kbn/i18n'; -import { BASE_PATH } from '../../../common/constants'; +import { ChromeBreadcrumb } from 'src/core/public'; + +import { ManagementAppMountParams } from '../../../../../../../../src/plugins/management/public'; + +import { BASE_PATH } from '../../../../common/constants'; + +let setBreadcrumbs: ManagementAppMountParams['setBreadcrumbs']; + +export const setBreadcrumbSetter = ({ + __LEGACY, +}: { + __LEGACY: { + chrome: any; + MANAGEMENT_BREADCRUMB: ChromeBreadcrumb; + }; +}): void => { + setBreadcrumbs = (crumbs: ChromeBreadcrumb[]) => { + __LEGACY.chrome.breadcrumbs.set([__LEGACY.MANAGEMENT_BREADCRUMB, ...crumbs]); + }; +}; export const listBreadcrumb = { text: i18n.translate('xpack.crossClusterReplication.homeBreadcrumbTitle', { @@ -25,3 +43,5 @@ export const editBreadcrumb = { defaultMessage: 'Edit', }), }; + +export { setBreadcrumbs }; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/documentation_links.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/documentation_links.ts new file mode 100644 index 0000000000000..f17926d2bee10 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/documentation_links.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +let esBase: string; + +export const setDocLinks = ({ + DOC_LINK_VERSION, + ELASTIC_WEBSITE_URL, +}: { + ELASTIC_WEBSITE_URL: string; + DOC_LINK_VERSION: string; +}) => { + esBase = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; +}; + +export const getAutoFollowPatternUrl = () => `${esBase}/ccr-put-auto-follow-pattern.html`; +export const getFollowerIndexUrl = () => `${esBase}/ccr-put-follow.html`; +export const getByteUnitsUrl = () => `${esBase}/common-options.html#byte-units`; +export const getTimeUnitsUrl = () => `${esBase}/common-options.html#time-units`; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/follower_index_default_settings.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/follower_index_default_settings.js similarity index 89% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/follower_index_default_settings.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/follower_index_default_settings.js index 118a54887d404..d20fa76ef5451 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/follower_index_default_settings.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/follower_index_default_settings.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FOLLOWER_INDEX_ADVANCED_SETTINGS } from '../../../common/constants'; +import { FOLLOWER_INDEX_ADVANCED_SETTINGS } from '../../../../common/constants'; export const getSettingDefault = name => { if (!FOLLOWER_INDEX_ADVANCED_SETTINGS[name]) { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/get_remote_cluster_name.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/get_remote_cluster_name.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/input_validation.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/input_validation.js similarity index 94% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/input_validation.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/input_validation.js index 22f7d3be2795f..64c3e8412437e 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/input_validation.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/input_validation.js @@ -6,7 +6,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; +import { indices } from '../../../../../../../../src/plugins/es_ui_shared/public'; const isEmpty = value => { return !value || !value.trim().length; @@ -19,7 +19,7 @@ const beginsWithPeriod = value => { }; const findIllegalCharacters = value => { - return INDEX_ILLEGAL_CHARACTERS_VISIBLE.reduce((chars, char) => { + return indices.INDEX_ILLEGAL_CHARACTERS_VISIBLE.reduce((chars, char) => { if (value.includes(char)) { chars.push(char); } diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/notifications.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/notifications.ts new file mode 100644 index 0000000000000..5e1c3e9e99437 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/notifications.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { NotificationsSetup, IToasts, FatalErrorsSetup } from 'src/core/public'; + +let _notifications: IToasts; +let _fatalErrors: FatalErrorsSetup; + +export const setNotifications = ( + notifications: NotificationsSetup, + fatalErrorsSetup: FatalErrorsSetup +) => { + _notifications = notifications.toasts; + _fatalErrors = fatalErrorsSetup; +}; + +export const getNotifications = () => _notifications; +export const getFatalErrors = () => _fatalErrors; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/query_params.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/query_params.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/query_params.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/query_params.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/routing.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/routing.js similarity index 99% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/routing.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/routing.js index 487b1068794f9..965aeaaad22ad 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/routing.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/routing.js @@ -10,7 +10,7 @@ import { createLocation } from 'history'; import { stringify } from 'query-string'; -import { APPS, BASE_PATH, BASE_PATH_REMOTE_CLUSTERS } from '../../../common/constants'; +import { APPS, BASE_PATH, BASE_PATH_REMOTE_CLUSTERS } from '../../../../common/constants'; const isModifiedEvent = event => !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/track_ui_metric.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/track_ui_metric.js similarity index 92% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/track_ui_metric.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/track_ui_metric.js index bd618f6a59e5c..36b9c185b487d 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/track_ui_metric.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/track_ui_metric.js @@ -7,7 +7,7 @@ import { createUiStatsReporter, METRIC_TYPE, -} from '../../../../../../../src/legacy/core_plugins/ui_metric/public'; +} from '../../../../../../../../src/legacy/core_plugins/ui_metric/public'; import { UIM_APP_NAME } from '../constants'; export const trackUiMetric = createUiStatsReporter(UIM_APP_NAME); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/utils.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/utils.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/utils.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/utils.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/utils.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/utils.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/services/utils.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/utils.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/action_types.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/action_types.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/action_types.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/action_types.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/api.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/api.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/api.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/api.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/auto_follow_pattern.js similarity index 95% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/auto_follow_pattern.js index 439858ad98ba3..b81cd30f3977a 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/auto_follow_pattern.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import { toastNotifications } from 'ui/notify'; +import { getNotifications } from '../../services/notifications'; import { SECTIONS, API_STATUS } from '../../constants'; import { loadAutoFollowPatterns as loadAutoFollowPatternsRequest, @@ -75,7 +75,7 @@ export const saveAutoFollowPattern = (id, autoFollowPattern, isUpdating = false) } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); routing.navigate(`/auto_follow_patterns`, undefined, { pattern: encodeURIComponent(id), }); @@ -111,7 +111,7 @@ export const deleteAutoFollowPattern = id => } ); - toastNotifications.addDanger(errorMessage); + getNotifications().addDanger(errorMessage); } if (response.itemsDeleted.length) { @@ -133,7 +133,7 @@ export const deleteAutoFollowPattern = id => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); // If we've just deleted a pattern we were looking at, we need to close the panel. const autoFollowPatternId = getSelectedAutoFollowPatternId('detail')(getState()); @@ -173,7 +173,7 @@ export const pauseAutoFollowPattern = id => } ); - toastNotifications.addDanger(errorMessage); + getNotifications().addDanger(errorMessage); } if (response.itemsPaused.length) { @@ -195,7 +195,7 @@ export const pauseAutoFollowPattern = id => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); } }, }); @@ -229,7 +229,7 @@ export const resumeAutoFollowPattern = id => } ); - toastNotifications.addDanger(errorMessage); + getNotifications().addDanger(errorMessage); } if (response.itemsResumed.length) { @@ -251,7 +251,7 @@ export const resumeAutoFollowPattern = id => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); } }, }); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/ccr.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/ccr.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/ccr.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/ccr.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/follower_index.js similarity index 95% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/follower_index.js index da1c259974498..ebdee067ced75 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/follower_index.js @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import { toastNotifications } from 'ui/notify'; + import routing from '../../services/routing'; +import { getNotifications } from '../../services/notifications'; import { SECTIONS, API_STATUS } from '../../constants'; import { loadFollowerIndices as loadFollowerIndicesRequest, @@ -75,7 +76,7 @@ export const saveFollowerIndex = (name, followerIndex, isUpdating = false) => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); routing.navigate(`/follower_indices`, undefined, { name: encodeURIComponent(name), }); @@ -111,7 +112,7 @@ export const pauseFollowerIndex = id => } ); - toastNotifications.addDanger(errorMessage); + getNotifications().addDanger(errorMessage); } if (response.itemsPaused.length) { @@ -133,7 +134,7 @@ export const pauseFollowerIndex = id => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); // Refresh list dispatch(loadFollowerIndices(true)); @@ -170,7 +171,7 @@ export const resumeFollowerIndex = id => } ); - toastNotifications.addDanger(errorMessage); + getNotifications().addDanger(errorMessage); } if (response.itemsResumed.length) { @@ -192,7 +193,7 @@ export const resumeFollowerIndex = id => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); } // Refresh list @@ -229,7 +230,7 @@ export const unfollowLeaderIndex = id => } ); - toastNotifications.addDanger(errorMessage); + getNotifications().addDanger(errorMessage); } if (response.itemsUnfollowed.length) { @@ -251,7 +252,7 @@ export const unfollowLeaderIndex = id => } ); - toastNotifications.addSuccess(successMessage); + getNotifications().addSuccess(successMessage); } if (response.itemsNotOpen.length) { @@ -273,7 +274,7 @@ export const unfollowLeaderIndex = id => } ); - toastNotifications.addWarning(warningMessage); + getNotifications().addWarning(warningMessage); } // If we've just unfollowed a follower index we were looking at, we need to close the panel. diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/actions/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/actions/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/api.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/api.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/api.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/api.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/api.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/api.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/auto_follow_pattern.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/auto_follow_pattern.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/follower_index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/follower_index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/stats.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/stats.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/reducers/stats.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/reducers/stats.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/selectors/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/selectors/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/selectors/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/selectors/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/store/store.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/store.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/public/app/store/store.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/store/store.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/extend_index_management/index.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/extend_index_management.ts similarity index 67% rename from x-pack/legacy/plugins/cross_cluster_replication/public/extend_index_management/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/extend_index_management.ts index c44918c500849..01c6250383fb8 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/extend_index_management/index.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/extend_index_management.ts @@ -3,14 +3,15 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { i18n } from '@kbn/i18n'; -import { npSetup } from 'ui/new_platform'; import { get } from 'lodash'; +import { IndexMgmtSetup } from '../../../../../plugins/index_management/public'; const propertyPath = 'isFollowerIndex'; const followerBadgeExtension = { - matchIndex: index => { + matchIndex: (index: any) => { return get(index, propertyPath); }, label: i18n.translate('xpack.crossClusterReplication.indexMgmtBadge.followerLabel', { @@ -20,6 +21,8 @@ const followerBadgeExtension = { filterExpression: 'isFollowerIndex:true', }; -if (npSetup.plugins.indexManagement) { - npSetup.plugins.indexManagement.extensionsService.addBadge(followerBadgeExtension); -} +export const extendIndexManagement = (indexManagement?: IndexMgmtSetup) => { + if (indexManagement) { + indexManagement.extensionsService.addBadge(followerBadgeExtension); + } +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/index.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/index.ts new file mode 100644 index 0000000000000..11aea6b7b5de4 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; + +import { CrossClusterReplicationUIPlugin } from './plugin'; + +export const plugin = (ctx: PluginInitializerContext) => new CrossClusterReplicationUIPlugin(ctx); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/plugin.ts b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/plugin.ts new file mode 100644 index 0000000000000..f7651cbb210a7 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/plugin.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { + ChromeBreadcrumb, + CoreSetup, + Plugin, + PluginInitializerContext, + DocLinksStart, +} from 'src/core/public'; + +import { IndexMgmtSetup } from '../../../../../plugins/index_management/public'; + +// @ts-ignore; +import { setHttpClient } from './app/services/api'; +import { setBreadcrumbSetter } from './app/services/breadcrumbs'; +import { setDocLinks } from './app/services/documentation_links'; +import { setNotifications } from './app/services/notifications'; +import { extendIndexManagement } from './extend_index_management'; + +interface PluginDependencies { + indexManagement: IndexMgmtSetup; + __LEGACY: { + chrome: any; + MANAGEMENT_BREADCRUMB: ChromeBreadcrumb; + docLinks: DocLinksStart; + }; +} + +export class CrossClusterReplicationUIPlugin implements Plugin { + // @ts-ignore + constructor(private readonly ctx: PluginInitializerContext) {} + setup({ http, notifications, fatalErrors }: CoreSetup, deps: PluginDependencies) { + setHttpClient(http); + setBreadcrumbSetter(deps); + setDocLinks(deps.__LEGACY.docLinks); + setNotifications(notifications, fatalErrors); + extendIndexManagement(deps.indexManagement); + } + + start() {} +} diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/register_routes.js b/x-pack/legacy/plugins/cross_cluster_replication/public/register_routes.js index 7b9ba07f46c18..838939f46e523 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/register_routes.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/register_routes.js @@ -6,15 +6,21 @@ import { unmountComponentAtNode } from 'react-dom'; import chrome from 'ui/chrome'; -import { management } from 'ui/management'; +import { management, MANAGEMENT_BREADCRUMB } from 'ui/management'; +import { npSetup, npStart } from 'ui/new_platform'; import routes from 'ui/routes'; import { xpackInfo } from 'plugins/xpack_main/services/xpack_info'; import { i18n } from '@kbn/i18n'; import template from './main.html'; import { BASE_PATH } from '../common/constants'; -import { renderReact } from './app'; -import { setHttpClient } from './app/services/api'; + +import { plugin } from './np_ready'; + +/** + * TODO: When this file is deleted, use the management section for rendering + */ +import { renderReact } from './np_ready/app'; const isAvailable = xpackInfo.get('features.crossClusterReplication.isAvailable'); const isActive = xpackInfo.get('features.crossClusterReplication.isActive'); @@ -37,26 +43,31 @@ if (isLicenseOK && isCcrUiEnabled) { const CCR_REACT_ROOT = 'ccrReactRoot'; + plugin({}).setup(npSetup.core, { + ...npSetup.plugins, + __LEGACY: { + chrome, + docLinks: npStart.core.docLinks, + MANAGEMENT_BREADCRUMB, + }, + }); + const unmountReactApp = () => elem && unmountComponentAtNode(elem); routes.when(`${BASE_PATH}/:section?/:subsection?/:view?/:id?`, { template, controllerAs: 'ccr', controller: class CrossClusterReplicationController { - constructor($scope, $route, $http, $q) { + constructor($scope, $route) { // React-router's does not play well with the angular router. It will cause this controller // to re-execute without the $destroy handler being called. This means that the app will be mounted twice // creating a memory leak when leaving (only 1 app will be unmounted). // To avoid this, we unmount the React app each time we enter the controller. unmountReactApp(); - // NOTE: We depend upon Angular's $http service because it's decorated with interceptors, - // e.g. to check license status per request. - setHttpClient($http, $q); - $scope.$$postDigest(() => { elem = document.getElementById(CCR_REACT_ROOT); - renderReact(elem); + renderReact(elem, npStart.core.i18n.Context); // Angular Lifecycle const appRoute = $route.current; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_custom_error.js b/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_custom_error.js deleted file mode 100644 index f9c102be7a1ff..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_custom_error.js +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { wrapCustomError } from '../wrap_custom_error'; - -describe('wrap_custom_error', () => { - describe('#wrapCustomError', () => { - it('should return a Boom object', () => { - const originalError = new Error('I am an error'); - const statusCode = 404; - const wrappedError = wrapCustomError(originalError, statusCode); - - expect(wrappedError.isBoom).to.be(true); - expect(wrappedError.output.statusCode).to.equal(statusCode); - }); - }); -}); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_unknown_error.js b/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_unknown_error.js deleted file mode 100644 index 85e0b2b3033ad..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_unknown_error.js +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { wrapUnknownError } from '../wrap_unknown_error'; - -describe('wrap_unknown_error', () => { - describe('#wrapUnknownError', () => { - it('should return a Boom object', () => { - const originalError = new Error('I am an error'); - const wrappedError = wrapUnknownError(originalError); - - expect(wrappedError.isBoom).to.be(true); - }); - }); -}); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_custom_error.js b/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_custom_error.js deleted file mode 100644 index 3295113d38ee5..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_custom_error.js +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Boom from 'boom'; - -/** - * Wraps a custom error into a Boom error response and returns it - * - * @param err Object error - * @param statusCode Error status code - * @return Object Boom error response - */ -export function wrapCustomError(err, statusCode) { - return Boom.boomify(err, { statusCode }); -} diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_unknown_error.js b/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_unknown_error.js deleted file mode 100644 index ffd915c513362..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_unknown_error.js +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Boom from 'boom'; - -/** - * Wraps an unknown error into a Boom error response and returns it - * - * @param err Object Unknown error - * @return Object Boom error response - */ -export function wrapUnknownError(err) { - return Boom.boomify(err); -} diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js b/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js deleted file mode 100644 index a73aa96209c26..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/__tests__/license_pre_routing_factory.js +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { licensePreRoutingFactory } from '../license_pre_routing_factory'; - -describe('license_pre_routing_factory', () => { - describe('#reportingFeaturePreRoutingFactory', () => { - let mockServer; - let mockLicenseCheckResults; - - beforeEach(() => { - mockServer = { - plugins: { - xpack_main: { - info: { - feature: () => ({ - getLicenseCheckResults: () => mockLicenseCheckResults, - }), - }, - }, - }, - }; - }); - - it('only instantiates one instance per server', () => { - const firstInstance = licensePreRoutingFactory(mockServer); - const secondInstance = licensePreRoutingFactory(mockServer); - - expect(firstInstance).to.be(secondInstance); - }); - - describe('isAvailable is false', () => { - beforeEach(() => { - mockLicenseCheckResults = { - isAvailable: false, - }; - }); - - it('replies with 403', () => { - const licensePreRouting = licensePreRoutingFactory(mockServer); - const response = licensePreRouting(); - expect(response).to.be.an(Error); - expect(response.isBoom).to.be(true); - expect(response.output.statusCode).to.be(403); - }); - }); - - describe('isAvailable is true', () => { - beforeEach(() => { - mockLicenseCheckResults = { - isAvailable: true, - }; - }); - - it('replies with nothing', () => { - const licensePreRouting = licensePreRoutingFactory(mockServer); - const response = licensePreRouting(); - expect(response).to.be(null); - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/license_pre_routing_factory.js b/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/license_pre_routing_factory.js deleted file mode 100644 index 548ad7ca02104..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/license_pre_routing_factory.js +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { once } from 'lodash'; -import { wrapCustomError } from '../error_wrappers'; -import { PLUGIN } from '../../../common/constants'; - -export const licensePreRoutingFactory = once(server => { - const xpackMainPlugin = server.plugins.xpack_main; - - // License checking and enable/disable logic - function licensePreRouting() { - const licenseCheckResults = xpackMainPlugin.info.feature(PLUGIN.ID).getLicenseCheckResults(); - if (!licenseCheckResults.isAvailable) { - const error = new Error(licenseCheckResults.message); - const statusCode = 403; - const wrappedError = wrapCustomError(error, statusCode); - return wrappedError; - } else { - return null; - } - } - - return licensePreRouting; -}); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/client/elasticsearch_ccr.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/client/elasticsearch_ccr.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/cross_cluster_replication_data.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/cross_cluster_replication_data.ts similarity index 59% rename from x-pack/legacy/plugins/cross_cluster_replication/cross_cluster_replication_data.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/cross_cluster_replication_data.ts index 2944c3e6bc2ec..ae15073b979e1 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/cross_cluster_replication_data.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/cross_cluster_replication_data.ts @@ -3,9 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { APICaller } from 'src/core/server'; +import { Index } from '../../../../../plugins/index_management/server'; -export const ccrDataEnricher = async (indicesList, callWithRequest) => { - if (!indicesList || !indicesList.length) { +export const ccrDataEnricher = async (indicesList: Index[], callWithRequest: APICaller) => { + if (!indicesList?.length) { return indicesList; } const params = { @@ -18,9 +20,11 @@ export const ccrDataEnricher = async (indicesList, callWithRequest) => { params ); return indicesList.map(index => { - const isFollowerIndex = !!followerIndices.find(followerIndex => { - return followerIndex.follower_index === index.name; - }); + const isFollowerIndex = !!followerIndices.find( + (followerIndex: { follower_index: string }) => { + return followerIndex.follower_index === index.name; + } + ); return { ...index, isFollowerIndex, diff --git a/x-pack/legacy/plugins/license_management/server/np_ready/index.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/index.ts similarity index 64% rename from x-pack/legacy/plugins/license_management/server/np_ready/index.ts rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/index.ts index 2ad4143a94730..7a38d024d99a2 100644 --- a/x-pack/legacy/plugins/license_management/server/np_ready/index.ts +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/index.ts @@ -5,6 +5,7 @@ */ import { PluginInitializerContext } from 'src/core/server'; -import { LicenseManagementServerPlugin } from './plugin'; +import { CrossClusterReplicationServerPlugin } from './plugin'; -export const plugin = (ctx: PluginInitializerContext) => new LicenseManagementServerPlugin(); +export const plugin = (ctx: PluginInitializerContext) => + new CrossClusterReplicationServerPlugin(ctx); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/__snapshots__/ccr_stats_serialization.test.js.snap b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/__snapshots__/ccr_stats_serialization.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/__snapshots__/ccr_stats_serialization.test.js.snap rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/__snapshots__/ccr_stats_serialization.test.js.snap diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/call_with_request_factory/call_with_request_factory.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/call_with_request_factory/call_with_request_factory.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/call_with_request_factory/call_with_request_factory.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/call_with_request_factory/call_with_request_factory.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/call_with_request_factory/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/call_with_request_factory/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/call_with_request_factory/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/call_with_request_factory/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/ccr_stats_serialization.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/ccr_stats_serialization.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/ccr_stats_serialization.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/ccr_stats_serialization.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/ccr_stats_serialization.test.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/ccr_stats_serialization.test.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/ccr_stats_serialization.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/ccr_stats_serialization.test.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/check_license/check_license.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/check_license/check_license.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/check_license/check_license.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/check_license/check_license.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/check_license/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/check_license/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/check_license/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/check_license/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_es_error.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/__tests__/wrap_es_error.test.js similarity index 55% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_es_error.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/__tests__/wrap_es_error.test.js index 8241dc4329137..11a6fd4e1d816 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/__tests__/wrap_es_error.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/__tests__/wrap_es_error.test.js @@ -16,24 +16,18 @@ describe('wrap_es_error', () => { originalError.response = '{}'; }); - it('should return a Boom object', () => { + it('should return the correct object', () => { const wrappedError = wrapEsError(originalError); - expect(wrappedError.isBoom).to.be(true); + expect(wrappedError.statusCode).to.be(originalError.statusCode); + expect(wrappedError.message).to.be(originalError.message); }); - it('should return the correct Boom object', () => { - const wrappedError = wrapEsError(originalError); - - expect(wrappedError.output.statusCode).to.be(originalError.statusCode); - expect(wrappedError.output.payload.message).to.be(originalError.message); - }); - - it('should return the correct Boom object with custom message', () => { + it('should return the correct object with custom message', () => { const wrappedError = wrapEsError(originalError, { 404: 'No encontrado!' }); - expect(wrappedError.output.statusCode).to.be(originalError.statusCode); - expect(wrappedError.output.payload.message).to.be('No encontrado!'); + expect(wrappedError.statusCode).to.be(originalError.statusCode); + expect(wrappedError.message).to.be('No encontrado!'); }); }); }); diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/index.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/index.ts similarity index 83% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/index.ts rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/index.ts index 1f963d7f8fcce..3756b0c74fb10 100644 --- a/x-pack/legacy/plugins/license_management/public/np_ready/application/index.ts +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './boot'; +export { wrapEsError } from './wrap_es_error'; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_es_error.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/wrap_es_error.ts similarity index 66% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_es_error.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/wrap_es_error.ts index 5f4884a3f2d26..8afd5f1a018eb 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/wrap_es_error.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/error_wrappers/wrap_es_error.ts @@ -4,16 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; - -function extractCausedByChain(causedBy = {}, accumulator = []) { - const { reason, caused_by } = causedBy; // eslint-disable-line camelcase +function extractCausedByChain( + causedBy: Record = {}, + accumulator: string[] = [] +): string[] { + const { reason, caused_by } = causedBy; // eslint-disable-line @typescript-eslint/camelcase if (reason) { accumulator.push(reason); } - // eslint-disable-next-line camelcase + // eslint-disable-next-line @typescript-eslint/camelcase if (caused_by) { return extractCausedByChain(caused_by, accumulator); } @@ -26,34 +27,39 @@ function extractCausedByChain(causedBy = {}, accumulator = []) { * * @param err Object Error thrown by ES JS client * @param statusCodeToMessageMap Object Optional map of HTTP status codes => error messages - * @return Object Boom error response */ -export function wrapEsError(err, statusCodeToMessageMap = {}) { +export function wrapEsError( + err: any, + statusCodeToMessageMap: Record = {} +): { message: string; body?: { cause?: string[] }; statusCode: number } { const { statusCode, response } = err; const { error: { - root_cause = [], // eslint-disable-line camelcase - caused_by, // eslint-disable-line camelcase + root_cause = [], // eslint-disable-line @typescript-eslint/camelcase + caused_by = undefined, // eslint-disable-line @typescript-eslint/camelcase } = {}, } = JSON.parse(response); // If no custom message if specified for the error's status code, just // wrap the error as a Boom error response and return it if (!statusCodeToMessageMap[statusCode]) { - const boomError = Boom.boomify(err, { statusCode }); - // The caused_by chain has the most information so use that if it's available. If not then // settle for the root_cause. const causedByChain = extractCausedByChain(caused_by); const defaultCause = root_cause.length ? extractCausedByChain(root_cause[0]) : undefined; - boomError.output.payload.cause = causedByChain.length ? causedByChain : defaultCause; - return boomError; + return { + message: err.message, + statusCode, + body: { + cause: causedByChain.length ? causedByChain : defaultCause, + }, + }; } // Otherwise, use the custom message to create a Boom error response and // return it const message = statusCodeToMessageMap[statusCode]; - return new Boom(message, { statusCode }); + return { message, statusCode }; } diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error.ts similarity index 54% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error.ts index f275f15637091..4137293cf39c0 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/error_wrappers/index.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error.ts @@ -4,6 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -export { wrapCustomError } from './wrap_custom_error'; -export { wrapEsError } from './wrap_es_error'; -export { wrapUnknownError } from './wrap_unknown_error'; +import * as legacyElasticsearch from 'elasticsearch'; + +const esErrorsParent = legacyElasticsearch.errors._Abstract; + +export function isEsError(err: Error) { + return err instanceof esErrorsParent; +} diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/__tests__/is_es_error_factory.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/__tests__/is_es_error_factory.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/__tests__/is_es_error_factory.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/__tests__/is_es_error_factory.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/index.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/index.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/is_es_error_factory.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/is_es_error_factory.ts similarity index 76% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/is_es_error_factory.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/is_es_error_factory.ts index 6c17554385ef8..fc6405b8e7513 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/is_es_error_factory/is_es_error_factory.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/is_es_error_factory/is_es_error_factory.ts @@ -6,13 +6,13 @@ import { memoize } from 'lodash'; -const esErrorsFactory = memoize(server => { +const esErrorsFactory = memoize((server: any) => { return server.plugins.elasticsearch.getCluster('admin').errors; }); -export function isEsErrorFactory(server) { +export function isEsErrorFactory(server: any) { const esErrors = esErrorsFactory(server); - return function isEsError(err) { + return function isEsError(err: any) { return err instanceof esErrors._Abstract; }; } diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/__jest__/license_pre_routing_factory.test.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/__jest__/license_pre_routing_factory.test.ts new file mode 100644 index 0000000000000..d22505f0e315a --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/__jest__/license_pre_routing_factory.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { kibanaResponseFactory } from '../../../../../../../../../src/core/server'; +import { licensePreRoutingFactory } from '../license_pre_routing_factory'; + +describe('license_pre_routing_factory', () => { + describe('#reportingFeaturePreRoutingFactory', () => { + let mockDeps: any; + let mockLicenseCheckResults: any; + + const anyContext: any = {}; + const anyRequest: any = {}; + + beforeEach(() => { + mockDeps = { + __LEGACY: { + server: { + plugins: { + xpack_main: { + info: { + feature: () => ({ + getLicenseCheckResults: () => mockLicenseCheckResults, + }), + }, + }, + }, + }, + }, + requestHandler: jest.fn(), + }; + }); + + describe('isAvailable is false', () => { + beforeEach(() => { + mockLicenseCheckResults = { + isAvailable: false, + }; + }); + + it('replies with 403', async () => { + const licensePreRouting = licensePreRoutingFactory(mockDeps); + const response = await licensePreRouting(anyContext, anyRequest, kibanaResponseFactory); + expect(response.status).toBe(403); + }); + }); + + describe('isAvailable is true', () => { + beforeEach(() => { + mockLicenseCheckResults = { + isAvailable: true, + }; + }); + + it('it calls the wrapped handler', async () => { + const licensePreRouting = licensePreRoutingFactory(mockDeps); + await licensePreRouting(anyContext, anyRequest, kibanaResponseFactory); + expect(mockDeps.requestHandler).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/index.ts similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/license_pre_routing_factory/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/index.ts diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/license_pre_routing_factory.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/license_pre_routing_factory.ts new file mode 100644 index 0000000000000..c47faa940a650 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/license_pre_routing_factory/license_pre_routing_factory.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RequestHandler } from 'src/core/server'; +import { PLUGIN } from '../../../../common/constants'; + +export const licensePreRoutingFactory = ({ + __LEGACY, + requestHandler, +}: { + __LEGACY: { server: any }; + requestHandler: RequestHandler; +}) => { + const xpackMainPlugin = __LEGACY.server.plugins.xpack_main; + + // License checking and enable/disable logic + const licensePreRouting: RequestHandler = (ctx, request, response) => { + const licenseCheckResults = xpackMainPlugin.info.feature(PLUGIN.ID).getLicenseCheckResults(); + if (!licenseCheckResults.isAvailable) { + return response.forbidden({ + body: licenseCheckResults.message, + }); + } else { + return requestHandler(ctx, request, response); + } + }; + + return licensePreRouting; +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/index.js similarity index 100% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/index.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/index.js diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/register_license_checker.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/register_license_checker.js similarity index 66% rename from x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/register_license_checker.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/register_license_checker.js index dbd99efd95573..b9bb34a80ce79 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/lib/register_license_checker/register_license_checker.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/lib/register_license_checker/register_license_checker.js @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { mirrorPluginStatus } from '../../../../../server/lib/mirror_plugin_status'; -import { PLUGIN } from '../../../common/constants'; +import { mirrorPluginStatus } from '../../../../../../server/lib/mirror_plugin_status'; +import { PLUGIN } from '../../../../common/constants'; import { checkLicense } from '../check_license'; -export function registerLicenseChecker(server) { - const xpackMainPlugin = server.plugins.xpack_main; - const ccrPluggin = server.plugins[PLUGIN.ID]; +export function registerLicenseChecker(__LEGACY) { + const xpackMainPlugin = __LEGACY.server.plugins.xpack_main; + const ccrPluggin = __LEGACY.server.plugins[PLUGIN.ID]; mirrorPluginStatus(xpackMainPlugin, ccrPluggin); xpackMainPlugin.status.once('green', () => { diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/plugin.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/plugin.ts new file mode 100644 index 0000000000000..1012c07af3d2a --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/plugin.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Plugin, PluginInitializerContext, CoreSetup } from 'src/core/server'; + +import { IndexMgmtSetup } from '../../../../../plugins/index_management/server'; + +// @ts-ignore +import { registerLicenseChecker } from './lib/register_license_checker'; +// @ts-ignore +import { registerRoutes } from './routes/register_routes'; +import { ccrDataEnricher } from './cross_cluster_replication_data'; + +interface PluginDependencies { + indexManagement: IndexMgmtSetup; + __LEGACY: { + server: any; + ccrUIEnabled: boolean; + }; +} + +export class CrossClusterReplicationServerPlugin implements Plugin { + // @ts-ignore + constructor(private readonly ctx: PluginInitializerContext) {} + setup({ http }: CoreSetup, { indexManagement, __LEGACY }: PluginDependencies) { + registerLicenseChecker(__LEGACY); + + const router = http.createRouter(); + registerRoutes({ router, __LEGACY }); + if (__LEGACY.ccrUIEnabled && indexManagement && indexManagement.indexDataEnricher) { + indexManagement.indexDataEnricher.add(ccrDataEnricher); + } + } + start() {} +} diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.test.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/auto_follow_pattern.test.js similarity index 68% rename from x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/auto_follow_pattern.test.js index c610039cfd2ac..f3024515c7213 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/auto_follow_pattern.test.js @@ -3,23 +3,23 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { deserializeAutoFollowPattern } from '../../../../../common/services/auto_follow_pattern_serialization'; +import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { getAutoFollowPatternMock, getAutoFollowPatternListMock } from '../../../../../fixtures'; +import { registerAutoFollowPatternRoutes } from '../auto_follow_pattern'; -import { deserializeAutoFollowPattern } from '../../../common/services/auto_follow_pattern_serialization'; -import { callWithRequestFactory } from '../../lib/call_with_request_factory'; -import { isEsErrorFactory } from '../../lib/is_es_error_factory'; -import { getAutoFollowPatternMock, getAutoFollowPatternListMock } from '../../../fixtures'; -import { registerAutoFollowPatternRoutes } from './auto_follow_pattern'; +import { createRouter, callRoute } from './helpers'; -jest.mock('../../lib/call_with_request_factory'); -jest.mock('../../lib/is_es_error_factory'); -jest.mock('../../lib/license_pre_routing_factory'); +jest.mock('../../../lib/call_with_request_factory'); +jest.mock('../../../lib/is_es_error_factory'); +jest.mock('../../../lib/license_pre_routing_factory', () => ({ + licensePreRoutingFactory: ({ requestHandler }) => requestHandler, +})); const DESERIALIZED_KEYS = Object.keys(deserializeAutoFollowPattern(getAutoFollowPatternMock())); -/** - * Hashtable to save the route handlers - */ -const routeHandlers = {}; +let routeRegistry; /** * Helper to extract all the different server route handler so we can easily call them in our tests. @@ -28,8 +28,6 @@ const routeHandlers = {}; * if a "server.route()" call is moved or deleted, then the HANDLER_INDEX_TO_ACTION must be updated here. */ const registerHandlers = () => { - let index = 0; - const HANDLER_INDEX_TO_ACTION = { 0: 'list', 1: 'create', @@ -40,15 +38,12 @@ const registerHandlers = () => { 6: 'resume', }; - const server = { - route({ handler }) { - // Save handler and increment index - routeHandlers[HANDLER_INDEX_TO_ACTION[index]] = handler; - index++; - }, - }; + routeRegistry = createRouter(HANDLER_INDEX_TO_ACTION); - registerAutoFollowPatternRoutes(server); + registerAutoFollowPatternRoutes({ + __LEGACY: {}, + router: routeRegistry.router, + }); }; /** @@ -94,14 +89,16 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { describe('list()', () => { beforeEach(() => { - routeHandler = routeHandlers.list; + routeHandler = routeRegistry.getRoutes().list; }); it('should deserialize the response from Elasticsearch', async () => { const totalResult = 2; setHttpRequestResponse(null, getAutoFollowPatternListMock(totalResult)); - const response = await routeHandler(); + const { + options: { body: response }, + } = await callRoute(routeHandler); const autoFollowPattern = response.patterns[0]; expect(response.patterns.length).toEqual(totalResult); @@ -112,21 +109,25 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { describe('create()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.create; + routeHandler = routeRegistry.getRoutes().create; }); it('should throw a 409 conflict error if id already exists', async () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ - payload: { - id: 'some-id', - foo: 'bar', - }, - }).catch(err => err); // return the error - - expect(response.output.statusCode).toEqual(409); + const response = await callRoute( + routeHandler, + {}, + { + body: { + id: 'some-id', + foo: 'bar', + }, + } + ); + + expect(response.status).toEqual(409); }); it('should return 200 status when the id does not exist', async () => { @@ -135,12 +136,18 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(error); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ - payload: { - id: 'some-id', - foo: 'bar', - }, - }); + const { + options: { body: response }, + } = await callRoute( + routeHandler, + {}, + { + body: { + id: 'some-id', + foo: 'bar', + }, + } + ); expect(response).toEqual({ acknowledge: true }); }); @@ -148,7 +155,7 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { describe('update()', () => { beforeEach(() => { - routeHandler = routeHandlers.update; + routeHandler = routeRegistry.getRoutes().update; }); it('should serialize the payload before sending it to Elasticsearch', async () => { @@ -156,16 +163,16 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { const request = { params: { id: 'foo' }, - payload: { + body: { remoteCluster: 'bar1', leaderIndexPatterns: ['bar2'], followIndexPattern: 'bar3', }, }; - const response = await routeHandler(request); + const response = await callRoute(routeHandler, {}, request); - expect(response).toEqual({ + expect(response.options.body).toEqual({ id: 'foo', body: { remote_cluster: 'bar1', @@ -178,7 +185,7 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { describe('get()', () => { beforeEach(() => { - routeHandler = routeHandlers.get; + routeHandler = routeRegistry.getRoutes().get; }); it('should return a single resource even though ES return an array with 1 item', async () => { @@ -187,21 +194,23 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, esResponse); - const response = await routeHandler({ params: { id: 1 } }); - expect(Object.keys(response)).toEqual(DESERIALIZED_KEYS); + const response = await callRoute(routeHandler, {}, { params: { id: 1 } }); + expect(Object.keys(response.options.body)).toEqual(DESERIALIZED_KEYS); }); }); describe('delete()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.delete; + routeHandler = routeRegistry.getRoutes().delete; }); it('should delete a single item', async () => { setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: 'a' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: 'a' } }); expect(response.itemsDeleted).toEqual(['a']); expect(response.errors).toEqual([]); @@ -212,9 +221,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: 'a,b,c' } }); + const response = await callRoute(routeHandler, {}, { params: { id: 'a,b,c' } }); - expect(response.itemsDeleted).toEqual(['a', 'b', 'c']); + expect(response.options.body.itemsDeleted).toEqual(['a', 'b', 'c']); }); it('should catch error and return them in array', async () => { @@ -224,7 +233,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(error); - const response = await routeHandler({ params: { id: 'a,b' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: 'a,b' } }); expect(response.itemsDeleted).toEqual(['a']); expect(response.errors[0].id).toEqual('b'); @@ -234,13 +245,15 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { describe('pause()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.pause; + routeHandler = routeRegistry.getRoutes().pause; }); it('accept a single item', async () => { setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: 'a' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: 'a' } }); expect(response.itemsPaused).toEqual(['a']); expect(response.errors).toEqual([]); @@ -251,9 +264,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: 'a,b,c' } }); + const response = await callRoute(routeHandler, {}, { params: { id: 'a,b,c' } }); - expect(response.itemsPaused).toEqual(['a', 'b', 'c']); + expect(response.options.body.itemsPaused).toEqual(['a', 'b', 'c']); }); it('should catch error and return them in array', async () => { @@ -263,7 +276,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(error); - const response = await routeHandler({ params: { id: 'a,b' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: 'a,b' } }); expect(response.itemsPaused).toEqual(['a']); expect(response.errors[0].id).toEqual('b'); @@ -273,13 +288,15 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { describe('resume()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.resume; + routeHandler = routeRegistry.getRoutes().resume; }); it('accept a single item', async () => { setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: 'a' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: 'a' } }); expect(response.itemsResumed).toEqual(['a']); expect(response.errors).toEqual([]); @@ -290,9 +307,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: 'a,b,c' } }); + const response = await callRoute(routeHandler, {}, { params: { id: 'a,b,c' } }); - expect(response.itemsResumed).toEqual(['a', 'b', 'c']); + expect(response.options.body.itemsResumed).toEqual(['a', 'b', 'c']); }); it('should catch error and return them in array', async () => { @@ -302,7 +319,9 @@ describe('[CCR API Routes] Auto Follow Pattern', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(error); - const response = await routeHandler({ params: { id: 'a,b' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: 'a,b' } }); expect(response.itemsResumed).toEqual(['a']); expect(response.errors[0].id).toEqual('b'); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/follower_index.test.js similarity index 72% rename from x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/follower_index.test.js index 7e363c2758a4c..f0139e5bd7011 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/follower_index.test.js @@ -3,21 +3,23 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { deserializeFollowerIndex } from '../../../common/services/follower_index_serialization'; +import { deserializeFollowerIndex } from '../../../../../common/services/follower_index_serialization'; import { getFollowerIndexStatsMock, getFollowerIndexListStatsMock, getFollowerIndexInfoMock, getFollowerIndexListInfoMock, -} from '../../../fixtures'; -import { callWithRequestFactory } from '../../lib/call_with_request_factory'; -import { isEsErrorFactory } from '../../lib/is_es_error_factory'; -import { registerFollowerIndexRoutes } from './follower_index'; - -jest.mock('../../lib/call_with_request_factory'); -jest.mock('../../lib/is_es_error_factory'); -jest.mock('../../lib/license_pre_routing_factory'); +} from '../../../../../fixtures'; +import { callWithRequestFactory } from '../../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../../lib/is_es_error_factory'; +import { registerFollowerIndexRoutes } from '../follower_index'; +import { createRouter, callRoute } from './helpers'; + +jest.mock('../../../lib/call_with_request_factory'); +jest.mock('../../../lib/is_es_error_factory'); +jest.mock('../../../lib/license_pre_routing_factory', () => ({ + licensePreRoutingFactory: ({ requestHandler }) => requestHandler, +})); const DESERIALIZED_KEYS = Object.keys( deserializeFollowerIndex({ @@ -26,10 +28,7 @@ const DESERIALIZED_KEYS = Object.keys( }) ); -/** - * Hashtable to save the route handlers - */ -const routeHandlers = {}; +let routeRegistry; /** * Helper to extract all the different server route handler so we can easily call them in our tests. @@ -38,8 +37,6 @@ const routeHandlers = {}; * if a 'server.route()' call is moved or deleted, then the HANDLER_INDEX_TO_ACTION must be updated here. */ const registerHandlers = () => { - let index = 0; - const HANDLER_INDEX_TO_ACTION = { 0: 'list', 1: 'get', @@ -50,15 +47,11 @@ const registerHandlers = () => { 6: 'unfollow', }; - const server = { - route({ handler }) { - // Save handler and increment index - routeHandlers[HANDLER_INDEX_TO_ACTION[index]] = handler; - index++; - }, - }; - - registerFollowerIndexRoutes(server); + routeRegistry = createRouter(HANDLER_INDEX_TO_ACTION); + registerFollowerIndexRoutes({ + __LEGACY: {}, + router: routeRegistry.router, + }); }; /** @@ -104,7 +97,7 @@ describe('[CCR API Routes] Follower Index', () => { describe('list()', () => { beforeEach(() => { - routeHandler = routeHandlers.list; + routeHandler = routeRegistry.getRoutes().list; }); it('deserializes the response from Elasticsearch', async () => { @@ -117,7 +110,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, infoResult); setHttpRequestResponse(null, statsResult); - const response = await routeHandler(); + const { + options: { body: response }, + } = await callRoute(routeHandler); const followerIndex = response.indices[0]; expect(response.indices.length).toEqual(totalResult); @@ -127,7 +122,7 @@ describe('[CCR API Routes] Follower Index', () => { describe('get()', () => { beforeEach(() => { - routeHandler = routeHandlers.get; + routeHandler = routeRegistry.getRoutes().get; }); it('should return a single resource even though ES return an array with 1 item', async () => { @@ -138,7 +133,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { follower_indices: [followerIndexInfo] }); setHttpRequestResponse(null, { indices: [followerIndexStats] }); - const response = await routeHandler({ params: { id: mockId } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: mockId } }); expect(Object.keys(response)).toEqual(DESERIALIZED_KEYS); }); }); @@ -146,34 +143,40 @@ describe('[CCR API Routes] Follower Index', () => { describe('create()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.create; + routeHandler = routeRegistry.getRoutes().create; }); it('should return 200 status when follower index is created', async () => { setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ - payload: { - name: 'follower_index', - remoteCluster: 'remote_cluster', - leaderIndex: 'leader_index', - }, - }); + const response = await callRoute( + routeHandler, + {}, + { + body: { + name: 'follower_index', + remoteCluster: 'remote_cluster', + leaderIndex: 'leader_index', + }, + } + ); - expect(response).toEqual({ acknowledge: true }); + expect(response.options.body).toEqual({ acknowledge: true }); }); }); describe('pause()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.pause; + routeHandler = routeRegistry.getRoutes().pause; }); it('should pause a single item', async () => { setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: '1' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: '1' } }); expect(response.itemsPaused).toEqual(['1']); expect(response.errors).toEqual([]); @@ -184,9 +187,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: '1,2,3' } }); + const response = await callRoute(routeHandler, {}, { params: { id: '1,2,3' } }); - expect(response.itemsPaused).toEqual(['1', '2', '3']); + expect(response.options.body.itemsPaused).toEqual(['1', '2', '3']); }); it('should catch error and return them in array', async () => { @@ -196,7 +199,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(error); - const response = await routeHandler({ params: { id: '1,2' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: '1,2' } }); expect(response.itemsPaused).toEqual(['1']); expect(response.errors[0].id).toEqual('2'); @@ -206,13 +211,15 @@ describe('[CCR API Routes] Follower Index', () => { describe('resume()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.resume; + routeHandler = routeRegistry.getRoutes().resume; }); it('should resume a single item', async () => { setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: '1' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: '1' } }); expect(response.itemsResumed).toEqual(['1']); expect(response.errors).toEqual([]); @@ -223,9 +230,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: '1,2,3' } }); + const response = await callRoute(routeHandler, {}, { params: { id: '1,2,3' } }); - expect(response.itemsResumed).toEqual(['1', '2', '3']); + expect(response.options.body.itemsResumed).toEqual(['1', '2', '3']); }); it('should catch error and return them in array', async () => { @@ -235,7 +242,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(error); - const response = await routeHandler({ params: { id: '1,2' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: '1,2' } }); expect(response.itemsResumed).toEqual(['1']); expect(response.errors[0].id).toEqual('2'); @@ -245,7 +254,7 @@ describe('[CCR API Routes] Follower Index', () => { describe('unfollow()', () => { beforeEach(() => { resetHttpRequestResponses(); - routeHandler = routeHandlers.unfollow; + routeHandler = routeRegistry.getRoutes().unfollow; }); it('should unfollow await single item', async () => { @@ -254,7 +263,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: '1' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: '1' } }); expect(response.itemsUnfollowed).toEqual(['1']); expect(response.errors).toEqual([]); @@ -274,9 +285,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(null, { acknowledge: true }); - const response = await routeHandler({ params: { id: '1,2,3' } }); + const response = await callRoute(routeHandler, {}, { params: { id: '1,2,3' } }); - expect(response.itemsUnfollowed).toEqual(['1', '2', '3']); + expect(response.options.body.itemsUnfollowed).toEqual(['1', '2', '3']); }); it('should catch error and return them in array', async () => { @@ -290,7 +301,9 @@ describe('[CCR API Routes] Follower Index', () => { setHttpRequestResponse(null, { acknowledge: true }); setHttpRequestResponse(error); - const response = await routeHandler({ params: { id: '1,2' } }); + const { + options: { body: response }, + } = await callRoute(routeHandler, {}, { params: { id: '1,2' } }); expect(response.itemsUnfollowed).toEqual(['1']); expect(response.errors[0].id).toEqual('2'); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/helpers.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/helpers.ts new file mode 100644 index 0000000000000..555fc0937c0ad --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/__jest__/helpers.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RequestHandler } from 'src/core/server'; +import { kibanaResponseFactory } from '../../../../../../../../../src/core/server'; + +export const callRoute = ( + route: RequestHandler, + ctx = {}, + request = {}, + response = kibanaResponseFactory +) => { + return route(ctx as any, request as any, response); +}; + +export const createRouter = (indexToActionMap: Record) => { + let index = 0; + const routeHandlers: Record> = {}; + const addHandler = (ignoreCtxForNow: any, handler: RequestHandler) => { + // Save handler and increment index + routeHandlers[indexToActionMap[index]] = handler; + index++; + }; + + return { + getRoutes: () => routeHandlers, + router: { + get: addHandler, + post: addHandler, + put: addHandler, + delete: addHandler, + }, + }; +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/auto_follow_pattern.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/auto_follow_pattern.ts new file mode 100644 index 0000000000000..d458f1ccb354b --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/auto_follow_pattern.ts @@ -0,0 +1,301 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; +// @ts-ignore +import { callWithRequestFactory } from '../../lib/call_with_request_factory'; +import { isEsError } from '../../lib/is_es_error'; +// @ts-ignore +import { + deserializeAutoFollowPattern, + deserializeListAutoFollowPatterns, + serializeAutoFollowPattern, + // @ts-ignore +} from '../../../../common/services/auto_follow_pattern_serialization'; + +import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; +import { API_BASE_PATH } from '../../../../common/constants'; + +import { RouteDependencies } from '../types'; +import { mapErrorToKibanaHttpResponse } from '../map_to_kibana_http_error'; + +export const registerAutoFollowPatternRoutes = ({ router, __LEGACY }: RouteDependencies) => { + /** + * Returns a list of all auto-follow patterns + */ + router.get( + { + path: `${API_BASE_PATH}/auto_follow_patterns`, + validate: false, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + + try { + const result = await callWithRequest('ccr.autoFollowPatterns'); + return response.ok({ + body: { + patterns: deserializeListAutoFollowPatterns(result.patterns), + }, + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Create an auto-follow pattern + */ + router.post( + { + path: `${API_BASE_PATH}/auto_follow_patterns`, + validate: { + body: schema.object( + { + id: schema.string(), + }, + { unknowns: 'allow' } + ), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id, ...rest } = request.body; + const body = serializeAutoFollowPattern(rest); + + /** + * First let's make sur that an auto-follow pattern with + * the same id does not exist. + */ + try { + await callWithRequest('ccr.autoFollowPattern', { id }); + // If we get here it means that an auto-follow pattern with the same id exists + return response.conflict({ + body: `An auto-follow pattern with the name "${id}" already exists.`, + }); + } catch (err) { + if (err.statusCode !== 404) { + return mapErrorToKibanaHttpResponse(err); + } + } + + try { + return response.ok({ + body: await callWithRequest('ccr.saveAutoFollowPattern', { id, body }), + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Update an auto-follow pattern + */ + router.put( + { + path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({}, { unknowns: 'allow' }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const body = serializeAutoFollowPattern(request.body); + + try { + return response.ok({ + body: await callWithRequest('ccr.saveAutoFollowPattern', { id, body }), + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Returns a single auto-follow pattern + */ + router.get( + { + path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + + try { + const result = await callWithRequest('ccr.autoFollowPattern', { id }); + const autoFollowPattern = result.patterns[0]; + + return response.ok({ + body: deserializeAutoFollowPattern(autoFollowPattern), + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Delete an auto-follow pattern + */ + router.delete( + { + path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsDeleted: string[] = []; + const errors: Array<{ id: string; error: any }> = []; + + await Promise.all( + ids.map(_id => + callWithRequest('ccr.deleteAutoFollowPattern', { id: _id }) + .then(() => itemsDeleted.push(_id)) + .catch((err: Error) => { + if (isEsError(err)) { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } else { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } + }) + ) + ); + + return response.ok({ + body: { + itemsDeleted, + errors, + }, + }); + }, + }) + ); + + /** + * Pause auto-follow pattern(s) + */ + router.post( + { + path: `${API_BASE_PATH}/auto_follow_patterns/{id}/pause`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsPaused: string[] = []; + const errors: Array<{ id: string; error: any }> = []; + + await Promise.all( + ids.map(_id => + callWithRequest('ccr.pauseAutoFollowPattern', { id: _id }) + .then(() => itemsPaused.push(_id)) + .catch((err: Error) => { + if (isEsError(err)) { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } else { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } + }) + ) + ); + + return response.ok({ + body: { + itemsPaused, + errors, + }, + }); + }, + }) + ); + + /** + * Resume auto-follow pattern(s) + */ + router.post( + { + path: `${API_BASE_PATH}/auto_follow_patterns/{id}/resume`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsResumed: string[] = []; + const errors: Array<{ id: string; error: any }> = []; + + await Promise.all( + ids.map(_id => + callWithRequest('ccr.resumeAutoFollowPattern', { id: _id }) + .then(() => itemsResumed.push(_id)) + .catch((err: Error) => { + if (isEsError(err)) { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } else { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } + }) + ) + ); + + return response.ok({ + body: { + itemsResumed, + errors, + }, + }); + }, + }) + ); +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/ccr.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/ccr.ts new file mode 100644 index 0000000000000..b08b056ad2c8a --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/ccr.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { API_BASE_PATH } from '../../../../common/constants'; +// @ts-ignore +import { callWithRequestFactory } from '../../lib/call_with_request_factory'; +// @ts-ignore +import { deserializeAutoFollowStats } from '../../lib/ccr_stats_serialization'; +import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; + +import { mapErrorToKibanaHttpResponse } from '../map_to_kibana_http_error'; +import { RouteDependencies } from '../types'; + +export const registerCcrRoutes = ({ router, __LEGACY }: RouteDependencies) => { + /** + * Returns Auto-follow stats + */ + router.get( + { + path: `${API_BASE_PATH}/stats/auto_follow`, + validate: false, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + + try { + const { auto_follow_stats: autoFollowStats } = await callWithRequest('ccr.stats'); + + return response.ok({ + body: deserializeAutoFollowStats(autoFollowStats), + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Returns whether the user has CCR permissions + */ + router.get( + { + path: `${API_BASE_PATH}/permissions`, + validate: false, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const xpackMainPlugin = __LEGACY.server.plugins.xpack_main; + const xpackInfo = xpackMainPlugin && xpackMainPlugin.info; + + if (!xpackInfo) { + // xpackInfo is updated via poll, so it may not be available until polling has begun. + // In this rare situation, tell the client the service is temporarily unavailable. + return response.customError({ + statusCode: 503, + body: 'Security info unavailable', + }); + } + + const securityInfo = xpackInfo && xpackInfo.isAvailable() && xpackInfo.feature('security'); + if (!securityInfo || !securityInfo.isAvailable() || !securityInfo.isEnabled()) { + // If security isn't enabled or available (in the case where security is enabled but license reverted to Basic) let the user use CCR. + return response.ok({ + body: { + hasPermission: true, + missingClusterPrivileges: [], + }, + }); + } + + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + + try { + const { has_all_requested: hasPermission, cluster } = await callWithRequest( + 'ccr.permissions', + { + body: { + cluster: ['manage', 'manage_ccr'], + }, + } + ); + + const missingClusterPrivileges = Object.keys(cluster).reduce( + (permissions: any, permissionName: any) => { + if (!cluster[permissionName]) { + permissions.push(permissionName); + return permissions; + } + }, + [] as any[] + ); + + return response.ok({ + body: { + hasPermission, + missingClusterPrivileges, + }, + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/follower_index.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/follower_index.ts new file mode 100644 index 0000000000000..3896e1c02c915 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/follower_index.ts @@ -0,0 +1,345 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; +import { + deserializeFollowerIndex, + deserializeListFollowerIndices, + serializeFollowerIndex, + serializeAdvancedSettings, + // @ts-ignore +} from '../../../../common/services/follower_index_serialization'; +import { API_BASE_PATH } from '../../../../common/constants'; +// @ts-ignore +import { removeEmptyFields } from '../../../../common/services/utils'; +// @ts-ignore +import { callWithRequestFactory } from '../../lib/call_with_request_factory'; +import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; + +import { RouteDependencies } from '../types'; +import { mapErrorToKibanaHttpResponse } from '../map_to_kibana_http_error'; + +export const registerFollowerIndexRoutes = ({ router, __LEGACY }: RouteDependencies) => { + /** + * Returns a list of all follower indices + */ + router.get( + { + path: `${API_BASE_PATH}/follower_indices`, + validate: false, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + + try { + const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { + id: '_all', + }); + + const { + follow_stats: { indices: followerIndicesStats }, + } = await callWithRequest('ccr.stats'); + + const followerIndicesStatsMap = followerIndicesStats.reduce((map: any, stats: any) => { + map[stats.index] = stats; + return map; + }, {}); + + const collatedFollowerIndices = followerIndices.map((followerIndex: any) => { + return { + ...followerIndex, + ...followerIndicesStatsMap[followerIndex.follower_index], + }; + }); + + return response.ok({ + body: { + indices: deserializeListFollowerIndices(collatedFollowerIndices), + }, + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Returns a single follower index pattern + */ + router.get( + { + path: `${API_BASE_PATH}/follower_indices/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + + try { + const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { id }); + + const followerIndexInfo = followerIndices && followerIndices[0]; + + if (!followerIndexInfo) { + return response.notFound({ + body: `The follower index "${id}" does not exist.`, + }); + } + + // If this follower is paused, skip call to ES stats api since it will return 404 + if (followerIndexInfo.status === 'paused') { + return response.ok({ + body: deserializeFollowerIndex({ + ...followerIndexInfo, + }), + }); + } else { + const { + indices: followerIndicesStats, + } = await callWithRequest('ccr.followerIndexStats', { id }); + + return response.ok({ + body: deserializeFollowerIndex({ + ...followerIndexInfo, + ...(followerIndicesStats ? followerIndicesStats[0] : {}), + }), + }); + } + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Create a follower index + */ + router.post( + { + path: `${API_BASE_PATH}/follower_indices`, + validate: { + body: schema.object( + { + name: schema.string(), + }, + { unknowns: 'allow' } + ), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { name, ...rest } = request.body; + const body = removeEmptyFields(serializeFollowerIndex(rest)); + + try { + return response.ok({ + body: await callWithRequest('ccr.saveFollowerIndex', { name, body }), + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Edit a follower index + */ + router.put( + { + path: `${API_BASE_PATH}/follower_indices/{id}`, + validate: { + params: schema.object({ id: schema.string() }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + + // We need to first pause the follower and then resume it passing the advanced settings + try { + const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { id }); + const followerIndexInfo = followerIndices && followerIndices[0]; + if (!followerIndexInfo) { + return response.notFound({ body: `The follower index "${id}" does not exist.` }); + } + + // Retrieve paused state instead of pulling it from the payload to ensure it's not stale. + const isPaused = followerIndexInfo.status === 'paused'; + // Pause follower if not already paused + if (!isPaused) { + await callWithRequest('ccr.pauseFollowerIndex', { id }); + } + + // Resume follower + const body = removeEmptyFields(serializeAdvancedSettings(request.body)); + return response.ok({ + body: await callWithRequest('ccr.resumeFollowerIndex', { id, body }), + }); + } catch (err) { + return mapErrorToKibanaHttpResponse(err); + } + }, + }) + ); + + /** + * Pauses a follower index + */ + router.put( + { + path: `${API_BASE_PATH}/follower_indices/{id}/pause`, + validate: { + params: schema.object({ id: schema.string() }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsPaused: string[] = []; + const errors: Array<{ id: string; error: any }> = []; + + await Promise.all( + ids.map(_id => + callWithRequest('ccr.pauseFollowerIndex', { id: _id }) + .then(() => itemsPaused.push(_id)) + .catch((err: Error) => { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + }) + ) + ); + + return response.ok({ + body: { + itemsPaused, + errors, + }, + }); + }, + }) + ); + + /** + * Resumes a follower index + */ + router.put( + { + path: `${API_BASE_PATH}/follower_indices/{id}/resume`, + validate: { + params: schema.object({ id: schema.string() }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsResumed: string[] = []; + const errors: Array<{ id: string; error: any }> = []; + + await Promise.all( + ids.map(_id => + callWithRequest('ccr.resumeFollowerIndex', { id: _id }) + .then(() => itemsResumed.push(_id)) + .catch((err: Error) => { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + }) + ) + ); + + return response.ok({ + body: { + itemsResumed, + errors, + }, + }); + }, + }) + ); + + /** + * Unfollow follower index's leader index + */ + router.put( + { + path: `${API_BASE_PATH}/follower_indices/{id}/unfollow`, + validate: { + params: schema.object({ id: schema.string() }), + }, + }, + licensePreRoutingFactory({ + __LEGACY, + requestHandler: async (ctx, request, response) => { + const callWithRequest = callWithRequestFactory(__LEGACY.server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsUnfollowed: string[] = []; + const itemsNotOpen: string[] = []; + const errors: Array<{ id: string; error: any }> = []; + + await Promise.all( + ids.map(async _id => { + try { + // Try to pause follower, let it fail silently since it may already be paused + try { + await callWithRequest('ccr.pauseFollowerIndex', { id: _id }); + } catch (e) { + // Swallow errors + } + + // Close index + await callWithRequest('indices.close', { index: _id }); + + // Unfollow leader + await callWithRequest('ccr.unfollowLeaderIndex', { id: _id }); + + // Try to re-open the index, store failures in a separate array to surface warnings in the UI + // This will allow users to query their index normally after unfollowing + try { + await callWithRequest('indices.open', { index: _id }); + } catch (e) { + itemsNotOpen.push(_id); + } + + // Push success + itemsUnfollowed.push(_id); + } catch (err) { + errors.push({ id: _id, error: mapErrorToKibanaHttpResponse(err) }); + } + }) + ); + + return response.ok({ + body: { + itemsUnfollowed, + itemsNotOpen, + errors, + }, + }); + }, + }) + ); +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/map_to_kibana_http_error.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/map_to_kibana_http_error.ts new file mode 100644 index 0000000000000..6a81bd26dc47d --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/map_to_kibana_http_error.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { kibanaResponseFactory } from '../../../../../../../src/core/server'; +// @ts-ignore +import { wrapEsError } from '../lib/error_wrappers'; +import { isEsError } from '../lib/is_es_error'; + +export const mapErrorToKibanaHttpResponse = (err: any) => { + if (isEsError(err)) { + const { statusCode, message, body } = wrapEsError(err); + return kibanaResponseFactory.customError({ + statusCode, + body: { + message, + attributes: { + cause: body?.cause, + }, + }, + }); + } + return kibanaResponseFactory.internalError(err); +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/register_routes.js b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/register_routes.ts similarity index 67% rename from x-pack/legacy/plugins/cross_cluster_replication/server/routes/register_routes.js rename to x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/register_routes.ts index 6e4088ec8600f..7e59417550691 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/register_routes.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/register_routes.ts @@ -7,9 +7,10 @@ import { registerAutoFollowPatternRoutes } from './api/auto_follow_pattern'; import { registerFollowerIndexRoutes } from './api/follower_index'; import { registerCcrRoutes } from './api/ccr'; +import { RouteDependencies } from './types'; -export function registerRoutes(server) { - registerAutoFollowPatternRoutes(server); - registerFollowerIndexRoutes(server); - registerCcrRoutes(server); +export function registerRoutes(deps: RouteDependencies) { + registerAutoFollowPatternRoutes(deps); + registerFollowerIndexRoutes(deps); + registerCcrRoutes(deps); } diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/types.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/types.ts new file mode 100644 index 0000000000000..7f57c20c536e0 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/types.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { IRouter } from 'src/core/server'; + +export interface RouteDependencies { + router: IRouter; + __LEGACY: { + server: any; + }; +} diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js b/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js deleted file mode 100644 index 4667f0a110c1f..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js +++ /dev/null @@ -1,256 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import Boom from 'boom'; -import { callWithRequestFactory } from '../../lib/call_with_request_factory'; -import { isEsErrorFactory } from '../../lib/is_es_error_factory'; -import { wrapEsError, wrapUnknownError } from '../../lib/error_wrappers'; -import { - deserializeAutoFollowPattern, - deserializeListAutoFollowPatterns, - serializeAutoFollowPattern, -} from '../../../common/services/auto_follow_pattern_serialization'; -import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; -import { API_BASE_PATH } from '../../../common/constants'; - -export const registerAutoFollowPatternRoutes = server => { - const isEsError = isEsErrorFactory(server); - const licensePreRouting = licensePreRoutingFactory(server); - - /** - * Returns a list of all auto-follow patterns - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns`, - method: 'GET', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - - try { - const response = await callWithRequest('ccr.autoFollowPatterns'); - return { - patterns: deserializeListAutoFollowPatterns(response.patterns), - }; - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Create an auto-follow pattern - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns`, - method: 'POST', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id, ...rest } = request.payload; - const body = serializeAutoFollowPattern(rest); - - /** - * First let's make sur that an auto-follow pattern with - * the same id does not exist. - */ - try { - await callWithRequest('ccr.autoFollowPattern', { id }); - // If we get here it means that an auto-follow pattern with the same id exists - const error = Boom.conflict(`An auto-follow pattern with the name "${id}" already exists.`); - throw error; - } catch (err) { - if (err.statusCode !== 404) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - } - - try { - return await callWithRequest('ccr.saveAutoFollowPattern', { id, body }); - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Update an auto-follow pattern - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, - method: 'PUT', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const body = serializeAutoFollowPattern(request.payload); - - try { - return await callWithRequest('ccr.saveAutoFollowPattern', { id, body }); - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Returns a single auto-follow pattern - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, - method: 'GET', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - - try { - const response = await callWithRequest('ccr.autoFollowPattern', { id }); - const autoFollowPattern = response.patterns[0]; - - return deserializeAutoFollowPattern(autoFollowPattern); - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Delete an auto-follow pattern - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, - method: 'DELETE', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const ids = id.split(','); - - const itemsDeleted = []; - const errors = []; - - await Promise.all( - ids.map(_id => - callWithRequest('ccr.deleteAutoFollowPattern', { id: _id }) - .then(() => itemsDeleted.push(_id)) - .catch(err => { - if (isEsError(err)) { - errors.push({ id: _id, error: wrapEsError(err) }); - } else { - errors.push({ id: _id, error: wrapUnknownError(err) }); - } - }) - ) - ); - - return { - itemsDeleted, - errors, - }; - }, - }); - - /** - * Pause auto-follow pattern(s) - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns/{id}/pause`, - method: 'POST', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const ids = id.split(','); - - const itemsPaused = []; - const errors = []; - - await Promise.all( - ids.map(_id => - callWithRequest('ccr.pauseAutoFollowPattern', { id: _id }) - .then(() => itemsPaused.push(_id)) - .catch(err => { - if (isEsError(err)) { - errors.push({ id: _id, error: wrapEsError(err) }); - } else { - errors.push({ id: _id, error: wrapUnknownError(err) }); - } - }) - ) - ); - - return { - itemsPaused, - errors, - }; - }, - }); - - /** - * Resume auto-follow pattern(s) - */ - server.route({ - path: `${API_BASE_PATH}/auto_follow_patterns/{id}/resume`, - method: 'POST', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const ids = id.split(','); - - const itemsResumed = []; - const errors = []; - - await Promise.all( - ids.map(_id => - callWithRequest('ccr.resumeAutoFollowPattern', { id: _id }) - .then(() => itemsResumed.push(_id)) - .catch(err => { - if (isEsError(err)) { - errors.push({ id: _id, error: wrapEsError(err) }); - } else { - errors.push({ id: _id, error: wrapUnknownError(err) }); - } - }) - ) - ); - - return { - itemsResumed, - errors, - }; - }, - }); -}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/ccr.js b/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/ccr.js deleted file mode 100644 index 8255eb6e86b07..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/ccr.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; - * you may not use this file except in compliance with the Elastic License. - */ - -import Boom from 'boom'; - -import { API_BASE_PATH } from '../../../common/constants'; -import { callWithRequestFactory } from '../../lib/call_with_request_factory'; -import { isEsErrorFactory } from '../../lib/is_es_error_factory'; -import { wrapEsError, wrapUnknownError } from '../../lib/error_wrappers'; -import { deserializeAutoFollowStats } from '../../lib/ccr_stats_serialization'; -import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; - -export const registerCcrRoutes = server => { - const isEsError = isEsErrorFactory(server); - const licensePreRouting = licensePreRoutingFactory(server); - - /** - * Returns Auto-follow stats - */ - server.route({ - path: `${API_BASE_PATH}/stats/auto_follow`, - method: 'GET', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - - try { - const { auto_follow_stats: autoFollowStats } = await callWithRequest('ccr.stats'); - - return deserializeAutoFollowStats(autoFollowStats); - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Returns whether the user has CCR permissions - */ - server.route({ - path: `${API_BASE_PATH}/permissions`, - method: 'GET', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const xpackMainPlugin = server.plugins.xpack_main; - const xpackInfo = xpackMainPlugin && xpackMainPlugin.info; - - if (!xpackInfo) { - // xpackInfo is updated via poll, so it may not be available until polling has begun. - // In this rare situation, tell the client the service is temporarily unavailable. - throw new Boom('Security info unavailable', { statusCode: 503 }); - } - - const securityInfo = xpackInfo && xpackInfo.isAvailable() && xpackInfo.feature('security'); - if (!securityInfo || !securityInfo.isAvailable() || !securityInfo.isEnabled()) { - // If security isn't enabled or available (in the case where security is enabled but license reverted to Basic) let the user use CCR. - return { - hasPermission: true, - missingClusterPrivileges: [], - }; - } - - const callWithRequest = callWithRequestFactory(server, request); - - try { - const { has_all_requested: hasPermission, cluster } = await callWithRequest( - 'ccr.permissions', - { - body: { - cluster: ['manage', 'manage_ccr'], - }, - } - ); - - const missingClusterPrivileges = Object.keys(cluster).reduce( - (permissions, permissionName) => { - if (!cluster[permissionName]) { - permissions.push(permissionName); - return permissions; - } - }, - [] - ); - - return { - hasPermission, - missingClusterPrivileges, - }; - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); -}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.js b/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.js deleted file mode 100644 index e532edaa39636..0000000000000 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/routes/api/follower_index.js +++ /dev/null @@ -1,328 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Boom from 'boom'; - -import { - deserializeFollowerIndex, - deserializeListFollowerIndices, - serializeFollowerIndex, - serializeAdvancedSettings, -} from '../../../common/services/follower_index_serialization'; -import { API_BASE_PATH } from '../../../common/constants'; -import { removeEmptyFields } from '../../../common/services/utils'; -import { callWithRequestFactory } from '../../lib/call_with_request_factory'; -import { isEsErrorFactory } from '../../lib/is_es_error_factory'; -import { wrapEsError, wrapUnknownError } from '../../lib/error_wrappers'; -import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory'; - -export const registerFollowerIndexRoutes = server => { - const isEsError = isEsErrorFactory(server); - const licensePreRouting = licensePreRoutingFactory(server); - - /** - * Returns a list of all follower indices - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices`, - method: 'GET', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - - try { - const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { - id: '_all', - }); - - const { - follow_stats: { indices: followerIndicesStats }, - } = await callWithRequest('ccr.stats'); - - const followerIndicesStatsMap = followerIndicesStats.reduce((map, stats) => { - map[stats.index] = stats; - return map; - }, {}); - - const collatedFollowerIndices = followerIndices.map(followerIndex => { - return { - ...followerIndex, - ...followerIndicesStatsMap[followerIndex.follower_index], - }; - }); - - return { - indices: deserializeListFollowerIndices(collatedFollowerIndices), - }; - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Returns a single follower index pattern - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices/{id}`, - method: 'GET', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - - try { - const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { id }); - - const followerIndexInfo = followerIndices && followerIndices[0]; - - if (!followerIndexInfo) { - const error = Boom.notFound(`The follower index "${id}" does not exist.`); - throw error; - } - - // If this follower is paused, skip call to ES stats api since it will return 404 - if (followerIndexInfo.status === 'paused') { - return deserializeFollowerIndex({ - ...followerIndexInfo, - }); - } else { - const { indices: followerIndicesStats } = await callWithRequest( - 'ccr.followerIndexStats', - { id } - ); - - return deserializeFollowerIndex({ - ...followerIndexInfo, - ...(followerIndicesStats ? followerIndicesStats[0] : {}), - }); - } - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Create a follower index - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices`, - method: 'POST', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { name, ...rest } = request.payload; - const body = removeEmptyFields(serializeFollowerIndex(rest)); - - try { - return await callWithRequest('ccr.saveFollowerIndex', { name, body }); - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Edit a follower index - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices/{id}`, - method: 'PUT', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - - async function isFollowerIndexPaused() { - const { follower_indices: followerIndices } = await callWithRequest('ccr.info', { id }); - - const followerIndexInfo = followerIndices && followerIndices[0]; - - if (!followerIndexInfo) { - const error = Boom.notFound(`The follower index "${id}" does not exist.`); - throw error; - } - - return followerIndexInfo.status === 'paused'; - } - - // We need to first pause the follower and then resume it passing the advanced settings - try { - // Retrieve paused state instead of pulling it from the payload to ensure it's not stale. - const isPaused = await isFollowerIndexPaused(); - // Pause follower if not already paused - if (!isPaused) { - await callWithRequest('ccr.pauseFollowerIndex', { id }); - } - - // Resume follower - const body = removeEmptyFields(serializeAdvancedSettings(request.payload)); - return await callWithRequest('ccr.resumeFollowerIndex', { id, body }); - } catch (err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }, - }); - - /** - * Pauses a follower index - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices/{id}/pause`, - method: 'PUT', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const ids = id.split(','); - - const itemsPaused = []; - const errors = []; - - await Promise.all( - ids.map(_id => - callWithRequest('ccr.pauseFollowerIndex', { id: _id }) - .then(() => itemsPaused.push(_id)) - .catch(err => { - if (isEsError(err)) { - errors.push({ id: _id, error: wrapEsError(err) }); - } else { - errors.push({ id: _id, error: wrapUnknownError(err) }); - } - }) - ) - ); - - return { - itemsPaused, - errors, - }; - }, - }); - - /** - * Resumes a follower index - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices/{id}/resume`, - method: 'PUT', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const ids = id.split(','); - - const itemsResumed = []; - const errors = []; - - await Promise.all( - ids.map(_id => - callWithRequest('ccr.resumeFollowerIndex', { id: _id }) - .then(() => itemsResumed.push(_id)) - .catch(err => { - if (isEsError(err)) { - errors.push({ id: _id, error: wrapEsError(err) }); - } else { - errors.push({ id: _id, error: wrapUnknownError(err) }); - } - }) - ) - ); - - return { - itemsResumed, - errors, - }; - }, - }); - - /** - * Unfollow follower index's leader index - */ - server.route({ - path: `${API_BASE_PATH}/follower_indices/{id}/unfollow`, - method: 'PUT', - config: { - pre: [licensePreRouting], - }, - handler: async request => { - const callWithRequest = callWithRequestFactory(server, request); - const { id } = request.params; - const ids = id.split(','); - - const itemsUnfollowed = []; - const itemsNotOpen = []; - const errors = []; - - await Promise.all( - ids.map(async _id => { - try { - // Try to pause follower, let it fail silently since it may already be paused - try { - await callWithRequest('ccr.pauseFollowerIndex', { id: _id }); - } catch (e) { - // Swallow errors - } - - // Close index - await callWithRequest('indices.close', { index: _id }); - - // Unfollow leader - await callWithRequest('ccr.unfollowLeaderIndex', { id: _id }); - - // Try to re-open the index, store failures in a separate array to surface warnings in the UI - // This will allow users to query their index normally after unfollowing - try { - await callWithRequest('indices.open', { index: _id }); - } catch (e) { - itemsNotOpen.push(_id); - } - - // Push success - itemsUnfollowed.push(_id); - } catch (err) { - if (isEsError(err)) { - errors.push({ id: _id, error: wrapEsError(err) }); - } else { - errors.push({ id: _id, error: wrapUnknownError(err) }); - } - } - }) - ); - - return { - itemsUnfollowed, - itemsNotOpen, - errors, - }; - }, - }); -}; diff --git a/x-pack/legacy/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap b/x-pack/legacy/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap deleted file mode 100644 index e19958568b3be..0000000000000 --- a/x-pack/legacy/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap +++ /dev/null @@ -1,2826 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`UploadLicense should display a modal when license requires acknowledgement 1`] = ` - - - - - -
- -
- -

- - Upload your license - -

-
- -
- - - -
-
-
-
- -
-
-
- Confirm License Upload -
-
-
-
-
-
-
- Some functionality will be lost if you replace your TRIAL license with a BASIC license. Review the list of features below. -
-
-
    -
  • - Watcher will be disabled -
  • -
-
-
-
-
-
-
- - -
-
-
-
-
-
- } - > - - } - confirmButtonText={ - - } - onCancel={[Function]} - onConfirm={[Function]} - title={ - - } - > - - - -
-
-
- -
- -
-
-
- Confirm License Upload -
-
-
-
-
-
-
- Some functionality will be lost if you replace your TRIAL license with a BASIC license. Review the list of features below. -
-
-
    -
  • - Watcher will be disabled -
  • -
-
-
-
-
-
-
- - -
-
-
-
- } - onActivation={[Function]} - onDeactivation={[Function]} - persistentFocus={false} - > - -
- -
-
-
- Confirm License Upload -
-
-
-
-
-
-
- Some functionality will be lost if you replace your TRIAL license with a BASIC license. Review the list of features below. -
-
-
    -
  • - Watcher will be disabled -
  • -
-
-
-
-
-
-
- - -
-
-
-
- } - onActivation={[Function]} - onDeactivation={[Function]} - persistentFocus={false} - /> - -
- - - - - -
- -
- -
- - Confirm License Upload - -
-
-
-
- -
-
- -
-
- -
- Some functionality will be lost if you replace your TRIAL license with a BASIC license. Review the list of features below. -
-
- -
-
    -
  • - Watcher will be disabled -
  • -
-
-
-
-
-
-
-
-
- -
- - - - - - -
-
-
-
-
-
- - - - - - - -
-

- - Your license key is a JSON file with a signature attached. - -

-

- - - , - } - } - > - Uploading a license will replace your current - - license. - -

-
-
- -
- - -
- -
- -
- -
- - } - onChange={[Function]} - > - -
-
- - - -
-
-
- - -
- -
- -
- - -
- - -
- - -
- - - - -
- - - -
-
-
-
-
- -
- -
- - - - - -`; - -exports[`UploadLicense should display an error when ES says license is expired 1`] = ` - - - - - -
- -
- -

- - Upload your license - -

-
- -
- - -
-

- - Your license key is a JSON file with a signature attached. - -

-

- - - , - } - } - > - Uploading a license will replace your current - - license. - -

-
- - -
- - -
- - -
-
- - Please address the errors in your form. - -
- -
-
    -
  • - The supplied license has expired. -
  • -
-
-
-
-
-
- -
- -
- -
- - } - onChange={[Function]} - > - -
-
- - - -
-
-
- - -
- -
- -
- - -
- - -
- - -
- - - - -
- - - -
-
-
-
-
- -
- -
- - - - - -`; - -exports[`UploadLicense should display an error when ES says license is invalid 1`] = ` - - - - - -
- -
- -

- - Upload your license - -

-
- -
- - -
-

- - Your license key is a JSON file with a signature attached. - -

-

- - - , - } - } - > - Uploading a license will replace your current - - license. - -

-
- - -
- - -
- - -
-
- - Please address the errors in your form. - -
- -
-
    -
  • - The supplied license is not valid for this product. -
  • -
-
-
-
-
-
- -
- -
- -
- - } - onChange={[Function]} - > - -
-
- - - -
-
-
- - -
- -
- -
- - -
- - -
- - -
- - - - -
- - - -
-
-
-
-
- -
- -
- - - - - -`; - -exports[`UploadLicense should display an error when submitting invalid JSON 1`] = ` - - - - - -
- -
- -

- - Upload your license - -

-
- -
- - -
-

- - Your license key is a JSON file with a signature attached. - -

-

- - - , - } - } - > - Uploading a license will replace your current - - license. - -

-
- - -
- - -
- - -
-
- - Please address the errors in your form. - -
- -
-
    -
  • - Error encountered uploading license: Check your license file. -
  • -
-
-
-
-
-
- -
- -
- -
- - } - onChange={[Function]} - > - -
-
- - - -
-
-
- - -
- -
- -
- - -
- - -
- - -
- - - - -
- - - -
-
-
-
-
- -
- -
- - - - - -`; - -exports[`UploadLicense should display error when ES returns error 1`] = ` - - - - - -
- -
- -

- - Upload your license - -

-
- -
- - -
-

- - Your license key is a JSON file with a signature attached. - -

-

- - - , - } - } - > - Uploading a license will replace your current - - license. - -

-
- - -
- - -
- - -
-
- - Please address the errors in your form. - -
- -
-
    -
  • - Error encountered uploading license: Can not upgrade to a production license unless TLS is configured or security is disabled -
  • -
-
-
-
-
-
- -
- -
- -
- - } - onChange={[Function]} - > - -
-
- - - -
-
-
- - -
- -
- -
- - -
- - -
- - -
- - - - -
- - - -
-
-
-
-
- -
- -
- - - - - -`; diff --git a/x-pack/legacy/plugins/license_management/index.ts b/x-pack/legacy/plugins/license_management/index.ts deleted file mode 100644 index e9fbb56e9d6ac..0000000000000 --- a/x-pack/legacy/plugins/license_management/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Legacy } from 'kibana'; -import { resolve } from 'path'; -import { PLUGIN } from './common/constants'; -import { plugin } from './server/np_ready'; - -export function licenseManagement(kibana: any) { - return new kibana.Plugin({ - id: PLUGIN.ID, - configPrefix: 'xpack.license_management', - publicDir: resolve(__dirname, 'public'), - require: ['kibana', 'elasticsearch'], - uiExports: { - styleSheetPaths: resolve(__dirname, 'public/np_ready/application/index.scss'), - managementSections: ['plugins/license_management/legacy'], - injectDefaultVars(server: Legacy.Server) { - const config = server.config(); - return { - licenseManagementUiEnabled: config.get('xpack.license_management.ui.enabled'), - }; - }, - }, - config(Joi: any) { - return Joi.object({ - // display menu item - ui: Joi.object({ - enabled: Joi.boolean().default(true), - }).default(), - - // enable plugin - enabled: Joi.boolean().default(true), - }).default(); - }, - init: (server: Legacy.Server) => { - plugin({} as any).setup(server.newPlatform.setup.core, { - ...server.newPlatform.setup.plugins, - __LEGACY: { - xpackMain: server.plugins.xpack_main, - elasticsearch: server.plugins.elasticsearch, - }, - }); - }, - }); -} diff --git a/x-pack/legacy/plugins/license_management/public/management_section.ts b/x-pack/legacy/plugins/license_management/public/management_section.ts deleted file mode 100644 index c7232649857e3..0000000000000 --- a/x-pack/legacy/plugins/license_management/public/management_section.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { management } from 'ui/management'; -import chrome from 'ui/chrome'; -import { BASE_PATH, PLUGIN } from '../common/constants'; - -const licenseManagementUiEnabled = chrome.getInjected('licenseManagementUiEnabled'); - -if (licenseManagementUiEnabled) { - management.getSection('elasticsearch').register('license_management', { - visible: true, - display: PLUGIN.TITLE, - order: 99, - url: `#${BASE_PATH}home`, - }); -} diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/boot.tsx b/x-pack/legacy/plugins/license_management/public/np_ready/application/boot.tsx deleted file mode 100644 index 49bb4ce984e48..0000000000000 --- a/x-pack/legacy/plugins/license_management/public/np_ready/application/boot.tsx +++ /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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { Provider } from 'react-redux'; -import { HashRouter } from 'react-router-dom'; -import { render, unmountComponentAtNode } from 'react-dom'; -import * as history from 'history'; -import { DocLinksStart, HttpSetup, ToastsSetup, ChromeStart } from 'src/core/public'; - -import { TelemetryPluginSetup } from 'src/plugins/telemetry/public'; -// @ts-ignore -import { App } from './app.container'; -// @ts-ignore -import { licenseManagementStore } from './store'; - -import { setDocLinks } from './lib/docs_links'; -import { BASE_PATH } from '../../../common/constants'; -import { Breadcrumb } from './breadcrumbs'; - -interface AppDependencies { - element: HTMLElement; - chrome: ChromeStart; - - I18nContext: any; - legacy: { - xpackInfo: any; - refreshXpack: () => void; - MANAGEMENT_BREADCRUMB: Breadcrumb; - }; - - toasts: ToastsSetup; - docLinks: DocLinksStart; - http: HttpSetup; - telemetry?: TelemetryPluginSetup; -} - -export const boot = (deps: AppDependencies) => { - const { I18nContext, element, legacy, toasts, docLinks, http, chrome, telemetry } = deps; - const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks; - const esBase = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; - const securityDocumentationLink = `${esBase}/security-settings.html`; - - const initialState = { license: legacy.xpackInfo.get('license') }; - - setDocLinks({ securityDocumentationLink }); - - const services = { - legacy: { - refreshXpack: legacy.refreshXpack, - xPackInfo: legacy.xpackInfo, - }, - // So we can imperatively control the hash route - history: history.createHashHistory({ basename: BASE_PATH }), - toasts, - http, - chrome, - telemetry, - MANAGEMENT_BREADCRUMB: legacy.MANAGEMENT_BREADCRUMB, - }; - - const store = licenseManagementStore(initialState, services); - - render( - - - - - - - , - element - ); - - return () => unmountComponentAtNode(element); -}; diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/breadcrumbs.ts b/x-pack/legacy/plugins/license_management/public/np_ready/application/breadcrumbs.ts deleted file mode 100644 index 2da04b22c0386..0000000000000 --- a/x-pack/legacy/plugins/license_management/public/np_ready/application/breadcrumbs.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; - -import { BASE_PATH } from '../../../common/constants'; - -export interface Breadcrumb { - text: string; - href: string; -} - -export function getDashboardBreadcrumbs(root: Breadcrumb) { - return [ - root, - { - text: i18n.translate('xpack.licenseMgmt.dashboard.breadcrumb', { - defaultMessage: 'License management', - }), - href: `#${BASE_PATH}home`, - }, - ]; -} - -export function getUploadBreadcrumbs(root: Breadcrumb) { - return [ - ...getDashboardBreadcrumbs(root), - { - text: i18n.translate('xpack.licenseMgmt.upload.breadcrumb', { - defaultMessage: 'Upload', - }), - }, - ]; -} diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/set_breadcrumb.ts b/x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/set_breadcrumb.ts deleted file mode 100644 index bcb4a907bdf88..0000000000000 --- a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/set_breadcrumb.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; - * you may not use this file except in compliance with the Elastic License. - */ -import { ThunkAction } from 'redux-thunk'; -import { ChromeStart } from 'src/core/public'; -import { getDashboardBreadcrumbs, getUploadBreadcrumbs, Breadcrumb } from '../../breadcrumbs'; - -export const setBreadcrumb = ( - section: 'dashboard' | 'upload' -): ThunkAction => ( - dispatch, - getState, - { chrome, MANAGEMENT_BREADCRUMB } -) => { - if (section === 'upload') { - chrome.setBreadcrumbs(getUploadBreadcrumbs(MANAGEMENT_BREADCRUMB)); - } else { - chrome.setBreadcrumbs(getDashboardBreadcrumbs(MANAGEMENT_BREADCRUMB)); - } -}; diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/plugin.ts b/x-pack/legacy/plugins/license_management/public/np_ready/plugin.ts deleted file mode 100644 index 60876c9b638d1..0000000000000 --- a/x-pack/legacy/plugins/license_management/public/np_ready/plugin.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { TelemetryPluginSetup } from 'src/plugins/telemetry/public'; -import { XPackMainPlugin } from '../../../xpack_main/server/xpack_main'; -import { PLUGIN } from '../../common/constants'; -import { Breadcrumb } from './application/breadcrumbs'; -export interface Plugins { - telemetry: TelemetryPluginSetup; - __LEGACY: { - xpackInfo: XPackMainPlugin; - refreshXpack: () => void; - MANAGEMENT_BREADCRUMB: Breadcrumb; - }; -} - -export class LicenseManagementUIPlugin implements Plugin { - setup({ application, notifications, http }: CoreSetup, { __LEGACY, telemetry }: Plugins) { - application.register({ - id: PLUGIN.ID, - title: PLUGIN.TITLE, - async mount( - { - core: { - docLinks, - i18n: { Context: I18nContext }, - chrome, - }, - }, - { element } - ) { - const { boot } = await import('./application'); - return boot({ - legacy: { ...__LEGACY }, - I18nContext, - toasts: notifications.toasts, - docLinks, - http, - element, - chrome, - telemetry, - }); - }, - }); - } - start(core: CoreStart, plugins: any) {} - stop() {} -} diff --git a/x-pack/legacy/plugins/license_management/public/register_route.ts b/x-pack/legacy/plugins/license_management/public/register_route.ts deleted file mode 100644 index f9258f68c555a..0000000000000 --- a/x-pack/legacy/plugins/license_management/public/register_route.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { App } from 'src/core/public'; - -/* Legacy Imports */ -import { npSetup, npStart } from 'ui/new_platform'; -import { MANAGEMENT_BREADCRUMB } from 'ui/management'; -import chrome from 'ui/chrome'; -import routes from 'ui/routes'; -// @ts-ignore -import { xpackInfo } from 'plugins/xpack_main/services/xpack_info'; - -import { plugin } from './np_ready'; -import { BASE_PATH } from '../common/constants'; - -const licenseManagementUiEnabled = chrome.getInjected('licenseManagementUiEnabled'); - -if (licenseManagementUiEnabled) { - /* - This method handles the cleanup needed when route is scope is destroyed. It also prevents Angular - from destroying scope when route changes and both old route and new route are this same route. - */ - const manageAngularLifecycle = ($scope: any, $route: any, unmount: () => void) => { - const lastRoute = $route.current; - const deregister = $scope.$on('$locationChangeSuccess', () => { - const currentRoute = $route.current; - // if templates are the same we are on the same route - if (lastRoute.$$route.template === currentRoute.$$route.template) { - // this prevents angular from destroying scope - $route.current = lastRoute; - } - }); - $scope.$on('$destroy', () => { - if (deregister) { - deregister(); - } - unmount(); - }); - }; - - const template = ` -
-
`; - - routes.when(`${BASE_PATH}:view?`, { - template, - controllerAs: 'licenseManagement', - controller: class LicenseManagementController { - constructor($injector: any, $rootScope: any, $scope: any, $route: any) { - $scope.$$postDigest(() => { - const element = document.getElementById('licenseReactRoot')!; - - const refreshXpack = async () => { - await xpackInfo.refresh($injector); - }; - - plugin({} as any).setup( - { - ...npSetup.core, - application: { - ...npSetup.core.application, - async register(app: App) { - const unmountApp = await app.mount({ ...npStart } as any, { - element, - appBasePath: '', - onAppLeave: () => undefined, - // TODO: adapt to use Core's ScopedHistory - history: {} as any, - }); - manageAngularLifecycle($scope, $route, unmountApp as any); - }, - }, - }, - { - telemetry: (npSetup.plugins as any).telemetry, - __LEGACY: { xpackInfo, refreshXpack, MANAGEMENT_BREADCRUMB }, - } - ); - }); - } - } as any, - } as any); -} diff --git a/x-pack/legacy/plugins/license_management/server/np_ready/lib/start_trial.ts b/x-pack/legacy/plugins/license_management/server/np_ready/lib/start_trial.ts deleted file mode 100644 index 3569085d413ca..0000000000000 --- a/x-pack/legacy/plugins/license_management/server/np_ready/lib/start_trial.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { KibanaRequest } from 'src/core/server'; -import { ElasticsearchPlugin } from '../../../../../../../src/legacy/core_plugins/elasticsearch'; - -export async function canStartTrial( - req: KibanaRequest, - elasticsearch: ElasticsearchPlugin -) { - const { callWithRequest } = elasticsearch.getCluster('admin'); - const options = { - method: 'GET', - path: '/_license/trial_status', - }; - try { - const response = await callWithRequest(req as any, 'transport.request', options); - return response.eligible_to_start_trial; - } catch (error) { - return error.body; - } -} - -export async function startTrial( - req: KibanaRequest, - elasticsearch: ElasticsearchPlugin, - xpackInfo: any -) { - const { callWithRequest } = elasticsearch.getCluster('admin'); - const options = { - method: 'POST', - path: '/_license/start_trial?acknowledge=true', - }; - try { - const response = await callWithRequest(req as any, 'transport.request', options); - const { trial_was_started: trialWasStarted } = response; - if (trialWasStarted) { - await xpackInfo.refreshNow(); - } - return response; - } catch (error) { - return error.body; - } -} diff --git a/x-pack/legacy/plugins/license_management/server/np_ready/plugin.ts b/x-pack/legacy/plugins/license_management/server/np_ready/plugin.ts deleted file mode 100644 index 9f065cf98d715..0000000000000 --- a/x-pack/legacy/plugins/license_management/server/np_ready/plugin.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Plugin, CoreSetup } from 'src/core/server'; -import { Dependencies, Server } from './types'; - -import { - registerLicenseRoute, - registerStartTrialRoutes, - registerStartBasicRoute, - registerPermissionsRoute, -} from './routes/api/license'; - -export class LicenseManagementServerPlugin implements Plugin { - setup({ http }: CoreSetup, { __LEGACY }: Dependencies) { - const xpackInfo = __LEGACY.xpackMain.info; - const router = http.createRouter(); - - const server: Server = { - router, - }; - - const legacy = { plugins: __LEGACY }; - - registerLicenseRoute(server, legacy, xpackInfo); - registerStartTrialRoutes(server, legacy, xpackInfo); - registerStartBasicRoute(server, legacy, xpackInfo); - registerPermissionsRoute(server, legacy, xpackInfo); - } - start() {} - stop() {} -} diff --git a/x-pack/legacy/plugins/license_management/server/np_ready/routes/api/license/register_permissions_route.ts b/x-pack/legacy/plugins/license_management/server/np_ready/routes/api/license/register_permissions_route.ts deleted file mode 100644 index 0f6c343d04fcd..0000000000000 --- a/x-pack/legacy/plugins/license_management/server/np_ready/routes/api/license/register_permissions_route.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { getPermissions } from '../../../lib/permissions'; -import { Legacy, Server } from '../../../types'; - -export function registerPermissionsRoute(server: Server, legacy: Legacy, xpackInfo: any) { - server.router.post( - { path: '/api/license/permissions', validate: false }, - async (ctx, request, response) => { - if (!xpackInfo) { - // xpackInfo is updated via poll, so it may not be available until polling has begun. - // In this rare situation, tell the client the service is temporarily unavailable. - return response.customError({ statusCode: 503, body: 'Security info unavailable' }); - } - - try { - return response.ok({ - body: await getPermissions(request, legacy.plugins.elasticsearch, xpackInfo), - }); - } catch (e) { - return response.internalError({ body: e }); - } - } - ); -} diff --git a/x-pack/legacy/plugins/license_management/server/np_ready/routes/api/license/register_start_basic_route.ts b/x-pack/legacy/plugins/license_management/server/np_ready/routes/api/license/register_start_basic_route.ts deleted file mode 100644 index ee7ac8602104b..0000000000000 --- a/x-pack/legacy/plugins/license_management/server/np_ready/routes/api/license/register_start_basic_route.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { startBasic } from '../../../lib/start_basic'; -import { Legacy, Server } from '../../../types'; - -export function registerStartBasicRoute(server: Server, legacy: Legacy, xpackInfo: any) { - server.router.post( - { - path: '/api/license/start_basic', - validate: { query: schema.object({ acknowledge: schema.string() }) }, - }, - async (ctx, request, response) => { - try { - return response.ok({ - body: await startBasic(request, legacy.plugins.elasticsearch, xpackInfo), - }); - } catch (e) { - return response.internalError({ body: e }); - } - } - ); -} diff --git a/x-pack/legacy/plugins/license_management/server/np_ready/routes/api/license/register_start_trial_routes.ts b/x-pack/legacy/plugins/license_management/server/np_ready/routes/api/license/register_start_trial_routes.ts deleted file mode 100644 index d93f13eba363a..0000000000000 --- a/x-pack/legacy/plugins/license_management/server/np_ready/routes/api/license/register_start_trial_routes.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { canStartTrial, startTrial } from '../../../lib/start_trial'; -import { Legacy, Server } from '../../../types'; - -export function registerStartTrialRoutes(server: Server, legacy: Legacy, xpackInfo: any) { - server.router.get( - { path: '/api/license/start_trial', validate: false }, - async (ctx, request, response) => { - try { - return response.ok({ body: await canStartTrial(request, legacy.plugins.elasticsearch) }); - } catch (e) { - return response.internalError({ body: e }); - } - } - ); - - server.router.post( - { path: '/api/license/start_trial', validate: false }, - async (ctx, request, response) => { - try { - return response.ok({ - body: await startTrial(request, legacy.plugins.elasticsearch, xpackInfo), - }); - } catch (e) { - return response.internalError({ body: e }); - } - } - ); -} diff --git a/x-pack/legacy/plugins/license_management/server/np_ready/types.ts b/x-pack/legacy/plugins/license_management/server/np_ready/types.ts deleted file mode 100644 index 0e66946ec1cc6..0000000000000 --- a/x-pack/legacy/plugins/license_management/server/np_ready/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { IRouter } from 'src/core/server'; -import { XPackMainPlugin } from '../../../xpack_main/server/xpack_main'; -import { ElasticsearchPlugin } from '../../../../../../src/legacy/core_plugins/elasticsearch'; - -export interface Dependencies { - __LEGACY: { - xpackMain: XPackMainPlugin; - elasticsearch: ElasticsearchPlugin; - }; -} - -export interface Server { - router: IRouter; -} - -export interface Legacy { - plugins: Dependencies['__LEGACY']; -} diff --git a/x-pack/legacy/plugins/maps/common/data_request_descriptor_types.d.ts b/x-pack/legacy/plugins/maps/common/data_request_descriptor_types.d.ts new file mode 100644 index 0000000000000..3281fb5892eac --- /dev/null +++ b/x-pack/legacy/plugins/maps/common/data_request_descriptor_types.d.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ + +// Global map state passed to every layer. +export type MapFilters = { + buffer: unknown; + extent: unknown; + filters: unknown[]; + query: unknown; + refreshTimerLastTriggeredAt: string; + timeFilters: unknown; + zoom: number; +}; + +export type VectorLayerRequestMeta = MapFilters & { + applyGlobalQuery: boolean; + fieldNames: string[]; + geogridPrecision: number; + sourceQuery: unknown; + sourceMeta: unknown; +}; + +export type ESSearchSourceResponseMeta = { + areResultsTrimmed?: boolean; + sourceType?: string; + + // top hits meta + areEntitiesTrimmed?: boolean; + entityCount?: number; + totalEntities?: number; +}; + +// Partial because objects are justified downstream in constructors +export type DataMeta = Partial & Partial; + +export type DataRequestDescriptor = { + dataId: string; + dataMetaAtStart?: DataMeta; + dataRequestToken?: symbol; + data?: object; + dataMeta?: DataMeta; +}; diff --git a/x-pack/legacy/plugins/maps/common/descriptor_types.d.ts b/x-pack/legacy/plugins/maps/common/descriptor_types.d.ts index ce0743ba2baed..2f45c525828db 100644 --- a/x-pack/legacy/plugins/maps/common/descriptor_types.d.ts +++ b/x-pack/legacy/plugins/maps/common/descriptor_types.d.ts @@ -5,7 +5,8 @@ */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ -import { AGG_TYPE, GRID_RESOLUTION, RENDER_AS, SORT_ORDER } from './constants'; +import { DataRequestDescriptor } from './data_request_descriptor_types'; +import { AGG_TYPE, GRID_RESOLUTION, RENDER_AS, SORT_ORDER, SCALING_TYPES } from './constants'; export type AbstractSourceDescriptor = { id?: string; @@ -49,7 +50,7 @@ export type ESSearchSourceDescriptor = AbstractESSourceDescriptor & { tooltipProperties?: string[]; sortField?: string; sortOrder?: SORT_ORDER; - useTopHits?: boolean; + scalingType: SCALING_TYPES; topHitsSplitField?: string; topHitsSize?: number; }; @@ -93,14 +94,6 @@ export type JoinDescriptor = { right: ESTermSourceDescriptor; }; -export type DataRequestDescriptor = { - dataId: string; - dataMetaAtStart: object; - dataRequestToken: symbol; - data: object; - dataMeta: object; -}; - export type LayerDescriptor = { __dataRequests?: DataRequestDescriptor[]; __isInErrorState?: boolean; diff --git a/x-pack/legacy/plugins/maps/common/migrations/scaling_type.test.ts b/x-pack/legacy/plugins/maps/common/migrations/scaling_type.test.ts new file mode 100644 index 0000000000000..4fbb1ef4c55ed --- /dev/null +++ b/x-pack/legacy/plugins/maps/common/migrations/scaling_type.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { migrateUseTopHitsToScalingType } from './scaling_type'; + +describe('migrateUseTopHitsToScalingType', () => { + test('Should handle missing layerListJSON attribute', () => { + const attributes = { + title: 'my map', + }; + expect(migrateUseTopHitsToScalingType({ attributes })).toEqual({ + title: 'my map', + }); + }); + + test('Should migrate useTopHits: true to scalingType TOP_HITS for ES documents sources', () => { + const layerListJSON = JSON.stringify([ + { + sourceDescriptor: { + type: 'ES_SEARCH', + useTopHits: true, + }, + }, + ]); + const attributes = { + title: 'my map', + layerListJSON, + }; + expect(migrateUseTopHitsToScalingType({ attributes })).toEqual({ + title: 'my map', + layerListJSON: '[{"sourceDescriptor":{"type":"ES_SEARCH","scalingType":"TOP_HITS"}}]', + }); + }); + + test('Should migrate useTopHits: false to scalingType LIMIT for ES documents sources', () => { + const layerListJSON = JSON.stringify([ + { + sourceDescriptor: { + type: 'ES_SEARCH', + useTopHits: false, + }, + }, + ]); + const attributes = { + title: 'my map', + layerListJSON, + }; + expect(migrateUseTopHitsToScalingType({ attributes })).toEqual({ + title: 'my map', + layerListJSON: '[{"sourceDescriptor":{"type":"ES_SEARCH","scalingType":"LIMIT"}}]', + }); + }); + + test('Should set scalingType to LIMIT when useTopHits is not set', () => { + const layerListJSON = JSON.stringify([ + { + sourceDescriptor: { + type: 'ES_SEARCH', + }, + }, + ]); + const attributes = { + title: 'my map', + layerListJSON, + }; + expect(migrateUseTopHitsToScalingType({ attributes })).toEqual({ + title: 'my map', + layerListJSON: '[{"sourceDescriptor":{"type":"ES_SEARCH","scalingType":"LIMIT"}}]', + }); + }); +}); diff --git a/x-pack/legacy/plugins/maps/common/migrations/scaling_type.ts b/x-pack/legacy/plugins/maps/common/migrations/scaling_type.ts new file mode 100644 index 0000000000000..5823ddd6b42e3 --- /dev/null +++ b/x-pack/legacy/plugins/maps/common/migrations/scaling_type.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { ES_SEARCH, SCALING_TYPES } from '../constants'; +import { LayerDescriptor, ESSearchSourceDescriptor } from '../descriptor_types'; +import { MapSavedObjectAttributes } from '../../../../../plugins/maps/common/map_saved_object_type'; + +function isEsDocumentSource(layerDescriptor: LayerDescriptor) { + const sourceType = _.get(layerDescriptor, 'sourceDescriptor.type'); + return sourceType === ES_SEARCH; +} + +export function migrateUseTopHitsToScalingType({ + attributes, +}: { + attributes: MapSavedObjectAttributes; +}): MapSavedObjectAttributes { + if (!attributes || !attributes.layerListJSON) { + return attributes; + } + + const layerList: LayerDescriptor[] = JSON.parse(attributes.layerListJSON); + layerList.forEach((layerDescriptor: LayerDescriptor) => { + if (isEsDocumentSource(layerDescriptor)) { + const sourceDescriptor = layerDescriptor.sourceDescriptor as ESSearchSourceDescriptor; + sourceDescriptor.scalingType = _.get(layerDescriptor, 'sourceDescriptor.useTopHits', false) + ? SCALING_TYPES.TOP_HITS + : SCALING_TYPES.LIMIT; + // @ts-ignore useTopHits no longer in type definition but that does not mean its not in live data + // hence the entire point of this method + delete sourceDescriptor.useTopHits; + } + }); + + return { + ...attributes, + layerListJSON: JSON.stringify(layerList), + }; +} diff --git a/x-pack/legacy/plugins/maps/migrations.js b/x-pack/legacy/plugins/maps/migrations.js index 9622f6ba63fac..6a1f5bc937497 100644 --- a/x-pack/legacy/plugins/maps/migrations.js +++ b/x-pack/legacy/plugins/maps/migrations.js @@ -10,6 +10,7 @@ import { topHitsTimeToSort } from './common/migrations/top_hits_time_to_sort'; import { moveApplyGlobalQueryToSources } from './common/migrations/move_apply_global_query'; import { addFieldMetaOptions } from './common/migrations/add_field_meta_options'; import { migrateSymbolStyleDescriptor } from './common/migrations/migrate_symbol_style_descriptor'; +import { migrateUseTopHitsToScalingType } from './common/migrations/scaling_type'; export const migrations = { map: { @@ -48,11 +49,12 @@ export const migrations = { }; }, '7.7.0': doc => { - const attributes = migrateSymbolStyleDescriptor(doc); + const attributesPhase1 = migrateSymbolStyleDescriptor(doc); + const attributesPhase2 = migrateUseTopHitsToScalingType({ attributes: attributesPhase1 }); return { ...doc, - attributes, + attributes: attributesPhase2, }; }, }, diff --git a/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts b/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts new file mode 100644 index 0000000000000..418f2880c1077 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ + +import { DataMeta, MapFilters } from '../../common/data_request_descriptor_types'; + +export type SyncContext = { + startLoading(dataId: string, requestToken: symbol, meta: DataMeta): void; + stopLoading(dataId: string, requestToken: symbol, data: unknown, meta: DataMeta): void; + onLoadError(dataId: string, requestToken: symbol, errorMessage: string): void; + updateSourceData(newData: unknown): void; + isRequestStillActive(dataId: string, requestToken: symbol): boolean; + registerCancelCallback(requestToken: symbol, callback: () => void): void; + dataFilters: MapFilters; +}; diff --git a/x-pack/legacy/plugins/maps/public/actions/map_actions.js b/x-pack/legacy/plugins/maps/public/actions/map_actions.js index 7a1e5e5266246..415630d9f730b 100644 --- a/x-pack/legacy/plugins/maps/public/actions/map_actions.js +++ b/x-pack/legacy/plugins/maps/public/actions/map_actions.js @@ -649,13 +649,14 @@ export function onDataLoadError(layerId, dataId, requestToken, errorMessage) { }; } -export function updateSourceProp(layerId, propName, value) { +export function updateSourceProp(layerId, propName, value, newLayerType) { return async dispatch => { dispatch({ type: UPDATE_SOURCE_PROP, layerId, propName, value, + newLayerType, }); await dispatch(clearMissingStyleProperties(layerId)); dispatch(syncDataForLayer(layerId)); diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js index 94e855fc6708f..60bbaa9825db7 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js @@ -16,8 +16,6 @@ import { EuiTextColor, EuiTextAlign, EuiButtonEmpty, - EuiFormRow, - EuiSwitch, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -80,14 +78,6 @@ export class FilterEditor extends Component { this._close(); }; - _onFilterByMapBoundsChange = event => { - this.props.updateSourceProp( - this.props.layer.getId(), - 'filterByMapBounds', - event.target.checked - ); - }; - _onApplyGlobalQueryChange = applyGlobalQuery => { this.props.updateSourceProp(this.props.layer.getId(), 'applyGlobalQuery', applyGlobalQuery); }; @@ -182,22 +172,6 @@ export class FilterEditor extends Component { } render() { - let filterByBoundsSwitch; - if (this.props.layer.getSource().isFilterByMapBoundsConfigurable()) { - filterByBoundsSwitch = ( - - - - ); - } - return ( @@ -217,8 +191,6 @@ export class FilterEditor extends Component { - {filterByBoundsSwitch} - { dispatch(fitToLayerExtent(layerId)); }, - updateSourceProp: (id, propName, value) => dispatch(updateSourceProp(id, propName, value)), + updateSourceProp: (id, propName, value, newLayerType) => + dispatch(updateSourceProp(id, propName, value, newLayerType)), }; } diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.js b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.js index 755d4bb6b323a..1b269e388bea0 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.js @@ -99,8 +99,8 @@ export class LayerPanel extends React.Component { } } - _onSourceChange = ({ propName, value }) => { - this.props.updateSourceProp(this.props.selectedLayer.getId(), propName, value); + _onSourceChange = ({ propName, value, newLayerType }) => { + this.props.updateSourceProp(this.props.selectedLayer.getId(), propName, value, newLayerType); }; _renderFilterSection() { diff --git a/x-pack/legacy/plugins/maps/public/layers/blended_vector_layer.ts b/x-pack/legacy/plugins/maps/public/layers/blended_vector_layer.ts new file mode 100644 index 0000000000000..b35eeedfa44fa --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/blended_vector_layer.ts @@ -0,0 +1,261 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { VectorLayer } from './vector_layer'; +import { IVectorStyle, VectorStyle } from './styles/vector/vector_style'; +// @ts-ignore +import { getDefaultDynamicProperties, VECTOR_STYLES } from './styles/vector/vector_style_defaults'; +import { IDynamicStyleProperty } from './styles/vector/properties/dynamic_style_property'; +import { IStyleProperty } from './styles/vector/properties/style_property'; +import { + COUNT_PROP_LABEL, + COUNT_PROP_NAME, + ES_GEO_GRID, + LAYER_TYPE, + AGG_TYPE, + SOURCE_DATA_ID_ORIGIN, + RENDER_AS, + STYLE_TYPE, +} from '../../common/constants'; +import { ESGeoGridSource } from './sources/es_geo_grid_source/es_geo_grid_source'; +// @ts-ignore +import { canSkipSourceUpdate } from './util/can_skip_fetch'; +import { IVectorLayer, VectorLayerArguments } from './vector_layer'; +import { IESSource } from './sources/es_source'; +import { IESAggSource } from './sources/es_agg_source'; +import { ISource } from './sources/source'; +import { SyncContext } from '../actions/map_actions'; +import { DataRequestAbortError } from './util/data_request'; + +const ACTIVE_COUNT_DATA_ID = 'ACTIVE_COUNT_DATA_ID'; + +function getAggType(dynamicProperty: IDynamicStyleProperty): AGG_TYPE { + return dynamicProperty.isOrdinal() ? AGG_TYPE.AVG : AGG_TYPE.TERMS; +} + +function getClusterSource(documentSource: IESSource, documentStyle: IVectorStyle): IESAggSource { + const clusterSourceDescriptor = ESGeoGridSource.createDescriptor({ + indexPatternId: documentSource.getIndexPatternId(), + geoField: documentSource.getGeoFieldName(), + requestType: RENDER_AS.POINT, + }); + clusterSourceDescriptor.metrics = [ + { + type: AGG_TYPE.COUNT, + label: COUNT_PROP_LABEL, + }, + ...documentStyle.getDynamicPropertiesArray().map(dynamicProperty => { + return { + type: getAggType(dynamicProperty), + field: dynamicProperty.getFieldName(), + }; + }), + ]; + clusterSourceDescriptor.id = documentSource.getId(); + return new ESGeoGridSource(clusterSourceDescriptor, documentSource.getInspectorAdapters()); +} + +function getClusterStyleDescriptor( + documentStyle: IVectorStyle, + clusterSource: IESAggSource +): unknown { + const defaultDynamicProperties = getDefaultDynamicProperties(); + const clusterStyleDescriptor: any = { + ...documentStyle.getDescriptor(), + properties: { + [VECTOR_STYLES.LABEL_TEXT]: { + type: STYLE_TYPE.DYNAMIC, + options: { + ...defaultDynamicProperties[VECTOR_STYLES.LABEL_TEXT].options, + field: { + name: COUNT_PROP_NAME, + origin: SOURCE_DATA_ID_ORIGIN, + }, + }, + }, + [VECTOR_STYLES.ICON_SIZE]: { + type: STYLE_TYPE.DYNAMIC, + options: { + ...defaultDynamicProperties[VECTOR_STYLES.ICON_SIZE].options, + field: { + name: COUNT_PROP_NAME, + origin: SOURCE_DATA_ID_ORIGIN, + }, + }, + }, + }, + }; + documentStyle.getAllStyleProperties().forEach((styleProperty: IStyleProperty) => { + const styleName = styleProperty.getStyleName(); + if ( + [VECTOR_STYLES.LABEL_TEXT, VECTOR_STYLES.ICON_SIZE].includes(styleName) && + (!styleProperty.isDynamic() || !styleProperty.isComplete()) + ) { + // Do not migrate static label and icon size properties to provide unique cluster styling out of the box + return; + } + + if (styleProperty.isDynamic()) { + const options = (styleProperty as IDynamicStyleProperty).getOptions(); + const field = + options && options.field && options.field.name + ? { + ...options.field, + name: clusterSource.getAggKey( + getAggType(styleProperty as IDynamicStyleProperty), + options.field.name + ), + } + : undefined; + clusterStyleDescriptor.properties[styleName] = { + type: STYLE_TYPE.DYNAMIC, + options: { + ...options, + field, + }, + }; + } else { + clusterStyleDescriptor.properties[styleName] = { + type: STYLE_TYPE.STATIC, + options: { ...styleProperty.getOptions() }, + }; + } + }); + + return clusterStyleDescriptor; +} + +export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { + static type = LAYER_TYPE.BLENDED_VECTOR; + + static createDescriptor(options: VectorLayerArguments, mapColors: string[]) { + const layerDescriptor = VectorLayer.createDescriptor(options, mapColors); + layerDescriptor.type = BlendedVectorLayer.type; + return layerDescriptor; + } + + private readonly _isClustered: boolean; + private readonly _clusterSource: IESAggSource; + private readonly _clusterStyle: IVectorStyle; + private readonly _documentSource: IESSource; + private readonly _documentStyle: IVectorStyle; + + constructor(options: VectorLayerArguments) { + super(options); + + this._documentSource = this._source as IESSource; // VectorLayer constructor sets _source as document source + this._documentStyle = this._style; // VectorLayer constructor sets _style as document source + + this._clusterSource = getClusterSource(this._documentSource, this._documentStyle); + const clusterStyleDescriptor = getClusterStyleDescriptor( + this._documentStyle, + this._clusterSource + ); + this._clusterStyle = new VectorStyle(clusterStyleDescriptor, this._clusterSource, this); + + let isClustered = false; + const sourceDataRequest = this.getSourceDataRequest(); + if (sourceDataRequest) { + const requestMeta = sourceDataRequest.getMeta(); + if (requestMeta && requestMeta.sourceType && requestMeta.sourceType === ES_GEO_GRID) { + isClustered = true; + } + } + this._isClustered = isClustered; + } + + destroy() { + if (this._documentSource) { + this._documentSource.destroy(); + } + if (this._clusterSource) { + this._clusterSource.destroy(); + } + } + + async getDisplayName(source: ISource) { + const displayName = await super.getDisplayName(source); + return this._isClustered + ? i18n.translate('xpack.maps.blendedVectorLayer.clusteredLayerName', { + defaultMessage: 'Clustered {displayName}', + values: { displayName }, + }) + : displayName; + } + + isJoinable() { + return false; + } + + getJoins() { + return []; + } + + getSource() { + return this._isClustered ? this._clusterSource : this._documentSource; + } + + getSourceForEditing() { + // Layer is based on this._documentSource + // this._clusterSource is a derived source for rendering only. + // Regardless of this._activeSource, this._documentSource should always be displayed in the editor + return this._documentSource; + } + + getCurrentStyle() { + return this._isClustered ? this._clusterStyle : this._documentStyle; + } + + getStyleForEditing() { + return this._documentStyle; + } + + async syncData(syncContext: SyncContext) { + const dataRequestId = ACTIVE_COUNT_DATA_ID; + const requestToken = Symbol(`layer-active-count:${this.getId()}`); + const searchFilters = this._getSearchFilters( + syncContext.dataFilters, + this.getSource(), + this.getCurrentStyle() + ); + const canSkipFetch = await canSkipSourceUpdate({ + source: this.getSource(), + prevDataRequest: this.getDataRequest(dataRequestId), + nextMeta: searchFilters, + }); + if (canSkipFetch) { + return; + } + + let isSyncClustered; + try { + syncContext.startLoading(dataRequestId, requestToken, searchFilters); + const searchSource = await this._documentSource.makeSearchSource(searchFilters, 0); + const resp = await searchSource.fetch(); + const maxResultWindow = await this._documentSource.getMaxResultWindow(); + isSyncClustered = resp.hits.total > maxResultWindow; + syncContext.stopLoading(dataRequestId, requestToken, { isSyncClustered }, searchFilters); + } catch (error) { + if (!(error instanceof DataRequestAbortError)) { + syncContext.onLoadError(dataRequestId, requestToken, error.message); + } + return; + } + + let activeSource; + let activeStyle; + if (isSyncClustered) { + activeSource = this._clusterSource; + activeStyle = this._clusterStyle; + } else { + activeSource = this._documentSource; + activeStyle = this._documentStyle; + } + + super._syncData(syncContext, activeSource, activeStyle); + } +} diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.ts b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.ts index c9dfdf6ad1fef..65f952ca01038 100644 --- a/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.ts +++ b/x-pack/legacy/plugins/maps/public/layers/fields/es_agg_field.ts @@ -23,11 +23,11 @@ export interface IESAggField extends IField { } export class ESAggField implements IESAggField { - private _source: IESAggSource; - private _origin: FIELD_ORIGIN; - private _label?: string; - private _aggType: AGG_TYPE; - private _esDocField?: IField | undefined; + private readonly _source: IESAggSource; + private readonly _origin: FIELD_ORIGIN; + private readonly _label?: string; + private readonly _aggType: AGG_TYPE; + private readonly _esDocField?: IField | undefined; constructor({ label, diff --git a/x-pack/legacy/plugins/maps/public/layers/fields/top_term_percentage_field.ts b/x-pack/legacy/plugins/maps/public/layers/fields/top_term_percentage_field.ts index bb40a24288a28..84bade4d94490 100644 --- a/x-pack/legacy/plugins/maps/public/layers/fields/top_term_percentage_field.ts +++ b/x-pack/legacy/plugins/maps/public/layers/fields/top_term_percentage_field.ts @@ -12,7 +12,7 @@ import { TOP_TERM_PERCENTAGE_SUFFIX } from '../../../common/constants'; import { FIELD_ORIGIN } from '../../../common/constants'; export class TopTermPercentageField implements IESAggField { - private _topTermAggField: IESAggField; + private readonly _topTermAggField: IESAggField; constructor(topTermAggField: IESAggField) { this._topTermAggField = topTermAggField; diff --git a/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js b/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js index 29223d6a67c6b..ef78b5afe3a3a 100644 --- a/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js @@ -32,7 +32,7 @@ export class HeatmapLayer extends VectorLayer { } _getPropKeyOfSelectedMetric() { - const metricfields = this._source.getMetricFields(); + const metricfields = this.getSource().getMetricFields(); return metricfields[0].getName(); } @@ -84,11 +84,11 @@ export class HeatmapLayer extends VectorLayer { } this.syncVisibilityWithMb(mbMap, heatmapLayerId); - this._style.setMBPaintProperties({ + this.getCurrentStyle().setMBPaintProperties({ mbMap, layerId: heatmapLayerId, propertyName: SCALED_PROPERTY_NAME, - resolution: this._source.getGridResolution(), + resolution: this.getSource().getGridResolution(), }); mbMap.setPaintProperty(heatmapLayerId, 'heatmap-opacity', this.getAlpha()); mbMap.setLayerZoomRange(heatmapLayerId, this._descriptor.minZoom, this._descriptor.maxZoom); @@ -103,7 +103,7 @@ export class HeatmapLayer extends VectorLayer { } renderLegendDetails() { - const metricFields = this._source.getMetricFields(); - return this._style.renderLegendDetails(metricFields[0]); + const metricFields = this.getSource().getMetricFields(); + return this.getCurrentStyle().renderLegendDetails(metricFields[0]); } } diff --git a/x-pack/legacy/plugins/maps/public/layers/layer.d.ts b/x-pack/legacy/plugins/maps/public/layers/layer.d.ts index eebbaac7d4f97..777566298e607 100644 --- a/x-pack/legacy/plugins/maps/public/layers/layer.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/layer.d.ts @@ -5,9 +5,17 @@ */ import { LayerDescriptor } from '../../common/descriptor_types'; import { ISource } from './sources/source'; +import { DataRequest } from './util/data_request'; +import { SyncContext } from '../actions/map_actions'; export interface ILayer { - getDisplayName(): Promise; + getDataRequest(id: string): DataRequest | undefined; + getDisplayName(source?: ISource): Promise; + getId(): string; + getSourceDataRequest(): DataRequest | undefined; + getSource(): ISource; + getSourceForEditing(): ISource; + syncData(syncContext: SyncContext): Promise; } export interface ILayerArguments { @@ -17,5 +25,11 @@ export interface ILayerArguments { export class AbstractLayer implements ILayer { constructor(layerArguments: ILayerArguments); - getDisplayName(): Promise; + getDataRequest(id: string): DataRequest | undefined; + getDisplayName(source?: ISource): Promise; + getId(): string; + getSourceDataRequest(): DataRequest | undefined; + getSource(): ISource; + getSourceForEditing(): ISource; + syncData(syncContext: SyncContext): Promise; } diff --git a/x-pack/legacy/plugins/maps/public/layers/layer.js b/x-pack/legacy/plugins/maps/public/layers/layer.js index 5c9532a3841f3..d162e342dfd1a 100644 --- a/x-pack/legacy/plugins/maps/public/layers/layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/layer.js @@ -63,7 +63,7 @@ export class AbstractLayer { clonedDescriptor.id = uuid(); const displayName = await this.getDisplayName(); clonedDescriptor.label = `Clone of ${displayName}`; - clonedDescriptor.sourceDescriptor = this._source.cloneDescriptor(); + clonedDescriptor.sourceDescriptor = this.getSource().cloneDescriptor(); if (clonedDescriptor.joins) { clonedDescriptor.joins.forEach(joinDescriptor => { // right.id is uuid used to track requests in inspector @@ -78,28 +78,31 @@ export class AbstractLayer { } isJoinable() { - return this._source.isJoinable(); + return this.getSource().isJoinable(); } supportsElasticsearchFilters() { - return this._source.isESSource(); + return this.getSource().isESSource(); } async supportsFitToBounds() { - return await this._source.supportsFitToBounds(); + return await this.getSource().supportsFitToBounds(); } - async getDisplayName() { + async getDisplayName(source) { if (this._descriptor.label) { return this._descriptor.label; } - return (await this._source.getDisplayName()) || `Layer ${this._descriptor.id}`; + const sourceDisplayName = source + ? await source.getDisplayName() + : await this.getSource().getDisplayName(); + return sourceDisplayName || `Layer ${this._descriptor.id}`; } async getAttributions() { if (!this.hasErrors()) { - return await this._source.getAttributions(); + return await this.getSource().getAttributions(); } return []; } @@ -191,6 +194,10 @@ export class AbstractLayer { return this._source; } + getSourceForEditing() { + return this._source; + } + isVisible() { return this._descriptor.visible; } @@ -226,12 +233,16 @@ export class AbstractLayer { return this._style; } + getStyleForEditing() { + return this._style; + } + async getImmutableSourceProperties() { - return this._source.getImmutableProperties(); + return this.getSource().getImmutableProperties(); } renderSourceSettingsEditor = ({ onChange }) => { - return this._source.renderSourceSettingsEditor({ onChange }); + return this.getSourceForEditing().renderSourceSettingsEditor({ onChange }); }; getPrevRequestToken(dataId) { @@ -319,10 +330,11 @@ export class AbstractLayer { } renderStyleEditor({ onStyleDescriptorChange }) { - if (!this._style) { + const style = this.getStyleForEditing(); + if (!style) { return null; } - return this._style.renderEditor({ layer: this, onStyleDescriptorChange }); + return style.renderEditor({ layer: this, onStyleDescriptorChange }); } getIndexPatternIds() { @@ -333,10 +345,6 @@ export class AbstractLayer { return []; } - async getFields() { - return []; - } - syncVisibilityWithMb(mbMap, mbLayerId) { mbMap.setLayoutProperty(mbLayerId, 'visibility', this.isVisible() ? 'visible' : 'none'); } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.d.ts index 48e90b6c41d51..3f596cea1ae39 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.d.ts @@ -6,10 +6,23 @@ import { AbstractESAggSource } from '../es_agg_source'; import { ESGeoGridSourceDescriptor } from '../../../../common/descriptor_types'; -import { GRID_RESOLUTION } from '../../../../common/constants'; +import { GRID_RESOLUTION, RENDER_AS } from '../../../../common/constants'; export class ESGeoGridSource extends AbstractESAggSource { + static createDescriptor({ + indexPatternId, + geoField, + requestType, + resolution, + }: { + indexPatternId: string; + geoField: string; + requestType: RENDER_AS; + resolution?: GRID_RESOLUTION; + }): ESGeoGridSourceDescriptor; + constructor(sourceDescriptor: ESGeoGridSourceDescriptor, inspectorAdapters: unknown); + getGridResolution(): GRID_RESOLUTION; getGeoGridPrecision(zoom: number): number; } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js index 3b3e8004ded05..5ad202a02ae6d 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_geo_grid_source/es_geo_grid_source.js @@ -75,7 +75,7 @@ export class ESGeoGridSource extends AbstractESAggSource { renderSourceSettingsEditor({ onChange }) { return ( { @@ -325,6 +325,7 @@ export class ESGeoGridSource extends AbstractESAggSource { }, meta: { areResultsTrimmed: false, + sourceType: ES_GEO_GRID, }, }; } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js index 53536b11aaca6..8e1145c531f9e 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_pew_pew_source/es_pew_pew_source.js @@ -64,7 +64,7 @@ export class ESPewPewSource extends AbstractESAggSource { renderSourceSettingsEditor({ onChange }) { return ( - + + + +
+ +
+
+ + + + @@ -112,7 +152,7 @@ exports[`should enable sort order select when sort field provided 1`] = `
`; -exports[`should render top hits form when useTopHits is true 1`] = ` +exports[`should render top hits form when scaling type is TOP_HITS 1`] = ` - + + + +
+ +
+
+ + + + + - + + + +
+ +
+
+ + + + diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.d.ts index 5d8188f19f4ea..0a4e48a195ec6 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.d.ts @@ -8,5 +8,5 @@ import { AbstractESSource } from '../es_source'; import { ESSearchSourceDescriptor } from '../../../../common/descriptor_types'; export class ESSearchSource extends AbstractESSource { - constructor(sourceDescriptor: ESSearchSourceDescriptor, inspectorAdapters: unknown); + constructor(sourceDescriptor: Partial, inspectorAdapters: unknown); } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js index 7f0e870760512..440b9aa89a945 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js @@ -11,6 +11,8 @@ import uuid from 'uuid/v4'; import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; import { AbstractESSource } from '../es_source'; import { SearchSource } from '../../../kibana_services'; +import { VectorStyle } from '../../styles/vector/vector_style'; +import { VectorLayer } from '../../vector_layer'; import { hitsToGeoJson } from '../../../elasticsearch_geo_utils'; import { CreateSourceEditor } from './create_source_editor'; import { UpdateSourceEditor } from './update_source_editor'; @@ -19,11 +21,13 @@ import { ES_GEO_FIELD_TYPE, DEFAULT_MAX_BUCKETS_LIMIT, SORT_ORDER, + SCALING_TYPES, } from '../../../../common/constants'; import { i18n } from '@kbn/i18n'; import { getDataSourceLabel } from '../../../../common/i18n_getters'; import { getSourceFields } from '../../../index_pattern_util'; import { loadIndexSettings } from './load_index_settings'; +import { BlendedVectorLayer } from '../../blended_vector_layer'; import { DEFAULT_FILTER_BY_MAP_BOUNDS } from './constants'; import { ESDocField } from '../../fields/es_doc_field'; @@ -99,7 +103,7 @@ export class ESSearchSource extends AbstractESSource { tooltipProperties: _.get(descriptor, 'tooltipProperties', []), sortField: _.get(descriptor, 'sortField', ''), sortOrder: _.get(descriptor, 'sortOrder', SORT_ORDER.DESC), - useTopHits: _.get(descriptor, 'useTopHits', false), + scalingType: _.get(descriptor, 'scalingType', SCALING_TYPES.LIMIT), topHitsSplitField: descriptor.topHitsSplitField, topHitsSize: _.get(descriptor, 'topHitsSize', 1), }, @@ -111,6 +115,32 @@ export class ESSearchSource extends AbstractESSource { ); } + createDefaultLayer(options, mapColors) { + if (this._descriptor.scalingType === SCALING_TYPES.CLUSTERS) { + const layerDescriptor = BlendedVectorLayer.createDescriptor( + { + sourceDescriptor: this._descriptor, + ...options, + }, + mapColors + ); + const style = new VectorStyle(layerDescriptor.style, this); + return new BlendedVectorLayer({ + layerDescriptor: layerDescriptor, + source: this, + style, + }); + } + + const layerDescriptor = this._createDefaultLayerDescriptor(options, mapColors); + const style = new VectorStyle(layerDescriptor.style, this); + return new VectorLayer({ + layerDescriptor: layerDescriptor, + source: this, + style, + }); + } + createField({ fieldName }) { return new ESDocField({ fieldName, @@ -122,12 +152,14 @@ export class ESSearchSource extends AbstractESSource { return ( @@ -157,7 +189,7 @@ export class ESSearchSource extends AbstractESSource { } async getImmutableProperties() { - let indexPatternTitle = this._descriptor.indexPatternId; + let indexPatternTitle = this.getIndexPatternId(); let geoFieldType = ''; try { const indexPattern = await this.getIndexPattern(); @@ -239,7 +271,7 @@ export class ESSearchSource extends AbstractESSource { shard_size: DEFAULT_MAX_BUCKETS_LIMIT, }; - const searchSource = await this._makeSearchSource(searchFilters, 0); + const searchSource = await this.makeSearchSource(searchFilters, 0); searchSource.setField('aggs', { totalEntities: { cardinality: addFieldToDSL(cardinalityAgg, topHitsSplitField), @@ -300,7 +332,7 @@ export class ESSearchSource extends AbstractESSource { ); const initialSearchContext = { docvalue_fields: docValueFields }; // Request fields in docvalue_fields insted of _source - const searchSource = await this._makeSearchSource( + const searchSource = await this.makeSearchSource( searchFilters, maxResultWindow, initialSearchContext @@ -332,8 +364,8 @@ export class ESSearchSource extends AbstractESSource { } _isTopHits() { - const { useTopHits, topHitsSplitField } = this._descriptor; - return !!(useTopHits && topHitsSplitField); + const { scalingType, topHitsSplitField } = this._descriptor; + return !!(scalingType === SCALING_TYPES.TOP_HITS && topHitsSplitField); } _hasSort() { @@ -341,6 +373,12 @@ export class ESSearchSource extends AbstractESSource { return !!sortField && !!sortOrder; } + async getMaxResultWindow() { + const indexPattern = await this.getIndexPattern(); + const indexSettings = await loadIndexSettings(indexPattern.title); + return indexSettings.maxResultWindow; + } + async getGeoJsonWithMeta(layerName, searchFilters, registerCancelCallback) { const indexPattern = await this.getIndexPattern(); @@ -383,7 +421,7 @@ export class ESSearchSource extends AbstractESSource { return { data: featureCollection, - meta, + meta: { ...meta, sourceType: ES_SEARCH }, }; } @@ -442,11 +480,9 @@ export class ESSearchSource extends AbstractESSource { } isFilterByMapBounds() { - return _.get(this._descriptor, 'filterByMapBounds', false); - } - - isFilterByMapBoundsConfigurable() { - return true; + return this._descriptor.scalingType === SCALING_TYPES.CLUSTER + ? true + : this._descriptor.filterByMapBounds; } async getLeftJoinFields() { @@ -533,7 +569,7 @@ export class ESSearchSource extends AbstractESSource { return { sortField: this._descriptor.sortField, sortOrder: this._descriptor.sortOrder, - useTopHits: this._descriptor.useTopHits, + scalingType: this._descriptor.scalingType, topHitsSplitField: this._descriptor.topHitsSplitField, topHitsSize: this._descriptor.topHitsSize, }; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.test.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.test.ts index 1e10923cea1d0..59120e221ca49 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.test.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.test.ts @@ -7,7 +7,7 @@ jest.mock('ui/new_platform'); import { ESSearchSource } from './es_search_source'; import { VectorLayer } from '../../vector_layer'; -import { ES_SEARCH } from '../../../../common/constants'; +import { ES_SEARCH, SCALING_TYPES } from '../../../../common/constants'; import { ESSearchSourceDescriptor } from '../../../../common/descriptor_types'; const descriptor: ESSearchSourceDescriptor = { @@ -15,6 +15,7 @@ const descriptor: ESSearchSourceDescriptor = { id: '1234', indexPatternId: 'myIndexPattern', geoField: 'myLocation', + scalingType: SCALING_TYPES.LIMIT, }; describe('ES Search Source', () => { diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js index 52702c1f4ecc7..b85cca113cf98 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js @@ -14,6 +14,7 @@ import { EuiPanel, EuiSpacer, EuiHorizontalRule, + EuiRadioGroup, } from '@elastic/eui'; import { SingleFieldSelect } from '../../../components/single_field_select'; import { TooltipSelector } from '../../../components/tooltip_selector'; @@ -22,7 +23,13 @@ import { indexPatternService } from '../../../kibana_services'; import { i18n } from '@kbn/i18n'; import { getTermsFields, getSourceFields } from '../../../index_pattern_util'; import { ValidatedRange } from '../../../components/validated_range'; -import { DEFAULT_MAX_INNER_RESULT_WINDOW, SORT_ORDER } from '../../../../common/constants'; +import { + DEFAULT_MAX_INNER_RESULT_WINDOW, + DEFAULT_MAX_RESULT_WINDOW, + SORT_ORDER, + SCALING_TYPES, + LAYER_TYPE, +} from '../../../../common/constants'; import { ESDocField } from '../../fields/es_doc_field'; import { FormattedMessage } from '@kbn/i18n/react'; import { loadIndexSettings } from './load_index_settings'; @@ -35,7 +42,7 @@ export class UpdateSourceEditor extends Component { tooltipFields: PropTypes.arrayOf(PropTypes.object).isRequired, sortField: PropTypes.string, sortOrder: PropTypes.string.isRequired, - useTopHits: PropTypes.bool.isRequired, + scalingType: PropTypes.string.isRequired, topHitsSplitField: PropTypes.string, topHitsSize: PropTypes.number.isRequired, source: PropTypes.object, @@ -46,6 +53,8 @@ export class UpdateSourceEditor extends Component { termFields: null, sortFields: null, maxInnerResultWindow: DEFAULT_MAX_INNER_RESULT_WINDOW, + maxResultWindow: DEFAULT_MAX_RESULT_WINDOW, + supportsClustering: false, }; componentDidMount() { @@ -61,9 +70,9 @@ export class UpdateSourceEditor extends Component { async loadIndexSettings() { try { const indexPattern = await indexPatternService.get(this.props.indexPatternId); - const { maxInnerResultWindow } = await loadIndexSettings(indexPattern.title); + const { maxInnerResultWindow, maxResultWindow } = await loadIndexSettings(indexPattern.title); if (this._isMounted) { - this.setState({ maxInnerResultWindow }); + this.setState({ maxInnerResultWindow, maxResultWindow }); } } catch (err) { return; @@ -88,6 +97,16 @@ export class UpdateSourceEditor extends Component { return; } + let geoField; + try { + geoField = await this.props.getGeoField(); + } catch (err) { + if (this._isMounted) { + this.setState({ loadError: err.message }); + } + return; + } + if (!this._isMounted) { return; } @@ -102,6 +121,7 @@ export class UpdateSourceEditor extends Component { }); this.setState({ + supportsClustering: geoField.aggregatable, sourceFields: sourceFields, termFields: getTermsFields(indexPattern.fields), //todo change term fields to use fields sortFields: indexPattern.fields.filter( @@ -113,8 +133,14 @@ export class UpdateSourceEditor extends Component { this.props.onChange({ propName: 'tooltipProperties', value: propertyNames }); }; - onUseTopHitsChange = event => { - this.props.onChange({ propName: 'useTopHits', value: event.target.checked }); + _onScalingTypeChange = optionId => { + const layerType = + optionId === SCALING_TYPES.CLUSTERS ? LAYER_TYPE.BLENDED_VECTOR : LAYER_TYPE.VECTOR; + this.props.onChange({ propName: 'scalingType', value: optionId, newLayerType: layerType }); + }; + + _onFilterByMapBoundsChange = event => { + this.props.onChange({ propName: 'filterByMapBounds', value: event.target.checked }); }; onTopHitsSplitFieldChange = topHitsSplitField => { @@ -133,29 +159,7 @@ export class UpdateSourceEditor extends Component { this.props.onChange({ propName: 'topHitsSize', value: size }); }; - renderTopHitsForm() { - const topHitsSwitch = ( - - - - ); - - if (!this.props.useTopHits) { - return topHitsSwitch; - } - + _renderTopHitsForm() { let sizeSlider; if (this.props.topHitsSplitField) { sizeSlider = ( @@ -183,7 +187,6 @@ export class UpdateSourceEditor extends Component { return ( - {topHitsSwitch} +
+ ); + } + + _renderScalingPanel() { + const scalingOptions = [ + { + id: SCALING_TYPES.LIMIT, + label: i18n.translate('xpack.maps.source.esSearch.limitScalingLabel', { + defaultMessage: 'Limit results to {maxResultWindow}.', + values: { maxResultWindow: this.state.maxResultWindow }, + }), + }, + { + id: SCALING_TYPES.TOP_HITS, + label: i18n.translate('xpack.maps.source.esSearch.useTopHitsLabel', { + defaultMessage: 'Show top hits per entity.', + }), + }, + ]; + if (this.state.supportsClustering) { + scalingOptions.push({ + id: SCALING_TYPES.CLUSTERS, + label: i18n.translate('xpack.maps.source.esSearch.clusterScalingLabel', { + defaultMessage: 'Show clusters when results exceed {maxResultWindow}.', + values: { maxResultWindow: this.state.maxResultWindow }, + }), + }); + } - - {this.renderTopHitsForm()} + let filterByBoundsSwitch; + if (this.props.scalingType !== SCALING_TYPES.CLUSTERS) { + filterByBoundsSwitch = ( + + + + ); + } + + let scalingForm = null; + if (this.props.scalingType === SCALING_TYPES.TOP_HITS) { + scalingForm = ( + + + {this._renderTopHitsForm()} + + ); + } + + return ( + + +
+ +
+
+ + + + + + + + {filterByBoundsSwitch} + + {scalingForm}
); } @@ -302,6 +379,9 @@ export class UpdateSourceEditor extends Component { {this._renderSortPanel()} + + {this._renderScalingPanel()} +
); } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js index badfba7665dfd..e8a845c4b1669 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js @@ -16,6 +16,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { UpdateSourceEditor } from './update_source_editor'; +import { SCALING_TYPES } from '../../../../common/constants'; const defaultProps = { indexPatternId: 'indexPattern1', @@ -23,7 +24,7 @@ const defaultProps = { filterByMapBounds: true, tooltipFields: [], sortOrder: 'DESC', - useTopHits: false, + scalingType: SCALING_TYPES.LIMIT, topHitsSplitField: 'trackId', topHitsSize: 1, }; @@ -40,8 +41,10 @@ test('should enable sort order select when sort field provided', async () => { expect(component).toMatchSnapshot(); }); -test('should render top hits form when useTopHits is true', async () => { - const component = shallow(); +test('should render top hits form when scaling type is TOP_HITS', async () => { + const component = shallow( + + ); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts index 25c4fae89f024..963a30c7413e8 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts @@ -6,12 +6,31 @@ import { AbstractVectorSource } from './vector_source'; import { IVectorSource } from './vector_source'; -import { IndexPattern } from '../../../../../../../src/plugins/data/public'; +import { IndexPattern, SearchSource } from '../../../../../../../src/plugins/data/public'; +import { VectorLayerRequestMeta } from '../../../common/data_request_descriptor_types'; export interface IESSource extends IVectorSource { + getId(): string; getIndexPattern(): Promise; + getIndexPatternId(): string; + getGeoFieldName(): string; + getMaxResultWindow(): Promise; + makeSearchSource( + searchFilters: VectorLayerRequestMeta, + limit: number, + initialSearchContext?: object + ): Promise; } export class AbstractESSource extends AbstractVectorSource implements IESSource { + getId(): string; getIndexPattern(): Promise; + getIndexPatternId(): string; + getGeoFieldName(): string; + getMaxResultWindow(): Promise; + makeSearchSource( + searchFilters: VectorLayerRequestMeta, + limit: number, + initialSearchContext?: object + ): Promise; } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.js index 1552db277e609..c5bf9a8be75bd 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.js @@ -35,6 +35,10 @@ export class AbstractESSource extends AbstractVectorSource { ); } + getId() { + return this._descriptor.id; + } + isFieldAware() { return true; } @@ -48,12 +52,12 @@ export class AbstractESSource extends AbstractVectorSource { } getIndexPatternIds() { - return [this._descriptor.indexPatternId]; + return [this.getIndexPatternId()]; } getQueryableIndexPatternIds() { if (this.getApplyGlobalQuery()) { - return [this._descriptor.indexPatternId]; + return [this.getIndexPatternId()]; } return []; } @@ -106,7 +110,7 @@ export class AbstractESSource extends AbstractVectorSource { } } - async _makeSearchSource(searchFilters, limit, initialSearchContext) { + async makeSearchSource(searchFilters, limit, initialSearchContext) { const indexPattern = await this.getIndexPattern(); const isTimeAware = await this.isTimeAware(); const applyGlobalQuery = _.get(searchFilters, 'applyGlobalQuery', true); @@ -143,7 +147,7 @@ export class AbstractESSource extends AbstractVectorSource { } async getBoundsForFilters({ sourceQuery, query, timeFilters, filters, applyGlobalQuery }) { - const searchSource = await this._makeSearchSource( + const searchSource = await this.makeSearchSource( { sourceQuery, query, timeFilters, filters, applyGlobalQuery }, 0 ); @@ -190,19 +194,27 @@ export class AbstractESSource extends AbstractVectorSource { } } + getIndexPatternId() { + return this._descriptor.indexPatternId; + } + + getGeoFieldName() { + return this._descriptor.geoField; + } + async getIndexPattern() { if (this.indexPattern) { return this.indexPattern; } try { - this.indexPattern = await indexPatternService.get(this._descriptor.indexPatternId); + this.indexPattern = await indexPatternService.get(this.getIndexPatternId()); return this.indexPattern; } catch (error) { throw new Error( i18n.translate('xpack.maps.source.esSource.noIndexPatternErrorMessage', { defaultMessage: `Unable to find Index pattern for id: {indexPatternId}`, - values: { indexPatternId: this._descriptor.indexPatternId }, + values: { indexPatternId: this.getIndexPatternId() }, }) ); } @@ -219,7 +231,7 @@ export class AbstractESSource extends AbstractVectorSource { } } - async _getGeoField() { + _getGeoField = async () => { const indexPattern = await this.getIndexPattern(); const geoField = indexPattern.fields.getByName(this._descriptor.geoField); if (!geoField) { @@ -231,7 +243,7 @@ export class AbstractESSource extends AbstractVectorSource { ); } return geoField; - } + }; async getDisplayName() { try { @@ -239,7 +251,7 @@ export class AbstractESSource extends AbstractVectorSource { return indexPattern.title; } catch (error) { // Unable to load index pattern, just return id as display name - return this._descriptor.indexPatternId; + return this.getIndexPatternId(); } } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js index c12b4befc0684..3ce0fb58aba19 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_term_source.js @@ -51,10 +51,6 @@ export class ESTermSource extends AbstractESAggSource { return _.has(this._descriptor, 'indexPatternId') && _.has(this._descriptor, 'term'); } - getIndexPatternIds() { - return [this._descriptor.indexPatternId]; - } - getTermField() { return this._termField; } @@ -90,7 +86,7 @@ export class ESTermSource extends AbstractESAggSource { } const indexPattern = await this.getIndexPattern(); - const searchSource = await this._makeSearchSource(searchFilters, 0); + const searchSource = await this.makeSearchSource(searchFilters, 0); const termsField = getField(indexPattern, this._termField.getName()); const termsAgg = { size: DEFAULT_MAX_BUCKETS_LIMIT }; searchSource.setField('aggs', { @@ -126,7 +122,7 @@ export class ESTermSource extends AbstractESAggSource { async getDisplayName() { //no need to localize. this is never rendered. - return `es_table ${this._descriptor.indexPatternId}`; + return `es_table ${this.getIndexPatternId()}`; } async filterAndFormatPropertiesToHtml(properties) { diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/source.d.ts index b5b34efabda0a..2ca18e47a4bf9 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/source.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/source.d.ts @@ -10,10 +10,14 @@ import { ILayer } from '../layer'; export interface ISource { createDefaultLayer(): ILayer; getDisplayName(): Promise; + destroy(): void; + getInspectorAdapters(): object; } export class AbstractSource implements ISource { constructor(sourceDescriptor: AbstractSourceDescriptor, inspectorAdapters: object); createDefaultLayer(): ILayer; getDisplayName(): Promise; + destroy(): void; + getInspectorAdapters(): object; } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.d.ts index 7de3fe1823cb7..14fc23751ac1a 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.d.ts @@ -7,13 +7,9 @@ import { AbstractSource, ISource } from './source'; import { IField } from '../fields/field'; +import { ESSearchSourceResponseMeta } from '../../../common/data_request_descriptor_types'; -export type GeoJsonFetchMeta = { - areResultsTrimmed: boolean; - areEntitiesTrimmed?: boolean; - entityCount?: number; - totalEntities?: number; -}; +export type GeoJsonFetchMeta = ESSearchSourceResponseMeta; export type GeoJsonWithMeta = { data: unknown; // geojson feature collection diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js index 0f74dd605c8f1..7ff1c735c8613 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js @@ -98,10 +98,6 @@ export class AbstractVectorSource extends AbstractSource { return false; } - isFilterByMapBoundsConfigurable() { - return false; - } - isBoundsAware() { return false; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js index 8e05cf287efa6..acc26e5fce699 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js @@ -69,7 +69,7 @@ export class VectorStyleEditor extends Component { }; //These are all fields (only used for text labeling) - const fields = await this.props.layer.getFields(); + const fields = await this.props.layer.getStyleEditorFields(); const fieldPromises = fields.map(getFieldMeta); const fieldsArrayAll = await Promise.all(fieldPromises); if (!this._isMounted || _.isEqual(fieldsArrayAll, this.state.fields)) { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js index f74deb17fff7c..5b5028f68f08c 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js @@ -71,7 +71,7 @@ class MockLayer { return new MockStyle(); } - findDataRequestById() { + getDataRequest() { return null; } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts index f4c487b28757e..25063944b8891 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts @@ -7,13 +7,17 @@ import { IStyleProperty } from './style_property'; import { FIELD_ORIGIN } from '../../../../../common/constants'; -import { FieldMetaOptions } from '../../../../../common/style_property_descriptor_types'; +import { + FieldMetaOptions, + DynamicStylePropertyOptions, +} from '../../../../../common/style_property_descriptor_types'; import { IField } from '../../../fields/field'; import { IVectorLayer } from '../../../vector_layer'; import { IVectorSource } from '../../../sources/vector_source'; import { CategoryFieldMeta, RangeFieldMeta } from '../../../../../common/descriptor_types'; export interface IDynamicStyleProperty extends IStyleProperty { + getOptions(): DynamicStylePropertyOptions; getFieldMetaOptions(): FieldMetaOptions; getField(): IField | undefined; getFieldName(): string; @@ -22,6 +26,7 @@ export interface IDynamicStyleProperty extends IStyleProperty { getRangeFieldMeta(): RangeFieldMeta; getCategoryFieldMeta(): CategoryFieldMeta; isFieldMetaEnabled(): boolean; + isOrdinal(): boolean; supportsFieldMeta(): boolean; getFieldMetaRequest(): Promise; supportsMbFeatureState(): boolean; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js index 030d3a2a1ef87..68e06bacfa7b7 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js @@ -62,7 +62,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return rangeFieldMetaFromLocalFeatures; } - const styleMetaDataRequest = this._layer.findDataRequestById(dataRequestId); + const styleMetaDataRequest = this._layer.getDataRequest(dataRequestId); if (!styleMetaDataRequest || !styleMetaDataRequest.hasData()) { return rangeFieldMetaFromLocalFeatures; } @@ -87,7 +87,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return categoryFieldMetaFromLocalFeatures; } - const styleMetaDataRequest = this._layer.findDataRequestById(dataRequestId); + const styleMetaDataRequest = this._layer.getDataRequest(dataRequestId); if (!styleMetaDataRequest || !styleMetaDataRequest.hasData()) { return categoryFieldMetaFromLocalFeatures; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/style_property.ts b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/style_property.ts index bba6cdb48e672..6c00c01dec442 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/style_property.ts +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/style_property.ts @@ -34,8 +34,8 @@ export interface IStyleProperty { } export class AbstractStyleProperty implements IStyleProperty { - private _options: StylePropertyOptions; - private _styleName: string; + private readonly _options: StylePropertyOptions; + private readonly _styleName: string; constructor(options: StylePropertyOptions, styleName: string) { this._options = options; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.d.ts b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.d.ts new file mode 100644 index 0000000000000..ac84a3b6447d2 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.d.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { IStyleProperty } from './properties/style_property'; +import { IDynamicStyleProperty } from './properties/dynamic_style_property'; +import { IVectorLayer } from '../../vector_layer'; +import { IVectorSource } from '../../sources/vector_source'; + +export interface IVectorStyle { + getAllStyleProperties(): IStyleProperty[]; + getDescriptor(): object; + getDynamicPropertiesArray(): IDynamicStyleProperty[]; +} + +export class VectorStyle implements IVectorStyle { + constructor(descriptor: unknown, source: IVectorSource, layer: IVectorLayer); + + getAllStyleProperties(): IStyleProperty[]; + getDescriptor(): object; + getDynamicPropertiesArray(): IDynamicStyleProperty[]; +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js index 1c8ff3e205a38..6ad60e15f10e1 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js @@ -123,7 +123,7 @@ export class VectorStyle extends AbstractStyle { ); } - _getAllStyleProperties() { + getAllStyleProperties() { return [ this._symbolizeAsStyleProperty, this._iconStyleProperty, @@ -164,7 +164,7 @@ export class VectorStyle extends AbstractStyle { }); const styleProperties = {}; - this._getAllStyleProperties().forEach(styleProperty => { + this.getAllStyleProperties().forEach(styleProperty => { styleProperties[styleProperty.getStyleName()] = styleProperty; }); @@ -339,7 +339,7 @@ export class VectorStyle extends AbstractStyle { } getDynamicPropertiesArray() { - const styleProperties = this._getAllStyleProperties(); + const styleProperties = this.getAllStyleProperties(); return styleProperties.filter( styleProperty => styleProperty.isDynamic() && styleProperty.isComplete() ); @@ -390,7 +390,7 @@ export class VectorStyle extends AbstractStyle { return null; } - const formattersDataRequest = this._layer.findDataRequestById(dataRequestId); + const formattersDataRequest = this._layer.getDataRequest(dataRequestId); if (!formattersDataRequest || !formattersDataRequest.hasData()) { return null; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style_defaults.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style_defaults.js index 8bc397dd98b56..dd2cf79318d8e 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style_defaults.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style_defaults.js @@ -16,7 +16,7 @@ import chrome from 'ui/chrome'; export const MIN_SIZE = 1; export const MAX_SIZE = 64; -export const DEFAULT_MIN_SIZE = 4; +export const DEFAULT_MIN_SIZE = 7; // Make default large enough to fit default label size export const DEFAULT_MAX_SIZE = 32; export const DEFAULT_SIGMA = 3; export const DEFAULT_LABEL_SIZE = 14; diff --git a/x-pack/legacy/plugins/maps/public/layers/tile_layer.js b/x-pack/legacy/plugins/maps/public/layers/tile_layer.js index b35adcad976c3..aa2619e96f834 100644 --- a/x-pack/legacy/plugins/maps/public/layers/tile_layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/tile_layer.js @@ -30,7 +30,7 @@ export class TileLayer extends AbstractLayer { const requestToken = Symbol(`layer-source-refresh:${this.getId()} - source`); startLoading(SOURCE_DATA_ID_ORIGIN, requestToken, dataFilters); try { - const url = await this._source.getUrlTemplate(); + const url = await this.getSource().getUrlTemplate(); stopLoading(SOURCE_DATA_ID_ORIGIN, requestToken, url, {}); } catch (error) { onLoadError(SOURCE_DATA_ID_ORIGIN, requestToken, error.message); diff --git a/x-pack/legacy/plugins/maps/public/layers/tile_layer.test.ts b/x-pack/legacy/plugins/maps/public/layers/tile_layer.test.ts index 065fbd79d9789..8ce38a128ebc4 100644 --- a/x-pack/legacy/plugins/maps/public/layers/tile_layer.test.ts +++ b/x-pack/legacy/plugins/maps/public/layers/tile_layer.test.ts @@ -17,7 +17,7 @@ const sourceDescriptor: XYZTMSSourceDescriptor = { }; class MockTileSource implements ITMSSource { - private _descriptor: XYZTMSSourceDescriptor; + private readonly _descriptor: XYZTMSSourceDescriptor; constructor(descriptor: XYZTMSSourceDescriptor) { this._descriptor = descriptor; } @@ -32,6 +32,14 @@ class MockTileSource implements ITMSSource { async getUrlTemplate(): Promise { return 'template/{x}/{y}/{z}.png'; } + + destroy(): void { + // no-op + } + + getInspectorAdapters(): object { + return {}; + } } describe('TileLayer', () => { diff --git a/x-pack/legacy/plugins/maps/public/layers/util/data_request.js b/x-pack/legacy/plugins/maps/public/layers/util/data_request.ts similarity index 61% rename from x-pack/legacy/plugins/maps/public/layers/util/data_request.js rename to x-pack/legacy/plugins/maps/public/layers/util/data_request.ts index 3a6c10a9f07a6..e361574194628 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/data_request.js +++ b/x-pack/legacy/plugins/maps/public/layers/util/data_request.ts @@ -3,42 +3,47 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable max-classes-per-file */ + import _ from 'lodash'; +import { DataRequestDescriptor, DataMeta } from '../../../common/data_request_descriptor_types'; export class DataRequest { - constructor(descriptor) { + private readonly _descriptor: DataRequestDescriptor; + + constructor(descriptor: DataRequestDescriptor) { this._descriptor = { ...descriptor, }; } - getData() { + getData(): object | undefined { return this._descriptor.data; } - isLoading() { + isLoading(): boolean { return !!this._descriptor.dataRequestToken; } - getMeta() { + getMeta(): DataMeta { return this.hasData() ? _.get(this._descriptor, 'dataMeta', {}) : _.get(this._descriptor, 'dataMetaAtStart', {}); } - hasData() { + hasData(): boolean { return !!this._descriptor.data; } - hasDataOrRequestInProgress() { - return this._descriptor.data || this._descriptor.dataRequestToken; + hasDataOrRequestInProgress(): boolean { + return this.hasData() || this.isLoading(); } - getDataId() { + getDataId(): string { return this._descriptor.dataId; } - getRequestToken() { + getRequestToken(): symbol | undefined { return this._descriptor.dataRequestToken; } } diff --git a/x-pack/legacy/plugins/maps/public/layers/vector_layer.d.ts b/x-pack/legacy/plugins/maps/public/layers/vector_layer.d.ts index 748b2fd1d782c..77e8ab768cd00 100644 --- a/x-pack/legacy/plugins/maps/public/layers/vector_layer.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/vector_layer.d.ts @@ -8,20 +8,43 @@ import { AbstractLayer } from './layer'; import { IVectorSource } from './sources/vector_source'; import { VectorLayerDescriptor } from '../../common/descriptor_types'; +import { MapFilters, VectorLayerRequestMeta } from '../../common/data_request_descriptor_types'; import { ILayer } from './layer'; import { IJoin } from './joins/join'; +import { IVectorStyle } from './styles/vector/vector_style'; +import { IField } from './fields/field'; +import { SyncContext } from '../actions/map_actions'; type VectorLayerArguments = { source: IVectorSource; + joins: IJoin[]; layerDescriptor: VectorLayerDescriptor; }; export interface IVectorLayer extends ILayer { + getFields(): Promise; + getStyleEditorFields(): Promise; getValidJoins(): IJoin[]; } export class VectorLayer extends AbstractLayer implements IVectorLayer { + static createDescriptor( + options: VectorLayerArguments, + mapColors: string[] + ): VectorLayerDescriptor; + + protected readonly _source: IVectorSource; + protected readonly _style: IVectorStyle; + constructor(options: VectorLayerArguments); + getFields(): Promise; + getStyleEditorFields(): Promise; getValidJoins(): IJoin[]; + _getSearchFilters( + dataFilters: MapFilters, + source: IVectorSource, + style: IVectorStyle + ): VectorLayerRequestMeta; + _syncData(syncContext: SyncContext, source: IVectorSource, style: IVectorStyle): Promise; } diff --git a/x-pack/legacy/plugins/maps/public/layers/vector_layer.js b/x-pack/legacy/plugins/maps/public/layers/vector_layer.js index 70bba3d91c723..6b89554546330 100644 --- a/x-pack/legacy/plugins/maps/public/layers/vector_layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/vector_layer.js @@ -58,7 +58,7 @@ export class VectorLayer extends AbstractLayer { constructor({ layerDescriptor, source, joins = [] }) { super({ layerDescriptor, source }); this._joins = joins; - this._style = new VectorStyle(this._descriptor.style, this._source, this); + this._style = new VectorStyle(this._descriptor.style, source, this); } getStyle() { @@ -66,10 +66,10 @@ export class VectorLayer extends AbstractLayer { } destroy() { - if (this._source) { - this._source.destroy(); + if (this.getSource()) { + this.getSource().destroy(); } - this._joins.forEach(joinSource => { + this.getJoins().forEach(joinSource => { joinSource.destroy(); }); } @@ -79,7 +79,7 @@ export class VectorLayer extends AbstractLayer { } getValidJoins() { - return this._joins.filter(join => { + return this.getJoins().filter(join => { return join.hasCompleteConfig(); }); } @@ -119,7 +119,7 @@ export class VectorLayer extends AbstractLayer { } if ( - this._joins.length && + this.getJoins().length && !featureCollection.features.some(feature => feature.properties[FEATURE_VISIBLE_PROPERTY_NAME]) ) { return { @@ -131,11 +131,11 @@ export class VectorLayer extends AbstractLayer { } const sourceDataRequest = this.getSourceDataRequest(); - const { tooltipContent, areResultsTrimmed } = this._source.getSourceTooltipContent( + const { tooltipContent, areResultsTrimmed } = this.getSource().getSourceTooltipContent( sourceDataRequest ); return { - icon: this._style.getIcon(), + icon: this.getCurrentStyle().getIcon(), tooltipContent: tooltipContent, areResultsTrimmed: areResultsTrimmed, }; @@ -146,11 +146,11 @@ export class VectorLayer extends AbstractLayer { } async hasLegendDetails() { - return this._style.hasLegendDetails(); + return this.getCurrentStyle().hasLegendDetails(); } renderLegendDetails() { - return this._style.renderLegendDetails(); + return this.getCurrentStyle().renderLegendDetails(); } _getBoundsBasedOnData() { @@ -175,17 +175,22 @@ export class VectorLayer extends AbstractLayer { } async getBounds(dataFilters) { - const isStaticLayer = !this._source.isBoundsAware() || !this._source.isFilterByMapBounds(); + const isStaticLayer = + !this.getSource().isBoundsAware() || !this.getSource().isFilterByMapBounds(); if (isStaticLayer) { return this._getBoundsBasedOnData(); } - const searchFilters = this._getSearchFilters(dataFilters); - return await this._source.getBoundsForFilters(searchFilters); + const searchFilters = this._getSearchFilters( + dataFilters, + this.getSource(), + this.getCurrentStyle() + ); + return await this.getSource().getBoundsForFilters(searchFilters); } async getLeftJoinFields() { - return await this._source.getLeftJoinFields(); + return await this.getSource().getLeftJoinFields(); } _getJoinFields() { @@ -198,12 +203,17 @@ export class VectorLayer extends AbstractLayer { } async getFields() { - const sourceFields = await this._source.getFields(); + const sourceFields = await this.getSource().getFields(); + return [...sourceFields, ...this._getJoinFields()]; + } + + async getStyleEditorFields() { + const sourceFields = await this.getSourceForEditing().getFields(); return [...sourceFields, ...this._getJoinFields()]; } getIndexPatternIds() { - const indexPatternIds = this._source.getIndexPatternIds(); + const indexPatternIds = this.getSource().getIndexPatternIds(); this.getValidJoins().forEach(join => { indexPatternIds.push(...join.getIndexPatternIds()); }); @@ -211,17 +221,13 @@ export class VectorLayer extends AbstractLayer { } getQueryableIndexPatternIds() { - const indexPatternIds = this._source.getQueryableIndexPatternIds(); + const indexPatternIds = this.getSource().getQueryableIndexPatternIds(); this.getValidJoins().forEach(join => { indexPatternIds.push(...join.getQueryableIndexPatternIds()); }); return indexPatternIds; } - findDataRequestById(sourceDataId) { - return this._dataRequests.find(dataRequest => dataRequest.getDataId() === sourceDataId); - } - async _syncJoin({ join, startLoading, @@ -239,7 +245,7 @@ export class VectorLayer extends AbstractLayer { sourceQuery: joinSource.getWhereQuery(), applyGlobalQuery: joinSource.getApplyGlobalQuery(), }; - const prevDataRequest = this.findDataRequestById(sourceDataId); + const prevDataRequest = this.getDataRequest(sourceDataId); const canSkipFetch = await canSkipSourceUpdate({ source: joinSource, @@ -281,30 +287,30 @@ export class VectorLayer extends AbstractLayer { } } - async _syncJoins(syncContext) { + async _syncJoins(syncContext, style) { const joinSyncs = this.getValidJoins().map(async join => { - await this._syncJoinStyleMeta(syncContext, join); - await this._syncJoinFormatters(syncContext, join); + await this._syncJoinStyleMeta(syncContext, join, style); + await this._syncJoinFormatters(syncContext, join, style); return this._syncJoin({ join, ...syncContext }); }); return await Promise.all(joinSyncs); } - _getSearchFilters(dataFilters) { + _getSearchFilters(dataFilters, source, style) { const fieldNames = [ - ...this._source.getFieldNames(), - ...this._style.getSourceFieldNames(), + ...source.getFieldNames(), + ...style.getSourceFieldNames(), ...this.getValidJoins().map(join => join.getLeftField().getName()), ]; return { ...dataFilters, fieldNames: _.uniq(fieldNames).sort(), - geogridPrecision: this._source.getGeoGridPrecision(dataFilters.zoom), + geogridPrecision: source.getGeoGridPrecision(dataFilters.zoom), sourceQuery: this.getQuery(), - applyGlobalQuery: this._source.getApplyGlobalQuery(), - sourceMeta: this._source.getSyncMeta(), + applyGlobalQuery: source.getApplyGlobalQuery(), + sourceMeta: source.getSyncMeta(), }; } @@ -347,20 +353,21 @@ export class VectorLayer extends AbstractLayer { } } - async _syncSource({ - startLoading, - stopLoading, - onLoadError, - registerCancelCallback, - dataFilters, - isRequestStillActive, - }) { + async _syncSource(syncContext, source, style) { + const { + startLoading, + stopLoading, + onLoadError, + registerCancelCallback, + dataFilters, + isRequestStillActive, + } = syncContext; const dataRequestId = SOURCE_DATA_ID_ORIGIN; const requestToken = Symbol(`layer-${this.getId()}-${dataRequestId}`); - const searchFilters = this._getSearchFilters(dataFilters); + const searchFilters = this._getSearchFilters(dataFilters, source, style); const prevDataRequest = this.getSourceDataRequest(); const canSkipFetch = await canSkipSourceUpdate({ - source: this._source, + source, prevDataRequest, nextMeta: searchFilters, }); @@ -373,8 +380,8 @@ export class VectorLayer extends AbstractLayer { try { startLoading(dataRequestId, requestToken, searchFilters); - const layerName = await this.getDisplayName(); - const { data: sourceFeatureCollection, meta } = await this._source.getGeoJsonWithMeta( + const layerName = await this.getDisplayName(source); + const { data: sourceFeatureCollection, meta } = await source.getGeoJsonWithMeta( layerName, searchFilters, registerCancelCallback.bind(null, requestToken), @@ -398,16 +405,17 @@ export class VectorLayer extends AbstractLayer { } } - async _syncSourceStyleMeta(syncContext) { - if (this._style.constructor.type !== LAYER_STYLE_TYPE.VECTOR) { + async _syncSourceStyleMeta(syncContext, source, style) { + if (this.getCurrentStyle().constructor.type !== LAYER_STYLE_TYPE.VECTOR) { return; } return this._syncStyleMeta({ - source: this._source, + source, + style, sourceQuery: this.getQuery(), dataRequestId: SOURCE_META_ID_ORIGIN, - dynamicStyleProps: this._style.getDynamicPropertiesArray().filter(dynamicStyleProp => { + dynamicStyleProps: style.getDynamicPropertiesArray().filter(dynamicStyleProp => { return ( dynamicStyleProp.getFieldOrigin() === FIELD_ORIGIN.SOURCE && dynamicStyleProp.isFieldMetaEnabled() @@ -417,28 +425,32 @@ export class VectorLayer extends AbstractLayer { }); } - async _syncJoinStyleMeta(syncContext, join) { + async _syncJoinStyleMeta(syncContext, join, style) { const joinSource = join.getRightJoinSource(); return this._syncStyleMeta({ source: joinSource, + style, sourceQuery: joinSource.getWhereQuery(), dataRequestId: join.getSourceMetaDataRequestId(), - dynamicStyleProps: this._style.getDynamicPropertiesArray().filter(dynamicStyleProp => { - const matchingField = joinSource.getMetricFieldForName( - dynamicStyleProp.getField().getName() - ); - return ( - dynamicStyleProp.getFieldOrigin() === FIELD_ORIGIN.JOIN && - !!matchingField && - dynamicStyleProp.isFieldMetaEnabled() - ); - }), + dynamicStyleProps: this.getCurrentStyle() + .getDynamicPropertiesArray() + .filter(dynamicStyleProp => { + const matchingField = joinSource.getMetricFieldForName( + dynamicStyleProp.getField().getName() + ); + return ( + dynamicStyleProp.getFieldOrigin() === FIELD_ORIGIN.JOIN && + !!matchingField && + dynamicStyleProp.isFieldMetaEnabled() + ); + }), ...syncContext, }); } async _syncStyleMeta({ source, + style, sourceQuery, dataRequestId, dynamicStyleProps, @@ -459,10 +471,10 @@ export class VectorLayer extends AbstractLayer { const nextMeta = { dynamicStyleFields: _.uniq(dynamicStyleFields).sort(), sourceQuery, - isTimeAware: this._style.isTimeAware() && (await source.isTimeAware()), + isTimeAware: this.getCurrentStyle().isTimeAware() && (await source.isTimeAware()), timeFilters: dataFilters.timeFilters, }; - const prevDataRequest = this.findDataRequestById(dataRequestId); + const prevDataRequest = this.getDataRequest(dataRequestId); const canSkipFetch = canSkipStyleMetaUpdate({ prevDataRequest, nextMeta }); if (canSkipFetch) { return; @@ -471,10 +483,10 @@ export class VectorLayer extends AbstractLayer { const requestToken = Symbol(`layer-${this.getId()}-${dataRequestId}`); try { startLoading(dataRequestId, requestToken, nextMeta); - const layerName = await this.getDisplayName(); + const layerName = await this.getDisplayName(source); const styleMeta = await source.loadStylePropsMeta( layerName, - this._style, + style, dynamicStyleProps, registerCancelCallback, nextMeta @@ -487,15 +499,15 @@ export class VectorLayer extends AbstractLayer { } } - async _syncSourceFormatters(syncContext) { - if (this._style.constructor.type !== LAYER_STYLE_TYPE.VECTOR) { + async _syncSourceFormatters(syncContext, source, style) { + if (style.constructor.type !== LAYER_STYLE_TYPE.VECTOR) { return; } return this._syncFormatters({ - source: this._source, + source, dataRequestId: SOURCE_FORMATTERS_ID_ORIGIN, - fields: this._style + fields: style .getDynamicPropertiesArray() .filter(dynamicStyleProp => { return dynamicStyleProp.getFieldOrigin() === FIELD_ORIGIN.SOURCE; @@ -507,12 +519,12 @@ export class VectorLayer extends AbstractLayer { }); } - async _syncJoinFormatters(syncContext, join) { + async _syncJoinFormatters(syncContext, join, style) { const joinSource = join.getRightJoinSource(); return this._syncFormatters({ source: joinSource, dataRequestId: join.getSourceFormattersDataRequestId(), - fields: this._style + fields: style .getDynamicPropertiesArray() .filter(dynamicStyleProp => { const matchingField = joinSource.getMetricFieldForName( @@ -538,7 +550,7 @@ export class VectorLayer extends AbstractLayer { const nextMeta = { fieldNames: _.uniq(fieldNames).sort(), }; - const prevDataRequest = this.findDataRequestById(dataRequestId); + const prevDataRequest = this.getDataRequest(dataRequestId); const canSkipUpdate = canSkipFormattersUpdate({ prevDataRequest, nextMeta }); if (canSkipUpdate) { return; @@ -565,13 +577,27 @@ export class VectorLayer extends AbstractLayer { } async syncData(syncContext) { + this._syncData(syncContext, this.getSource(), this.getCurrentStyle()); + } + + // TLDR: Do not call getSource or getCurrentStyle in syncData flow. Use 'source' and 'style' arguments instead. + // + // 1) State is contained in the redux store. Layer instance state is readonly. + // 2) Even though data request descriptor updates trigger new instances for rendering, + // syncing data executes on a single object instance. Syncing data can not use updated redux store state. + // + // Blended layer data syncing branches on the source/style depending on whether clustering is used or not. + // Given 1 above, which source/style to use can not be stored in Layer instance state. + // Given 2 above, which source/style to use can not be pulled from data request state. + // Therefore, source and style are provided as arugments and must be used instead of calling getSource or getCurrentStyle. + async _syncData(syncContext, source, style) { if (!this.isVisible() || !this.showAtZoomLevel(syncContext.dataFilters.zoom)) { return; } - await this._syncSourceStyleMeta(syncContext); - await this._syncSourceFormatters(syncContext); - const sourceResult = await this._syncSource(syncContext); + await this._syncSourceStyleMeta(syncContext, source, style); + await this._syncSourceFormatters(syncContext, source, style); + const sourceResult = await this._syncSource(syncContext, source, style); if ( !sourceResult.featureCollection || !sourceResult.featureCollection.features.length || @@ -580,7 +606,7 @@ export class VectorLayer extends AbstractLayer { return; } - const joinStates = await this._syncJoins(syncContext); + const joinStates = await this._syncJoins(syncContext, style); await this._performInnerJoins(sourceResult, joinStates, syncContext.updateSourceData); } @@ -596,7 +622,7 @@ export class VectorLayer extends AbstractLayer { if (!featureCollection) { if (featureCollectionOnMap) { - this._style.clearFeatureState(featureCollectionOnMap, mbMap, this.getId()); + this.getCurrentStyle().clearFeatureState(featureCollectionOnMap, mbMap, this.getId()); } mbGeoJSONSource.setData(EMPTY_FEATURE_COLLECTION); return; @@ -605,7 +631,7 @@ export class VectorLayer extends AbstractLayer { // "feature-state" data expressions are not supported with layout properties. // To work around this limitation, // scaled layout properties (like icon-size) must fall back to geojson property values :( - const hasGeoJsonProperties = this._style.setFeatureStateAndStyleProps( + const hasGeoJsonProperties = this.getCurrentStyle().setFeatureStateAndStyleProps( featureCollection, mbMap, this.getId() @@ -626,7 +652,7 @@ export class VectorLayer extends AbstractLayer { // Point layers symbolized as icons only contain a single mapbox layer. let markerLayerId; let textLayerId; - if (this._style.arePointsSymbolizedAsCircles()) { + if (this.getCurrentStyle().arePointsSymbolizedAsCircles()) { markerLayerId = pointLayerId; textLayerId = this._getMbTextLayerId(); if (symbolLayer) { @@ -680,13 +706,13 @@ export class VectorLayer extends AbstractLayer { mbMap.setFilter(textLayerId, filterExpr); } - this._style.setMBPaintPropertiesForPoints({ + this.getCurrentStyle().setMBPaintPropertiesForPoints({ alpha: this.getAlpha(), mbMap, pointLayerId, }); - this._style.setMBPropertiesForLabelText({ + this.getCurrentStyle().setMBPropertiesForLabelText({ alpha: this.getAlpha(), mbMap, textLayerId, @@ -711,13 +737,13 @@ export class VectorLayer extends AbstractLayer { mbMap.setFilter(symbolLayerId, filterExpr); } - this._style.setMBSymbolPropertiesForPoints({ + this.getCurrentStyle().setMBSymbolPropertiesForPoints({ alpha: this.getAlpha(), mbMap, symbolLayerId, }); - this._style.setMBPropertiesForLabelText({ + this.getCurrentStyle().setMBPropertiesForLabelText({ alpha: this.getAlpha(), mbMap, textLayerId: symbolLayerId, @@ -745,7 +771,7 @@ export class VectorLayer extends AbstractLayer { paint: {}, }); } - this._style.setMBPaintProperties({ + this.getCurrentStyle().setMBPaintProperties({ alpha: this.getAlpha(), mbMap, fillLayerId, @@ -830,9 +856,13 @@ export class VectorLayer extends AbstractLayer { for (let i = 0; i < tooltipsFromSource.length; i++) { const tooltipProperty = tooltipsFromSource[i]; const matchingJoins = []; - for (let j = 0; j < this._joins.length; j++) { - if (this._joins[j].getLeftField().getName() === tooltipProperty.getPropertyKey()) { - matchingJoins.push(this._joins[j]); + for (let j = 0; j < this.getJoins().length; j++) { + if ( + this.getJoins() + [j].getLeftField() + .getName() === tooltipProperty.getPropertyKey() + ) { + matchingJoins.push(this.getJoins()[j]); } } if (matchingJoins.length) { @@ -842,18 +872,22 @@ export class VectorLayer extends AbstractLayer { } async getPropertiesForTooltip(properties) { - let allTooltips = await this._source.filterAndFormatPropertiesToHtml(properties); + let allTooltips = await this.getSource().filterAndFormatPropertiesToHtml(properties); this._addJoinsToSourceTooltips(allTooltips); - for (let i = 0; i < this._joins.length; i++) { - const propsFromJoin = await this._joins[i].filterAndFormatPropertiesForTooltip(properties); + for (let i = 0; i < this.getJoins().length; i++) { + const propsFromJoin = await this.getJoins()[i].filterAndFormatPropertiesForTooltip( + properties + ); allTooltips = [...allTooltips, ...propsFromJoin]; } return allTooltips; } canShowTooltip() { - return this.isVisible() && (this._source.canFormatFeatureProperties() || this._joins.length); + return ( + this.isVisible() && (this.getSource().canFormatFeatureProperties() || this.getJoins().length) + ); } getFeatureById(id) { diff --git a/x-pack/legacy/plugins/maps/public/layers/vector_tile_layer.js b/x-pack/legacy/plugins/maps/public/layers/vector_tile_layer.js index b09ccdc3af8ba..44987fd3e78f0 100644 --- a/x-pack/legacy/plugins/maps/public/layers/vector_tile_layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/vector_tile_layer.js @@ -48,7 +48,7 @@ export class VectorTileLayer extends TileLayer { return; } - const nextMeta = { tileLayerId: this._source.getTileLayerId() }; + const nextMeta = { tileLayerId: this.getSource().getTileLayerId() }; const canSkipSync = this._canSkipSync({ prevDataRequest: this.getSourceDataRequest(), nextMeta, @@ -60,7 +60,7 @@ export class VectorTileLayer extends TileLayer { const requestToken = Symbol(`layer-source-refresh:${this.getId()} - source`); try { startLoading(SOURCE_DATA_ID_ORIGIN, requestToken, dataFilters); - const styleAndSprites = await this._source.getVectorStyleSheetAndSpriteMeta(isRetina()); + const styleAndSprites = await this.getSource().getVectorStyleSheetAndSpriteMeta(isRetina()); const spriteSheetImageData = await loadSpriteSheetImageData(styleAndSprites.spriteMeta.png); const data = { ...styleAndSprites, @@ -78,7 +78,7 @@ export class VectorTileLayer extends TileLayer { _generateMbSourceIdPrefix() { const DELIMITTER = '___'; - return `${this.getId()}${DELIMITTER}${this._source.getTileLayerId()}${DELIMITTER}`; + return `${this.getId()}${DELIMITTER}${this.getSource().getTileLayerId()}${DELIMITTER}`; } _generateMbSourceId(name) { @@ -141,7 +141,7 @@ export class VectorTileLayer extends TileLayer { } _makeNamespacedImageId(imageId) { - const prefix = this._source.getSpriteNamespacePrefix() + '/'; + const prefix = this.getSource().getSpriteNamespacePrefix() + '/'; return prefix + imageId; } diff --git a/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js b/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js index e5eaf8870aa77..79d890bc21f14 100644 --- a/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js +++ b/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js @@ -10,6 +10,7 @@ import { TileLayer } from '../layers/tile_layer'; import { VectorTileLayer } from '../layers/vector_tile_layer'; import { VectorLayer } from '../layers/vector_layer'; import { HeatmapLayer } from '../layers/heatmap_layer'; +import { BlendedVectorLayer } from '../layers/blended_vector_layer'; import { ALL_SOURCES } from '../layers/sources/all_sources'; import { timefilter } from 'ui/timefilter'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths @@ -40,6 +41,8 @@ function createLayerInstance(layerDescriptor, inspectorAdapters) { return new VectorTileLayer({ layerDescriptor, source }); case HeatmapLayer.type: return new HeatmapLayer({ layerDescriptor, source }); + case BlendedVectorLayer.type: + return new BlendedVectorLayer({ layerDescriptor, source }); default: throw new Error(`Unrecognized layerType ${layerDescriptor.type}`); } diff --git a/x-pack/legacy/plugins/maps/public/selectors/map_selectors.test.js b/x-pack/legacy/plugins/maps/public/selectors/map_selectors.test.js index 5ec40a57ebc7f..ef2e23e51a092 100644 --- a/x-pack/legacy/plugins/maps/public/selectors/map_selectors.test.js +++ b/x-pack/legacy/plugins/maps/public/selectors/map_selectors.test.js @@ -5,6 +5,7 @@ */ jest.mock('../layers/vector_layer', () => {}); +jest.mock('../layers/blended_vector_layer', () => {}); jest.mock('../layers/heatmap_layer', () => {}); jest.mock('../layers/vector_tile_layer', () => {}); jest.mock('../layers/sources/all_sources', () => {}); diff --git a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts index 3562834230ea1..c01e6377b039e 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; +import Boom from 'boom'; import { errors as elasticsearchErrors } from 'elasticsearch'; import { ElasticsearchServiceSetup } from 'kibana/server'; import { get } from 'lodash'; @@ -152,5 +154,21 @@ export function jobsQueryFactory(server: ServerFacade, elasticsearch: Elasticsea return hits[0]; }); }, + + async delete(deleteIndex: string, id: string) { + try { + const query = { id, index: deleteIndex }; + return callAsInternalUser('delete', query); + } catch (error) { + const wrappedError = new Error( + i18n.translate('xpack.reporting.jobsQuery.deleteError', { + defaultMessage: 'Could not delete the report: {error}', + values: { error: error.message }, + }) + ); + + throw Boom.boomify(wrappedError, { statusCode: error.status }); + } + }, }; } diff --git a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts index 2de420e6577c3..b9aa75e0ddd00 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts @@ -18,9 +18,13 @@ import { } from '../../types'; import { jobsQueryFactory } from '../lib/jobs_query'; import { ReportingSetupDeps, ReportingCore } from '../types'; -import { jobResponseHandlerFactory } from './lib/job_response_handler'; +import { + deleteJobResponseHandlerFactory, + downloadJobResponseHandlerFactory, +} from './lib/job_response_handler'; import { makeRequestFacade } from './lib/make_request_facade'; import { + getRouteConfigFactoryDeletePre, getRouteConfigFactoryDownloadPre, getRouteConfigFactoryManagementPre, } from './lib/route_config_factories'; @@ -40,7 +44,6 @@ export function registerJobInfoRoutes( const { elasticsearch } = plugins; const jobsQuery = jobsQueryFactory(server, elasticsearch); const getRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); - const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(server, plugins, logger); // list jobs in the queue, paginated server.route({ @@ -138,7 +141,8 @@ export function registerJobInfoRoutes( // trigger a download of the output from a job const exportTypesRegistry = reporting.getExportTypesRegistry(); - const jobResponseHandler = jobResponseHandlerFactory(server, elasticsearch, exportTypesRegistry); + const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(server, plugins, logger); + const downloadResponseHandler = downloadJobResponseHandlerFactory(server, elasticsearch, exportTypesRegistry); // prettier-ignore server.route({ path: `${MAIN_ENTRY}/download/{docId}`, method: 'GET', @@ -147,7 +151,47 @@ export function registerJobInfoRoutes( const request = makeRequestFacade(legacyRequest); const { docId } = request.params; - let response = await jobResponseHandler( + let response = await downloadResponseHandler( + request.pre.management.jobTypes, + request.pre.user, + h, + { docId } + ); + + if (isResponse(response)) { + const { statusCode } = response; + + if (statusCode !== 200) { + if (statusCode === 500) { + logger.error(`Report ${docId} has failed: ${JSON.stringify(response.source)}`); + } else { + logger.debug( + `Report ${docId} has non-OK status: [${statusCode}] Reason: [${JSON.stringify( + response.source + )}]` + ); + } + } + + response = response.header('accept-ranges', 'none'); + } + + return response; + }, + }); + + // allow a report to be deleted + const getRouteConfigDelete = getRouteConfigFactoryDeletePre(server, plugins, logger); + const deleteResponseHandler = deleteJobResponseHandlerFactory(server, elasticsearch); + server.route({ + path: `${MAIN_ENTRY}/delete/{docId}`, + method: 'DELETE', + options: getRouteConfigDelete(), + handler: async (legacyRequest: Legacy.Request, h: ReportingResponseToolkit) => { + const request = makeRequestFacade(legacyRequest); + const { docId } = request.params; + + let response = await deleteResponseHandler( request.pre.management.jobTypes, request.pre.user, h, diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts index 62f0d0a72b389..30627d5b23230 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts @@ -20,7 +20,7 @@ interface JobResponseHandlerOpts { excludeContent?: boolean; } -export function jobResponseHandlerFactory( +export function downloadJobResponseHandlerFactory( server: ServerFacade, elasticsearch: ElasticsearchServiceSetup, exportTypesRegistry: ExportTypesRegistry @@ -36,6 +36,7 @@ export function jobResponseHandlerFactory( opts: JobResponseHandlerOpts = {} ) { const { docId } = params; + // TODO: async/await return jobsQuery.get(user, docId, { includeContent: !opts.excludeContent }).then(doc => { if (!doc) return Boom.notFound(); @@ -67,3 +68,34 @@ export function jobResponseHandlerFactory( }); }; } + +export function deleteJobResponseHandlerFactory( + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup +) { + const jobsQuery = jobsQueryFactory(server, elasticsearch); + + return async function deleteJobResponseHander( + validJobTypes: string[], + user: any, + h: ResponseToolkit, + params: JobResponseHandlerParams + ) { + const { docId } = params; + const doc = await jobsQuery.get(user, docId, { includeContent: false }); + if (!doc) return Boom.notFound(); + + const { jobtype: jobType } = doc._source; + if (!validJobTypes.includes(jobType)) { + return Boom.unauthorized(`Sorry, you are not authorized to delete ${jobType} reports`); + } + + try { + const docIndex = doc._index; + await jobsQuery.delete(docIndex, docId); + return h.response({ deleted: true }); + } catch (error) { + return Boom.boomify(error, { statusCode: error.statusCode }); + } + }; +} diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts index 82ba9ba22c706..3d275d34e2f7d 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts @@ -106,7 +106,22 @@ export function getRouteConfigFactoryDownloadPre( const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); return (): RouteConfigFactory => ({ ...getManagementRouteConfig(), - tags: [API_TAG], + tags: [API_TAG, 'download'], + response: { + ranges: false, + }, + }); +} + +export function getRouteConfigFactoryDeletePre( + server: ServerFacade, + plugins: ReportingSetupDeps, + logger: Logger +): GetRouteConfigFactoryFn { + const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); + return (): RouteConfigFactory => ({ + ...getManagementRouteConfig(), + tags: [API_TAG, 'delete'], response: { ranges: false, }, diff --git a/x-pack/legacy/plugins/reporting/types.d.ts b/x-pack/legacy/plugins/reporting/types.d.ts index 917e9d7daae40..238079ba92a29 100644 --- a/x-pack/legacy/plugins/reporting/types.d.ts +++ b/x-pack/legacy/plugins/reporting/types.d.ts @@ -197,6 +197,7 @@ export interface JobDocPayload { export interface JobSource { _id: string; + _index: string; _source: { jobtype: string; output: JobDocOutput; diff --git a/x-pack/legacy/plugins/rollup/public/crud_app/sections/job_create/steps/step_logistics.js b/x-pack/legacy/plugins/rollup/public/crud_app/sections/job_create/steps/step_logistics.js index 024001d463240..c3996fe3231b1 100644 --- a/x-pack/legacy/plugins/rollup/public/crud_app/sections/job_create/steps/step_logistics.js +++ b/x-pack/legacy/plugins/rollup/public/crud_app/sections/job_create/steps/step_logistics.js @@ -26,14 +26,14 @@ import { // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { CronEditor } from '../../../../../../../../../src/plugins/es_ui_shared/public/components/cron_editor'; -import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from '../../../../legacy_imports'; +import { indexPatterns } from '../../../../../../../../../src/plugins/data/public'; + +import { indices } from '../../../../shared_imports'; import { getLogisticalDetailsUrl, getCronUrl } from '../../../services'; import { StepError } from './components'; -import { indexPatterns } from '../../../../../../../../../src/plugins/data/public'; - const indexPatternIllegalCharacters = indexPatterns.ILLEGAL_CHARACTERS_VISIBLE.join(' '); -const indexIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); +const indexIllegalCharacters = indices.INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); export class StepLogistics extends Component { static propTypes = { diff --git a/x-pack/legacy/plugins/rollup/public/crud_app/sections/job_create/steps_config/validate_rollup_index.js b/x-pack/legacy/plugins/rollup/public/crud_app/sections/job_create/steps_config/validate_rollup_index.js index 637caa2199c42..ac4bacc291ea3 100644 --- a/x-pack/legacy/plugins/rollup/public/crud_app/sections/job_create/steps_config/validate_rollup_index.js +++ b/x-pack/legacy/plugins/rollup/public/crud_app/sections/job_create/steps_config/validate_rollup_index.js @@ -6,7 +6,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { findIllegalCharactersInIndexName } from '../../../../legacy_imports'; +import { indices } from '../../../../shared_imports'; export function validateRollupIndex(rollupIndex, indexPattern) { if (!rollupIndex || !rollupIndex.trim()) { @@ -27,7 +27,7 @@ export function validateRollupIndex(rollupIndex, indexPattern) { ]; } - const illegalCharacters = findIllegalCharactersInIndexName(rollupIndex); + const illegalCharacters = indices.findIllegalCharactersInIndexName(rollupIndex); if (illegalCharacters.length) { return [ diff --git a/x-pack/legacy/plugins/rollup/public/legacy_imports.ts b/x-pack/legacy/plugins/rollup/public/legacy_imports.ts index 07155a4b0a60e..85fa3022f59ed 100644 --- a/x-pack/legacy/plugins/rollup/public/legacy_imports.ts +++ b/x-pack/legacy/plugins/rollup/public/legacy_imports.ts @@ -4,8 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore -export { findIllegalCharactersInIndexName, INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; - export { AggTypeFilters } from 'ui/agg_types'; export { AggTypeFieldFilters } from 'ui/agg_types'; diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/datasources/index.js b/x-pack/legacy/plugins/rollup/public/shared_imports.ts similarity index 75% rename from x-pack/legacy/plugins/canvas/public/expression_types/datasources/index.js rename to x-pack/legacy/plugins/rollup/public/shared_imports.ts index 91dca7d275f8b..6bf74da6db6fe 100644 --- a/x-pack/legacy/plugins/canvas/public/expression_types/datasources/index.js +++ b/x-pack/legacy/plugins/rollup/public/shared_imports.ts @@ -4,6 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { esdocs } from './esdocs'; - -export const datasourceSpecs = [esdocs]; +export { indices } from '../../../../../src/plugins/es_ui_shared/public'; diff --git a/x-pack/legacy/plugins/siem/common/constants.ts b/x-pack/legacy/plugins/siem/common/constants.ts index 2a30293c244af..c3fc4aea77863 100644 --- a/x-pack/legacy/plugins/siem/common/constants.ts +++ b/x-pack/legacy/plugins/siem/common/constants.ts @@ -72,6 +72,9 @@ export const DETECTION_ENGINE_TAGS_URL = `${DETECTION_ENGINE_URL}/tags`; export const DETECTION_ENGINE_RULES_STATUS_URL = `${DETECTION_ENGINE_RULES_URL}/_find_statuses`; export const DETECTION_ENGINE_PREPACKAGED_RULES_STATUS_URL = `${DETECTION_ENGINE_RULES_URL}/prepackaged/_status`; +export const TIMELINE_URL = '/api/timeline'; +export const TIMELINE_EXPORT_URL = `${TIMELINE_URL}/_export`; + /** * Default signals index key for kibana.dev.yml */ diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/common.ts b/x-pack/legacy/plugins/siem/cypress/tasks/common.ts index a99471d92828e..03a1fe4496030 100644 --- a/x-pack/legacy/plugins/siem/cypress/tasks/common.ts +++ b/x-pack/legacy/plugins/siem/cypress/tasks/common.ts @@ -23,14 +23,14 @@ export const drag = (subject: JQuery) => { clientY: subjectLocation.top, force: true, }) - .wait(5) + .wait(100) .trigger('mousemove', { button: primaryButton, clientX: subjectLocation.left + dndSloppyClickDetectionThreshold, clientY: subjectLocation.top, force: true, }) - .wait(5); + .wait(100); }; /** Drags the subject being dragged on the specified drop target, but does not drop it */ @@ -44,7 +44,7 @@ export const dragWithoutDrop = (dropTarget: JQuery) => { export const drop = (dropTarget: JQuery) => { cy.wrap(dropTarget) .trigger('mousemove', { button: primaryButton, force: true }) - .wait(5) + .wait(100) .trigger('mouseup', { force: true }) - .wait(5); + .wait(100); }; diff --git a/x-pack/legacy/plugins/siem/public/components/generic_downloader/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/generic_downloader/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..219be8cbda311 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/generic_downloader/__snapshots__/index.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GenericDownloader renders correctly against snapshot 1`] = ``; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.test.tsx similarity index 63% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.test.tsx rename to x-pack/legacy/plugins/siem/public/components/generic_downloader/index.test.tsx index 6306260dfc872..a70772911ba60 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.test.tsx @@ -6,12 +6,16 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { RuleDownloaderComponent } from './index'; +import { GenericDownloaderComponent } from './index'; -describe('RuleDownloader', () => { +describe('GenericDownloader', () => { test('renders correctly against snapshot', () => { const wrapper = shallow( - + ); expect(wrapper).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx similarity index 62% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx rename to x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx index 959864d50747f..6f08f5c8c381c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx @@ -7,18 +7,28 @@ import React, { useEffect, useRef } from 'react'; import styled from 'styled-components'; import { isFunction } from 'lodash/fp'; -import { exportRules } from '../../../../../containers/detection_engine/rules'; -import { useStateToaster, errorToToaster } from '../../../../../components/toasters'; import * as i18n from './translations'; +import { ExportDocumentsProps } from '../../containers/detection_engine/rules'; +import { useStateToaster, errorToToaster } from '../toasters'; + const InvisibleAnchor = styled.a` display: none; `; -export interface RuleDownloaderProps { +export type ExportSelectedData = ({ + excludeExportDetails, + filename, + ids, + signal, +}: ExportDocumentsProps) => Promise; + +export interface GenericDownloaderProps { filename: string; - ruleIds?: string[]; - onExportComplete: (exportCount: number) => void; + ids?: string[]; + exportSelectedData: ExportSelectedData; + onExportSuccess?: (exportCount: number) => void; + onExportFailure?: () => void; } /** @@ -28,11 +38,14 @@ export interface RuleDownloaderProps { * @param payload Rule[] * */ -export const RuleDownloaderComponent = ({ + +export const GenericDownloaderComponent = ({ + exportSelectedData, filename, - ruleIds, - onExportComplete, -}: RuleDownloaderProps) => { + ids, + onExportSuccess, + onExportFailure, +}: GenericDownloaderProps) => { const anchorRef = useRef(null); const [, dispatchToaster] = useStateToaster(); @@ -40,11 +53,11 @@ export const RuleDownloaderComponent = ({ let isSubscribed = true; const abortCtrl = new AbortController(); - async function exportData() { - if (anchorRef && anchorRef.current && ruleIds != null && ruleIds.length > 0) { + const exportData = async () => { + if (anchorRef && anchorRef.current && ids != null && ids.length > 0) { try { - const exportResponse = await exportRules({ - ruleIds, + const exportResponse = await exportSelectedData({ + ids, signal: abortCtrl.signal, }); @@ -61,15 +74,20 @@ export const RuleDownloaderComponent = ({ window.URL.revokeObjectURL(objectURL); } - onExportComplete(ruleIds.length); + if (onExportSuccess != null) { + onExportSuccess(ids.length); + } } } catch (error) { if (isSubscribed) { + if (onExportFailure != null) { + onExportFailure(); + } errorToToaster({ title: i18n.EXPORT_FAILURE, error, dispatchToaster }); } } } - } + }; exportData(); @@ -77,13 +95,13 @@ export const RuleDownloaderComponent = ({ isSubscribed = false; abortCtrl.abort(); }; - }, [ruleIds]); + }, [ids]); return ; }; -RuleDownloaderComponent.displayName = 'RuleDownloaderComponent'; +GenericDownloaderComponent.displayName = 'GenericDownloaderComponent'; -export const RuleDownloader = React.memo(RuleDownloaderComponent); +export const GenericDownloader = React.memo(GenericDownloaderComponent); -RuleDownloader.displayName = 'RuleDownloader'; +GenericDownloader.displayName = 'GenericDownloader'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/translations.ts b/x-pack/legacy/plugins/siem/public/components/generic_downloader/translations.ts similarity index 100% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/translations.ts rename to x-pack/legacy/plugins/siem/public/components/generic_downloader/translations.ts diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx index e061141bf43e7..bb8f9b807c030 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx @@ -12,10 +12,10 @@ import { DeleteTimelineModal } from './delete_timeline_modal'; import * as i18n from '../translations'; describe('DeleteTimelineModal', () => { - test('it renders the expected title when a title is specified', () => { + test('it renders the expected title when a timeline is selected', () => { const wrapper = mountWithIntl( @@ -29,10 +29,10 @@ describe('DeleteTimelineModal', () => { ).toEqual('Delete "Privilege Escalation"?'); }); - test('it trims leading and trailing whitespace around the title', () => { + test('it trims leading whitespace around the title', () => { const wrapper = mountWithIntl( diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index 82fe0d1d162a4..026c43feeff9b 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -6,7 +6,8 @@ import { EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React from 'react'; +import React, { useCallback } from 'react'; +import { isEmpty } from 'lodash/fp'; import * as i18n from '../translations'; @@ -21,27 +22,34 @@ 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 }) => ( - (({ title, closeModal, onDelete }) => { + const getTitle = useCallback(() => { + const trimmedTitle = title != null ? title.trim() : ''; + const titleResult = !isEmpty(trimmedTitle) ? trimmedTitle : i18n.UNTITLED_TIMELINE; + return ( 0 ? title.trim() : i18n.UNTITLED_TIMELINE, + title: titleResult, }} /> - } - onCancel={closeModal} - onConfirm={onDelete} - cancelButtonText={i18n.CANCEL} - confirmButtonText={i18n.DELETE} - buttonColor="danger" - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - > -
{i18n.DELETE_WARNING}
-
-)); + ); + }, [title]); + return ( + +
{i18n.DELETE_WARNING}
+
+ ); +}); DeleteTimelineModal.displayName = 'DeleteTimelineModal'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx index a3c5371435e52..6e0ba5ebe2425 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx @@ -4,114 +4,54 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonIconProps } from '@elastic/eui'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; -import { DeleteTimelineModalButton } from '.'; +import { DeleteTimelineModalOverlay } from '.'; describe('DeleteTimelineModal', () => { const savedObjectId = 'abcd'; + const defaultProps = { + closeModal: jest.fn(), + deleteTimelines: jest.fn(), + isModalOpen: true, + savedObjectIds: [savedObjectId], + title: 'Privilege Escalation', + }; describe('showModalState', () => { - test('it disables the delete icon if deleteTimelines is not provided', () => { - const wrapper = mountWithIntl( - - ); + test('it does NOT render the modal when isModalOpen is false', () => { + const testProps = { + ...defaultProps, + isModalOpen: false, + }; + const wrapper = mountWithIntl(); - const props = wrapper - .find('[data-test-subj="delete-timeline"]') - .first() - .props() as EuiButtonIconProps; - - expect(props.isDisabled).toBe(true); - }); - - test('it disables the delete icon if savedObjectId is null', () => { - const wrapper = mountWithIntl( - - ); - - const props = wrapper - .find('[data-test-subj="delete-timeline"]') - .first() - .props() as EuiButtonIconProps; - - expect(props.isDisabled).toBe(true); - }); - - test('it disables the delete icon if savedObjectId is an empty string', () => { - const wrapper = mountWithIntl( - - ); - - const props = wrapper - .find('[data-test-subj="delete-timeline"]') - .first() - .props() as EuiButtonIconProps; - - expect(props.isDisabled).toBe(true); - }); - - test('it enables the delete icon if savedObjectId is NOT an empty string', () => { - const wrapper = mountWithIntl( - - ); - - const props = wrapper - .find('[data-test-subj="delete-timeline"]') - .first() - .props() as EuiButtonIconProps; - - expect(props.isDisabled).toBe(false); + expect( + wrapper + .find('[data-test-subj="delete-timeline-modal"]') + .first() + .exists() + ).toBe(false); }); - test('it does NOT render the modal when showModal is false', () => { - const wrapper = mountWithIntl( - - ); + test('it renders the modal when isModalOpen is true', () => { + const wrapper = mountWithIntl(); expect( wrapper .find('[data-test-subj="delete-timeline-modal"]') .first() .exists() - ).toBe(false); + ).toBe(true); }); - test('it renders the modal when showModal is clicked', () => { - const wrapper = mountWithIntl( - - ); - - wrapper - .find('[data-test-subj="delete-timeline"]') - .first() - .simulate('click'); + test('it hides popover when isModalOpen is true', () => { + const wrapper = mountWithIntl(); expect( wrapper - .find('[data-test-subj="delete-timeline-modal"]') + .find('[data-test-subj="remove-popover"]') .first() .exists() ).toBe(true); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx index 982937659c0aa..df01ebacb1f93 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx @@ -4,58 +4,54 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonIcon, EuiModal, EuiToolTip, EuiOverlayMask } from '@elastic/eui'; -import React, { useCallback, useState } from 'react'; +import { EuiModal, EuiOverlayMask } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { createGlobalStyle } from 'styled-components'; import { DeleteTimelineModal, DELETE_TIMELINE_MODAL_WIDTH } from './delete_timeline_modal'; -import * as i18n from '../translations'; import { DeleteTimelines } from '../types'; +const RemovePopover = createGlobalStyle` +div.euiPopover__panel-isOpen { + display: none; +} +`; interface Props { - deleteTimelines?: DeleteTimelines; - savedObjectId?: string | null; - title?: string | null; + deleteTimelines: DeleteTimelines; + onComplete?: () => void; + isModalOpen: boolean; + savedObjectIds: string[]; + title: string | null; } /** * Renders a button that when clicked, displays the `Delete Timeline` modal */ -export const DeleteTimelineModalButton = React.memo( - ({ deleteTimelines, savedObjectId, title }) => { - const [showModal, setShowModal] = useState(false); - - const openModal = useCallback(() => setShowModal(true), [setShowModal]); - const closeModal = useCallback(() => setShowModal(false), [setShowModal]); - +export const DeleteTimelineModalOverlay = React.memo( + ({ deleteTimelines, isModalOpen, savedObjectIds, title, onComplete }) => { + const internalCloseModal = useCallback(() => { + if (onComplete != null) { + onComplete(); + } + }, [onComplete]); const onDelete = useCallback(() => { - if (deleteTimelines != null && savedObjectId != null) { - deleteTimelines([savedObjectId]); + if (savedObjectIds != null) { + deleteTimelines(savedObjectIds); } - closeModal(); - }, [deleteTimelines, savedObjectId, closeModal]); - + if (onComplete != null) { + onComplete(); + } + }, [deleteTimelines, savedObjectIds, onComplete]); return ( <> - - - - - {showModal ? ( + {isModalOpen && } + {isModalOpen ? ( - + @@ -64,5 +60,4 @@ export const DeleteTimelineModalButton = React.memo( ); } ); - -DeleteTimelineModalButton.displayName = 'DeleteTimelineModalButton'; +DeleteTimelineModalOverlay.displayName = 'DeleteTimelineModalOverlay'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx new file mode 100644 index 0000000000000..112e73a47ce7d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, useCallback } from 'react'; +import { OpenTimelineResult } from './types'; + +export const useEditTimelineActions = () => { + const [actionItem, setActionTimeline] = useState(null); + const [isDeleteTimelineModalOpen, setIsDeleteTimelineModalOpen] = useState(false); + const [isEnableDownloader, setIsEnableDownloader] = useState(false); + + // Handle Delete Modal + const onCloseDeleteTimelineModal = useCallback(() => { + setIsDeleteTimelineModalOpen(false); + setActionTimeline(null); + }, [actionItem]); + + const onOpenDeleteTimelineModal = useCallback((selectedActionItem?: OpenTimelineResult) => { + setIsDeleteTimelineModalOpen(true); + if (selectedActionItem != null) { + setActionTimeline(selectedActionItem); + } + }, []); + + // Handle Downloader Modal + const enableExportTimelineDownloader = useCallback((selectedActionItem?: OpenTimelineResult) => { + setIsEnableDownloader(true); + if (selectedActionItem != null) { + setActionTimeline(selectedActionItem); + } + }, []); + + const disableExportTimelineDownloader = useCallback(() => { + setIsEnableDownloader(false); + setActionTimeline(null); + }, []); + + // On Compete every tasks + const onCompleteEditTimelineAction = useCallback(() => { + setIsDeleteTimelineModalOpen(false); + setIsEnableDownloader(false); + setActionTimeline(null); + }, []); + + return { + actionItem, + onCompleteEditTimelineAction, + isDeleteTimelineModalOpen, + onCloseDeleteTimelineModal, + onOpenDeleteTimelineModal, + isEnableDownloader, + enableExportTimelineDownloader, + disableExportTimelineDownloader, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx new file mode 100644 index 0000000000000..74b9a8cad98dc --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiContextMenuPanel, EuiContextMenuItem, EuiBasicTable } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { isEmpty } from 'lodash/fp'; +import * as i18n from './translations'; +import { DeleteTimelines, OpenTimelineResult } from './types'; +import { EditTimelineActions } from './export_timeline'; +import { useEditTimelineActions } from './edit_timeline_actions'; + +const getExportedIds = (selectedTimelines: OpenTimelineResult[]) => { + const array = Array.isArray(selectedTimelines) ? selectedTimelines : [selectedTimelines]; + return array.reduce( + (acc, item) => (item.savedObjectId != null ? [...acc, item.savedObjectId] : [...acc]), + [] as string[] + ); +}; + +export const useEditTimelinBatchActions = ({ + deleteTimelines, + selectedItems, + tableRef, +}: { + deleteTimelines?: DeleteTimelines; + selectedItems?: OpenTimelineResult[]; + tableRef: React.MutableRefObject | undefined>; +}) => { + const { + enableExportTimelineDownloader, + disableExportTimelineDownloader, + isEnableDownloader, + isDeleteTimelineModalOpen, + onOpenDeleteTimelineModal, + onCloseDeleteTimelineModal, + } = useEditTimelineActions(); + + const onCompleteBatchActions = useCallback( + (closePopover?: () => void) => { + if (closePopover != null) closePopover(); + if (tableRef != null && tableRef.current != null) { + tableRef.current.changeSelection([]); + } + disableExportTimelineDownloader(); + onCloseDeleteTimelineModal(); + }, + [disableExportTimelineDownloader, onCloseDeleteTimelineModal, tableRef.current] + ); + + const selectedIds = useMemo(() => getExportedIds(selectedItems ?? []), [selectedItems]); + + const handleEnableExportTimelineDownloader = useCallback(() => enableExportTimelineDownloader(), [ + enableExportTimelineDownloader, + ]); + + const handleOnOpenDeleteTimelineModal = useCallback(() => onOpenDeleteTimelineModal(), [ + onOpenDeleteTimelineModal, + ]); + + const getBatchItemsPopoverContent = useCallback( + (closePopover: () => void) => { + const isDisabled = isEmpty(selectedItems); + return ( + <> + + + + {i18n.EXPORT_SELECTED} + , + + {i18n.DELETE_SELECTED} + , + ]} + /> + + ); + }, + [ + deleteTimelines, + isEnableDownloader, + isDeleteTimelineModalOpen, + selectedIds, + selectedItems, + handleEnableExportTimelineDownloader, + handleOnOpenDeleteTimelineModal, + onCompleteBatchActions, + ] + ); + return { onCompleteBatchActions, getBatchItemsPopoverContent }; +}; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx new file mode 100644 index 0000000000000..d377b10a55c21 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { TimelineDownloader } from './export_timeline'; +import { mockSelectedTimeline } from './mocks'; +import { ReactWrapper, mount } from 'enzyme'; +import { useExportTimeline } from '.'; + +jest.mock('../translations', () => { + return { + EXPORT_SELECTED: 'EXPORT_SELECTED', + EXPORT_FILENAME: 'TIMELINE', + }; +}); + +jest.mock('.', () => { + return { + useExportTimeline: jest.fn(), + }; +}); + +describe('TimelineDownloader', () => { + let wrapper: ReactWrapper; + const defaultTestProps = { + exportedIds: ['baa20980-6301-11ea-9223-95b6d4dd806c'], + getExportedData: jest.fn(), + isEnableDownloader: true, + onComplete: jest.fn(), + }; + describe('should not render a downloader', () => { + beforeAll(() => { + ((useExportTimeline as unknown) as jest.Mock).mockReturnValue({ + enableDownloader: false, + setEnableDownloader: jest.fn(), + exportedIds: {}, + getExportedData: jest.fn(), + }); + }); + + afterAll(() => { + ((useExportTimeline as unknown) as jest.Mock).mockReset(); + }); + + test('Without exportedIds', () => { + const testProps = { + ...defaultTestProps, + exportedIds: undefined, + }; + wrapper = mount(); + expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeFalsy(); + }); + + test('With isEnableDownloader is false', () => { + const testProps = { + ...defaultTestProps, + isEnableDownloader: false, + }; + wrapper = mount(); + expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeFalsy(); + }); + }); + + describe('should render a downloader', () => { + beforeAll(() => { + ((useExportTimeline as unknown) as jest.Mock).mockReturnValue({ + enableDownloader: false, + setEnableDownloader: jest.fn(), + exportedIds: {}, + getExportedData: jest.fn(), + }); + }); + + afterAll(() => { + ((useExportTimeline as unknown) as jest.Mock).mockReset(); + }); + + test('With selectedItems and exportedIds is given and isEnableDownloader is true', () => { + const testProps = { + ...defaultTestProps, + selectedItems: mockSelectedTimeline, + }; + wrapper = mount(); + expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx new file mode 100644 index 0000000000000..ebfd5c18bd5dc --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import uuid from 'uuid'; +import { GenericDownloader, ExportSelectedData } from '../../generic_downloader'; +import * as i18n from '../translations'; +import { useStateToaster } from '../../toasters'; + +const ExportTimeline: React.FC<{ + exportedIds: string[] | undefined; + getExportedData: ExportSelectedData; + isEnableDownloader: boolean; + onComplete?: () => void; +}> = ({ onComplete, isEnableDownloader, exportedIds, getExportedData }) => { + const [, dispatchToaster] = useStateToaster(); + const onExportSuccess = useCallback( + exportCount => { + if (onComplete != null) { + onComplete(); + } + dispatchToaster({ + type: 'addToaster', + toast: { + id: uuid.v4(), + title: i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount), + color: 'success', + iconType: 'check', + }, + }); + }, + [dispatchToaster, onComplete] + ); + const onExportFailure = useCallback(() => { + if (onComplete != null) { + onComplete(); + } + }, [onComplete]); + + return ( + <> + {exportedIds != null && isEnableDownloader && ( + + )} + + ); +}; +ExportTimeline.displayName = 'ExportTimeline'; +export const TimelineDownloader = React.memo(ExportTimeline); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx new file mode 100644 index 0000000000000..674cd6dad5f76 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { useExportTimeline, ExportTimeline } from '.'; + +describe('useExportTimeline', () => { + describe('call with selected timelines', () => { + let exportTimelineRes: ExportTimeline; + const TestHook = () => { + exportTimelineRes = useExportTimeline(); + return
; + }; + + beforeAll(() => { + mount(); + }); + + test('Downloader should be disabled by default', () => { + expect(exportTimelineRes.isEnableDownloader).toBeFalsy(); + }); + + test('Should include disableExportTimelineDownloader in return value', () => { + expect(exportTimelineRes).toHaveProperty('disableExportTimelineDownloader'); + }); + + test('Should include enableExportTimelineDownloader in return value', () => { + expect(exportTimelineRes).toHaveProperty('enableExportTimelineDownloader'); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx new file mode 100644 index 0000000000000..946c4b3a612dd --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useCallback } from 'react'; +import { DeleteTimelines } from '../types'; + +import { TimelineDownloader } from './export_timeline'; +import { DeleteTimelineModalOverlay } from '../delete_timeline_modal'; +import { exportSelectedTimeline } from '../../../containers/timeline/all/api'; + +export interface ExportTimeline { + disableExportTimelineDownloader: () => void; + enableExportTimelineDownloader: () => void; + isEnableDownloader: boolean; +} + +export const useExportTimeline = (): ExportTimeline => { + const [isEnableDownloader, setIsEnableDownloader] = useState(false); + + const enableExportTimelineDownloader = useCallback(() => { + setIsEnableDownloader(true); + }, []); + + const disableExportTimelineDownloader = useCallback(() => { + setIsEnableDownloader(false); + }, []); + + return { + disableExportTimelineDownloader, + enableExportTimelineDownloader, + isEnableDownloader, + }; +}; + +const EditTimelineActionsComponent: React.FC<{ + deleteTimelines: DeleteTimelines | undefined; + ids: string[]; + isEnableDownloader: boolean; + isDeleteTimelineModalOpen: boolean; + onComplete: () => void; + title: string; +}> = ({ + deleteTimelines, + ids, + isEnableDownloader, + isDeleteTimelineModalOpen, + onComplete, + title, +}) => ( + <> + + {deleteTimelines != null && ( + + )} + +); + +export const EditTimelineActions = React.memo(EditTimelineActionsComponent); +export const EditOneTimelineAction = React.memo(EditTimelineActionsComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/mocks.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/mocks.ts new file mode 100644 index 0000000000000..34d763839003c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/mocks.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const mockSelectedTimeline = [ + { + savedObjectId: 'baa20980-6301-11ea-9223-95b6d4dd806c', + version: 'WzExNzAsMV0=', + columns: [ + { + columnHeaderType: 'not-filtered', + indexes: null, + id: '@timestamp', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'message', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'event.category', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'event.action', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'host.name', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'source.ip', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'destination.ip', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'user.name', + name: null, + searchable: null, + }, + ], + dataProviders: [], + description: 'with a global note', + eventType: 'raw', + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + kuery: { kind: 'kuery', expression: 'zeek.files.sha1 : * ' }, + serializedQuery: + '{"bool":{"should":[{"exists":{"field":"zeek.files.sha1"}}],"minimum_should_match":1}}', + }, + }, + title: 'duplicate timeline', + dateRange: { start: 1582538951145, end: 1582625351145 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1583866966262, + createdBy: 'elastic', + updated: 1583866966262, + updatedBy: 'elastic', + notes: [ + { + noteId: 'noteIdOne', + }, + { + noteId: 'noteIdTwo', + }, + ], + pinnedEventIds: { '23D_e3ABGy2SlgJPuyEh': true, eHD_e3ABGy2SlgJPsh4u: true }, + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx index 520e2094fb336..04f0abe0d00d1 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx @@ -526,11 +526,6 @@ describe('StatefulOpenTimeline', () => { .first() .simulate('change', { target: { checked: true } }); expect(getSelectedItem().length).toEqual(13); - wrapper - .find('[data-test-subj="delete-selected"]') - .first() - .simulate('click'); - expect(getSelectedItem().length).toEqual(0); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx index 26a7487fee52b..6d00edf28a88f 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx @@ -256,7 +256,7 @@ export const StatefulOpenTimelineComponent = React.memo( sort={{ sortField: sortField as SortFieldTimeline, sortOrder: sortDirection as Direction }} onlyUserFavorite={onlyFavorites} > - {({ timelines, loading, totalCount }) => { + {({ timelines, loading, totalCount, refetch }) => { return !isModal ? ( ( pageIndex={pageIndex} pageSize={pageSize} query={search} + refetch={refetch} searchResults={timelines} selectedItems={selectedItems} sortDirection={sortDirection} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx index a1ca7812bba34..e010d54d711c3 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx @@ -290,4 +290,270 @@ describe('OpenTimeline', () => { expect(props.actionTimelineToShow).not.toContain('delete'); }); + + test('it renders an empty string when the query is an empty string', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="selectable-query-text"]') + .first() + .text() + ).toEqual(''); + }); + + test('it renders the expected message when the query just has spaces', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="selectable-query-text"]') + .first() + .text() + ).toEqual(''); + }); + + test('it echos the query when the query has non-whitespace characters', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="selectable-query-text"]') + .first() + .text() + ).toContain('Would you like to go to Denver?'); + }); + + test('trims whitespace from the ends of the query', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="selectable-query-text"]') + .first() + .text() + ).toContain('Is it starting to feel cramped in here?'); + }); + + test('it renders the expected message when the query is an empty string', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="query-message"]') + .first() + .text() + ).toContain(`Showing: ${mockResults.length} timelines `); + }); + + test('it renders the expected message when the query just has whitespace', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="query-message"]') + .first() + .text() + ).toContain(`Showing: ${mockResults.length} timelines `); + }); + + test('it includes the word "with" when the query has non-whitespace characters', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="query-message"]') + .first() + .text() + ).toContain(`Showing: ${mockResults.length} timelines with "How was your day?"`); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index 8aab02b495392..b1b100349eb86 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -4,15 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiPanel } from '@elastic/eui'; -import React from 'react'; - +import { EuiPanel, EuiBasicTable } from '@elastic/eui'; +import React, { useCallback, useMemo, useRef } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; -import { OpenTimelineProps } from './types'; +import { OpenTimelineProps, OpenTimelineResult } from './types'; import { SearchRow } from './search_row'; import { TimelinesTable } from './timelines_table'; import { TitleRow } from './title_row'; +import * as i18n from './translations'; +import { + UtilityBarGroup, + UtilityBarText, + UtilityBar, + UtilityBarSection, + UtilityBarAction, +} from '../utility_bar'; +import { useEditTimelinBatchActions } from './edit_timeline_batch_actions'; +import { useEditTimelineActions } from './edit_timeline_actions'; +import { EditOneTimelineAction } from './export_timeline'; + export const OpenTimeline = React.memo( ({ deleteTimelines, @@ -31,56 +43,145 @@ export const OpenTimeline = React.memo( pageIndex, pageSize, query, + refetch, searchResults, selectedItems, sortDirection, sortField, title, totalSearchResultsCount, - }) => ( - - + }) => { + const tableRef = useRef>(); + + const { + actionItem, + enableExportTimelineDownloader, + isEnableDownloader, + isDeleteTimelineModalOpen, + onOpenDeleteTimelineModal, + onCompleteEditTimelineAction, + } = useEditTimelineActions(); + + const { getBatchItemsPopoverContent } = useEditTimelinBatchActions({ + deleteTimelines, + selectedItems, + tableRef, + }); + + const nTimelines = useMemo( + () => ( + + {query.trim().length ? `${i18n.WITH} "${query.trim()}"` : ''} + + ), + }} + /> + ), + [totalSearchResultsCount, query] + ); + + const actionItemId = useMemo( + () => + actionItem != null && actionItem.savedObjectId != null ? [actionItem.savedObjectId] : [], + [actionItem] + ); + + const onRefreshBtnClick = useCallback(() => { + if (typeof refetch === 'function') refetch(); + }, [refetch]); + + return ( + <> + + + + + + + + + + + + <> + {i18n.SHOWING} {nTimelines} + + + - + + {i18n.SELECTED_TIMELINES(selectedItems.length)} + + {i18n.BATCH_ACTIONS} + + + {i18n.REFRESH} + + + + - - - ) + + onDeleteSelected != null && deleteTimelines != null + ? ['delete', 'duplicate', 'export', 'selectable'] + : ['duplicate', 'export', 'selectable'], + [onDeleteSelected, deleteTimelines] + )} + data-test-subj="timelines-table" + deleteTimelines={deleteTimelines} + defaultPageSize={defaultPageSize} + loading={isLoading} + itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} + enableExportTimelineDownloader={enableExportTimelineDownloader} + onOpenDeleteTimelineModal={onOpenDeleteTimelineModal} + onOpenTimeline={onOpenTimeline} + onSelectionChange={onSelectionChange} + onTableChange={onTableChange} + onToggleShowNotes={onToggleShowNotes} + pageIndex={pageIndex} + pageSize={pageSize} + searchResults={searchResults} + showExtendedColumns={true} + sortDirection={sortDirection} + sortField={sortField} + tableRef={tableRef} + totalSearchResultsCount={totalSearchResultsCount} + /> + + + ); + } ); OpenTimeline.displayName = 'OpenTimeline'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx index dcd0b37770583..60ebf2118d556 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx @@ -58,7 +58,6 @@ export const OpenTimelineModalBody = memo( { expect(onQueryChange).toHaveBeenCalled(); }); }); - - describe('Showing message', () => { - test('it renders the expected message when the query is an empty string', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="query-message"]') - .first() - .text() - ).toContain('Showing: 32 timelines '); - }); - - test('it renders the expected message when the query just has whitespace', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="query-message"]') - .first() - .text() - ).toContain('Showing: 32 timelines '); - }); - - test('it includes the word "with" when the query has non-whitespace characters', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="query-message"]') - .first() - .text() - ).toContain('Showing: 32 timelines with'); - }); - }); - - describe('selectable query text', () => { - test('it renders an empty string when the query is an empty string', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="selectable-query-text"]') - .first() - .text() - ).toEqual(''); - }); - - test('it renders the expected message when the query just has spaces', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="selectable-query-text"]') - .first() - .text() - ).toEqual(''); - }); - - test('it echos the query when the query has non-whitespace characters', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="selectable-query-text"]') - .first() - .text() - ).toContain('Would you like to go to Denver?'); - }); - - test('trims whitespace from the ends of the query', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="selectable-query-text"]') - .first() - .text() - ).toContain('Is it starting to feel cramped in here?'); - }); - }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.tsx index 5765d31078bcf..55fce1f1c1ed0 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.tsx @@ -11,9 +11,7 @@ import { EuiFlexItem, // @ts-ignore EuiSearchBar, - EuiText, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import styled from 'styled-components'; @@ -39,56 +37,38 @@ type Props = Pick< 'onlyFavorites' | 'onQueryChange' | 'onToggleOnlyFavorites' | 'query' | 'totalSearchResultsCount' >; +const searchBox = { + placeholder: i18n.SEARCH_PLACEHOLDER, + incremental: false, +}; + /** * Renders the row containing the search input and Only Favorites filter */ export const SearchRow = React.memo( - ({ onlyFavorites, onQueryChange, onToggleOnlyFavorites, query, totalSearchResultsCount }) => ( - - - - - - - - - - {i18n.ONLY_FAVORITES} - - - - + ({ onlyFavorites, onQueryChange, onToggleOnlyFavorites, query, totalSearchResultsCount }) => { + return ( + + + + + - -

- - {query.trim().length ? `${i18n.WITH} "${query.trim()}"` : ''} - - ), - }} - /> -

-
-
- ) + + + + {i18n.ONLY_FAVORITES} + + + + +
+ ); + } ); SearchRow.displayName = 'SearchRow'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx index eec11f571328f..ca82e30798d82 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx @@ -11,14 +11,15 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; -import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; import { mockTimelineResults } from '../../../mock/timeline_results'; import { OpenTimelineResult } from '../types'; -import { TimelinesTable } from '.'; -import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; +import { TimelinesTableProps } from '.'; +import { getMockTimelinesTableProps } from './mocks'; jest.mock('../../../lib/kibana'); +const { TimelinesTable } = jest.requireActual('.'); + describe('#getActionsColumns', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; @@ -28,26 +29,13 @@ describe('#getActionsColumns', () => { }); test('it renders the delete timeline (trash icon) when actionTimelineToShow is including the action delete', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + actionTimelineToShow: ['delete'], + }; const wrapper = mountWithIntl( - + ); @@ -55,26 +43,13 @@ describe('#getActionsColumns', () => { }); test('it does NOT render the delete timeline (trash icon) when actionTimelineToShow is NOT including the action delete', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + actionTimelineToShow: [], + }; const wrapper = mountWithIntl( - + ); @@ -82,26 +57,13 @@ describe('#getActionsColumns', () => { }); test('it renders the duplicate icon timeline when actionTimelineToShow is including the action duplicate', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + actionTimelineToShow: ['duplicate'], + }; const wrapper = mountWithIntl( - + ); @@ -109,26 +71,13 @@ describe('#getActionsColumns', () => { }); test('it does NOT render the duplicate timeline when actionTimelineToShow is NOT including the action duplicate)', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + actionTimelineToShow: [], + }; const wrapper = mountWithIntl( - + ); @@ -136,25 +85,13 @@ describe('#getActionsColumns', () => { }); test('it does NOT render the delete timeline (trash icon) when deleteTimelines is not provided', () => { + const testProps: TimelinesTableProps = { + ...omit('deleteTimelines', getMockTimelinesTableProps(mockResults)), + actionTimelineToShow: ['delete'], + }; const wrapper = mountWithIntl( - + ); @@ -166,56 +103,29 @@ describe('#getActionsColumns', () => { omit('savedObjectId', { ...mockResults[0] }), ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(missingSavedObjectId), + }; const wrapper = mountWithIntl( - + + + ); const props = wrapper .find('[data-test-subj="open-duplicate"]') .first() .props() as EuiButtonIconProps; - expect(props.isDisabled).toBe(true); }); test('it renders an enabled the open duplicate button if the timeline has have a saved object id', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + }; const wrapper = mountWithIntl( - + ); @@ -229,27 +139,13 @@ describe('#getActionsColumns', () => { test('it invokes onOpenTimeline with the expected params when the button is clicked', () => { const onOpenTimeline = jest.fn(); - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + onOpenTimeline, + }; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx index 2b8bd3339cca2..4bbf98dafe38d 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx @@ -6,76 +6,78 @@ /* eslint-disable react/display-name */ -import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; -import React from 'react'; - -import { ACTION_COLUMN_WIDTH } from './common_styles'; -import { DeleteTimelineModalButton } from '../delete_timeline_modal'; -import * as i18n from '../translations'; import { ActionTimelineToShow, DeleteTimelines, + EnableExportTimelineDownloader, OnOpenTimeline, OpenTimelineResult, + OnOpenDeleteTimelineModal, + TimelineActionsOverflowColumns, } from '../types'; +import * as i18n from '../translations'; /** * Returns the action columns (e.g. delete, open duplicate timeline) */ export const getActionsColumns = ({ actionTimelineToShow, - onOpenTimeline, deleteTimelines, + enableExportTimelineDownloader, + onOpenDeleteTimelineModal, + onOpenTimeline, }: { actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; + enableExportTimelineDownloader?: EnableExportTimelineDownloader; + onOpenDeleteTimelineModal?: OnOpenDeleteTimelineModal; onOpenTimeline: OnOpenTimeline; -}) => { +}): [TimelineActionsOverflowColumns] => { const openAsDuplicateColumn = { - align: 'center', - field: 'savedObjectId', - name: '', - render: (savedObjectId: string, timelineResult: OpenTimelineResult) => ( - - - onOpenTimeline({ - duplicate: true, - timelineId: `${timelineResult.savedObjectId}`, - }) - } - size="s" - /> - - ), - sortable: false, - width: ACTION_COLUMN_WIDTH, + name: i18n.OPEN_AS_DUPLICATE, + icon: 'copy', + onClick: ({ savedObjectId }: OpenTimelineResult) => { + onOpenTimeline({ + duplicate: true, + timelineId: savedObjectId ?? '', + }); + }, + enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, + description: i18n.OPEN_AS_DUPLICATE, + 'data-test-subj': 'open-duplicate', + }; + + const exportTimelineAction = { + name: i18n.EXPORT_SELECTED, + icon: 'exportAction', + onClick: (selectedTimeline: OpenTimelineResult) => { + if (enableExportTimelineDownloader != null) enableExportTimelineDownloader(selectedTimeline); + }, + enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, + description: i18n.EXPORT_SELECTED, }; const deleteTimelineColumn = { - align: 'center', - field: 'savedObjectId', - name: '', - render: (savedObjectId: string, { title }: OpenTimelineResult) => ( - - ), - sortable: false, - width: ACTION_COLUMN_WIDTH, + name: i18n.DELETE_SELECTED, + icon: 'trash', + onClick: (selectedTimeline: OpenTimelineResult) => { + if (onOpenDeleteTimelineModal != null) onOpenDeleteTimelineModal(selectedTimeline); + }, + enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, + description: i18n.DELETE_SELECTED, + 'data-test-subj': 'delete-timeline', }; return [ - actionTimelineToShow.includes('duplicate') ? openAsDuplicateColumn : null, - actionTimelineToShow.includes('delete') && deleteTimelines != null - ? deleteTimelineColumn - : null, - ].filter(action => action != null); + { + width: '40px', + actions: [ + actionTimelineToShow.includes('duplicate') ? openAsDuplicateColumn : null, + actionTimelineToShow.includes('export') ? exportTimelineAction : null, + actionTimelineToShow.includes('delete') && deleteTimelines != null + ? deleteTimelineColumn + : null, + ].filter(action => action != null), + }, + ]; }; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx index 0f2cda9d79f0b..093e4a5bab100 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx @@ -11,15 +11,14 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; import { getEmptyValue } from '../../empty_value'; import { OpenTimelineResult } from '../types'; import { mockTimelineResults } from '../../../mock/timeline_results'; import { NotePreviews } from '../note_previews'; -import { TimelinesTable } from '.'; +import { TimelinesTable, TimelinesTableProps } from '.'; import * as i18n from '../translations'; -import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; +import { getMockTimelinesTableProps } from './mocks'; jest.mock('../../../lib/kibana'); @@ -35,25 +34,13 @@ describe('#getCommonColumns', () => { test('it renders the expand button when the timelineResult has notes', () => { const hasNotes: OpenTimelineResult[] = [{ ...mockResults[0] }]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(hasNotes), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(true); @@ -62,25 +49,13 @@ describe('#getCommonColumns', () => { test('it does NOT render the expand button when the timelineResult notes are undefined', () => { const missingNotes: OpenTimelineResult[] = [omit('notes', { ...mockResults[0] })]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(missingNotes), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); @@ -89,25 +64,13 @@ describe('#getCommonColumns', () => { test('it does NOT render the expand button when the timelineResult notes are null', () => { const nullNotes: OpenTimelineResult[] = [{ ...mockResults[0], notes: null }]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(nullNotes), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); @@ -116,25 +79,13 @@ describe('#getCommonColumns', () => { test('it does NOT render the expand button when the notes are empty', () => { const emptylNotes: OpenTimelineResult[] = [{ ...mockResults[0], notes: [] }]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(emptylNotes), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); @@ -144,26 +95,13 @@ describe('#getCommonColumns', () => { const missingSavedObjectId: OpenTimelineResult[] = [ omit('savedObjectId', { ...mockResults[0] }), ]; - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(missingSavedObjectId), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); @@ -172,25 +110,13 @@ describe('#getCommonColumns', () => { test('it does NOT render the expand button when the timelineResult savedObjectId is null', () => { const nullSavedObjectId: OpenTimelineResult[] = [{ ...mockResults[0], savedObjectId: null }]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(nullSavedObjectId), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); @@ -199,25 +125,13 @@ describe('#getCommonColumns', () => { test('it renders the right arrow expander when the row is not expanded', () => { const hasNotes: OpenTimelineResult[] = [{ ...mockResults[0] }]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(hasNotes), + }; const wrapper = mountWithIntl( - + + + ); const props = wrapper @@ -235,26 +149,13 @@ describe('#getCommonColumns', () => { [mockResults[0].savedObjectId!]: , }; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(hasNotes), + itemIdToExpandedNotesRowMap, + }; const wrapper = mountWithIntl( - + ); @@ -275,25 +176,15 @@ describe('#getCommonColumns', () => { abc:
, }; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(hasNotes), + itemIdToExpandedNotesRowMap, + onToggleShowNotes, + }; const wrapper = mountWithIntl( - + + + ); wrapper @@ -317,26 +208,14 @@ describe('#getCommonColumns', () => { 'saved-timeline-11': , }; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(hasNotes), + itemIdToExpandedNotesRowMap, + onToggleShowNotes, + }; const wrapper = mountWithIntl( - + ); @@ -353,26 +232,12 @@ describe('#getCommonColumns', () => { describe('Timeline Name column', () => { test('it renders the expected column name', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + }; const wrapper = mountWithIntl( - + ); @@ -385,26 +250,12 @@ describe('#getCommonColumns', () => { }); test('it renders the title when the timeline has a title and a saved object id', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + }; const wrapper = mountWithIntl( - + ); @@ -421,25 +272,13 @@ describe('#getCommonColumns', () => { omit('savedObjectId', { ...mockResults[0] }), ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(missingSavedObjectId), + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -453,25 +292,13 @@ describe('#getCommonColumns', () => { test('it renders an Untitled Timeline title when the timeline has no title and a saved object id', () => { const missingTitle: OpenTimelineResult[] = [omit('title', { ...mockResults[0] })]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(missingTitle), + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -487,25 +314,13 @@ describe('#getCommonColumns', () => { omit(['title', 'savedObjectId'], { ...mockResults[0] }), ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(withMissingSavedObjectIdAndTitle), + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -521,25 +336,13 @@ describe('#getCommonColumns', () => { { ...mockResults[0], title: ' ' }, ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(withJustWhitespaceTitle), + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -555,25 +358,13 @@ describe('#getCommonColumns', () => { omit('savedObjectId', { ...mockResults[0], title: ' ' }), ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(withMissingSavedObjectId), + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -587,24 +378,7 @@ describe('#getCommonColumns', () => { test('it renders a hyperlink when the timeline has a saved object id', () => { const wrapper = mountWithIntl( - + ); @@ -621,25 +395,13 @@ describe('#getCommonColumns', () => { omit('savedObjectId', { ...mockResults[0] }), ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(missingSavedObjectId), + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -653,26 +415,13 @@ describe('#getCommonColumns', () => { test('it invokes `onOpenTimeline` when the hyperlink is clicked', () => { const onOpenTimeline = jest.fn(); + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + onOpenTimeline, + }; const wrapper = mountWithIntl( - + ); @@ -692,24 +441,7 @@ describe('#getCommonColumns', () => { test('it renders the expected column name', () => { const wrapper = mountWithIntl( - + ); @@ -724,24 +456,7 @@ describe('#getCommonColumns', () => { test('it renders the description when the timeline has a description', () => { const wrapper = mountWithIntl( - + ); @@ -758,24 +473,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( - + ); expect( @@ -791,26 +489,12 @@ describe('#getCommonColumns', () => { { ...mockResults[0], description: ' ' }, ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(justWhitespaceDescription), + }; const wrapper = mountWithIntl( - + ); expect( @@ -826,24 +510,7 @@ describe('#getCommonColumns', () => { test('it renders the expected column name', () => { const wrapper = mountWithIntl( - + ); @@ -858,24 +525,7 @@ describe('#getCommonColumns', () => { test('it renders the last modified (updated) date when the timeline has an updated property', () => { const wrapper = mountWithIntl( - + ); @@ -893,24 +543,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( - + ); expect( diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx index 4cbe1e45c473b..3960d08765126 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx @@ -10,15 +10,14 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; -import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; import { getEmptyValue } from '../../empty_value'; import { mockTimelineResults } from '../../../mock/timeline_results'; import { OpenTimelineResult } from '../types'; -import { TimelinesTable } from '.'; +import { TimelinesTable, TimelinesTableProps } from '.'; import * as i18n from '../translations'; -import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; +import { getMockTimelinesTableProps } from './mocks'; jest.mock('../../../lib/kibana'); @@ -32,26 +31,12 @@ describe('#getExtendedColumns', () => { describe('Modified By column', () => { test('it renders the expected column name', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + }; const wrapper = mountWithIntl( - + ); @@ -64,26 +49,12 @@ describe('#getExtendedColumns', () => { }); test('it renders the username when the timeline has an updatedBy property', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + }; const wrapper = mountWithIntl( - + ); @@ -97,27 +68,12 @@ describe('#getExtendedColumns', () => { test('it renders a placeholder when the timeline is missing the updatedBy property', () => { const missingUpdatedBy: OpenTimelineResult[] = [omit('updatedBy', { ...mockResults[0] })]; - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(missingUpdatedBy), + }; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx index 31377d176acac..658dd96faa986 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx @@ -10,12 +10,10 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; -import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; import { mockTimelineResults } from '../../../mock/timeline_results'; -import { TimelinesTable } from '.'; +import { TimelinesTable, TimelinesTableProps } from '.'; import { OpenTimelineResult } from '../types'; -import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; - +import { getMockTimelinesTableProps } from './mocks'; jest.mock('../../../lib/kibana'); describe('#getActionsColumns', () => { @@ -29,24 +27,7 @@ describe('#getActionsColumns', () => { test('it renders the pinned events header icon', () => { const wrapper = mountWithIntl( - + ); @@ -55,26 +36,13 @@ describe('#getActionsColumns', () => { test('it renders the expected pinned events count', () => { const with6Events = [mockResults[0]]; - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(with6Events), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="pinned-event-count"]').text()).toEqual('6'); @@ -83,24 +51,7 @@ describe('#getActionsColumns', () => { test('it renders the notes count header icon', () => { const wrapper = mountWithIntl( - + ); @@ -109,26 +60,13 @@ describe('#getActionsColumns', () => { test('it renders the expected notes count', () => { const with4Notes = [mockResults[0]]; - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(with4Notes), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="notes-count"]').text()).toEqual('4'); @@ -137,24 +75,7 @@ describe('#getActionsColumns', () => { test('it renders the favorites header icon', () => { const wrapper = mountWithIntl( - + ); @@ -163,26 +84,13 @@ describe('#getActionsColumns', () => { test('it renders an empty star when favorite is undefined', () => { const undefinedFavorite: OpenTimelineResult[] = [omit('favorite', { ...mockResults[0] })]; - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(undefinedFavorite), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="favorite-starEmpty-star"]').exists()).toBe(true); @@ -190,26 +98,13 @@ describe('#getActionsColumns', () => { test('it renders an empty star when favorite is null', () => { const nullFavorite: OpenTimelineResult[] = [{ ...mockResults[0], favorite: null }]; - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(nullFavorite), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="favorite-starEmpty-star"]').exists()).toBe(true); @@ -217,33 +112,20 @@ describe('#getActionsColumns', () => { test('it renders an empty star when favorite is empty', () => { const emptyFavorite: OpenTimelineResult[] = [{ ...mockResults[0], favorite: [] }]; - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(emptyFavorite), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="favorite-starEmpty-star"]').exists()).toBe(true); }); test('it renders an filled star when favorite has one entry', () => { - const emptyFavorite: OpenTimelineResult[] = [ + const favorite: OpenTimelineResult[] = [ { ...mockResults[0], favorite: [ @@ -255,32 +137,20 @@ describe('#getActionsColumns', () => { }, ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(favorite), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="favorite-starFilled-star"]').exists()).toBe(true); }); test('it renders an filled star when favorite has more than one entry', () => { - const emptyFavorite: OpenTimelineResult[] = [ + const favorite: OpenTimelineResult[] = [ { ...mockResults[0], favorite: [ @@ -296,25 +166,13 @@ describe('#getActionsColumns', () => { }, ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(favorite), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="favorite-starFilled-star"]').exists()).toBe(true); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx index 9463bf7de28c1..e124f58a0c989 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx @@ -10,13 +10,12 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; -import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; import { mockTimelineResults } from '../../../mock/timeline_results'; import { OpenTimelineResult } from '../types'; import { TimelinesTable, TimelinesTableProps } from '.'; +import { getMockTimelinesTableProps } from './mocks'; import * as i18n from '../translations'; -import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; jest.mock('../../../lib/kibana'); @@ -31,24 +30,7 @@ describe('TimelinesTable', () => { test('it renders the select all timelines header checkbox when actionTimelineToShow has the action selectable', () => { const wrapper = mountWithIntl( - + ); @@ -61,26 +43,13 @@ describe('TimelinesTable', () => { }); test('it does NOT render the select all timelines header checkbox when actionTimelineToShow has not the action selectable', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + actionTimelineToShow: ['delete', 'duplicate'], + }; const wrapper = mountWithIntl( - + ); @@ -93,26 +62,13 @@ describe('TimelinesTable', () => { }); test('it renders the Modified By column when showExtendedColumns is true ', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + showExtendedColumns: true, + }; const wrapper = mountWithIntl( - + ); @@ -125,33 +81,20 @@ describe('TimelinesTable', () => { }); test('it renders the notes column in the position of the Modified By column when showExtendedColumns is false', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + showExtendedColumns: false, + }; const wrapper = mountWithIntl( - + ); expect( wrapper .find('thead tr th') - .at(5) + .at(6) .find('[data-test-subj="notes-count-header-icon"]') .first() .exists() @@ -161,24 +104,7 @@ describe('TimelinesTable', () => { test('it renders the delete timeline (trash icon) when actionTimelineToShow has the delete action', () => { const wrapper = mountWithIntl( - + ); @@ -191,26 +117,13 @@ describe('TimelinesTable', () => { }); test('it does NOT render the delete timeline (trash icon) when actionTimelineToShow has NOT the delete action', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + actionTimelineToShow: ['duplicate', 'selectable'], + }; const wrapper = mountWithIntl( - + ); @@ -225,24 +138,7 @@ describe('TimelinesTable', () => { test('it renders the rows per page selector when showExtendedColumns is true', () => { const wrapper = mountWithIntl( - + ); @@ -255,26 +151,13 @@ describe('TimelinesTable', () => { }); test('it does NOT render the rows per page selector when showExtendedColumns is false', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + showExtendedColumns: false, + }; const wrapper = mountWithIntl( - + ); @@ -288,27 +171,14 @@ describe('TimelinesTable', () => { test('it renders the default page size specified by the defaultPageSize prop', () => { const defaultPageSize = 123; - + const testProps = { + ...getMockTimelinesTableProps(mockResults), + defaultPageSize, + pageSize: defaultPageSize, + }; const wrapper = mountWithIntl( - + ); @@ -323,24 +193,7 @@ describe('TimelinesTable', () => { test('it sorts the Last Modified column in descending order when showExtendedColumns is true ', () => { const wrapper = mountWithIntl( - + ); @@ -353,26 +206,13 @@ describe('TimelinesTable', () => { }); test('it sorts the Last Modified column in descending order when showExtendedColumns is false ', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + showExtendedColumns: false, + }; const wrapper = mountWithIntl( - + ); @@ -385,25 +225,14 @@ describe('TimelinesTable', () => { }); test('it displays the expected message when no search results are found', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + searchResults: [], + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -416,27 +245,13 @@ describe('TimelinesTable', () => { test('it invokes onTableChange with the expected parameters when a table header is clicked to sort it', () => { const onTableChange = jest.fn(); - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + onTableChange, + }; const wrapper = mountWithIntl( - + ); @@ -455,27 +270,13 @@ describe('TimelinesTable', () => { test('it invokes onSelectionChange when a row is selected', () => { const onSelectionChange = jest.fn(); - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + onSelectionChange, + }; const wrapper = mountWithIntl( - + ); @@ -490,26 +291,13 @@ describe('TimelinesTable', () => { }); test('it enables the table loading animation when isLoading is true', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + loading: true, + }; const wrapper = mountWithIntl( - + ); @@ -524,24 +312,7 @@ describe('TimelinesTable', () => { test('it disables the table loading animation when isLoading is false', () => { const wrapper = mountWithIntl( - + ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx index f09a9f6af048b..7091ef1f0a1f9 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx @@ -17,6 +17,8 @@ import { OnTableChange, OnToggleShowNotes, OpenTimelineResult, + EnableExportTimelineDownloader, + OnOpenDeleteTimelineModal, } from '../types'; import { getActionsColumns } from './actions_columns'; import { getCommonColumns } from './common_columns'; @@ -46,34 +48,44 @@ const getExtendedColumnsIfEnabled = (showExtendedColumns: boolean) => * view, and the full view shown in the `All Timelines` view of the * `Timelines` page */ -const getTimelinesTableColumns = ({ + +export const getTimelinesTableColumns = ({ actionTimelineToShow, deleteTimelines, + enableExportTimelineDownloader, itemIdToExpandedNotesRowMap, + onOpenDeleteTimelineModal, onOpenTimeline, onToggleShowNotes, showExtendedColumns, }: { actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; + enableExportTimelineDownloader?: EnableExportTimelineDownloader; itemIdToExpandedNotesRowMap: Record; + onOpenDeleteTimelineModal?: OnOpenDeleteTimelineModal; onOpenTimeline: OnOpenTimeline; + onSelectionChange: OnSelectionChange; onToggleShowNotes: OnToggleShowNotes; showExtendedColumns: boolean; -}) => [ - ...getCommonColumns({ - itemIdToExpandedNotesRowMap, - onOpenTimeline, - onToggleShowNotes, - }), - ...getExtendedColumnsIfEnabled(showExtendedColumns), - ...getIconHeaderColumns(), - ...getActionsColumns({ - deleteTimelines, - onOpenTimeline, - actionTimelineToShow, - }), -]; +}) => { + return [ + ...getCommonColumns({ + itemIdToExpandedNotesRowMap, + onOpenTimeline, + onToggleShowNotes, + }), + ...getExtendedColumnsIfEnabled(showExtendedColumns), + ...getIconHeaderColumns(), + ...getActionsColumns({ + actionTimelineToShow, + deleteTimelines, + enableExportTimelineDownloader, + onOpenDeleteTimelineModal, + onOpenTimeline, + }), + ]; +}; export interface TimelinesTableProps { actionTimelineToShow: ActionTimelineToShow[]; @@ -81,6 +93,8 @@ export interface TimelinesTableProps { defaultPageSize: number; loading: boolean; itemIdToExpandedNotesRowMap: Record; + enableExportTimelineDownloader?: EnableExportTimelineDownloader; + onOpenDeleteTimelineModal?: OnOpenDeleteTimelineModal; onOpenTimeline: OnOpenTimeline; onSelectionChange: OnSelectionChange; onTableChange: OnTableChange; @@ -91,6 +105,8 @@ export interface TimelinesTableProps { showExtendedColumns: boolean; sortDirection: 'asc' | 'desc'; sortField: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tableRef?: React.MutableRefObject<_EuiBasicTable | undefined>; totalSearchResultsCount: number; } @@ -105,6 +121,8 @@ export const TimelinesTable = React.memo( defaultPageSize, loading: isLoading, itemIdToExpandedNotesRowMap, + enableExportTimelineDownloader, + onOpenDeleteTimelineModal, onOpenTimeline, onSelectionChange, onTableChange, @@ -115,6 +133,7 @@ export const TimelinesTable = React.memo( showExtendedColumns, sortField, sortDirection, + tableRef, totalSearchResultsCount, }) => { const pagination = { @@ -142,14 +161,17 @@ export const TimelinesTable = React.memo( !selectable ? i18n.MISSING_SAVED_OBJECT_ID : undefined, onSelectionChange, }; - + const basicTableProps = tableRef != null ? { ref: tableRef } : {}; return ( ( pagination={pagination} selection={actionTimelineToShow.includes('selectable') ? selection : undefined} sorting={sorting} + {...basicTableProps} /> ); } diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/mocks.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/mocks.ts new file mode 100644 index 0000000000000..519dfc1b66efe --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/mocks.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; +import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; +import { OpenTimelineResult } from '../types'; +import { TimelinesTableProps } from '.'; + +export const getMockTimelinesTableProps = ( + mockOpenTimelineResults: OpenTimelineResult[] +): TimelinesTableProps => ({ + actionTimelineToShow: ['delete', 'duplicate', 'selectable'], + deleteTimelines: jest.fn(), + defaultPageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, + enableExportTimelineDownloader: jest.fn(), + itemIdToExpandedNotesRowMap: {}, + loading: false, + onOpenDeleteTimelineModal: jest.fn(), + onOpenTimeline: jest.fn(), + onSelectionChange: jest.fn(), + onTableChange: jest.fn(), + onToggleShowNotes: jest.fn(), + pageIndex: 0, + pageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, + searchResults: mockOpenTimelineResults, + showExtendedColumns: true, + sortDirection: DEFAULT_SORT_DIRECTION, + sortField: DEFAULT_SORT_FIELD, + totalSearchResultsCount: mockOpenTimelineResults.length, +}); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.test.tsx index 88dfab470ac96..fe49b05ae6275 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.test.tsx @@ -19,12 +19,7 @@ describe('TitleRow', () => { test('it renders the title', () => { const wrapper = mountWithIntl( - + ); @@ -42,7 +37,6 @@ describe('TitleRow', () => { @@ -60,7 +54,7 @@ describe('TitleRow', () => { test('it does NOT render the Favorite Selected button when onAddTimelinesToFavorites is NOT provided', () => { const wrapper = mountWithIntl( - + ); @@ -77,7 +71,6 @@ describe('TitleRow', () => { @@ -97,7 +90,6 @@ describe('TitleRow', () => { @@ -119,7 +111,6 @@ describe('TitleRow', () => { @@ -134,107 +125,4 @@ describe('TitleRow', () => { expect(onAddTimelinesToFavorites).toHaveBeenCalled(); }); }); - - describe('Delete Selected button', () => { - test('it renders the Delete Selected button when onDeleteSelected is provided', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="delete-selected"]') - .first() - .exists() - ).toBe(true); - }); - - test('it does NOT render the Delete Selected button when onDeleteSelected is NOT provided', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="delete-selected"]') - .first() - .exists() - ).toBe(false); - }); - - test('it disables the Delete Selected button when the selectedTimelinesCount is 0', () => { - const wrapper = mountWithIntl( - - - - ); - - const props = wrapper - .find('[data-test-subj="delete-selected"]') - .first() - .props() as EuiButtonProps; - - expect(props.isDisabled).toBe(true); - }); - - test('it enables the Delete Selected button when the selectedTimelinesCount is greater than 0', () => { - const wrapper = mountWithIntl( - - - - ); - - const props = wrapper - .find('[data-test-subj="delete-selected"]') - .first() - .props() as EuiButtonProps; - - expect(props.isDisabled).toBe(false); - }); - - test('it invokes onDeleteSelected when the Delete Selected button is clicked', () => { - const onDeleteSelected = jest.fn(); - - const wrapper = mountWithIntl( - - - - ); - - wrapper - .find('[data-test-subj="delete-selected"]') - .first() - .simulate('click'); - - expect(onDeleteSelected).toHaveBeenCalled(); - }); - }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.tsx index c7de367e04364..559bbc3eecb82 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.tsx @@ -11,9 +11,10 @@ import * as i18n from '../translations'; import { OpenTimelineProps } from '../types'; import { HeaderSection } from '../../header_section'; -type Props = Pick & { +type Props = Pick & { /** The number of timelines currently selected */ selectedTimelinesCount: number; + children?: JSX.Element; }; /** @@ -21,39 +22,25 @@ type Props = Pick( - ({ onAddTimelinesToFavorites, onDeleteSelected, selectedTimelinesCount, title }) => ( - - {(onAddTimelinesToFavorites || onDeleteSelected) && ( - - {onAddTimelinesToFavorites && ( - - - {i18n.FAVORITE_SELECTED} - - - )} + ({ children, onAddTimelinesToFavorites, selectedTimelinesCount, title }) => ( + + + {onAddTimelinesToFavorites && ( + + + {i18n.FAVORITE_SELECTED} + + + )} - {onDeleteSelected && ( - - - {i18n.DELETE_SELECTED} - - - )} - - )} + {children && {children}} + ) ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts index b4e0d9967f2a9..4063b73d9499a 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts @@ -6,6 +6,14 @@ import { i18n } from '@kbn/i18n'; +export const ALL_ACTIONS = i18n.translate('xpack.siem.open.timeline.allActionsTooltip', { + defaultMessage: 'All actions', +}); + +export const BATCH_ACTIONS = i18n.translate('xpack.siem.open.timeline.batchActionsTitle', { + defaultMessage: 'Bulk actions', +}); + export const CANCEL = i18n.translate('xpack.siem.open.timeline.cancelButton', { defaultMessage: 'Cancel', }); @@ -34,6 +42,14 @@ export const EXPAND = i18n.translate('xpack.siem.open.timeline.expandButton', { defaultMessage: 'Expand', }); +export const EXPORT_FILENAME = i18n.translate('xpack.siem.open.timeline.exportFileNameTitle', { + defaultMessage: 'timelines_export', +}); + +export const EXPORT_SELECTED = i18n.translate('xpack.siem.open.timeline.exportSelectedButton', { + defaultMessage: 'Export selected', +}); + export const FAVORITE_SELECTED = i18n.translate('xpack.siem.open.timeline.favoriteSelectedButton', { defaultMessage: 'Favorite selected', }); @@ -66,7 +82,7 @@ export const ONLY_FAVORITES = i18n.translate('xpack.siem.open.timeline.onlyFavor }); export const OPEN_AS_DUPLICATE = i18n.translate('xpack.siem.open.timeline.openAsDuplicateTooltip', { - defaultMessage: 'Open as a duplicate timeline', + defaultMessage: 'Duplicate timeline', }); export const OPEN_TIMELINE = i18n.translate('xpack.siem.open.timeline.openTimelineButton', { @@ -85,6 +101,10 @@ export const POSTED = i18n.translate('xpack.siem.open.timeline.postedLabel', { defaultMessage: 'Posted:', }); +export const REFRESH = i18n.translate('xpack.siem.open.timeline.refreshTitle', { + defaultMessage: 'Refresh', +}); + export const SEARCH_PLACEHOLDER = i18n.translate('xpack.siem.open.timeline.searchPlaceholder', { defaultMessage: 'e.g. timeline name, or description', }); @@ -107,3 +127,21 @@ export const ZERO_TIMELINES_MATCH = i18n.translate( defaultMessage: '0 timelines match the search criteria', } ); + +export const SELECTED_TIMELINES = (selectedTimelines: number) => + i18n.translate('xpack.siem.open.timeline.selectedTimelinesTitle', { + values: { selectedTimelines }, + defaultMessage: + 'Selected {selectedTimelines} {selectedTimelines, plural, =1 {timeline} other {timelines}}', + }); + +export const SHOWING = i18n.translate('xpack.siem.open.timeline.showingLabel', { + defaultMessage: 'Showing:', +}); + +export const SUCCESSFULLY_EXPORTED_TIMELINES = (totalTimelines: number) => + i18n.translate('xpack.siem.open.timeline.successfullyExportedTimelinesTitle', { + values: { totalTimelines }, + defaultMessage: + 'Successfully exported {totalTimelines, plural, =0 {all timelines} =1 {{totalTimelines} timeline} other {{totalTimelines} timelines}}', + }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts index b14bb1cf86d31..b466ea32799d9 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { SetStateAction, Dispatch } from 'react'; import { AllTimelinesVariables } from '../../containers/timeline/all'; import { TimelineModel } from '../../store/timeline/model'; import { NoteResult } from '../../graphql/types'; +import { Refetch } from '../../store/inputs/model'; /** The users who added a timeline to favorites */ export interface FavoriteTimelineResult { @@ -18,10 +20,22 @@ export interface FavoriteTimelineResult { export interface TimelineResultNote { savedObjectId?: string | null; note?: string | null; + noteId?: string | null; updated?: number | null; updatedBy?: string | null; } +export interface TimelineActionsOverflowColumns { + width: string; + actions: Array<{ + name: string; + icon?: string; + onClick?: (timeline: OpenTimelineResult) => void; + description: string; + render?: (timeline: OpenTimelineResult) => JSX.Element; + } | null>; +} + /** The results of the query run by the OpenTimeline component */ export interface OpenTimelineResult { created?: number | null; @@ -65,6 +79,9 @@ export type OnOpenTimeline = ({ timelineId: string; }) => void; +export type OnOpenDeleteTimelineModal = (selectedItem: OpenTimelineResult) => void; +export type SetActionTimeline = Dispatch>; +export type EnableExportTimelineDownloader = (selectedItem: OpenTimelineResult) => void; /** Invoked when the user presses enters to submit the text in the search input */ export type OnQueryChange = (query: EuiSearchBarQuery) => void; @@ -92,7 +109,7 @@ export interface OnTableChangeParams { /** Invoked by the EUI table implementation when the user interacts with the table */ export type OnTableChange = (tableChange: OnTableChangeParams) => void; -export type ActionTimelineToShow = 'duplicate' | 'delete' | 'selectable'; +export type ActionTimelineToShow = 'duplicate' | 'delete' | 'export' | 'selectable'; export interface OpenTimelineProps { /** Invoked when the user clicks the delete (trash) icon on an individual timeline */ @@ -127,6 +144,9 @@ export interface OpenTimelineProps { pageSize: number; /** The currently applied search criteria */ query: string; + /** Refetch timelines data */ + refetch?: Refetch; + /** The results of executing a search */ searchResults: OpenTimelineResult[]; /** the currently-selected timelines in the table */ diff --git a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap index c3ce9a97bbea1..e15ce0ae5f543 100644 --- a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap @@ -38,18 +38,18 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] = data-test-subj="stat-item" >

@@ -258,18 +258,18 @@ exports[`Stat Items Component disable charts it renders the default widget 2`] = data-test-subj="stat-item" >

@@ -548,18 +548,18 @@ exports[`Stat Items Component rendering kpis with charts it renders the default data-test-subj="stat-item" >

1,714 @@ -734,10 +734,10 @@ exports[`Stat Items Component rendering kpis with charts it renders the default key="stat-items-field-uniqueDestinationIps" >

2,359 @@ -815,10 +815,10 @@ exports[`Stat Items Component rendering kpis with charts it renders the default >

=> { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { + const response = await KibanaServices.get().http.fetch(CASES_URL, { method: 'POST', body: JSON.stringify(newCase), }); @@ -104,13 +112,21 @@ export const patchCase = async ( updatedCase: Partial, version: string ): Promise => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { + const response = await KibanaServices.get().http.fetch(CASES_URL, { method: 'PATCH', body: JSON.stringify({ cases: [{ ...updatedCase, id: caseId, version }] }), }); return convertToCamelCase(decodeCasesResponse(response)); }; +export const patchCasesStatus = async (cases: BulkUpdateStatus[]): Promise => { + const response = await KibanaServices.get().http.fetch(CASES_URL, { + method: 'PATCH', + body: JSON.stringify({ cases }), + }); + return convertToCamelCase(decodeCasesResponse(response)); +}; + export const postComment = async (newComment: CommentRequest, caseId: string): Promise => { const response = await KibanaServices.get().http.fetch( `${CASES_URL}/${caseId}/comments`, @@ -139,7 +155,7 @@ export const patchComment = async ( }; export const deleteCases = async (caseIds: string[]): Promise => { - const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { + const response = await KibanaServices.get().http.fetch(CASES_URL, { method: 'DELETE', query: { ids: JSON.stringify(caseIds) }, }); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/configure/api.ts index a6db36d8f64e7..ed47cdc62a1b6 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/api.ts @@ -16,7 +16,7 @@ import { KibanaServices } from '../../../lib/kibana'; import { CASES_CONFIGURE_URL } from '../constants'; import { ApiProps } from '../types'; import { convertToCamelCase, decodeCaseConfigureResponse } from '../utils'; -import { CaseConfigure, PatchConnectorProps } from './types'; +import { CaseConfigure } from './types'; export const fetchConnectors = async ({ signal }: ApiProps): Promise => { const response = await KibanaServices.get().http.fetch( @@ -79,20 +79,3 @@ export const patchCaseConfigure = async ( decodeCaseConfigureResponse(response) ); }; - -export const patchConfigConnector = async ({ - connectorId, - config, - signal, -}: PatchConnectorProps): Promise => { - const response = await KibanaServices.get().http.fetch( - `${CASES_CONFIGURE_URL}/connectors/${connectorId}`, - { - method: 'PATCH', - body: JSON.stringify(config), - signal, - } - ); - - return response; -}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts index 840828307163c..fc7aaa3643d77 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticUser, ApiProps } from '../types'; +import { ElasticUser } from '../types'; import { ActionType, - CasesConnectorConfiguration, CasesConfigurationMaps, CaseField, ClosureType, @@ -33,11 +32,6 @@ export interface CaseConfigure { version: string; } -export interface PatchConnectorProps extends ApiProps { - connectorId: string; - config: CasesConnectorConfiguration; -} - export interface CCMapsCombinedActionAttributes extends CasesConfigurationMaps { actionType?: ActionType; } diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_connectors.tsx b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_connectors.tsx index f905ebe756d7d..d31dcdbee2a14 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_connectors.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_connectors.tsx @@ -8,14 +8,13 @@ import { useState, useEffect, useCallback } from 'react'; import { useStateToaster, errorToToaster } from '../../../components/toasters'; import * as i18n from '../translations'; -import { fetchConnectors, patchConfigConnector } from './api'; -import { CasesConfigurationMapping, Connector } from './types'; +import { fetchConnectors } from './api'; +import { Connector } from './types'; export interface ReturnConnectors { loading: boolean; connectors: Connector[]; refetchConnectors: () => void; - updateConnector: (connectorId: string, mappings: CasesConfigurationMapping[]) => unknown; } export const useConnectors = (): ReturnConnectors => { @@ -53,55 +52,6 @@ export const useConnectors = (): ReturnConnectors => { }; }, []); - const updateConnector = useCallback( - (connectorId: string, mappings: CasesConfigurationMapping[]) => { - if (connectorId === 'none') { - return; - } - - let didCancel = false; - const abortCtrl = new AbortController(); - const update = async () => { - try { - setLoading(true); - await patchConfigConnector({ - connectorId, - config: { - cases_configuration: { - mapping: mappings.map(m => ({ - source: m.source, - target: m.target, - action_type: m.actionType, - })), - }, - }, - signal: abortCtrl.signal, - }); - if (!didCancel) { - setLoading(false); - refetchConnectors(); - } - } catch (error) { - if (!didCancel) { - setLoading(false); - refetchConnectors(); - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); - } - } - }; - update(); - return () => { - didCancel = true; - abortCtrl.abort(); - }; - }, - [] - ); - useEffect(() => { refetchConnectors(); }, []); @@ -110,6 +60,5 @@ export const useConnectors = (): ReturnConnectors => { loading, connectors, refetchConnectors, - updateConnector, }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 65d94865bf00c..44519031e91cb 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -18,6 +18,8 @@ export interface Comment { export interface Case { id: string; + closedAt: string | null; + closedBy: ElasticUser | null; comments: Comment[]; commentIds: string[]; createdAt: string; @@ -59,12 +61,13 @@ export interface AllCases extends CasesStatus { export enum SortFieldCase { createdAt = 'createdAt', - updatedAt = 'updatedAt', + closedAt = 'closedAt', } export interface ElasticUser { - readonly username: string; + readonly email?: string | null; readonly fullName?: string | null; + readonly username: string; } export interface FetchCasesProps { @@ -75,3 +78,9 @@ export interface FetchCasesProps { export interface ApiProps { signal: AbortSignal; } + +export interface BulkUpdateStatus { + status: string; + id: string; + version: string; +} diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.tsx new file mode 100644 index 0000000000000..77d779ab906cf --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_bulk_update_case.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback, useReducer } from 'react'; +import { errorToToaster, useStateToaster } from '../../components/toasters'; +import * as i18n from './translations'; +import { patchCasesStatus } from './api'; +import { BulkUpdateStatus, Case } from './types'; + +interface UpdateState { + isUpdated: boolean; + isLoading: boolean; + isError: boolean; +} +type Action = + | { type: 'FETCH_INIT' } + | { type: 'FETCH_SUCCESS'; payload: boolean } + | { type: 'FETCH_FAILURE' } + | { type: 'RESET_IS_UPDATED' }; + +const dataFetchReducer = (state: UpdateState, action: Action): UpdateState => { + switch (action.type) { + case 'FETCH_INIT': + return { + ...state, + isLoading: true, + isError: false, + }; + case 'FETCH_SUCCESS': + return { + ...state, + isLoading: false, + isError: false, + isUpdated: action.payload, + }; + case 'FETCH_FAILURE': + return { + ...state, + isLoading: false, + isError: true, + }; + case 'RESET_IS_UPDATED': + return { + ...state, + isUpdated: false, + }; + default: + return state; + } +}; +interface UseUpdateCase extends UpdateState { + updateBulkStatus: (cases: Case[], status: string) => void; + dispatchResetIsUpdated: () => void; +} + +export const useUpdateCases = (): UseUpdateCase => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: false, + isError: false, + isUpdated: false, + }); + const [, dispatchToaster] = useStateToaster(); + + const dispatchUpdateCases = useCallback((cases: BulkUpdateStatus[]) => { + let cancel = false; + const patchData = async () => { + try { + dispatch({ type: 'FETCH_INIT' }); + await patchCasesStatus(cases); + if (!cancel) { + dispatch({ type: 'FETCH_SUCCESS', payload: true }); + } + } catch (error) { + if (!cancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + dispatch({ type: 'FETCH_FAILURE' }); + } + } + }; + patchData(); + return () => { + cancel = true; + }; + }, []); + + const dispatchResetIsUpdated = useCallback(() => { + 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); + }, []); + return { ...state, updateBulkStatus, dispatchResetIsUpdated }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index a179b6f546b9b..b70195e2c126f 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -49,6 +49,8 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { }; const initialData: Case = { id: '', + closedAt: null, + closedBy: null, createdAt: '', comments: [], commentIds: [], diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index afcbe20fa791a..987620469901b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -5,7 +5,7 @@ */ import { useReducer, useCallback } from 'react'; - +import { cloneDeep } from 'lodash/fp'; import { CaseRequest } from '../../../../../../plugins/case/common/api'; import { errorToToaster, useStateToaster } from '../../components/toasters'; @@ -47,7 +47,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => ...state, isLoading: false, isError: false, - caseData: action.payload, + caseData: cloneDeep(action.payload), updateKey: null, }; case 'FETCH_FAILURE': diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts index 3048fc3dc5a02..8fdc6a67f7d71 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts @@ -402,7 +402,7 @@ describe('Detections Rules API', () => { test('check parameter url, body and query when exporting rules', async () => { await exportRules({ - ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + ids: ['mySuperRuleId', 'mySuperRuleId_II'], signal: abortCtrl.signal, }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_export', { @@ -419,7 +419,7 @@ describe('Detections Rules API', () => { test('check parameter url, body and query when exporting rules with excludeExportDetails', async () => { await exportRules({ excludeExportDetails: true, - ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + ids: ['mySuperRuleId', 'mySuperRuleId_II'], signal: abortCtrl.signal, }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_export', { @@ -436,7 +436,7 @@ describe('Detections Rules API', () => { test('check parameter url, body and query when exporting rules with fileName', async () => { await exportRules({ filename: 'myFileName.ndjson', - ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + ids: ['mySuperRuleId', 'mySuperRuleId_II'], signal: abortCtrl.signal, }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_export', { @@ -454,7 +454,7 @@ describe('Detections Rules API', () => { await exportRules({ excludeExportDetails: true, filename: 'myFileName.ndjson', - ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + ids: ['mySuperRuleId', 'mySuperRuleId_II'], signal: abortCtrl.signal, }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_export', { @@ -470,7 +470,7 @@ describe('Detections Rules API', () => { test('happy path', async () => { const resp = await exportRules({ - ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + ids: ['mySuperRuleId', 'mySuperRuleId_II'], signal: abortCtrl.signal, }); expect(resp).toEqual(blob); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts index b52c4964c6695..126de9762a696 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts @@ -16,7 +16,7 @@ import { FetchRuleProps, BasicFetchProps, ImportRulesProps, - ExportRulesProps, + ExportDocumentsProps, RuleStatusResponse, ImportRulesResponse, PrePackagedRulesStatusResponse, @@ -233,13 +233,11 @@ export const importRules = async ({ export const exportRules = async ({ excludeExportDetails = false, filename = `${i18n.EXPORT_FILENAME}.ndjson`, - ruleIds = [], + ids = [], signal, -}: ExportRulesProps): Promise => { +}: ExportDocumentsProps): Promise => { const body = - ruleIds.length > 0 - ? JSON.stringify({ objects: ruleIds.map(rule => ({ rule_id: rule })) }) - : undefined; + ids.length > 0 ? JSON.stringify({ objects: ids.map(rule => ({ rule_id: rule })) }) : undefined; return KibanaServices.get().http.fetch(`${DETECTION_ENGINE_RULES_URL}/_export`, { method: 'POST', diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index f962204c6b1b4..c75d7b78cf92f 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -6,26 +6,35 @@ import * as t from 'io-ts'; +export const RuleTypeSchema = t.keyof({ + query: null, + saved_query: null, + machine_learning: null, +}); +export type RuleType = t.TypeOf; + export const NewRuleSchema = t.intersection([ t.type({ description: t.string, enabled: t.boolean, - filters: t.array(t.unknown), - index: t.array(t.string), interval: t.string, - language: t.string, name: t.string, - query: t.string, risk_score: t.number, severity: t.string, - type: t.union([t.literal('query'), t.literal('saved_query')]), + type: RuleTypeSchema, }), t.partial({ + anomaly_threshold: t.number, created_by: t.string, false_positives: t.array(t.string), + filters: t.array(t.unknown), from: t.string, id: t.string, + index: t.array(t.string), + language: t.string, + machine_learning_job_id: t.string, max_signals: t.number, + query: t.string, references: t.array(t.string), rule_id: t.string, saved_id: t.string, @@ -56,32 +65,34 @@ export const RuleSchema = t.intersection([ description: t.string, enabled: t.boolean, false_positives: t.array(t.string), - filters: t.array(t.unknown), from: t.string, id: t.string, - index: t.array(t.string), interval: t.string, immutable: t.boolean, - language: t.string, name: t.string, max_signals: t.number, - query: t.string, references: t.array(t.string), risk_score: t.number, rule_id: t.string, severity: t.string, tags: t.array(t.string), - type: t.string, + type: RuleTypeSchema, to: t.string, threat: t.array(t.unknown), updated_at: t.string, updated_by: t.string, }), t.partial({ + anomaly_threshold: t.number, + filters: t.array(t.unknown), + index: t.array(t.string), + language: t.string, last_failure_at: t.string, last_failure_message: t.string, meta: MetaRule, + machine_learning_job_id: t.string, output_index: t.string, + query: t.string, saved_id: t.string, status: t.string, status_date: t.string, @@ -180,8 +191,8 @@ export interface ImportRulesResponse { errors: ImportRulesResponseError[]; } -export interface ExportRulesProps { - ruleIds?: string[]; +export interface ExportDocumentsProps { + ids: string[]; filename?: string; excludeExportDetails?: boolean; signal: AbortSignal; diff --git a/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts b/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts new file mode 100644 index 0000000000000..edda2e30ea400 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaServices } from '../../../lib/kibana'; +import { ExportSelectedData } from '../../../components/generic_downloader'; +import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; + +export const exportSelectedTimeline: ExportSelectedData = async ({ + excludeExportDetails = false, + filename = `timelines_export.ndjson`, + ids = [], + signal, +}): Promise => { + const body = ids.length > 0 ? JSON.stringify({ ids }) : undefined; + const response = await KibanaServices.get().http.fetch(`${TIMELINE_EXPORT_URL}`, { + method: 'POST', + body, + query: { + exclude_export_details: excludeExportDetails, + file_name: filename, + }, + signal, + asResponse: true, + }); + + return response.body!; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx b/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx index 22c7b03f34dd5..b5c91ca287f0b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx @@ -3,13 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import React, { useCallback } from 'react'; import { getOr } from 'lodash/fp'; -import React from 'react'; import memoizeOne from 'memoize-one'; import { Query } from 'react-apollo'; +import { ApolloQueryResult } from 'apollo-client'; import { OpenTimelineResult } from '../../../components/open_timeline/types'; import { GetAllTimeline, @@ -23,6 +23,7 @@ export interface AllTimelinesArgs { timelines: OpenTimelineResult[]; loading: boolean; totalCount: number; + refetch: () => void; } export interface AllTimelinesVariables { @@ -36,6 +37,10 @@ interface OwnProps extends AllTimelinesVariables { children?: (args: AllTimelinesArgs) => React.ReactNode; } +type Refetch = ( + variables: GetAllTimeline.Variables | undefined +) => Promise>; + const getAllTimeline = memoizeOne( (variables: string, timelines: TimelineResult[]): OpenTimelineResult[] => timelines.map(timeline => ({ @@ -84,6 +89,8 @@ const AllTimelinesQueryComponent: React.FC = ({ search, sort, }; + const handleRefetch = useCallback((refetch: Refetch) => refetch(variables), [variables]); + return ( query={allTimelinesQuery} @@ -91,9 +98,10 @@ const AllTimelinesQueryComponent: React.FC = ({ notifyOnNetworkStatusChange variables={variables} > - {({ data, loading }) => + {({ data, loading, refetch }) => children!({ loading, + refetch: handleRefetch.bind(null, refetch), totalCount: getOr(0, 'getAllTimeline.totalCount', data), timelines: getAllTimeline( JSON.stringify(variables), diff --git a/x-pack/legacy/plugins/siem/public/legacy.ts b/x-pack/legacy/plugins/siem/public/legacy.ts index 157ec54353a3e..b3a06a170bb80 100644 --- a/x-pack/legacy/plugins/siem/public/legacy.ts +++ b/x-pack/legacy/plugins/siem/public/legacy.ts @@ -5,19 +5,12 @@ */ import { npSetup, npStart } from 'ui/new_platform'; -import { PluginsSetup, PluginsStart } from 'ui/new_platform/new_platform'; import { PluginInitializerContext } from '../../../../../src/core/public'; import { plugin } from './'; -import { - TriggersAndActionsUIPublicPluginSetup, - TriggersAndActionsUIPublicPluginStart, -} from '../../../../plugins/triggers_actions_ui/public'; +import { SetupPlugins, StartPlugins } from './plugin'; const pluginInstance = plugin({} as PluginInitializerContext); -type myPluginsSetup = PluginsSetup & { triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup }; -type myPluginsStart = PluginsStart & { triggers_actions_ui: TriggersAndActionsUIPublicPluginStart }; - -pluginInstance.setup(npSetup.core, npSetup.plugins as myPluginsSetup); -pluginInstance.start(npStart.core, npStart.plugins as myPluginsStart); +pluginInstance.setup(npSetup.core, (npSetup.plugins as unknown) as SetupPlugins); +pluginInstance.start(npStart.core, (npStart.plugins as unknown) as StartPlugins); diff --git a/x-pack/legacy/plugins/siem/public/lib/connectors/servicenow.tsx b/x-pack/legacy/plugins/siem/public/lib/connectors/servicenow.tsx index 877757df30fb3..8e947fbc0f9bb 100644 --- a/x-pack/legacy/plugins/siem/public/lib/connectors/servicenow.tsx +++ b/x-pack/legacy/plugins/siem/public/lib/connectors/servicenow.tsx @@ -87,6 +87,10 @@ export function getActionType(): ActionTypeModel { const ServiceNowConnectorFields: React.FunctionComponent> = ({ action, editActionConfig, editActionSecrets, errors }) => { + /* We do not provide defaults values to the fields (like empty string for apiUrl) intentionally. + * If we do, errors will be shown the first time the flyout is open even though the user did not + * interact with the form. Also, we would like to show errors for empty fields provided by the user. + /*/ const { apiUrl, casesConfiguration: { mapping = [] } = {} } = action.config; const { username, password } = action.secrets; @@ -153,7 +157,7 @@ const ServiceNowConnectorFields: React.FunctionComponent useUiSetting(DEFAULT_DATE_FORMAT); @@ -17,3 +22,62 @@ export const useTimeZone = (): string => { }; export const useBasePath = (): string => useKibana().services.http.basePath.get(); + +interface UserRealm { + name: string; + type: string; +} + +export interface AuthenticatedElasticUser { + username: string; + email: string; + fullName: string; + roles: string[]; + enabled: boolean; + metadata?: { + _reserved: boolean; + }; + authenticationRealm: UserRealm; + lookupRealm: UserRealm; + authenticationProvider: string; +} + +export const useCurrentUser = (): AuthenticatedElasticUser | null => { + const [user, setUser] = useState(null); + + const [, dispatchToaster] = useStateToaster(); + + const { security } = useKibana().services; + + const fetchUser = useCallback(() => { + let didCancel = false; + const fetchData = async () => { + try { + const response = await security.authc.getCurrentUser(); + if (!didCancel) { + setUser(convertToCamelCase(response)); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ + title: i18n.translate('xpack.siem.getCurrentUser.Error', { + defaultMessage: 'Error getting user', + }), + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + setUser(null); + } + } + }; + fetchData(); + return () => { + didCancel = true; + }; + }, [security]); + + useEffect(() => { + fetchUser(); + }, []); + return user; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index 0fe8daafcb30a..48fbb4e74c407 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -10,9 +10,11 @@ import { UseGetCasesState } from '../../../../../containers/case/use_get_cases'; export const useGetCasesMockState: UseGetCasesState = { data: { countClosedCases: 0, - countOpenCases: 0, + countOpenCases: 5, cases: [ { + closedAt: null, + closedBy: null, id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:23.627Z', createdBy: { username: 'elastic' }, @@ -27,6 +29,8 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { + closedAt: null, + closedBy: null, id: '362a5c10-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:13.328Z', createdBy: { username: 'elastic' }, @@ -41,6 +45,8 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { + closedAt: null, + closedBy: null, id: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:11.328Z', createdBy: { username: 'elastic' }, @@ -55,6 +61,8 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { + closedAt: '2020-02-13T19:44:13.328Z', + closedBy: { username: 'elastic' }, id: '31890e90-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:05.563Z', createdBy: { username: 'elastic' }, @@ -64,11 +72,13 @@ export const useGetCasesMockState: UseGetCasesState = { status: 'closed', tags: ['phishing'], title: 'Uh oh', - updatedAt: null, - updatedBy: null, + updatedAt: '2020-02-13T19:44:13.328Z', + updatedBy: { username: 'elastic' }, version: 'WzQ3LDFd', }, { + closedAt: null, + closedBy: null, id: '2f5b3210-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:01.901Z', createdBy: { username: 'elastic' }, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index 5859e6bbce263..b9e1113c486ad 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -36,7 +36,8 @@ const Spacer = styled.span` const renderStringField = (field: string, dataTestSubj: string) => field != null ? {field} : getEmptyTagValue(); export const getCasesColumns = ( - actions: Array> + actions: Array>, + filterStatus: string ): CasesColumns[] => [ { name: i18n.NAME, @@ -113,22 +114,39 @@ export const getCasesColumns = ( render: (comments: Case['commentIds']) => renderStringField(`${comments.length}`, `case-table-column-commentCount`), }, - { - field: 'createdAt', - name: i18n.OPENED_ON, - sortable: true, - render: (createdAt: Case['createdAt']) => { - if (createdAt != null) { - return ( - - ); + filterStatus === 'open' + ? { + field: 'createdAt', + name: i18n.OPENED_ON, + sortable: true, + render: (createdAt: Case['createdAt']) => { + if (createdAt != null) { + return ( + + ); + } + return getEmptyTagValue(); + }, } - return getEmptyTagValue(); - }, - }, + : { + field: 'closedAt', + name: i18n.CLOSED_ON, + sortable: true, + render: (closedAt: Case['closedAt']) => { + if (closedAt != null) { + return ( + + ); + } + return getEmptyTagValue(); + }, + }, { name: 'Actions', actions, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx index 001acc1d4d36e..13869c79c45fd 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx @@ -10,35 +10,86 @@ import moment from 'moment-timezone'; import { AllCases } from './'; import { TestProviders } from '../../../../mock'; import { useGetCasesMockState } from './__mock__'; -import * as apiHook from '../../../../containers/case/use_get_cases'; -import { act } from '@testing-library/react'; -import { wait } from '../../../../lib/helpers'; +import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; +import { useGetCases } from '../../../../containers/case/use_get_cases'; +import { useGetCasesStatus } from '../../../../containers/case/use_get_cases_status'; +import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; +jest.mock('../../../../containers/case/use_bulk_update_case'); +jest.mock('../../../../containers/case/use_delete_cases'); +jest.mock('../../../../containers/case/use_get_cases'); +jest.mock('../../../../containers/case/use_get_cases_status'); +const useDeleteCasesMock = useDeleteCases as jest.Mock; +const useGetCasesMock = useGetCases as jest.Mock; +const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; +const useUpdateCasesMock = useUpdateCases as jest.Mock; describe('AllCases', () => { + const dispatchResetIsDeleted = jest.fn(); + const dispatchResetIsUpdated = jest.fn(); const dispatchUpdateCaseProperty = jest.fn(); + const handleOnDeleteConfirm = jest.fn(); + const handleToggleModal = jest.fn(); const refetchCases = jest.fn(); const setFilters = jest.fn(); const setQueryParams = jest.fn(); const setSelectedCases = jest.fn(); + const updateBulkStatus = jest.fn(); + const fetchCasesStatus = jest.fn(); + + const defaultGetCases = { + ...useGetCasesMockState, + dispatchUpdateCaseProperty, + refetchCases, + setFilters, + setQueryParams, + setSelectedCases, + }; + const defaultDeleteCases = { + dispatchResetIsDeleted, + handleOnDeleteConfirm, + handleToggleModal, + isDeleted: false, + isDisplayConfirmDeleteModal: false, + isLoading: false, + }; + const defaultCasesStatus = { + countClosedCases: 0, + countOpenCases: 5, + fetchCasesStatus, + isError: false, + isLoading: true, + }; + const defaultUpdateCases = { + isUpdated: false, + isLoading: false, + isError: false, + dispatchResetIsUpdated, + updateBulkStatus, + }; + /* eslint-disable no-console */ + // Silence until enzyme fixed to use ReactTestUtils.act() + const originalError = console.error; + beforeAll(() => { + console.error = jest.fn(); + }); + afterAll(() => { + console.error = originalError; + }); + /* eslint-enable no-console */ beforeEach(() => { jest.resetAllMocks(); - jest.spyOn(apiHook, 'useGetCases').mockReturnValue({ - ...useGetCasesMockState, - dispatchUpdateCaseProperty, - refetchCases, - setFilters, - setQueryParams, - setSelectedCases, - }); + useUpdateCasesMock.mockImplementation(() => defaultUpdateCases); + useGetCasesMock.mockImplementation(() => defaultGetCases); + useDeleteCasesMock.mockImplementation(() => defaultDeleteCases); + useGetCasesStatusMock.mockImplementation(() => defaultCasesStatus); moment.tz.setDefault('UTC'); }); - it('should render AllCases', async () => { + it('should render AllCases', () => { const wrapper = mount( ); - await act(() => wait()); expect( wrapper .find(`a[data-test-subj="case-details-link"]`) @@ -76,13 +127,12 @@ describe('AllCases', () => { .text() ).toEqual('Showing 10 cases'); }); - it('should tableHeaderSortButton AllCases', async () => { + it('should tableHeaderSortButton AllCases', () => { const wrapper = mount( ); - await act(() => wait()); wrapper .find('[data-test-subj="tableHeaderSortButton"]') .first() @@ -94,4 +144,139 @@ describe('AllCases', () => { sortOrder: 'asc', }); }); + it('closes case when row action icon clicked', () => { + const wrapper = mount( + + + + ); + wrapper + .find('[data-test-subj="action-close"]') + .first() + .simulate('click'); + const firstCase = useGetCasesMockState.data.cases[0]; + expect(dispatchUpdateCaseProperty).toBeCalledWith({ + caseId: firstCase.id, + updateKey: 'status', + updateValue: 'closed', + refetchCasesStatus: fetchCasesStatus, + version: firstCase.version, + }); + }); + it('Bulk delete', () => { + useGetCasesMock.mockImplementation(() => ({ + ...defaultGetCases, + selectedCases: useGetCasesMockState.data.cases, + })); + useDeleteCasesMock + .mockReturnValueOnce({ + ...defaultDeleteCases, + isDisplayConfirmDeleteModal: false, + }) + .mockReturnValue({ + ...defaultDeleteCases, + isDisplayConfirmDeleteModal: true, + }); + + const wrapper = mount( + + + + ); + wrapper + .find('[data-test-subj="case-table-bulk-actions"] button') + .first() + .simulate('click'); + wrapper + .find('[data-test-subj="cases-bulk-delete-button"]') + .first() + .simulate('click'); + expect(handleToggleModal).toBeCalled(); + + wrapper + .find( + '[data-test-subj="confirm-delete-case-modal"] [data-test-subj="confirmModalConfirmButton"]' + ) + .last() + .simulate('click'); + expect(handleOnDeleteConfirm.mock.calls[0][0]).toStrictEqual( + useGetCasesMockState.data.cases.map(theCase => theCase.id) + ); + }); + it('Bulk close status update', () => { + useGetCasesMock.mockImplementation(() => ({ + ...defaultGetCases, + selectedCases: useGetCasesMockState.data.cases, + })); + + const wrapper = mount( + + + + ); + wrapper + .find('[data-test-subj="case-table-bulk-actions"] button') + .first() + .simulate('click'); + wrapper + .find('[data-test-subj="cases-bulk-close-button"]') + .first() + .simulate('click'); + expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'closed'); + }); + it('Bulk open status update', () => { + useGetCasesMock.mockImplementation(() => ({ + ...defaultGetCases, + selectedCases: useGetCasesMockState.data.cases, + filterOptions: { + ...defaultGetCases.filterOptions, + status: 'closed', + }, + })); + + const wrapper = mount( + + + + ); + wrapper + .find('[data-test-subj="case-table-bulk-actions"] button') + .first() + .simulate('click'); + wrapper + .find('[data-test-subj="cases-bulk-open-button"]') + .first() + .simulate('click'); + expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'open'); + }); + it('isDeleted is true, refetch', () => { + useDeleteCasesMock.mockImplementation(() => ({ + ...defaultDeleteCases, + isDeleted: true, + })); + + mount( + + + + ); + expect(refetchCases).toBeCalled(); + expect(fetchCasesStatus).toBeCalled(); + expect(dispatchResetIsDeleted).toBeCalled(); + }); + it('isUpdated is true, refetch', () => { + useUpdateCasesMock.mockImplementation(() => ({ + ...defaultUpdateCases, + isUpdated: true, + })); + + mount( + + + + ); + expect(refetchCases).toBeCalled(); + expect(fetchCasesStatus).toBeCalled(); + expect(dispatchResetIsUpdated).toBeCalled(); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index 7b655999ace09..e7e1e624ccba2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -43,6 +43,7 @@ import { OpenClosedStats } from '../open_closed_stats'; import { getActions } from './actions'; import { CasesTableFilters } from './table_filters'; +import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; const CONFIGURE_CASES_URL = getConfigureCasesUrl(); const CREATE_CASE_URL = getCreateCaseUrl(); @@ -71,8 +72,8 @@ const ProgressLoader = styled(EuiProgress)` const getSortField = (field: string): SortFieldCase => { if (field === SortFieldCase.createdAt) { return SortFieldCase.createdAt; - } else if (field === SortFieldCase.updatedAt) { - return SortFieldCase.updatedAt; + } else if (field === SortFieldCase.closedAt) { + return SortFieldCase.closedAt; } return SortFieldCase.createdAt; }; @@ -106,13 +107,20 @@ export const AllCases = React.memo(() => { isDisplayConfirmDeleteModal, } = useDeleteCases(); + const { dispatchResetIsUpdated, isUpdated, updateBulkStatus } = useUpdateCases(); + useEffect(() => { if (isDeleted) { refetchCases(filterOptions, queryParams); fetchCasesStatus(); dispatchResetIsDeleted(); } - }, [isDeleted, filterOptions, queryParams]); + if (isUpdated) { + refetchCases(filterOptions, queryParams); + fetchCasesStatus(); + dispatchResetIsUpdated(); + } + }, [isDeleted, isUpdated, filterOptions, queryParams]); const [deleteThisCase, setDeleteThisCase] = useState({ title: '', @@ -135,36 +143,38 @@ export const AllCases = React.memo(() => { [deleteBulk, deleteThisCase, isDisplayConfirmDeleteModal] ); - const toggleDeleteModal = useCallback( - (deleteCase: Case) => { - handleToggleModal(); - setDeleteThisCase(deleteCase); - }, - [isDisplayConfirmDeleteModal] - ); + const toggleDeleteModal = useCallback((deleteCase: Case) => { + handleToggleModal(); + setDeleteThisCase(deleteCase); + }, []); + + const toggleBulkDeleteModal = useCallback((deleteCases: string[]) => { + handleToggleModal(); + setDeleteBulk(deleteCases); + }, []); - const toggleBulkDeleteModal = useCallback( - (deleteCases: string[]) => { - handleToggleModal(); - setDeleteBulk(deleteCases); + const handleUpdateCaseStatus = useCallback( + (status: string) => { + updateBulkStatus(selectedCases, status); }, - [isDisplayConfirmDeleteModal] + [selectedCases] ); const selectedCaseIds = useMemo( - (): string[] => - selectedCases.reduce((arr: string[], caseObj: Case) => [...arr, caseObj.id], []), + (): string[] => selectedCases.map((caseObj: Case) => caseObj.id), [selectedCases] ); const getBulkItemsPopoverContent = useCallback( (closePopover: () => void) => ( ), @@ -206,17 +216,25 @@ export const AllCases = React.memo(() => { } setQueryParams(newQueryParams); }, - [setQueryParams, queryParams] + [queryParams] ); const onFilterChangedCallback = useCallback( (newFilterOptions: Partial) => { + if (newFilterOptions.status && newFilterOptions.status === 'closed') { + setQueryParams({ ...queryParams, sortField: SortFieldCase.closedAt }); + } else if (newFilterOptions.status && newFilterOptions.status === 'open') { + setQueryParams({ ...queryParams, sortField: SortFieldCase.createdAt }); + } setFilters({ ...filterOptions, ...newFilterOptions }); }, - [filterOptions, setFilters] + [filterOptions, queryParams] ); - const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions), [actions]); + const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions, filterOptions.status), [ + actions, + filterOptions.status, + ]); const memoizedPagination = useMemo( () => ({ pageIndex: queryParams.page - 1, @@ -231,10 +249,7 @@ export const AllCases = React.memo(() => { sort: { field: queryParams.sortField, direction: queryParams.sortOrder }, }; const euiBasicTableSelectionProps = useMemo>( - () => ({ - selectable: (item: Case) => true, - onSelectionChange: setSelectedCases, - }), + () => ({ onSelectionChange: setSelectedCases }), [selectedCases] ); const isCasesLoading = useMemo( @@ -305,6 +320,7 @@ export const AllCases = React.memo(() => { {i18n.SHOWING_SELECTED_CASES(selectedCases.length)} { void; deleteCasesAction: (cases: string[]) => void; selectedCaseIds: string[]; - caseStatus: string; + updateCaseStatus: (status: string) => void; } export const getBulkItems = ({ - deleteCasesAction, - closePopover, caseStatus, + closePopover, + deleteCasesAction, selectedCaseIds, + updateCaseStatus, }: GetBulkItems) => { return [ caseStatus === 'open' ? ( { + onClick={() => { closePopover(); + updateCaseStatus('closed'); }} > {i18n.BULK_ACTION_CLOSE_SELECTED} ) : ( { closePopover(); + updateCaseStatus('open'); }} > {i18n.BULK_ACTION_OPEN_SELECTED} ), { + disabled={selectedCaseIds.length === 0} + onClick={() => { closePopover(); deleteCasesAction(selectedCaseIds); }} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts index 0bf213868bd76..97045c8ebaf8b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/bulk_actions/translations.ts @@ -16,7 +16,7 @@ export const BULK_ACTION_CLOSE_SELECTED = i18n.translate( export const BULK_ACTION_OPEN_SELECTED = i18n.translate( 'xpack.siem.case.caseTable.bulkActions.openSelectedTitle', { - defaultMessage: 'Open selected', + defaultMessage: 'Reopen selected', } ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx new file mode 100644 index 0000000000000..9dbd71ea3e34c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import styled, { css } from 'styled-components'; +import { + EuiBadge, + EuiButtonToggle, + EuiDescriptionList, + EuiDescriptionListDescription, + EuiDescriptionListTitle, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import * as i18n from '../case_view/translations'; +import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date'; +import { CaseViewActions } from '../case_view/actions'; + +const MyDescriptionList = styled(EuiDescriptionList)` + ${({ theme }) => css` + & { + padding-right: ${theme.eui.euiSizeL}; + border-right: ${theme.eui.euiBorderThin}; + } + `} +`; + +interface CaseStatusProps { + 'data-test-subj': string; + badgeColor: string; + buttonLabel: string; + caseId: string; + caseTitle: string; + icon: string; + isLoading: boolean; + isSelected: boolean; + status: string; + title: string; + toggleStatusCase: (status: string) => void; + value: string | null; +} +const CaseStatusComp: React.FC = ({ + 'data-test-subj': dataTestSubj, + badgeColor, + buttonLabel, + caseId, + caseTitle, + icon, + isLoading, + isSelected, + status, + title, + toggleStatusCase, + value, +}) => { + const onChange = useCallback(e => toggleStatusCase(e.target.checked ? 'closed' : 'open'), [ + toggleStatusCase, + ]); + return ( + + + + + + {i18n.STATUS} + + + {status} + + + + + {title} + + + + + + + + + + + + + + + + + + + ); +}; + +export const CaseStatus = React.memo(CaseStatusComp); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx index 53cc1f80b5c10..e11441eac3a9d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -10,6 +10,8 @@ import { Case } from '../../../../../containers/case/types'; export const caseProps: CaseProps = { caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', initialData: { + closedAt: null, + closedBy: null, id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'], comments: [ @@ -20,6 +22,7 @@ export const caseProps: CaseProps = { createdBy: { fullName: 'Steph Milovic', username: 'smilovic', + email: 'notmyrealemailfool@elastic.co', }, updatedAt: '2020-02-20T23:06:33.798Z', updatedBy: { @@ -29,7 +32,7 @@ export const caseProps: CaseProps = { }, ], createdAt: '2020-02-13T19:44:23.627Z', - createdBy: { fullName: null, username: 'elastic' }, + createdBy: { fullName: null, email: 'testemail@elastic.co', username: 'elastic' }, description: 'Security banana Issue', status: 'open', tags: ['defacement'], @@ -41,35 +44,22 @@ export const caseProps: CaseProps = { version: 'WzQ3LDFd', }, }; - -export const data: Case = { - id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', - commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'], - comments: [ - { - comment: 'Solve this fast!', - id: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', - createdAt: '2020-02-20T23:06:33.798Z', - createdBy: { - fullName: 'Steph Milovic', - username: 'smilovic', - }, - updatedAt: '2020-02-20T23:06:33.798Z', - updatedBy: { - username: 'elastic', - }, - version: 'WzQ3LDFd', +export const caseClosedProps: CaseProps = { + ...caseProps, + initialData: { + ...caseProps.initialData, + closedAt: '2020-02-20T23:06:33.798Z', + closedBy: { + username: 'elastic', }, - ], - createdAt: '2020-02-13T19:44:23.627Z', - createdBy: { username: 'elastic', fullName: null }, - description: 'Security banana Issue', - status: 'open', - tags: ['defacement'], - title: 'Another horrible breach!!', - updatedAt: '2020-02-19T15:02:57.995Z', - updatedBy: { - username: 'elastic', + status: 'closed', }, - version: 'WzQ3LDFd', +}; + +export const data: Case = { + ...caseProps.initialData, +}; + +export const dataClosed: Case = { + ...caseClosedProps.initialData, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx new file mode 100644 index 0000000000000..4e1e5ba753c36 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.test.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { CaseViewActions } from './actions'; +import { TestProviders } from '../../../../mock'; +import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; +jest.mock('../../../../containers/case/use_delete_cases'); +const useDeleteCasesMock = useDeleteCases as jest.Mock; + +describe('CaseView actions', () => { + const caseTitle = 'Cool title'; + const caseId = 'cool-id'; + const handleOnDeleteConfirm = jest.fn(); + const handleToggleModal = jest.fn(); + const dispatchResetIsDeleted = jest.fn(); + const defaultDeleteState = { + dispatchResetIsDeleted, + handleToggleModal, + handleOnDeleteConfirm, + isLoading: false, + isError: false, + isDeleted: false, + isDisplayConfirmDeleteModal: false, + }; + beforeEach(() => { + jest.resetAllMocks(); + useDeleteCasesMock.mockImplementation(() => defaultDeleteState); + }); + it('clicking trash toggles modal', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeFalsy(); + + wrapper + .find('button[data-test-subj="property-actions-ellipses"]') + .first() + .simulate('click'); + wrapper.find('button[data-test-subj="property-actions-trash"]').simulate('click'); + expect(handleToggleModal).toHaveBeenCalled(); + }); + it('toggle delete modal and confirm', () => { + useDeleteCasesMock.mockImplementation(() => ({ + ...defaultDeleteState, + isDisplayConfirmDeleteModal: true, + })); + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="confirm-delete-case-modal"]').exists()).toBeTruthy(); + wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click'); + expect(handleOnDeleteConfirm.mock.calls[0][0]).toEqual([caseId]); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx new file mode 100644 index 0000000000000..88a717ac5fa6a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/actions.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; + +import { Redirect } from 'react-router-dom'; +import * as i18n from './translations'; +import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; +import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; +import { SiemPageName } from '../../../home/types'; +import { PropertyActions } from '../property_actions'; + +interface CaseViewActions { + caseId: string; + caseTitle: string; +} + +const CaseViewActionsComponent: React.FC = ({ caseId, caseTitle }) => { + // Delete case + const { + handleToggleModal, + handleOnDeleteConfirm, + isDeleted, + isDisplayConfirmDeleteModal, + } = useDeleteCases(); + + const confirmDeleteModal = useMemo( + () => ( + + ), + [isDisplayConfirmDeleteModal] + ); + // TO DO refactor each of these const's into their own components + const propertyActions = useMemo( + () => [ + { + iconType: 'trash', + label: i18n.DELETE_CASE, + onClick: handleToggleModal, + }, + { + iconType: 'popout', + label: 'View ServiceNow incident', + onClick: () => null, + }, + { + iconType: 'importAction', + label: 'Update ServiceNow incident', + onClick: () => null, + }, + ], + [handleToggleModal] + ); + + if (isDeleted) { + return ; + } + return ( + <> + + {confirmDeleteModal} + + ); +}; + +export const CaseViewActions = React.memo(CaseViewActionsComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index 8754c0404d40b..41100ec6d50f1 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -5,30 +5,68 @@ */ import React from 'react'; +import { Router } from 'react-router-dom'; import { mount } from 'enzyme'; import { CaseComponent } from './'; -import * as apiHook from '../../../../containers/case/use_update_case'; -import { caseProps, data } from './__mock__'; +import { caseProps, caseClosedProps, data, dataClosed } from './__mock__'; import { TestProviders } from '../../../../mock'; +import { useUpdateCase } from '../../../../containers/case/use_update_case'; +jest.mock('../../../../containers/case/use_update_case'); +const useUpdateCaseMock = useUpdateCase as jest.Mock; +type Action = 'PUSH' | 'POP' | 'REPLACE'; +const pop: Action = 'POP'; +const location = { + pathname: '/network', + search: '', + state: '', + hash: '', +}; +const mockHistory = { + length: 2, + location, + action: pop, + push: jest.fn(), + replace: jest.fn(), + go: jest.fn(), + goBack: jest.fn(), + goForward: jest.fn(), + block: jest.fn(), + createHref: jest.fn(), + listen: jest.fn(), +}; describe('CaseView ', () => { const updateCaseProperty = jest.fn(); + /* eslint-disable no-console */ + // Silence until enzyme fixed to use ReactTestUtils.act() + const originalError = console.error; + beforeAll(() => { + console.error = jest.fn(); + }); + afterAll(() => { + console.error = originalError; + }); + /* eslint-enable no-console */ + + const defaultUpdateCaseState = { + caseData: data, + isLoading: false, + isError: false, + updateKey: null, + updateCaseProperty, + }; beforeEach(() => { jest.resetAllMocks(); - jest.spyOn(apiHook, 'useUpdateCase').mockReturnValue({ - caseData: data, - isLoading: false, - isError: false, - updateKey: null, - updateCaseProperty, - }); + useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState); }); it('should render CaseComponent', () => { const wrapper = mount( - + + + ); expect( @@ -55,6 +93,7 @@ describe('CaseView ', () => { .first() .text() ).toEqual(data.createdBy.username); + expect(wrapper.contains(`[data-test-subj="case-view-closedAt"]`)).toBe(false); expect( wrapper .find(`[data-test-subj="case-view-createdAt"]`) @@ -69,16 +108,45 @@ describe('CaseView ', () => { ).toEqual(data.description); }); + it('should show closed indicators in header when case is closed', () => { + useUpdateCaseMock.mockImplementation(() => ({ + ...defaultUpdateCaseState, + caseData: dataClosed, + })); + const wrapper = mount( + + + + + + ); + expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false); + expect( + wrapper + .find(`[data-test-subj="case-view-closedAt"]`) + .first() + .prop('value') + ).toEqual(dataClosed.closedAt); + expect( + wrapper + .find(`[data-test-subj="case-view-status"]`) + .first() + .text() + ).toEqual(dataClosed.status); + }); + it('should dispatch update state when button is toggled', () => { const wrapper = mount( - + + + ); wrapper .find('input[data-test-subj="toggle-case-status"]') - .simulate('change', { target: { value: false } }); + .simulate('change', { target: { checked: true } }); expect(updateCaseProperty).toBeCalledWith({ updateKey: 'status', @@ -89,7 +157,9 @@ describe('CaseView ', () => { it('should render comments', () => { const wrapper = mount( - + + + ); expect( diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 5ff542d208905..08af603cb0dbf 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -5,26 +5,14 @@ */ import React, { useCallback, useMemo } from 'react'; -import { - EuiBadge, - EuiButtonToggle, - EuiDescriptionList, - EuiDescriptionListDescription, - EuiDescriptionListTitle, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; -import styled, { css } from 'styled-components'; -import { Redirect } from 'react-router-dom'; +import styled from 'styled-components'; import * as i18n from './translations'; import { Case } from '../../../../containers/case/types'; -import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date'; import { getCaseUrl } from '../../../../components/link_to'; import { HeaderPage } from '../../../../components/header_page'; import { EditableTitle } from '../../../../components/header_page/editable_title'; -import { PropertyActions } from '../property_actions'; import { TagList } from '../tag_list'; import { useGetCase } from '../../../../containers/case/use_get_case'; import { UserActionTree } from '../user_action_tree'; @@ -33,23 +21,14 @@ import { useUpdateCase } from '../../../../containers/case/use_update_case'; import { WrapperPage } from '../../../../components/wrapper_page'; import { getTypedPayload } from '../../../../containers/case/utils'; import { WhitePageWrapper } from '../wrappers'; -import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; -import { SiemPageName } from '../../../home/types'; -import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; +import { useBasePath } from '../../../../lib/kibana'; +import { CaseStatus } from '../case_status'; +import { SpyRoute } from '../../../../utils/route/spy_routes'; interface Props { caseId: string; } -const MyDescriptionList = styled(EuiDescriptionList)` - ${({ theme }) => css` - & { - padding-right: ${theme.eui.euiSizeL}; - border-right: ${theme.eui.euiBorderThin}; - } - `} -`; - const MyWrapper = styled(WrapperPage)` padding-bottom: 0; `; @@ -64,6 +43,8 @@ export interface CaseProps { } export const CaseComponent = React.memo(({ caseId, initialData }) => { + const basePath = window.location.origin + useBasePath(); + const caseLink = `${basePath}/app/siem#/case/${caseId}`; const { caseData, isLoading, updateKey, updateCaseProperty } = useUpdateCase(caseId, initialData); // Update Fields @@ -107,58 +88,46 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => return null; } }, - [updateCaseProperty, caseData.status] - ); - const toggleStatusCase = useCallback( - e => onUpdateField('status', e.target.checked ? 'open' : 'closed'), - [onUpdateField] + [caseData.status] ); - const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]); - - // Delete case - const { - handleToggleModal, - handleOnDeleteConfirm, - isDeleted, - isDisplayConfirmDeleteModal, - } = useDeleteCases(); - - const confirmDeleteModal = useMemo( - () => ( - - ), - [isDisplayConfirmDeleteModal] + const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); + const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]); + + const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]); + + const caseStatusData = useMemo( + () => + caseData.status === 'open' + ? { + 'data-test-subj': 'case-view-createdAt', + value: caseData.createdAt, + title: i18n.CASE_OPENED, + buttonLabel: i18n.CLOSE_CASE, + status: caseData.status, + icon: 'checkInCircleFilled', + badgeColor: 'secondary', + isSelected: false, + } + : { + 'data-test-subj': 'case-view-closedAt', + value: caseData.closedAt, + title: i18n.CASE_CLOSED, + buttonLabel: i18n.REOPEN_CASE, + status: caseData.status, + icon: 'magnet', + badgeColor: 'danger', + isSelected: true, + }, + [caseData.closedAt, caseData.createdAt, caseData.status] + ); + const emailContent = useMemo( + () => ({ + subject: i18n.EMAIL_SUBJECT(caseData.title), + body: i18n.EMAIL_BODY(caseLink), + }), + [caseData.title] ); - // TO DO refactor each of these const's into their own components - const propertyActions = [ - { - iconType: 'trash', - label: 'Delete case', - onClick: handleToggleModal, - }, - { - iconType: 'popout', - label: 'View ServiceNow incident', - onClick: () => null, - }, - { - iconType: 'importAction', - label: 'Update ServiceNow incident', - onClick: () => null, - }, - ]; - - if (isDeleted) { - return ; - } - return ( <> @@ -177,51 +146,13 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => } title={caseData.title} > - - - - - - {i18n.STATUS} - - - {caseData.status} - - - - - {i18n.CASE_OPENED} - - - - - - - - - - - - - - - - - - + @@ -237,6 +168,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => @@ -250,7 +182,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => - {confirmDeleteModal} + ); }); @@ -273,4 +205,5 @@ export const CaseView = React.memo(({ caseId }: Props) => { return ; }); +CaseComponent.displayName = 'CaseComponent'; CaseView.displayName = 'CaseView'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts index 82b5e771e2151..e5fa3bff51f85 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -55,3 +55,19 @@ export const STATUS = i18n.translate('xpack.siem.case.caseView.statusLabel', { export const CASE_OPENED = i18n.translate('xpack.siem.case.caseView.caseOpened', { defaultMessage: 'Case opened', }); + +export const CASE_CLOSED = i18n.translate('xpack.siem.case.caseView.caseClosed', { + defaultMessage: 'Case closed', +}); + +export const EMAIL_SUBJECT = (caseTitle: string) => + i18n.translate('xpack.siem.case.caseView.emailSubject', { + values: { caseTitle }, + defaultMessage: 'SIEM Case - {caseTitle}', + }); + +export const EMAIL_BODY = (caseUrl: string) => + i18n.translate('xpack.siem.case.caseView.emailBody', { + values: { caseUrl }, + defaultMessage: 'Case reference: {caseUrl}', + }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors.tsx index 55b256b66b72b..bb0c50b3b193a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useCallback } from 'react'; +import React from 'react'; import { EuiDescribedFormGroup, EuiFormRow, @@ -18,12 +18,7 @@ import styled from 'styled-components'; import { ConnectorsDropdown } from './connectors_dropdown'; import * as i18n from './translations'; -import { - ActionsConnectorsContextProvider, - ConnectorAddFlyout, -} from '../../../../../../../../plugins/triggers_actions_ui/public'; import { Connector } from '../../../../containers/case/configure/types'; -import { useKibana } from '../../../../lib/kibana'; const EuiFormRowExtended = styled(EuiFormRow)` .euiFormRow__labelWrapper { @@ -38,41 +33,26 @@ interface Props { disabled: boolean; isLoading: boolean; onChangeConnector: (id: string) => void; - refetchConnectors: () => void; selectedConnector: string; + handleShowAddFlyout: () => void; } -const actionTypes = [ - { - id: '.servicenow', - name: 'ServiceNow', - enabled: true, - }, -]; - const ConnectorsComponent: React.FC = ({ connectors, disabled, isLoading, onChangeConnector, - refetchConnectors, selectedConnector, + handleShowAddFlyout, }) => { - const { http, triggers_actions_ui, notifications, application } = useKibana().services; - const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); - - const handleShowFlyout = useCallback(() => setAddFlyoutVisibility(true), []); - const dropDownLabel = ( {i18n.INCIDENT_MANAGEMENT_SYSTEM_LABEL} - {i18n.ADD_NEW_CONNECTOR} + {i18n.ADD_NEW_CONNECTOR} ); - const reloadConnectors = useCallback(async () => refetchConnectors(), []); - return ( <> = ({ /> - - - ); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx index da715fb66953f..cbc3be6d144a2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx @@ -7,10 +7,27 @@ import React, { useReducer, useCallback, useEffect, useState } from 'react'; import styled, { css } from 'styled-components'; -import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiSpacer, EuiCallOut } from '@elastic/eui'; -import { noop, isEmpty } from 'lodash/fp'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiCallOut, + EuiBottomBar, + EuiButtonEmpty, +} from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; +import { useKibana } from '../../../../lib/kibana'; import { useConnectors } from '../../../../containers/case/configure/use_connectors'; import { useCaseConfigure } from '../../../../containers/case/configure/use_configure'; +import { + ActionsConnectorsContextProvider, + ConnectorAddFlyout, + ConnectorEditFlyout, +} from '../../../../../../../../plugins/triggers_actions_ui/public'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ActionConnectorTableItem } from '../../../../../../../../plugins/triggers_actions_ui/public/types'; + import { ClosureType, CasesConfigurationMapping, @@ -22,6 +39,9 @@ import { Mapping } from '../configure_cases/mapping'; import { SectionWrapper } from '../wrappers'; import { configureCasesReducer, State } from './reducer'; import * as i18n from './translations'; +import { getCaseUrl } from '../../../../components/link_to'; + +const CASE_URL = getCaseUrl(); const FormWrapper = styled.div` ${({ theme }) => css` @@ -40,8 +60,27 @@ const initialState: State = { mapping: null, }; +const actionTypes = [ + { + id: '.servicenow', + name: 'ServiceNow', + enabled: true, + }, +]; + const ConfigureCasesComponent: React.FC = () => { + const { http, triggers_actions_ui, notifications, application } = useKibana().services; + const [connectorIsValid, setConnectorIsValid] = useState(true); + const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); + const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); + const [editedConnectorItem, setEditedConnectorItem] = useState( + null + ); + + const [actionBarVisible, setActionBarVisible] = useState(false); + + const handleShowAddFlyout = useCallback(() => setAddFlyoutVisibility(true), []); const [{ connectorId, closureType, mapping }, dispatch] = useReducer( configureCasesReducer(), @@ -73,24 +112,33 @@ const ConfigureCasesComponent: React.FC = () => { setConnectorId, setClosureType, }); - const { - loading: isLoadingConnectors, - connectors, - refetchConnectors, - updateConnector, - } = useConnectors(); + const { loading: isLoadingConnectors, connectors, refetchConnectors } = useConnectors(); + // ActionsConnectorsContextProvider reloadConnectors prop expects a Promise. + // TODO: Fix it if reloadConnectors type change. + const reloadConnectors = useCallback(async () => refetchConnectors(), []); const isLoadingAny = isLoadingConnectors || persistLoading || loadingCaseConfigure; + const updateConnectorDisabled = isLoadingAny || !connectorIsValid || connectorId === 'none'; const handleSubmit = useCallback( // TO DO give a warning/error to user when field are not mapped so they have chance to do it () => { + setActionBarVisible(false); persistCaseConfigure({ connectorId, closureType }); - updateConnector(connectorId, mapping ?? []); }, [connectorId, closureType, mapping] ); + const onChangeConnector = useCallback((newConnectorId: string) => { + setActionBarVisible(true); + setConnectorId(newConnectorId); + }, []); + + const onChangeClosureType = useCallback((newClosureType: ClosureType) => { + setActionBarVisible(true); + setClosureType(newClosureType); + }, []); + useEffect(() => { if ( !isEmpty(connectors) && @@ -124,6 +172,14 @@ const ConfigureCasesComponent: React.FC = () => { } }, [connectors, connectorId]); + useEffect(() => { + if (!isLoadingConnectors && connectorId !== 'none') { + setEditedConnectorItem( + connectors.find(c => c.id === connectorId) as ActionConnectorTableItem + ); + } + }, [connectors, connectorId]); + return ( {!connectorIsValid && ( @@ -138,8 +194,8 @@ const ConfigureCasesComponent: React.FC = () => { connectors={connectors ?? []} disabled={persistLoading || isLoadingConnectors} isLoading={isLoadingConnectors} - onChangeConnector={setConnectorId} - refetchConnectors={refetchConnectors} + onChangeConnector={onChangeConnector} + handleShowAddFlyout={handleShowAddFlyout} selectedConnector={connectorId} /> @@ -147,53 +203,76 @@ const ConfigureCasesComponent: React.FC = () => { - - - - - - {i18n.CANCEL} - - - - - {i18n.SAVE_CHANGES} - - - - + {actionBarVisible && ( + + + + + + + {i18n.CANCEL} + + + + + {i18n.SAVE_CHANGES} + + + + + + + )} + + + {editedConnectorItem && ( + + )} + ); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/mapping.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/mapping.tsx index 10c8f6b938023..2600a9f4e13ac 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/mapping.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/mapping.tsx @@ -4,8 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { EuiDescribedFormGroup } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import styled from 'styled-components'; + +import { + EuiDescribedFormGroup, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiButtonEmpty, +} from '@elastic/eui'; import * as i18n from './translations'; @@ -14,18 +22,44 @@ import { CasesConfigurationMapping } from '../../../../containers/case/configure interface MappingProps { disabled: boolean; + updateConnectorDisabled: boolean; mapping: CasesConfigurationMapping[] | null; onChangeMapping: (newMapping: CasesConfigurationMapping[]) => void; + setEditFlyoutVisibility: React.Dispatch>; } -const MappingComponent: React.FC = ({ disabled, mapping, onChangeMapping }) => ( - {i18n.FIELD_MAPPING_TITLE}} - description={i18n.FIELD_MAPPING_DESC} - > - - -); +const EuiButtonEmptyExtended = styled(EuiButtonEmpty)` + font-size: 12px; + height: 24px; +`; + +const MappingComponent: React.FC = ({ + disabled, + updateConnectorDisabled, + mapping, + onChangeMapping, + setEditFlyoutVisibility, +}) => { + const onClick = useCallback(() => setEditFlyoutVisibility(true), []); + + return ( + {i18n.FIELD_MAPPING_TITLE}} + description={i18n.FIELD_MAPPING_DESC} + > + + + + + {i18n.UPDATE_CONNECTOR} + + + + + + + ); +}; export const Mapping = React.memo(MappingComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/translations.ts index d24921a636082..dd9bf82fb0b0d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/translations.ts @@ -186,3 +186,7 @@ export const FIELD_MAPPING_FIELD_COMMENTS = i18n.translate( defaultMessage: 'Comments', } ); + +export const UPDATE_CONNECTOR = i18n.translate('xpack.siem.case.configureCases.updateConnector', { + defaultMessage: 'Update connector', +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/confirm_delete_case/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/confirm_delete_case/index.tsx index dff36a6dac571..5755258b36388 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/confirm_delete_case/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/confirm_delete_case/index.tsx @@ -32,6 +32,7 @@ const ConfirmDeleteCaseModalComp: React.FC = ({ buttonColor="danger" cancelButtonText={i18n.CANCEL} confirmButtonText={isPlural ? i18n.DELETE_CASES : i18n.DELETE_CASE} + data-test-subj="confirm-delete-case-modal" defaultFocusedButton="confirm" onCancel={onCancel} onConfirm={onConfirm} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx index 3b9af8349437e..20712c3c5a815 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx @@ -72,6 +72,10 @@ export const Create = React.memo(() => { } }, [form]); + const handleSetIsCancel = useCallback(() => { + setIsCancel(true); + }, [isCancel]); + if (caseData != null && caseData.id) { return ; } @@ -137,7 +141,12 @@ export const Create = React.memo(() => { responsive={false} > - setIsCancel(true)} iconType="cross"> + {i18n.CANCEL} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/property_actions/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/property_actions/index.tsx index 7fe5b6f5f8794..01ccf3c510b60 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/property_actions/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/property_actions/index.tsx @@ -13,9 +13,12 @@ export interface PropertyActionButtonProps { label: string; } +const ComponentId = 'property-actions'; + const PropertyActionButton = React.memo( ({ onClick, iconType, label }) => ( (({ propertyActio }, []); return ( - + (({ propertyActio isOpen={showActions} closePopover={onClosePopover} > - + {propertyActions.map((action, key) => ( { const { comments, isLoadingIds, updateComment, addPostedComment } = useUpdateComment( caseData.comments ); - + const currentUser = useCurrentUser(); const [manageMarkdownEditIds, setManangeMardownEditIds] = useState([]); const handleManageMarkdownEditId = useCallback( @@ -112,10 +113,10 @@ export const UserActionTree = React.memo( id={NewId} isEditable={true} isLoading={isLoadingIds.includes(NewId)} - fullName="to be determined" + fullName={currentUser != null ? currentUser.fullName : ''} markdown={MarkdownNewComment} onEdit={handleManageMarkdownEditId.bind(null, NewId)} - userName="to be determined" + userName={currentUser != null ? currentUser.username : ''} /> ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx index 0a33301010535..7b99f2ef76ab3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; import React from 'react'; import styled, { css } from 'styled-components'; @@ -48,6 +48,12 @@ const UserActionItemContainer = styled(EuiFlexGroup)` margin-right: ${theme.eui.euiSize}; vertical-align: top; } + .userAction_loadingAvatar { + position: relative; + margin-right: ${theme.eui.euiSizeXL}; + top: ${theme.eui.euiSizeM}; + left: ${theme.eui.euiSizeS}; + } .userAction__title { padding: ${theme.eui.euiSizeS} ${theme.eui.euiSizeL}; background: ${theme.eui.euiColorLightestShade}; @@ -74,7 +80,11 @@ export const UserActionItem = ({ }: UserActionItemProps) => ( - + {fullName.length > 0 || userName.length > 0 ? ( + + ) : ( + + )} {isEditable && markdown} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx new file mode 100644 index 0000000000000..51acb3b810d92 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { UserList } from './'; +import * as i18n from '../case_view/translations'; + +describe('UserList ', () => { + const title = 'Case Title'; + const caseLink = 'http://reddit.com'; + const user = { username: 'username', fullName: 'Full Name', email: 'testemail@elastic.co' }; + const open = jest.fn(); + beforeAll(() => { + window.open = open; + }); + beforeEach(() => { + jest.resetAllMocks(); + }); + it('triggers mailto when email icon clicked', () => { + const wrapper = shallow( + + ); + wrapper.find('[data-test-subj="user-list-email-button"]').simulate('click'); + expect(open).toBeCalledWith( + `mailto:${user.email}?subject=${i18n.EMAIL_SUBJECT(title)}&body=${i18n.EMAIL_BODY(caseLink)}`, + '_blank' + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx index abb49122dc142..74a1b98c29eef 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { EuiButtonIcon, EuiText, @@ -17,6 +17,10 @@ import styled, { css } from 'styled-components'; import { ElasticUser } from '../../../../containers/case/types'; interface UserListProps { + email: { + subject: string; + body: string; + }; headline: string; users: ElasticUser[]; } @@ -31,8 +35,11 @@ const MyFlexGroup = styled(EuiFlexGroup)` `} `; -const renderUsers = (users: ElasticUser[]) => { - return users.map(({ fullName, username }, key) => ( +const renderUsers = ( + users: ElasticUser[], + handleSendEmail: (emailAddress: string | undefined | null) => void +) => { + return users.map(({ fullName, username, email }, key) => ( @@ -50,7 +57,8 @@ const renderUsers = (users: ElasticUser[]) => { {}} // TO DO + data-test-subj="user-list-email-button" + onClick={handleSendEmail.bind(null, email)} // TO DO iconType="email" aria-label="email" /> @@ -59,12 +67,20 @@ const renderUsers = (users: ElasticUser[]) => { )); }; -export const UserList = React.memo(({ headline, users }: UserListProps) => { +export const UserList = React.memo(({ email, headline, users }: UserListProps) => { + const handleSendEmail = useCallback( + (emailAddress: string | undefined | null) => { + if (emailAddress && emailAddress != null) { + window.open(`mailto:${emailAddress}?subject=${email.subject}&body=${email.body}`, '_blank'); + } + }, + [email.subject] + ); return (

{headline}

- {renderUsers(users)} + {renderUsers(users, handleSendEmail)}
); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts index 6ef412d408ae5..341a34240fe49 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -30,6 +30,16 @@ export const OPENED_ON = i18n.translate('xpack.siem.case.caseView.openedOn', { defaultMessage: 'Opened on', }); +export const CLOSED_ON = i18n.translate('xpack.siem.case.caseView.closedOn', { + defaultMessage: 'Closed on', +}); +export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', { + defaultMessage: 'Reopen case', +}); +export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', { + defaultMessage: 'Close case', +}); + export const REPORTER = i18n.translate('xpack.siem.case.caseView.createdBy', { defaultMessage: 'Reporter', }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts index bd6cb5da5eb01..ccb3b71a476ec 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts @@ -28,7 +28,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { breadcrumb = [ ...breadcrumb, { - text: params.detailName, + text: params.state?.caseTitle ?? '', href: getCaseDetailsUrl(params.detailName), }, ]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/helpers.test.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/helpers.test.ts new file mode 100644 index 0000000000000..7e6778ca4fb4f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/helpers.test.ts @@ -0,0 +1,273 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + getStringArray, + replaceTemplateFieldFromQuery, + replaceTemplateFieldFromMatchFilters, + reformatDataProviderWithNewValue, +} from './helpers'; +import { mockEcsData } from '../../../../mock/mock_ecs'; +import { Filter } from '../../../../../../../../../src/plugins/data/public'; +import { DataProvider } from '../../../../components/timeline/data_providers/data_provider'; +import { mockDataProviders } from '../../../../components/timeline/data_providers/mock/mock_data_providers'; +import { cloneDeep } from 'lodash/fp'; + +describe('helpers', () => { + let mockEcsDataClone = cloneDeep(mockEcsData); + beforeEach(() => { + mockEcsDataClone = cloneDeep(mockEcsData); + }); + describe('getStringOrStringArray', () => { + test('it should correctly return a string array', () => { + const value = getStringArray('x', { + x: 'The nickname of the developer we all :heart:', + }); + expect(value).toEqual(['The nickname of the developer we all :heart:']); + }); + + test('it should correctly return a string array with a single element', () => { + const value = getStringArray('x', { + x: ['The nickname of the developer we all :heart:'], + }); + expect(value).toEqual(['The nickname of the developer we all :heart:']); + }); + + test('it should correctly return a string array with two elements of strings', () => { + const value = getStringArray('x', { + x: ['The nickname of the developer we all :heart:', 'We are all made of stars'], + }); + expect(value).toEqual([ + 'The nickname of the developer we all :heart:', + 'We are all made of stars', + ]); + }); + + test('it should correctly return a string array with deep elements', () => { + const value = getStringArray('x.y.z', { + x: { y: { z: 'zed' } }, + }); + expect(value).toEqual(['zed']); + }); + + test('it should correctly return a string array with a non-existent value', () => { + const value = getStringArray('non.existent', { + x: { y: { z: 'zed' } }, + }); + expect(value).toEqual([]); + }); + + test('it should trace an error if the value is not a string', () => { + const mockConsole: Console = ({ trace: jest.fn() } as unknown) as Console; + const value = getStringArray('a', { a: 5 }, mockConsole); + expect(value).toEqual([]); + expect( + mockConsole.trace + ).toHaveBeenCalledWith( + 'Data type that is not a string or string array detected:', + 5, + 'when trying to access field:', + 'a', + 'from data object of:', + { a: 5 } + ); + }); + + test('it should trace an error if the value is an array of mixed values', () => { + const mockConsole: Console = ({ trace: jest.fn() } as unknown) as Console; + const value = getStringArray('a', { a: ['hi', 5] }, mockConsole); + expect(value).toEqual([]); + expect( + mockConsole.trace + ).toHaveBeenCalledWith( + 'Data type that is not a string or string array detected:', + ['hi', 5], + 'when trying to access field:', + 'a', + 'from data object of:', + { a: ['hi', 5] } + ); + }); + }); + + describe('replaceTemplateFieldFromQuery', () => { + test('given an empty query string this returns an empty query string', () => { + const replacement = replaceTemplateFieldFromQuery('', mockEcsDataClone[0]); + expect(replacement).toEqual(''); + }); + + test('given a query string with spaces this returns an empty query string', () => { + const replacement = replaceTemplateFieldFromQuery(' ', mockEcsDataClone[0]); + expect(replacement).toEqual(''); + }); + + test('it should replace a query with a template value such as apache from a mock template', () => { + const replacement = replaceTemplateFieldFromQuery( + 'host.name: placeholdertext', + mockEcsDataClone[0] + ); + expect(replacement).toEqual('host.name: apache'); + }); + + test('it should replace a template field with an ECS value that is not an array', () => { + mockEcsDataClone[0].host!.name = ('apache' as unknown) as string[]; // very unsafe cast for this test case + const replacement = replaceTemplateFieldFromQuery('host.name: *', mockEcsDataClone[0]); + expect(replacement).toEqual('host.name: *'); + }); + + test('it should NOT replace a query with a template value that is not part of the template fields array', () => { + const replacement = replaceTemplateFieldFromQuery( + 'user.id: placeholdertext', + mockEcsDataClone[0] + ); + expect(replacement).toEqual('user.id: placeholdertext'); + }); + }); + + describe('replaceTemplateFieldFromMatchFilters', () => { + test('given an empty query filter this will return an empty filter', () => { + const replacement = replaceTemplateFieldFromMatchFilters([], mockEcsDataClone[0]); + expect(replacement).toEqual([]); + }); + + test('given a query filter this will return that filter with the placeholder replaced', () => { + const filters: Filter[] = [ + { + meta: { + type: 'phrase', + key: 'host.name', + alias: 'alias', + disabled: false, + negate: false, + params: { query: 'Braden' }, + }, + query: { match_phrase: { 'host.name': 'Braden' } }, + }, + ]; + const replacement = replaceTemplateFieldFromMatchFilters(filters, mockEcsDataClone[0]); + const expected: Filter[] = [ + { + meta: { + type: 'phrase', + key: 'host.name', + alias: 'alias', + disabled: false, + negate: false, + params: { query: 'apache' }, + }, + query: { match_phrase: { 'host.name': 'apache' } }, + }, + ]; + expect(replacement).toEqual(expected); + }); + + test('given a query filter with a value not in the templateFields, this will NOT replace the placeholder value', () => { + const filters: Filter[] = [ + { + meta: { + type: 'phrase', + key: 'user.id', + alias: 'alias', + disabled: false, + negate: false, + params: { query: 'Evan' }, + }, + query: { match_phrase: { 'user.id': 'Evan' } }, + }, + ]; + const replacement = replaceTemplateFieldFromMatchFilters(filters, mockEcsDataClone[0]); + const expected: Filter[] = [ + { + meta: { + type: 'phrase', + key: 'user.id', + alias: 'alias', + disabled: false, + negate: false, + params: { query: 'Evan' }, + }, + query: { match_phrase: { 'user.id': 'Evan' } }, + }, + ]; + expect(replacement).toEqual(expected); + }); + }); + + describe('reformatDataProviderWithNewValue', () => { + test('it should replace a query with a template value such as apache from a mock data provider', () => { + const mockDataProvider: DataProvider = mockDataProviders[0]; + mockDataProvider.queryMatch.field = 'host.name'; + mockDataProvider.id = 'Braden'; + mockDataProvider.name = 'Braden'; + mockDataProvider.queryMatch.value = 'Braden'; + const replacement = reformatDataProviderWithNewValue(mockDataProvider, mockEcsDataClone[0]); + expect(replacement).toEqual({ + id: 'apache', + name: 'apache', + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: 'host.name', + value: 'apache', + operator: ':', + displayField: undefined, + displayValue: undefined, + }, + and: [], + }); + }); + + test('it should replace a query with a template value such as apache from a mock data provider using a string in the data provider', () => { + mockEcsDataClone[0].host!.name = ('apache' as unknown) as string[]; // very unsafe cast for this test case + const mockDataProvider: DataProvider = mockDataProviders[0]; + mockDataProvider.queryMatch.field = 'host.name'; + mockDataProvider.id = 'Braden'; + mockDataProvider.name = 'Braden'; + mockDataProvider.queryMatch.value = 'Braden'; + const replacement = reformatDataProviderWithNewValue(mockDataProvider, mockEcsDataClone[0]); + expect(replacement).toEqual({ + id: 'apache', + name: 'apache', + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: 'host.name', + value: 'apache', + operator: ':', + displayField: undefined, + displayValue: undefined, + }, + and: [], + }); + }); + + test('it should NOT replace a query with a template value that is not part of a template such as user.id', () => { + const mockDataProvider: DataProvider = mockDataProviders[0]; + mockDataProvider.queryMatch.field = 'user.id'; + mockDataProvider.id = 'my-id'; + mockDataProvider.name = 'Rebecca'; + mockDataProvider.queryMatch.value = 'Rebecca'; + const replacement = reformatDataProviderWithNewValue(mockDataProvider, mockEcsDataClone[0]); + expect(replacement).toEqual({ + id: 'my-id', + name: 'Rebecca', + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: 'user.id', + value: 'Rebecca', + operator: ':', + displayField: undefined, + displayValue: undefined, + }, + and: [], + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/helpers.ts index 715d98ed33694..e8c9c2e3cf6c9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/helpers.ts @@ -17,6 +17,11 @@ interface FindValueToChangeInQuery { valueToChange: string; } +/** + * Fields that will be replaced with the template strings from a a saved timeline template. + * This is used for the signals detection engine feature when you save a timeline template + * and are the fields you can replace when creating a template. + */ const templateFields = [ 'host.name', 'host.hostname', @@ -32,6 +37,36 @@ const templateFields = [ 'process.name', ]; +/** + * This will return an unknown as a string array if it exists from an unknown data type and a string + * that represents the path within the data object the same as lodash's "get". If the value is non-existent + * we will return an empty array. If it is a non string value then this will log a trace to the console + * that it encountered an error and return an empty array. + * @param field string of the field to access + * @param data The unknown data that is typically a ECS value to get the value + * @param localConsole The local console which can be sent in to make this pure (for tests) or use the default console + */ +export const getStringArray = (field: string, data: unknown, localConsole = console): string[] => { + const value: unknown | undefined = get(field, data); + if (value == null) { + return []; + } else if (typeof value === 'string') { + return [value]; + } else if (Array.isArray(value) && value.every(element => typeof element === 'string')) { + return value; + } else { + localConsole.trace( + 'Data type that is not a string or string array detected:', + value, + 'when trying to access field:', + field, + 'from data object of:', + data + ); + return []; + } +}; + export const findValueToChangeInQuery = ( keuryNode: KueryNode, valueToChange: FindValueToChangeInQuery[] = [] @@ -66,31 +101,33 @@ export const findValueToChangeInQuery = ( ); }; -export const replaceTemplateFieldFromQuery = (query: string, ecsData: Ecs) => { +export const replaceTemplateFieldFromQuery = (query: string, ecsData: Ecs): string => { if (query.trim() !== '') { const valueToChange = findValueToChangeInQuery(esKuery.fromKueryExpression(query)); return valueToChange.reduce((newQuery, vtc) => { - const newValue = get(vtc.field, ecsData); - if (newValue != null) { - return newQuery.replace(vtc.valueToChange, newValue); + const newValue = getStringArray(vtc.field, ecsData); + if (newValue.length) { + return newQuery.replace(vtc.valueToChange, newValue[0]); + } else { + return newQuery; } - return newQuery; }, query); + } else { + return ''; } - return ''; }; -export const replaceTemplateFieldFromMatchFilters = (filters: Filter[], ecsData: Ecs) => +export const replaceTemplateFieldFromMatchFilters = (filters: Filter[], ecsData: Ecs): Filter[] => filters.map(filter => { if ( filter.meta.type === 'phrase' && filter.meta.key != null && templateFields.includes(filter.meta.key) ) { - const newValue = get(filter.meta.key, ecsData); - if (newValue != null) { - filter.meta.params = { query: newValue }; - filter.query = { match_phrase: { [filter.meta.key]: newValue } }; + const newValue = getStringArray(filter.meta.key, ecsData); + if (newValue.length) { + filter.meta.params = { query: newValue[0] }; + filter.query = { match_phrase: { [filter.meta.key]: newValue[0] } }; } } return filter; @@ -101,11 +138,11 @@ export const reformatDataProviderWithNewValue = { if (templateFields.includes(dataProvider.queryMatch.field)) { - const newValue = get(dataProvider.queryMatch.field, ecsData); - if (newValue != null) { - dataProvider.id = dataProvider.id.replace(dataProvider.name, newValue); - dataProvider.name = newValue; - dataProvider.queryMatch.value = newValue; + const newValue = getStringArray(dataProvider.queryMatch.field, ecsData); + if (newValue.length) { + dataProvider.id = dataProvider.id.replace(dataProvider.name, newValue[0]); + dataProvider.name = newValue[0]; + dataProvider.queryMatch.value = newValue[0]; dataProvider.queryMatch.displayField = undefined; dataProvider.queryMatch.displayValue = undefined; } @@ -116,8 +153,8 @@ export const reformatDataProviderWithNewValue = - dataProviders.map((dataProvider: DataProvider) => { +): DataProvider[] => + dataProviders.map(dataProvider => { const newDataProvider = reformatDataProviderWithNewValue(dataProvider, ecsData); if (newDataProvider.and != null && !isEmpty(newDataProvider.and)) { newDataProvider.and = newDataProvider.and.map(andDataProvider => diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts index 5627d33818500..011a2614c1af9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -181,6 +181,9 @@ export const mockAboutStepRule = (isNew = false): AboutStepRule => ({ export const mockDefineStepRule = (isNew = false): DefineStepRule => ({ isNew, + ruleType: 'query', + anomalyThreshold: 50, + machineLearningJobId: '', index: ['filebeat-'], queryBar: mockQueryBar, }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index bb718d8029817..621c70e391319 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -22,6 +22,7 @@ import { FilterOptions, Rule, PaginationOptions, + exportRules, } from '../../../../containers/detection_engine/rules'; import { HeaderSection } from '../../../../components/header_section'; import { @@ -35,7 +36,7 @@ import { useStateToaster } from '../../../../components/toasters'; import { Loader } from '../../../../components/loader'; import { Panel } from '../../../../components/panel'; import { PrePackagedRulesPrompt } from '../components/pre_packaged_rules/load_empty_prompt'; -import { RuleDownloader } from '../components/rule_downloader'; +import { GenericDownloader } from '../../../../components/generic_downloader'; import { getPrePackagedRuleStatus } from '../helpers'; import * as i18n from '../translations'; import { EuiBasicTableOnChange } from '../types'; @@ -244,10 +245,10 @@ export const AllRules = React.memo( return ( <> - { + ids={exportRuleIds} + onExportSuccess={exportCount => { dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); dispatchToaster({ type: 'addToaster', @@ -259,6 +260,7 @@ export const AllRules = React.memo( }, }); }} + exportSelectedData={exportRules} /> diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx new file mode 100644 index 0000000000000..18970ff935b8d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import { EuiFlexGrid, EuiFlexItem, EuiRange, EuiFormRow } from '@elastic/eui'; + +import { FieldHook } from '../../../../../shared_imports'; + +interface AnomalyThresholdSliderProps { + field: FieldHook; +} +type Event = React.ChangeEvent; +type EventArg = Event | React.MouseEvent; + +export const AnomalyThresholdSlider: React.FC = ({ field }) => { + const threshold = field.value as number; + const onThresholdChange = useCallback( + (event: EventArg) => { + const thresholdValue = Number((event as Event).target.value); + field.setValue(thresholdValue); + }, + [field] + ); + + return ( + + + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.test.tsx index 56c9d6da15607..7a3f0105d3d15 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.test.tsx @@ -38,10 +38,7 @@ setupMock.uiSettings.get.mockImplementation(uiSettingsMock(true)); const mockFilterManager = new FilterManager(setupMock.uiSettings); const mockQueryBar = { - query: { - query: 'test query', - language: 'kuery', - }, + query: 'test query', filters: [ { $state: { @@ -93,10 +90,7 @@ describe('helpers', () => { describe('buildQueryBarDescription', () => { test('returns empty array if no filters, query or savedId exist', () => { const emptyMockQueryBar = { - query: { - query: '', - language: 'kuery', - }, + query: '', filters: [], saved_id: '', }; @@ -113,10 +107,7 @@ describe('helpers', () => { test('returns expected array of ListItems when filters exists, but no indexPatterns passed in', () => { const mockQueryBarWithFilters = { ...mockQueryBar, - query: { - query: '', - language: 'kuery', - }, + query: '', saved_id: '', }; const result: ListItems[] = buildQueryBarDescription({ @@ -135,10 +126,7 @@ describe('helpers', () => { test('returns expected array of ListItems when filters AND indexPatterns exist', () => { const mockQueryBarWithFilters = { ...mockQueryBar, - query: { - query: '', - language: 'kuery', - }, + query: '', saved_id: '', }; const result: ListItems[] = buildQueryBarDescription({ @@ -171,16 +159,13 @@ describe('helpers', () => { savedId: mockQueryBarWithQuery.saved_id, }); expect(result[0].title).toEqual(<>{i18n.QUERY_LABEL} ); - expect(result[0].description).toEqual(<>{mockQueryBarWithQuery.query.query} ); + expect(result[0].description).toEqual(<>{mockQueryBarWithQuery.query} ); }); test('returns expected array of ListItems when "savedId" exists', () => { const mockQueryBarWithSavedId = { ...mockQueryBar, - query: { - query: '', - language: 'kuery', - }, + query: '', filters: [], }; const result: ListItems[] = buildQueryBarDescription({ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx index bc454ecb1134a..7b22078c89d1b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx @@ -77,12 +77,12 @@ export const buildQueryBarDescription = ({ }, ]; } - if (!isEmpty(query.query)) { + if (!isEmpty(query)) { items = [ ...items, { title: <>{i18n.QUERY_LABEL} , - description: <>{query.query} , + description: <>{query} , }, ]; } diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx index 1d58ef8014899..43b4a5f781b89 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx @@ -5,7 +5,7 @@ */ import { EuiDescriptionList, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { isEmpty, chunk, get, pick } from 'lodash/fp'; +import { isEmpty, chunk, get, pick, isNumber } from 'lodash/fp'; import React, { memo, useState } from 'react'; import styled from 'styled-components'; @@ -14,7 +14,6 @@ import { Filter, esFilters, FilterManager, - Query, } from '../../../../../../../../../../src/plugins/data/public'; import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/translations'; import { useKibana } from '../../../../../lib/kibana'; @@ -133,14 +132,14 @@ export const addFilterStateIfNotThere = (filters: Filter[]): Filter[] => { export const getDescriptionItem = ( field: string, label: string, - value: unknown, + data: unknown, filterManager: FilterManager, indexPatterns?: IIndexPattern ): ListItems[] => { if (field === 'queryBar') { - const filters = addFilterStateIfNotThere(get('queryBar.filters', value) ?? []); - const query = get('queryBar.query', value) as Query; - const savedId = get('queryBar.saved_id', value); + const filters = addFilterStateIfNotThere(get('queryBar.filters', data) ?? []); + const query = get('queryBar.query.query', data); + const savedId = get('queryBar.saved_id', data); return buildQueryBarDescription({ field, filters, @@ -150,31 +149,24 @@ export const getDescriptionItem = ( indexPatterns, }); } else if (field === 'threat') { - const threat: IMitreEnterpriseAttack[] = get(field, value).filter( + const threat: IMitreEnterpriseAttack[] = get(field, data).filter( (singleThreat: IMitreEnterpriseAttack) => singleThreat.tactic.name !== 'none' ); return buildThreatDescription({ label, threat }); } else if (field === 'references') { - const urls: string[] = get(field, value); + const urls: string[] = get(field, data); return buildUrlsDescription(label, urls); } else if (field === 'falsePositives') { - const values: string[] = get(field, value); + const values: string[] = get(field, data); return buildUnorderedListArrayDescription(label, field, values); - } else if (Array.isArray(get(field, value))) { - const values: string[] = get(field, value); + } else if (Array.isArray(get(field, data))) { + const values: string[] = get(field, data); return buildStringArrayDescription(label, field, values); } else if (field === 'severity') { - const val: string = get(field, value); + const val: string = get(field, data); return buildSeverityDescription(label, val); - } else if (field === 'riskScore') { - return [ - { - title: label, - description: get(field, value), - }, - ]; } else if (field === 'timeline') { - const timeline = get(field, value) as FieldValueTimeline; + const timeline = get(field, data) as FieldValueTimeline; return [ { title: label, @@ -182,11 +174,12 @@ export const getDescriptionItem = ( }, ]; } else if (field === 'note') { - const val: string = get(field, value); + const val: string = get(field, data); return buildNoteDescription(label, val); } - const description: string = get(field, value); - if (!isEmpty(description)) { + + const description: string = get(field, data); + if (isNumber(description) || !isEmpty(description)) { return [ { title: label, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/types.ts index ab73c52ae9070..bfca6b2068443 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/types.ts @@ -9,7 +9,6 @@ import { IIndexPattern, Filter, FilterManager, - Query, } from '../../../../../../../../../../src/plugins/data/public'; import { IMitreEnterpriseAttack } from '../../types'; @@ -22,7 +21,7 @@ export interface BuildQueryBarDescription { field: string; filters: Filter[]; filterManager: FilterManager; - query: Query; + query: string; savedId: string; indexPatterns?: IIndexPattern; } diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx new file mode 100644 index 0000000000000..627fa21cc2f61 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSuperSelect, EuiText } from '@elastic/eui'; + +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../../shared_imports'; +import { useSiemJobs } from '../../../../../components/ml_popover/hooks/use_siem_jobs'; + +const JobDisplay = ({ title, description }: { title: string; description: string }) => ( + <> + {title} + +

{description}

+
+ +); + +interface MlJobSelectProps { + field: FieldHook; +} + +export const MlJobSelect: React.FC = ({ field }) => { + const jobId = field.value as string; + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + const [isLoading, siemJobs] = useSiemJobs(false); + const handleJobChange = useCallback( + (machineLearningJobId: string) => { + field.setValue(machineLearningJobId); + }, + [field] + ); + + const options = siemJobs.map(job => ({ + value: job.id, + inputDisplay: job.id, + dropdownDisplay: , + })); + + return ( + + + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx index 5886a76182eec..d232c86c19e6f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx @@ -35,7 +35,7 @@ import * as i18n from './translations'; export interface FieldValueQueryBar { filters: Filter[]; query: Query; - saved_id: string | null; + saved_id?: string; } interface QueryBarDefineRuleProps { browserFields: BrowserFields; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap index 9355d0ae2cccb..65a606604d4a7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap @@ -55,10 +55,11 @@ exports[`RuleActionsOverflow renders correctly against snapshot 1`] = ` } />
- `; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx index 7c8926c2064c7..e1ca84ed8cc64 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx @@ -16,12 +16,12 @@ import styled from 'styled-components'; import { noop } from 'lodash/fp'; import { useHistory } from 'react-router-dom'; -import { Rule } from '../../../../../containers/detection_engine/rules'; +import { Rule, exportRules } from '../../../../../containers/detection_engine/rules'; import * as i18n from './translations'; import * as i18nActions from '../../../rules/translations'; import { displaySuccessToast, useStateToaster } from '../../../../../components/toasters'; import { deleteRulesAction, duplicateRulesAction } from '../../all/actions'; -import { RuleDownloader } from '../rule_downloader'; +import { GenericDownloader } from '../../../../../components/generic_downloader'; import { DETECTION_ENGINE_PAGE_NAME } from '../../../../../components/link_to/redirect_to_detection_engine'; const MyEuiButtonIcon = styled(EuiButtonIcon)` @@ -129,10 +129,11 @@ const RuleActionsOverflowComponent = ({ > - { + ids={rulesToExport} + exportSelectedData={exportRules} + onExportSuccess={exportCount => { displaySuccessToast( i18nActions.SUCCESSFULLY_EXPORTED_RULES(exportCount), dispatchToaster diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 4259b68bf14a2..0000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`RuleDownloader renders correctly against snapshot 1`] = ``; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx new file mode 100644 index 0000000000000..229ccde54ecab --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import { EuiCard, EuiFlexGrid, EuiFlexItem, EuiIcon, EuiFormRow } from '@elastic/eui'; + +import { FieldHook } from '../../../../../shared_imports'; +import { RuleType } from '../../../../../containers/detection_engine/rules/types'; +import * as i18n from './translations'; +import { isMlRule } from '../../helpers'; + +interface SelectRuleTypeProps { + field: FieldHook; + isReadOnly: boolean; +} + +export const SelectRuleType: React.FC = ({ field, isReadOnly = false }) => { + const ruleType = field.value as RuleType; + const setType = useCallback( + (type: RuleType) => { + field.setValue(type); + }, + [field] + ); + const setMl = useCallback(() => setType('machine_learning'), [setType]); + const setQuery = useCallback(() => setType('query'), [setType]); + const license = true; // TODO + + return ( + + + + } + selectable={{ + isDisabled: isReadOnly, + onClick: setQuery, + isSelected: !isMlRule(ruleType), + }} + /> + + + } + selectable={{ + isDisabled: isReadOnly, + onClick: setMl, + isSelected: isMlRule(ruleType), + }} + /> + + + + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/translations.ts new file mode 100644 index 0000000000000..32b860e8f703e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/translations.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const QUERY_TYPE_TITLE = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.ruleTypeField.queryTypeTitle', + { + defaultMessage: 'Custom query', + } +); + +export const QUERY_TYPE_DESCRIPTION = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.ruleTypeField.queryTypeDescription', + { + defaultMessage: 'Use KQL or Lucene to detect issues across indices.', + } +); + +export const ML_TYPE_TITLE = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.ruleTypeField.mlTypeTitle', + { + defaultMessage: 'Machine Learning', + } +); + +export const ML_TYPE_DESCRIPTION = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.ruleTypeField.mlTypeDescription', + { + defaultMessage: 'Select ML job to detect anomalous activity.', + } +); + +export const ML_TYPE_DISABLED_DESCRIPTION = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.ruleTypeField.mlTypeDisabledDescription', + { + defaultMessage: 'Access to ML requires a Platinum subscription.', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index 2327ac36a5906..d3ef185f3786b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -9,6 +9,7 @@ import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, + EuiFormRow, EuiButton, } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; @@ -20,11 +21,14 @@ import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/pu import { useFetchIndexPatterns } from '../../../../../containers/detection_engine/rules'; import { DEFAULT_INDEX_KEY } from '../../../../../../common/constants'; import { useUiSetting$ } from '../../../../../lib/kibana'; -import { setFieldValue } from '../../helpers'; +import { setFieldValue, isMlRule } from '../../helpers'; import * as RuleI18n from '../../translations'; import { DefineStepRule, RuleStep, RuleStepProps } from '../../types'; import { StepRuleDescription } from '../description_step'; import { QueryBarDefineRule } from '../query_bar'; +import { SelectRuleType } from '../select_rule_type'; +import { AnomalyThresholdSlider } from '../anomaly_threshold_slider'; +import { MlJobSelect } from '../ml_job_select'; import { StepContentWrapper } from '../step_content_wrapper'; import { Field, @@ -33,9 +37,11 @@ import { getUseField, UseField, useForm, + FormSchema, } from '../../../../../shared_imports'; import { schema } from './schema'; import * as i18n from './translations'; +import { filterRuleFieldsForType, RuleFields } from '../../create/helpers'; const CommonUseField = getUseField({ component: Field }); @@ -43,13 +49,16 @@ interface StepDefineRuleProps extends RuleStepProps { defaultValues?: DefineStepRule | null; } -const stepDefineDefaultValue = { +const stepDefineDefaultValue: DefineStepRule = { + anomalyThreshold: 50, index: [], isNew: true, + machineLearningJobId: '', + ruleType: 'query', queryBar: { query: { query: '', language: 'kuery' }, filters: [], - saved_id: null, + saved_id: undefined, }, }; @@ -96,6 +105,7 @@ const StepDefineRuleComponent: FC = ({ }) => { const [openTimelineSearch, setOpenTimelineSearch] = useState(false); const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(false); + const [localIsMlRule, setIsMlRule] = useState(false); const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); const [mylocalIndicesConfig, setMyLocalIndicesConfig] = useState( defaultValues != null ? defaultValues.index : indicesConfig ?? [] @@ -112,6 +122,7 @@ const StepDefineRuleComponent: FC = ({ options: { stripEmptyFields: false }, schema, }); + const clearErrors = useCallback(() => form.reset({ resetValues: false }), [form]); const onSubmit = useCallback(async () => { if (setStepData) { @@ -154,64 +165,81 @@ const StepDefineRuleComponent: FC = ({ setOpenTimelineSearch(false); }, []); - return isReadOnlyView && myStepData?.queryBar != null ? ( + return isReadOnlyView ? ( ) : ( <> - - {i18n.RESET_DEFAULT_INDEX} - - ) : null, - }} - componentProps={{ - idAria: 'detectionEngineStepDefineRuleIndices', - 'data-test-subj': 'detectionEngineStepDefineRuleIndices', - euiFieldProps: { - fullWidth: true, - isDisabled: isLoading, - placeholder: '', - }, - }} - /> - {i18n.IMPORT_TIMELINE_QUERY} - - ), - }} - component={QueryBarDefineRule} + path="ruleType" + component={SelectRuleType} componentProps={{ - browserFields, - loading: indexPatternLoadingQueryBar, - idAria: 'detectionEngineStepDefineRuleQueryBar', - indexPattern: indexPatternQueryBar, - isDisabled: isLoading, - isLoading: indexPatternLoadingQueryBar, - dataTestSubj: 'detectionEngineStepDefineRuleQueryBar', - openTimelineSearch, - onCloseTimelineSearch: handleCloseTimelineSearch, + isReadOnly: isUpdateView, }} /> - - {({ index }) => { + + <> + + {i18n.RESET_DEFAULT_INDEX} + + ) : null, + }} + componentProps={{ + idAria: 'detectionEngineStepDefineRuleIndices', + 'data-test-subj': 'detectionEngineStepDefineRuleIndices', + euiFieldProps: { + fullWidth: true, + isDisabled: isLoading, + placeholder: '', + }, + }} + /> + + {i18n.IMPORT_TIMELINE_QUERY} + + ), + }} + component={QueryBarDefineRule} + componentProps={{ + browserFields, + loading: indexPatternLoadingQueryBar, + idAria: 'detectionEngineStepDefineRuleQueryBar', + indexPattern: indexPatternQueryBar, + isDisabled: isLoading, + isLoading: indexPatternLoadingQueryBar, + dataTestSubj: 'detectionEngineStepDefineRuleQueryBar', + openTimelineSearch, + onCloseTimelineSearch: handleCloseTimelineSearch, + }} + /> + + + + <> + + + + + + {({ index, ruleType }) => { if (index != null) { if (deepEqual(index, indicesConfig) && !localUseIndicesConfig) { setLocalUseIndicesConfig(true); @@ -223,6 +251,15 @@ const StepDefineRuleComponent: FC = ({ setMyLocalIndicesConfig(index); } } + + if (isMlRule(ruleType) && !localIsMlRule) { + setIsMlRule(true); + clearErrors(); + } else if (!isMlRule(ruleType) && localIsMlRule) { + setIsMlRule(false); + clearErrors(); + } + return null; }} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx index e202ff030cd90..bcfcd4f4ee09d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx @@ -19,8 +19,7 @@ import { ValidationFunc, } from '../../../../../shared_imports'; import { CUSTOM_QUERY_REQUIRED, INVALID_CUSTOM_QUERY, INDEX_HELPER_TEXT } from './translations'; - -const { emptyField } = fieldValidators; +import { isMlRule } from '../../helpers'; export const schema: FormSchema = { index: { @@ -34,14 +33,25 @@ export const schema: FormSchema = { helpText: {INDEX_HELPER_TEXT}, validations: [ { - validator: emptyField( - i18n.translate( - 'xpack.siem.detectionEngine.createRule.stepDefineRule.outputIndiceNameFieldRequiredError', - { - defaultMessage: 'A minimum of one index pattern is required.', - } - ) - ), + validator: ( + ...args: Parameters + ): ReturnType> | undefined => { + const [{ formData }] = args; + const needsValidation = !isMlRule(formData.ruleType); + + if (!needsValidation) { + return; + } + + return fieldValidators.emptyField( + i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.outputIndiceNameFieldRequiredError', + { + defaultMessage: 'A minimum of one index pattern is required.', + } + ) + )(...args); + }, }, ], }, @@ -57,8 +67,13 @@ export const schema: FormSchema = { validator: ( ...args: Parameters ): ReturnType> | undefined => { - const [{ value, path }] = args; + const [{ value, path, formData }] = args; const { query, filters } = value as FieldValueQueryBar; + const needsValidation = !isMlRule(formData.ruleType); + if (!needsValidation) { + return; + } + return isEmpty(query.query as string) && isEmpty(filters) ? { code: 'ERR_FIELD_MISSING', @@ -72,8 +87,13 @@ export const schema: FormSchema = { validator: ( ...args: Parameters ): ReturnType> | undefined => { - const [{ value, path }] = args; + const [{ value, path, formData }] = args; const { query } = value as FieldValueQueryBar; + const needsValidation = !isMlRule(formData.ruleType); + if (!needsValidation) { + return; + } + if (!isEmpty(query.query as string) && query.language === 'kuery') { try { esKuery.fromKueryExpression(query.query); @@ -85,7 +105,55 @@ export const schema: FormSchema = { }; } } - return undefined; + }, + }, + ], + }, + ruleType: { + label: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.fieldRuleTypeLabel', + { + defaultMessage: 'Rule type', + } + ), + validations: [], + }, + anomalyThreshold: { + label: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.fieldAnomalyThresholdLabel', + { + defaultMessage: 'Anomaly score threshold', + } + ), + validations: [], + }, + machineLearningJobId: { + label: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.fieldMachineLearningJobIdLabel', + { + defaultMessage: 'Machine Learning job', + } + ), + validations: [ + { + validator: ( + ...args: Parameters + ): ReturnType> | undefined => { + const [{ formData }] = args; + const needsValidation = isMlRule(formData.ruleType); + + if (!needsValidation) { + return; + } + + return fieldValidators.emptyField( + i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.machineLearningJobIdRequired', + { + defaultMessage: 'A Machine Learning job is required.', + } + ) + )(...args); }, }, ], diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts index dbc5dd9bbe29a..ea6b02924cb3e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts @@ -87,6 +87,7 @@ describe('helpers', () => { query: 'test query', saved_id: 'test123', index: ['filebeat-'], + type: 'saved_query', }; expect(result).toEqual(expected); @@ -106,6 +107,8 @@ describe('helpers', () => { filters: mockQueryBar.filters, query: 'test query', index: ['filebeat-'], + saved_id: '', + type: 'query', }; expect(result).toEqual(expected); @@ -574,12 +577,6 @@ describe('helpers', () => { expect(result.type).toEqual('query'); }); - test('returns NewRule with id set to ruleId if ruleId exists', () => { - const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule, 'query-with-rule-id'); - - expect(result.id).toEqual('query-with-rule-id'); - }); - test('returns NewRule without id if ruleId does not exist', () => { const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts index 07578e870bf2b..1f3379bf681bb 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEmpty } from 'lodash/fp'; +import { has, isEmpty } from 'lodash/fp'; import moment from 'moment'; -import { NewRule } from '../../../../containers/detection_engine/rules'; +import { NewRule, RuleType } from '../../../../containers/detection_engine/rules'; import { AboutStepRule, @@ -16,8 +16,8 @@ import { DefineStepRuleJson, ScheduleStepRuleJson, AboutStepRuleJson, - FormatRuleType, } from '../types'; +import { isMlRule } from '../helpers'; export const getTimeTypeValue = (time: string): { unit: string; value: number } => { const timeObj = { @@ -39,16 +39,52 @@ export const getTimeTypeValue = (time: string): { unit: string; value: number } return timeObj; }; +export interface RuleFields { + anomalyThreshold: unknown; + machineLearningJobId: unknown; + queryBar: unknown; + index: unknown; + ruleType: unknown; +} +type QueryRuleFields = Omit; +type MlRuleFields = Omit; + +const isMlFields = (fields: QueryRuleFields | MlRuleFields): fields is MlRuleFields => + has('anomalyThreshold', fields); + +export const filterRuleFieldsForType = (fields: T, type: RuleType) => { + if (isMlRule(type)) { + const { index, queryBar, ...mlRuleFields } = fields; + return mlRuleFields; + } else { + const { anomalyThreshold, machineLearningJobId, ...queryRuleFields } = fields; + return queryRuleFields; + } +}; + export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStepRuleJson => { - const { queryBar, isNew, ...rest } = defineStepData; - const { filters, query, saved_id: savedId } = queryBar; - return { - ...rest, - language: query.language, - filters, - query: query.query as string, - ...(savedId != null && savedId !== '' ? { saved_id: savedId } : {}), - }; + const ruleFields = filterRuleFieldsForType(defineStepData, defineStepData.ruleType); + + if (isMlFields(ruleFields)) { + const { anomalyThreshold, machineLearningJobId, isNew, ruleType, ...rest } = ruleFields; + return { + ...rest, + type: ruleType, + anomaly_threshold: anomalyThreshold, + machine_learning_job_id: machineLearningJobId, + }; + } else { + const { queryBar, isNew, ruleType, ...rest } = ruleFields; + return { + ...rest, + type: ruleType, + filters: queryBar?.filters, + language: queryBar?.query?.language, + query: queryBar?.query?.query as string, + saved_id: queryBar?.saved_id, + ...(ruleType === 'query' && queryBar?.saved_id ? { type: 'saved_query' as RuleType } : {}), + }; + } }; export const formatScheduleStepData = (scheduleData: ScheduleStepRule): ScheduleStepRuleJson => { @@ -110,15 +146,9 @@ export const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRule export const formatRule = ( defineStepData: DefineStepRule, aboutStepData: AboutStepRule, - scheduleData: ScheduleStepRule, - ruleId?: string -): NewRule => { - const type: FormatRuleType = !isEmpty(defineStepData.queryBar.saved_id) ? 'saved_query' : 'query'; - const persistData = { - type, - ...formatDefineStepData(defineStepData), - ...formatAboutStepData(aboutStepData), - ...formatScheduleStepData(scheduleData), - }; - return ruleId != null ? { id: ruleId, ...persistData } : persistData; -}; + scheduleData: ScheduleStepRule +): NewRule => ({ + ...formatDefineStepData(defineStepData), + ...formatAboutStepData(aboutStepData), + ...formatScheduleStepData(scheduleData), +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx index c9f44ab0048f9..67aaabfe70fda 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx @@ -98,7 +98,6 @@ const CreateRulePageComponent: React.FC = () => { const userHasNoPermissions = canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false; - // eslint-disable-next-line react-hooks/rules-of-hooks const setStepData = useCallback( (step: RuleStep, data: unknown, isValid: boolean) => { stepsData.current[step] = { ...stepsData.current[step], data, isValid }; @@ -138,12 +137,10 @@ const CreateRulePageComponent: React.FC = () => { [isStepRuleInReadOnlyView, openAccordionId, stepsData.current, setRule] ); - // eslint-disable-next-line react-hooks/rules-of-hooks const setStepsForm = useCallback((step: RuleStep, form: FormHook) => { stepsForm.current[step] = form; }, []); - // eslint-disable-next-line react-hooks/rules-of-hooks const getAccordionType = useCallback( (accordionId: RuleStep) => { if (accordionId === openAccordionId) { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx index 5e0e4223e3e27..8618bf9504861 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx @@ -195,8 +195,8 @@ const EditRulePageComponent: FC = () => { if (invalidForms.length === 0 && activeForm != null) { setTabHasError([]); - setRule( - formatRule( + setRule({ + ...formatRule( (activeFormId === RuleStep.defineRule ? activeForm.data : myDefineRuleForm.data) as DefineStepRule, @@ -205,10 +205,10 @@ const EditRulePageComponent: FC = () => { : myAboutRuleForm.data) as AboutStepRule, (activeFormId === RuleStep.scheduleRule ? activeForm.data - : myScheduleRuleForm.data) as ScheduleStepRule, - ruleId - ) - ); + : myScheduleRuleForm.data) as ScheduleStepRule + ), + ...(ruleId ? { id: ruleId } : {}), + }); } else { setTabHasError(invalidForms); } diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx index 0c29bc31cdebc..ee43ae5f1d6e2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx @@ -32,7 +32,10 @@ describe('rule helpers', () => { }); const defineRuleStepData = { isNew: false, + ruleType: 'saved_query', + anomalyThreshold: 50, index: ['auditbeat-*'], + machineLearningJobId: '', queryBar: { query: { query: 'user.name: root or user.name: admin', @@ -180,6 +183,9 @@ describe('rule helpers', () => { const result: DefineStepRule = getDefineStepsData(mockRule('test-id')); const expected = { isNew: false, + ruleType: 'saved_query', + anomalyThreshold: 50, + machineLearningJobId: '', index: ['auditbeat-*'], queryBar: { query: { @@ -194,7 +200,7 @@ describe('rule helpers', () => { expect(result).toEqual(expected); }); - test('returns with saved_id of null if value does not exist on rule', () => { + test('returns with saved_id of undefined if value does not exist on rule', () => { const mockedRule = { ...mockRule('test-id'), }; @@ -202,6 +208,9 @@ describe('rule helpers', () => { const result: DefineStepRule = getDefineStepsData(mockedRule); const expected = { isNew: false, + ruleType: 'saved_query', + anomalyThreshold: 50, + machineLearningJobId: '', index: ['auditbeat-*'], queryBar: { query: { @@ -209,7 +218,7 @@ describe('rule helpers', () => { language: 'kuery', }, filters: [], - saved_id: null, + saved_id: undefined, }, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx index 1fc8a86a476f2..e59ca5e7e14e5 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx @@ -10,7 +10,7 @@ import moment from 'moment'; import { useLocation } from 'react-router-dom'; import { Filter } from '../../../../../../../../src/plugins/data/public'; -import { Rule } from '../../../containers/detection_engine/rules'; +import { Rule, RuleType } from '../../../containers/detection_engine/rules'; import { FormData, FormHook, FormSchema } from '../../../shared_imports'; import { AboutStepRule, @@ -43,18 +43,16 @@ export const getStepsData = ({ }; export const getDefineStepsData = (rule: Rule): DefineStepRule => { - const { index, query, language, filters, saved_id: savedId } = rule; - return { isNew: false, - index, + ruleType: rule.type, + anomalyThreshold: rule.anomaly_threshold ?? 50, + machineLearningJobId: rule.machine_learning_job_id ?? '', + index: rule.index ?? [], queryBar: { - query: { - query, - language, - }, - filters: filters as Filter[], - saved_id: savedId ?? null, + query: { query: rule.query ?? '', language: rule.language ?? '' }, + filters: (rule.filters ?? []) as Filter[], + saved_id: rule.saved_id, }, }; }; @@ -195,6 +193,8 @@ export const setFieldValue = ( } }); +export const isMlRule = (ruleType: RuleType) => ruleType === 'machine_learning'; + export const redirectToDetections = ( isSignalIndexExists: boolean | null, isAuthenticated: boolean | null, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts index aa50626a1231a..447b5dc6325ee 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts @@ -5,6 +5,7 @@ */ import { Filter } from '../../../../../../../../src/plugins/data/common'; +import { RuleType } from '../../../containers/detection_engine/rules/types'; import { FieldValueQueryBar } from './components/query_bar'; import { FormData, FormHook } from '../../../shared_imports'; import { FieldValueTimeline } from './components/pick_timeline'; @@ -67,8 +68,11 @@ export interface AboutStepRuleDetails { } export interface DefineStepRule extends StepRuleData { + anomalyThreshold: number; index: string[]; + machineLearningJobId: string; queryBar: FieldValueQueryBar; + ruleType: RuleType; } export interface ScheduleStepRule extends StepRuleData { @@ -79,11 +83,14 @@ export interface ScheduleStepRule extends StepRuleData { } export interface DefineStepRuleJson { - index: string[]; - filters: Filter[]; + anomaly_threshold?: number; + index?: string[]; + filters?: Filter[]; + machine_learning_job_id?: string; saved_id?: string; - query: string; - language: string; + query?: string; + language?: string; + type: RuleType; } export interface AboutStepRuleJson { @@ -112,8 +119,6 @@ export type MyRule = Omit = ({ apolloClient }) => ( - <> - - - - - - - - - - -); +const TimelinesPageComponent: React.FC = ({ apolloClient }) => { + return ( + <> + + + + + + + + + + + ); +}; export const TimelinesPage = React.memo(TimelinesPageComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/timelines/translations.ts b/x-pack/legacy/plugins/siem/public/pages/timelines/translations.ts index 5426ccbdb4f9a..723d164ad2cdd 100644 --- a/x-pack/legacy/plugins/siem/public/pages/timelines/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/timelines/translations.ts @@ -16,3 +16,10 @@ export const ALL_TIMELINES_PANEL_TITLE = i18n.translate( defaultMessage: 'All timelines', } ); + +export const ALL_TIMELINES_IMPORT_TIMELINE_TITLE = i18n.translate( + 'xpack.siem.timelines.allTimelines.importTimelineTitle', + { + defaultMessage: 'Import Timeline', + } +); diff --git a/x-pack/legacy/plugins/siem/public/plugin.tsx b/x-pack/legacy/plugins/siem/public/plugin.tsx index 71fa3a54df768..da4aad97e5b48 100644 --- a/x-pack/legacy/plugins/siem/public/plugin.tsx +++ b/x-pack/legacy/plugins/siem/public/plugin.tsx @@ -27,21 +27,24 @@ import { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, } from '../../../../plugins/triggers_actions_ui/public'; +import { SecurityPluginSetup } from '../../../../plugins/security/public'; export { AppMountParameters, CoreSetup, CoreStart, PluginInitializerContext }; export interface SetupPlugins { home: HomePublicPluginSetup; - usageCollection: UsageCollectionSetup; + security: SecurityPluginSetup; triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; + usageCollection: UsageCollectionSetup; } export interface StartPlugins { data: DataPublicPluginStart; embeddable: EmbeddableStart; inspector: InspectorStart; newsfeed?: NewsfeedStart; - uiActions: UiActionsStart; + security: SecurityPluginSetup; triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; + uiActions: UiActionsStart; } export type StartServices = CoreStart & StartPlugins; @@ -61,6 +64,8 @@ export class Plugin implements IPlugin { public setup(core: CoreSetup, plugins: SetupPlugins) { initTelemetry(plugins.usageCollection, this.id); + const security = plugins.security; + core.application.register({ id: this.id, title: this.name, @@ -69,8 +74,7 @@ export class Plugin implements IPlugin { const { renderApp } = await import('./app'); plugins.triggers_actions_ui.actionTypeRegistry.register(serviceNowActionType()); - - return renderApp(coreStart, startPlugins as StartPlugins, params); + return renderApp(coreStart, { ...startPlugins, security } as StartPlugins, params); }, }); diff --git a/x-pack/legacy/plugins/siem/public/utils/route/spy_routes.tsx b/x-pack/legacy/plugins/siem/public/utils/route/spy_routes.tsx index ddee2359b28ba..9030e2713548b 100644 --- a/x-pack/legacy/plugins/siem/public/utils/route/spy_routes.tsx +++ b/x-pack/legacy/plugins/siem/public/utils/route/spy_routes.tsx @@ -39,12 +39,13 @@ export const SpyRouteComponent = memo( dispatch({ type: 'updateRouteWithOutSearch', route: { - pageName, detailName, - tabName, - pathName: pathname, - history, flowTarget, + history, + pageName, + pathName: pathname, + state, + tabName, }, }); setIsInitializing(false); @@ -52,13 +53,14 @@ export const SpyRouteComponent = memo( dispatch({ type: 'updateRoute', route: { - pageName, detailName, - tabName, - search, - pathName: pathname, - history, flowTarget, + history, + pageName, + pathName: pathname, + search, + state, + tabName, }, }); } @@ -67,14 +69,14 @@ export const SpyRouteComponent = memo( dispatch({ type: 'updateRoute', route: { - pageName, detailName, - tabName, - search, - pathName: pathname, - history, flowTarget, + history, + pageName, + pathName: pathname, + search, state, + tabName, }, }); } diff --git a/x-pack/legacy/plugins/siem/server/graphql/timeline/schema.gql.ts b/x-pack/legacy/plugins/siem/server/graphql/timeline/schema.gql.ts index 8b24cea0d6af9..9dd04247b7f47 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/timeline/schema.gql.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/timeline/schema.gql.ts @@ -150,7 +150,7 @@ export const timelineSchema = gql` updated created } - + input SortTimeline { sortField: SortFieldTimeline! sortOrder: Direction! diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/feature_flags.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/feature_flags.test.ts new file mode 100644 index 0000000000000..920064f9a1b77 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/feature_flags.test.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + listsEnvFeatureFlagName, + hasListsFeature, + unSetFeatureFlagsForTestsOnly, + setFeatureFlagsForTestsOnly, +} from './feature_flags'; + +describe('feature_flags', () => { + beforeAll(() => { + delete process.env[listsEnvFeatureFlagName]; + }); + + afterEach(() => { + delete process.env[listsEnvFeatureFlagName]; + }); + + describe('hasListsFeature', () => { + test('hasListsFeature should return false if process.env is not set', () => { + expect(hasListsFeature()).toEqual(false); + }); + + test('hasListsFeature should return true if process.env is set to true', () => { + process.env[listsEnvFeatureFlagName] = 'true'; + expect(hasListsFeature()).toEqual(true); + }); + + test('hasListsFeature should return false if process.env is set to false', () => { + process.env[listsEnvFeatureFlagName] = 'false'; + expect(hasListsFeature()).toEqual(false); + }); + + test('hasListsFeature should return false if process.env is set to a non true value', () => { + process.env[listsEnvFeatureFlagName] = 'something else'; + expect(hasListsFeature()).toEqual(false); + }); + }); + + describe('setFeatureFlagsForTestsOnly', () => { + test('it can be called once and sets the environment variable for tests', () => { + setFeatureFlagsForTestsOnly(); + expect(process.env[listsEnvFeatureFlagName]).toEqual('true'); + unSetFeatureFlagsForTestsOnly(); // This is needed to not pollute other tests since this has to be paired + }); + + test('if it is called twice it throws an exception', () => { + setFeatureFlagsForTestsOnly(); + expect(() => setFeatureFlagsForTestsOnly()).toThrow( + 'In your tests you need to ensure in your afterEach/afterAll blocks you are calling unSetFeatureFlagsForTestsOnly' + ); + unSetFeatureFlagsForTestsOnly(); // This is needed to not pollute other tests since this has to be paired + }); + + test('it can be called twice as long as unSetFeatureFlagsForTestsOnly is called in-between', () => { + setFeatureFlagsForTestsOnly(); + unSetFeatureFlagsForTestsOnly(); + setFeatureFlagsForTestsOnly(); + expect(process.env[listsEnvFeatureFlagName]).toEqual('true'); + unSetFeatureFlagsForTestsOnly(); // This is needed to not pollute other tests since this has to be paired + }); + }); + + describe('unSetFeatureFlagsForTestsOnly', () => { + test('it can sets the value to undefined', () => { + setFeatureFlagsForTestsOnly(); + unSetFeatureFlagsForTestsOnly(); + expect(process.env[listsEnvFeatureFlagName]).toEqual(undefined); + }); + + test('it can not be be called before setFeatureFlagsForTestsOnly without throwing', () => { + expect(() => unSetFeatureFlagsForTestsOnly()).toThrow( + 'In your tests you need to ensure in your beforeEach/beforeAll blocks you are calling setFeatureFlagsForTestsOnly' + ); + }); + + test('if it is called twice it throws an exception', () => { + setFeatureFlagsForTestsOnly(); + unSetFeatureFlagsForTestsOnly(); + expect(() => unSetFeatureFlagsForTestsOnly()).toThrow( + 'In your tests you need to ensure in your beforeEach/beforeAll blocks you are calling setFeatureFlagsForTestsOnly' + ); + }); + + test('it can be called twice as long as setFeatureFlagsForTestsOnly is called in-between', () => { + setFeatureFlagsForTestsOnly(); + unSetFeatureFlagsForTestsOnly(); + setFeatureFlagsForTestsOnly(); + unSetFeatureFlagsForTestsOnly(); + expect(process.env[listsEnvFeatureFlagName]).toEqual(undefined); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/feature_flags.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/feature_flags.ts new file mode 100644 index 0000000000000..4e309faa46e1b --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/feature_flags.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// TODO: (LIST-FEATURE) Delete this file once the lists features are within the product and in a particular version + +// Very temporary file where we put our feature flags for detection lists. +// We need to use an environment variable and CANNOT use a kibana.dev.yml setting because some definitions +// of things are global in the modules are are initialized before the init of the server has a chance to start. +// Set this in your .bashrc/.zshrc to turn on lists feature, export ELASTIC_XPACK_SIEM_LISTS_FEATURE=true + +// NOTE: This feature is forwards and backwards compatible but forwards compatible is not guaranteed. +// Once you enable this and begin using it you might not be able to easily go back back. +// So it's best to not turn it on unless you are developing code. +export const listsEnvFeatureFlagName = 'ELASTIC_XPACK_SIEM_LISTS_FEATURE'; + +// This is for setFeatureFlagsForTestsOnly and unSetFeatureFlagsForTestsOnly only to use +let setFeatureFlagsForTestsOnlyCalled = false; + +// Use this to detect if the lists feature is enabled or not +export const hasListsFeature = (): boolean => { + return process.env[listsEnvFeatureFlagName]?.trim().toLowerCase() === 'true'; +}; + +// This is for tests only to use in your beforeAll() calls +export const setFeatureFlagsForTestsOnly = (): void => { + if (setFeatureFlagsForTestsOnlyCalled) { + throw new Error( + 'In your tests you need to ensure in your afterEach/afterAll blocks you are calling unSetFeatureFlagsForTestsOnly' + ); + } else { + setFeatureFlagsForTestsOnlyCalled = true; + process.env[listsEnvFeatureFlagName] = 'true'; + } +}; + +// This is for tests only to use in your afterAll() calls +export const unSetFeatureFlagsForTestsOnly = (): void => { + if (!setFeatureFlagsForTestsOnlyCalled) { + throw new Error( + 'In your tests you need to ensure in your beforeEach/beforeAll blocks you are calling setFeatureFlagsForTestsOnly' + ); + } else { + delete process.env[listsEnvFeatureFlagName]; + setFeatureFlagsForTestsOnlyCalled = false; + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.test.ts index cb358c15e5fad..25945e72ff179 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.test.ts @@ -5,6 +5,7 @@ */ import { getIndexExists } from './get_index_exists'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../feature_flags'; class StatusCode extends Error { status: number = -1; @@ -15,6 +16,14 @@ class StatusCode extends Error { } describe('get_index_exists', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('it should return a true if you have _shards', async () => { const callWithRequest = jest.fn().mockResolvedValue({ _shards: { total: 1 } }); const indexExists = await getIndexExists(callWithRequest, 'some-index'); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 54ed42a1d2b6c..0e0ab58a7a199 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -50,6 +50,7 @@ export const mockPrepackagedRule = (): PrepackagedRules => ({ technique: [{ id: 'techniqueId', name: 'techniqueName', reference: 'techniqueRef' }], }, ], + throttle: null, enabled: true, filters: [], immutable: false, @@ -293,6 +294,21 @@ export const getCreateRequest = () => body: typicalPayload(), }); +export const createMlRuleRequest = () => { + const { query, language, index, ...mlParams } = typicalPayload(); + + return requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_RULES_URL, + body: { + ...mlParams, + type: 'machine_learning', + anomaly_threshold: 50, + machine_learning_job_id: 'some-uuid', + }, + }); +}; + export const getSetSignalStatusByIdsRequest = () => requestMock.create({ method: 'post', @@ -349,6 +365,7 @@ export const getResult = (): RuleAlertType => ({ alertTypeId: 'siem.signals', consumer: 'siem', params: { + anomalyThreshold: undefined, description: 'Detecting root and admin users', ruleId: 'rule-1', index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], @@ -357,6 +374,7 @@ export const getResult = (): RuleAlertType => ({ immutable: false, query: 'user.name: root or user.name: admin', language: 'kuery', + machineLearningJobId: undefined, outputIndex: '.siem-signals', timelineId: 'some-timeline-id', timelineTitle: 'some-timeline-title', @@ -395,6 +413,32 @@ export const getResult = (): RuleAlertType => ({ references: ['http://www.example.com', 'https://ww.example.com'], note: '# Investigative notes', version: 1, + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], }, createdAt: new Date('2019-12-13T16:40:33.400Z'), updatedAt: new Date('2019-12-13T16:40:33.400Z'), @@ -411,6 +455,24 @@ export const getResult = (): RuleAlertType => ({ scheduledTaskId: '2dabe330-0702-11ea-8b50-773b89126888', }); +export const getMlResult = (): RuleAlertType => { + const result = getResult(); + + return { + ...result, + params: { + ...result.params, + query: undefined, + language: undefined, + filters: undefined, + index: undefined, + type: 'machine_learning', + anomalyThreshold: 44, + machineLearningJobId: 'some_job_id', + }, + }; +}; + export const updateActionResult = (): ActionResult => ({ id: 'result-1', actionTypeId: 'action-id-1', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts index f59370ce481b6..13d75cc44992c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts @@ -77,3 +77,93 @@ export const buildHapiStream = (string: string, filename = 'file.ndjson'): HapiR return stream; }; + +export const getOutputRuleAlertForRest = (): Omit< + OutputRuleAlertRest, + 'machine_learning_job_id' | 'anomaly_threshold' +> => ({ + actions: [], + created_by: 'elastic', + created_at: '2019-12-13T16:40:33.400Z', + updated_at: '2019-12-13T16:40:33.400Z', + description: 'Detecting root and admin users', + enabled: true, + false_positives: [], + from: 'now-6m', + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + risk_score: 50, + rule_id: 'rule-1', + language: 'kuery', + max_signals: 100, + name: 'Detect Root/Admin Users', + output_index: '.siem-signals', + query: 'user.name: root or user.name: admin', + references: ['http://www.example.com', 'https://ww.example.com'], + severity: 'high', + updated_by: 'elastic', + tags: [], + threat: [ + { + framework: 'MITRE ATT&CK', + tactic: { + id: 'TA0040', + name: 'impact', + reference: 'https://attack.mitre.org/tactics/TA0040/', + }, + technique: [ + { + id: 'T1499', + name: 'endpoint denial of service', + reference: 'https://attack.mitre.org/techniques/T1499/', + }, + ], + }, + ], + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], + filters: [ + { + query: { + match_phrase: { + 'host.name': 'some-host', + }, + }, + }, + ], + meta: { + someMeta: 'someField', + }, + timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', + to: 'now', + type: 'query', + note: '# Investigative notes', + version: 1, +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts index 2b4fb8fa08a60..f53efc8a3234d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts @@ -14,6 +14,7 @@ import { import { requestContextMock, serverMock } from '../__mocks__'; import { addPrepackedRulesRoute } from './add_prepackaged_rules_route'; import { PrepackagedRules } from '../../types'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; jest.mock('../../rules/get_prepackaged_rules', () => { return { @@ -44,6 +45,14 @@ describe('add_prepackaged_rules_route', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts index 6ad9efebce2dd..e2af678c828e6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts @@ -16,11 +16,20 @@ import { } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { createRulesBulkRoute } from './create_rules_bulk_route'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('create_rules_bulk', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); @@ -137,7 +146,7 @@ describe('create_rules_bulk', () => { const result = server.validate(request); expect(result.badRequest).toHaveBeenCalledWith( - '"value" at position 0 fails because [child "type" fails because ["type" must be one of [query, saved_query]]]' + '"value" at position 0 fails because [child "type" fails because ["type" must be one of [query, saved_query, machine_learning]]]' ); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts index d727bbb953d2a..4ffa29c385f28 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts @@ -56,12 +56,15 @@ export const createRulesBulkRoute = (router: IRouter) => { .filter(rule => rule.rule_id == null || !dupes.includes(rule.rule_id)) .map(async payloadRule => { const { + actions, + anomaly_threshold: anomalyThreshold, description, enabled, false_positives: falsePositives, from, query, language, + machine_learning_job_id: machineLearningJobId, output_index: outputIndex, saved_id: savedId, meta, @@ -75,6 +78,7 @@ export const createRulesBulkRoute = (router: IRouter) => { severity, tags, threat, + throttle, to, type, references, @@ -82,6 +86,7 @@ export const createRulesBulkRoute = (router: IRouter) => { timeline_id: timelineId, timeline_title: timelineTitle, version, + lists, } = payloadRule; const ruleIdOrUuid = ruleId ?? uuid.v4(); try { @@ -107,6 +112,8 @@ export const createRulesBulkRoute = (router: IRouter) => { const createdRule = await createRules({ alertsClient, actionsClient, + actions, + anomalyThreshold, description, enabled, falsePositives, @@ -114,6 +121,7 @@ export const createRulesBulkRoute = (router: IRouter) => { immutable: false, query, language, + machineLearningJobId, outputIndex: finalIndex, savedId, timelineId, @@ -128,12 +136,14 @@ export const createRulesBulkRoute = (router: IRouter) => { name, severity, tags, + throttle, to, type, threat, references, note, version, + lists, }); return transformValidateBulkError(ruleIdOrUuid, createdRule); } catch (err) { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index d019668e2a8d1..1a4e19c2047b5 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -14,14 +14,24 @@ import { getNonEmptyIndex, getEmptyIndex, getFindResultWithSingleHit, + createMlRuleRequest, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { createRulesRoute } from './create_rules_route'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('create_rules', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); @@ -48,6 +58,13 @@ describe('create_rules', () => { }); }); + describe('creating an ML Rule', () => { + it('is successful', async () => { + const response = await server.inject(createMlRuleRequest(), context); + expect(response.status).toEqual(200); + }); + }); + describe('unhappy paths', () => { test('it returns a 400 if the index does not exist', async () => { clients.clusterClient.callAsCurrentUser.mockResolvedValue(getEmptyIndex()); @@ -111,7 +128,7 @@ describe('create_rules', () => { const result = server.validate(request); expect(result.badRequest).toHaveBeenCalledWith( - 'child "type" fails because ["type" must be one of [query, saved_query]]' + 'child "type" fails because ["type" must be one of [query, saved_query, machine_learning]]' ); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts index fcfcee99f369e..cee9054cf922e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -31,6 +31,8 @@ export const createRulesRoute = (router: IRouter): void => { }, async (context, request, response) => { const { + actions, + anomaly_threshold: anomalyThreshold, description, enabled, false_positives: falsePositives, @@ -42,6 +44,7 @@ export const createRulesRoute = (router: IRouter): void => { timeline_id: timelineId, timeline_title: timelineTitle, meta, + machine_learning_job_id: machineLearningJobId, filters, rule_id: ruleId, index, @@ -52,10 +55,12 @@ export const createRulesRoute = (router: IRouter): void => { severity, tags, threat, + throttle, to, type, references, note, + lists, } = request.body; const siemResponse = buildSiemResponse(response); @@ -93,6 +98,8 @@ export const createRulesRoute = (router: IRouter): void => { const createdRule = await createRules({ alertsClient, actionsClient, + actions, + anomalyThreshold, description, enabled, falsePositives, @@ -105,6 +112,7 @@ export const createRulesRoute = (router: IRouter): void => { timelineId, timelineTitle, meta, + machineLearningJobId, filters, ruleId: ruleId ?? uuid.v4(), index, @@ -114,12 +122,14 @@ export const createRulesRoute = (router: IRouter): void => { name, severity, tags, + throttle, to, type, threat, references, note, version: 1, + lists, }); const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts index 16f9a9524df55..f2da3ab4be8f6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts @@ -17,11 +17,20 @@ import { } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { deleteRulesBulkRoute } from './delete_rules_bulk_route'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('delete_rules', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts index 0519addb275d6..e30f332ecd1ca 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts @@ -15,11 +15,20 @@ import { } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { deleteRulesRoute } from './delete_rules_route'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('delete_rules', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.test.ts index 57759844c100d..b4591a8141f7b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.test.ts @@ -13,11 +13,20 @@ import { } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { findRulesRoute } from './find_rules_route'; +import { unSetFeatureFlagsForTestsOnly, setFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('find_rules', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts index 9c86b70b88270..89c9f34027120 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.test.ts @@ -8,11 +8,20 @@ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { getFindResultStatus, ruleStatusRequest } from '../__mocks__/request_responses'; import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { findRulesStatusesRoute } from './find_rules_status_route'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('find_statuses', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts index 03059ed5ec5cc..2bbd4f78afae1 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts @@ -13,6 +13,7 @@ import { getNonEmptyIndex, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock } from '../__mocks__'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; jest.mock('../../rules/get_prepackaged_rules', () => { return { @@ -38,6 +39,14 @@ jest.mock('../../rules/get_prepackaged_rules', () => { }); describe('get_prepackaged_rule_status_route', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + let server: ReturnType; let { clients, context } = requestContextMock.createTools(); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index c224e0f055b85..f6e1cf6e2420c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -23,8 +23,17 @@ import { createMockConfig, requestContextMock, serverMock, requestMock } from '. import { importRulesRoute } from './import_rules_route'; import { DEFAULT_SIGNALS_INDEX } from '../../../../../common/constants'; import * as createRulesStreamFromNdJson from '../../rules/create_rules_stream_from_ndjson'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('import_rules_route', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + let config = createMockConfig(); let server: ReturnType; let request: ReturnType; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts index ec4e707f46e50..72a6e70cbb14a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -111,6 +111,8 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config return null; } const { + actions, + anomaly_threshold: anomalyThreshold, description, enabled, false_positives: falsePositives, @@ -118,6 +120,7 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config immutable, query, language, + machine_learning_job_id: machineLearningJobId, output_index: outputIndex, saved_id: savedId, meta, @@ -131,6 +134,7 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config severity, tags, threat, + throttle, to, type, references, @@ -138,7 +142,9 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config timeline_id: timelineId, timeline_title: timelineTitle, version, + lists, } = parsedRule; + try { const signalsIndex = siemClient.signalsIndex; const indexExists = await getIndexExists( @@ -159,6 +165,8 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config await createRules({ alertsClient, actionsClient, + actions, + anomalyThreshold, description, enabled, falsePositives, @@ -166,6 +174,7 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config immutable, query, language, + machineLearningJobId, outputIndex: signalsIndex, savedId, timelineId, @@ -183,15 +192,18 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config to, type, threat, + throttle, references, note, version, + lists, }); resolve({ rule_id: ruleId, status_code: 200 }); } else if (rule != null && request.query.overwrite) { await patchRules({ alertsClient, actionsClient, + actions, savedObjectsClient, description, enabled, @@ -218,9 +230,12 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config to, type, threat, + throttle, references, note, version, + anomalyThreshold, + machineLearningJobId, }); resolve({ rule_id: ruleId, status_code: 200 }); } else if (rule != null) { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts index 19bcd2e7f0596..4c980c8cc60d2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts @@ -14,11 +14,20 @@ import { } from '../__mocks__/request_responses'; import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { patchRulesBulkRoute } from './patch_rules_bulk_route'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('patch_rules_bulk', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); @@ -89,7 +98,7 @@ describe('patch_rules_bulk', () => { const result = server.validate(request); expect(result.badRequest).toHaveBeenCalledWith( - '"value" at position 0 fails because [child "type" fails because ["type" must be one of [query, saved_query]]]' + '"value" at position 0 fails because [child "type" fails because ["type" must be one of [query, saved_query, machine_learning]]]' ); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts index e64bbe625f5f6..698f58438a5e6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts @@ -46,6 +46,7 @@ export const patchRulesBulkRoute = (router: IRouter) => { const rules = await Promise.all( request.body.map(async payloadRule => { const { + actions, description, enabled, false_positives: falsePositives, @@ -70,6 +71,7 @@ export const patchRulesBulkRoute = (router: IRouter) => { to, type, threat, + throttle, references, note, version, @@ -79,6 +81,7 @@ export const patchRulesBulkRoute = (router: IRouter) => { const rule = await patchRules({ alertsClient, actionsClient, + actions, description, enabled, falsePositives, @@ -104,6 +107,7 @@ export const patchRulesBulkRoute = (router: IRouter) => { to, type, threat, + throttle, references, note, version, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts index 1658de77e3390..b92c18827557c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts @@ -16,11 +16,20 @@ import { } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { patchRulesRoute } from './patch_rules_route'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('patch_rules', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); @@ -112,7 +121,7 @@ describe('patch_rules', () => { const result = server.validate(request); expect(result.badRequest).toHaveBeenCalledWith( - 'child "type" fails because ["type" must be one of [query, saved_query]]' + 'child "type" fails because ["type" must be one of [query, saved_query, machine_learning]]' ); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts index 2d810d33c6e51..4493bb380d03d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts @@ -30,6 +30,7 @@ export const patchRulesRoute = (router: IRouter) => { }, async (context, request, response) => { const { + actions, description, enabled, false_positives: falsePositives, @@ -54,6 +55,7 @@ export const patchRulesRoute = (router: IRouter) => { to, type, threat, + throttle, references, note, version, @@ -76,6 +78,7 @@ export const patchRulesRoute = (router: IRouter) => { const rule = await patchRules({ actionsClient, alertsClient, + actions, description, enabled, falsePositives, @@ -101,6 +104,7 @@ export const patchRulesRoute = (router: IRouter) => { to, type, threat, + throttle, references, note, version, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.test.ts index 7ebac9b785c82..982e1bb47a53a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.test.ts @@ -14,11 +14,20 @@ import { getFindResultStatusEmpty, } from '../__mocks__/request_responses'; import { requestMock, requestContextMock, serverMock } from '../__mocks__'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('read_signals', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts index 7a9159ecc852b..d530866edaf0d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts @@ -16,11 +16,20 @@ import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { updateRulesBulkRoute } from './update_rules_bulk_route'; import { BulkError } from '../utils'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('update_rules_bulk', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); @@ -110,7 +119,7 @@ describe('update_rules_bulk', () => { const result = server.validate(request); expect(result.badRequest).toHaveBeenCalledWith( - '"value" at position 0 fails because [child "type" fails because ["type" must be one of [query, saved_query]]]' + '"value" at position 0 fails because [child "type" fails because ["type" must be one of [query, saved_query, machine_learning]]]' ); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index 777b9f3cc7a9d..6c3c8dffa3dfa 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -47,12 +47,15 @@ export const updateRulesBulkRoute = (router: IRouter) => { const rules = await Promise.all( request.body.map(async payloadRule => { const { + actions, + anomaly_threshold: anomalyThreshold, description, enabled, false_positives: falsePositives, from, query, language, + machine_learning_job_id: machineLearningJobId, output_index: outputIndex, saved_id: savedId, timeline_id: timelineId, @@ -71,9 +74,11 @@ export const updateRulesBulkRoute = (router: IRouter) => { to, type, threat, + throttle, references, note, version, + lists, } = payloadRule; const finalIndex = outputIndex ?? siemClient.signalsIndex; const idOrRuleIdOrUnknown = id ?? ruleId ?? '(unknown id)'; @@ -81,6 +86,8 @@ export const updateRulesBulkRoute = (router: IRouter) => { const rule = await updateRules({ alertsClient, actionsClient, + actions, + anomalyThreshold, description, enabled, immutable: false, @@ -88,6 +95,7 @@ export const updateRulesBulkRoute = (router: IRouter) => { from, query, language, + machineLearningJobId, outputIndex: finalIndex, savedId, savedObjectsClient, @@ -107,9 +115,11 @@ export const updateRulesBulkRoute = (router: IRouter) => { to, type, threat, + throttle, references, note, version, + lists, }); if (rule != null) { const ruleStatuses = await savedObjectsClient.find< diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts index 6ef508b817713..a15f1ca9b044e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts @@ -16,11 +16,20 @@ import { } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('update_rules', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); @@ -115,7 +124,7 @@ describe('update_rules', () => { const result = await server.validate(request); expect(result.badRequest).toHaveBeenCalledWith( - 'child "type" fails because ["type" must be one of [query, saved_query]]' + 'child "type" fails because ["type" must be one of [query, saved_query, machine_learning]]' ); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts index 1393de8c725cb..7e56c32ade92a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -30,12 +30,15 @@ export const updateRulesRoute = (router: IRouter) => { }, async (context, request, response) => { const { + actions, + anomaly_threshold: anomalyThreshold, description, enabled, false_positives: falsePositives, from, query, language, + machine_learning_job_id: machineLearningJobId, output_index: outputIndex, saved_id: savedId, timeline_id: timelineId, @@ -54,9 +57,11 @@ export const updateRulesRoute = (router: IRouter) => { to, type, threat, + throttle, references, note, version, + lists, } = request.body; const siemResponse = buildSiemResponse(response); @@ -77,6 +82,8 @@ export const updateRulesRoute = (router: IRouter) => { const rule = await updateRules({ alertsClient, actionsClient, + actions, + anomalyThreshold, description, enabled, falsePositives, @@ -84,6 +91,7 @@ export const updateRulesRoute = (router: IRouter) => { immutable: false, query, language, + machineLearningJobId, outputIndex: finalIndex, savedId, savedObjectsClient, @@ -103,9 +111,11 @@ export const updateRulesRoute = (router: IRouter) => { to, type, threat, + throttle, references, note, version, + lists, }); if (rule != null) { const ruleStatuses = await savedObjectsClient.find< diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts index 70fcbb2c163ca..3a047f91a0bcb 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts @@ -12,7 +12,7 @@ import { transformTags, getIdBulkError, transformOrBulkError, - transformRulesToNdjson, + transformDataToNdjson, transformAlertsToRules, transformOrImportError, getDuplicates, @@ -20,403 +20,88 @@ import { } from './utils'; import { getResult } from '../__mocks__/request_responses'; import { INTERNAL_IDENTIFIER } from '../../../../../common/constants'; -import { OutputRuleAlertRest, ImportRuleAlertRest, RuleAlertParamsRest } from '../../types'; +import { ImportRuleAlertRest, RuleAlertParamsRest, RuleTypeParams } from '../../types'; import { BulkError, ImportSuccessError } from '../utils'; import { sampleRule } from '../../signals/__mocks__/es_results'; -import { getSimpleRule } from '../__mocks__/utils'; +import { getSimpleRule, getOutputRuleAlertForRest } from '../__mocks__/utils'; import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson'; import { createPromiseFromStreams } from '../../../../../../../../../src/legacy/utils/streams'; import { PartialAlert } from '../../../../../../../../plugins/alerting/server'; import { SanitizedAlert } from '../../../../../../../../plugins/alerting/server/types'; +import { RuleAlertType } from '../../rules/types'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; type PromiseFromStreams = ImportRuleAlertRest | Error; describe('utils', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + describe('transformAlertToRule', () => { test('should work with a full data set', () => { const fullRule = getResult(); const rule = transformAlertToRule(fullRule); - const expected: OutputRuleAlertRest = { - created_by: 'elastic', - created_at: '2019-12-13T16:40:33.400Z', - updated_at: '2019-12-13T16:40:33.400Z', - description: 'Detecting root and admin users', - enabled: true, - false_positives: [], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - risk_score: 50, - rule_id: 'rule-1', - language: 'kuery', - max_signals: 100, - name: 'Detect Root/Admin Users', - output_index: '.siem-signals', - query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], - severity: 'high', - updated_by: 'elastic', - tags: [], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: 'TA0040', - name: 'impact', - reference: 'https://attack.mitre.org/tactics/TA0040/', - }, - technique: [ - { - id: 'T1499', - name: 'endpoint denial of service', - reference: 'https://attack.mitre.org/techniques/T1499/', - }, - ], - }, - ], - filters: [ - { - query: { - match_phrase: { - 'host.name': 'some-host', - }, - }, - }, - ], - meta: { - someMeta: 'someField', - }, - timeline_id: 'some-timeline-id', - timeline_title: 'some-timeline-title', - to: 'now', - type: 'query', - note: '# Investigative notes', - version: 1, - }; - expect(rule).toEqual(expected); + expect(rule).toEqual(getOutputRuleAlertForRest()); }); test('should work with a partial data set missing data', () => { const fullRule = getResult(); - const { from, language, ...omitData } = transformAlertToRule(fullRule); - const expected: Partial = { - created_by: 'elastic', - created_at: '2019-12-13T16:40:33.400Z', - updated_at: '2019-12-13T16:40:33.400Z', - description: 'Detecting root and admin users', - enabled: true, - false_positives: [], - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - output_index: '.siem-signals', - interval: '5m', - risk_score: 50, - rule_id: 'rule-1', - max_signals: 100, - name: 'Detect Root/Admin Users', - query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], - severity: 'high', - updated_by: 'elastic', - tags: [], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: 'TA0040', - name: 'impact', - reference: 'https://attack.mitre.org/tactics/TA0040/', - }, - technique: [ - { - id: 'T1499', - name: 'endpoint denial of service', - reference: 'https://attack.mitre.org/techniques/T1499/', - }, - ], - }, - ], - filters: [ - { - query: { - match_phrase: { - 'host.name': 'some-host', - }, - }, - }, - ], - meta: { - someMeta: 'someField', - }, - timeline_id: 'some-timeline-id', - timeline_title: 'some-timeline-title', - to: 'now', - type: 'query', - note: '# Investigative notes', - version: 1, - }; - expect(omitData).toEqual(expected); + const { from, language, ...omitParams } = fullRule.params; + fullRule.params = omitParams as RuleTypeParams; + const rule = transformAlertToRule(fullRule); + const { + from: from2, + language: language2, + ...expectedWithoutFromWithoutLanguage + } = getOutputRuleAlertForRest(); + expect(rule).toEqual(expectedWithoutFromWithoutLanguage); }); test('should omit query if query is null', () => { const fullRule = getResult(); fullRule.params.query = null; const rule = transformAlertToRule(fullRule); - const expected: Partial = { - created_by: 'elastic', - created_at: '2019-12-13T16:40:33.400Z', - updated_at: '2019-12-13T16:40:33.400Z', - description: 'Detecting root and admin users', - enabled: true, - false_positives: [], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - output_index: '.siem-signals', - interval: '5m', - risk_score: 50, - rule_id: 'rule-1', - language: 'kuery', - max_signals: 100, - name: 'Detect Root/Admin Users', - references: ['http://www.example.com', 'https://ww.example.com'], - severity: 'high', - updated_by: 'elastic', - tags: [], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: 'TA0040', - name: 'impact', - reference: 'https://attack.mitre.org/tactics/TA0040/', - }, - technique: [ - { - id: 'T1499', - name: 'endpoint denial of service', - reference: 'https://attack.mitre.org/techniques/T1499/', - }, - ], - }, - ], - filters: [ - { - query: { - match_phrase: { - 'host.name': 'some-host', - }, - }, - }, - ], - meta: { - someMeta: 'someField', - }, - timeline_id: 'some-timeline-id', - timeline_title: 'some-timeline-title', - to: 'now', - type: 'query', - note: '# Investigative notes', - version: 1, - }; - expect(rule).toEqual(expected); + const { query, ...expectedWithoutQuery } = getOutputRuleAlertForRest(); + expect(rule).toEqual(expectedWithoutQuery); }); test('should omit query if query is undefined', () => { const fullRule = getResult(); fullRule.params.query = undefined; const rule = transformAlertToRule(fullRule); - const expected: Partial = { - created_by: 'elastic', - created_at: '2019-12-13T16:40:33.400Z', - updated_at: '2019-12-13T16:40:33.400Z', - description: 'Detecting root and admin users', - enabled: true, - false_positives: [], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - output_index: '.siem-signals', - interval: '5m', - rule_id: 'rule-1', - risk_score: 50, - language: 'kuery', - max_signals: 100, - name: 'Detect Root/Admin Users', - references: ['http://www.example.com', 'https://ww.example.com'], - severity: 'high', - updated_by: 'elastic', - tags: [], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: 'TA0040', - name: 'impact', - reference: 'https://attack.mitre.org/tactics/TA0040/', - }, - technique: [ - { - id: 'T1499', - name: 'endpoint denial of service', - reference: 'https://attack.mitre.org/techniques/T1499/', - }, - ], - }, - ], - filters: [ - { - query: { - match_phrase: { - 'host.name': 'some-host', - }, - }, - }, - ], - meta: { - someMeta: 'someField', - }, - timeline_id: 'some-timeline-id', - timeline_title: 'some-timeline-title', - to: 'now', - type: 'query', - note: '# Investigative notes', - version: 1, - }; - expect(rule).toEqual(expected); + const { query, ...expectedWithoutQuery } = getOutputRuleAlertForRest(); + expect(rule).toEqual(expectedWithoutQuery); }); test('should omit a mix of undefined, null, and missing fields', () => { const fullRule = getResult(); fullRule.params.query = undefined; fullRule.params.language = null; - const { from, enabled, ...omitData } = transformAlertToRule(fullRule); - const expected: Partial = { - created_by: 'elastic', - created_at: '2019-12-13T16:40:33.400Z', - updated_at: '2019-12-13T16:40:33.400Z', - description: 'Detecting root and admin users', - false_positives: [], - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - output_index: '.siem-signals', - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - rule_id: 'rule-1', - risk_score: 50, - max_signals: 100, - name: 'Detect Root/Admin Users', - references: ['http://www.example.com', 'https://ww.example.com'], - severity: 'high', - updated_by: 'elastic', - tags: [], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: 'TA0040', - name: 'impact', - reference: 'https://attack.mitre.org/tactics/TA0040/', - }, - technique: [ - { - id: 'T1499', - name: 'endpoint denial of service', - reference: 'https://attack.mitre.org/techniques/T1499/', - }, - ], - }, - ], - filters: [ - { - query: { - match_phrase: { - 'host.name': 'some-host', - }, - }, - }, - ], - meta: { - someMeta: 'someField', - }, - timeline_id: 'some-timeline-id', - timeline_title: 'some-timeline-title', - to: 'now', - type: 'query', - note: '# Investigative notes', - version: 1, - }; - expect(omitData).toEqual(expected); + const { from, ...omitParams } = fullRule.params; + fullRule.params = omitParams as RuleTypeParams; + const { enabled, ...omitEnabled } = fullRule; + const rule = transformAlertToRule(omitEnabled as RuleAlertType); + const { + from: from2, + enabled: enabled2, + language, + query, + ...expectedWithoutFromEnabledLanguageQuery + } = getOutputRuleAlertForRest(); + expect(rule).toEqual(expectedWithoutFromEnabledLanguageQuery); }); test('should return enabled is equal to false', () => { const fullRule = getResult(); fullRule.enabled = false; const ruleWithEnabledFalse = transformAlertToRule(fullRule); - const expected: OutputRuleAlertRest = { - created_by: 'elastic', - created_at: '2019-12-13T16:40:33.400Z', - updated_at: '2019-12-13T16:40:33.400Z', - description: 'Detecting root and admin users', - enabled: false, - from: 'now-6m', - false_positives: [], - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - output_index: '.siem-signals', - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - risk_score: 50, - rule_id: 'rule-1', - max_signals: 100, - name: 'Detect Root/Admin Users', - query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], - severity: 'high', - updated_by: 'elastic', - tags: [], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: 'TA0040', - name: 'impact', - reference: 'https://attack.mitre.org/tactics/TA0040/', - }, - technique: [ - { - id: 'T1499', - name: 'endpoint denial of service', - reference: 'https://attack.mitre.org/techniques/T1499/', - }, - ], - }, - ], - filters: [ - { - query: { - match_phrase: { - 'host.name': 'some-host', - }, - }, - }, - ], - meta: { - someMeta: 'someField', - }, - timeline_id: 'some-timeline-id', - timeline_title: 'some-timeline-title', - to: 'now', - type: 'query', - note: '# Investigative notes', - version: 1, - }; + const expected = getOutputRuleAlertForRest(); + expected.enabled = false; expect(ruleWithEnabledFalse).toEqual(expected); }); @@ -424,65 +109,7 @@ describe('utils', () => { const fullRule = getResult(); fullRule.params.immutable = false; const ruleWithEnabledFalse = transformAlertToRule(fullRule); - const expected: OutputRuleAlertRest = { - created_by: 'elastic', - created_at: '2019-12-13T16:40:33.400Z', - updated_at: '2019-12-13T16:40:33.400Z', - description: 'Detecting root and admin users', - enabled: true, - from: 'now-6m', - false_positives: [], - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - output_index: '.siem-signals', - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - risk_score: 50, - rule_id: 'rule-1', - max_signals: 100, - name: 'Detect Root/Admin Users', - query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], - severity: 'high', - updated_by: 'elastic', - tags: [], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: 'TA0040', - name: 'impact', - reference: 'https://attack.mitre.org/tactics/TA0040/', - }, - technique: [ - { - id: 'T1499', - name: 'endpoint denial of service', - reference: 'https://attack.mitre.org/techniques/T1499/', - }, - ], - }, - ], - filters: [ - { - query: { - match_phrase: { - 'host.name': 'some-host', - }, - }, - }, - ], - meta: { - someMeta: 'someField', - }, - timeline_id: 'some-timeline-id', - timeline_title: 'some-timeline-title', - to: 'now', - type: 'query', - note: '# Investigative notes', - version: 1, - }; + const expected = getOutputRuleAlertForRest(); expect(ruleWithEnabledFalse).toEqual(expected); }); @@ -490,67 +117,26 @@ describe('utils', () => { const fullRule = getResult(); fullRule.tags = ['tag 1', 'tag 2', `${INTERNAL_IDENTIFIER}_some_other_value`]; const rule = transformAlertToRule(fullRule); - const expected: OutputRuleAlertRest = { - created_at: '2019-12-13T16:40:33.400Z', - updated_at: '2019-12-13T16:40:33.400Z', - created_by: 'elastic', - description: 'Detecting root and admin users', - enabled: true, - false_positives: [], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - risk_score: 50, - rule_id: 'rule-1', - language: 'kuery', - max_signals: 100, - name: 'Detect Root/Admin Users', - output_index: '.siem-signals', - query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], - severity: 'high', - updated_by: 'elastic', - tags: ['tag 1', 'tag 2'], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: 'TA0040', - name: 'impact', - reference: 'https://attack.mitre.org/tactics/TA0040/', - }, - technique: [ - { - id: 'T1499', - name: 'endpoint denial of service', - reference: 'https://attack.mitre.org/techniques/T1499/', - }, - ], - }, - ], - filters: [ - { - query: { - match_phrase: { - 'host.name': 'some-host', - }, - }, - }, - ], - meta: { - someMeta: 'someField', - }, - timeline_id: 'some-timeline-id', - timeline_title: 'some-timeline-title', - to: 'now', - type: 'query', - note: '# Investigative notes', - version: 1, - }; + const expected = getOutputRuleAlertForRest(); + expected.tags = ['tag 1', 'tag 2']; expect(rule).toEqual(expected); }); + + it('transforms ML Rule fields', () => { + const mlRule = getResult(); + mlRule.params.anomalyThreshold = 55; + mlRule.params.machineLearningJobId = 'some_job_id'; + mlRule.params.type = 'machine_learning'; + + const rule = transformAlertToRule(mlRule); + expect(rule).toEqual( + expect.objectContaining({ + anomaly_threshold: 55, + machine_learning_job_id: 'some_job_id', + type: 'machine_learning', + }) + ); + }); }); describe('getIdError', () => { @@ -640,65 +226,7 @@ describe('utils', () => { total: 0, data: [getResult()], }); - const expected: OutputRuleAlertRest = { - created_by: 'elastic', - created_at: '2019-12-13T16:40:33.400Z', - updated_at: '2019-12-13T16:40:33.400Z', - description: 'Detecting root and admin users', - enabled: true, - false_positives: [], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - output_index: '.siem-signals', - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - risk_score: 50, - rule_id: 'rule-1', - language: 'kuery', - max_signals: 100, - name: 'Detect Root/Admin Users', - query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], - severity: 'high', - updated_by: 'elastic', - tags: [], - to: 'now', - type: 'query', - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: 'TA0040', - name: 'impact', - reference: 'https://attack.mitre.org/tactics/TA0040/', - }, - technique: [ - { - id: 'T1499', - name: 'endpoint denial of service', - reference: 'https://attack.mitre.org/techniques/T1499/', - }, - ], - }, - ], - filters: [ - { - query: { - match_phrase: { - 'host.name': 'some-host', - }, - }, - }, - ], - meta: { - someMeta: 'someField', - }, - timeline_id: 'some-timeline-id', - timeline_title: 'some-timeline-title', - note: '# Investigative notes', - version: 1, - }; + const expected = getOutputRuleAlertForRest(); expect(output).toEqual({ page: 1, perPage: 0, @@ -722,65 +250,7 @@ describe('utils', () => { describe('transform', () => { test('outputs 200 if the data is of type siem alert', () => { const output = transform(getResult()); - const expected: OutputRuleAlertRest = { - created_by: 'elastic', - created_at: '2019-12-13T16:40:33.400Z', - updated_at: '2019-12-13T16:40:33.400Z', - description: 'Detecting root and admin users', - enabled: true, - false_positives: [], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - output_index: '.siem-signals', - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - rule_id: 'rule-1', - risk_score: 50, - language: 'kuery', - max_signals: 100, - name: 'Detect Root/Admin Users', - query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], - severity: 'high', - updated_by: 'elastic', - tags: [], - to: 'now', - type: 'query', - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: 'TA0040', - name: 'impact', - reference: 'https://attack.mitre.org/tactics/TA0040/', - }, - technique: [ - { - id: 'T1499', - name: 'endpoint denial of service', - reference: 'https://attack.mitre.org/techniques/T1499/', - }, - ], - }, - ], - filters: [ - { - query: { - match_phrase: { - 'host.name': 'some-host', - }, - }, - }, - ], - meta: { - someMeta: 'someField', - }, - timeline_id: 'some-timeline-id', - timeline_title: 'some-timeline-title', - note: '# Investigative notes', - version: 1, - }; + const expected = getOutputRuleAlertForRest(); expect(output).toEqual(expected); }); @@ -895,65 +365,7 @@ describe('utils', () => { describe('transformOrBulkError', () => { test('outputs 200 if the data is of type siem alert', () => { const output = transformOrBulkError('rule-1', getResult()); - const expected: OutputRuleAlertRest = { - created_by: 'elastic', - created_at: '2019-12-13T16:40:33.400Z', - updated_at: '2019-12-13T16:40:33.400Z', - description: 'Detecting root and admin users', - enabled: true, - false_positives: [], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - output_index: '.siem-signals', - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - rule_id: 'rule-1', - risk_score: 50, - language: 'kuery', - max_signals: 100, - name: 'Detect Root/Admin Users', - query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], - severity: 'high', - updated_by: 'elastic', - tags: [], - to: 'now', - type: 'query', - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: 'TA0040', - name: 'impact', - reference: 'https://attack.mitre.org/tactics/TA0040/', - }, - technique: [ - { - id: 'T1499', - name: 'endpoint denial of service', - reference: 'https://attack.mitre.org/techniques/T1499/', - }, - ], - }, - ], - filters: [ - { - query: { - match_phrase: { - 'host.name': 'some-host', - }, - }, - }, - ], - meta: { - someMeta: 'someField', - }, - timeline_id: 'some-timeline-id', - timeline_title: 'some-timeline-title', - note: '# Investigative notes', - version: 1, - }; + const expected = getOutputRuleAlertForRest(); expect(output).toEqual(expected); }); @@ -968,15 +380,15 @@ describe('utils', () => { }); }); - describe('transformRulesToNdjson', () => { + describe('transformDataToNdjson', () => { test('if rules are empty it returns an empty string', () => { - const ruleNdjson = transformRulesToNdjson([]); + const ruleNdjson = transformDataToNdjson([]); expect(ruleNdjson).toEqual(''); }); test('single rule will transform with new line ending character for ndjson', () => { const rule = sampleRule(); - const ruleNdjson = transformRulesToNdjson([rule]); + const ruleNdjson = transformDataToNdjson([rule]); expect(ruleNdjson.endsWith('\n')).toBe(true); }); @@ -987,7 +399,7 @@ describe('utils', () => { result2.rule_id = 'some other id'; result2.name = 'Some other rule'; - const ruleNdjson = transformRulesToNdjson([result1, result2]); + const ruleNdjson = transformDataToNdjson([result1, result2]); // this is how we count characters in JavaScript :-) const count = ruleNdjson.split('\n').length - 1; expect(count).toBe(2); @@ -1000,7 +412,7 @@ describe('utils', () => { result2.rule_id = 'some other id'; result2.name = 'Some other rule'; - const ruleNdjson = transformRulesToNdjson([result1, result2]); + const ruleNdjson = transformDataToNdjson([result1, result2]); const ruleStrings = ruleNdjson.split('\n'); const reParsed1 = JSON.parse(ruleStrings[0]); const reParsed2 = JSON.parse(ruleStrings[1]); @@ -1017,57 +429,8 @@ describe('utils', () => { test('given single alert will return the alert transformed', () => { const result1 = getResult(); const transformed = transformAlertsToRules([result1]); - expect(transformed).toEqual([ - { - created_at: '2019-12-13T16:40:33.400Z', - created_by: 'elastic', - description: 'Detecting root and admin users', - enabled: true, - false_positives: [], - filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - max_signals: 100, - meta: { someMeta: 'someField' }, - name: 'Detect Root/Admin Users', - output_index: '.siem-signals', - query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], - risk_score: 50, - rule_id: 'rule-1', - severity: 'high', - tags: [], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: 'TA0040', - name: 'impact', - reference: 'https://attack.mitre.org/tactics/TA0040/', - }, - technique: [ - { - id: 'T1499', - name: 'endpoint denial of service', - reference: 'https://attack.mitre.org/techniques/T1499/', - }, - ], - }, - ], - timeline_id: 'some-timeline-id', - timeline_title: 'some-timeline-title', - to: 'now', - type: 'query', - updated_at: '2019-12-13T16:40:33.400Z', - updated_by: 'elastic', - note: '# Investigative notes', - version: 1, - }, - ]); + const expected = getOutputRuleAlertForRest(); + expect(transformed).toEqual([expected]); }); test('given two alerts will return the two alerts transformed', () => { @@ -1077,106 +440,11 @@ describe('utils', () => { result2.params.ruleId = 'some other id'; const transformed = transformAlertsToRules([result1, result2]); - expect(transformed).toEqual([ - { - created_at: '2019-12-13T16:40:33.400Z', - created_by: 'elastic', - description: 'Detecting root and admin users', - enabled: true, - false_positives: [], - filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - max_signals: 100, - meta: { someMeta: 'someField' }, - name: 'Detect Root/Admin Users', - output_index: '.siem-signals', - query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], - risk_score: 50, - rule_id: 'rule-1', - severity: 'high', - tags: [], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: 'TA0040', - name: 'impact', - reference: 'https://attack.mitre.org/tactics/TA0040/', - }, - technique: [ - { - id: 'T1499', - name: 'endpoint denial of service', - reference: 'https://attack.mitre.org/techniques/T1499/', - }, - ], - }, - ], - timeline_id: 'some-timeline-id', - timeline_title: 'some-timeline-title', - to: 'now', - type: 'query', - updated_at: '2019-12-13T16:40:33.400Z', - updated_by: 'elastic', - note: '# Investigative notes', - version: 1, - }, - { - created_at: '2019-12-13T16:40:33.400Z', - created_by: 'elastic', - description: 'Detecting root and admin users', - enabled: true, - false_positives: [], - filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], - from: 'now-6m', - id: 'some other id', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - language: 'kuery', - max_signals: 100, - meta: { someMeta: 'someField' }, - name: 'Detect Root/Admin Users', - output_index: '.siem-signals', - query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], - risk_score: 50, - rule_id: 'some other id', - severity: 'high', - tags: [], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: 'TA0040', - name: 'impact', - reference: 'https://attack.mitre.org/tactics/TA0040/', - }, - technique: [ - { - id: 'T1499', - name: 'endpoint denial of service', - reference: 'https://attack.mitre.org/techniques/T1499/', - }, - ], - }, - ], - timeline_id: 'some-timeline-id', - timeline_title: 'some-timeline-title', - to: 'now', - type: 'query', - updated_at: '2019-12-13T16:40:33.400Z', - updated_by: 'elastic', - note: '# Investigative notes', - version: 1, - }, - ]); + const expected1 = getOutputRuleAlertForRest(); + const expected2 = getOutputRuleAlertForRest(); + expected2.id = 'some other id'; + expected2.rule_id = 'some other id'; + expect(transformed).toEqual([expected1, expected2]); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts index ecf669b0106c3..e0ecbdedaac7c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts @@ -28,6 +28,8 @@ import { createImportErrorObject, OutputError, } from '../utils'; +import { hasListsFeature } from '../../feature_flags'; +import { transformAlertToRuleAction } from '../../rules/transform_actions'; type PromiseFromStreams = ImportRuleAlertRest | Error; @@ -101,11 +103,13 @@ export const transformAlertToRule = ( ruleStatus?: SavedObject ): Partial => { return pickBy((value: unknown) => value != null, { + actions: alert.actions.map(transformAlertToRuleAction), created_at: alert.createdAt.toISOString(), updated_at: alert.updatedAt.toISOString(), created_by: alert.createdBy, description: alert.params.description, enabled: alert.enabled, + anomaly_threshold: alert.params.anomalyThreshold, false_positives: alert.params.falsePositives, filters: alert.params.filters, from: alert.params.from, @@ -117,6 +121,7 @@ export const transformAlertToRule = ( language: alert.params.language, output_index: alert.params.outputIndex, max_signals: alert.params.maxSignals, + machine_learning_job_id: alert.params.machineLearningJobId, risk_score: alert.params.riskScore, name: alert.name, query: alert.params.query, @@ -131,6 +136,7 @@ export const transformAlertToRule = ( to: alert.params.to, type: alert.params.type, threat: alert.params.threat, + throttle: alert.throttle, note: alert.params.note, version: alert.params.version, status: ruleStatus?.attributes.status, @@ -139,13 +145,15 @@ export const transformAlertToRule = ( last_success_at: ruleStatus?.attributes.lastSuccessAt, last_failure_message: ruleStatus?.attributes.lastFailureMessage, last_success_message: ruleStatus?.attributes.lastSuccessMessage, + // TODO: (LIST-FEATURE) Remove hasListsFeature() check once we have lists available for a release + lists: hasListsFeature() ? alert.params.lists : null, }); }; -export const transformRulesToNdjson = (rules: Array>): string => { - if (rules.length !== 0) { - const rulesString = rules.map(rule => JSON.stringify(rule)).join('\n'); - return `${rulesString}\n`; +export const transformDataToNdjson = (data: unknown[]): string => { + if (data.length !== 0) { + const dataString = data.map(rule => JSON.stringify(rule)).join('\n'); + return `${dataString}\n`; } else { return ''; } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/validate.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/validate.test.ts index ba6c702e9601b..3727908ac62de 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/validate.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/validate.test.ts @@ -16,8 +16,10 @@ import { getResult } from '../__mocks__/request_responses'; import { FindResult } from '../../../../../../../../plugins/alerting/server'; import { RulesSchema } from '../schemas/response/rules_schema'; import { BulkError } from '../utils'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; export const ruleOutput: RulesSchema = { + actions: [], created_at: '2019-12-13T16:40:33.400Z', updated_at: '2019-12-13T16:40:33.400Z', created_by: 'elastic', @@ -68,6 +70,32 @@ export const ruleOutput: RulesSchema = { }, }, ], + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], meta: { someMeta: 'someField', @@ -78,6 +106,14 @@ export const ruleOutput: RulesSchema = { }; describe('validate', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + describe('validate', () => { test('it should do a validation correctly', () => { const schema = t.exact(t.type({ a: t.number })); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts index a002cc9324012..2b18e1b9bf52c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts @@ -4,10 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ThreatParams, PrepackagedRules } from '../../types'; +import { AlertAction } from '../../../../../../../../plugins/alerting/common'; +import { ThreatParams, PrepackagedRules, RuleAlertAction } from '../../types'; import { addPrepackagedRulesSchema } from './add_prepackaged_rules_schema'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('add prepackaged rules schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('empty objects do not validate', () => { expect(addPrepackagedRulesSchema.validate>({}).error).toBeTruthy(); }); @@ -1275,6 +1285,200 @@ describe('add prepackaged rules schema', () => { ); }); + test('The default for "actions" will be an empty array', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + index: ['auditbeat-*'], + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).value.actions + ).toEqual([]); + }); + + test('You cannot send in an array of actions that are missing "group"', () => { + expect( + addPrepackagedRulesSchema.validate< + Partial> & { + actions: Array>; + } + >({ + actions: [ + { + id: 'id', + action_type_id: 'actionTypeId', + params: {}, + }, + ], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "group" fails because ["group" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "id"', () => { + expect( + addPrepackagedRulesSchema.validate< + Partial> & { + actions: Array>; + } + >({ + actions: [ + { + group: 'group', + action_type_id: 'action_type_id', + params: {}, + }, + ], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "id" fails because ["id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "action_type_id"', () => { + expect( + addPrepackagedRulesSchema.validate< + Partial> & { + actions: Array>; + } + >({ + actions: [ + { + group: 'group', + id: 'id', + params: {}, + }, + ], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "params"', () => { + expect( + addPrepackagedRulesSchema.validate< + Partial> & { + actions: Array>; + } + >({ + actions: [ + { + group: 'group', + id: 'id', + action_type_id: 'action_type_id', + }, + ], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "params" fails because ["params" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are including "actionTypeId', () => { + expect( + addPrepackagedRulesSchema.validate< + Partial> & { + actions: AlertAction[]; + } + >({ + actions: [ + { + group: 'group', + id: 'id', + actionTypeId: 'actionTypeId', + params: {}, + }, + ], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + test('The default for "throttle" will be null', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + index: ['auditbeat-*'], + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).value.throttle + ).toEqual(null); + }); + describe('note', () => { test('You can set note to any string you want', () => { expect( @@ -1332,4 +1536,116 @@ describe('add prepackaged rules schema', () => { ).toEqual('child "note" fails because ["note" must be a string]'); }); }); + + // TODO: (LIST-FEATURE) We can enable this once we change the schema's to not be global per module but rather functions that can create the schema + // on demand. Since they are per module, we have a an issue where the ENV variables do not take effect. It is better we change all the + // schema's to be function calls to avoid global side effects or just wait until the feature is available. If you want to test this early, + // you can remove the .skip and set your env variable of export ELASTIC_XPACK_SIEM_LISTS_FEATURE=true locally + describe.skip('lists', () => { + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and lists] does validate', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + version: 1, + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and empty lists] does validate', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + lists: [], + version: 1, + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and invalid lists] does NOT validate', () => { + expect( + addPrepackagedRulesSchema.validate>>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + lists: [{ invalid_value: 'invalid value' }], + version: 1, + }).error.message + ).toEqual( + 'child "lists" fails because ["lists" at position 0 fails because [child "field" fails because ["field" is required]]]' + ); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and non-existent lists] does validate with empty lists', () => { + expect( + addPrepackagedRulesSchema.validate>>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + version: 1, + }).value.lists + ).toEqual([]); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.ts index 974ddcf35eeb4..da9f9777a01a6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.ts @@ -8,6 +8,7 @@ import Joi from 'joi'; /* eslint-disable @typescript-eslint/camelcase */ import { + actions, enabled, description, false_positives, @@ -31,13 +32,18 @@ import { to, type, threat, + throttle, references, note, version, + lists, + anomaly_threshold, + machine_learning_job_id, } from './schemas'; /* eslint-enable @typescript-eslint/camelcase */ import { DEFAULT_MAX_SIGNALS } from '../../../../../common/constants'; +import { hasListsFeature } from '../../feature_flags'; /** * Big differences between this schema and the createRulesSchema @@ -49,6 +55,12 @@ import { DEFAULT_MAX_SIGNALS } from '../../../../../common/constants'; * - index is a required field that must exist */ export const addPrepackagedRulesSchema = Joi.object({ + actions: actions.default([]), + anomaly_threshold: anomaly_threshold.when('type', { + is: 'machine_learning', + then: Joi.required(), + otherwise: Joi.forbidden(), + }), description: description.required(), enabled: enabled.default(false), false_positives: false_positives.default([]), @@ -61,8 +73,21 @@ export const addPrepackagedRulesSchema = Joi.object({ .valid(true), index: index.required(), interval: interval.default('5m'), - query: query.allow('').default(''), - language: language.default('kuery'), + query: query.when('type', { + is: 'machine_learning', + then: Joi.forbidden(), + otherwise: query.allow('').default(''), + }), + language: language.when('type', { + is: 'machine_learning', + then: Joi.forbidden(), + otherwise: language.default('kuery'), + }), + machine_learning_job_id: machine_learning_job_id.when('type', { + is: 'machine_learning', + then: Joi.required(), + otherwise: Joi.forbidden(), + }), saved_id: saved_id.when('type', { is: 'saved_query', then: Joi.required(), @@ -79,7 +104,11 @@ export const addPrepackagedRulesSchema = Joi.object({ to: to.default('now'), type: type.required(), threat: threat.default([]), + throttle: throttle.default(null), references: references.default([]), note: note.allow(''), version: version.required(), + + // TODO: (LIST-FEATURE) Remove the hasListsFeatures once this is ready for release + lists: hasListsFeature() ? lists.default([]) : lists.forbidden().default([]), }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_bulk_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_bulk_schema.test.ts index 6512bfdc4361f..0bf59759a6db6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_bulk_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_bulk_schema.test.ts @@ -6,11 +6,20 @@ import { createRulesBulkSchema } from './create_rules_bulk_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; // only the basics of testing are here. // see: create_rules_schema.test.ts for the bulk of the validation tests // this just wraps createRulesSchema in an array describe('create_rules_bulk_schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('can take an empty array and validate it', () => { expect( createRulesBulkSchema.validate>>([]).error @@ -208,4 +217,44 @@ describe('create_rules_bulk_schema', () => { '"value" at position 0 fails because [child "note" fails because ["note" must be a string]]' ); }); + + test('The default for "actions" will be an empty array', () => { + expect( + createRulesBulkSchema.validate>([ + { + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }, + ]).value[0].actions + ).toEqual([]); + }); + + test('The default for "throttle" will be null', () => { + expect( + createRulesBulkSchema.validate>([ + { + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }, + ]).value[0].throttle + ).toEqual(null); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts index 3bad87dc1a9ad..d9c3055512815 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts @@ -4,11 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { createRulesSchema } from './create_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertParamsRest } from '../../types'; +import { ThreatParams, RuleAlertParamsRest, RuleAlertAction } from '../../types'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('create rules schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('empty objects do not validate', () => { expect(createRulesSchema.validate>({}).error).toBeTruthy(); }); @@ -1225,6 +1235,184 @@ describe('create rules schema', () => { ); }); + test('The default for "actions" will be an empty array', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).value.actions + ).toEqual([]); + }); + + test('You cannot send in an array of actions that are missing "group"', () => { + expect( + createRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ id: 'id', action_type_id: 'action_type_id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "group" fails because ["group" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "id"', () => { + expect( + createRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', action_type_id: 'action_type_id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "id" fails because ["id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "action_type_id"', () => { + expect( + createRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', id: 'id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "params"', () => { + expect( + createRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', id: 'id', action_type_id: 'action_type_id' }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "params" fails because ["params" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are including "actionTypeId"', () => { + expect( + createRulesSchema.validate< + Partial< + Omit & { + actions: AlertAction[]; + } + > + >({ + actions: [ + { + group: 'group', + id: 'id', + actionTypeId: 'actionTypeId', + params: {}, + }, + ], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + test('The default for "throttle" will be null', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).value.throttle + ).toEqual(null); + }); + describe('note', () => { test('You can set note to a string', () => { expect( @@ -1314,5 +1502,113 @@ describe('create rules schema', () => { }).error ).toBeFalsy(); }); + + // TODO: (LIST-FEATURE) We can enable this once we change the schema's to not be global per module but rather functions that can create the schema + // on demand. Since they are per module, we have a an issue where the ENV variables do not take effect. It is better we change all the + // schema's to be function calls to avoid global side effects or just wait until the feature is available. If you want to test this early, + // you can remove the .skip and set your env variable of export ELASTIC_XPACK_SIEM_LISTS_FEATURE=true locally + describe.skip('lists', () => { + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and lists] does validate', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and empty lists] does validate', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + lists: [], + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and invalid lists] does NOT validate', () => { + expect( + createRulesSchema.validate>>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + lists: [{ invalid_value: 'invalid value' }], + }).error.message + ).toEqual( + 'child "lists" fails because ["lists" at position 0 fails because [child "field" fails because ["field" is required]]]' + ); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and non-existent lists] does validate with empty lists', () => { + expect( + createRulesSchema.validate>>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + }).value.lists + ).toEqual([]); + }); + }); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.ts index c9b380d3c67e1..5213f3faaf486 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.ts @@ -8,6 +8,8 @@ import Joi from 'joi'; /* eslint-disable @typescript-eslint/camelcase */ import { + actions, + anomaly_threshold, enabled, description, false_positives, @@ -31,15 +33,25 @@ import { to, type, threat, + throttle, references, note, version, + lists, + machine_learning_job_id, } from './schemas'; /* eslint-enable @typescript-eslint/camelcase */ import { DEFAULT_MAX_SIGNALS } from '../../../../../common/constants'; +import { hasListsFeature } from '../../feature_flags'; export const createRulesSchema = Joi.object({ + actions: actions.default([]), + anomaly_threshold: anomaly_threshold.when('type', { + is: 'machine_learning', + then: Joi.required(), + otherwise: Joi.forbidden(), + }), description: description.required(), enabled: enabled.default(true), false_positives: false_positives.default([]), @@ -48,8 +60,16 @@ export const createRulesSchema = Joi.object({ rule_id, index, interval: interval.default('5m'), - query: query.allow('').default(''), - language: language.default('kuery'), + query: query.when('type', { + is: 'machine_learning', + then: Joi.forbidden(), + otherwise: query.allow('').default(''), + }), + language: language.when('type', { + is: 'machine_learning', + then: Joi.forbidden(), + otherwise: language.default('kuery'), + }), output_index, saved_id: saved_id.when('type', { is: 'saved_query', @@ -59,6 +79,11 @@ export const createRulesSchema = Joi.object({ timeline_id, timeline_title, meta, + machine_learning_job_id: machine_learning_job_id.when('type', { + is: 'machine_learning', + then: Joi.required(), + otherwise: Joi.forbidden(), + }), risk_score: risk_score.required(), max_signals: max_signals.default(DEFAULT_MAX_SIGNALS), name: name.required(), @@ -67,7 +92,11 @@ export const createRulesSchema = Joi.object({ to: to.default('now'), type: type.required(), threat: threat.default([]), + throttle: throttle.default(null), references: references.default([]), note: note.allow(''), version: version.default(1), + + // TODO: (LIST-FEATURE) Remove the hasListsFeatures once this is ready for release + lists: hasListsFeature() ? lists.default([]) : lists.forbidden().default([]), }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/export_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/export_rules_schema.test.ts index 621dcd8fa8ed4..0e71237f75232 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/export_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/export_rules_schema.test.ts @@ -6,8 +6,17 @@ import { exportRulesSchema, exportRulesQuerySchema } from './export_rules_schema'; import { ExportRulesRequestParams } from '../../rules/types'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('create rules schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + describe('exportRulesSchema', () => { test('null value or absent values validate', () => { expect(exportRulesSchema.validate(null).error).toBeFalsy(); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/find_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/find_rules_schema.test.ts index 339874e19c33a..ffbfd193873a8 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/find_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/find_rules_schema.test.ts @@ -6,8 +6,17 @@ import { findRulesSchema } from './find_rules_schema'; import { FindParamsRest } from '../../rules/types'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('find rules schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('empty objects do validate', () => { expect(findRulesSchema.validate>({}).error).toBeFalsy(); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts index 9c80ddde9e7b7..ffb49896ef7c7 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts @@ -4,15 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { importRulesSchema, importRulesQuerySchema, importRulesPayloadSchema, } from './import_rules_schema'; -import { ThreatParams, ImportRuleAlertRest } from '../../types'; +import { ThreatParams, ImportRuleAlertRest, RuleAlertAction } from '../../types'; import { ImportRulesRequestParams } from '../../rules/types'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('import rules schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + describe('importRulesSchema', () => { test('empty objects do not validate', () => { expect(importRulesSchema.validate>({}).error).toBeTruthy(); @@ -1424,6 +1434,184 @@ describe('import rules schema', () => { ); }); + test('The default for "actions" will be an empty array', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).value.actions + ).toEqual([]); + }); + + test('You cannot send in an array of actions that are missing "group"', () => { + expect( + importRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ id: 'id', action_type_id: 'action_type_id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'junk', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "group" fails because ["group" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "id"', () => { + expect( + importRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', action_type_id: 'action_type_id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "id" fails because ["id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "action_type_id"', () => { + expect( + importRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', id: 'id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "params"', () => { + expect( + importRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', id: 'id', action_type_id: 'action_type_id' }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "params" fails because ["params" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are including "actionTypeId', () => { + expect( + importRulesSchema.validate< + Partial< + Omit & { + actions: AlertAction[]; + } + > + >({ + actions: [ + { + group: 'group', + id: 'id', + actionTypeId: 'actionTypeId', + params: {}, + }, + ], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + test('The default for "throttle" will be null', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).value.throttle + ).toEqual(null); + }); + describe('note', () => { test('You can set note to a string', () => { expect( @@ -1535,4 +1723,112 @@ describe('import rules schema', () => { ).toEqual('child "note" fails because ["note" must be a string]'); }); }); + + // TODO: (LIST-FEATURE) We can enable this once we change the schema's to not be global per module but rather functions that can create the schema + // on demand. Since they are per module, we have a an issue where the ENV variables do not take effect. It is better we change all the + // schema's to be function calls to avoid global side effects or just wait until the feature is available. If you want to test this early, + // you can remove the .skip and set your env variable of export ELASTIC_XPACK_SIEM_LISTS_FEATURE=true locally + describe.skip('lists', () => { + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and lists] does validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and empty lists] does validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + lists: [], + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and invalid lists] does NOT validate and lists is empty', () => { + expect( + importRulesSchema.validate>>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + lists: [{ invalid_value: 'invalid value' }], + }).error.message + ).toEqual( + 'child "lists" fails because ["lists" at position 0 fails because [child "field" fails because ["field" is required]]]' + ); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and non-existent lists] does validate', () => { + expect( + importRulesSchema.validate>>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + }).value.lists + ).toEqual([]); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.ts index bd12872c4dc72..56aa45659fda7 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.ts @@ -9,6 +9,7 @@ import Joi from 'joi'; /* eslint-disable @typescript-eslint/camelcase */ import { id, + actions, created_at, updated_at, created_by, @@ -37,13 +38,18 @@ import { to, type, threat, + throttle, references, note, version, + lists, + anomaly_threshold, + machine_learning_job_id, } from './schemas'; /* eslint-enable @typescript-eslint/camelcase */ import { DEFAULT_MAX_SIGNALS } from '../../../../../common/constants'; +import { hasListsFeature } from '../../feature_flags'; /** * Differences from this and the createRulesSchema are @@ -55,7 +61,13 @@ import { DEFAULT_MAX_SIGNALS } from '../../../../../common/constants'; * - updated_by is optional (but ignored in the import code) */ export const importRulesSchema = Joi.object({ + anomaly_threshold: anomaly_threshold.when('type', { + is: 'machine_learning', + then: Joi.required(), + otherwise: Joi.forbidden(), + }), id, + actions: actions.default([]), description: description.required(), enabled: enabled.default(true), false_positives: false_positives.default([]), @@ -65,9 +77,22 @@ export const importRulesSchema = Joi.object({ immutable: immutable.default(false).valid(false), index, interval: interval.default('5m'), - query: query.allow('').default(''), - language: language.default('kuery'), + query: query.when('type', { + is: 'machine_learning', + then: Joi.forbidden(), + otherwise: query.allow('').default(''), + }), + language: language.when('type', { + is: 'machine_learning', + then: Joi.forbidden(), + otherwise: language.default('kuery'), + }), output_index, + machine_learning_job_id: machine_learning_job_id.when('type', { + is: 'machine_learning', + then: Joi.required(), + otherwise: Joi.forbidden(), + }), saved_id: saved_id.when('type', { is: 'saved_query', then: Joi.required(), @@ -84,6 +109,7 @@ export const importRulesSchema = Joi.object({ to: to.default('now'), type: type.required(), threat: threat.default([]), + throttle: throttle.default(null), references: references.default([]), note: note.allow(''), version: version.default(1), @@ -91,6 +117,9 @@ export const importRulesSchema = Joi.object({ updated_at, created_by, updated_by, + + // TODO: (LIST-FEATURE) Remove the hasListsFeatures once this is ready for release + lists: hasListsFeature() ? lists.default([]) : lists.forbidden().default([]), }); export const importRulesQuerySchema = Joi.object({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_bulk_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_bulk_schema.test.ts index 43d1e7ab2aa3b..e87c732e8a2f7 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_bulk_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_bulk_schema.test.ts @@ -6,11 +6,20 @@ import { patchRulesBulkSchema } from './patch_rules_bulk_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; // only the basics of testing are here. // see: patch_rules_schema.test.ts for the bulk of the validation tests // this just wraps patchRulesSchema in an array describe('patch_rules_bulk_schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('can take an empty array and validate it', () => { expect( patchRulesBulkSchema.validate>>([]).error diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts index ecdba7ccc0091..42945e0970cba 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts @@ -4,11 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { patchRulesSchema } from './patch_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams } from '../../types'; +import { ThreatParams, RuleAlertAction } from '../../types'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('patch rules schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('empty objects do not validate as they require at least id or rule_id', () => { expect(patchRulesSchema.validate>({}).error).toBeTruthy(); }); @@ -1053,4 +1063,288 @@ describe('patch rules schema', () => { ).toEqual('child "note" fails because ["note" must be a string]'); }); }); + + test('You cannot send in an array of actions that are missing "group"', () => { + expect( + patchRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ id: 'id', action_type_id: 'action_type_id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "group" fails because ["group" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "id"', () => { + expect( + patchRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', action_type_id: 'action_type_id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "id" fails because ["id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "action_type_id"', () => { + expect( + patchRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', id: 'id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "params"', () => { + expect( + patchRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', id: 'id', action_type_id: 'action_type_id' }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "params" fails because ["params" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are including "actionTypeId', () => { + expect( + patchRulesSchema.validate< + Partial< + Omit & { + actions: AlertAction[]; + } + > + >({ + actions: [ + { + group: 'group', + id: 'id', + actionTypeId: 'actionTypeId', + params: {}, + }, + ], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + // TODO: (LIST-FEATURE) We can enable this once we change the schema's to not be global per module but rather functions that can create the schema + // on demand. Since they are per module, we have a an issue where the ENV variables do not take effect. It is better we change all the + // schema's to be function calls to avoid global side effects or just wait until the feature is available. If you want to test this early, + // you can remove the .skip and set your env variable of export ELASTIC_XPACK_SIEM_LISTS_FEATURE=true locally + describe.skip('lists', () => { + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and lists] does validate', () => { + expect( + patchRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], + }).error + ).toBeFalsy(); + }); + + test('lists can be patched', () => { + expect( + patchRulesSchema.validate>({ + rule_id: 'some id', + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and empty lists] does validate', () => { + expect( + patchRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + lists: [], + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and invalid lists] does NOT validate', () => { + expect( + patchRulesSchema.validate>>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + lists: [{ invalid_value: 'invalid value' }], + }).error.message + ).toEqual( + 'child "lists" fails because ["lists" at position 0 fails because [child "field" fails because ["field" is required]]]' + ); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and non-existent lists] does validate with empty lists', () => { + expect( + patchRulesSchema.validate>>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + }).value.lists + ).toEqual([]); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.ts index 4d1b73fb69e5b..52aefa29884c3 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.ts @@ -8,6 +8,7 @@ import Joi from 'joi'; /* eslint-disable @typescript-eslint/camelcase */ import { + actions, enabled, description, false_positives, @@ -31,14 +32,21 @@ import { to, type, threat, + throttle, references, note, id, version, + lists, + anomaly_threshold, + machine_learning_job_id, } from './schemas'; +import { hasListsFeature } from '../../feature_flags'; /* eslint-enable @typescript-eslint/camelcase */ export const patchRulesSchema = Joi.object({ + actions, + anomaly_threshold, description, enabled, false_positives, @@ -50,6 +58,7 @@ export const patchRulesSchema = Joi.object({ interval, query: query.allow(''), language, + machine_learning_job_id, output_index, saved_id, timeline_id, @@ -63,7 +72,11 @@ export const patchRulesSchema = Joi.object({ to, type, threat, + throttle, references, note: note.allow(''), version, + + // TODO: (LIST-FEATURE) Remove the hasListsFeatures once this is ready for release + lists: hasListsFeature() ? lists.default([]) : lists.forbidden().default([]), }).xor('id', 'rule_id'); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_rules_bulk_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_rules_bulk_schema.test.ts index 7ea7fcbd1d86b..389c5ff7ea617 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_rules_bulk_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_rules_bulk_schema.test.ts @@ -6,11 +6,20 @@ import { queryRulesBulkSchema } from './query_rules_bulk_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; // only the basics of testing are here. // see: query_rules_bulk_schema.test.ts for the bulk of the validation tests // this just wraps queryRulesSchema in an array describe('query_rules_bulk_schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('can take an empty array and validate it', () => { expect( queryRulesBulkSchema.validate>>([]).error diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_rules_schema.test.ts index 0f392e399f36c..68be4c627780c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_rules_schema.test.ts @@ -6,8 +6,17 @@ import { queryRulesSchema } from './query_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('queryRulesSchema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('empty objects do not validate', () => { expect(queryRulesSchema.validate>({}).error).toBeTruthy(); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_signals_index_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_signals_index_schema.test.ts index 5c293f4825b95..4752d1794ff28 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_signals_index_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/query_signals_index_schema.test.ts @@ -6,8 +6,17 @@ import { querySignalsSchema } from './query_signals_index_schema'; import { SignalsQueryRestParams } from '../../signals/types'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('query, aggs, size, _source and track_total_hits on signals index', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('query, aggs, size, _source and track_total_hits simultaneously', () => { expect( querySignalsSchema.validate>({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/__mocks__/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/__mocks__/utils.ts index 05b85ffab7263..46cd1b653b5b4 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/__mocks__/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/__mocks__/utils.ts @@ -63,10 +63,48 @@ export const getBaseResponsePayload = (anchorDate: string = ANCHOR_DATE): RulesS language: 'kuery', rule_id: 'query-rule-id', interval: '5m', + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], }); export const getRulesBulkPayload = (): RulesBulkSchema => [getBaseResponsePayload()]; +export const getMlRuleResponsePayload = (anchorDate: string = ANCHOR_DATE): RulesSchema => { + const basePayload = getBaseResponsePayload(anchorDate); + const { filters, index, query, language, ...rest } = basePayload; + + return { + ...rest, + type: 'machine_learning', + anomaly_threshold: 59, + machine_learning_job_id: 'some_machine_learning_job_id', + }; +}; + export const getErrorPayload = ( id: string = '819eded6-e9c8-445b-a647-519aea39e063' ): ErrorSchema => ({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/check_type_dependents.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/check_type_dependents.test.ts index fc1c019ff97b5..0eda2a7a13d96 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/check_type_dependents.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/check_type_dependents.test.ts @@ -12,14 +12,30 @@ import { getDependents, addSavedId, addTimelineTitle, + addQueryFields, + addMlFields, } from './check_type_dependents'; -import { foldLeftRight, getBaseResponsePayload, getPaths } from './__mocks__/utils'; +import { + foldLeftRight, + getBaseResponsePayload, + getPaths, + getMlRuleResponsePayload, +} from './__mocks__/utils'; import { left } from 'fp-ts/lib/Either'; import { exactCheck } from './exact_check'; import { RulesSchema } from './rules_schema'; import { TypeAndTimelineOnly } from './type_timeline_only_schema'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../../feature_flags'; describe('check_type_dependents', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + describe('checkTypeDependents', () => { test('it should validate a type of "query" without anything extra', () => { const payload = getBaseResponsePayload(); @@ -375,6 +391,34 @@ describe('check_type_dependents', () => { ]); expect(message.schema).toEqual({}); }); + + test('it validates an ML rule response', () => { + const payload = getMlRuleResponsePayload(); + + const dependents = getDependents(payload); + const decoded = dependents.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + const expected = getMlRuleResponsePayload(); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(expected); + }); + + test('it rejects a response with both ML and query properties', () => { + const payload = { + ...getBaseResponsePayload(), + ...getMlRuleResponsePayload(), + }; + + const dependents = getDependents(payload); + const decoded = dependents.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['invalid keys "query,language"']); + expect(message.schema).toEqual({}); + }); }); describe('addSavedId', () => { @@ -402,4 +446,35 @@ describe('check_type_dependents', () => { expect(array.length).toEqual(2); }); }); + + describe('addQueryFields', () => { + test('should return empty array if type is not "query"', () => { + const fields = addQueryFields({ type: 'machine_learning' }); + const expected: t.Mixed[] = []; + expect(fields).toEqual(expected); + }); + + test('should return two fields for a rule of type "query"', () => { + const fields = addQueryFields({ type: 'query' }); + expect(fields.length).toEqual(2); + }); + + test('should return two fields for a rule of type "saved_query"', () => { + const fields = addQueryFields({ type: 'saved_query' }); + expect(fields.length).toEqual(2); + }); + }); + + describe('addMlFields', () => { + test('should return empty array if type is not "machine_learning"', () => { + const fields = addMlFields({ type: 'query' }); + const expected: t.Mixed[] = []; + expect(fields).toEqual(expected); + }); + + test('should return two fields for a rule of type "machine_learning"', () => { + const fields = addMlFields({ type: 'machine_learning' }); + expect(fields.length).toEqual(2); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/check_type_dependents.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/check_type_dependents.ts index 09142c8568b2d..b5a01e3e5c6df 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/check_type_dependents.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/check_type_dependents.ts @@ -35,12 +35,38 @@ export const addTimelineTitle = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mi } }; +export const addQueryFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed[] => { + if (typeAndTimelineOnly.type === 'query' || typeAndTimelineOnly.type === 'saved_query') { + return [ + t.exact(t.type({ query: dependentRulesSchema.props.query })), + t.exact(t.type({ language: dependentRulesSchema.props.language })), + ]; + } else { + return []; + } +}; + +export const addMlFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed[] => { + if (typeAndTimelineOnly.type === 'machine_learning') { + return [ + t.exact(t.type({ anomaly_threshold: dependentRulesSchema.props.anomaly_threshold })), + t.exact( + t.type({ machine_learning_job_id: dependentRulesSchema.props.machine_learning_job_id }) + ), + ]; + } else { + return []; + } +}; + export const getDependents = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed => { const dependents: t.Mixed[] = [ t.exact(requiredRulesSchema), t.exact(partialRulesSchema), ...addSavedId(typeAndTimelineOnly), ...addTimelineTitle(typeAndTimelineOnly), + ...addQueryFields(typeAndTimelineOnly), + ...addMlFields(typeAndTimelineOnly), ]; if (dependents.length > 1) { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/error_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/error_schema.test.ts index 9708c928870f5..11d8b85f25920 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/error_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/error_schema.test.ts @@ -10,8 +10,17 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { exactCheck } from './exact_check'; import { foldLeftRight, getErrorPayload, getPaths } from './__mocks__/utils'; import { errorSchema, ErrorSchema } from './error_schema'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../../feature_flags'; describe('error_schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('it should validate an error with a UUID given for id', () => { const error = getErrorPayload(); const decoded = errorSchema.decode(getErrorPayload()); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/exact_check.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/exact_check.test.ts index d01c5e19d4322..cae4365d06856 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/exact_check.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/exact_check.test.ts @@ -10,8 +10,17 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { foldLeftRight, getPaths } from './__mocks__/utils'; import { exactCheck, findDifferencesRecursive } from './exact_check'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../../feature_flags'; describe('exact_check', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('it returns an error if given extra object properties', () => { const someType = t.exact( t.type({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/find_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/find_rules_schema.test.ts index 937af223b91ab..f5c1970ee8c55 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/find_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/find_rules_schema.test.ts @@ -15,8 +15,17 @@ import { } from './__mocks__/utils'; import { left } from 'fp-ts/lib/Either'; import { RulesSchema } from './rules_schema'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../../feature_flags'; describe('find_rules_schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('it should validate a typical single find rules response', () => { const payload = getFindResponseSingle(); const decoded = findRulesSchema.decode(payload); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/import_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/import_rules_schema.test.ts index 62ffcd527eea8..ce4bbf420a634 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/import_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/import_rules_schema.test.ts @@ -10,8 +10,17 @@ import { foldLeftRight, getPaths } from './__mocks__/utils'; import { left } from 'fp-ts/lib/Either'; import { ImportRulesSchema, importRulesSchema } from './import_rules_schema'; import { ErrorSchema } from './error_schema'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../../feature_flags'; describe('import_rules_schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('it should validate an empty import response with no errors', () => { const payload: ImportRulesSchema = { success: true, success_count: 0, errors: [] }; const decoded = importRulesSchema.decode(payload); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/prepackaged_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/prepackaged_rules_schema.test.ts index 7f9b296e2d466..46667826416e1 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/prepackaged_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/prepackaged_rules_schema.test.ts @@ -9,8 +9,17 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { foldLeftRight, getPaths } from './__mocks__/utils'; import { left } from 'fp-ts/lib/Either'; import { PrePackagedRulesSchema, prePackagedRulesSchema } from './prepackaged_rules_schema'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../../feature_flags'; describe('prepackaged_rules_schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('it should validate an empty prepackaged response with defaults', () => { const payload: PrePackagedRulesSchema = { rules_installed: 0, rules_updated: 0 }; const decoded = prePackagedRulesSchema.decode(payload); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/prepackaged_rules_status_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/prepackaged_rules_status_schema.test.ts index 9d44e09e847a0..1c270ff402f75 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/prepackaged_rules_status_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/prepackaged_rules_status_schema.test.ts @@ -12,8 +12,17 @@ import { PrePackagedRulesStatusSchema, prePackagedRulesStatusSchema, } from './prepackaged_rules_status_schema'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../../feature_flags'; describe('prepackaged_rules_schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('it should validate an empty prepackaged response with defaults', () => { const payload: PrePackagedRulesStatusSchema = { rules_installed: 0, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/rules_bulk_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/rules_bulk_schema.test.ts index c2f346cacc43e..8dc97d727c4d1 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/rules_bulk_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/rules_bulk_schema.test.ts @@ -17,8 +17,17 @@ import { import { RulesBulkSchema, rulesBulkSchema } from './rules_bulk_schema'; import { RulesSchema } from './rules_schema'; import { ErrorSchema } from './error_schema'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../../feature_flags'; describe('prepackaged_rule_schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('it should validate a regular message and and error together with a uuid', () => { const payload: RulesBulkSchema = [getBaseResponsePayload(), getErrorPayload()]; const decoded = rulesBulkSchema.decode(payload); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/rules_schema.test.ts index a2594ffa21c45..fb9ff2c28dc44 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/rules_schema.test.ts @@ -8,12 +8,21 @@ import { left } from 'fp-ts/lib/Either'; import { pipe } from 'fp-ts/lib/pipeable'; import { exactCheck } from './exact_check'; -import { rulesSchema, RulesSchema } from './rules_schema'; +import { rulesSchema, RulesSchema, removeList } from './rules_schema'; import { foldLeftRight, getBaseResponsePayload, getPaths } from './__mocks__/utils'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../../feature_flags'; export const ANCHOR_DATE = '2020-02-20T03:57:54.037Z'; describe('rules_schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('it should validate a type of "query" without anything extra', () => { const payload = getBaseResponsePayload(); @@ -196,4 +205,84 @@ describe('rules_schema', () => { ]); expect(message.schema).toEqual({}); }); + + // TODO: (LIST-FEATURE) Remove this test once the feature flag is deployed + test('it should remove lists when we need it to be removed because the feature is off but there exists a list in the data', () => { + const payload = getBaseResponsePayload(); + const decoded = rulesSchema.decode(payload); + const listRemoved = removeList(decoded); + const message = pipe(listRemoved, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual({ + id: '7a7065d7-6e8b-4aae-8d20-c93613dec9f9', + created_at: '2020-02-20T03:57:54.037Z', + updated_at: '2020-02-20T03:57:54.037Z', + created_by: 'elastic', + description: 'some description', + enabled: true, + false_positives: ['false positive 1', 'false positive 2'], + from: 'now-6m', + immutable: false, + name: 'Query with a rule id', + query: 'user.name: root or user.name: admin', + references: ['test 1', 'test 2'], + severity: 'high', + updated_by: 'elastic_kibana', + tags: [], + to: 'now', + type: 'query', + threat: [], + version: 1, + output_index: '.siem-signals-hassanabad-frank-default', + max_signals: 100, + risk_score: 55, + language: 'kuery', + rule_id: 'query-rule-id', + interval: '5m', + status: 'succeeded', + status_date: '2020-02-22T16:47:50.047Z', + last_success_at: '2020-02-22T16:47:50.047Z', + last_success_message: 'succeeded', + }); + }); + + test('it should work with lists that are not there and not cause invalidation or errors', () => { + const payload = getBaseResponsePayload(); + const { lists, ...payloadWithoutLists } = payload; + const decoded = rulesSchema.decode(payloadWithoutLists); + const listRemoved = removeList(decoded); + const message = pipe(listRemoved, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual({ + id: '7a7065d7-6e8b-4aae-8d20-c93613dec9f9', + created_at: '2020-02-20T03:57:54.037Z', + updated_at: '2020-02-20T03:57:54.037Z', + created_by: 'elastic', + description: 'some description', + enabled: true, + false_positives: ['false positive 1', 'false positive 2'], + from: 'now-6m', + immutable: false, + name: 'Query with a rule id', + query: 'user.name: root or user.name: admin', + references: ['test 1', 'test 2'], + severity: 'high', + updated_by: 'elastic_kibana', + tags: [], + to: 'now', + type: 'query', + threat: [], + version: 1, + output_index: '.siem-signals-hassanabad-frank-default', + max_signals: 100, + risk_score: 55, + language: 'kuery', + rule_id: 'query-rule-id', + interval: '5m', + status: 'succeeded', + status_date: '2020-02-22T16:47:50.047Z', + last_success_at: '2020-02-22T16:47:50.047Z', + last_success_message: 'succeeded', + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/rules_schema.ts index 945b5651be066..1574e8f5aa6e1 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/rules_schema.ts @@ -7,10 +7,13 @@ /* eslint-disable @typescript-eslint/camelcase */ import * as t from 'io-ts'; import { isObject } from 'lodash/fp'; -import { Either } from 'fp-ts/lib/Either'; +import { Either, fold, right, left } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; import { checkTypeDependents } from './check_type_dependents'; import { + actions, + anomaly_threshold, description, enabled, false_positives, @@ -24,6 +27,7 @@ import { name, output_index, max_signals, + machine_learning_job_id, query, references, severity, @@ -39,6 +43,7 @@ import { timeline_title, type, threat, + throttle, job_status, status_date, last_success_at, @@ -50,6 +55,8 @@ import { meta, note, } from './schemas'; +import { ListsDefaultArray } from '../types/lists_default_array'; +import { hasListsFeature } from '../../../feature_flags'; /** * This is the required fields for the rules schema response. Put all required properties on @@ -65,12 +72,10 @@ export const requiredRulesSchema = t.type({ immutable, interval, rule_id, - language, output_index, max_signals, risk_score, name, - query, references, severity, updated_by, @@ -82,6 +87,7 @@ export const requiredRulesSchema = t.type({ updated_at, created_by, version, + lists: ListsDefaultArray, }); export type RequiredRulesSchema = t.TypeOf; @@ -91,12 +97,20 @@ export type RequiredRulesSchema = t.TypeOf; * check_type_dependents file for whichever REST flow it is going through. */ export const dependentRulesSchema = t.partial({ + // query fields + language, + query, + // when type = saved_query, saved_is is required saved_id, // These two are required together or not at all. timeline_id, timeline_title, + + // ML fields + anomaly_threshold, + machine_learning_job_id, }); /** @@ -105,6 +119,8 @@ export const dependentRulesSchema = t.partial({ * Instead use dependentRulesSchema and check_type_dependents for how to do those. */ export const partialRulesSchema = t.partial({ + actions, + throttle, status: job_status, status_date, last_success_at, @@ -139,11 +155,30 @@ export const rulesSchema = new t.Type< 'RulesSchema', (input: unknown): input is RulesWithoutTypeDependentsSchema => isObject(input), (input): Either => { - return checkTypeDependents(input); + const output = checkTypeDependents(input); + if (!hasListsFeature()) { + // TODO: (LIST-FEATURE) Remove this after the lists feature is an accepted feature for a particular release + return removeList(output); + } else { + return output; + } }, t.identity ); +// TODO: (LIST-FEATURE) Remove this after the lists feature is an accepted feature for a particular release +export const removeList = ( + decoded: Either +): Either => { + const onLeft = (errors: t.Errors): Either => left(errors); + const onRight = (decodedValue: RequiredRulesSchema): Either => { + delete decodedValue.lists; + return right(decodedValue); + }; + const folded = fold(onLeft, onRight); + return pipe(decoded, folded); +}; + /** * This is the correct type you want to use for Rules that are outputted from the * REST interface. This has all base and all optional properties merged together. diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/schemas.ts index 16f6c0fd6b8b4..538c8f754fd6e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/schemas.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/schemas.ts @@ -25,6 +25,25 @@ export const file_name = t.string; */ export const filters = t.array(t.unknown); // Filters are not easily type-able yet +/** + * Params is an "object", since it is a type of AlertActionParams which is action templates. + * @see x-pack/plugins/alerting/common/alert.ts + */ +export const action_group = t.string; +export const action_id = t.string; +export const action_action_type_id = t.string; +export const action_params = t.object; +export const action = t.exact( + t.type({ + group: action_group, + id: action_id, + action_type_id: action_action_type_id, + params: action_params, + }) +); + +export const actions = t.array(action); + // TODO: Create a regular expression type or custom date math part type here export const from = t.string; @@ -45,6 +64,9 @@ export const output_index = t.string; export const saved_id = t.string; export const timeline_id = t.string; export const timeline_title = t.string; +export const throttle = t.string; +export const anomaly_threshold = PositiveInteger; +export const machine_learning_job_id = t.string; /** * Note that this is a plain unknown object because we allow the UI @@ -64,7 +86,7 @@ export const job_status = t.keyof({ succeeded: null, failed: null, 'going to run // TODO: Create a regular expression type or custom date math part type here export const to = t.string; -export const type = t.keyof({ query: null, saved_query: null }); +export const type = t.keyof({ machine_learning: null, query: null, saved_query: null }); export const queryFilter = t.string; export const references = t.array(t.string); export const per_page = PositiveInteger; @@ -129,3 +151,16 @@ export const rules_custom_installed = PositiveInteger; export const rules_not_installed = PositiveInteger; export const rules_not_updated = PositiveInteger; export const note = t.string; + +// NOTE: Experimental list support not being shipped currently and behind a feature flag +// TODO: Remove this comment once we lists have passed testing and is ready for the release +export const boolean_operator = t.keyof({ and: null, 'and not': null }); +export const list_type = t.keyof({ value: null }); // TODO: (LIST-FEATURE) Eventually this can include "list" when we support lists CRUD +export const list_value = t.exact(t.type({ name: t.string, type: list_type })); +export const list = t.exact( + t.type({ + field: t.string, + boolean_operator, + values: t.array(list_value), + }) +); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/type_timeline_only_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/type_timeline_only_schema.test.ts index 219cd68d3a2a1..68a3c8b303823 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/type_timeline_only_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/type_timeline_only_schema.test.ts @@ -10,8 +10,17 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { exactCheck } from './exact_check'; import { foldLeftRight, getPaths } from './__mocks__/utils'; import { TypeAndTimelineOnly, typeAndTimelineOnlySchema } from './type_timeline_only_schema'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../../feature_flags'; describe('prepackaged_rule_schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('it should validate a a type and timeline_id together', () => { const payload: TypeAndTimelineOnly = { type: 'query', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/utils.test.ts index cd223c24792bf..c1eb32be4895c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/response/utils.test.ts @@ -6,8 +6,17 @@ import * as t from 'io-ts'; import { formatErrors } from './utils'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../../feature_flags'; describe('utils', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('returns an empty error message string if there are no errors', () => { const errors: t.Errors = []; const output = formatErrors(errors); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts index 2ba9ec7f83253..16e419f389f09 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts @@ -7,6 +7,10 @@ import Joi from 'joi'; /* eslint-disable @typescript-eslint/camelcase */ +export const anomaly_threshold = Joi.number() + .integer() + .greater(-1) + .less(101); export const description = Joi.string(); export const enabled = Joi.boolean(); export const exclude_export_details = Joi.boolean(); @@ -48,7 +52,8 @@ export const risk_score = Joi.number() export const severity = Joi.string().valid('low', 'medium', 'high', 'critical'); export const status = Joi.string().valid('open', 'closed'); export const to = Joi.string(); -export const type = Joi.string().valid('query', 'saved_query'); +export const type = Joi.string().valid('query', 'saved_query', 'machine_learning'); +export const machine_learning_job_id = Joi.string(); export const queryFilter = Joi.string(); export const references = Joi.array() .items(Joi.string()) @@ -105,4 +110,28 @@ export const updated_by = Joi.string(); export const version = Joi.number() .integer() .min(1); +export const action_group = Joi.string(); +export const action_id = Joi.string(); +export const action_action_type_id = Joi.string(); +export const action_params = Joi.object(); +export const action = Joi.object({ + group: action_group.required(), + id: action_id.required(), + action_type_id: action_action_type_id.required(), + params: action_params.required(), +}); +export const actions = Joi.array().items(action); +export const throttle = Joi.string().allow(null); export const note = Joi.string(); + +// NOTE: Experimental list support not being shipped currently and behind a feature flag +// TODO: (LIST-FEATURE) Remove this comment once we lists have passed testing and is ready for the release +export const boolean_operator = Joi.string().valid('and', 'and not'); +export const list_type = Joi.string().valid('value'); // TODO: (LIST-FEATURE) Eventually this can be "list" when we support list types +export const list_value = Joi.object({ name: Joi.string().required(), type: list_type.required() }); +export const list = Joi.object({ + field: Joi.string().required(), + boolean_operator: boolean_operator.required(), + values: Joi.array().items(list_value), +}); +export const lists = Joi.array().items(list); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/set_signal_status_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/set_signal_status_schema.test.ts index a6ba9b19a9d7d..953532a6e1c26 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/set_signal_status_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/set_signal_status_schema.test.ts @@ -6,8 +6,17 @@ import { setSignalsStatusSchema } from './set_signal_status_schema'; import { SignalsStatusRestParams } from '../../signals/types'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('set signal status schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('signal_ids and status is valid', () => { expect( setSignalsStatusSchema.validate>({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/types/lists_default_array.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/types/lists_default_array.test.ts new file mode 100644 index 0000000000000..14df1c3d8cd55 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/types/lists_default_array.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ListsDefaultArray } from './lists_default_array'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { foldLeftRight, getPaths } from '../response/__mocks__/utils'; +import { left } from 'fp-ts/lib/Either'; + +describe('lists_default_array', () => { + test('it should validate an empty array', () => { + const payload: string[] = []; + const decoded = ListsDefaultArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of lists', () => { + const payload = [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ]; + const decoded = ListsDefaultArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate an array with a number', () => { + const payload = [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + 5, + ]; + const decoded = ListsDefaultArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to ""']); + expect(message.schema).toEqual({}); + }); + + test('it should return a default array entry', () => { + const payload = null; + const decoded = ListsDefaultArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([]); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/types/lists_default_array.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/types/lists_default_array.ts new file mode 100644 index 0000000000000..0e0944a11b416 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/types/lists_default_array.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +import { list } from '../response/schemas'; + +export type ListsDefaultArrayC = t.Type; +type List = t.TypeOf; + +/** + * Types the ListsDefaultArray as: + * - If null or undefined, then a default array will be set for the list + */ +export const ListsDefaultArray: ListsDefaultArrayC = new t.Type( + 'listsWithDefaultArray', + t.array(list).is, + (input): Either => + input == null ? t.success([]) : t.array(list).decode(input), + t.identity +); + +export type ListsDefaultArraySchema = t.TypeOf; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_bulk_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_bulk_schema.test.ts index e866260662ad7..d329070eaaa0a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_bulk_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_bulk_schema.test.ts @@ -6,11 +6,20 @@ import { updateRulesBulkSchema } from './update_rules_bulk_schema'; import { UpdateRuleAlertParamsRest } from '../../rules/types'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; // only the basics of testing are here. // see: update_rules_schema.test.ts for the bulk of the validation tests // this just wraps updateRulesSchema in an array describe('update_rules_bulk_schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('can take an empty array and validate it', () => { expect( updateRulesBulkSchema.validate>>([]).error diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts index e37abf3746ae6..db3709cd6b126 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts @@ -4,11 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { updateRulesSchema } from './update_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertParamsRest } from '../../types'; +import { ThreatParams, RuleAlertParamsRest, RuleAlertAction } from '../../types'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('create rules schema', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('empty objects do not validate as they require at least id or rule_id', () => { expect(updateRulesSchema.validate>({}).error).toBeTruthy(); }); @@ -1244,6 +1254,184 @@ describe('create rules schema', () => { ); }); + test('The default for "actions" will be an empty array', () => { + expect( + updateRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).value.actions + ).toEqual([]); + }); + + test('You cannot send in an array of actions that are missing "group"', () => { + expect( + updateRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ id: 'id', action_type_id: 'action_type_id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "group" fails because ["group" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "id"', () => { + expect( + updateRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', action_type_id: 'action_type_id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "id" fails because ["id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "action_type_id"', () => { + expect( + updateRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', id: 'id', params: {} }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are missing "params"', () => { + expect( + updateRulesSchema.validate< + Partial< + Omit & { + actions: Array>; + } + > + >({ + actions: [{ group: 'group', id: 'id', action_type_id: 'action_type_id' }], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "params" fails because ["params" is required]]]' + ); + }); + + test('You cannot send in an array of actions that are including "actionTypeId"', () => { + expect( + updateRulesSchema.validate< + Partial< + Omit & { + actions: AlertAction[]; + } + > + >({ + actions: [ + { + group: 'group', + id: 'id', + actionTypeId: 'actionTypeId', + params: {}, + }, + ], + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).error.message + ).toEqual( + 'child "actions" fails because ["actions" at position 0 fails because [child "action_type_id" fails because ["action_type_id" is required]]]' + ); + }); + + test('The default for "throttle" will be null', () => { + expect( + updateRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + name: 'some-name', + severity: 'low', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + version: 1, + }).value.throttle + ).toEqual(null); + }); + describe('note', () => { test('You can set note to a string', () => { expect( @@ -1340,4 +1528,112 @@ describe('create rules schema', () => { ).toEqual('child "note" fails because ["note" must be a string]'); }); }); + + // TODO: (LIST-FEATURE) We can enable this once we change the schema's to not be global per module but rather functions that can create the schema + // on demand. Since they are per module, we have a an issue where the ENV variables do not take effect. It is better we change all the + // schema's to be function calls to avoid global side effects or just wait until the feature is available. If you want to test this early, + // you can remove the .skip and set your env variable of export ELASTIC_XPACK_SIEM_LISTS_FEATURE=true locally + describe.skip('lists', () => { + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and lists] does validate', () => { + expect( + updateRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and empty lists] does validate', () => { + expect( + updateRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + lists: [], + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and invalid lists] does NOT validate', () => { + expect( + updateRulesSchema.validate>>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + lists: [{ invalid_value: 'invalid value' }], + }).error.message + ).toEqual( + 'child "lists" fails because ["lists" at position 0 fails because [child "field" fails because ["field" is required]]]' + ); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, note, and non-existent lists] does validate with empty lists', () => { + expect( + updateRulesSchema.validate>>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'low', + interval: '5m', + type: 'query', + risk_score: 50, + note: '# some markdown', + }).value.lists + ).toEqual([]); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts index a72105142d287..f842c14f41ae6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts @@ -8,6 +8,7 @@ import Joi from 'joi'; /* eslint-disable @typescript-eslint/camelcase */ import { + actions, enabled, description, false_positives, @@ -31,14 +32,19 @@ import { to, type, threat, + throttle, references, id, note, version, + lists, + anomaly_threshold, + machine_learning_job_id, } from './schemas'; /* eslint-enable @typescript-eslint/camelcase */ import { DEFAULT_MAX_SIGNALS } from '../../../../../common/constants'; +import { hasListsFeature } from '../../feature_flags'; /** * This almost identical to the create_rules_schema except for a few details. @@ -48,6 +54,12 @@ import { DEFAULT_MAX_SIGNALS } from '../../../../../common/constants'; * - id is on here because you can pass in an id to update using it instead of rule_id. */ export const updateRulesSchema = Joi.object({ + actions: actions.default([]), + anomaly_threshold: anomaly_threshold.when('type', { + is: 'machine_learning', + then: Joi.required(), + otherwise: Joi.forbidden(), + }), description: description.required(), enabled: enabled.default(true), id, @@ -57,8 +69,21 @@ export const updateRulesSchema = Joi.object({ rule_id, index, interval: interval.default('5m'), - query: query.allow('').default(''), - language: language.default('kuery'), + query: query.when('type', { + is: 'machine_learning', + then: Joi.forbidden(), + otherwise: query.allow('').default(''), + }), + language: language.when('type', { + is: 'machine_learning', + then: Joi.forbidden(), + otherwise: language.default('kuery'), + }), + machine_learning_job_id: machine_learning_job_id.when('type', { + is: 'machine_learning', + then: Joi.required(), + otherwise: Joi.forbidden(), + }), output_index, saved_id: saved_id.when('type', { is: 'saved_query', @@ -76,7 +101,11 @@ export const updateRulesSchema = Joi.object({ to: to.default('now'), type: type.required(), threat: threat.default([]), + throttle: throttle.default(null), references: references.default([]), note: note.allow(''), version, + + // TODO: (LIST-FEATURE) Remove the hasListsFeatures once this is ready for release + lists: hasListsFeature() ? lists.default([]) : lists.forbidden().default([]), }).xor('id', 'rule_id'); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/signals/open_close_signals.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/signals/open_close_signals.test.ts index b189eac186a78..612d08c09785a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/signals/open_close_signals.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/signals/open_close_signals.test.ts @@ -15,8 +15,17 @@ import { } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { setSignalsStatusRoute } from './open_close_signals_route'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('set signal status', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + let server: ReturnType; let { clients, context } = requestContextMock.createTools(); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/signals/query_signals_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/signals/query_signals_route.test.ts index dcbb7b8e1fe44..8d7b171a8537b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/signals/query_signals_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/signals/query_signals_route.test.ts @@ -15,8 +15,17 @@ import { } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { querySignalsRoute } from './query_signals_route'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('query for signal', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + let server: ReturnType; let { clients, context } = requestContextMock.createTools(); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts index 6768e9534a87e..fdb1cd148c7fa 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts @@ -21,8 +21,17 @@ import { SiemResponseFactory, } from './utils'; import { responseMock } from './__mocks__'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../feature_flags'; describe('utils', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + describe('transformError', () => { test('returns transformed output error from boom object with a 500 and payload of internal server error', () => { const boom = new Boom('some boom message'); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.test.ts new file mode 100644 index 0000000000000..4c8d0f51f251b --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; +import { getMlResult } from '../routes/__mocks__/request_responses'; +import { createRules } from './create_rules'; + +describe('createRules', () => { + let actionsClient: ReturnType; + let alertsClient: ReturnType; + + beforeEach(() => { + actionsClient = actionsClientMock.create(); + alertsClient = alertsClientMock.create(); + }); + + it('calls the alertsClient with ML params', async () => { + const params = { + ...getMlResult().params, + anomalyThreshold: 55, + machineLearningJobId: 'new_job_id', + }; + + await createRules({ + alertsClient, + actionsClient, + ...params, + ruleId: 'new-rule-id', + enabled: true, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + params: expect.objectContaining({ + anomalyThreshold: 55, + machineLearningJobId: 'new_job_id', + }), + }), + }) + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts index ea87950a59b78..db70b90d5a17c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts @@ -8,10 +8,14 @@ import { Alert } from '../../../../../../../plugins/alerting/common'; import { APP_ID, SIGNALS_ID } from '../../../../common/constants'; import { CreateRuleParams } from './types'; import { addTags } from './add_tags'; +import { hasListsFeature } from '../feature_flags'; +import { transformRuleToAlertAction } from './transform_actions'; export const createRules = ({ alertsClient, actionsClient, // TODO: Use this actionsClient once we have actions such as email, etc... + actions, + anomalyThreshold, description, enabled, falsePositives, @@ -22,6 +26,7 @@ export const createRules = ({ timelineId, timelineTitle, meta, + machineLearningJobId, filters, ruleId, immutable, @@ -34,12 +39,16 @@ export const createRules = ({ severity, tags, threat, + throttle, to, type, references, note, version, + lists, }: CreateRuleParams): Promise => { + // TODO: Remove this and use regular lists once the feature is stable for a release + const listsParam = hasListsFeature() ? { lists } : {}; return alertsClient.create({ data: { name, @@ -47,6 +56,7 @@ export const createRules = ({ alertTypeId: SIGNALS_ID, consumer: APP_ID, params: { + anomalyThreshold, description, ruleId, index, @@ -60,6 +70,7 @@ export const createRules = ({ timelineId, timelineTitle, meta, + machineLearningJobId, filters, maxSignals, riskScore, @@ -70,11 +81,12 @@ export const createRules = ({ references, note, version, + ...listsParam, }, schedule: { interval }, enabled, - actions: [], // TODO: Create and add actions here once we have email, etc... - throttle: null, + actions: actions?.map(transformRuleToAlertAction), + throttle, }, }); }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts index 8705682f61bcc..695057ccc2f70 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts @@ -49,6 +49,7 @@ describe('create_rules_stream_from_ndjson', () => { ]); expect(result).toEqual([ { + actions: [], rule_id: 'rule-1', output_index: '.siem-signals', risk_score: 50, @@ -65,13 +66,16 @@ describe('create_rules_stream_from_ndjson', () => { immutable: false, query: '', language: 'kuery', + lists: [], max_signals: 100, tags: [], threat: [], + throttle: null, references: [], version: 1, }, { + actions: [], rule_id: 'rule-2', output_index: '.siem-signals', risk_score: 50, @@ -88,9 +92,11 @@ describe('create_rules_stream_from_ndjson', () => { immutable: false, query: '', language: 'kuery', + lists: [], max_signals: 100, tags: [], threat: [], + throttle: null, references: [], version: 1, }, @@ -133,6 +139,7 @@ describe('create_rules_stream_from_ndjson', () => { ]); expect(result).toEqual([ { + actions: [], rule_id: 'rule-1', output_index: '.siem-signals', risk_score: 50, @@ -151,11 +158,14 @@ describe('create_rules_stream_from_ndjson', () => { language: 'kuery', max_signals: 100, tags: [], + lists: [], threat: [], + throttle: null, references: [], version: 1, }, { + actions: [], rule_id: 'rule-2', output_index: '.siem-signals', risk_score: 50, @@ -173,8 +183,10 @@ describe('create_rules_stream_from_ndjson', () => { query: '', language: 'kuery', max_signals: 100, + lists: [], tags: [], threat: [], + throttle: null, references: [], version: 1, }, @@ -200,6 +212,7 @@ describe('create_rules_stream_from_ndjson', () => { ]); expect(result).toEqual([ { + actions: [], rule_id: 'rule-1', output_index: '.siem-signals', risk_score: 50, @@ -217,12 +230,15 @@ describe('create_rules_stream_from_ndjson', () => { query: '', language: 'kuery', max_signals: 100, + lists: [], tags: [], threat: [], + throttle: null, references: [], version: 1, }, { + actions: [], rule_id: 'rule-2', output_index: '.siem-signals', risk_score: 50, @@ -240,8 +256,10 @@ describe('create_rules_stream_from_ndjson', () => { query: '', language: 'kuery', max_signals: 100, + lists: [], tags: [], threat: [], + throttle: null, references: [], version: 1, }, @@ -267,6 +285,7 @@ describe('create_rules_stream_from_ndjson', () => { ]); const resultOrError = result as Error[]; expect(resultOrError[0]).toEqual({ + actions: [], rule_id: 'rule-1', output_index: '.siem-signals', risk_score: 50, @@ -284,13 +303,16 @@ describe('create_rules_stream_from_ndjson', () => { query: '', language: 'kuery', max_signals: 100, + lists: [], tags: [], threat: [], + throttle: null, references: [], version: 1, }); expect(resultOrError[1].message).toEqual('Unexpected token , in JSON at position 1'); expect(resultOrError[2]).toEqual({ + actions: [], rule_id: 'rule-2', output_index: '.siem-signals', risk_score: 50, @@ -308,8 +330,10 @@ describe('create_rules_stream_from_ndjson', () => { query: '', language: 'kuery', max_signals: 100, + lists: [], tags: [], threat: [], + throttle: null, references: [], version: 1, }); @@ -334,6 +358,7 @@ describe('create_rules_stream_from_ndjson', () => { ]); const resultOrError = result as BadRequestError[]; expect(resultOrError[0]).toEqual({ + actions: [], rule_id: 'rule-1', output_index: '.siem-signals', risk_score: 50, @@ -351,8 +376,10 @@ describe('create_rules_stream_from_ndjson', () => { query: '', language: 'kuery', max_signals: 100, + lists: [], tags: [], threat: [], + throttle: null, references: [], version: 1, }); @@ -360,6 +387,7 @@ describe('create_rules_stream_from_ndjson', () => { 'child "description" fails because ["description" is required]' ); expect(resultOrError[2]).toEqual({ + actions: [], rule_id: 'rule-2', output_index: '.siem-signals', risk_score: 50, @@ -377,8 +405,10 @@ describe('create_rules_stream_from_ndjson', () => { query: '', language: 'kuery', max_signals: 100, + lists: [], tags: [], threat: [], + throttle: null, references: [], version: 1, }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.test.ts index 39b596dfed855..20ddcdc3f5362 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.test.ts @@ -11,8 +11,17 @@ import { } from '../routes/__mocks__/request_responses'; import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; import { getExportAll } from './get_export_all'; +import { unSetFeatureFlagsForTestsOnly, setFeatureFlagsForTestsOnly } from '../feature_flags'; describe('getExportAll', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + test('it exports everything from the alerts client', async () => { const alertsClient = alertsClientMock.create(); alertsClient.get.mockResolvedValue(getResult()); @@ -20,9 +29,87 @@ describe('getExportAll', () => { const exports = await getExportAll(alertsClient); expect(exports).toEqual({ - rulesNdjson: - '{"created_at":"2019-12-13T16:40:33.400Z","updated_at":"2019-12-13T16:40:33.400Z","created_by":"elastic","description":"Detecting root and admin users","enabled":true,"false_positives":[],"filters":[{"query":{"match_phrase":{"host.name":"some-host"}}}],"from":"now-6m","id":"04128c15-0d1b-4716-a4c5-46997ac7f3bd","immutable":false,"index":["auditbeat-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"rule-1","language":"kuery","output_index":".siem-signals","max_signals":100,"risk_score":50,"name":"Detect Root/Admin Users","query":"user.name: root or user.name: admin","references":["http://www.example.com","https://ww.example.com"],"timeline_id":"some-timeline-id","timeline_title":"some-timeline-title","meta":{"someMeta":"someField"},"severity":"high","updated_by":"elastic","tags":[],"to":"now","type":"query","threat":[{"framework":"MITRE ATT&CK","tactic":{"id":"TA0040","name":"impact","reference":"https://attack.mitre.org/tactics/TA0040/"},"technique":[{"id":"T1499","name":"endpoint denial of service","reference":"https://attack.mitre.org/techniques/T1499/"}]}],"note":"# Investigative notes","version":1}\n', - exportDetails: '{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n', + rulesNdjson: `${JSON.stringify({ + actions: [], + created_at: '2019-12-13T16:40:33.400Z', + updated_at: '2019-12-13T16:40:33.400Z', + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: true, + false_positives: [], + filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], + from: 'now-6m', + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + rule_id: 'rule-1', + language: 'kuery', + output_index: '.siem-signals', + max_signals: 100, + risk_score: 50, + name: 'Detect Root/Admin Users', + query: 'user.name: root or user.name: admin', + references: ['http://www.example.com', 'https://ww.example.com'], + timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', + meta: { someMeta: 'someField' }, + severity: 'high', + updated_by: 'elastic', + tags: [], + to: 'now', + type: 'query', + threat: [ + { + framework: 'MITRE ATT&CK', + tactic: { + id: 'TA0040', + name: 'impact', + reference: 'https://attack.mitre.org/tactics/TA0040/', + }, + technique: [ + { + id: 'T1499', + name: 'endpoint denial of service', + reference: 'https://attack.mitre.org/techniques/T1499/', + }, + ], + }, + ], + note: '# Investigative notes', + version: 1, + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], + })}\n`, + exportDetails: `${JSON.stringify({ + exported_count: 1, + missing_rules: [], + missing_rules_count: 0, + })}\n`, }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts index 434919f80e149..6a27abb66ce85 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts @@ -7,7 +7,7 @@ import { AlertsClient } from '../../../../../../../plugins/alerting/server'; import { getNonPackagedRules } from './get_existing_prepackaged_rules'; import { getExportDetailsNdjson } from './get_export_details_ndjson'; -import { transformAlertsToRules, transformRulesToNdjson } from '../routes/rules/utils'; +import { transformAlertsToRules, transformDataToNdjson } from '../routes/rules/utils'; export const getExportAll = async ( alertsClient: AlertsClient @@ -17,7 +17,7 @@ export const getExportAll = async ( }> => { const ruleAlertTypes = await getNonPackagedRules({ alertsClient }); const rules = transformAlertsToRules(ruleAlertTypes); - const rulesNdjson = transformRulesToNdjson(rules); + const rulesNdjson = transformDataToNdjson(rules); const exportDetails = getExportDetailsNdjson(rules); return { rulesNdjson, exportDetails }; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts index 1406c7c9000b2..e6d4c68d7108d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts @@ -12,8 +12,17 @@ import { } from '../routes/__mocks__/request_responses'; import * as readRules from './read_rules'; import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../feature_flags'; describe('get_export_by_object_ids', () => { + beforeAll(() => { + setFeatureFlagsForTestsOnly(); + }); + + afterAll(() => { + unSetFeatureFlagsForTestsOnly(); + }); + beforeEach(() => { jest.resetAllMocks(); jest.restoreAllMocks(); @@ -28,9 +37,87 @@ describe('get_export_by_object_ids', () => { const objects = [{ rule_id: 'rule-1' }]; const exports = await getExportByObjectIds(alertsClient, objects); expect(exports).toEqual({ - rulesNdjson: - '{"created_at":"2019-12-13T16:40:33.400Z","updated_at":"2019-12-13T16:40:33.400Z","created_by":"elastic","description":"Detecting root and admin users","enabled":true,"false_positives":[],"filters":[{"query":{"match_phrase":{"host.name":"some-host"}}}],"from":"now-6m","id":"04128c15-0d1b-4716-a4c5-46997ac7f3bd","immutable":false,"index":["auditbeat-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"rule-1","language":"kuery","output_index":".siem-signals","max_signals":100,"risk_score":50,"name":"Detect Root/Admin Users","query":"user.name: root or user.name: admin","references":["http://www.example.com","https://ww.example.com"],"timeline_id":"some-timeline-id","timeline_title":"some-timeline-title","meta":{"someMeta":"someField"},"severity":"high","updated_by":"elastic","tags":[],"to":"now","type":"query","threat":[{"framework":"MITRE ATT&CK","tactic":{"id":"TA0040","name":"impact","reference":"https://attack.mitre.org/tactics/TA0040/"},"technique":[{"id":"T1499","name":"endpoint denial of service","reference":"https://attack.mitre.org/techniques/T1499/"}]}],"note":"# Investigative notes","version":1}\n', - exportDetails: '{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n', + rulesNdjson: `${JSON.stringify({ + actions: [], + created_at: '2019-12-13T16:40:33.400Z', + updated_at: '2019-12-13T16:40:33.400Z', + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: true, + false_positives: [], + filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], + from: 'now-6m', + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + rule_id: 'rule-1', + language: 'kuery', + output_index: '.siem-signals', + max_signals: 100, + risk_score: 50, + name: 'Detect Root/Admin Users', + query: 'user.name: root or user.name: admin', + references: ['http://www.example.com', 'https://ww.example.com'], + timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', + meta: { someMeta: 'someField' }, + severity: 'high', + updated_by: 'elastic', + tags: [], + to: 'now', + type: 'query', + threat: [ + { + framework: 'MITRE ATT&CK', + tactic: { + id: 'TA0040', + name: 'impact', + reference: 'https://attack.mitre.org/tactics/TA0040/', + }, + technique: [ + { + id: 'T1499', + name: 'endpoint denial of service', + reference: 'https://attack.mitre.org/techniques/T1499/', + }, + ], + }, + ], + note: '# Investigative notes', + version: 1, + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], + })}\n`, + exportDetails: `${JSON.stringify({ + exported_count: 1, + missing_rules: [], + missing_rules_count: 0, + })}\n`, }); }); @@ -72,6 +159,7 @@ describe('get_export_by_object_ids', () => { missingRules: [], rules: [ { + actions: [], created_at: '2019-12-13T16:40:33.400Z', updated_at: '2019-12-13T16:40:33.400Z', created_by: 'elastic', @@ -119,6 +207,32 @@ describe('get_export_by_object_ids', () => { ], note: '# Investigative notes', version: 1, + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], }, ], }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts index e3b38a879fc3d..6f642231ebbaf 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts @@ -8,7 +8,7 @@ import { AlertsClient } from '../../../../../../../plugins/alerting/server'; import { getExportDetailsNdjson } from './get_export_details_ndjson'; import { isAlertType } from '../rules/types'; import { readRules } from './read_rules'; -import { transformRulesToNdjson, transformAlertToRule } from '../routes/rules/utils'; +import { transformDataToNdjson, transformAlertToRule } from '../routes/rules/utils'; import { OutputRuleAlertRest } from '../types'; interface ExportSuccesRule { @@ -37,7 +37,7 @@ export const getExportByObjectIds = async ( exportDetails: string; }> => { const rulesAndErrors = await getRulesFromObjects(alertsClient, objects); - const rulesNdjson = transformRulesToNdjson(rulesAndErrors.rules); + const rulesNdjson = transformDataToNdjson(rulesAndErrors.rules); const exportDetails = getExportDetailsNdjson(rulesAndErrors.rules, rulesAndErrors.missingRules); return { rulesNdjson, exportDetails }; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/install_prepacked_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/install_prepacked_rules.ts index 3b5ef57d3dcb6..801f3d949ed78 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/install_prepacked_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/install_prepacked_rules.ts @@ -18,6 +18,8 @@ export const installPrepackagedRules = ( ): Array> => rules.reduce>>((acc, rule) => { const { + actions, + anomaly_threshold: anomalyThreshold, description, enabled, false_positives: falsePositives, @@ -25,6 +27,7 @@ export const installPrepackagedRules = ( immutable, query, language, + machine_learning_job_id: machineLearningJobId, saved_id: savedId, timeline_id: timelineId, timeline_title: timelineTitle, @@ -41,15 +44,19 @@ export const installPrepackagedRules = ( to, type, threat, + throttle, references, note, version, + lists, } = rule; return [ ...acc, createRules({ alertsClient, actionsClient, + actions, + anomalyThreshold, description, enabled, falsePositives, @@ -57,6 +64,7 @@ export const installPrepackagedRules = ( immutable, query, language, + machineLearningJobId, outputIndex, savedId, timelineId, @@ -74,9 +82,11 @@ export const installPrepackagedRules = ( to, type, threat, + throttle, references, note, version, + lists, }), ]; }, []); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts new file mode 100644 index 0000000000000..b424d2912ebc8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; +import { getMlResult } from '../routes/__mocks__/request_responses'; +import { patchRules } from './patch_rules'; + +describe('patchRules', () => { + let actionsClient: ReturnType; + let alertsClient: ReturnType; + let savedObjectsClient: ReturnType; + + beforeEach(() => { + actionsClient = actionsClientMock.create(); + alertsClient = alertsClientMock.create(); + savedObjectsClient = savedObjectsClientMock.create(); + }); + + it('calls the alertsClient with ML params', async () => { + alertsClient.get.mockResolvedValue(getMlResult()); + const params = { + ...getMlResult().params, + anomalyThreshold: 55, + machineLearningJobId: 'new_job_id', + }; + + await patchRules({ + alertsClient, + actionsClient, + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...params, + }); + + expect(alertsClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + params: expect.objectContaining({ + anomalyThreshold: 55, + machineLearningJobId: 'new_job_id', + }), + }), + }) + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts index 628f4033d5665..5b6fd08a9ea89 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts @@ -11,10 +11,12 @@ import { PatchRuleParams, IRuleSavedAttributesSavedObjectAttributes } from './ty import { addTags } from './add_tags'; import { ruleStatusSavedObjectType } from './saved_object_mappings'; import { calculateVersion, calculateName, calculateInterval } from './utils'; +import { transformRuleToAlertAction } from './transform_actions'; export const patchRules = async ({ alertsClient, actionsClient, // TODO: Use this whenever we add feature support for different action types + actions, savedObjectsClient, description, falsePositives, @@ -39,12 +41,15 @@ export const patchRules = async ({ severity, tags, threat, + throttle, to, type, references, note, version, - throttle, + lists, + anomalyThreshold, + machineLearningJobId, }: PatchRuleParams): Promise => { const rule = await readRules({ alertsClient, ruleId, id }); if (rule == null) { @@ -52,6 +57,7 @@ export const patchRules = async ({ } const calculatedVersion = calculateVersion(rule.params.immutable, rule.params.version, { + actions, description, falsePositives, query, @@ -71,12 +77,15 @@ export const patchRules = async ({ severity, tags, threat, + throttle, to, type, references, version, - throttle, note, + lists, + anomalyThreshold, + machineLearningJobId, }); const nextParams = defaults( @@ -106,6 +115,9 @@ export const patchRules = async ({ references, note, version: calculatedVersion, + lists, + anomalyThreshold, + machineLearningJobId, } ); @@ -113,12 +125,12 @@ export const patchRules = async ({ id: rule.id, data: { tags: addTags(tags ?? rule.tags, rule.params.ruleId, immutable ?? rule.params.immutable), - throttle: throttle ?? rule.throttle ?? null, + throttle: throttle !== undefined ? throttle : rule.throttle, name: calculateName({ updatedName: name, originalName: rule.name }), schedule: { interval: calculateInterval(interval, rule.schedule.interval), }, - actions: rule.actions, + actions: actions?.map(transformRuleToAlertAction) ?? rule.actions, params: nextParams, }, }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.test.ts new file mode 100644 index 0000000000000..93b5f238be9ed --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { transformRuleToAlertAction, transformAlertToRuleAction } from './transform_actions'; + +describe('transform_actions', () => { + test('it should transform RuleAlertAction[] to AlertAction[]', () => { + const ruleAction = { + id: 'id', + group: 'group', + action_type_id: 'action_type_id', + params: {}, + }; + const alertAction = transformRuleToAlertAction(ruleAction); + expect(alertAction).toEqual({ + id: ruleAction.id, + group: ruleAction.group, + actionTypeId: ruleAction.action_type_id, + params: ruleAction.params, + }); + }); + + test('it should transform AlertAction[] to RuleAlertAction[]', () => { + const alertAction = { + id: 'id', + group: 'group', + actionTypeId: 'actionTypeId', + params: {}, + }; + const ruleAction = transformAlertToRuleAction(alertAction); + expect(ruleAction).toEqual({ + id: alertAction.id, + group: alertAction.group, + action_type_id: alertAction.actionTypeId, + params: alertAction.params, + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts new file mode 100644 index 0000000000000..c1c17d2c70836 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertAction } from '../../../../../../../plugins/alerting/common'; +import { RuleAlertAction } from '../types'; + +export const transformRuleToAlertAction = ({ + group, + id, + action_type_id, + params, +}: RuleAlertAction): AlertAction => ({ + group, + id, + params, + actionTypeId: action_type_id, +}); + +export const transformAlertToRuleAction = ({ + group, + id, + actionTypeId, + params, +}: AlertAction): RuleAlertAction => ({ + group, + id, + params, + action_type_id: actionTypeId, +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts index 1051ac28885b8..cc67622176a04 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -19,6 +19,7 @@ export const updatePrepackagedRules = async ( ): Promise => { await rules.forEach(async rule => { const { + actions, description, false_positives: falsePositives, from, @@ -39,9 +40,9 @@ export const updatePrepackagedRules = async ( to, type, threat, + throttle, references, version, - throttle, note, } = rule; @@ -50,6 +51,7 @@ export const updatePrepackagedRules = async ( return patchRules({ alertsClient, actionsClient, + actions, description, falsePositives, from, @@ -73,9 +75,9 @@ export const updatePrepackagedRules = async ( to, type, threat, + throttle, references, version, - throttle, note, }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts new file mode 100644 index 0000000000000..5ee740a8b8845 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; +import { getMlResult } from '../routes/__mocks__/request_responses'; +import { updateRules } from './update_rules'; + +describe('updateRules', () => { + let actionsClient: ReturnType; + let alertsClient: ReturnType; + let savedObjectsClient: ReturnType; + + beforeEach(() => { + actionsClient = actionsClientMock.create(); + alertsClient = alertsClientMock.create(); + savedObjectsClient = savedObjectsClientMock.create(); + }); + + it('calls the alertsClient with ML params', async () => { + alertsClient.get.mockResolvedValue(getMlResult()); + + const params = { + ...getMlResult().params, + anomalyThreshold: 55, + machineLearningJobId: 'new_job_id', + }; + + await updateRules({ + alertsClient, + actionsClient, + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...params, + enabled: true, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + params: expect.objectContaining({ + anomalyThreshold: 55, + machineLearningJobId: 'new_job_id', + }), + }), + }) + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts index 3987654589bdd..a80f986482010 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts @@ -10,10 +10,13 @@ import { IRuleSavedAttributesSavedObjectAttributes, UpdateRuleParams } from './t import { addTags } from './add_tags'; import { ruleStatusSavedObjectType } from './saved_object_mappings'; import { calculateVersion } from './utils'; +import { hasListsFeature } from '../feature_flags'; +import { transformRuleToAlertAction } from './transform_actions'; export const updateRules = async ({ alertsClient, actionsClient, // TODO: Use this whenever we add feature support for different action types + actions, savedObjectsClient, description, falsePositives, @@ -38,12 +41,15 @@ export const updateRules = async ({ severity, tags, threat, + throttle, to, type, references, version, - throttle, note, + lists, + anomalyThreshold, + machineLearningJobId, }: UpdateRuleParams): Promise => { const rule = await readRules({ alertsClient, ruleId, id }); if (rule == null) { @@ -51,6 +57,7 @@ export const updateRules = async ({ } const calculatedVersion = calculateVersion(rule.params.immutable, rule.params.version, { + actions, description, falsePositives, query, @@ -70,22 +77,27 @@ export const updateRules = async ({ severity, tags, threat, + throttle, to, type, references, version, - throttle, note, + anomalyThreshold, + machineLearningJobId, }); + // TODO: Remove this and use regular lists once the feature is stable for a release + const listsParam = hasListsFeature() ? { lists } : {}; + const update = await alertsClient.update({ id: rule.id, data: { tags: addTags(tags, rule.params.ruleId, immutable), name, schedule: { interval }, - actions: rule.actions, - throttle: throttle ?? rule.throttle ?? null, + actions: actions?.map(transformRuleToAlertAction) ?? rule.actions, + throttle: throttle !== undefined ? throttle : rule.throttle, params: { description, ruleId: rule.params.ruleId, @@ -110,6 +122,9 @@ export const updateRules = async ({ references, note, version: calculatedVersion, + anomalyThreshold, + machineLearningJobId, + ...listsParam, }, }, }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/update_list.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/update_list.json new file mode 100644 index 0000000000000..8c86f4c85af1d --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/patches/update_list.json @@ -0,0 +1,25 @@ +{ + "rule_id": "query-with-list", + "lists": [ + { + "field": "source.ip", + "boolean_operator": "and", + "values": [ + { + "name": "127.0.0.1", + "type": "value" + } + ] + }, + { + "field": "host.name", + "boolean_operator": "and not", + "values": [ + { + "name": "rock01", + "type": "value" + } + ] + } + ] +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_list.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_list.json new file mode 100644 index 0000000000000..f6856eec59966 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_list.json @@ -0,0 +1,35 @@ +{ + "name": "Query with a list", + "description": "Query with a list", + "rule_id": "query-with-list", + "risk_score": 1, + "severity": "high", + "type": "query", + "query": "user.name: root or user.name: admin", + "lists": [ + { + "field": "source.ip", + "boolean_operator": "and", + "values": [ + { + "name": "127.0.0.1", + "type": "value" + } + ] + }, + { + "field": "host.name", + "boolean_operator": "and not", + "values": [ + { + "name": "rock01", + "type": "value" + }, + { + "name": "mothra", + "type": "value" + } + ] + } + ] +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_list.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_list.json new file mode 100644 index 0000000000000..6704c9676fa56 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_list.json @@ -0,0 +1,31 @@ +{ + "name": "Query with a list", + "description": "Query with a list", + "rule_id": "query-with-list", + "risk_score": 1, + "severity": "high", + "type": "query", + "query": "user.name: root or user.name: admin", + "lists": [ + { + "field": "source.ip", + "boolean_operator": "and", + "values": [ + { + "name": "127.0.0.1", + "type": "value" + } + ] + }, + { + "field": "host.name", + "boolean_operator": "and not", + "values": [ + { + "name": "rock01", + "type": "value" + } + ] + } + ] +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts index 922651edc4082..31b922e0067cd 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -29,6 +29,8 @@ export const sampleRuleAlertParams = ( riskScore: riskScore ? riskScore : 50, maxSignals: maxSignals ? maxSignals : 10000, note: '', + anomalyThreshold: undefined, + machineLearningJobId: undefined, filters: undefined, savedId: undefined, timelineId: undefined, @@ -36,6 +38,32 @@ export const sampleRuleAlertParams = ( meta: undefined, threat: undefined, version: 1, + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], }); export const sampleDocNoSortId = (someUuid: string = sampleIdGuid): SignalSourceHit => ({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.test.ts index 30dac114ac506..c86696d6ec5eb 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.test.ts @@ -25,6 +25,7 @@ describe('buildBulkBody', () => { ruleParams: sampleParams, id: sampleRuleGuid, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -32,6 +33,7 @@ describe('buildBulkBody', () => { interval: '5m', enabled: true, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; @@ -60,6 +62,7 @@ describe('buildBulkBody', () => { original_time: 'someTimeStamp', status: 'open', rule: { + actions: [], id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', rule_id: 'rule-1', false_positives: [], @@ -86,6 +89,32 @@ describe('buildBulkBody', () => { version: 1, created_at: fakeSignalSourceHit.signal.rule?.created_at, updated_at: fakeSignalSourceHit.signal.rule?.updated_at, + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], }, }, }; @@ -106,6 +135,7 @@ describe('buildBulkBody', () => { ruleParams: sampleParams, id: sampleRuleGuid, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -113,6 +143,7 @@ describe('buildBulkBody', () => { interval: '5m', enabled: true, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; @@ -150,6 +181,7 @@ describe('buildBulkBody', () => { original_time: 'someTimeStamp', status: 'open', rule: { + actions: [], id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', rule_id: 'rule-1', false_positives: [], @@ -176,6 +208,32 @@ describe('buildBulkBody', () => { version: 1, created_at: fakeSignalSourceHit.signal.rule?.created_at, updated_at: fakeSignalSourceHit.signal.rule?.updated_at, + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], }, }, }; @@ -195,6 +253,7 @@ describe('buildBulkBody', () => { ruleParams: sampleParams, id: sampleRuleGuid, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -202,6 +261,7 @@ describe('buildBulkBody', () => { interval: '5m', enabled: true, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; @@ -238,6 +298,7 @@ describe('buildBulkBody', () => { original_time: 'someTimeStamp', status: 'open', rule: { + actions: [], id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', rule_id: 'rule-1', false_positives: [], @@ -264,6 +325,32 @@ describe('buildBulkBody', () => { version: 1, created_at: fakeSignalSourceHit.signal.rule?.created_at, updated_at: fakeSignalSourceHit.signal.rule?.updated_at, + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], }, }, }; @@ -281,6 +368,7 @@ describe('buildBulkBody', () => { ruleParams: sampleParams, id: sampleRuleGuid, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -288,6 +376,7 @@ describe('buildBulkBody', () => { interval: '5m', enabled: true, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; @@ -319,6 +408,7 @@ describe('buildBulkBody', () => { original_time: 'someTimeStamp', status: 'open', rule: { + actions: [], id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', rule_id: 'rule-1', false_positives: [], @@ -345,6 +435,32 @@ describe('buildBulkBody', () => { version: 1, updated_at: fakeSignalSourceHit.signal.rule?.updated_at, created_at: fakeSignalSourceHit.signal.rule?.created_at, + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], }, }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts index e77755073b374..adbd5f81d372a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts @@ -8,12 +8,13 @@ import { SignalSourceHit, SignalHit } from './types'; import { buildRule } from './build_rule'; import { buildSignal } from './build_signal'; import { buildEventTypeSignal } from './build_event_type_signal'; -import { RuleTypeParams } from '../types'; +import { RuleTypeParams, RuleAlertAction } from '../types'; interface BuildBulkBodyParams { doc: SignalSourceHit; ruleParams: RuleTypeParams; id: string; + actions: RuleAlertAction[]; name: string; createdAt: string; createdBy: string; @@ -22,6 +23,7 @@ interface BuildBulkBodyParams { interval: string; enabled: boolean; tags: string[]; + throttle: string | null; } // format search_after result for signals index. @@ -30,6 +32,7 @@ export const buildBulkBody = ({ ruleParams, id, name, + actions, createdAt, createdBy, updatedAt, @@ -37,8 +40,10 @@ export const buildBulkBody = ({ interval, enabled, tags, + throttle, }: BuildBulkBodyParams): SignalHit => { const rule = buildRule({ + actions, ruleParams, id, name, @@ -49,6 +54,7 @@ export const buildBulkBody = ({ updatedBy, interval, tags, + throttle, }); const signal = buildSignal(doc, rule); const event = buildEventTypeSignal(doc); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.test.ts index c2900782ed676..37d7ed8a51082 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.test.ts @@ -27,6 +27,7 @@ describe('buildRule', () => { }, ]; const rule = buildRule({ + actions: [], ruleParams, name: 'some-name', id: sampleRuleGuid, @@ -37,8 +38,10 @@ describe('buildRule', () => { updatedBy: 'elastic', interval: 'some interval', tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); const expected: Partial = { + actions: [], created_by: 'elastic', description: 'Detecting root and admin users', enabled: false, @@ -75,15 +78,42 @@ describe('buildRule', () => { query: 'host.name: Braden', }, ], + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], version: 1, }; expect(rule).toEqual(expected); }); - test('it omits a null value such as if enabled is null if is present', () => { + test('it omits a null value such as if "enabled" is null if is present', () => { const ruleParams = sampleRuleAlertParams(); ruleParams.filters = undefined; const rule = buildRule({ + actions: [], ruleParams, name: 'some-name', id: sampleRuleGuid, @@ -94,8 +124,10 @@ describe('buildRule', () => { updatedBy: 'elastic', interval: 'some interval', tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); const expected: Partial = { + actions: [], created_by: 'elastic', description: 'Detecting root and admin users', enabled: true, @@ -122,14 +154,115 @@ describe('buildRule', () => { version: 1, updated_at: rule.updated_at, created_at: rule.created_at, + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], }; expect(rule).toEqual(expected); }); - test('it omits a null value such as if filters is undefined if is present', () => { + test('it omits a null value such as if "filters" is undefined if is present', () => { const ruleParams = sampleRuleAlertParams(); ruleParams.filters = undefined; const rule = buildRule({ + actions: [], + ruleParams, + name: 'some-name', + id: sampleRuleGuid, + enabled: true, + createdAt: '2020-01-28T15:58:34.810Z', + updatedAt: '2020-01-28T15:59:14.004Z', + createdBy: 'elastic', + updatedBy: 'elastic', + interval: 'some interval', + tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, + }); + const expected: Partial = { + actions: [], + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: true, + false_positives: [], + from: 'now-6m', + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: 'some interval', + language: 'kuery', + max_signals: 10000, + name: 'some-name', + note: '', + output_index: '.siem-signals', + query: 'user.name: root or user.name: admin', + references: ['http://google.com'], + risk_score: 50, + rule_id: 'rule-1', + severity: 'high', + tags: ['some fake tag 1', 'some fake tag 2'], + to: 'now', + type: 'query', + updated_by: 'elastic', + version: 1, + updated_at: rule.updated_at, + created_at: rule.created_at, + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], + }; + expect(rule).toEqual(expected); + }); + + test('it omits a null value such as if "throttle" is undefined if is present', () => { + const ruleParams = sampleRuleAlertParams(); + const rule = buildRule({ + actions: [], ruleParams, name: 'some-name', id: sampleRuleGuid, @@ -140,8 +273,10 @@ describe('buildRule', () => { updatedBy: 'elastic', interval: 'some interval', tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); const expected: Partial = { + actions: [], created_by: 'elastic', description: 'Detecting root and admin users', enabled: true, @@ -168,6 +303,32 @@ describe('buildRule', () => { version: 1, updated_at: rule.updated_at, created_at: rule.created_at, + lists: [ + { + field: 'source.ip', + boolean_operator: 'and', + values: [ + { + name: '127.0.0.1', + type: 'value', + }, + ], + }, + { + field: 'host.name', + boolean_operator: 'and not', + values: [ + { + name: 'rock01', + type: 'value', + }, + { + name: 'mothra', + type: 'value', + }, + ], + }, + ], }; expect(rule).toEqual(expected); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts index 9baf6a55b7f48..e94ca18b186e4 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts @@ -5,12 +5,13 @@ */ import { pickBy } from 'lodash/fp'; -import { RuleTypeParams, OutputRuleAlertRest } from '../types'; +import { RuleTypeParams, OutputRuleAlertRest, RuleAlertAction } from '../types'; interface BuildRuleParams { ruleParams: RuleTypeParams; name: string; id: string; + actions: RuleAlertAction[]; enabled: boolean; createdAt: string; createdBy: string; @@ -18,12 +19,14 @@ interface BuildRuleParams { updatedBy: string; interval: string; tags: string[]; + throttle: string | null; } export const buildRule = ({ ruleParams, name, id, + actions, enabled, createdAt, createdBy, @@ -31,10 +34,12 @@ export const buildRule = ({ updatedBy, interval, tags, + throttle, }: BuildRuleParams): Partial => { return pickBy((value: unknown) => value != null, { id, rule_id: ruleParams.ruleId, + actions, false_positives: ruleParams.falsePositives, saved_id: ruleParams.savedId, timeline_id: ruleParams.timelineId, @@ -62,8 +67,12 @@ export const buildRule = ({ created_by: createdBy, updated_by: updatedBy, threat: ruleParams.threat, + throttle, version: ruleParams.version, created_at: createdAt, updated_at: updatedAt, + lists: ruleParams.lists, + machine_learning_job_id: ruleParams.machineLearningJobId, + anomaly_threshold: ruleParams.anomalyThreshold, }); }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.test.ts new file mode 100644 index 0000000000000..d9fb9d4bbabde --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { transformAnomalyFieldsToEcs } from './bulk_create_ml_signals'; + +const buildMockAnomaly = () => ({ + job_id: 'rare_process_by_host_linux_ecs', + result_type: 'record', + probability: 0.03406145177566593, + multi_bucket_impact: -0.0, + record_score: 10.86784984522809, + initial_record_score: 10.86784984522809, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1584482400000, + by_field_name: 'process.name', + by_field_value: 'gzip', + partition_field_name: 'host.name', + partition_field_value: 'rock01', + function: 'rare', + function_description: 'rare', + typical: [0.03406145177566593], + actual: [1.0], + influencers: [ + { + influencer_field_name: 'user.name', + influencer_field_values: ['root'], + }, + { + influencer_field_name: 'process.pid', + influencer_field_values: ['123'], + }, + { + influencer_field_name: 'host.name', + influencer_field_values: ['rock01'], + }, + ], + 'process.name': ['gzip'], + 'process.pid': ['123'], + 'user.name': ['root'], + 'host.name': ['rock01'], +}); + +describe('transformAnomalyFieldsToEcs', () => { + it('adds a @timestamp field based on timestamp', () => { + const anomaly = buildMockAnomaly(); + const result = transformAnomalyFieldsToEcs(anomaly); + const expectedTime = '2020-03-17T22:00:00.000Z'; + + expect(result['@timestamp']).toEqual(expectedTime); + }); + + it('deletes dotted influencer fields', () => { + const anomaly = buildMockAnomaly(); + const result = transformAnomalyFieldsToEcs(anomaly); + + const ecsKeys = Object.keys(result); + expect(ecsKeys).not.toContain('user.name'); + expect(ecsKeys).not.toContain('process.pid'); + expect(ecsKeys).not.toContain('host.name'); + }); + + it('deletes dotted entity field', () => { + const anomaly = buildMockAnomaly(); + const result = transformAnomalyFieldsToEcs(anomaly); + + const ecsKeys = Object.keys(result); + expect(ecsKeys).not.toContain('process.name'); + }); + + it('creates nested influencer fields', () => { + const anomaly = buildMockAnomaly(); + const result = transformAnomalyFieldsToEcs(anomaly); + + expect(result.process.pid).toEqual(['123']); + expect(result.user.name).toEqual(['root']); + expect(result.host.name).toEqual(['rock01']); + }); + + it('creates nested entity field', () => { + const anomaly = buildMockAnomaly(); + const result = transformAnomalyFieldsToEcs(anomaly); + + expect(result.process.name).toEqual(['gzip']); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts new file mode 100644 index 0000000000000..95adb90172404 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { flow, set, omit } from 'lodash/fp'; +import { SearchResponse } from 'elasticsearch'; + +import { Logger } from '../../../../../../../../src/core/server'; +import { AlertServices } from '../../../../../../../plugins/alerting/server'; +import { RuleTypeParams, RuleAlertAction } from '../types'; +import { singleBulkCreate } from './single_bulk_create'; +import { AnomalyResults, Anomaly } from '../../machine_learning'; + +interface BulkCreateMlSignalsParams { + actions: RuleAlertAction[]; + someResult: AnomalyResults; + ruleParams: RuleTypeParams; + services: AlertServices; + logger: Logger; + id: string; + signalsIndex: string; + name: string; + createdAt: string; + createdBy: string; + updatedAt: string; + updatedBy: string; + interval: string; + enabled: boolean; + tags: string[]; + throttle: string | null; +} + +interface EcsAnomaly extends Anomaly { + '@timestamp': string; +} + +export const transformAnomalyFieldsToEcs = (anomaly: Anomaly): EcsAnomaly => { + const { + by_field_name: entityName, + by_field_value: entityValue, + influencers, + timestamp, + } = anomaly; + let errantFields = (influencers ?? []).map(influencer => ({ + name: influencer.influencer_field_name, + value: influencer.influencer_field_values, + })); + + if (entityName && entityValue) { + errantFields = [...errantFields, { name: entityName, value: [entityValue] }]; + } + + const omitDottedFields = omit(errantFields.map(field => field.name)); + const setNestedFields = errantFields.map(field => set(field.name, field.value)); + const setTimestamp = set('@timestamp', new Date(timestamp).toISOString()); + + return flow(omitDottedFields, setNestedFields, setTimestamp)(anomaly); +}; + +const transformAnomalyResultsToEcs = (results: AnomalyResults): SearchResponse => { + const transformedHits = results.hits.hits.map(({ _source, ...rest }) => ({ + ...rest, + _source: transformAnomalyFieldsToEcs(_source), + })); + + return { + ...results, + hits: { + ...results.hits, + hits: transformedHits, + }, + }; +}; + +export const bulkCreateMlSignals = async (params: BulkCreateMlSignalsParams) => { + const anomalyResults = params.someResult; + const ecsResults = transformAnomalyResultsToEcs(anomalyResults); + + return singleBulkCreate({ ...params, someResult: ecsResults }); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/find_ml_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/find_ml_signals.ts new file mode 100644 index 0000000000000..b7f752e6ba5e0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/find_ml_signals.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import dateMath from '@elastic/datemath'; + +import { AlertServices } from '../../../../../../../plugins/alerting/server'; + +import { getAnomalies } from '../../machine_learning'; + +export const findMlSignals = async ( + jobId: string, + anomalyThreshold: number, + from: string, + to: string, + callCluster: AlertServices['callCluster'] +) => { + const params = { + jobIds: [jobId], + threshold: anomalyThreshold, + earliestMs: dateMath.parse(from)?.valueOf() ?? 0, + latestMs: dateMath.parse(to)?.valueOf() ?? 0, + }; + const relevantAnomalies = await getAnomalies(params, callCluster); + + return relevantAnomalies; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.ts index 9c3e15de7ce90..82a50222dc351 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.ts @@ -107,6 +107,11 @@ export const getFilter = async ({ throw new BadRequestError('savedId parameter should be defined'); } } + case 'machine_learning': { + throw new BadRequestError( + 'Unsupported Rule of type "machine_learning" supplied to getFilter' + ); + } } return assertUnreachable(type); }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index bf7a97a29aef3..315a5dd88d94e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -26,8 +26,10 @@ export const mockService = { }; describe('searchAfterAndBulkCreate', () => { + let inputIndexPattern: string[] = []; beforeEach(() => { jest.clearAllMocks(); + inputIndexPattern = ['auditbeat-*']; }); test('if successful with empty search results', async () => { @@ -38,8 +40,10 @@ describe('searchAfterAndBulkCreate', () => { services: mockService, logger: mockLogger, id: sampleRuleGuid, + inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -49,6 +53,7 @@ describe('searchAfterAndBulkCreate', () => { pageSize: 1, filter: undefined, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(mockService.callCluster).toHaveBeenCalledTimes(0); expect(result).toEqual(true); @@ -93,8 +98,10 @@ describe('searchAfterAndBulkCreate', () => { services: mockService, logger: mockLogger, id: sampleRuleGuid, + inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -104,6 +111,7 @@ describe('searchAfterAndBulkCreate', () => { pageSize: 1, filter: undefined, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(mockService.callCluster).toHaveBeenCalledTimes(5); expect(result).toEqual(true); @@ -119,8 +127,10 @@ describe('searchAfterAndBulkCreate', () => { services: mockService, logger: mockLogger, id: sampleRuleGuid, + inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -130,6 +140,7 @@ describe('searchAfterAndBulkCreate', () => { pageSize: 1, filter: undefined, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(mockLogger.error).toHaveBeenCalled(); expect(result).toEqual(false); @@ -152,8 +163,10 @@ describe('searchAfterAndBulkCreate', () => { services: mockService, logger: mockLogger, id: sampleRuleGuid, + inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -163,6 +176,7 @@ describe('searchAfterAndBulkCreate', () => { pageSize: 1, filter: undefined, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(mockLogger.error).toHaveBeenCalled(); expect(result).toEqual(false); @@ -185,8 +199,10 @@ describe('searchAfterAndBulkCreate', () => { services: mockService, logger: mockLogger, id: sampleRuleGuid, + inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -196,6 +212,7 @@ describe('searchAfterAndBulkCreate', () => { pageSize: 1, filter: undefined, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(result).toEqual(true); }); @@ -220,8 +237,10 @@ describe('searchAfterAndBulkCreate', () => { services: mockService, logger: mockLogger, id: sampleRuleGuid, + inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -231,6 +250,7 @@ describe('searchAfterAndBulkCreate', () => { pageSize: 1, filter: undefined, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(result).toEqual(true); }); @@ -255,8 +275,10 @@ describe('searchAfterAndBulkCreate', () => { services: mockService, logger: mockLogger, id: sampleRuleGuid, + inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -266,6 +288,7 @@ describe('searchAfterAndBulkCreate', () => { pageSize: 1, filter: undefined, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(result).toEqual(true); }); @@ -292,8 +315,10 @@ describe('searchAfterAndBulkCreate', () => { services: mockService, logger: mockLogger, id: sampleRuleGuid, + inputIndexPattern, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -303,6 +328,7 @@ describe('searchAfterAndBulkCreate', () => { pageSize: 1, filter: undefined, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(result).toEqual(false); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts index 1cfd2f812a195..a12778d5b8f16 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -5,7 +5,7 @@ */ import { AlertServices } from '../../../../../../../plugins/alerting/server'; -import { RuleTypeParams } from '../types'; +import { RuleTypeParams, RuleAlertAction } from '../types'; import { Logger } from '../../../../../../../../src/core/server'; import { singleSearchAfter } from './single_search_after'; import { singleBulkCreate } from './single_bulk_create'; @@ -17,8 +17,10 @@ interface SearchAfterAndBulkCreateParams { services: AlertServices; logger: Logger; id: string; + inputIndexPattern: string[]; signalsIndex: string; name: string; + actions: RuleAlertAction[]; createdAt: string; createdBy: string; updatedBy: string; @@ -28,6 +30,7 @@ interface SearchAfterAndBulkCreateParams { pageSize: number; filter: unknown; tags: string[]; + throttle: string | null; } // search_after through documents and re-index using bulk endpoint. @@ -37,8 +40,10 @@ export const searchAfterAndBulkCreate = async ({ services, logger, id, + inputIndexPattern, signalsIndex, filter, + actions, name, createdAt, createdBy, @@ -48,6 +53,7 @@ export const searchAfterAndBulkCreate = async ({ enabled, pageSize, tags, + throttle, }: SearchAfterAndBulkCreateParams): Promise => { if (someResult.hits.hits.length === 0) { return true; @@ -61,6 +67,7 @@ export const searchAfterAndBulkCreate = async ({ logger, id, signalsIndex, + actions, name, createdAt, createdBy, @@ -69,6 +76,7 @@ export const searchAfterAndBulkCreate = async ({ interval, enabled, tags, + throttle, }); const totalHits = typeof someResult.hits.total === 'number' ? someResult.hits.total : someResult.hits.total.value; @@ -77,7 +85,7 @@ export const searchAfterAndBulkCreate = async ({ // If the total number of hits for the overall search result is greater than // maxSignals, default to requesting a total of maxSignals, otherwise use the // totalHits in the response from the searchAfter query. - const maxTotalHitsSize = totalHits >= ruleParams.maxSignals ? ruleParams.maxSignals : totalHits; + const maxTotalHitsSize = Math.min(totalHits, ruleParams.maxSignals); // number of docs in the current search result let hitsSize = someResult.hits.hits.length; @@ -98,7 +106,9 @@ export const searchAfterAndBulkCreate = async ({ logger.debug(`sortIds: ${sortIds}`); const searchAfterResult: SignalSearchResponse = await singleSearchAfter({ searchAfterSortId: sortId, - ruleParams, + index: inputIndexPattern, + from: ruleParams.from, + to: ruleParams.to, services, logger, filter, @@ -123,6 +133,7 @@ export const searchAfterAndBulkCreate = async ({ logger, id, signalsIndex, + actions, name, createdAt, createdBy, @@ -131,6 +142,7 @@ export const searchAfterAndBulkCreate = async ({ interval, enabled, tags, + throttle, }); logger.debug('finished next bulk index'); } catch (exc) { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_params_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_params_schema.ts index adbb5fa618957..58dd53b6447c5 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_params_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_params_schema.ts @@ -14,6 +14,7 @@ import { DEFAULT_MAX_SIGNALS } from '../../../../common/constants'; */ export const signalParamsSchema = () => schema.object({ + anomalyThreshold: schema.maybe(schema.number()), description: schema.string(), note: schema.nullable(schema.string()), falsePositives: schema.arrayOf(schema.string(), { defaultValue: [] }), @@ -27,6 +28,7 @@ export const signalParamsSchema = () => timelineId: schema.nullable(schema.string()), timelineTitle: schema.nullable(schema.string()), meta: schema.nullable(schema.object({}, { unknowns: 'allow' })), + machineLearningJobId: schema.maybe(schema.string()), query: schema.nullable(schema.string()), filters: schema.nullable(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), maxSignals: schema.number({ defaultValue: DEFAULT_MAX_SIGNALS }), @@ -37,4 +39,5 @@ export const signalParamsSchema = () => type: schema.string(), references: schema.arrayOf(schema.string(), { defaultValue: [] }), version: schema.number({ defaultValue: 1 }), + lists: schema.maybe(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts index e3ea121a9ebb1..89dcd3274ebed 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -20,6 +20,8 @@ import { writeGapErrorToSavedObject } from './write_gap_error_to_saved_object'; import { getRuleStatusSavedObjects } from './get_rule_status_saved_objects'; import { getCurrentStatusSavedObject } from './get_current_status_saved_object'; import { writeCurrentStatusSucceeded } from './write_current_status_succeeded'; +import { findMlSignals } from './find_ml_signals'; +import { bulkCreateMlSignals } from './bulk_create_ml_signals'; export const signalRulesAlertType = ({ logger, @@ -38,11 +40,13 @@ export const signalRulesAlertType = ({ }, async executor({ previousStartedAt, alertId, services, params }) { const { + anomalyThreshold, from, ruleId, index, filters, language, + machineLearningJobId, outputIndex, savedId, query, @@ -63,6 +67,7 @@ export const signalRulesAlertType = ({ }); const { + actions, name, tags, createdAt, @@ -70,6 +75,7 @@ export const signalRulesAlertType = ({ updatedBy, enabled, schedule: { interval }, + throttle, } = savedObject.attributes; const updatedAt = savedObject.updated_at ?? ''; @@ -86,33 +92,72 @@ export const signalRulesAlertType = ({ ruleStatusSavedObjects, name, }); - // set searchAfter page size to be the lesser of default page size or maxSignals. - const searchAfterSize = - DEFAULT_SEARCH_AFTER_PAGE_SIZE <= params.maxSignals - ? DEFAULT_SEARCH_AFTER_PAGE_SIZE - : params.maxSignals; + + const searchAfterSize = Math.min(params.maxSignals, DEFAULT_SEARCH_AFTER_PAGE_SIZE); + let creationSucceeded = false; + try { - const inputIndex = await getInputIndex(services, version, index); - const esFilter = await getFilter({ - type, - filters, - language, - query, - savedId, - services, - index: inputIndex, - }); + if (type === 'machine_learning') { + if (machineLearningJobId == null || anomalyThreshold == null) { + throw new Error( + `Attempted to execute machine learning rule, but it is missing job id and/or anomaly threshold for rule id: "${ruleId}", name: "${name}", signals index: "${outputIndex}", job id: "${machineLearningJobId}", anomaly threshold: "${anomalyThreshold}"` + ); + } - const noReIndex = buildEventsSearchQuery({ - index: inputIndex, - from, - to, - filter: esFilter, - size: searchAfterSize, - searchAfterSortId: undefined, - }); + const anomalyResults = await findMlSignals( + machineLearningJobId, + anomalyThreshold, + from, + to, + services.callCluster + ); + + const anomalyCount = anomalyResults.hits.hits.length; + if (anomalyCount) { + logger.info( + `Found ${anomalyCount} signals from ML anomalies for signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}", pushing signals to index "${outputIndex}"` + ); + } + + creationSucceeded = await bulkCreateMlSignals({ + actions, + throttle, + someResult: anomalyResults, + ruleParams: params, + services, + logger, + id: alertId, + signalsIndex: outputIndex, + name, + createdBy, + createdAt, + updatedBy, + updatedAt, + interval, + enabled, + tags, + }); + } else { + const inputIndex = await getInputIndex(services, version, index); + const esFilter = await getFilter({ + type, + filters, + language, + query, + savedId, + services, + index: inputIndex, + }); + + const noReIndex = buildEventsSearchQuery({ + index: inputIndex, + from, + to, + filter: esFilter, + size: searchAfterSize, + searchAfterSortId: undefined, + }); - try { logger.debug( `Starting signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"` ); @@ -130,14 +175,16 @@ export const signalRulesAlertType = ({ ); } - const bulkIndexResult = await searchAfterAndBulkCreate({ + creationSucceeded = await searchAfterAndBulkCreate({ someResult: noReIndexResult, ruleParams: params, services, logger, id: alertId, + inputIndexPattern: inputIndex, signalsIndex: outputIndex, filter: esFilter, + actions, name, createdBy, createdAt, @@ -147,47 +194,37 @@ export const signalRulesAlertType = ({ enabled, pageSize: searchAfterSize, tags, + throttle, }); + } - if (bulkIndexResult) { - logger.debug( - `Finished signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"` - ); - await writeCurrentStatusSucceeded({ - services, - currentStatusSavedObject, - }); - } else { - await writeSignalRuleExceptionToSavedObject({ - name, - alertId, - currentStatusSavedObject, - logger, - message: `Bulk Indexing signals failed. Check logs for further details \nRule name: "${name}"\nid: "${alertId}"\nrule_id: "${ruleId}"\n`, - services, - ruleStatusSavedObjects, - ruleId: ruleId ?? '(unknown rule id)', - }); - } - } catch (err) { + if (creationSucceeded) { + logger.debug( + `Finished signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}", output_index: "${outputIndex}"` + ); + await writeCurrentStatusSucceeded({ + services, + currentStatusSavedObject, + }); + } else { await writeSignalRuleExceptionToSavedObject({ name, alertId, currentStatusSavedObject, logger, - message: err?.message ?? '(no error message given)', + message: `Bulk Indexing signals failed. Check logs for further details Rule name: "${name}" id: "${alertId}" rule_id: "${ruleId}" output_index: "${outputIndex}"`, services, ruleStatusSavedObjects, ruleId: ruleId ?? '(unknown rule id)', }); } - } catch (exception) { + } catch (error) { await writeSignalRuleExceptionToSavedObject({ name, alertId, currentStatusSavedObject, logger, - message: exception?.message ?? '(no error message given)', + message: error?.message ?? '(no error message given)', services, ruleStatusSavedObjects, ruleId: ruleId ?? '(unknown rule id)', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts index 09e2c6b4fd586..afabd4c44de7d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts @@ -151,6 +151,7 @@ describe('singleBulkCreate', () => { logger: mockLogger, id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, + actions: [], name: 'rule-name', createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', @@ -159,6 +160,7 @@ describe('singleBulkCreate', () => { interval: '5m', enabled: true, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(successfulsingleBulkCreate).toEqual(true); }); @@ -182,6 +184,7 @@ describe('singleBulkCreate', () => { id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -189,6 +192,7 @@ describe('singleBulkCreate', () => { interval: '5m', enabled: true, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(successfulsingleBulkCreate).toEqual(true); }); @@ -204,6 +208,7 @@ describe('singleBulkCreate', () => { id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -211,6 +216,7 @@ describe('singleBulkCreate', () => { interval: '5m', enabled: true, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(successfulsingleBulkCreate).toEqual(true); }); @@ -227,6 +233,7 @@ describe('singleBulkCreate', () => { id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -234,6 +241,7 @@ describe('singleBulkCreate', () => { interval: '5m', enabled: true, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(mockLogger.error).not.toHaveBeenCalled(); @@ -252,6 +260,7 @@ describe('singleBulkCreate', () => { id: sampleRuleGuid, signalsIndex: DEFAULT_SIGNALS_INDEX, name: 'rule-name', + actions: [], createdAt: '2020-01-28T15:58:34.810Z', updatedAt: '2020-01-28T15:59:14.004Z', createdBy: 'elastic', @@ -259,6 +268,7 @@ describe('singleBulkCreate', () => { interval: '5m', enabled: true, tags: ['some fake tag 1', 'some fake tag 2'], + throttle: null, }); expect(mockLogger.error).toHaveBeenCalled(); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts index 7d6d6d99fa422..333a938e09d45 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts @@ -8,7 +8,7 @@ import { countBy, isEmpty } from 'lodash'; import { performance } from 'perf_hooks'; import { AlertServices } from '../../../../../../../plugins/alerting/server'; import { SignalSearchResponse, BulkResponse } from './types'; -import { RuleTypeParams } from '../types'; +import { RuleTypeParams, RuleAlertAction } from '../types'; import { generateId } from './utils'; import { buildBulkBody } from './build_bulk_body'; import { Logger } from '../../../../../../../../src/core/server'; @@ -20,6 +20,7 @@ interface SingleBulkCreateParams { logger: Logger; id: string; signalsIndex: string; + actions: RuleAlertAction[]; name: string; createdAt: string; createdBy: string; @@ -28,6 +29,7 @@ interface SingleBulkCreateParams { interval: string; enabled: boolean; tags: string[]; + throttle: string | null; } /** @@ -60,6 +62,7 @@ export const singleBulkCreate = async ({ logger, id, signalsIndex, + actions, name, createdAt, createdBy, @@ -68,6 +71,7 @@ export const singleBulkCreate = async ({ interval, enabled, tags, + throttle, }: SingleBulkCreateParams): Promise => { someResult.hits.hits = filterDuplicateRules(id, someResult); @@ -99,6 +103,7 @@ export const singleBulkCreate = async ({ doc, ruleParams, id, + actions, name, createdAt, createdBy, @@ -107,6 +112,7 @@ export const singleBulkCreate = async ({ interval, enabled, tags, + throttle, }), ]); const start = performance.now(); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts index a5d1f66d3089e..1685c6518def3 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts @@ -6,7 +6,6 @@ import { savedObjectsClientMock } from 'src/core/server/mocks'; import { - sampleRuleAlertParams, sampleDocSearchResultsNoSortId, mockLogger, sampleDocSearchResultsWithSortId, @@ -26,12 +25,13 @@ describe('singleSearchAfter', () => { test('if singleSearchAfter works without a given sort id', async () => { let searchAfterSortId; - const sampleParams = sampleRuleAlertParams(); mockService.callCluster.mockReturnValue(sampleDocSearchResultsNoSortId); await expect( singleSearchAfter({ searchAfterSortId, - ruleParams: sampleParams, + index: [], + from: 'now-360s', + to: 'now', services: mockService, logger: mockLogger, pageSize: 1, @@ -41,11 +41,12 @@ describe('singleSearchAfter', () => { }); test('if singleSearchAfter works with a given sort id', async () => { const searchAfterSortId = '1234567891111'; - const sampleParams = sampleRuleAlertParams(); mockService.callCluster.mockReturnValue(sampleDocSearchResultsWithSortId); const searchAfterResult = await singleSearchAfter({ searchAfterSortId, - ruleParams: sampleParams, + index: [], + from: 'now-360s', + to: 'now', services: mockService, logger: mockLogger, pageSize: 1, @@ -55,14 +56,15 @@ describe('singleSearchAfter', () => { }); test('if singleSearchAfter throws error', async () => { const searchAfterSortId = '1234567891111'; - const sampleParams = sampleRuleAlertParams(); mockService.callCluster.mockImplementation(async () => { throw Error('Fake Error'); }); await expect( singleSearchAfter({ searchAfterSortId, - ruleParams: sampleParams, + index: [], + from: 'now-360s', + to: 'now', services: mockService, logger: mockLogger, pageSize: 1, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.ts index a0e7047ad1cd6..bb12b5a802f8f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.ts @@ -5,14 +5,15 @@ */ import { AlertServices } from '../../../../../../../plugins/alerting/server'; -import { RuleTypeParams } from '../types'; import { Logger } from '../../../../../../../../src/core/server'; import { SignalSearchResponse } from './types'; import { buildEventsSearchQuery } from './build_events_query'; interface SingleSearchAfterParams { searchAfterSortId: string | undefined; - ruleParams: RuleTypeParams; + index: string[]; + from: string; + to: string; services: AlertServices; logger: Logger; pageSize: number; @@ -22,7 +23,9 @@ interface SingleSearchAfterParams { // utilize search_after for paging results into bulk. export const singleSearchAfter = async ({ searchAfterSortId, - ruleParams, + index, + from, + to, services, filter, logger, @@ -33,9 +36,9 @@ export const singleSearchAfter = async ({ } try { const searchAfterQuery = buildEventsSearchQuery({ - index: ruleParams.index, - from: ruleParams.from, - to: ruleParams.to, + index, + from, + to, filter, size: pageSize, searchAfterSortId, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts index eaed3f2ead3a5..06acff825f68e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RuleAlertParams, OutputRuleAlertRest } from '../types'; +import { RuleAlertParams, OutputRuleAlertRest, RuleAlertAction } from '../types'; import { SearchResponse } from '../../types'; import { AlertType, @@ -104,7 +104,7 @@ export interface GetResponse { } export type SignalSearchResponse = SearchResponse; -export type SignalSourceHit = SignalSearchResponse['hits']['hits'][0]; +export type SignalSourceHit = SignalSearchResponse['hits']['hits'][number]; export type RuleExecutorOptions = Omit & { params: RuleAlertParams & { @@ -147,6 +147,7 @@ export interface SignalHit { } export interface AlertAttributes { + actions: RuleAlertAction[]; enabled: boolean; name: string; tags: string[]; @@ -156,4 +157,5 @@ export interface AlertAttributes { schedule: { interval: string; }; + throttle: string | null; } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts index fa43ac1debb92..2cbdc7db3ba64 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AlertAction } from '../../../../../../plugins/alerting/common'; import { CallAPIOptions } from '../../../../../../../src/core/server'; import { Filter } from '../../../../../../../src/plugins/data/server'; import { IRuleStatusAttributes } from './rules/types'; +import { ListsDefaultArraySchema } from './routes/schemas/types/lists_default_array'; export type PartialFilter = Partial; @@ -22,7 +24,19 @@ export interface ThreatParams { technique: IMitreAttack[]; } +export type RuleAlertAction = Omit & { + action_type_id: string; +}; + +// Notice below we are using lists: ListsDefaultArraySchema[]; which is coming directly from the response output section. +// TODO: Eventually this whole RuleAlertParams will be replaced with io-ts. For now we can slowly strangle it out and reduce duplicate types +// We don't have the input types defined through io-ts just yet but as we being introducing types from there we will more and more remove +// types and share them between input and output schema but have an input Rule Schema and an output Rule Schema. +export type RuleType = 'query' | 'saved_query' | 'machine_learning'; + export interface RuleAlertParams { + actions: RuleAlertAction[]; + anomalyThreshold: number | undefined; description: string; note: string | undefined | null; enabled: boolean; @@ -30,11 +44,12 @@ export interface RuleAlertParams { filters: PartialFilter[] | undefined | null; from: string; immutable: boolean; - index: string[]; + index: string[] | undefined | null; interval: string; ruleId: string | undefined | null; language: string | undefined | null; maxSignals: number; + machineLearningJobId: string | undefined; riskScore: number; outputIndex: string; name: string; @@ -48,19 +63,25 @@ export interface RuleAlertParams { timelineId: string | undefined | null; timelineTitle: string | undefined | null; threat: ThreatParams[] | undefined | null; - type: 'query' | 'saved_query'; + type: RuleType; version: number; - throttle?: string; + throttle: string | null; + lists: ListsDefaultArraySchema | null | undefined; } -export type RuleTypeParams = Omit; +export type RuleTypeParams = Omit< + RuleAlertParams, + 'name' | 'enabled' | 'interval' | 'tags' | 'actions' | 'throttle' +>; export type RuleAlertParamsRest = Omit< RuleAlertParams, + | 'anomalyThreshold' | 'ruleId' | 'falsePositives' | 'immutable' | 'maxSignals' + | 'machineLearningJobId' | 'savedId' | 'riskScore' | 'timelineId' @@ -77,12 +98,14 @@ export type RuleAlertParamsRest = Omit< | 'lastSuccessMessage' | 'lastFailureMessage' > & { + anomaly_threshold: RuleAlertParams['anomalyThreshold']; rule_id: RuleAlertParams['ruleId']; false_positives: RuleAlertParams['falsePositives']; saved_id?: RuleAlertParams['savedId']; timeline_id: RuleAlertParams['timelineId']; timeline_title: RuleAlertParams['timelineTitle']; max_signals: RuleAlertParams['maxSignals']; + machine_learning_job_id: RuleAlertParams['machineLearningJobId']; risk_score: RuleAlertParams['riskScore']; output_index: RuleAlertParams['outputIndex']; created_at: string; diff --git a/x-pack/legacy/plugins/siem/server/lib/machine_learning/index.ts b/x-pack/legacy/plugins/siem/server/lib/machine_learning/index.ts new file mode 100644 index 0000000000000..aa83df15f68d4 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/machine_learning/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchResponse } from 'elasticsearch'; + +import { AlertServices } from '../../../../../../plugins/alerting/server'; +import { AnomalyRecordDoc as Anomaly } from '../../../../../../plugins/ml/common/types/anomalies'; + +export { Anomaly }; +export type AnomalyResults = SearchResponse; + +export interface AnomaliesSearchParams { + jobIds: string[]; + threshold: number; + earliestMs: number; + latestMs: number; + maxRecords?: number; +} + +export const getAnomalies = async ( + params: AnomaliesSearchParams, + callCluster: AlertServices['callCluster'] +): Promise => { + const boolCriteria = buildCriteria(params); + + return callCluster('search', { + index: '.ml-anomalies-*', + size: params.maxRecords || 100, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], + }, + }, + sort: [{ record_score: { order: 'desc' } }], + }, + }); +}; + +const buildCriteria = (params: AnomaliesSearchParams): object[] => { + const { earliestMs, jobIds, latestMs, threshold } = params; + const jobIdsFilterable = jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*'); + + const boolCriteria: object[] = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + { + range: { + record_score: { + gte: threshold, + }, + }, + }, + ]; + + if (jobIdsFilterable) { + const jobIdFilter = jobIds.map(jobId => `job_id:${jobId}`).join(' OR '); + + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilter, + }, + }); + } + + return boolCriteria; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts index d825aae1b480b..b6a43fc523adb 100644 --- a/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts @@ -194,7 +194,7 @@ export class Note { } } -const convertSavedObjectToSavedNote = ( +export const convertSavedObjectToSavedNote = ( savedObject: unknown, timelineVersion?: string | undefined | null ): NoteSavedObject => diff --git a/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts index afa3595a09e1c..9ea950e8a443b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts @@ -180,7 +180,7 @@ export class PinnedEvent { } } -const convertSavedObjectToSavedPinnedEvent = ( +export const convertSavedObjectToSavedPinnedEvent = ( savedObject: unknown, timelineVersion?: string | undefined | null ): PinnedEventSavedObject => diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts new file mode 100644 index 0000000000000..eae1ece7e789d --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts @@ -0,0 +1,250 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TIMELINE_EXPORT_URL } from '../../../../../common/constants'; +import { requestMock } from '../../../detection_engine/routes/__mocks__'; + +export const getExportTimelinesRequest = () => + requestMock.create({ + method: 'get', + path: TIMELINE_EXPORT_URL, + body: { + ids: ['f0e58720-57b6-11ea-b88d-3f1a31716be8', '890b8ae0-57df-11ea-a7c9-3976b7f1cb37'], + }, + }); + +export const mockTimelinesSavedObjects = () => ({ + saved_objects: [ + { + id: 'f0e58720-57b6-11ea-b88d-3f1a31716be8', + type: 'fakeType', + attributes: {}, + references: [], + }, + { + id: '890b8ae0-57df-11ea-a7c9-3976b7f1cb37', + type: 'fakeType', + attributes: {}, + references: [], + }, + ], +}); + +export const mockTimelines = () => ({ + saved_objects: [ + { + savedObjectId: 'f0e58720-57b6-11ea-b88d-3f1a31716be8', + version: 'Wzk0OSwxXQ==', + columns: [ + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: '@timestamp', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'message', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'event.category', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'event.action', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'host.name', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'source.ip', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'destination.ip', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'user.name', + searchable: null, + }, + ], + dataProviders: [], + description: 'with a global note', + eventType: 'raw', + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + kuery: { kind: 'kuery', expression: 'zeek.files.sha1 : * ' }, + serializedQuery: + '{"bool":{"should":[{"exists":{"field":"zeek.files.sha1"}}],"minimum_should_match":1}}', + }, + }, + title: 'test no.2', + dateRange: { start: 1582538951145, end: 1582625351145 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1582625382448, + createdBy: 'elastic', + updated: 1583741197521, + updatedBy: 'elastic', + }, + { + savedObjectId: '890b8ae0-57df-11ea-a7c9-3976b7f1cb37', + version: 'Wzk0NywxXQ==', + columns: [ + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: '@timestamp', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'message', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'event.category', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'event.action', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'host.name', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'source.ip', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'destination.ip', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'user.name', + searchable: null, + }, + ], + dataProviders: [], + description: 'with an event note', + eventType: 'raw', + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + serializedQuery: + '{"bool":{"should":[{"exists":{"field":"zeek.files.sha1"}}],"minimum_should_match":1}}', + kuery: { expression: 'zeek.files.sha1 : * ', kind: 'kuery' }, + }, + }, + title: 'test no.3', + dateRange: { start: 1582538951145, end: 1582625351145 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1582642817439, + createdBy: 'elastic', + updated: 1583741175216, + updatedBy: 'elastic', + }, + ], +}); + +export const mockNotesSavedObjects = () => ({ + saved_objects: [ + { + id: 'eb3f3930-61dc-11ea-8a49-e77254c5b742', + type: 'fakeType', + attributes: {}, + references: [], + }, + { + id: '706e7510-5d52-11ea-8f07-0392944939c1', + type: 'fakeType', + attributes: {}, + references: [], + }, + ], +}); + +export const mockNotes = () => ({ + saved_objects: [ + { + noteId: 'eb3f3930-61dc-11ea-8a49-e77254c5b742', + version: 'Wzk1MCwxXQ==', + note: 'Global note', + timelineId: 'f0e58720-57b6-11ea-b88d-3f1a31716be8', + created: 1583741205473, + createdBy: 'elastic', + updated: 1583741205473, + updatedBy: 'elastic', + }, + { + noteId: '706e7510-5d52-11ea-8f07-0392944939c1', + version: 'WzEwMiwxXQ==', + eventId: '6HW_eHABMQha2n6bHvQ0', + note: 'this is a note!!', + timelineId: '890b8ae0-57df-11ea-a7c9-3976b7f1cb37', + created: 1583241924223, + createdBy: 'elastic', + updated: 1583241924223, + updatedBy: 'elastic', + }, + ], +}); + +export const mockPinnedEvents = () => ({ + saved_objects: [], +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts new file mode 100644 index 0000000000000..fe434b5399212 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + mockTimelines, + mockNotes, + mockTimelinesSavedObjects, + mockPinnedEvents, + getExportTimelinesRequest, +} from './__mocks__/request_responses'; +import { exportTimelinesRoute } from './export_timelines_route'; +import { + serverMock, + requestContextMock, + requestMock, +} from '../../detection_engine/routes/__mocks__'; +import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; +import { convertSavedObjectToSavedNote } from '../../note/saved_object'; +import { convertSavedObjectToSavedPinnedEvent } from '../../pinned_event/saved_object'; +import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; +jest.mock('../convert_saved_object_to_savedtimeline', () => { + return { + convertSavedObjectToSavedTimeline: jest.fn(), + }; +}); + +jest.mock('../../note/saved_object', () => { + return { + convertSavedObjectToSavedNote: jest.fn(), + }; +}); + +jest.mock('../../pinned_event/saved_object', () => { + return { + convertSavedObjectToSavedPinnedEvent: jest.fn(), + }; +}); +describe('export timelines', () => { + let server: ReturnType; + let { clients, context } = requestContextMock.createTools(); + const config = jest.fn().mockImplementation(() => { + return { + get: () => { + return 100; + }, + has: jest.fn(), + }; + }); + + beforeEach(() => { + server = serverMock.create(); + ({ clients, context } = requestContextMock.createTools()); + + clients.savedObjectsClient.bulkGet.mockResolvedValue(mockTimelinesSavedObjects()); + + ((convertSavedObjectToSavedTimeline as unknown) as jest.Mock).mockReturnValue(mockTimelines()); + ((convertSavedObjectToSavedNote as unknown) as jest.Mock).mockReturnValue(mockNotes()); + ((convertSavedObjectToSavedPinnedEvent as unknown) as jest.Mock).mockReturnValue( + mockPinnedEvents() + ); + exportTimelinesRoute(server.router, config); + }); + + describe('status codes', () => { + test('returns 200 when finding selected timelines', async () => { + const response = await server.inject(getExportTimelinesRequest(), context); + expect(response.status).toEqual(200); + }); + + test('catch error when status search throws error', async () => { + clients.savedObjectsClient.bulkGet.mockReset(); + clients.savedObjectsClient.bulkGet.mockRejectedValue(new Error('Test error')); + const response = await server.inject(getExportTimelinesRequest(), context); + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + message: 'Test error', + status_code: 500, + }); + }); + }); + + describe('request validation', () => { + test('disallows singular id query param', async () => { + const request = requestMock.create({ + method: 'get', + path: TIMELINE_EXPORT_URL, + body: { id: 'someId' }, + }); + const result = server.validate(request); + + expect(result.badRequest).toHaveBeenCalledWith('"id" is not allowed'); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts new file mode 100644 index 0000000000000..3ded959aced36 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { set as _set } from 'lodash/fp'; +import { IRouter } from '../../../../../../../../src/core/server'; +import { LegacyServices } from '../../../types'; +import { ExportTimelineRequestParams } from '../types'; + +import { + transformError, + buildRouteValidation, + buildSiemResponse, +} from '../../detection_engine/routes/utils'; +import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; + +import { + exportTimelinesSchema, + exportTimelinesQuerySchema, +} from './schemas/export_timelines_schema'; + +import { getExportTimelineByObjectIds } from './utils'; + +export const exportTimelinesRoute = (router: IRouter, config: LegacyServices['config']) => { + router.post( + { + path: TIMELINE_EXPORT_URL, + validate: { + query: buildRouteValidation( + exportTimelinesQuerySchema + ), + body: buildRouteValidation(exportTimelinesSchema), + }, + options: { + tags: ['access:siem'], + }, + }, + async (context, request, response) => { + try { + const siemResponse = buildSiemResponse(response); + const savedObjectsClient = context.core.savedObjects.client; + const exportSizeLimit = config().get('savedObjects.maxImportExportSize'); + if (request.body?.ids != null && request.body.ids.length > exportSizeLimit) { + return siemResponse.error({ + statusCode: 400, + body: `Can't export more than ${exportSizeLimit} timelines`, + }); + } + + const responseBody = await getExportTimelineByObjectIds({ + client: savedObjectsClient, + request, + }); + + return response.ok({ + headers: { + 'Content-Disposition': `attachment; filename="${request.query.file_name}"`, + 'Content-Type': 'application/ndjson', + }, + body: responseBody, + }); + } catch (err) { + const error = transformError(err); + const siemResponse = buildSiemResponse(response); + + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.ts new file mode 100644 index 0000000000000..04edbbd7046c9 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import Joi from 'joi'; + +/* eslint-disable @typescript-eslint/camelcase */ +import { ids, exclude_export_details, file_name } from './schemas'; +/* eslint-disable @typescript-eslint/camelcase */ + +export const exportTimelinesSchema = Joi.object({ + ids, +}).min(1); + +export const exportTimelinesQuerySchema = Joi.object({ + file_name: file_name.default('export.ndjson'), + exclude_export_details: exclude_export_details.default(false), +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts new file mode 100644 index 0000000000000..67697c347634e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.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; + * you may not use this file except in compliance with the Elastic License. + */ +import Joi from 'joi'; + +/* eslint-disable @typescript-eslint/camelcase */ + +export const ids = Joi.array().items(Joi.string()); + +export const exclude_export_details = Joi.boolean(); +export const file_name = Joi.string(); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts new file mode 100644 index 0000000000000..066862e025833 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { set as _set } from 'lodash/fp'; +import { + SavedObjectsFindOptions, + SavedObjectsFindResponse, +} from '../../../../../../../../src/core/server'; + +import { + ExportTimelineSavedObjectsClient, + ExportTimelineRequest, + ExportedNotes, + TimelineSavedObject, + ExportedTimelines, +} from '../types'; +import { + timelineSavedObjectType, + noteSavedObjectType, + pinnedEventSavedObjectType, +} from '../../../saved_objects'; + +import { convertSavedObjectToSavedNote } from '../../note/saved_object'; +import { convertSavedObjectToSavedPinnedEvent } from '../../pinned_event/saved_object'; +import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; +import { transformDataToNdjson } from '../../detection_engine/routes/rules/utils'; +import { NoteSavedObject } from '../../note/types'; +import { PinnedEventSavedObject } from '../../pinned_event/types'; + +const getAllSavedPinnedEvents = ( + pinnedEventsSavedObjects: SavedObjectsFindResponse +): PinnedEventSavedObject[] => { + return pinnedEventsSavedObjects != null + ? pinnedEventsSavedObjects.saved_objects.map(savedObject => + convertSavedObjectToSavedPinnedEvent(savedObject) + ) + : []; +}; + +const getPinnedEventsByTimelineId = ( + savedObjectsClient: ExportTimelineSavedObjectsClient, + timelineId: string +): Promise> => { + const options: SavedObjectsFindOptions = { + type: pinnedEventSavedObjectType, + search: timelineId, + searchFields: ['timelineId'], + }; + return savedObjectsClient.find(options); +}; + +const getAllSavedNote = ( + noteSavedObjects: SavedObjectsFindResponse +): NoteSavedObject[] => { + return noteSavedObjects != null + ? noteSavedObjects.saved_objects.map(savedObject => convertSavedObjectToSavedNote(savedObject)) + : []; +}; + +const getNotesByTimelineId = ( + savedObjectsClient: ExportTimelineSavedObjectsClient, + timelineId: string +): Promise> => { + const options: SavedObjectsFindOptions = { + type: noteSavedObjectType, + search: timelineId, + searchFields: ['timelineId'], + }; + + return savedObjectsClient.find(options); +}; + +const getGlobalEventNotesByTimelineId = (currentNotes: NoteSavedObject[]): ExportedNotes => { + const initialNotes: ExportedNotes = { + eventNotes: [], + globalNotes: [], + }; + + return ( + currentNotes.reduce((acc, note) => { + if (note.eventId == null) { + return { + ...acc, + globalNotes: [...acc.globalNotes, note], + }; + } else { + return { + ...acc, + eventNotes: [...acc.eventNotes, note], + }; + } + }, initialNotes) ?? initialNotes + ); +}; + +const getPinnedEventsIdsByTimelineId = ( + currentPinnedEvents: PinnedEventSavedObject[] +): string[] => { + return currentPinnedEvents.map(event => event.eventId) ?? []; +}; + +const getTimelines = async ( + savedObjectsClient: ExportTimelineSavedObjectsClient, + timelineIds: string[] +) => { + const savedObjects = await Promise.resolve( + savedObjectsClient.bulkGet( + timelineIds.reduce( + (acc, timelineId) => [...acc, { id: timelineId, type: timelineSavedObjectType }], + [] as Array<{ id: string; type: string }> + ) + ) + ); + + const timelineObjects: TimelineSavedObject[] | undefined = + savedObjects != null + ? savedObjects.saved_objects.map((savedObject: unknown) => { + return convertSavedObjectToSavedTimeline(savedObject); + }) + : []; + + return timelineObjects; +}; + +const getTimelinesFromObjects = async ( + savedObjectsClient: ExportTimelineSavedObjectsClient, + request: ExportTimelineRequest +): Promise => { + const timelines: TimelineSavedObject[] = await getTimelines(savedObjectsClient, request.body.ids); + // To Do for feature freeze + // if (timelines.length !== request.body.ids.length) { + // //figure out which is missing to tell user + // } + + const [notes, pinnedEventIds] = await Promise.all([ + Promise.all( + request.body.ids.map(timelineId => getNotesByTimelineId(savedObjectsClient, timelineId)) + ), + Promise.all( + request.body.ids.map(timelineId => + getPinnedEventsByTimelineId(savedObjectsClient, timelineId) + ) + ), + ]); + + const myNotes = notes.reduce( + (acc, note) => [...acc, ...getAllSavedNote(note)], + [] + ); + + const myPinnedEventIds = pinnedEventIds.reduce( + (acc, pinnedEventId) => [...acc, ...getAllSavedPinnedEvents(pinnedEventId)], + [] + ); + + const myResponse = request.body.ids.reduce((acc, timelineId) => { + const myTimeline = timelines.find(t => t.savedObjectId === timelineId); + if (myTimeline != null) { + const timelineNotes = myNotes.filter(n => n.timelineId === timelineId); + const timelinePinnedEventIds = myPinnedEventIds.filter(p => p.timelineId === timelineId); + return [ + ...acc, + { + ...myTimeline, + ...getGlobalEventNotesByTimelineId(timelineNotes), + pinnedEventIds: getPinnedEventsIdsByTimelineId(timelinePinnedEventIds), + }, + ]; + } + return acc; + }, []); + + return myResponse ?? []; +}; + +export const getExportTimelineByObjectIds = async ({ + client, + request, +}: { + client: ExportTimelineSavedObjectsClient; + request: ExportTimelineRequest; +}) => { + const timeline = await getTimelinesFromObjects(client, request); + return transformDataToNdjson(timeline); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts index 4b78a7bd3d06d..88d7fcdb68164 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts @@ -271,7 +271,7 @@ export const convertStringToBase64 = (text: string): string => Buffer.from(text) // then this interface does not allow types without index signature // this is limiting us with our type for now so the easy way was to use any -const timelineWithReduxProperties = ( +export const timelineWithReduxProperties = ( notes: NoteSavedObject[], pinnedEvents: PinnedEventSavedObject[], timeline: TimelineSavedObject, @@ -279,7 +279,9 @@ const timelineWithReduxProperties = ( ): TimelineSavedObject => ({ ...timeline, favorite: - timeline.favorite != null ? timeline.favorite.filter(fav => fav.userName === userName) : [], + timeline.favorite != null && userName != null + ? timeline.favorite.filter(fav => fav.userName === userName) + : [], eventIdToNoteIds: notes.filter(note => note.eventId != null), noteIds: notes .filter(note => note.eventId == null && note.noteId != null) diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts index d757ea8049bc1..35bf86c17db7e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts @@ -9,8 +9,12 @@ import * as runtimeTypes from 'io-ts'; import { unionWithNullType } from '../framework'; -import { NoteSavedObjectToReturnRuntimeType } from '../note/types'; -import { PinnedEventToReturnSavedObjectRuntimeType } from '../pinned_event/types'; +import { NoteSavedObjectToReturnRuntimeType, NoteSavedObject } from '../note/types'; +import { + PinnedEventToReturnSavedObjectRuntimeType, + PinnedEventSavedObject, +} from '../pinned_event/types'; +import { SavedObjectsClient, KibanaRequest } from '../../../../../../../src/core/server'; /* * ColumnHeader Types @@ -199,3 +203,54 @@ export const AllTimelineSavedObjectRuntimeType = runtimeTypes.type({ export interface AllTimelineSavedObject extends runtimeTypes.TypeOf {} + +export interface ExportTimelineRequestParams { + body: { ids: string[] }; + query: { + file_name: string; + exclude_export_details: boolean; + }; +} + +export type ExportTimelineRequest = KibanaRequest< + unknown, + ExportTimelineRequestParams['query'], + ExportTimelineRequestParams['body'], + 'post' +>; + +export type ExportTimelineSavedObjectsClient = Pick< + SavedObjectsClient, + | 'get' + | 'errors' + | 'create' + | 'bulkCreate' + | 'delete' + | 'find' + | 'bulkGet' + | 'update' + | 'bulkUpdate' +>; + +export type ExportedGlobalNotes = Array>; +export type ExportedEventNotes = NoteSavedObject[]; + +export interface ExportedNotes { + eventNotes: ExportedEventNotes; + globalNotes: ExportedGlobalNotes; +} + +export type ExportedTimelines = TimelineSavedObject & + ExportedNotes & { + pinnedEventIds: string[]; + }; + +export interface BulkGetInput { + type: string; + id: string; +} + +export type NotesAndPinnedEventsByTimelineId = Record< + string, + { notes: NoteSavedObject[]; pinnedEvents: PinnedEventSavedObject[] } +>; diff --git a/x-pack/legacy/plugins/siem/server/plugin.ts b/x-pack/legacy/plugins/siem/server/plugin.ts index d9d381498fb56..c505edc79bc76 100644 --- a/x-pack/legacy/plugins/siem/server/plugin.ts +++ b/x-pack/legacy/plugins/siem/server/plugin.ts @@ -34,6 +34,7 @@ import { ruleStatusSavedObjectType, } from './saved_objects'; import { SiemClientFactory } from './client'; +import { hasListsFeature, listsEnvFeatureFlagName } from './lib/detection_engine/feature_flags'; export { CoreSetup, CoreStart }; @@ -66,6 +67,12 @@ export class Plugin { public setup(core: CoreSetup, plugins: SetupPlugins, __legacy: LegacyServices) { this.logger.debug('Shim plugin setup'); + if (hasListsFeature()) { + // TODO: Remove this once we have the lists feature supported + this.logger.error( + `You have activated the lists feature flag which is NOT currently supported for SIEM! You should turn this feature flag off immediately by un-setting the environment variable: ${listsEnvFeatureFlagName} and restarting Kibana` + ); + } const router = core.http.createRouter(); core.http.registerRouteHandlerContext(this.name, (context, request, response) => ({ diff --git a/x-pack/legacy/plugins/siem/server/routes/index.ts b/x-pack/legacy/plugins/siem/server/routes/index.ts index 08bdfc3aa5d4f..08ff9208ce20b 100644 --- a/x-pack/legacy/plugins/siem/server/routes/index.ts +++ b/x-pack/legacy/plugins/siem/server/routes/index.ts @@ -29,6 +29,7 @@ import { importRulesRoute } from '../lib/detection_engine/routes/rules/import_ru import { exportRulesRoute } from '../lib/detection_engine/routes/rules/export_rules_route'; import { findRulesStatusesRoute } from '../lib/detection_engine/routes/rules/find_rules_status_route'; import { getPrepackagedRulesStatusRoute } from '../lib/detection_engine/routes/rules/get_prepackaged_rules_status_route'; +import { exportTimelinesRoute } from '../lib/timeline/routes/export_timelines_route'; export const initRoutes = ( router: IRouter, @@ -54,6 +55,8 @@ export const initRoutes = ( importRulesRoute(router, config); exportRulesRoute(router, config); + exportTimelinesRoute(router, config); + findRulesStatusesRoute(router); // Detection Engine Signals routes that have the REST endpoints of /api/detection_engine/signals diff --git a/x-pack/legacy/plugins/triggers_actions_ui/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/index.ts index e871573b266a7..eb74290c84682 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/index.ts +++ b/x-pack/legacy/plugins/triggers_actions_ui/index.ts @@ -24,18 +24,11 @@ export function triggersActionsUI(kibana: any) { return Joi.object() .keys({ enabled: Joi.boolean().default(true), - createAlertUiEnabled: Joi.boolean().default(false), }) .default(); }, uiExports: { styleSheetPaths: resolve(__dirname, 'public/index.scss'), - injectDefaultVars(server: Legacy.Server) { - const serverConfig = server.config(); - return { - createAlertUiEnabled: serverConfig.get('xpack.triggers_actions_ui.createAlertUiEnabled'), - }; - }, }, }); } diff --git a/x-pack/legacy/plugins/uptime/README.md b/x-pack/legacy/plugins/uptime/README.md index 308f78ecdc368..2ed0e2fc77cbc 100644 --- a/x-pack/legacy/plugins/uptime/README.md +++ b/x-pack/legacy/plugins/uptime/README.md @@ -62,3 +62,13 @@ You can login with username `elastic` and password `changeme` by default. If you want to freeze a UI or API test you can include an async call like `await new Promise(r => setTimeout(r, 1000 * 60))` to freeze the execution for 60 seconds if you need to click around or check things in the state that is loaded. + +#### Running --ssl tests + +Some of our tests require there to be an SSL connection between Kibana and Elasticsearch. + +We can run these tests like described above, but with some special config. + +`node scripts/functional_tests_server.js --config=test/functional_with_es_ssl/config.ts` + +`node scripts/functional_test_runner.js --config=test/functional_with_es_ssl/config.ts` diff --git a/x-pack/legacy/plugins/uptime/common/constants/alerts.ts b/x-pack/legacy/plugins/uptime/common/constants/alerts.ts new file mode 100644 index 0000000000000..c0db9ae309843 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/constants/alerts.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +interface ActionGroupDefinition { + id: string; + name: string; +} + +type ActionGroupDefinitions = Record; + +export const ACTION_GROUP_DEFINITIONS: ActionGroupDefinitions = { + MONITOR_STATUS: { + id: 'xpack.uptime.alerts.actionGroups.monitorStatus', + name: 'Uptime Down Monitor', + }, +}; diff --git a/x-pack/legacy/plugins/uptime/common/constants/index.ts b/x-pack/legacy/plugins/uptime/common/constants/index.ts index 0425fc19a7b45..19f2de3c6f0f4 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/index.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export { ACTION_GROUP_DEFINITIONS } from './alerts'; export { CHART_FORMAT_LIMITS } from './chart_format_limits'; export { CLIENT_DEFAULTS } from './client_defaults'; export { CONTEXT_DEFAULTS } from './context_defaults'; diff --git a/x-pack/legacy/plugins/uptime/common/constants/index_names.ts b/x-pack/legacy/plugins/uptime/common/constants/index_names.ts index e9c6b1e1106ab..9f33d280a1268 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/index_names.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/index_names.ts @@ -6,5 +6,4 @@ export const INDEX_NAMES = { HEARTBEAT: 'heartbeat-8*', - HEARTBEAT_STATES: 'heartbeat-states-8*', }; diff --git a/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts b/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts index f09c795977831..61197d6dc373d 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts @@ -4,6 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -export enum REST_API_URLS { +export enum API_URLS { + INDEX_PATTERN = `/api/uptime/index_pattern`, INDEX_STATUS = '/api/uptime/index_status', + MONITOR_LOCATIONS = `/api/uptime/monitor/locations`, + MONITOR_DURATION = `/api/uptime/monitor/duration`, + MONITOR_DETAILS = `/api/uptime/monitor/details`, + MONITOR_SELECTED = `/api/uptime/monitor/selected`, + MONITOR_STATUS = `/api/uptime/monitor/status`, + PINGS = '/api/uptime/pings', + PING_HISTOGRAM = `/api/uptime/ping/histogram`, + SNAPSHOT_COUNT = `/api/uptime/snapshot/count`, + FILTERS = `/api/uptime/filters`, } diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/index.ts new file mode 100644 index 0000000000000..ee284249c38c0 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + StatusCheckAlertStateType, + StatusCheckAlertState, + StatusCheckExecutorParamsType, + StatusCheckExecutorParams, +} from './status_check'; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/status_check.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/status_check.ts new file mode 100644 index 0000000000000..bc234b268df27 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/alerts/status_check.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; + +export const StatusCheckAlertStateType = t.intersection([ + t.partial({ + currentTriggerStarted: t.string, + firstTriggeredAt: t.string, + lastTriggeredAt: t.string, + lastResolvedAt: t.string, + }), + t.type({ + firstCheckedAt: t.string, + lastCheckedAt: t.string, + isTriggered: t.boolean, + }), +]); + +export type StatusCheckAlertState = t.TypeOf; + +export const StatusCheckExecutorParamsType = t.intersection([ + t.partial({ + filters: t.string, + }), + t.type({ + locations: t.array(t.string), + numTimes: t.number, + timerange: t.type({ + from: t.string, + to: t.string, + }), + }), +]); + +export type StatusCheckExecutorParams = t.TypeOf; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts index 58f79abcf91ec..82fc9807300ed 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './alerts'; export * from './common'; export * from './monitor'; export * from './overview_filters'; diff --git a/x-pack/legacy/plugins/uptime/index.ts b/x-pack/legacy/plugins/uptime/index.ts index feecef5857895..f52ad8ce867b6 100644 --- a/x-pack/legacy/plugins/uptime/index.ts +++ b/x-pack/legacy/plugins/uptime/index.ts @@ -14,7 +14,7 @@ export const uptime = (kibana: any) => configPrefix: 'xpack.uptime', id: PLUGIN.ID, publicDir: resolve(__dirname, 'public'), - require: ['kibana', 'elasticsearch', 'xpack_main'], + require: ['alerting', 'kibana', 'elasticsearch', 'xpack_main'], uiExports: { app: { description: i18n.translate('xpack.uptime.pluginDescription', { diff --git a/x-pack/legacy/plugins/uptime/public/apps/index.ts b/x-pack/legacy/plugins/uptime/public/apps/index.ts index d322c35364d1a..d58bf8398fcde 100644 --- a/x-pack/legacy/plugins/uptime/public/apps/index.ts +++ b/x-pack/legacy/plugins/uptime/public/apps/index.ts @@ -8,8 +8,9 @@ import { npSetup } from 'ui/new_platform'; import { Plugin } from './plugin'; import 'uiExports/embeddableFactories'; -new Plugin({ +const plugin = new Plugin({ opaqueId: Symbol('uptime'), env: {} as any, config: { get: () => ({} as any) }, -}).setup(npSetup); +}); +plugin.setup(npSetup); diff --git a/x-pack/legacy/plugins/uptime/public/apps/plugin.ts b/x-pack/legacy/plugins/uptime/public/apps/plugin.ts index 2204d7e4097dd..eec49418910f8 100644 --- a/x-pack/legacy/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/legacy/plugins/uptime/public/apps/plugin.ts @@ -36,6 +36,7 @@ export class Plugin { public setup(setup: SetupObject) { const { core, plugins } = setup; const { home } = plugins; + home.featureCatalogue.register({ category: FeatureCatalogueCategory.DATA, description: PLUGIN.DESCRIPTION, @@ -45,6 +46,7 @@ export class Plugin { showOnHomePage: true, title: PLUGIN.TITLE, }); + core.application.register({ id: PLUGIN.ID, euiIconType: 'uptimeApp', diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/alert_monitor_status.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/alert_monitor_status.tsx new file mode 100644 index 0000000000000..1529ab6db8875 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/alert_monitor_status.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useSelector } from 'react-redux'; +import { DataPublicPluginSetup } from 'src/plugins/data/public'; +import { selectMonitorStatusAlert } from '../../../state/selectors'; +import { AlertMonitorStatusComponent } from '../../functional/alerts/alert_monitor_status'; + +interface Props { + autocomplete: DataPublicPluginSetup['autocomplete']; + enabled: boolean; + numTimes: number; + setAlertParams: (key: string, value: any) => void; + timerange: { + from: string; + to: string; + }; +} + +export const AlertMonitorStatus = ({ + autocomplete, + enabled, + numTimes, + setAlertParams, + timerange, +}: Props) => { + const { filters, locations } = useSelector(selectMonitorStatusAlert); + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/index.ts b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/index.ts new file mode 100644 index 0000000000000..87179a96fc0b2 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AlertMonitorStatus } from './alert_monitor_status'; +export { ToggleAlertFlyoutButton } from './toggle_alert_flyout_button'; +export { UptimeAlertsFlyoutWrapper } from './uptime_alerts_flyout_wrapper'; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/toggle_alert_flyout_button.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/toggle_alert_flyout_button.tsx new file mode 100644 index 0000000000000..43b0be45365a1 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/toggle_alert_flyout_button.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useDispatch } from 'react-redux'; +import { ToggleAlertFlyoutButtonComponent } from '../../functional'; +import { setAlertFlyoutVisible } from '../../../state/actions'; + +export const ToggleAlertFlyoutButton = () => { + const dispatch = useDispatch(); + return ( + dispatch(setAlertFlyoutVisible(value))} + /> + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/alerts/uptime_alerts_flyout_wrapper.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/uptime_alerts_flyout_wrapper.tsx new file mode 100644 index 0000000000000..b547f8b076f93 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/connected/alerts/uptime_alerts_flyout_wrapper.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { UptimeAlertsFlyoutWrapperComponent } from '../../functional'; +import { setAlertFlyoutVisible } from '../../../state/actions'; +import { selectAlertFlyoutVisibility } from '../../../state/selectors'; + +interface Props { + alertTypeId?: string; + canChangeTrigger?: boolean; +} + +export const UptimeAlertsFlyoutWrapper = ({ alertTypeId, canChangeTrigger }: Props) => { + const dispatch = useDispatch(); + const setAddFlyoutVisiblity = (value: React.SetStateAction) => + // @ts-ignore the value here is a boolean, and it works with the action creator function + dispatch(setAlertFlyoutVisible(value)); + + const alertFlyoutVisible = useSelector(selectAlertFlyoutVisibility); + + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/charts/snapshot_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/charts/snapshot_container.tsx index 08421cb56d14c..ac8ff13d1edce 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/charts/snapshot_container.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/charts/snapshot_container.tsx @@ -8,9 +8,10 @@ import React, { useEffect } from 'react'; import { connect } from 'react-redux'; import { useUrlParams } from '../../../hooks'; import { AppState } from '../../../state'; -import { fetchSnapshotCount } from '../../../state/actions'; +import { getSnapshotCountAction } from '../../../state/actions'; import { SnapshotComponent } from '../../functional/snapshot'; import { Snapshot as SnapshotType } from '../../../../common/runtime_types'; +import { SnapShotQueryParams } from '../../../state/api'; /** * Props expected from parent components. @@ -37,7 +38,7 @@ interface StoreProps { * for this component's life cycle */ interface DispatchProps { - loadSnapshotCount: typeof fetchSnapshotCount; + loadSnapshotCount: typeof getSnapshotCountAction; } /** @@ -57,7 +58,7 @@ export const Container: React.FC = ({ const { dateRangeStart, dateRangeEnd, statusFilter } = getUrlParams(); useEffect(() => { - loadSnapshotCount(dateRangeStart, dateRangeEnd, esKuery, statusFilter); + loadSnapshotCount({ dateRangeStart, dateRangeEnd, filters: esKuery, statusFilter }); }, [dateRangeStart, dateRangeEnd, esKuery, lastRefresh, loadSnapshotCount, statusFilter]); return ; }; @@ -81,13 +82,8 @@ const mapStateToProps = ({ * @param dispatch redux-provided action dispatcher */ const mapDispatchToProps = (dispatch: any) => ({ - loadSnapshotCount: ( - dateRangeStart: string, - dateRangeEnd: string, - filters?: string, - statusFilter?: string - ): DispatchProps => { - return dispatch(fetchSnapshotCount(dateRangeStart, dateRangeEnd, filters, statusFilter)); + loadSnapshotCount: (params: SnapShotQueryParams): DispatchProps => { + return dispatch(getSnapshotCountAction(params)); }, }); diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/index.ts b/x-pack/legacy/plugins/uptime/public/components/connected/index.ts index baa961ddc87d2..7e442cbe850ba 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/index.ts +++ b/x-pack/legacy/plugins/uptime/public/components/connected/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export { AlertMonitorStatus, ToggleAlertFlyoutButton, UptimeAlertsFlyoutWrapper } from './alerts'; export { PingHistogram } from './charts/ping_histogram'; export { Snapshot } from './charts/snapshot_container'; export { KueryBar } from './kuerybar/kuery_bar_container'; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx index a42f96962b95e..132ae57b5154f 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/kuerybar/kuery_bar_container.tsx @@ -8,7 +8,7 @@ import { connect } from 'react-redux'; import { AppState } from '../../../state'; import { selectIndexPattern } from '../../../state/selectors'; import { getIndexPattern } from '../../../state/actions'; -import { KueryBarComponent } from '../../functional'; +import { KueryBarComponent } from '../../functional/kuery_bar/kuery_bar'; const mapStateToProps = (state: AppState) => ({ ...selectIndexPattern(state) }); diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/monitor/list_drawer_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/monitor/list_drawer_container.tsx index 8c670b485cc56..ceeaa7026059f 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/monitor/list_drawer_container.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/monitor/list_drawer_container.tsx @@ -7,9 +7,9 @@ import React, { useEffect } from 'react'; import { connect } from 'react-redux'; import { AppState } from '../../../state'; -import { getMonitorDetails } from '../../../state/selectors'; +import { monitorDetailsSelector } from '../../../state/selectors'; import { MonitorDetailsActionPayload } from '../../../state/actions/types'; -import { fetchMonitorDetails } from '../../../state/actions/monitor'; +import { getMonitorDetailsAction } from '../../../state/actions/monitor'; import { MonitorListDrawerComponent } from '../../functional/monitor_list/monitor_list_drawer/monitor_list_drawer'; import { useUrlParams } from '../../../hooks'; import { MonitorSummary } from '../../../../common/graphql/types'; @@ -18,7 +18,7 @@ import { MonitorDetails } from '../../../../common/runtime_types/monitor'; interface ContainerProps { summary: MonitorSummary; monitorDetails: MonitorDetails; - loadMonitorDetails: typeof fetchMonitorDetails; + loadMonitorDetails: typeof getMonitorDetailsAction; } const Container: React.FC = ({ summary, loadMonitorDetails, monitorDetails }) => { @@ -38,12 +38,12 @@ const Container: React.FC = ({ summary, loadMonitorDetails, moni }; const mapStateToProps = (state: AppState, { summary }: any) => ({ - monitorDetails: getMonitorDetails(state, summary), + monitorDetails: monitorDetailsSelector(state, summary), }); const mapDispatchToProps = (dispatch: any) => ({ loadMonitorDetails: (actionPayload: MonitorDetailsActionPayload) => - dispatch(fetchMonitorDetails(actionPayload)), + dispatch(getMonitorDetailsAction(actionPayload)), }); export const MonitorListDrawer = connect(mapStateToProps, mapDispatchToProps)(Container); diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx index b2b555d32a3c7..456fa2b30bca8 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx @@ -8,9 +8,9 @@ import React, { useContext, useEffect } from 'react'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; import { AppState } from '../../../state'; -import { selectMonitorLocations, selectMonitorStatus } from '../../../state/selectors'; +import { monitorLocationsSelector, selectMonitorStatus } from '../../../state/selectors'; import { MonitorStatusBarComponent } from '../../functional/monitor_status_details/monitor_status_bar'; -import { getMonitorStatus, getSelectedMonitor } from '../../../state/actions'; +import { getMonitorStatusAction, getSelectedMonitorAction } from '../../../state/actions'; import { useUrlParams } from '../../../hooks'; import { Ping } from '../../../../common/graphql/types'; import { MonitorLocations } from '../../../../common/runtime_types/monitor'; @@ -57,20 +57,20 @@ const Container: React.FC = ({ const mapStateToProps = (state: AppState, ownProps: OwnProps) => ({ monitorStatus: selectMonitorStatus(state), - monitorLocations: selectMonitorLocations(state, ownProps.monitorId), + monitorLocations: monitorLocationsSelector(state, ownProps.monitorId), }); const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ loadMonitorStatus: (dateStart: string, dateEnd: string, monitorId: string) => { dispatch( - getMonitorStatus({ + getMonitorStatusAction({ monitorId, dateStart, dateEnd, }) ); dispatch( - getSelectedMonitor({ + getSelectedMonitorAction({ monitorId, }) ); diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_details_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_details_container.tsx index 6929e3bd64c4d..3ced251dfab8c 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_details_container.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_details_container.tsx @@ -9,8 +9,8 @@ import { connect } from 'react-redux'; import { Dispatch } from 'redux'; import { useUrlParams } from '../../../hooks'; import { AppState } from '../../../state'; -import { selectMonitorLocations } from '../../../state/selectors'; -import { fetchMonitorLocations, MonitorLocationsPayload } from '../../../state/actions/monitor'; +import { monitorLocationsSelector } from '../../../state/selectors'; +import { getMonitorLocationsAction, MonitorLocationsPayload } from '../../../state/actions/monitor'; import { MonitorStatusDetailsComponent } from '../../functional/monitor_status_details'; import { MonitorLocations } from '../../../../common/runtime_types'; import { UptimeRefreshContext } from '../../../contexts'; @@ -24,7 +24,7 @@ interface StoreProps { } interface DispatchProps { - loadMonitorLocations: typeof fetchMonitorLocations; + loadMonitorLocations: typeof getMonitorLocationsAction; } type Props = OwnProps & StoreProps & DispatchProps; @@ -48,12 +48,12 @@ export const Container: React.FC = ({ ); }; const mapStateToProps = (state: AppState, { monitorId }: OwnProps) => ({ - monitorLocations: selectMonitorLocations(state, monitorId), + monitorLocations: monitorLocationsSelector(state, monitorId), }); const mapDispatchToProps = (dispatch: Dispatch) => ({ loadMonitorLocations: (params: MonitorLocationsPayload) => { - dispatch(fetchMonitorLocations(params)); + dispatch(getMonitorLocationsAction(params)); }, }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/__tests__/alert_monitor_status.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/__tests__/alert_monitor_status.test.tsx new file mode 100644 index 0000000000000..af8d17d1fc242 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/__tests__/alert_monitor_status.test.tsx @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + selectedLocationsToString, + AlertFieldNumber, + handleAlertFieldNumberChange, +} from '../alert_monitor_status'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; + +describe('alert monitor status component', () => { + describe('handleAlertFieldNumberChange', () => { + let mockSetIsInvalid: jest.Mock; + let mockSetFieldValue: jest.Mock; + + beforeEach(() => { + mockSetIsInvalid = jest.fn(); + mockSetFieldValue = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('sets a valid number', () => { + handleAlertFieldNumberChange( + // @ts-ignore no need to implement this entire type here + { target: { value: '23' } }, + false, + mockSetIsInvalid, + mockSetFieldValue + ); + expect(mockSetIsInvalid).not.toHaveBeenCalled(); + expect(mockSetFieldValue).toHaveBeenCalledTimes(1); + expect(mockSetFieldValue.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 23, + ], + ] + `); + }); + + it('sets invalid for NaN value', () => { + handleAlertFieldNumberChange( + // @ts-ignore no need to implement this entire type here + { target: { value: 'foo' } }, + false, + mockSetIsInvalid, + mockSetFieldValue + ); + expect(mockSetIsInvalid).toHaveBeenCalledTimes(1); + expect(mockSetIsInvalid.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + true, + ], + ] + `); + expect(mockSetFieldValue).not.toHaveBeenCalled(); + }); + + it('sets invalid to false when a valid value is received and invalid is true', () => { + handleAlertFieldNumberChange( + // @ts-ignore no need to implement this entire type here + { target: { value: '23' } }, + true, + mockSetIsInvalid, + mockSetFieldValue + ); + expect(mockSetIsInvalid).toHaveBeenCalledTimes(1); + expect(mockSetIsInvalid.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + false, + ], + ] + `); + expect(mockSetFieldValue).toHaveBeenCalledTimes(1); + expect(mockSetFieldValue.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 23, + ], + ] + `); + }); + }); + + describe('AlertFieldNumber', () => { + it('responds with correct number value when a valid number is specified', () => { + const mockValueHandler = jest.fn(); + const component = mountWithIntl( + + ); + component.find('input').simulate('change', { target: { value: '45' } }); + expect(mockValueHandler).toHaveBeenCalled(); + expect(mockValueHandler.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 45, + ], + ] + `); + }); + + it('does not set an invalid number value', () => { + const mockValueHandler = jest.fn(); + const component = mountWithIntl( + + ); + component.find('input').simulate('change', { target: { value: 'not a number' } }); + expect(mockValueHandler).not.toHaveBeenCalled(); + expect(mockValueHandler.mock.calls).toEqual([]); + }); + + it('does not set a number value less than 1', () => { + const mockValueHandler = jest.fn(); + const component = mountWithIntl( + + ); + component.find('input').simulate('change', { target: { value: '0' } }); + expect(mockValueHandler).not.toHaveBeenCalled(); + expect(mockValueHandler.mock.calls).toEqual([]); + }); + }); + + describe('selectedLocationsToString', () => { + it('generates a formatted string for a valid list of options', () => { + const locations = [ + { + checked: 'on', + label: 'fairbanks', + }, + { + checked: 'on', + label: 'harrisburg', + }, + { + checked: undefined, + label: 'orlando', + }, + ]; + expect(selectedLocationsToString(locations)).toEqual('fairbanks, harrisburg'); + }); + + it('generates a formatted string for a single item', () => { + expect(selectedLocationsToString([{ checked: 'on', label: 'fairbanks' }])).toEqual( + 'fairbanks' + ); + }); + + it('returns an empty string when no valid options are available', () => { + expect(selectedLocationsToString([{ checked: 'off', label: 'harrisburg' }])).toEqual(''); + }); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/alert_monitor_status.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/alert_monitor_status.tsx new file mode 100644 index 0000000000000..5143e1c963904 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/alert_monitor_status.tsx @@ -0,0 +1,431 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import { + EuiExpression, + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiSelectable, + EuiSpacer, + EuiSwitch, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { DataPublicPluginSetup } from 'src/plugins/data/public'; +import { KueryBar } from '../../connected/kuerybar/kuery_bar_container'; + +interface AlertFieldNumberProps { + 'aria-label': string; + 'data-test-subj': string; + disabled: boolean; + fieldValue: number; + setFieldValue: React.Dispatch>; +} + +export const handleAlertFieldNumberChange = ( + e: React.ChangeEvent, + isInvalid: boolean, + setIsInvalid: React.Dispatch>, + setFieldValue: React.Dispatch> +) => { + const num = parseInt(e.target.value, 10); + if (isNaN(num) || num < 1) { + setIsInvalid(true); + } else { + if (isInvalid) setIsInvalid(false); + setFieldValue(num); + } +}; + +export const AlertFieldNumber = ({ + 'aria-label': ariaLabel, + 'data-test-subj': dataTestSubj, + disabled, + fieldValue, + setFieldValue, +}: AlertFieldNumberProps) => { + const [isInvalid, setIsInvalid] = useState(false); + + return ( + handleAlertFieldNumberChange(e, isInvalid, setIsInvalid, setFieldValue)} + disabled={disabled} + value={fieldValue} + isInvalid={isInvalid} + /> + ); +}; + +interface AlertExpressionPopoverProps { + 'aria-label': string; + content: React.ReactElement; + description: string; + 'data-test-subj': string; + id: string; + value: string; +} + +const AlertExpressionPopover: React.FC = ({ + 'aria-label': ariaLabel, + content, + 'data-test-subj': dataTestSubj, + description, + id, + value, +}) => { + const [isOpen, setIsOpen] = useState(false); + return ( + setIsOpen(!isOpen)} + value={value} + /> + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + > + {content} + + ); +}; + +export const selectedLocationsToString = (selectedLocations: any[]) => + // create a nicely-formatted description string for all `on` locations + selectedLocations + .filter(({ checked }) => checked === 'on') + .map(({ label }) => label) + .sort() + .reduce((acc, cur) => { + if (acc === '') { + return cur; + } + return acc + `, ${cur}`; + }, ''); + +interface AlertMonitorStatusProps { + autocomplete: DataPublicPluginSetup['autocomplete']; + enabled: boolean; + filters: string; + locations: string[]; + numTimes: number; + setAlertParams: (key: string, value: any) => void; + timerange: { + from: string; + to: string; + }; +} + +export const AlertMonitorStatusComponent: React.FC = props => { + const { filters, locations } = props; + const [numTimes, setNumTimes] = useState(5); + const [numMins, setNumMins] = useState(15); + const [allLabels, setAllLabels] = useState(true); + + // locations is an array of `Option[]`, but that type doesn't seem to be exported by EUI + const [selectedLocations, setSelectedLocations] = useState( + locations.map(location => ({ + 'aria-label': i18n.translate('xpack.uptime.alerts.locationSelectionItem.ariaLabel', { + defaultMessage: 'Location selection item for "{location}"', + values: { + location, + }, + }), + disabled: allLabels, + label: location, + })) + ); + const [timerangeUnitOptions, setTimerangeUnitOptions] = useState([ + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.secondsOption.ariaLabel', + { + defaultMessage: '"Seconds" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.secondsOption', + key: 's', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.seconds', { + defaultMessage: 'seconds', + }), + }, + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.minutesOption.ariaLabel', + { + defaultMessage: '"Minutes" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.minutesOption', + checked: 'on', + key: 'm', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.minutes', { + defaultMessage: 'minutes', + }), + }, + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.hoursOption.ariaLabel', + { + defaultMessage: '"Hours" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.hoursOption', + key: 'h', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.hours', { + defaultMessage: 'hours', + }), + }, + { + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.timerangeUnitSelectable.daysOption.ariaLabel', + { + defaultMessage: '"Days" time range select item', + } + ), + 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.daysOption', + key: 'd', + label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.days', { + defaultMessage: 'days', + }), + }, + ]); + + const { setAlertParams } = props; + + useEffect(() => { + setAlertParams('numTimes', numTimes); + }, [numTimes, setAlertParams]); + + useEffect(() => { + const timerangeUnit = timerangeUnitOptions.find(({ checked }) => checked === 'on')?.key ?? 'm'; + setAlertParams('timerange', { from: `now-${numMins}${timerangeUnit}`, to: 'now' }); + }, [numMins, timerangeUnitOptions, setAlertParams]); + + useEffect(() => { + if (allLabels) { + setAlertParams('locations', []); + } else { + setAlertParams( + 'locations', + selectedLocations.filter(l => l.checked === 'on').map(l => l.label) + ); + } + }, [selectedLocations, setAlertParams, allLabels]); + + useEffect(() => { + setAlertParams('filters', filters); + }, [filters, setAlertParams]); + + return ( + <> + + + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.numTimesExpression" + description="any monitor is down >" + id="ping-count" + value={`${numTimes} times`} + /> + + + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.timerangeValueExpression" + description="within" + id="timerange" + value={`last ${numMins}`} + /> + + + + +
+ +
+
+ { + if (newOptions.reduce((acc, { checked }) => acc || checked === 'on', false)) { + setTimerangeUnitOptions(newOptions); + } + }} + singleSelection={true} + listProps={{ + showIcons: true, + }} + > + {list => list} + + + } + data-test-subj="xpack.uptime.alerts.monitorStatus.timerangeUnitExpression" + description="" + id="timerange-unit" + value={ + timerangeUnitOptions.find(({ checked }) => checked === 'on')?.label.toLowerCase() ?? + '' + } + /> +
+
+ + {selectedLocations.length === 0 && ( + + )} + {selectedLocations.length > 0 && ( + + + { + setAllLabels(!allLabels); + setSelectedLocations( + selectedLocations.map((l: any) => ({ + 'aria-label': i18n.translate( + 'xpack.uptime.alerts.monitorStatus.locationSelection', + { + defaultMessage: 'Select the location {location}', + values: { + location: l, + }, + } + ), + ...l, + 'data-test-subj': `xpack.uptime.alerts.monitorStatus.locationSelection.${l.label}LocationOption`, + disabled: !allLabels, + })) + ); + }} + /> + + + setSelectedLocations(e)} + > + {location => location} + + +
+ } + data-test-subj="xpack.uptime.alerts.monitorStatus.locationsSelectionExpression" + description="from" + id="locations" + value={ + selectedLocations.length === 0 || allLabels + ? 'any location' + : selectedLocationsToString(selectedLocations) + } + /> + )} + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/index.ts b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/index.ts new file mode 100644 index 0000000000000..275333b60c5ee --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AlertMonitorStatusComponent } from './alert_monitor_status'; +export { ToggleAlertFlyoutButtonComponent } from './toggle_alert_flyout_button'; +export { UptimeAlertsContextProvider } from './uptime_alerts_context_provider'; +export { UptimeAlertsFlyoutWrapperComponent } from './uptime_alerts_flyout_wrapper'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx new file mode 100644 index 0000000000000..99853a9f775ec --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import React, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; + +interface Props { + setAlertFlyoutVisible: (value: boolean) => void; +} + +export const ToggleAlertFlyoutButtonComponent = ({ setAlertFlyoutVisible }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const kibana = useKibana(); + + return ( + setIsOpen(!isOpen)} + > + +
+ } + closePopover={() => setIsOpen(false)} + isOpen={isOpen} + ownFocus + > + setAlertFlyoutVisible(true)} + > + +
, + + + , + ]} + /> + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx new file mode 100644 index 0000000000000..a174a7d9c0ea4 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { AlertsContextProvider } from '../../../../../../../plugins/triggers_actions_ui/public'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; + +export const UptimeAlertsContextProvider: React.FC = ({ children }) => { + const { + services: { + data: { fieldFormats }, + http, + charts, + notifications, + triggers_actions_ui: { actionTypeRegistry, alertTypeRegistry }, + uiSettings, + }, + } = useKibana(); + + return ( + + {children} + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx new file mode 100644 index 0000000000000..13705e7d19293 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_flyout_wrapper.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { AlertAdd } from '../../../../../../../plugins/triggers_actions_ui/public'; + +interface Props { + alertFlyoutVisible: boolean; + alertTypeId?: string; + canChangeTrigger?: boolean; + setAlertFlyoutVisibility: React.Dispatch>; +} + +export const UptimeAlertsFlyoutWrapperComponent = ({ + alertFlyoutVisible, + alertTypeId, + canChangeTrigger, + setAlertFlyoutVisibility, +}: Props) => ( + +); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/index.ts b/x-pack/legacy/plugins/uptime/public/components/functional/index.ts index daba13d8df641..8d0352e01d40e 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/index.ts +++ b/x-pack/legacy/plugins/uptime/public/components/functional/index.ts @@ -4,6 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +export { + ToggleAlertFlyoutButtonComponent, + UptimeAlertsContextProvider, + UptimeAlertsFlyoutWrapperComponent, +} from './alerts'; +export * from './alerts'; export { DonutChart } from './charts/donut_chart'; export { KueryBarComponent } from './kuery_bar/kuery_bar'; export { MonitorCharts } from './monitor_charts'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx index 2f5ccc2adf313..63aceed2be636 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/kuery_bar/kuery_bar.tsx @@ -33,14 +33,18 @@ function convertKueryToEsQuery(kuery: string, indexPattern: IIndexPattern) { } interface Props { + 'aria-label': string; autocomplete: DataPublicPluginSetup['autocomplete']; + 'data-test-subj': string; loadIndexPattern: () => void; indexPattern: IIndexPattern | null; loading: boolean; } export function KueryBarComponent({ + 'aria-label': ariaLabel, autocomplete: autocompleteService, + 'data-test-subj': dataTestSubj, loadIndexPattern, indexPattern, loading, @@ -119,6 +123,8 @@ export function KueryBarComponent({ return ( -
+
{ @@ -205,7 +204,7 @@ describe('PingList component', () => { loading={false} data={{ allPings }} onPageCountChange={jest.fn()} - onSelectedLocationChange={(loc: EuiComboBoxOptionOption[]) => {}} + onSelectedLocationChange={(_loc: any[]) => {}} onSelectedStatusChange={jest.fn()} pageSize={30} selectedOption="down" diff --git a/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx b/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx index a377b9ed1507b..a2f3328b98612 100644 --- a/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx +++ b/x-pack/legacy/plugins/uptime/public/lib/adapters/framework/new_platform_adapter.tsx @@ -10,6 +10,7 @@ import ReactDOM from 'react-dom'; import { get } from 'lodash'; import { i18n as i18nFormatter } from '@kbn/i18n'; import { PluginsSetup } from 'ui/new_platform/new_platform'; +import { alertTypeInitializers } from '../../alert_types'; import { UptimeApp, UptimeAppProps } from '../../../uptime_app'; import { getIntegratedAppAvailability } from './capabilities_adapter'; import { @@ -32,15 +33,30 @@ export const getKibanaFrameworkAdapter = ( http: { basePath }, i18n, } = core; + + const { + data: { autocomplete }, + // TODO: after NP migration we can likely fix this typing problem + // @ts-ignore we don't control this type + triggers_actions_ui, + } = plugins; + + alertTypeInitializers.forEach(init => + triggers_actions_ui.alertTypeRegistry.register(init({ autocomplete })) + ); + let breadcrumbs: ChromeBreadcrumb[] = []; core.chrome.getBreadcrumbs$().subscribe((nextBreadcrumbs?: ChromeBreadcrumb[]) => { breadcrumbs = nextBreadcrumbs || []; }); + const { apm, infrastructure, logs } = getIntegratedAppAvailability( capabilities, INTEGRATED_SOLUTIONS ); + const canSave = get(capabilities, 'uptime.save', false); + const props: UptimeAppProps = { basePath: basePath.get(), canSave, diff --git a/x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts b/x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts new file mode 100644 index 0000000000000..6323ee3951e21 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { validate, initMonitorStatusAlertType } from '../monitor_status'; + +describe('monitor status alert type', () => { + describe('validate', () => { + let params: any; + + beforeEach(() => { + params = { + locations: [], + numTimes: 5, + timerange: { + from: 'now-15m', + to: 'now', + }, + }; + }); + + it(`doesn't throw on empty set`, () => { + expect(validate({})).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/locations: Array", + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/numTimes: number", + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }", + ], + }, + } + `); + }); + + describe('timerange', () => { + it('is undefined', () => { + delete params.timerange; + expect(validate(params)).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }", + ], + }, + } + `); + }); + + it('is missing `from` or `to` value', () => { + expect( + validate({ + ...params, + timerange: {}, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }/from: string", + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }/to: string", + ], + }, + } + `); + }); + + it('is invalid timespan', () => { + expect( + validate({ + ...params, + timerange: { + from: 'now', + to: 'now-15m', + }, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "invalidTimeRange": "Time range start cannot exceed time range end", + }, + } + `); + }); + + it('has unparse-able `from` value', () => { + expect( + validate({ + ...params, + timerange: { + from: 'cannot parse this to a date', + to: 'now', + }, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "timeRangeStartValueNaN": "Specified time range \`from\` is an invalid value", + }, + } + `); + }); + + it('has unparse-able `to` value', () => { + expect( + validate({ + ...params, + timerange: { + from: 'now-15m', + to: 'cannot parse this to a date', + }, + }) + ).toMatchInlineSnapshot(` + Object { + "errors": Object { + "timeRangeEndValueNaN": "Specified time range \`to\` is an invalid value", + }, + } + `); + }); + }); + + describe('numTimes', () => { + it('is missing', () => { + delete params.numTimes; + expect(validate(params)).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/numTimes: number", + ], + }, + } + `); + }); + + it('is NaN', () => { + expect(validate({ ...params, numTimes: `this isn't a number` })).toMatchInlineSnapshot(` + Object { + "errors": Object { + "typeCheckFailure": "Provided parameters do not conform to the expected type.", + "typeCheckParsingMessage": Array [ + "Invalid value \\"this isn't a number\\" supplied to : (Partial<{ filters: string }> & { locations: Array, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array, numTimes: number, timerange: { from: string, to: string } }/numTimes: number", + ], + }, + } + `); + }); + + it('is < 1', () => { + expect(validate({ ...params, numTimes: 0 })).toMatchInlineSnapshot(` + Object { + "errors": Object { + "invalidNumTimes": "Number of alert check down times must be an integer greater than 0", + }, + } + `); + }); + }); + }); + + describe('initMonitorStatusAlertType', () => { + expect(initMonitorStatusAlertType({ autocomplete: {} })).toMatchInlineSnapshot(` + Object { + "alertParamsExpression": [Function], + "defaultActionMessage": "{{context.message}} + {{context.completeIdList}}", + "iconClass": "uptimeApp", + "id": "xpack.uptime.alerts.monitorStatus", + "name": "Uptime Monitor Status", + "validate": [Function], + } + `); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts b/x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts new file mode 100644 index 0000000000000..f764505a6d683 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// TODO: after NP migration is complete we should be able to remove this lint ignore comment +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertTypeModel } from '../../../../../../plugins/triggers_actions_ui/public/types'; +import { initMonitorStatusAlertType } from './monitor_status'; + +export type AlertTypeInitializer = (dependenies: { autocomplete: any }) => AlertTypeModel; + +export const alertTypeInitializers: AlertTypeInitializer[] = [initMonitorStatusAlertType]; diff --git a/x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx b/x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx new file mode 100644 index 0000000000000..effbb59539d16 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/alert_types/monitor_status.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PathReporter } from 'io-ts/lib/PathReporter'; +import React from 'react'; +import DateMath from '@elastic/datemath'; +import { isRight } from 'fp-ts/lib/Either'; +import { + AlertTypeModel, + ValidationResult, + // TODO: this typing issue should be resolved after NP migration + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../plugins/triggers_actions_ui/public/types'; +import { AlertTypeInitializer } from '.'; +import { StatusCheckExecutorParamsType } from '../../../common/runtime_types'; +import { AlertMonitorStatus } from '../../components/connected/alerts'; + +export const validate = (alertParams: any): ValidationResult => { + const errors: Record = {}; + const decoded = StatusCheckExecutorParamsType.decode(alertParams); + + /* + * When the UI initially loads, this validate function is called with an + * empty set of params, we don't want to type check against that. + */ + if (!isRight(decoded)) { + errors.typeCheckFailure = 'Provided parameters do not conform to the expected type.'; + errors.typeCheckParsingMessage = PathReporter.report(decoded); + } + + if (isRight(decoded)) { + const { numTimes, timerange } = decoded.right; + const { from, to } = timerange; + const fromAbs = DateMath.parse(from)?.valueOf(); + const toAbs = DateMath.parse(to)?.valueOf(); + if (!fromAbs || isNaN(fromAbs)) { + errors.timeRangeStartValueNaN = 'Specified time range `from` is an invalid value'; + } + if (!toAbs || isNaN(toAbs)) { + errors.timeRangeEndValueNaN = 'Specified time range `to` is an invalid value'; + } + + // the default values for this test will pass, we only want to specify an error + // in the case that `from` is more recent than `to` + if ((fromAbs ?? 0) > (toAbs ?? 1)) { + errors.invalidTimeRange = 'Time range start cannot exceed time range end'; + } + + if (numTimes < 1) { + errors.invalidNumTimes = 'Number of alert check down times must be an integer greater than 0'; + } + } + + return { errors }; +}; + +export const initMonitorStatusAlertType: AlertTypeInitializer = ({ + autocomplete, +}): AlertTypeModel => ({ + id: 'xpack.uptime.alerts.monitorStatus', + name: 'Uptime Monitor Status', + iconClass: 'uptimeApp', + alertParamsExpression: params => { + return ; + }, + validate, + defaultActionMessage: '{{context.message}}\n{{context.completeIdList}}', +}); diff --git a/x-pack/legacy/plugins/uptime/public/lib/helper/__tests__/__snapshots__/parameterize_values.test.ts.snap b/x-pack/legacy/plugins/uptime/public/lib/helper/__tests__/__snapshots__/parameterize_values.test.ts.snap deleted file mode 100644 index 39c28a87f5e71..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/lib/helper/__tests__/__snapshots__/parameterize_values.test.ts.snap +++ /dev/null @@ -1,5 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`parameterizeValues parameterizes provided values for multiple fields 1`] = `"foo=bar&foo=baz&bar=foo&bar=baz"`; - -exports[`parameterizeValues parameterizes the provided values for a given field name 1`] = `"foo=bar&foo=baz"`; diff --git a/x-pack/legacy/plugins/uptime/public/lib/helper/__tests__/get_api_path.test.ts b/x-pack/legacy/plugins/uptime/public/lib/helper/__tests__/get_api_path.test.ts deleted file mode 100644 index c111008fdc3d1..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/lib/helper/__tests__/get_api_path.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { getApiPath } from '../get_api_path'; - -describe('getApiPath', () => { - it('returns a path with basePath when provided', () => { - const result = getApiPath('/api/foo/bar', '/somebasepath'); - expect(result).toEqual('/somebasepath/api/foo/bar'); - }); - - it('returns a valid path when no basePath present', () => { - const result = getApiPath('/api/foo/bar'); - expect(result).toEqual('/api/foo/bar'); - }); - - it('returns a valid path when an empty string is supplied as basePath', () => { - const result = getApiPath('/api/foo/bar', ''); - expect(result).toEqual('/api/foo/bar'); - }); -}); diff --git a/x-pack/legacy/plugins/uptime/public/lib/helper/__tests__/parameterize_values.test.ts b/x-pack/legacy/plugins/uptime/public/lib/helper/__tests__/parameterize_values.test.ts deleted file mode 100644 index e550a1a6397e3..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/lib/helper/__tests__/parameterize_values.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { parameterizeValues } from '../parameterize_values'; - -describe('parameterizeValues', () => { - let params: URLSearchParams; - - beforeEach(() => { - params = new URLSearchParams(); - }); - - it('parameterizes the provided values for a given field name', () => { - parameterizeValues(params, { foo: ['bar', 'baz'] }); - expect(params.toString()).toMatchSnapshot(); - }); - - it('parameterizes provided values for multiple fields', () => { - parameterizeValues(params, { foo: ['bar', 'baz'], bar: ['foo', 'baz'] }); - expect(params.toString()).toMatchSnapshot(); - }); - - it('returns an empty string when there are no values provided', () => { - parameterizeValues(params, { foo: [] }); - expect(params.toString()).toBe(''); - }); -}); diff --git a/x-pack/legacy/plugins/uptime/public/lib/helper/index.ts b/x-pack/legacy/plugins/uptime/public/lib/helper/index.ts index ef191ce32e532..e2aa4a2b3d429 100644 --- a/x-pack/legacy/plugins/uptime/public/lib/helper/index.ts +++ b/x-pack/legacy/plugins/uptime/public/lib/helper/index.ts @@ -7,9 +7,7 @@ export { combineFiltersAndUserSearch } from './combine_filters_and_user_search'; export { convertMicrosecondsToMilliseconds } from './convert_measurements'; export * from './observability_integration'; -export { getApiPath } from './get_api_path'; export { getChartDateLabel } from './charts'; -export { parameterizeValues } from './parameterize_values'; export { seriesHasDownValues } from './series_has_down_values'; export { stringifyKueries } from './stringify_kueries'; export { UptimeUrlParams, getSupportedUrlParams } from './url_params'; diff --git a/x-pack/legacy/plugins/uptime/public/lib/helper/parameterize_values.ts b/x-pack/legacy/plugins/uptime/public/lib/helper/parameterize_values.ts deleted file mode 100644 index 4c9fa6838c2ed..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/lib/helper/parameterize_values.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export const parameterizeValues = ( - params: URLSearchParams, - obj: Record -): void => { - Object.keys(obj).forEach(key => { - obj[key].forEach(val => { - params.append(key, val); - }); - }); -}; diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap index 5906a77f55441..30e15ba132996 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap @@ -14,6 +14,39 @@ Array [ TestingHeading
+
+
+
+ +
+
+
@@ -130,6 +163,39 @@ Array [ TestingHeading
+
+
+
+ +
+
+
,
{ const simpleBreadcrumbs: ChromeBreadcrumb[] = [ @@ -21,22 +22,26 @@ describe('PageHeader', () => { it('shallow renders with breadcrumbs and the date picker', () => { const component = renderWithRouter( - + + + ); expect(component).toMatchSnapshot('page_header_with_date_picker'); }); it('shallow renders with breadcrumbs without the date picker', () => { const component = renderWithRouter( - + + + ); expect(component).toMatchSnapshot('page_header_no_date_picker'); }); @@ -45,13 +50,15 @@ describe('PageHeader', () => { const [getBreadcrumbs, core] = mockCore(); mountWithRouter( - - - + + + + + ); @@ -62,6 +69,19 @@ describe('PageHeader', () => { }); }); +const MockReduxProvider = ({ children }: { children: React.ReactElement }) => ( + + {children} + +); + const mockCore: () => [() => ChromeBreadcrumb[], any] = () => { let breadcrumbObj: ChromeBreadcrumb[] = []; const get = () => { diff --git a/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx b/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx index 18c4927af0797..b9d29ed017a05 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx @@ -17,7 +17,7 @@ import { MonitorStatusDetails } from '../components/connected'; import { Ping } from '../../common/graphql/types'; import { AppState } from '../state'; import { selectSelectedMonitor } from '../state/selectors'; -import { getSelectedMonitor } from '../state/actions'; +import { getSelectedMonitorAction } from '../state/actions'; import { PageHeader } from './page_header'; interface StateProps { @@ -102,7 +102,7 @@ const mapDispatchToProps: MapDispatchToPropsFunction = (dispa return { dispatchGetMonitorStatus: (monitorId: string) => { dispatch( - getSelectedMonitor({ + getSelectedMonitorAction({ monitorId, }) ); diff --git a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx index af9b8bf046416..f9184e2a0587f 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx @@ -83,7 +83,13 @@ export const OverviewPageComponent = ({ autocomplete, indexPattern, setEsKueryFi - + diff --git a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx index b0fb2d0ed7869..56d9ae2d5caa6 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx @@ -13,6 +13,7 @@ import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { stringifyUrlParams } from '../lib/helper/stringify_url_params'; import { useUrlParams } from '../hooks'; import { UptimeUrlParams } from '../lib/helper'; +import { ToggleAlertFlyoutButton } from '../components/connected'; interface PageHeaderProps { headingText: string; @@ -60,6 +61,9 @@ export const PageHeader = ({ headingText, breadcrumbs, datePicker = true }: Page

{headingText}

+ + + {datePickerComponent}
diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/monitor.ts b/x-pack/legacy/plugins/uptime/public/state/actions/monitor.ts index cf4525a08e43c..30ea8e71265e0 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/monitor.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/monitor.ts @@ -4,108 +4,33 @@ * you may not use this file except in compliance with the Elastic License. */ +import { createAction } from 'redux-actions'; import { MonitorDetailsActionPayload } from './types'; import { MonitorError } from '../../../common/runtime_types'; import { MonitorLocations } from '../../../common/runtime_types'; import { QueryParams } from './types'; -export const FETCH_MONITOR_DETAILS = 'FETCH_MONITOR_DETAILS'; -export const FETCH_MONITOR_DETAILS_SUCCESS = 'FETCH_MONITOR_DETAILS_SUCCESS'; -export const FETCH_MONITOR_DETAILS_FAIL = 'FETCH_MONITOR_DETAILS_FAIL'; - -export const FETCH_MONITOR_LOCATIONS = 'FETCH_MONITOR_LOCATIONS'; -export const FETCH_MONITOR_LOCATIONS_SUCCESS = 'FETCH_MONITOR_LOCATIONS_SUCCESS'; -export const FETCH_MONITOR_LOCATIONS_FAIL = 'FETCH_MONITOR_LOCATIONS_FAIL'; - -export interface MonitorDetailsState { - monitorId: string; - error: MonitorError; -} - -interface GetMonitorDetailsAction { - type: typeof FETCH_MONITOR_DETAILS; - payload: MonitorDetailsActionPayload; -} - -interface GetMonitorDetailsSuccessAction { - type: typeof FETCH_MONITOR_DETAILS_SUCCESS; - payload: MonitorDetailsState; -} - -interface GetMonitorDetailsFailAction { - type: typeof FETCH_MONITOR_DETAILS_FAIL; - payload: any; -} - export interface MonitorLocationsPayload extends QueryParams { monitorId: string; } -interface GetMonitorLocationsAction { - type: typeof FETCH_MONITOR_LOCATIONS; - payload: MonitorLocationsPayload; -} - -interface GetMonitorLocationsSuccessAction { - type: typeof FETCH_MONITOR_LOCATIONS_SUCCESS; - payload: MonitorLocations; -} - -interface GetMonitorLocationsFailAction { - type: typeof FETCH_MONITOR_LOCATIONS_FAIL; - payload: any; -} - -export function fetchMonitorDetails(payload: MonitorDetailsActionPayload): GetMonitorDetailsAction { - return { - type: FETCH_MONITOR_DETAILS, - payload, - }; -} - -export function fetchMonitorDetailsSuccess( - monitorDetailsState: MonitorDetailsState -): GetMonitorDetailsSuccessAction { - return { - type: FETCH_MONITOR_DETAILS_SUCCESS, - payload: monitorDetailsState, - }; -} - -export function fetchMonitorDetailsFail(error: any): GetMonitorDetailsFailAction { - return { - type: FETCH_MONITOR_DETAILS_FAIL, - payload: error, - }; -} - -export function fetchMonitorLocations(payload: MonitorLocationsPayload): GetMonitorLocationsAction { - return { - type: FETCH_MONITOR_LOCATIONS, - payload, - }; -} - -export function fetchMonitorLocationsSuccess( - monitorLocationsState: MonitorLocations -): GetMonitorLocationsSuccessAction { - return { - type: FETCH_MONITOR_LOCATIONS_SUCCESS, - payload: monitorLocationsState, - }; -} - -export function fetchMonitorLocationsFail(error: any): GetMonitorLocationsFailAction { - return { - type: FETCH_MONITOR_LOCATIONS_FAIL, - payload: error, - }; +export interface MonitorDetailsState { + monitorId: string; + error: MonitorError; } -export type MonitorActionTypes = - | GetMonitorDetailsAction - | GetMonitorDetailsSuccessAction - | GetMonitorDetailsFailAction - | GetMonitorLocationsAction - | GetMonitorLocationsSuccessAction - | GetMonitorLocationsFailAction; +export const getMonitorDetailsAction = createAction( + 'GET_MONITOR_DETAILS' +); +export const getMonitorDetailsActionSuccess = createAction( + 'GET_MONITOR_DETAILS_SUCCESS' +); +export const getMonitorDetailsActionFail = createAction('GET_MONITOR_DETAILS_FAIL'); + +export const getMonitorLocationsAction = createAction( + 'GET_MONITOR_LOCATIONS' +); +export const getMonitorLocationsActionSuccess = createAction( + 'GET_MONITOR_LOCATIONS_SUCCESS' +); +export const getMonitorLocationsActionFail = createAction('GET_MONITOR_LOCATIONS_FAIL'); diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/monitor_status.ts b/x-pack/legacy/plugins/uptime/public/state/actions/monitor_status.ts index db103f6cb780e..7917628abf7da 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/monitor_status.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/monitor_status.ts @@ -5,11 +5,12 @@ */ import { createAction } from 'redux-actions'; import { QueryParams } from './types'; +import { Ping } from '../../../common/graphql/types'; -export const getSelectedMonitor = createAction<{ monitorId: string }>('GET_SELECTED_MONITOR'); -export const getSelectedMonitorSuccess = createAction('GET_SELECTED_MONITOR_SUCCESS'); -export const getSelectedMonitorFail = createAction('GET_SELECTED_MONITOR_FAIL'); +export const getSelectedMonitorAction = createAction<{ monitorId: string }>('GET_SELECTED_MONITOR'); +export const getSelectedMonitorActionSuccess = createAction('GET_SELECTED_MONITOR_SUCCESS'); +export const getSelectedMonitorActionFail = createAction('GET_SELECTED_MONITOR_FAIL'); -export const getMonitorStatus = createAction('GET_MONITOR_STATUS'); -export const getMonitorStatusSuccess = createAction('GET_MONITOR_STATUS_SUCCESS'); -export const getMonitorStatusFail = createAction('GET_MONITOR_STATUS_FAIL'); +export const getMonitorStatusAction = createAction('GET_MONITOR_STATUS'); +export const getMonitorStatusActionSuccess = createAction('GET_MONITOR_STATUS_SUCCESS'); +export const getMonitorStatusActionFail = createAction('GET_MONITOR_STATUS_FAIL'); diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/snapshot.ts b/x-pack/legacy/plugins/uptime/public/state/actions/snapshot.ts index 57d2b4ce38204..e819a553e61f5 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/snapshot.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/snapshot.ts @@ -4,12 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { createAction } from 'redux-actions'; import { Snapshot } from '../../../common/runtime_types'; -export const FETCH_SNAPSHOT_COUNT = 'FETCH_SNAPSHOT_COUNT'; -export const FETCH_SNAPSHOT_COUNT_FAIL = 'FETCH_SNAPSHOT_COUNT_FAIL'; -export const FETCH_SNAPSHOT_COUNT_SUCCESS = 'FETCH_SNAPSHOT_COUNT_SUCCESS'; - export interface GetSnapshotPayload { dateRangeStart: string; dateRangeEnd: string; @@ -17,47 +14,6 @@ export interface GetSnapshotPayload { statusFilter?: string; } -interface GetSnapshotCountFetchAction { - type: typeof FETCH_SNAPSHOT_COUNT; - payload: GetSnapshotPayload; -} - -interface GetSnapshotCountSuccessAction { - type: typeof FETCH_SNAPSHOT_COUNT_SUCCESS; - payload: Snapshot; -} - -interface GetSnapshotCountFailAction { - type: typeof FETCH_SNAPSHOT_COUNT_FAIL; - payload: Error; -} - -export type SnapshotActionTypes = - | GetSnapshotCountFetchAction - | GetSnapshotCountSuccessAction - | GetSnapshotCountFailAction; - -export const fetchSnapshotCount = ( - dateRangeStart: string, - dateRangeEnd: string, - filters?: string, - statusFilter?: string -): GetSnapshotCountFetchAction => ({ - type: FETCH_SNAPSHOT_COUNT, - payload: { - dateRangeStart, - dateRangeEnd, - filters, - statusFilter, - }, -}); - -export const fetchSnapshotCountFail = (error: Error): GetSnapshotCountFailAction => ({ - type: FETCH_SNAPSHOT_COUNT_FAIL, - payload: error, -}); - -export const fetchSnapshotCountSuccess = (snapshot: Snapshot) => ({ - type: FETCH_SNAPSHOT_COUNT_SUCCESS, - payload: snapshot, -}); +export const getSnapshotCountAction = createAction('GET_SNAPSHOT_COUNT'); +export const getSnapshotCountActionSuccess = createAction('GET_SNAPSHOT_COUNT_SUCCESS'); +export const getSnapshotCountActionFail = createAction('GET_SNAPSHOT_COUNT_FAIL'); diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts b/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts index d15d601737b2d..4885f974dbbd4 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts @@ -12,6 +12,8 @@ export interface PopoverState { export type UiPayload = PopoverState & string & number & Map; +export const setAlertFlyoutVisible = createAction('TOGGLE ALERT FLYOUT'); + export const setBasePath = createAction('SET BASE PATH'); export const triggerAppRefresh = createAction('REFRESH APP'); diff --git a/x-pack/legacy/plugins/uptime/public/state/api/__tests__/__snapshots__/snapshot.test.ts.snap b/x-pack/legacy/plugins/uptime/public/state/api/__tests__/__snapshots__/snapshot.test.ts.snap index 0d2392390c7e4..1cd2aae446519 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/x-pack/legacy/plugins/uptime/public/state/api/__tests__/__snapshots__/snapshot.test.ts.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`snapshot API throws when server response doesn't correspond to expected type 1`] = ` -[Error: Invalid value undefined supplied to : { down: number, total: number, up: number }/down: number -Invalid value undefined supplied to : { down: number, total: number, up: number }/total: number -Invalid value undefined supplied to : { down: number, total: number, up: number }/up: number] +Object { + "foo": "bar", +} `; diff --git a/x-pack/legacy/plugins/uptime/public/state/api/__tests__/snapshot.test.ts b/x-pack/legacy/plugins/uptime/public/state/api/__tests__/snapshot.test.ts index e9b1391a23e32..66b376c3ac36f 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/__tests__/snapshot.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/__tests__/snapshot.test.ts @@ -5,17 +5,19 @@ */ import { fetchSnapshotCount } from '../snapshot'; +import { apiService } from '../utils'; +import { HttpFetchError } from '../../../../../../../../src/core/public/http/http_fetch_error'; describe('snapshot API', () => { - let fetchMock: jest.SpyInstance>>; - let mockResponse: Partial; + let fetchMock: jest.SpyInstance>; + let mockResponse: Partial; beforeEach(() => { - fetchMock = jest.spyOn(window, 'fetch'); - mockResponse = { - ok: true, - json: () => new Promise(r => r({ up: 3, down: 12, total: 15 })), - }; + apiService.http = { + get: jest.fn(), + } as any; + fetchMock = jest.spyOn(apiService.http, 'get'); + mockResponse = { up: 3, down: 12, total: 15 }; }); afterEach(() => { @@ -25,49 +27,43 @@ describe('snapshot API', () => { it('calls url with expected params and returns response body on 200', async () => { fetchMock.mockReturnValue(new Promise(r => r(mockResponse))); const resp = await fetchSnapshotCount({ - basePath: '', dateRangeStart: 'now-15m', dateRangeEnd: 'now', filters: 'monitor.id:"auto-http-0X21EE76EAC459873F"', statusFilter: 'up', }); - expect(fetchMock).toHaveBeenCalledWith( - '/api/uptime/snapshot/count?dateRangeStart=now-15m&dateRangeEnd=now&filters=monitor.id%3A%22auto-http-0X21EE76EAC459873F%22&statusFilter=up' - ); + expect(fetchMock).toHaveBeenCalledWith('/api/uptime/snapshot/count', { + query: { + dateRangeEnd: 'now', + dateRangeStart: 'now-15m', + filters: 'monitor.id:"auto-http-0X21EE76EAC459873F"', + statusFilter: 'up', + }, + }); expect(resp).toEqual({ up: 3, down: 12, total: 15 }); }); it(`throws when server response doesn't correspond to expected type`, async () => { - mockResponse = { ok: true, json: () => new Promise(r => r({ foo: 'bar' })) }; + mockResponse = { foo: 'bar' }; fetchMock.mockReturnValue(new Promise(r => r(mockResponse))); - let error: Error | undefined; - try { - await fetchSnapshotCount({ - basePath: '', - dateRangeStart: 'now-15m', - dateRangeEnd: 'now', - filters: 'monitor.id: baz', - statusFilter: 'up', - }); - } catch (e) { - error = e; - } - expect(error).toMatchSnapshot(); + const result = await fetchSnapshotCount({ + dateRangeStart: 'now-15m', + dateRangeEnd: 'now', + filters: 'monitor.id: baz', + statusFilter: 'up', + }); + + expect(result).toMatchSnapshot(); }); it('throws an error when response is not ok', async () => { - mockResponse = { ok: false, statusText: 'There was an error fetching your data.' }; - fetchMock.mockReturnValue(new Promise(r => r(mockResponse))); - let error: Error | undefined; - try { - await fetchSnapshotCount({ - basePath: '', - dateRangeStart: 'now-15m', - dateRangeEnd: 'now', - }); - } catch (e) { - error = e; - } - expect(error).toEqual(new Error('There was an error fetching your data.')); + mockResponse = new HttpFetchError('There was an error fetching your data.', 'error', {} as any); + fetchMock.mockReturnValue(mockResponse); + const result = await fetchSnapshotCount({ + dateRangeStart: 'now-15m', + dateRangeEnd: 'now', + }); + + expect(result).toEqual(new Error('There was an error fetching your data.')); }); }); diff --git a/x-pack/legacy/plugins/uptime/public/state/api/index_pattern.ts b/x-pack/legacy/plugins/uptime/public/state/api/index_pattern.ts index 2669376d728ab..1eecbc75c5bf4 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/index_pattern.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/index_pattern.ts @@ -4,18 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getApiPath } from '../../lib/helper'; +import { API_URLS } from '../../../common/constants'; +import { apiService } from './utils'; -interface APIParams { - basePath: string; -} - -export const fetchIndexPattern = async ({ basePath }: APIParams) => { - const url = getApiPath(`/api/uptime/index_pattern`, basePath); - - const response = await fetch(url); - if (!response.ok) { - throw new Error(response.statusText); - } - return await response.json(); +export const fetchIndexPattern = async () => { + return await apiService.get(API_URLS.INDEX_PATTERN); }; diff --git a/x-pack/legacy/plugins/uptime/public/state/api/index_status.ts b/x-pack/legacy/plugins/uptime/public/state/api/index_status.ts index 9c531b3406a7c..0e33ab617777a 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/index_status.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/index_status.ts @@ -4,28 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PathReporter } from 'io-ts/lib/PathReporter'; -import { isRight } from 'fp-ts/lib/Either'; -import { getApiPath } from '../../lib/helper'; -import { REST_API_URLS } from '../../../common/constants/rest_api'; +import { API_URLS } from '../../../common/constants'; import { StatesIndexStatus, StatesIndexStatusType } from '../../../common/runtime_types'; +import { apiService } from './utils'; -interface ApiRequest { - basePath: string; -} - -export const fetchIndexStatus = async ({ basePath }: ApiRequest): Promise => { - const url = getApiPath(REST_API_URLS.INDEX_STATUS, basePath); - - const response = await fetch(url); - if (!response.ok) { - throw new Error(response.statusText); - } - const responseData = await response.json(); - const decoded = StatesIndexStatusType.decode(responseData); - PathReporter.report(decoded); - if (isRight(decoded)) { - return decoded.right; - } - throw PathReporter.report(decoded); +export const fetchIndexStatus = async (): Promise => { + return await apiService.get(API_URLS.INDEX_STATUS, undefined, StatesIndexStatusType); }; diff --git a/x-pack/legacy/plugins/uptime/public/state/api/monitor.ts b/x-pack/legacy/plugins/uptime/public/state/api/monitor.ts index 80fd311c3ec7e..b36eccca98da9 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/monitor.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/monitor.ts @@ -4,71 +4,38 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PathReporter } from 'io-ts/lib/PathReporter'; -import { getApiPath } from '../../lib/helper'; import { BaseParams } from './types'; -import { - MonitorDetailsType, - MonitorDetails, - MonitorLocations, - MonitorLocationsType, -} from '../../../common/runtime_types'; +import { MonitorDetailsType, MonitorLocationsType } from '../../../common/runtime_types'; import { QueryParams } from '../actions/types'; +import { apiService } from './utils'; +import { API_URLS } from '../../../common/constants/rest_api'; interface ApiRequest { monitorId: string; - basePath: string; } export type MonitorQueryParams = BaseParams & ApiRequest; export const fetchMonitorDetails = async ({ monitorId, - basePath, dateStart, dateEnd, -}: MonitorQueryParams): Promise => { - const url = getApiPath(`/api/uptime/monitor/details`, basePath); +}: MonitorQueryParams) => { const params = { monitorId, dateStart, dateEnd, }; - const urlParams = new URLSearchParams(params).toString(); - const response = await fetch(`${url}?${urlParams}`); - - if (!response.ok) { - throw new Error(response.statusText); - } - return response.json().then(data => { - PathReporter.report(MonitorDetailsType.decode(data)); - return data; - }); + return await apiService.get(API_URLS.MONITOR_DETAILS, params, MonitorDetailsType); }; type ApiParams = QueryParams & ApiRequest; -export const fetchMonitorLocations = async ({ - monitorId, - basePath, - dateStart, - dateEnd, -}: ApiParams): Promise => { - const url = getApiPath(`/api/uptime/monitor/locations`, basePath); - +export const fetchMonitorLocations = async ({ monitorId, dateStart, dateEnd }: ApiParams) => { const params = { dateStart, dateEnd, monitorId, }; - const urlParams = new URLSearchParams(params).toString(); - const response = await fetch(`${url}?${urlParams}`); - - if (!response.ok) { - throw new Error(response.statusText); - } - return response.json().then(data => { - PathReporter.report(MonitorLocationsType.decode(data)); - return data; - }); + return await apiService.get(API_URLS.MONITOR_LOCATIONS, params, MonitorLocationsType); }; diff --git a/x-pack/legacy/plugins/uptime/public/state/api/monitor_duration.ts b/x-pack/legacy/plugins/uptime/public/state/api/monitor_duration.ts index 44e797457e5fd..daf725119fcf3 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/monitor_duration.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/monitor_duration.ts @@ -4,29 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { stringify } from 'query-string'; - -import { getApiPath } from '../../lib/helper'; import { BaseParams } from './types'; +import { apiService } from './utils'; +import { API_URLS } from '../../../common/constants/rest_api'; -export const fetchMonitorDuration = async ({ - basePath, - monitorId, - dateStart, - dateEnd, -}: BaseParams) => { - const url = getApiPath(`/api/uptime/monitor/duration`, basePath); - - const params = { +export const fetchMonitorDuration = async ({ monitorId, dateStart, dateEnd }: BaseParams) => { + const queryParams = { monitorId, dateStart, dateEnd, }; - const urlParams = stringify(params); - const response = await fetch(`${url}?${urlParams}`); - if (!response.ok) { - throw new Error(response.statusText); - } - return await response.json(); + return await apiService.get(API_URLS.MONITOR_DURATION, queryParams); }; diff --git a/x-pack/legacy/plugins/uptime/public/state/api/monitor_status.ts b/x-pack/legacy/plugins/uptime/public/state/api/monitor_status.ts index 936e864b75619..0f7608ba57ea7 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/monitor_status.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/monitor_status.ts @@ -4,46 +4,33 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getApiPath } from '../../lib/helper'; import { QueryParams } from '../actions/types'; import { Ping } from '../../../common/graphql/types'; +import { apiService } from './utils'; +import { API_URLS } from '../../../common/constants/rest_api'; export interface APIParams { - basePath: string; monitorId: string; } -export const fetchSelectedMonitor = async ({ basePath, monitorId }: APIParams): Promise => { - const url = getApiPath(`/api/uptime/monitor/selected`, basePath); - const params = { +export const fetchSelectedMonitor = async ({ monitorId }: APIParams): Promise => { + const queryParams = { monitorId, }; - const urlParams = new URLSearchParams(params).toString(); - const response = await fetch(`${url}?${urlParams}`); - if (!response.ok) { - throw new Error(response.statusText); - } - const responseData = await response.json(); - return responseData; + + return await apiService.get(API_URLS.MONITOR_SELECTED, queryParams); }; export const fetchMonitorStatus = async ({ - basePath, monitorId, dateStart, dateEnd, -}: QueryParams & APIParams): Promise => { - const url = getApiPath(`/api/uptime/monitor/status`, basePath); - const params = { +}: QueryParams): Promise => { + const queryParams = { monitorId, dateStart, dateEnd, }; - const urlParams = new URLSearchParams(params).toString(); - const response = await fetch(`${url}?${urlParams}`); - if (!response.ok) { - throw new Error(response.statusText); - } - const responseData = await response.json(); - return responseData; + + return await apiService.get(API_URLS.MONITOR_STATUS, queryParams); }; diff --git a/x-pack/legacy/plugins/uptime/public/state/api/overview_filters.ts b/x-pack/legacy/plugins/uptime/public/state/api/overview_filters.ts index c3ef62fa88dcf..9943bc27f11f0 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/overview_filters.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/overview_filters.ts @@ -4,18 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ThrowReporter } from 'io-ts/lib/ThrowReporter'; -import { isRight } from 'fp-ts/lib/Either'; import { GetOverviewFiltersPayload } from '../actions/overview_filters'; -import { getApiPath, parameterizeValues } from '../../lib/helper'; import { OverviewFiltersType } from '../../../common/runtime_types'; - -type ApiRequest = GetOverviewFiltersPayload & { - basePath: string; -}; +import { apiService } from './utils'; +import { API_URLS } from '../../../common/constants/rest_api'; export const fetchOverviewFilters = async ({ - basePath, dateRangeStart, dateRangeEnd, search, @@ -23,30 +17,16 @@ export const fetchOverviewFilters = async ({ locations, ports, tags, -}: ApiRequest) => { - const url = getApiPath(`/api/uptime/filters`, basePath); - - const params = new URLSearchParams({ +}: GetOverviewFiltersPayload) => { + const queryParams = { dateRangeStart, dateRangeEnd, - }); - - if (search) { - params.append('search', search); - } - - parameterizeValues(params, { schemes, locations, ports, tags }); - - const response = await fetch(`${url}?${params.toString()}`); - if (!response.ok) { - throw new Error(response.statusText); - } - const responseData = await response.json(); - const decoded = OverviewFiltersType.decode(responseData); - - ThrowReporter.report(decoded); - if (isRight(decoded)) { - return decoded.right; - } - throw new Error('`getOverviewFilters` response did not correspond to expected type'); + schemes, + locations, + ports, + tags, + search, + }; + + return await apiService.get(API_URLS.FILTERS, queryParams, OverviewFiltersType); }; diff --git a/x-pack/legacy/plugins/uptime/public/state/api/ping.ts b/x-pack/legacy/plugins/uptime/public/state/api/ping.ts index c61bf42c8c90e..df71cc8d67bd0 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/ping.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/ping.ts @@ -4,32 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { stringify } from 'query-string'; -import { getApiPath } from '../../lib/helper'; import { APIFn } from './types'; import { GetPingHistogramParams, HistogramResult } from '../../../common/types'; +import { apiService } from './utils'; +import { API_URLS } from '../../../common/constants/rest_api'; export const fetchPingHistogram: APIFn = async ({ - basePath, monitorId, dateStart, dateEnd, statusFilter, filters, }) => { - const url = getApiPath(`/api/uptime/ping/histogram`, basePath); - const params = { + const queryParams = { dateStart, dateEnd, ...(monitorId && { monitorId }), ...(statusFilter && { statusFilter }), ...(filters && { filters }), }; - const urlParams = stringify(params, { sort: false }); - const response = await fetch(`${url}?${urlParams}`); - if (!response.ok) { - throw new Error(response.statusText); - } - const responseData = await response.json(); - return responseData; + + return await apiService.get(API_URLS.PING_HISTOGRAM, queryParams); }; diff --git a/x-pack/legacy/plugins/uptime/public/state/api/snapshot.ts b/x-pack/legacy/plugins/uptime/public/state/api/snapshot.ts index cbfe00a4a8746..e663d0241d688 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/snapshot.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/snapshot.ts @@ -4,13 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ThrowReporter } from 'io-ts/lib/ThrowReporter'; -import { isRight } from 'fp-ts/lib/Either'; -import { getApiPath } from '../../lib/helper'; import { SnapshotType, Snapshot } from '../../../common/runtime_types'; +import { apiService } from './utils'; +import { API_URLS } from '../../../common/constants/rest_api'; -interface ApiRequest { - basePath: string; +export interface SnapShotQueryParams { dateRangeStart: string; dateRangeEnd: string; filters?: string; @@ -18,29 +16,17 @@ interface ApiRequest { } export const fetchSnapshotCount = async ({ - basePath, dateRangeStart, dateRangeEnd, filters, statusFilter, -}: ApiRequest): Promise => { - const url = getApiPath(`/api/uptime/snapshot/count`, basePath); - const params = { +}: SnapShotQueryParams): Promise => { + const queryParams = { dateRangeStart, dateRangeEnd, ...(filters && { filters }), ...(statusFilter && { statusFilter }), }; - const urlParams = new URLSearchParams(params).toString(); - const response = await fetch(`${url}?${urlParams}`); - if (!response.ok) { - throw new Error(response.statusText); - } - const responseData = await response.json(); - const decoded = SnapshotType.decode(responseData); - ThrowReporter.report(decoded); - if (isRight(decoded)) { - return decoded.right; - } - throw new Error('`getSnapshotCount` response did not correspond to expected type'); + + return await apiService.get(API_URLS.SNAPSHOT_COUNT, queryParams, SnapshotType); }; diff --git a/x-pack/legacy/plugins/uptime/public/state/api/types.ts b/x-pack/legacy/plugins/uptime/public/state/api/types.ts index a148f1c7d7ae3..4232751cbc032 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/types.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/types.ts @@ -5,7 +5,6 @@ */ export interface BaseParams { - basePath: string; dateStart: string; dateEnd: string; filters?: string; @@ -14,4 +13,4 @@ export interface BaseParams { monitorId?: string; } -export type APIFn = (params: { basePath: string } & P) => Promise; +export type APIFn = (params: P) => Promise; diff --git a/x-pack/legacy/plugins/uptime/public/state/api/utils.ts b/x-pack/legacy/plugins/uptime/public/state/api/utils.ts new file mode 100644 index 0000000000000..e67efa8570c11 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/api/utils.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PathReporter } from 'io-ts/lib/PathReporter'; +import { isRight } from 'fp-ts/lib/Either'; +import { HttpFetchQuery, HttpSetup } from '../../../../../../../target/types/core/public'; + +class ApiService { + private static instance: ApiService; + private _http!: HttpSetup; + + public get http() { + return this._http; + } + + public set http(httpSetup: HttpSetup) { + this._http = httpSetup; + } + + private constructor() {} + + static getInstance(): ApiService { + if (!ApiService.instance) { + ApiService.instance = new ApiService(); + } + + return ApiService.instance; + } + + public async get(apiUrl: string, params?: HttpFetchQuery, decodeType?: any) { + const response = await this._http!.get(apiUrl, { query: params }); + + if (decodeType) { + const decoded = decodeType.decode(response); + if (isRight(decoded)) { + return decoded.right; + } else { + // eslint-disable-next-line no-console + console.error( + `API ${apiUrl} is not returning expected response, ${PathReporter.report(decoded)}` + ); + } + } + + return response; + } + + public async post(apiUrl: string, data?: any, decodeType?: any) { + const response = await this._http!.post(apiUrl, { + method: 'POST', + body: JSON.stringify(data), + }); + + if (decodeType) { + const decoded = decodeType.decode(response); + if (isRight(decoded)) { + return decoded.right; + } else { + // eslint-disable-next-line no-console + console.warn( + `API ${apiUrl} is not returning expected response, ${PathReporter.report(decoded)}` + ); + } + } + return response; + } + + public async delete(apiUrl: string) { + const response = await this._http!.delete(apiUrl); + if (response instanceof Error) { + throw response; + } + return response; + } +} + +export const apiService = ApiService.getInstance(); diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/fetch_effect.ts b/x-pack/legacy/plugins/uptime/public/state/effects/fetch_effect.ts index ea389ff0a6745..d1d7626b2eab3 100644 --- a/x-pack/legacy/plugins/uptime/public/state/effects/fetch_effect.ts +++ b/x-pack/legacy/plugins/uptime/public/state/effects/fetch_effect.ts @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { call, put, select } from 'redux-saga/effects'; +import { call, put } from 'redux-saga/effects'; import { Action } from 'redux-actions'; -import { getBasePath } from '../selectors'; /** * Factory function for a fetch effect. It expects three action creators, @@ -25,15 +24,17 @@ export function fetchEffectFactory( fail: (error: Error) => Action ) { return function*(action: Action) { - try { - const { - payload: { ...params }, - } = action; - const basePath = yield select(getBasePath); - const response = yield call(fetch, { ...params, basePath }); + const { + payload: { ...params }, + } = action; + const response = yield call(fetch, params); + if (response instanceof Error) { + // eslint-disable-next-line no-console + console.error(response); + + yield put(fail(response)); + } else { yield put(success(response)); - } catch (error) { - yield put(fail(error)); } }; } diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/monitor.ts b/x-pack/legacy/plugins/uptime/public/state/effects/monitor.ts index 1cac7424b4e5b..ed21f315476d4 100644 --- a/x-pack/legacy/plugins/uptime/public/state/effects/monitor.ts +++ b/x-pack/legacy/plugins/uptime/public/state/effects/monitor.ts @@ -4,48 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ -import { call, put, takeLatest, select } from 'redux-saga/effects'; -import { Action } from 'redux-actions'; +import { takeLatest } from 'redux-saga/effects'; import { - FETCH_MONITOR_DETAILS, - FETCH_MONITOR_DETAILS_SUCCESS, - FETCH_MONITOR_DETAILS_FAIL, - FETCH_MONITOR_LOCATIONS, - FETCH_MONITOR_LOCATIONS_SUCCESS, - FETCH_MONITOR_LOCATIONS_FAIL, + getMonitorDetailsAction, + getMonitorDetailsActionSuccess, + getMonitorDetailsActionFail, + getMonitorLocationsAction, + getMonitorLocationsActionSuccess, + getMonitorLocationsActionFail, } from '../actions/monitor'; import { fetchMonitorDetails, fetchMonitorLocations } from '../api'; -import { getBasePath } from '../selectors'; -import { MonitorDetailsActionPayload } from '../actions/types'; - -function* monitorDetailsEffect(action: Action) { - const { monitorId, dateStart, dateEnd }: MonitorDetailsActionPayload = action.payload; - try { - const basePath = yield select(getBasePath); - const response = yield call(fetchMonitorDetails, { - monitorId, - basePath, - dateStart, - dateEnd, - }); - yield put({ type: FETCH_MONITOR_DETAILS_SUCCESS, payload: response }); - } catch (error) { - yield put({ type: FETCH_MONITOR_DETAILS_FAIL, payload: error.message }); - } -} - -function* monitorLocationsEffect(action: Action) { - const payload = action.payload; - try { - const basePath = yield select(getBasePath); - const response = yield call(fetchMonitorLocations, { basePath, ...payload }); - yield put({ type: FETCH_MONITOR_LOCATIONS_SUCCESS, payload: response }); - } catch (error) { - yield put({ type: FETCH_MONITOR_LOCATIONS_FAIL, payload: error.message }); - } -} +import { fetchEffectFactory } from './fetch_effect'; export function* fetchMonitorDetailsEffect() { - yield takeLatest(FETCH_MONITOR_DETAILS, monitorDetailsEffect); - yield takeLatest(FETCH_MONITOR_LOCATIONS, monitorLocationsEffect); + yield takeLatest( + getMonitorDetailsAction, + fetchEffectFactory( + fetchMonitorDetails, + getMonitorDetailsActionSuccess, + getMonitorDetailsActionFail + ) + ); + + yield takeLatest( + getMonitorLocationsAction, + fetchEffectFactory( + fetchMonitorLocations, + getMonitorLocationsActionSuccess, + getMonitorLocationsActionFail + ) + ); } diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/monitor_status.ts b/x-pack/legacy/plugins/uptime/public/state/effects/monitor_status.ts index cab32092a14cd..1207ab20bc711 100644 --- a/x-pack/legacy/plugins/uptime/public/state/effects/monitor_status.ts +++ b/x-pack/legacy/plugins/uptime/public/state/effects/monitor_status.ts @@ -4,50 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ -import { call, put, takeLatest, select } from 'redux-saga/effects'; -import { Action } from 'redux-actions'; +import { takeLatest } from 'redux-saga/effects'; import { - getSelectedMonitor, - getSelectedMonitorSuccess, - getSelectedMonitorFail, - getMonitorStatus, - getMonitorStatusSuccess, - getMonitorStatusFail, -} from '../actions/monitor_status'; + getSelectedMonitorAction, + getSelectedMonitorActionSuccess, + getSelectedMonitorActionFail, + getMonitorStatusAction, + getMonitorStatusActionSuccess, + getMonitorStatusActionFail, +} from '../actions'; import { fetchSelectedMonitor, fetchMonitorStatus } from '../api'; -import { getBasePath } from '../selectors'; - -function* selectedMonitorEffect(action: Action) { - const { monitorId } = action.payload; - try { - const basePath = yield select(getBasePath); - const response = yield call(fetchSelectedMonitor, { - monitorId, - basePath, - }); - yield put({ type: getSelectedMonitorSuccess, payload: response }); - } catch (error) { - yield put({ type: getSelectedMonitorFail, payload: error.message }); - } -} - -function* monitorStatusEffect(action: Action) { - const { monitorId, dateStart, dateEnd } = action.payload; - try { - const basePath = yield select(getBasePath); - const response = yield call(fetchMonitorStatus, { - monitorId, - basePath, - dateStart, - dateEnd, - }); - yield put({ type: getMonitorStatusSuccess, payload: response }); - } catch (error) { - yield put({ type: getMonitorStatusFail, payload: error.message }); - } -} +import { fetchEffectFactory } from './fetch_effect'; export function* fetchMonitorStatusEffect() { - yield takeLatest(getMonitorStatus, monitorStatusEffect); - yield takeLatest(getSelectedMonitor, selectedMonitorEffect); + yield takeLatest( + getMonitorStatusAction, + fetchEffectFactory( + fetchMonitorStatus, + getMonitorStatusActionSuccess, + getMonitorStatusActionFail + ) + ); + + yield takeLatest( + getSelectedMonitorAction, + fetchEffectFactory( + fetchSelectedMonitor, + getSelectedMonitorActionSuccess, + getSelectedMonitorActionFail + ) + ); } diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/snapshot.ts b/x-pack/legacy/plugins/uptime/public/state/effects/snapshot.ts index 91df43dd9e826..10010004d47a0 100644 --- a/x-pack/legacy/plugins/uptime/public/state/effects/snapshot.ts +++ b/x-pack/legacy/plugins/uptime/public/state/effects/snapshot.ts @@ -6,16 +6,20 @@ import { takeLatest } from 'redux-saga/effects'; import { - FETCH_SNAPSHOT_COUNT, - fetchSnapshotCountFail, - fetchSnapshotCountSuccess, + getSnapshotCountAction, + getSnapshotCountActionFail, + getSnapshotCountActionSuccess, } from '../actions'; import { fetchSnapshotCount } from '../api'; import { fetchEffectFactory } from './fetch_effect'; export function* fetchSnapshotCountEffect() { yield takeLatest( - FETCH_SNAPSHOT_COUNT, - fetchEffectFactory(fetchSnapshotCount, fetchSnapshotCountSuccess, fetchSnapshotCountFail) + getSnapshotCountAction, + fetchEffectFactory( + fetchSnapshotCount, + getSnapshotCountActionSuccess, + getSnapshotCountActionFail + ) ); } diff --git a/x-pack/legacy/plugins/uptime/public/state/kibana_service.ts b/x-pack/legacy/plugins/uptime/public/state/kibana_service.ts new file mode 100644 index 0000000000000..4fd2d446daa17 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/kibana_service.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreStart } from 'kibana/public'; +import { apiService } from './api/utils'; + +class KibanaService { + private static instance: KibanaService; + private _core!: CoreStart; + + public get core() { + return this._core; + } + + public set core(coreStart: CoreStart) { + this._core = coreStart; + apiService.http = this._core.http; + } + + private constructor() {} + + static getInstance(): KibanaService { + if (!KibanaService.instance) { + KibanaService.instance = new KibanaService(); + } + + return KibanaService.instance; + } +} + +export const kibanaService = KibanaService.getInstance(); diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap index 5d03c0058c3c1..1dc4e45606c60 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap @@ -2,6 +2,7 @@ exports[`ui reducer adds integration popover status to state 1`] = ` Object { + "alertFlyoutVisible": false, "basePath": "", "esKuery": "", "integrationsPopoverOpen": Object { @@ -14,6 +15,7 @@ Object { exports[`ui reducer sets the application's base path 1`] = ` Object { + "alertFlyoutVisible": false, "basePath": "yyz", "esKuery": "", "integrationsPopoverOpen": null, @@ -23,6 +25,7 @@ Object { exports[`ui reducer updates the refresh value 1`] = ` Object { + "alertFlyoutVisible": false, "basePath": "abc", "esKuery": "", "integrationsPopoverOpen": null, diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/snapshot.test.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/snapshot.test.ts index 95c576e0fd72e..3650422571ce8 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/snapshot.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/snapshot.test.ts @@ -5,19 +5,20 @@ */ import { snapshotReducer } from '../snapshot'; -import { SnapshotActionTypes } from '../../actions'; +import { + getSnapshotCountAction, + getSnapshotCountActionSuccess, + getSnapshotCountActionFail, +} from '../../actions'; describe('snapshot reducer', () => { it('updates existing state', () => { - const action: SnapshotActionTypes = { - type: 'FETCH_SNAPSHOT_COUNT', - payload: { - dateRangeStart: 'now-15m', - dateRangeEnd: 'now', - filters: 'foo: bar', - statusFilter: 'up', - }, - }; + const action = getSnapshotCountAction({ + dateRangeStart: 'now-15m', + dateRangeEnd: 'now', + filters: 'foo: bar', + statusFilter: 'up', + }); expect( snapshotReducer( { @@ -31,33 +32,28 @@ describe('snapshot reducer', () => { }); it(`sets the state's status to loading during a fetch`, () => { - const action: SnapshotActionTypes = { - type: 'FETCH_SNAPSHOT_COUNT', - payload: { - dateRangeStart: 'now-15m', - dateRangeEnd: 'now', - }, - }; + const action = getSnapshotCountAction({ + dateRangeStart: 'now-15m', + dateRangeEnd: 'now', + }); expect(snapshotReducer(undefined, action)).toMatchSnapshot(); }); it('changes the count when a snapshot fetch succeeds', () => { - const action: SnapshotActionTypes = { - type: 'FETCH_SNAPSHOT_COUNT_SUCCESS', - payload: { - up: 10, - down: 15, - total: 25, - }, - }; + const action = getSnapshotCountActionSuccess({ + up: 10, + down: 15, + total: 25, + }); + expect(snapshotReducer(undefined, action)).toMatchSnapshot(); }); it('appends a current error to existing errors list', () => { - const action: SnapshotActionTypes = { - type: 'FETCH_SNAPSHOT_COUNT_FAIL', - payload: new Error(`I couldn't get your data because the server denied the request`), - }; + const action = getSnapshotCountActionFail( + new Error(`I couldn't get your data because the server denied the request`) + ); + expect(snapshotReducer(undefined, action)).toMatchSnapshot(); }); }); diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts index 417095b64ba2d..3c134366347aa 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { setBasePath, toggleIntegrationsPopover, triggerAppRefresh } from '../../actions'; +import { + setBasePath, + toggleIntegrationsPopover, + triggerAppRefresh, + setAlertFlyoutVisible, +} from '../../actions'; import { uiReducer } from '../ui'; import { Action } from 'redux-actions'; @@ -14,6 +19,7 @@ describe('ui reducer', () => { expect( uiReducer( { + alertFlyoutVisible: false, basePath: 'abc', esKuery: '', integrationsPopoverOpen: null, @@ -32,6 +38,7 @@ describe('ui reducer', () => { expect( uiReducer( { + alertFlyoutVisible: false, basePath: '', esKuery: '', integrationsPopoverOpen: null, @@ -47,6 +54,7 @@ describe('ui reducer', () => { expect( uiReducer( { + alertFlyoutVisible: false, basePath: 'abc', esKuery: '', integrationsPopoverOpen: null, @@ -56,4 +64,28 @@ describe('ui reducer', () => { ) ).toMatchSnapshot(); }); + + it('updates the alert flyout value', () => { + const action = setAlertFlyoutVisible(true) as Action; + expect( + uiReducer( + { + alertFlyoutVisible: false, + basePath: '', + esKuery: '', + integrationsPopoverOpen: null, + lastRefresh: 125, + }, + action + ) + ).toMatchInlineSnapshot(` + Object { + "alertFlyoutVisible": true, + "basePath": "", + "esKuery": "", + "integrationsPopoverOpen": null, + "lastRefresh": 125, + } + `); + }); }); diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/monitor.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/monitor.ts index aac8a90598d0c..632f3a270e1a1 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/monitor.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/monitor.ts @@ -4,15 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Action } from 'redux-actions'; import { - MonitorActionTypes, MonitorDetailsState, - FETCH_MONITOR_DETAILS, - FETCH_MONITOR_DETAILS_SUCCESS, - FETCH_MONITOR_DETAILS_FAIL, - FETCH_MONITOR_LOCATIONS, - FETCH_MONITOR_LOCATIONS_SUCCESS, - FETCH_MONITOR_LOCATIONS_FAIL, + getMonitorDetailsAction, + getMonitorLocationsAction, + getMonitorDetailsActionSuccess, + getMonitorDetailsActionFail, + getMonitorLocationsActionSuccess, + getMonitorLocationsActionFail, } from '../actions/monitor'; import { MonitorLocations } from '../../../common/runtime_types'; @@ -32,14 +32,14 @@ const initialState: MonitorState = { errors: [], }; -export function monitorReducer(state = initialState, action: MonitorActionTypes): MonitorState { +export function monitorReducer(state = initialState, action: Action): MonitorState { switch (action.type) { - case FETCH_MONITOR_DETAILS: + case String(getMonitorDetailsAction): return { ...state, loading: true, }; - case FETCH_MONITOR_DETAILS_SUCCESS: + case String(getMonitorDetailsActionSuccess): const { monitorId } = action.payload; return { ...state, @@ -49,17 +49,17 @@ export function monitorReducer(state = initialState, action: MonitorActionTypes) }, loading: false, }; - case FETCH_MONITOR_DETAILS_FAIL: + case String(getMonitorDetailsActionFail): return { ...state, errors: [...state.errors, action.payload], }; - case FETCH_MONITOR_LOCATIONS: + case String(getMonitorLocationsAction): return { ...state, loading: true, }; - case FETCH_MONITOR_LOCATIONS_SUCCESS: + case String(getMonitorLocationsActionSuccess): const monLocations = state.monitorLocationsList; monLocations.set(action.payload.monitorId, action.payload); return { @@ -67,7 +67,7 @@ export function monitorReducer(state = initialState, action: MonitorActionTypes) monitorLocationsList: monLocations, loading: false, }; - case FETCH_MONITOR_LOCATIONS_FAIL: + case String(getMonitorLocationsActionFail): return { ...state, errors: [...state.errors, action.payload], diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/monitor_status.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/monitor_status.ts index 2688a0946dd61..c2dfbd7f90ff2 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/monitor_status.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/monitor_status.ts @@ -5,12 +5,12 @@ */ import { handleActions, Action } from 'redux-actions'; import { - getSelectedMonitor, - getSelectedMonitorSuccess, - getSelectedMonitorFail, - getMonitorStatus, - getMonitorStatusSuccess, - getMonitorStatusFail, + getSelectedMonitorAction, + getSelectedMonitorActionSuccess, + getSelectedMonitorActionFail, + getMonitorStatusAction, + getMonitorStatusActionSuccess, + getMonitorStatusActionFail, } from '../actions'; import { Ping } from '../../../common/graphql/types'; import { QueryParams } from '../actions/types'; @@ -31,34 +31,34 @@ type MonitorStatusPayload = QueryParams & Ping; export const monitorStatusReducer = handleActions( { - [String(getSelectedMonitor)]: (state, action: Action) => ({ + [String(getSelectedMonitorAction)]: (state, action: Action) => ({ ...state, loading: true, }), - [String(getSelectedMonitorSuccess)]: (state, action: Action) => ({ + [String(getSelectedMonitorActionSuccess)]: (state, action: Action) => ({ ...state, loading: false, monitor: { ...action.payload } as Ping, }), - [String(getSelectedMonitorFail)]: (state, action: Action) => ({ + [String(getSelectedMonitorActionFail)]: (state, action: Action) => ({ ...state, loading: false, }), - [String(getMonitorStatus)]: (state, action: Action) => ({ + [String(getMonitorStatusAction)]: (state, action: Action) => ({ ...state, loading: true, }), - [String(getMonitorStatusSuccess)]: (state, action: Action) => ({ + [String(getMonitorStatusActionSuccess)]: (state, action: Action) => ({ ...state, loading: false, status: { ...action.payload } as Ping, }), - [String(getMonitorStatusFail)]: (state, action: Action) => ({ + [String(getMonitorStatusActionFail)]: (state, action: Action) => ({ ...state, loading: false, }), diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/overview_filters.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/overview_filters.ts index b219421f4f4dc..0b67d8b0e7689 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/overview_filters.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/overview_filters.ts @@ -49,6 +49,7 @@ export function overviewFiltersReducer( return { ...state, errors: [...state.errors, action.payload], + loading: false, }; default: return state; diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/snapshot.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/snapshot.ts index 2155d0e3a74e3..3ba1ef84d41a5 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/snapshot.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/snapshot.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Action } from 'redux-actions'; import { Snapshot } from '../../../common/runtime_types'; import { - FETCH_SNAPSHOT_COUNT, - FETCH_SNAPSHOT_COUNT_FAIL, - FETCH_SNAPSHOT_COUNT_SUCCESS, - SnapshotActionTypes, + getSnapshotCountAction, + getSnapshotCountActionSuccess, + getSnapshotCountActionFail, } from '../actions'; export interface SnapshotState { @@ -28,20 +28,20 @@ const initialState: SnapshotState = { loading: false, }; -export function snapshotReducer(state = initialState, action: SnapshotActionTypes): SnapshotState { +export function snapshotReducer(state = initialState, action: Action): SnapshotState { switch (action.type) { - case FETCH_SNAPSHOT_COUNT: + case String(getSnapshotCountAction): return { ...state, loading: true, }; - case FETCH_SNAPSHOT_COUNT_SUCCESS: + case String(getSnapshotCountActionSuccess): return { ...state, count: action.payload, loading: false, }; - case FETCH_SNAPSHOT_COUNT_FAIL: + case String(getSnapshotCountActionFail): return { ...state, errors: [...state.errors, action.payload], diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts index bb5bd22085ac6..702d314250521 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts @@ -12,19 +12,22 @@ import { setEsKueryString, triggerAppRefresh, UiPayload, + setAlertFlyoutVisible, } from '../actions/ui'; export interface UiState { - integrationsPopoverOpen: PopoverState | null; + alertFlyoutVisible: boolean; basePath: string; esKuery: string; + integrationsPopoverOpen: PopoverState | null; lastRefresh: number; } const initialState: UiState = { - integrationsPopoverOpen: null, + alertFlyoutVisible: false, basePath: '', esKuery: '', + integrationsPopoverOpen: null, lastRefresh: Date.now(), }; @@ -35,6 +38,11 @@ export const uiReducer = handleActions( integrationsPopoverOpen: action.payload as PopoverState, }), + [String(setAlertFlyoutVisible)]: (state, action: Action) => ({ + ...state, + alertFlyoutVisible: action.payload ?? !state.alertFlyoutVisible, + }), + [String(setBasePath)]: (state, action: Action) => ({ ...state, basePath: action.payload as string, diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts index de446418632b8..b1da995709f93 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts @@ -35,6 +35,7 @@ describe('state selectors', () => { loading: false, }, ui: { + alertFlyoutVisible: false, basePath: 'yyz', esKuery: '', integrationsPopoverOpen: null, diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts index adba288b8b145..7b5a5ddf8d3ca 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts @@ -13,11 +13,11 @@ export const isIntegrationsPopupOpen = ({ ui: { integrationsPopoverOpen } }: App integrationsPopoverOpen; // Monitor Selectors -export const getMonitorDetails = (state: AppState, summary: any) => { +export const monitorDetailsSelector = (state: AppState, summary: any) => { return state.monitor.monitorDetailsList[summary.monitor_id]; }; -export const selectMonitorLocations = (state: AppState, monitorId: string) => { +export const monitorLocationsSelector = (state: AppState, monitorId: string) => { return state.monitor.monitorLocationsList?.get(monitorId); }; @@ -46,6 +46,15 @@ export const selectDurationLines = ({ monitorDuration }: AppState) => { return monitorDuration; }; +export const selectAlertFlyoutVisibility = ({ ui: { alertFlyoutVisible } }: AppState) => + alertFlyoutVisible; + +export const selectMonitorStatusAlert = ({ indexPattern, overviewFilters, ui }: AppState) => ({ + filters: ui.esKuery, + indexPattern: indexPattern.index_pattern, + locations: overviewFilters.filters.locations, +}); + export const indexStatusSelector = ({ indexStatus }: AppState) => { return indexStatus; }; diff --git a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx index 427870797a206..fa2998532d145 100644 --- a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx +++ b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx @@ -23,6 +23,9 @@ import { CommonlyUsedRange } from './components/functional/uptime_date_picker'; import { store } from './state'; import { setBasePath } from './state/actions'; import { PageRouter } from './routes'; +import { UptimeAlertsFlyoutWrapper } from './components/connected'; +import { UptimeAlertsContextProvider } from './components/functional/alerts'; +import { kibanaService } from './state/kibana_service'; export interface UptimeAppColors { danger: string; @@ -83,6 +86,8 @@ const Application = (props: UptimeAppProps) => { ); }, [canSave, renderGlobalHelpControls, setBadge]); + kibanaService.core = core; + // @ts-ignore store.dispatch(setBasePath(basePath)); @@ -96,11 +101,14 @@ const Application = (props: UptimeAppProps) => { - -
- -
-
+ + +
+ + +
+
+
diff --git a/x-pack/legacy/plugins/xpack_main/public/components/index.js b/x-pack/legacy/plugins/xpack_main/public/components/index.js index e57bd6af189f8..871d86e642dec 100644 --- a/x-pack/legacy/plugins/xpack_main/public/components/index.js +++ b/x-pack/legacy/plugins/xpack_main/public/components/index.js @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -export { LicenseStatus } from '../../../license_management/public/np_ready/application/sections/license_dashboard/license_status/license_status'; +export { LicenseStatus } from '../../../../../plugins/license_management/public/application/sections/license_dashboard/license_status/license_status'; -export { AddLicense } from '../../../license_management/public/np_ready/application/sections/license_dashboard/add_license/add_license'; +export { AddLicense } from '../../../../../plugins/license_management/public/application/sections/license_dashboard/add_license/add_license'; /* * For to link to management */ -export { BASE_PATH as MANAGEMENT_BASE_PATH } from '../../../license_management/common/constants'; +export { BASE_PATH as MANAGEMENT_BASE_PATH } from '../../../../../plugins/license_management/common/constants'; diff --git a/x-pack/package.json b/x-pack/package.json index 6f15b46e28f53..5d75e0c9edc4c 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -23,7 +23,7 @@ } }, "resolutions": { - "**/@types/node": "10.12.27" + "**/@types/node": ">=10.17.17 <10.20.0" }, "devDependencies": { "@cypress/webpack-preprocessor": "^4.1.0", @@ -80,7 +80,7 @@ "@types/mime": "^2.0.1", "@types/mocha": "^5.2.7", "@types/nock": "^10.0.3", - "@types/node": "^10.12.27", + "@types/node": ">=10.17.17 <10.20.0", "@types/node-fetch": "^2.5.0", "@types/nodemailer": "^6.2.1", "@types/object-hash": "^1.3.0", @@ -125,7 +125,7 @@ "enzyme-adapter-react-16": "^1.15.2", "enzyme-adapter-utils": "^1.13.0", "enzyme-to-json": "^3.4.4", - "execa": "^3.2.0", + "execa": "^4.0.0", "fancy-log": "^1.3.2", "fetch-mock": "^7.3.9", "graphql-code-generator": "^0.18.2", @@ -142,6 +142,7 @@ "jest-cli": "^24.9.0", "jest-styled-components": "^7.0.0", "jsdom": "^15.2.1", + "loader-utils": "^1.2.3", "madge": "3.4.4", "marge": "^1.0.1", "mocha": "^6.2.2", @@ -280,7 +281,7 @@ "moment-duration-format": "^2.3.2", "moment-timezone": "^0.5.27", "ngreact": "^0.5.1", - "nock": "10.0.6", + "nock": "12.0.3", "node-fetch": "^2.6.0", "nodemailer": "^4.7.0", "object-hash": "^1.3.1", diff --git a/x-pack/plugins/actions/common/index.ts b/x-pack/plugins/actions/common/index.ts index 9f4141dbcae7d..84eb64f6370ac 100644 --- a/x-pack/plugins/actions/common/index.ts +++ b/x-pack/plugins/actions/common/index.ts @@ -5,3 +5,5 @@ */ export * from './types'; + +export const BASE_ACTION_API_PATH = '/api/action'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts index 381b44439033c..2712b8f6ea9b5 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts @@ -4,74 +4,168 @@ * you may not use this file except in compliance with the Elastic License. */ -import { handleCreateIncident, handleUpdateIncident } from './action_handlers'; +import { + handleCreateIncident, + handleUpdateIncident, + handleIncident, + createComments, +} from './action_handlers'; import { ServiceNow } from './lib'; -import { finalMapping } from './mock'; -import { Incident } from './lib/types'; +import { Mapping } from './types'; jest.mock('./lib'); const ServiceNowMock = ServiceNow as jest.Mock; -const incident: Incident = { - short_description: 'A title', - description: 'A description', -}; +const finalMapping: Mapping = new Map(); + +finalMapping.set('title', { + target: 'short_description', + actionType: 'overwrite', +}); -const comments = [ - { - commentId: '456', - version: 'WzU3LDFd', - comment: 'A comment', - incidentCommentId: undefined, +finalMapping.set('description', { + target: 'description', + actionType: 'overwrite', +}); + +finalMapping.set('comments', { + target: 'comments', + actionType: 'append', +}); + +finalMapping.set('short_description', { + target: 'title', + actionType: 'overwrite', +}); + +const params = { + caseId: '123', + title: 'a title', + description: 'a description', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + incidentId: null, + incident: { + short_description: 'a title', + description: 'a description', }, -]; + comments: [ + { + commentId: '456', + version: 'WzU3LDFd', + comment: 'first comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + ], +}; -describe('handleCreateIncident', () => { - beforeAll(() => { - ServiceNowMock.mockImplementation(() => { - return { - serviceNow: { - getUserID: jest.fn().mockResolvedValue('1234'), - createIncident: jest.fn().mockResolvedValue({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - }), - updateIncident: jest.fn().mockResolvedValue({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - }), - batchCreateComments: jest - .fn() - .mockResolvedValue([{ commentId: '456', pushedDate: '2020-03-10T12:24:20.000Z' }]), - batchUpdateComments: jest - .fn() - .mockResolvedValue([{ commentId: '456', pushedDate: '2020-03-10T12:24:20.000Z' }]), +beforeAll(() => { + ServiceNowMock.mockImplementation(() => { + return { + serviceNow: { + getUserID: jest.fn().mockResolvedValue('1234'), + getIncident: jest.fn().mockResolvedValue({ + short_description: 'servicenow title', + description: 'servicenow desc', + }), + createIncident: jest.fn().mockResolvedValue({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }), + updateIncident: jest.fn().mockResolvedValue({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }), + batchCreateComments: jest + .fn() + .mockResolvedValue([{ commentId: '456', pushedDate: '2020-03-10T12:24:20.000Z' }]), + }, + }; + }); +}); + +describe('handleIncident', () => { + test('create an incident', async () => { + const { serviceNow } = new ServiceNowMock(); + + const res = await handleIncident({ + incidentId: null, + serviceNow, + params, + comments: params.comments, + mapping: finalMapping, + }); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + comments: [ + { + commentId: '456', + pushedDate: '2020-03-10T12:24:20.000Z', }, - }; + ], }); }); + test('update an incident', async () => { + const { serviceNow } = new ServiceNowMock(); + const res = await handleIncident({ + incidentId: '123', + serviceNow, + params, + comments: params.comments, + mapping: finalMapping, + }); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + comments: [ + { + commentId: '456', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + ], + }); + }); +}); + +describe('handleCreateIncident', () => { test('create an incident without comments', async () => { const { serviceNow } = new ServiceNowMock(); const res = await handleCreateIncident({ serviceNow, - params: incident, + params, comments: [], mapping: finalMapping, }); expect(serviceNow.createIncident).toHaveBeenCalled(); - expect(serviceNow.createIncident).toHaveBeenCalledWith(incident); + expect(serviceNow.createIncident).toHaveBeenCalledWith({ + short_description: 'a title (created at 2020-03-13T08:34:53.450Z by Elastic User)', + description: 'a description (created at 2020-03-13T08:34:53.450Z by Elastic User)', + }); expect(serviceNow.createIncident).toHaveReturned(); expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); expect(res).toEqual({ incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); @@ -80,20 +174,41 @@ describe('handleCreateIncident', () => { const res = await handleCreateIncident({ serviceNow, - params: incident, - comments, + params, + comments: params.comments, mapping: finalMapping, }); expect(serviceNow.createIncident).toHaveBeenCalled(); - expect(serviceNow.createIncident).toHaveBeenCalledWith(incident); + expect(serviceNow.createIncident).toHaveBeenCalledWith({ + description: 'a description (created at 2020-03-13T08:34:53.450Z by Elastic User)', + short_description: 'a title (created at 2020-03-13T08:34:53.450Z by Elastic User)', + }); expect(serviceNow.createIncident).toHaveReturned(); expect(serviceNow.batchCreateComments).toHaveBeenCalled(); - expect(serviceNow.batchCreateComments).toHaveBeenCalledWith('123', comments, 'comments'); + expect(serviceNow.batchCreateComments).toHaveBeenCalledWith( + '123', + [ + { + comment: 'first comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', + commentId: '456', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: null, + updatedBy: null, + version: 'WzU3LDFd', + }, + ], + 'comments' + ); expect(res).toEqual({ incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', comments: [ { commentId: '456', @@ -102,56 +217,626 @@ describe('handleCreateIncident', () => { ], }); }); +}); +describe('handleUpdateIncident', () => { test('update an incident without comments', async () => { const { serviceNow } = new ServiceNowMock(); const res = await handleUpdateIncident({ incidentId: '123', serviceNow, - params: incident, + params, comments: [], mapping: finalMapping, }); expect(serviceNow.updateIncident).toHaveBeenCalled(); - expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', incident); + expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { + short_description: 'a title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + description: 'a description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }); expect(serviceNow.updateIncident).toHaveReturned(); - expect(serviceNow.batchUpdateComments).not.toHaveBeenCalled(); + expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); expect(res).toEqual({ incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); - test('update an incident and create new comments', async () => { + test('update an incident with comments', async () => { const { serviceNow } = new ServiceNowMock(); + serviceNow.batchCreateComments.mockResolvedValue([ + { commentId: '456', pushedDate: '2020-03-10T12:24:20.000Z' }, + { commentId: '789', pushedDate: '2020-03-10T12:24:20.000Z' }, + ]); const res = await handleUpdateIncident({ incidentId: '123', serviceNow, - params: incident, - comments, + params, + comments: [ + { + comment: 'first comment', + commentId: '456', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: null, + updatedBy: null, + version: 'WzU3LDFd', + }, + { + comment: 'second comment', + commentId: '789', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-03-13T08:34:53.450Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + version: 'WzU3LDFd', + }, + ], mapping: finalMapping, }); expect(serviceNow.updateIncident).toHaveBeenCalled(); - expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', incident); + expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { + description: 'a description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + short_description: 'a title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }); expect(serviceNow.updateIncident).toHaveReturned(); - expect(serviceNow.batchUpdateComments).not.toHaveBeenCalled(); - expect(serviceNow.batchCreateComments).toHaveBeenCalledWith('123', comments, 'comments'); - + expect(serviceNow.batchCreateComments).toHaveBeenCalled(); + expect(serviceNow.batchCreateComments).toHaveBeenCalledWith( + '123', + [ + { + comment: 'first comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', + commentId: '456', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: null, + updatedBy: null, + version: 'WzU3LDFd', + }, + { + comment: 'second comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', + commentId: '789', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-03-13T08:34:53.450Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + version: 'WzU3LDFd', + }, + ], + 'comments' + ); expect(res).toEqual({ incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', comments: [ { commentId: '456', pushedDate: '2020-03-10T12:24:20.000Z', }, + { + commentId: '789', + pushedDate: '2020-03-10T12:24:20.000Z', + }, ], }); }); }); + +describe('handleUpdateIncident: different action types', () => { + test('overwrite & append', async () => { + const { serviceNow } = new ServiceNowMock(); + finalMapping.set('title', { + target: 'short_description', + actionType: 'overwrite', + }); + + finalMapping.set('description', { + target: 'description', + actionType: 'append', + }); + + finalMapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + finalMapping.set('short_description', { + target: 'title', + actionType: 'overwrite', + }); + + const res = await handleUpdateIncident({ + incidentId: '123', + serviceNow, + params, + comments: [], + mapping: finalMapping, + }); + + expect(serviceNow.updateIncident).toHaveBeenCalled(); + expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { + short_description: 'a title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + description: + 'servicenow desc \r\na description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }); + expect(serviceNow.updateIncident).toHaveReturned(); + expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }); + }); + + test('nothing & append', async () => { + const { serviceNow } = new ServiceNowMock(); + finalMapping.set('title', { + target: 'short_description', + actionType: 'nothing', + }); + + finalMapping.set('description', { + target: 'description', + actionType: 'append', + }); + + finalMapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + finalMapping.set('short_description', { + target: 'title', + actionType: 'nothing', + }); + + const res = await handleUpdateIncident({ + incidentId: '123', + serviceNow, + params, + comments: [], + mapping: finalMapping, + }); + + expect(serviceNow.updateIncident).toHaveBeenCalled(); + expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { + description: + 'servicenow desc \r\na description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }); + expect(serviceNow.updateIncident).toHaveReturned(); + expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }); + }); + + test('append & append', async () => { + const { serviceNow } = new ServiceNowMock(); + finalMapping.set('title', { + target: 'short_description', + actionType: 'append', + }); + + finalMapping.set('description', { + target: 'description', + actionType: 'append', + }); + + finalMapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + finalMapping.set('short_description', { + target: 'title', + actionType: 'append', + }); + + const res = await handleUpdateIncident({ + incidentId: '123', + serviceNow, + params, + comments: [], + mapping: finalMapping, + }); + + expect(serviceNow.updateIncident).toHaveBeenCalled(); + expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { + short_description: + 'servicenow title \r\na title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + description: + 'servicenow desc \r\na description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }); + expect(serviceNow.updateIncident).toHaveReturned(); + expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }); + }); + + test('nothing & nothing', async () => { + const { serviceNow } = new ServiceNowMock(); + finalMapping.set('title', { + target: 'short_description', + actionType: 'nothing', + }); + + finalMapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + finalMapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + finalMapping.set('short_description', { + target: 'title', + actionType: 'nothing', + }); + + const res = await handleUpdateIncident({ + incidentId: '123', + serviceNow, + params, + comments: [], + mapping: finalMapping, + }); + + expect(serviceNow.updateIncident).toHaveBeenCalled(); + expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', {}); + expect(serviceNow.updateIncident).toHaveReturned(); + expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }); + }); + + test('overwrite & nothing', async () => { + const { serviceNow } = new ServiceNowMock(); + finalMapping.set('title', { + target: 'short_description', + actionType: 'overwrite', + }); + + finalMapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + finalMapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + finalMapping.set('short_description', { + target: 'title', + actionType: 'overwrite', + }); + + const res = await handleUpdateIncident({ + incidentId: '123', + serviceNow, + params, + comments: [], + mapping: finalMapping, + }); + + expect(serviceNow.updateIncident).toHaveBeenCalled(); + expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { + short_description: 'a title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }); + expect(serviceNow.updateIncident).toHaveReturned(); + expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }); + }); + + test('overwrite & overwrite', async () => { + const { serviceNow } = new ServiceNowMock(); + finalMapping.set('title', { + target: 'short_description', + actionType: 'overwrite', + }); + + finalMapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + finalMapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + finalMapping.set('short_description', { + target: 'title', + actionType: 'overwrite', + }); + + const res = await handleUpdateIncident({ + incidentId: '123', + serviceNow, + params, + comments: [], + mapping: finalMapping, + }); + + expect(serviceNow.updateIncident).toHaveBeenCalled(); + expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { + short_description: 'a title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + description: 'a description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }); + expect(serviceNow.updateIncident).toHaveReturned(); + expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }); + }); + + test('nothing & overwrite', async () => { + const { serviceNow } = new ServiceNowMock(); + finalMapping.set('title', { + target: 'short_description', + actionType: 'nothing', + }); + + finalMapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + finalMapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + finalMapping.set('short_description', { + target: 'title', + actionType: 'nothing', + }); + + const res = await handleUpdateIncident({ + incidentId: '123', + serviceNow, + params, + comments: [], + mapping: finalMapping, + }); + + expect(serviceNow.updateIncident).toHaveBeenCalled(); + expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { + description: 'a description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }); + expect(serviceNow.updateIncident).toHaveReturned(); + expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }); + }); + + test('append & overwrite', async () => { + const { serviceNow } = new ServiceNowMock(); + finalMapping.set('title', { + target: 'short_description', + actionType: 'append', + }); + + finalMapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + finalMapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + finalMapping.set('short_description', { + target: 'title', + actionType: 'append', + }); + + const res = await handleUpdateIncident({ + incidentId: '123', + serviceNow, + params, + comments: [], + mapping: finalMapping, + }); + + expect(serviceNow.updateIncident).toHaveBeenCalled(); + expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { + short_description: + 'servicenow title \r\na title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + description: 'a description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }); + expect(serviceNow.updateIncident).toHaveReturned(); + expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }); + }); + + test('append & nothing', async () => { + const { serviceNow } = new ServiceNowMock(); + finalMapping.set('title', { + target: 'short_description', + actionType: 'append', + }); + + finalMapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + finalMapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + finalMapping.set('short_description', { + target: 'title', + actionType: 'append', + }); + + const res = await handleUpdateIncident({ + incidentId: '123', + serviceNow, + params, + comments: [], + mapping: finalMapping, + }); + + expect(serviceNow.updateIncident).toHaveBeenCalled(); + expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { + short_description: + 'servicenow title \r\na title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }); + expect(serviceNow.updateIncident).toHaveReturned(); + expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }); + }); +}); + +describe('createComments', () => { + test('create comments correctly', async () => { + const { serviceNow } = new ServiceNowMock(); + serviceNow.batchCreateComments.mockResolvedValue([ + { commentId: '456', pushedDate: '2020-03-10T12:24:20.000Z' }, + { commentId: '789', pushedDate: '2020-03-10T12:24:20.000Z' }, + ]); + + const comments = [ + { + comment: 'first comment', + commentId: '456', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: null, + updatedBy: null, + version: 'WzU3LDFd', + }, + { + comment: 'second comment', + commentId: '789', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-03-13T08:34:53.450Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + version: 'WzU3LDFd', + }, + ]; + + const res = await createComments(serviceNow, '123', 'comments', comments); + + expect(serviceNow.batchCreateComments).toHaveBeenCalled(); + expect(serviceNow.batchCreateComments).toHaveBeenCalledWith( + '123', + [ + { + comment: 'first comment', + commentId: '456', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: null, + updatedBy: null, + version: 'WzU3LDFd', + }, + { + comment: 'second comment', + commentId: '789', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-03-13T08:34:53.450Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + version: 'WzU3LDFd', + }, + ], + 'comments' + ); + expect(res).toEqual([ + { + commentId: '456', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + { + commentId: '789', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + ]); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts index 47120c5da096d..fb296089e9ec5 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts @@ -5,26 +5,27 @@ */ import { zipWith } from 'lodash'; -import { Incident, CommentResponse } from './lib/types'; +import { CommentResponse } from './lib/types'; import { - ActionHandlerArguments, - UpdateParamsType, - UpdateActionHandlerArguments, - IncidentCreationResponse, - CommentType, - CommentsZipped, + HandlerResponse, + Comment, + SimpleComment, + CreateHandlerArguments, + UpdateHandlerArguments, + IncidentHandlerArguments, } from './types'; import { ServiceNow } from './lib'; +import { transformFields, prepareFieldsForTransformation, transformComments } from './helpers'; -const createComments = async ( +export const createComments = async ( serviceNow: ServiceNow, incidentId: string, key: string, - comments: CommentType[] -): Promise => { + comments: Comment[] +): Promise => { const createdComments = await serviceNow.batchCreateComments(incidentId, comments, key); - return zipWith(comments, createdComments, (a: CommentType, b: CommentResponse) => ({ + return zipWith(comments, createdComments, (a: Comment, b: CommentResponse) => ({ commentId: a.commentId, pushedDate: b.pushedDate, })); @@ -35,18 +36,37 @@ export const handleCreateIncident = async ({ params, comments, mapping, -}: ActionHandlerArguments): Promise => { - const paramsAsIncident = params as Incident; +}: CreateHandlerArguments): Promise => { + const fields = prepareFieldsForTransformation({ + params, + mapping, + }); + + const incident = transformFields({ + params, + fields, + }); - const { incidentId, number, pushedDate } = await serviceNow.createIncident({ - ...paramsAsIncident, + const createdIncident = await serviceNow.createIncident({ + ...incident, }); - const res: IncidentCreationResponse = { incidentId, number, pushedDate }; + const res: HandlerResponse = { ...createdIncident }; - if (comments && Array.isArray(comments) && comments.length > 0) { + if ( + comments && + Array.isArray(comments) && + comments.length > 0 && + mapping.get('comments').actionType !== 'nothing' + ) { + comments = transformComments(comments, params, ['informationAdded']); res.comments = [ - ...(await createComments(serviceNow, incidentId, mapping.get('comments').target, comments)), + ...(await createComments( + serviceNow, + res.incidentId, + mapping.get('comments').target, + comments + )), ]; } @@ -59,16 +79,33 @@ export const handleUpdateIncident = async ({ params, comments, mapping, -}: UpdateActionHandlerArguments): Promise => { - const paramsAsIncident = params as UpdateParamsType; +}: UpdateHandlerArguments): Promise => { + const currentIncident = await serviceNow.getIncident(incidentId); + const fields = prepareFieldsForTransformation({ + params, + mapping, + defaultPipes: ['informationUpdated'], + }); + + const incident = transformFields({ + params, + fields, + currentIncident, + }); - const { number, pushedDate } = await serviceNow.updateIncident(incidentId, { - ...paramsAsIncident, + const updatedIncident = await serviceNow.updateIncident(incidentId, { + ...incident, }); - const res: IncidentCreationResponse = { incidentId, number, pushedDate }; + const res: HandlerResponse = { ...updatedIncident }; - if (comments && Array.isArray(comments) && comments.length > 0) { + if ( + comments && + Array.isArray(comments) && + comments.length > 0 && + mapping.get('comments').actionType !== 'nothing' + ) { + comments = transformComments(comments, params, ['informationAdded']); res.comments = [ ...(await createComments(serviceNow, incidentId, mapping.get('comments').target, comments)), ]; @@ -76,3 +113,17 @@ export const handleUpdateIncident = async ({ return { ...res }; }; + +export const handleIncident = async ({ + incidentId, + serviceNow, + params, + comments, + mapping, +}: IncidentHandlerArguments): Promise => { + if (!incidentId) { + return await handleCreateIncident({ serviceNow, params, comments, mapping }); + } else { + return await handleUpdateIncident({ incidentId, serviceNow, params, comments, mapping }); + } +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.test.ts index 96962b41b3c68..ce8c3542ab69f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.test.ts @@ -4,18 +4,62 @@ * you may not use this file except in compliance with the Elastic License. */ -import { normalizeMapping, buildMap, mapParams } from './helpers'; +import { + normalizeMapping, + buildMap, + mapParams, + appendField, + appendInformationToField, + prepareFieldsForTransformation, + transformFields, + transformComments, +} from './helpers'; import { mapping, finalMapping } from './mock'; import { SUPPORTED_SOURCE_FIELDS } from './constants'; -import { MapsType } from './types'; +import { MapEntry, Params, Comment } from './types'; -const maliciousMapping: MapsType[] = [ +const maliciousMapping: MapEntry[] = [ { source: '__proto__', target: 'short_description', actionType: 'nothing' }, { source: 'description', target: '__proto__', actionType: 'nothing' }, { source: 'comments', target: 'comments', actionType: 'nothing' }, { source: 'unsupportedSource', target: 'comments', actionType: 'nothing' }, ]; +const fullParams: Params = { + caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + title: 'a title', + description: 'a description', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + incidentId: null, + incident: { + short_description: 'a title', + description: 'a description', + }, + comments: [ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + version: 'WzU3LDFd', + comment: 'first comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + version: 'WzU3LDFd', + comment: 'second comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + ], +}; + describe('sanitizeMapping', () => { test('remove malicious fields', () => { const sanitizedMapping = normalizeMapping(SUPPORTED_SOURCE_FIELDS, maliciousMapping); @@ -81,3 +125,251 @@ describe('mapParams', () => { expect(fields).not.toEqual(expect.objectContaining(unexpectedFields)); }); }); + +describe('prepareFieldsForTransformation', () => { + test('prepare fields with defaults', () => { + const res = prepareFieldsForTransformation({ + params: fullParams, + mapping: finalMapping, + }); + expect(res).toEqual([ + { + key: 'short_description', + value: 'a title', + actionType: 'overwrite', + pipes: ['informationCreated'], + }, + { + key: 'description', + value: 'a description', + actionType: 'append', + pipes: ['informationCreated', 'append'], + }, + ]); + }); + + test('prepare fields with default pipes', () => { + const res = prepareFieldsForTransformation({ + params: fullParams, + mapping: finalMapping, + defaultPipes: ['myTestPipe'], + }); + expect(res).toEqual([ + { + key: 'short_description', + value: 'a title', + actionType: 'overwrite', + pipes: ['myTestPipe'], + }, + { + key: 'description', + value: 'a description', + actionType: 'append', + pipes: ['myTestPipe', 'append'], + }, + ]); + }); +}); + +describe('transformFields', () => { + test('transform fields for creation correctly', () => { + const fields = prepareFieldsForTransformation({ + params: fullParams, + mapping: finalMapping, + }); + + const res = transformFields({ + params: fullParams, + fields, + }); + + expect(res).toEqual({ + short_description: 'a title (created at 2020-03-13T08:34:53.450Z by Elastic User)', + description: 'a description (created at 2020-03-13T08:34:53.450Z by Elastic User)', + }); + }); + + test('transform fields for update correctly', () => { + const fields = prepareFieldsForTransformation({ + params: fullParams, + mapping: finalMapping, + defaultPipes: ['informationUpdated'], + }); + + const res = transformFields({ + params: fullParams, + fields, + currentIncident: { + short_description: 'first title (created at 2020-03-13T08:34:53.450Z by Elastic User)', + 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-13T08:34:53.450Z by Elastic User)', + description: + 'first description (created at 2020-03-13T08:34:53.450Z by Elastic User) \r\na description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }); + }); + + test('add newline character to descripton', () => { + const fields = prepareFieldsForTransformation({ + params: fullParams, + mapping: finalMapping, + defaultPipes: ['informationUpdated'], + }); + + const res = transformFields({ + params: fullParams, + fields, + currentIncident: { + short_description: 'first title', + description: 'first description', + }, + }); + expect(res.description?.includes('\r\n')).toBe(true); + }); + + test('append username if fullname is undefined', () => { + const fields = prepareFieldsForTransformation({ + params: fullParams, + mapping: finalMapping, + }); + + const res = transformFields({ + params: { ...fullParams, createdBy: { fullName: null, username: 'elastic' } }, + fields, + }); + + expect(res).toEqual({ + short_description: 'a title (created at 2020-03-13T08:34:53.450Z by elastic)', + description: 'a description (created at 2020-03-13T08:34:53.450Z by elastic)', + }); + }); +}); + +describe('appendField', () => { + test('prefix correctly', () => { + expect('my_prefixmy_value ').toEqual(appendField({ value: 'my_value', prefix: 'my_prefix' })); + }); + + test('suffix correctly', () => { + expect('my_value my_suffix').toEqual(appendField({ value: 'my_value', suffix: 'my_suffix' })); + }); + + test('prefix and suffix correctly', () => { + expect('my_prefixmy_value my_suffix').toEqual( + appendField({ value: 'my_value', prefix: 'my_prefix', suffix: 'my_suffix' }) + ); + }); +}); + +describe('appendInformationToField', () => { + test('creation mode', () => { + const res = appendInformationToField({ + value: 'my value', + user: 'Elastic Test User', + date: '2020-03-13T08:34:53.450Z', + mode: 'create', + }); + expect(res).toEqual('my value (created at 2020-03-13T08:34:53.450Z by Elastic Test User)'); + }); + + test('update mode', () => { + const res = appendInformationToField({ + value: 'my value', + user: 'Elastic Test User', + date: '2020-03-13T08:34:53.450Z', + mode: 'update', + }); + expect(res).toEqual('my value (updated at 2020-03-13T08:34:53.450Z by Elastic Test User)'); + }); + + test('add mode', () => { + const res = appendInformationToField({ + value: 'my value', + user: 'Elastic Test User', + date: '2020-03-13T08:34:53.450Z', + mode: 'add', + }); + expect(res).toEqual('my value (added at 2020-03-13T08:34:53.450Z by Elastic Test User)'); + }); +}); + +describe('transformComments', () => { + test('transform creation comments', () => { + const comments: Comment[] = [ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + version: 'WzU3LDFd', + comment: 'first comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + ]; + const res = transformComments(comments, fullParams, ['informationCreated']); + expect(res).toEqual([ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + version: 'WzU3LDFd', + comment: 'first comment (created at 2020-03-13T08:34:53.450Z by Elastic User)', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + ]); + }); + + test('transform update comments', () => { + const comments: Comment[] = [ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + version: 'WzU3LDFd', + comment: 'first comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + ]; + const res = transformComments(comments, fullParams, ['informationUpdated']); + expect(res).toEqual([ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + version: 'WzU3LDFd', + comment: 'first comment (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + ]); + }); + test('transform added comments', () => { + const comments: Comment[] = [ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + version: 'WzU3LDFd', + comment: 'first comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + ]; + const res = transformComments(comments, fullParams, ['informationAdded']); + expect(res).toEqual([ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + version: 'WzU3LDFd', + comment: 'first comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + ]); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.ts index 99e67c1c43f35..46d4789e0bd53 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.ts @@ -3,18 +3,34 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { flow } from 'lodash'; import { SUPPORTED_SOURCE_FIELDS } from './constants'; -import { MapsType, FinalMapping } from './types'; +import { + MapEntry, + Mapping, + AppendFieldArgs, + AppendInformationFieldArgs, + Params, + Comment, + TransformFieldsArgs, + PipedField, + PrepareFieldsForTransformArgs, + KeyAny, +} from './types'; +import { Incident } from './lib/types'; -export const normalizeMapping = (fields: string[], mapping: MapsType[]): MapsType[] => { +import * as transformers from './transformers'; +import * as i18n from './translations'; + +export const normalizeMapping = (supportedFields: string[], mapping: MapEntry[]): MapEntry[] => { // Prevent prototype pollution and remove unsupported fields return mapping.filter( - m => m.source !== '__proto__' && m.target !== '__proto__' && fields.includes(m.source) + m => m.source !== '__proto__' && m.target !== '__proto__' && supportedFields.includes(m.source) ); }; -export const buildMap = (mapping: MapsType[]): FinalMapping => { +export const buildMap = (mapping: MapEntry[]): Mapping => { return normalizeMapping(SUPPORTED_SOURCE_FIELDS, mapping).reduce((fieldsMap, field) => { const { source, target, actionType } = field; fieldsMap.set(source, { target, actionType }); @@ -23,11 +39,7 @@ export const buildMap = (mapping: MapsType[]): FinalMapping => { }, new Map()); }; -interface KeyAny { - [key: string]: unknown; -} - -export const mapParams = (params: any, mapping: FinalMapping) => { +export const mapParams = (params: any, mapping: Mapping) => { return Object.keys(params).reduce((prev: KeyAny, curr: string): KeyAny => { const field = mapping.get(curr); if (field) { @@ -36,3 +48,72 @@ export const mapParams = (params: any, mapping: FinalMapping) => { return prev; }, {}); }; + +export const appendField = ({ value, prefix = '', suffix = '' }: AppendFieldArgs): string => { + return `${prefix}${value} ${suffix}`; +}; + +const t = { ...transformers } as { [index: string]: Function }; // TODO: Find a better solution exists. + +export const prepareFieldsForTransformation = ({ + params, + mapping, + defaultPipes = ['informationCreated'], +}: PrepareFieldsForTransformArgs): PipedField[] => { + return Object.keys(params.incident) + .filter(p => mapping.get(p).actionType !== 'nothing') + .map(p => ({ + key: p, + value: params.incident[p], + actionType: mapping.get(p).actionType, + pipes: [...defaultPipes], + })) + .map(p => ({ + ...p, + pipes: p.actionType === 'append' ? [...p.pipes, 'append'] : p.pipes, + })); +}; + +export const transformFields = ({ + params, + fields, + currentIncident, +}: TransformFieldsArgs): Incident => { + return fields.reduce((prev: Incident, cur) => { + const transform = flow(...cur.pipes.map(p => t[p])); + prev[cur.key] = transform({ + value: cur.value, + date: params.createdAt, + user: params.createdBy.fullName ?? params.createdBy.username, + previousValue: currentIncident ? currentIncident[cur.key] : '', + }).value; + return prev; + }, {} as Incident); +}; + +export const appendInformationToField = ({ + value, + user, + date, + mode = 'create', +}: AppendInformationFieldArgs): string => { + return appendField({ + value, + suffix: i18n.FIELD_INFORMATION(mode, date, user), + }); +}; + +export const transformComments = ( + comments: Comment[], + params: Params, + pipes: string[] +): Comment[] => { + return comments.map(c => ({ + ...c, + comment: flow(...pipes.map(p => t[p]))({ + value: c.comment, + date: params.createdAt, + user: params.createdBy.fullName ?? '', + }).value, + })); +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts index a1df243b0ee7c..67d595cc3ec56 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts @@ -14,13 +14,12 @@ import { configUtilsMock } from '../../actions_config.mock'; import { ACTION_TYPE_ID } from './constants'; import * as i18n from './translations'; -import { handleCreateIncident, handleUpdateIncident } from './action_handlers'; +import { handleIncident } from './action_handlers'; import { incidentResponse } from './mock'; jest.mock('./action_handlers'); -const handleCreateIncidentMock = handleCreateIncident as jest.Mock; -const handleUpdateIncidentMock = handleUpdateIncident as jest.Mock; +const handleIncidentMock = handleIncident as jest.Mock; const services: Services = { callCluster: async (path: string, opts: any) => {}, @@ -63,12 +62,19 @@ const mockOptions = { incidentId: 'ceb5986e079f00100e48fbbf7c1ed06d', title: 'Incident title', description: 'Incident description', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, comments: [ { commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', version: 'WzU3LDFd', comment: 'A comment', - incidentCommentId: '315e1ece071300100e48fbbf7c1ed0d0', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, }, ], }, @@ -169,8 +175,7 @@ describe('validateParams()', () => { describe('execute()', () => { beforeEach(() => { - handleCreateIncidentMock.mockReset(); - handleUpdateIncidentMock.mockReset(); + handleIncidentMock.mockReset(); }); test('should create an incident', async () => { @@ -185,7 +190,7 @@ describe('execute()', () => { services, }; - handleCreateIncidentMock.mockImplementation(() => incidentResponse); + handleIncidentMock.mockImplementation(() => incidentResponse); const actionResponse = await actionType.executor(executorOptions); expect(actionResponse).toEqual({ actionId, status: 'ok', data: incidentResponse }); @@ -205,7 +210,7 @@ describe('execute()', () => { }; const errorMessage = 'Failed to create incident'; - handleCreateIncidentMock.mockImplementation(() => { + handleIncidentMock.mockImplementation(() => { throw new Error(errorMessage); }); @@ -226,8 +231,10 @@ describe('execute()', () => { services, }; + handleIncidentMock.mockImplementation(() => incidentResponse); + const actionResponse = await actionType.executor(executorOptions); - expect(actionResponse).toEqual({ actionId, status: 'ok' }); + expect(actionResponse).toEqual({ actionId, status: 'ok', data: incidentResponse }); }); test('should throw an error when failed to update an incident', async () => { @@ -243,7 +250,7 @@ describe('execute()', () => { }; const errorMessage = 'Failed to update incident'; - handleUpdateIncidentMock.mockImplementation(() => { + handleIncidentMock.mockImplementation(() => { throw new Error(errorMessage); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index 01e566af17d08..f844bef6441ee 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -18,12 +18,12 @@ import { ServiceNow } from './lib'; import * as i18n from './translations'; import { ACTION_TYPE_ID } from './constants'; -import { ConfigType, SecretsType, ParamsType, CommentType } from './types'; +import { ConfigType, SecretsType, Comment, ExecutorParams } from './types'; import { ConfigSchemaProps, SecretsSchemaProps, ParamsSchema } from './schema'; import { buildMap, mapParams } from './helpers'; -import { handleCreateIncident, handleUpdateIncident } from './action_handlers'; +import { handleIncident } from './action_handlers'; function validateConfig( configurationUtilities: ActionsConfigurationUtilities, @@ -77,21 +77,22 @@ async function serviceNowExecutor( const actionId = execOptions.actionId; const { apiUrl, - casesConfiguration: { mapping }, + casesConfiguration: { mapping: configurationMapping }, } = execOptions.config as ConfigType; const { username, password } = execOptions.secrets as SecretsType; - const params = execOptions.params as ParamsType; + const params = execOptions.params as ExecutorParams; const { comments, incidentId, ...restParams } = params; - const finalMap = buildMap(mapping); - const restParamsMapped = mapParams(restParams, finalMap); + const mapping = buildMap(configurationMapping); + const incident = mapParams(restParams, mapping); const serviceNow = new ServiceNow({ url: apiUrl, username, password }); const handlerInput = { + incidentId, serviceNow, - params: restParamsMapped, - comments: comments as CommentType[], - mapping: finalMap, + params: { ...params, incident }, + comments: comments as Comment[], + mapping, }; const res: Pick & @@ -100,13 +101,7 @@ async function serviceNowExecutor( actionId, }; - let data = {}; - - if (!incidentId) { - data = await handleCreateIncident(handlerInput); - } else { - data = await handleUpdateIncident({ incidentId, ...handlerInput }); - } + const data = await handleIncident(handlerInput); return { ...res, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts index c84e1928e2e5a..3f102ae19f437 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts @@ -8,3 +8,6 @@ export const API_VERSION = 'v2'; export const INCIDENT_URL = `api/now/${API_VERSION}/table/incident`; export const USER_URL = `api/now/${API_VERSION}/table/sys_user?user_name=`; export const COMMENT_URL = `api/now/${API_VERSION}/table/incident`; + +// Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html +export const VIEW_INCIDENT_URL = `nav_to.do?uri=incident.do?sys_id=`; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts index 22be625611e85..40eeb0f920f82 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts @@ -92,6 +92,7 @@ describe('ServiceNow lib', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); @@ -116,6 +117,7 @@ describe('ServiceNow lib', () => { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }); }); @@ -132,7 +134,10 @@ describe('ServiceNow lib', () => { commentId: '456', version: 'WzU3LDFd', comment: 'A comment', - incidentCommentId: undefined, + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, }; const res = await serviceNow.createComment('123', comment, 'comments'); @@ -173,13 +178,19 @@ describe('ServiceNow lib', () => { commentId: '123', version: 'WzU3LDFd', comment: 'A comment', - incidentCommentId: undefined, + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, }, { commentId: '456', version: 'WzU3LDFd', comment: 'A second comment', - incidentCommentId: undefined, + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, }, ]; const res = await serviceNow.batchCreateComments('000', comments, 'comments'); @@ -210,7 +221,9 @@ describe('ServiceNow lib', () => { try { await serviceNow.getUserID(); } catch (error) { - expect(error.message).toEqual('[ServiceNow]: Instance is not alive.'); + expect(error.message).toEqual( + '[Action][ServiceNow]: Unable to get user id. Error: [ServiceNow]: Instance is not alive.' + ); } }); @@ -226,7 +239,96 @@ describe('ServiceNow lib', () => { try { await serviceNow.getUserID(); } catch (error) { - expect(error.message).toEqual('[ServiceNow]: Instance is not alive.'); + expect(error.message).toEqual( + '[Action][ServiceNow]: Unable to get user id. Error: [ServiceNow]: Instance is not alive.' + ); + } + }); + + test('check error when getting user', async () => { + expect.assertions(1); + + axiosMock.mockImplementationOnce(() => { + throw new Error('Bad request.'); + }); + try { + await serviceNow.getUserID(); + } catch (error) { + expect(error.message).toEqual( + '[Action][ServiceNow]: Unable to get user id. Error: Bad request.' + ); + } + }); + + test('check error when getting incident', async () => { + expect.assertions(1); + + axiosMock.mockImplementationOnce(() => { + throw new Error('Bad request.'); + }); + try { + await serviceNow.getIncident('123'); + } catch (error) { + expect(error.message).toEqual( + '[Action][ServiceNow]: Unable to get incident with id 123. Error: Bad request.' + ); + } + }); + + test('check error when creating incident', async () => { + expect.assertions(1); + + axiosMock.mockImplementationOnce(() => { + throw new Error('Bad request.'); + }); + try { + await serviceNow.createIncident({ short_description: 'title' }); + } catch (error) { + expect(error.message).toEqual( + '[Action][ServiceNow]: Unable to create incident. Error: Bad request.' + ); + } + }); + + test('check error when updating incident', async () => { + expect.assertions(1); + + axiosMock.mockImplementationOnce(() => { + throw new Error('Bad request.'); + }); + try { + await serviceNow.updateIncident('123', { short_description: 'title' }); + } catch (error) { + expect(error.message).toEqual( + '[Action][ServiceNow]: Unable to update incident with id 123. Error: Bad request.' + ); + } + }); + + test('check error when creating comment', async () => { + expect.assertions(1); + + axiosMock.mockImplementationOnce(() => { + throw new Error('Bad request.'); + }); + try { + await serviceNow.createComment( + '123', + { + commentId: '456', + version: 'WzU3LDFd', + comment: 'A second comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + 'comment' + ); + } catch (error) { + expect(error.message).toEqual( + '[Action][ServiceNow]: Unable to create comment at incident with id 123. Error: Bad request.' + ); } }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts index b3d17affb14c2..1acb6c563801c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts @@ -6,9 +6,9 @@ import axios, { AxiosInstance, Method, AxiosResponse } from 'axios'; -import { INCIDENT_URL, USER_URL, COMMENT_URL } from './constants'; +import { INCIDENT_URL, USER_URL, COMMENT_URL, VIEW_INCIDENT_URL } from './constants'; import { Instance, Incident, IncidentResponse, UpdateIncident, CommentResponse } from './types'; -import { CommentType } from '../types'; +import { Comment } from '../types'; const validStatusCodes = [200, 201]; @@ -68,41 +68,83 @@ class ServiceNow { return `${date} GMT`; } + private _getErrorMessage(msg: string) { + return `[Action][ServiceNow]: ${msg}`; + } + + private _getIncidentViewURL(id: string) { + return `${this.instance.url}/${VIEW_INCIDENT_URL}${id}`; + } + async getUserID(): Promise { - const res = await this._request({ url: `${this.userUrl}${this.instance.username}` }); - return res.data.result[0].sys_id; + try { + const res = await this._request({ url: `${this.userUrl}${this.instance.username}` }); + return res.data.result[0].sys_id; + } catch (error) { + throw new Error(this._getErrorMessage(`Unable to get user id. Error: ${error.message}`)); + } } - async createIncident(incident: Incident): Promise { - const res = await this._request({ - url: `${this.incidentUrl}`, - method: 'post', - data: { ...incident }, - }); + async getIncident(incidentId: string) { + try { + const res = await this._request({ + url: `${this.incidentUrl}/${incidentId}`, + }); + + return { ...res.data.result }; + } catch (error) { + throw new Error( + this._getErrorMessage( + `Unable to get incident with id ${incidentId}. Error: ${error.message}` + ) + ); + } + } - return { - number: res.data.result.number, - incidentId: res.data.result.sys_id, - pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_created_on)).toISOString(), - }; + async createIncident(incident: Incident): Promise { + try { + const res = await this._request({ + url: `${this.incidentUrl}`, + method: 'post', + data: { ...incident }, + }); + + return { + number: res.data.result.number, + incidentId: res.data.result.sys_id, + pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_created_on)).toISOString(), + url: this._getIncidentViewURL(res.data.result.sys_id), + }; + } catch (error) { + throw new Error(this._getErrorMessage(`Unable to create incident. Error: ${error.message}`)); + } } async updateIncident(incidentId: string, incident: UpdateIncident): Promise { - const res = await this._patch({ - url: `${this.incidentUrl}/${incidentId}`, - data: { ...incident }, - }); - - return { - number: res.data.result.number, - incidentId: res.data.result.sys_id, - pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), - }; + try { + const res = await this._patch({ + url: `${this.incidentUrl}/${incidentId}`, + data: { ...incident }, + }); + + return { + number: res.data.result.number, + incidentId: res.data.result.sys_id, + pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), + url: this._getIncidentViewURL(res.data.result.sys_id), + }; + } catch (error) { + throw new Error( + this._getErrorMessage( + `Unable to update incident with id ${incidentId}. Error: ${error.message}` + ) + ); + } } async batchCreateComments( incidentId: string, - comments: CommentType[], + comments: Comment[], field: string ): Promise { const res = await Promise.all(comments.map(c => this.createComment(incidentId, c, field))); @@ -111,18 +153,26 @@ class ServiceNow { async createComment( incidentId: string, - comment: CommentType, + comment: Comment, field: string ): Promise { - const res = await this._patch({ - url: `${this.commentUrl}/${incidentId}`, - data: { [field]: comment.comment }, - }); - - return { - commentId: comment.commentId, - pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), - }; + try { + const res = await this._patch({ + url: `${this.commentUrl}/${incidentId}`, + data: { [field]: comment.comment }, + }); + + return { + commentId: comment.commentId, + pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), + }; + } catch (error) { + throw new Error( + this._getErrorMessage( + `Unable to create comment at incident with id ${incidentId}. Error: ${error.message}` + ) + ); + } } } diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts index 4a3c5c42fcb44..a65e417dbc486 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts @@ -11,15 +11,17 @@ export interface Instance { } export interface Incident { - short_description?: string; + short_description: string; description?: string; caller_id?: string; + [index: string]: string | undefined; } export interface IncidentResponse { number: string; incidentId: string; pushedDate: string; + url: string; } export interface CommentResponse { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts index 9a150bbede5f8..06c006fb37825 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts @@ -4,40 +4,44 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MapsType, FinalMapping, ParamsType } from './types'; +import { MapEntry, Mapping, ExecutorParams } from './types'; import { Incident } from './lib/types'; -const mapping: MapsType[] = [ - { source: 'title', target: 'short_description', actionType: 'nothing' }, - { source: 'description', target: 'description', actionType: 'nothing' }, - { source: 'comments', target: 'comments', actionType: 'nothing' }, +const mapping: MapEntry[] = [ + { source: 'title', target: 'short_description', actionType: 'overwrite' }, + { source: 'description', target: 'description', actionType: 'append' }, + { source: 'comments', target: 'comments', actionType: 'append' }, ]; -const finalMapping: FinalMapping = new Map(); +const finalMapping: Mapping = new Map(); finalMapping.set('title', { target: 'short_description', - actionType: 'nothing', + actionType: 'overwrite', }); finalMapping.set('description', { target: 'description', - actionType: 'nothing', + actionType: 'append', }); finalMapping.set('comments', { target: 'comments', - actionType: 'nothing', + actionType: 'append', }); finalMapping.set('short_description', { target: 'title', - actionType: 'nothing', + actionType: 'overwrite', }); -const params: ParamsType = { +const params: ExecutorParams = { caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', incidentId: 'ceb5986e079f00100e48fbbf7c1ed06d', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-03-13T08:34:53.450Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, title: 'Incident title', description: 'Incident description', comments: [ @@ -45,13 +49,19 @@ const params: ParamsType = { commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', version: 'WzU3LDFd', comment: 'A comment', - incidentCommentId: '263ede42075300100e48fbbf7c1ed047', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-03-13T08:34:53.450Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, }, { commentId: 'e3db587f-ca27-4ae9-ad2e-31f2dcc9bd0d', version: 'WlK3LDFd', comment: 'Another comment', - incidentCommentId: '315e1ece071300100e48fbbf7c1ed0d0', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-03-13T08:34:53.450Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, }, ], }; @@ -59,6 +69,8 @@ const params: ParamsType = { const incidentResponse = { incidentId: 'c816f79cc0a8016401c5a33be04be441', number: 'INC0010001', + pushedDate: '2020-03-13T08:34:53.450Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', }; const userId = '2e9a0a5e2f79001016ab51172799b670'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index 0bb4f50819665..889b57c8e92e2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -6,7 +6,7 @@ import { schema } from '@kbn/config-schema'; -export const MapsSchema = schema.object({ +export const MapEntrySchema = schema.object({ source: schema.string(), target: schema.string(), actionType: schema.oneOf([ @@ -17,7 +17,7 @@ export const MapsSchema = schema.object({ }); export const CasesConfigurationSchema = schema.object({ - mapping: schema.arrayOf(MapsSchema), + mapping: schema.arrayOf(MapEntrySchema), }); export const ConfigSchemaProps = { @@ -34,11 +34,25 @@ export const SecretsSchemaProps = { export const SecretsSchema = schema.object(SecretsSchemaProps); +export const UserSchema = schema.object({ + fullName: schema.nullable(schema.string()), + username: schema.string(), +}); + +const EntityInformationSchemaProps = { + createdAt: schema.string(), + createdBy: UserSchema, + updatedAt: schema.nullable(schema.string()), + updatedBy: schema.nullable(UserSchema), +}; + +export const EntityInformationSchema = schema.object(EntityInformationSchemaProps); + export const CommentSchema = schema.object({ commentId: schema.string(), comment: schema.string(), version: schema.maybe(schema.string()), - incidentCommentId: schema.maybe(schema.string()), + ...EntityInformationSchemaProps, }); export const ExecutorAction = schema.oneOf([ @@ -48,8 +62,9 @@ export const ExecutorAction = schema.oneOf([ export const ParamsSchema = schema.object({ caseId: schema.string(), + title: schema.string(), comments: schema.maybe(schema.arrayOf(CommentSchema)), description: schema.maybe(schema.string()), - title: schema.maybe(schema.string()), - incidentId: schema.maybe(schema.string()), + incidentId: schema.nullable(schema.string()), + ...EntityInformationSchemaProps, }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/transformers.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/transformers.ts new file mode 100644 index 0000000000000..dc0a03fab8c71 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/transformers.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TransformerArgs } from './types'; +import * as i18n from './translations'; + +export const informationCreated = ({ + value, + date, + user, + ...rest +}: TransformerArgs): TransformerArgs => ({ + value: `${value} ${i18n.FIELD_INFORMATION('create', date, user)}`, + ...rest, +}); + +export const informationUpdated = ({ + value, + date, + user, + ...rest +}: TransformerArgs): TransformerArgs => ({ + value: `${value} ${i18n.FIELD_INFORMATION('update', date, user)}`, + ...rest, +}); + +export const informationAdded = ({ + value, + date, + user, + ...rest +}: TransformerArgs): TransformerArgs => ({ + value: `${value} ${i18n.FIELD_INFORMATION('add', date, user)}`, + ...rest, +}); + +export const append = ({ value, previousValue, ...rest }: TransformerArgs): TransformerArgs => ({ + value: previousValue ? `${previousValue} \r\n${value}` : `${value}`, + ...rest, +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts index 8601c5ce772db..3b216a6c3260a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts @@ -51,3 +51,32 @@ export const UNEXPECTED_STATUS = (status: number) => status, }, }); + +export const FIELD_INFORMATION = ( + mode: string, + date: string | undefined, + user: string | undefined +) => { + switch (mode) { + case 'create': + return i18n.translate('xpack.actions.builtin.servicenow.informationCreated', { + values: { date, user }, + defaultMessage: '(created at {date} by {user})', + }); + case 'update': + return i18n.translate('xpack.actions.builtin.servicenow.informationUpdated', { + values: { date, user }, + defaultMessage: '(updated at {date} by {user})', + }); + case 'add': + return i18n.translate('xpack.actions.builtin.servicenow.informationAdded', { + values: { date, user }, + defaultMessage: '(added at {date} by {user})', + }); + default: + return i18n.translate('xpack.actions.builtin.servicenow.informationDefault', { + values: { date, user }, + defaultMessage: '(created at {date} by {user})', + }); + } +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index 7442f14fed064..71b05be8f3e4d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -11,11 +11,12 @@ import { SecretsSchema, ParamsSchema, CasesConfigurationSchema, - MapsSchema, + MapEntrySchema, CommentSchema, } from './schema'; import { ServiceNow } from './lib'; +import { Incident, IncidentResponse } from './lib/types'; // config definition export type ConfigType = TypeOf; @@ -23,34 +24,80 @@ export type ConfigType = TypeOf; // secrets definition export type SecretsType = TypeOf; -export type ParamsType = TypeOf; +export type ExecutorParams = TypeOf; export type CasesConfigurationType = TypeOf; -export type MapsType = TypeOf; -export type CommentType = TypeOf; +export type MapEntry = TypeOf; +export type Comment = TypeOf; -export type FinalMapping = Map; +export type Mapping = Map; -export interface ActionHandlerArguments { +export interface Params extends ExecutorParams { + incident: Record; +} +export interface CreateHandlerArguments { serviceNow: ServiceNow; - params: any; - comments: CommentType[]; - mapping: FinalMapping; + params: Params; + comments: Comment[]; + mapping: Mapping; } -export type UpdateParamsType = Partial; -export type UpdateActionHandlerArguments = ActionHandlerArguments & { +export type UpdateHandlerArguments = CreateHandlerArguments & { incidentId: string; }; -export interface IncidentCreationResponse { - incidentId: string; - number: string; - comments?: CommentsZipped[]; - pushedDate: string; +export type IncidentHandlerArguments = CreateHandlerArguments & { + incidentId: string | null; +}; + +export interface HandlerResponse extends IncidentResponse { + comments?: SimpleComment[]; } -export interface CommentsZipped { +export interface SimpleComment { commentId: string; pushedDate: string; } + +export interface AppendFieldArgs { + value: string; + prefix?: string; + suffix?: string; +} + +export interface KeyAny { + [index: string]: string; +} + +export interface AppendInformationFieldArgs { + value: string; + user: string; + date: string; + mode: string; +} + +export interface TransformerArgs { + value: string; + date?: string; + user?: string; + previousValue?: string; +} + +export interface PrepareFieldsForTransformArgs { + params: Params; + mapping: Mapping; + defaultPipes?: string[]; +} + +export interface PipedField { + key: string; + value: string; + actionType: string; + pipes: string[]; +} + +export interface TransformFieldsArgs { + params: Params; + fields: PipedField[]; + currentIncident?: Incident; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.ts index 042853796695d..3a351853c1e46 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.ts @@ -35,7 +35,7 @@ const SecretsSchema = schema.object(secretsSchemaProps); export type ActionParamsType = TypeOf; const ParamsSchema = schema.object({ - message: schema.string(), + message: schema.string({ minLength: 1 }), }); // action type definition diff --git a/x-pack/plugins/actions/server/routes/find.test.ts b/x-pack/plugins/actions/server/routes/find.test.ts index 862e26132fdc3..b51130b2640aa 100644 --- a/x-pack/plugins/actions/server/routes/find.test.ts +++ b/x-pack/plugins/actions/server/routes/find.test.ts @@ -81,6 +81,7 @@ describe('findActionRoute', () => { "perPage": 1, "search": undefined, "sortField": undefined, + "sortOrder": undefined, }, }, ] diff --git a/x-pack/plugins/actions/server/routes/find.ts b/x-pack/plugins/actions/server/routes/find.ts index 71d4274980fcc..820dd32d710ae 100644 --- a/x-pack/plugins/actions/server/routes/find.ts +++ b/x-pack/plugins/actions/server/routes/find.ts @@ -26,6 +26,7 @@ const querySchema = schema.object({ }), search_fields: schema.maybe(schema.oneOf([schema.arrayOf(schema.string()), schema.string()])), sort_field: schema.maybe(schema.string()), + sort_order: schema.maybe(schema.oneOf([schema.literal('asc'), schema.literal('desc')])), has_reference: schema.maybe( // use nullable as maybe is currently broken // in config-schema @@ -70,6 +71,7 @@ export const findActionRoute = (router: IRouter, licenseState: LicenseState) => sortField: query.sort_field, fields: query.fields, filter: query.filter, + sortOrder: query.sort_order, }; if (query.search_fields) { diff --git a/x-pack/plugins/alerting/README.md b/x-pack/plugins/alerting/README.md index fa2e5c8e2faa1..177e42de5a95b 100644 --- a/x-pack/plugins/alerting/README.md +++ b/x-pack/plugins/alerting/README.md @@ -18,6 +18,7 @@ Table of Contents - [Methods](#methods) - [Executor](#executor) - [Example](#example) + - [Alert Navigation](#alert-navigation) - [RESTful API](#restful-api) - [`POST /api/alert`: Create alert](#post-apialert-create-alert) - [`DELETE /api/alert/{id}`: Delete alert](#delete-apialertid-delete-alert) @@ -268,6 +269,61 @@ server.newPlatform.setup.plugins.alerting.registerType({ }); ``` +## Alert Navigation +When registering an Alert Type, you'll likely want to provide a way of viewing alerts of that type within your own plugin, or perhaps you want to provide a view for all alerts created from within your solution within your own UI. + +In order for the Alerting framework to know that your plugin has its own internal view for displaying an alert, you must resigter a navigation handler within the framework. + +A navigation handler is nothing more than a function that receives an Alert and its corresponding AlertType, and is expected to then return the path *within your plugin* which knows how to display this alert. + +The signature of such a handler is: + +``` +type AlertNavigationHandler = ( + alert: SanitizedAlert, + alertType: AlertType +) => string; +``` + +There are two ways to register this handler. +By specifying _alerting_ as a dependency of your *public* (client side) plugin, you'll gain access to two apis: _alerting.registerNavigation_ and _alerting.registerDefaultNavigation_. + +### registerNavigation +The _registerNavigation_ api allows you to register a handler for a specific alert type within your solution: + +``` +alerting.registerNavigation( + 'my-application-id', + 'my-application-id.my-alert-type', + (alert: SanitizedAlert, alertType: AlertType) => `/my-unique-alert/${alert.id}` +); +``` + +This tells the Alerting framework that, given an alert of the AlertType whose ID is `my-application-id.my-unique-alert-type`, if that Alert's `consumer` value (which is set when the alert is created by your plugin) is your application (whose id is `my-application-id`), then it will navigate to your application using the path `/my-unique-alert/${the id of the alert}`. + +The navigation is handled using the `navigateToApp` api, meaning that the path will be automatically picked up by your `react-router-dom` **Route** component, so all you have top do is configure a Route that handles the path `/my-unique-alert/:id`. + +You can look at the `alerting-example` plugin to see an example of using this API, which is enabled using the `--run-examples` flag when you run `yarn start`. + +### registerDefaultNavigation +The _registerDefaultNavigation_ api allows you to register a handler for any alert type within your solution: + +``` +alerting.registerDefaultNavigation( + 'my-application-id', + (alert: SanitizedAlert, alertType: AlertType) => `/my-other-alerts/${alert.id}` +); +``` + +This tells the Alerting framework that, given any alert whose `consumer` value is your application, as long as then it will navigate to your application using the path `/my-other-alerts/${the id of the alert}`. + +### balancing both APIs side by side +As we mentioned, using `registerDefaultNavigation` will tell the Alerting Framework that your application can handle any type of Alert we throw at it, as long as your application created it, using the handler you provide it. + +The only case in which this handler will not be used to evaluate the navigation for an alert (assuming your application is the `consumer`) is if you have also used `registerNavigation` api, along side your `registerDefaultNavigation` usage, to handle that alert's specific AlertType. + +You can use the `registerNavigation` api to specify as many AlertType specific handlers as you like, but you can only use it once per AlertType as we wouldn't know which handler to use if you specified two for the same AlertType. For the same reason, you can only use `registerDefaultNavigation` once per plugin, as it covers all cases for your specific plugin. + ## RESTful API Using an alert type requires you to create an alert that will contain parameters and actions for a given alert type. See below for CRUD operations using the API. @@ -480,4 +536,3 @@ The templating system will take the alert and alert type as described above and ``` There are limitations that we are aware of using only templates, and we are gathering feedback and use cases for these. (for example passing an array of strings to an action). - diff --git a/x-pack/plugins/alerting/common/alert_navigation.ts b/x-pack/plugins/alerting/common/alert_navigation.ts new file mode 100644 index 0000000000000..188764069e84f --- /dev/null +++ b/x-pack/plugins/alerting/common/alert_navigation.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { JsonObject } from '../../infra/common/typed_json'; +export interface AlertUrlNavigation { + path: string; +} +export interface AlertStateNavigation { + state: JsonObject; +} +export type AlertNavigation = AlertUrlNavigation | AlertStateNavigation; diff --git a/x-pack/plugins/alerting/common/alert_type.ts b/x-pack/plugins/alerting/common/alert_type.ts new file mode 100644 index 0000000000000..b30cf3fa18518 --- /dev/null +++ b/x-pack/plugins/alerting/common/alert_type.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface AlertType { + id: string; + name: string; + actionGroups: ActionGroup[]; + actionVariables: string[]; + defaultActionGroupId: ActionGroup['id']; +} + +export interface ActionGroup { + id: string; + name: string; +} diff --git a/x-pack/plugins/alerting/common/index.ts b/x-pack/plugins/alerting/common/index.ts index 8c6969cded85a..b705a334bc2b5 100644 --- a/x-pack/plugins/alerting/common/index.ts +++ b/x-pack/plugins/alerting/common/index.ts @@ -5,10 +5,9 @@ */ export * from './alert'; +export * from './alert_type'; export * from './alert_instance'; export * from './alert_task_instance'; +export * from './alert_navigation'; -export interface ActionGroup { - id: string; - name: string; -} +export const BASE_ALERT_API_PATH = '/api/alert'; diff --git a/x-pack/plugins/alerting/kibana.json b/x-pack/plugins/alerting/kibana.json index 12f48d98dbf58..02514511e7560 100644 --- a/x-pack/plugins/alerting/kibana.json +++ b/x-pack/plugins/alerting/kibana.json @@ -1,10 +1,10 @@ { "id": "alerting", "server": true, + "ui": true, "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "alerting"], "requiredPlugins": ["licensing", "taskManager", "encryptedSavedObjects", "actions"], - "optionalPlugins": ["usageCollection", "spaces", "security"], - "ui": false -} \ No newline at end of file + "optionalPlugins": ["usageCollection", "spaces", "security"] +} diff --git a/x-pack/plugins/alerting/public/alert_api.test.ts b/x-pack/plugins/alerting/public/alert_api.test.ts new file mode 100644 index 0000000000000..a1a90f7c893a7 --- /dev/null +++ b/x-pack/plugins/alerting/public/alert_api.test.ts @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertType } from '../common'; +import { httpServiceMock } from '../../../../src/core/public/mocks'; +import { loadAlert, loadAlertState, loadAlertType, loadAlertTypes } from './alert_api'; +import uuid from 'uuid'; + +const http = httpServiceMock.createStartContract(); + +beforeEach(() => jest.resetAllMocks()); + +describe('loadAlertTypes', () => { + test('should call get alert types API', async () => { + const resolvedValue: AlertType[] = [ + { + id: 'test', + name: 'Test', + actionVariables: ['var1'], + actionGroups: [{ id: 'default', name: 'Default' }], + defaultActionGroupId: 'default', + }, + ]; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlertTypes({ http }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/types", + ] + `); + }); +}); + +describe('loadAlertType', () => { + test('should call get alert types API', async () => { + const alertType: AlertType = { + id: 'test', + name: 'Test', + actionVariables: ['var1'], + actionGroups: [{ id: 'default', name: 'Default' }], + defaultActionGroupId: 'default', + }; + http.get.mockResolvedValueOnce([alertType]); + + await loadAlertType({ http, id: alertType.id }); + + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/types", + ] + `); + }); + + test('should find the required alertType', async () => { + const alertType: AlertType = { + id: 'test-another', + name: 'Test Another', + actionVariables: [], + actionGroups: [{ id: 'default', name: 'Default' }], + defaultActionGroupId: 'default', + }; + http.get.mockResolvedValueOnce([alertType]); + + expect(await loadAlertType({ http, id: 'test-another' })).toEqual(alertType); + }); + + test('should throw if required alertType is missing', async () => { + http.get.mockResolvedValueOnce([ + { + id: 'test-another', + name: 'Test Another', + actionVariables: [], + actionGroups: [{ id: 'default', name: 'Default' }], + defaultActionGroupId: 'default', + }, + ]); + + expect(loadAlertType({ http, id: 'test' })).rejects.toMatchInlineSnapshot( + `[Error: Alert type "test" is not registered.]` + ); + }); +}); + +describe('loadAlert', () => { + test('should call get API with base parameters', async () => { + const alertId = uuid.v4(); + const resolvedValue = { + id: alertId, + name: 'name', + tags: [], + enabled: true, + alertTypeId: '.noop', + schedule: { interval: '1s' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + throttle: null, + muteAll: false, + mutedInstanceIds: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + expect(await loadAlert({ http, alertId })).toEqual(resolvedValue); + expect(http.get).toHaveBeenCalledWith(`/api/alert/${alertId}`); + }); +}); + +describe('loadAlertState', () => { + test('should call get API with base parameters', async () => { + const alertId = uuid.v4(); + const resolvedValue = { + alertTypeState: { + some: 'value', + }, + alertInstances: { + first_instance: {}, + second_instance: {}, + }, + }; + http.get.mockResolvedValueOnce(resolvedValue); + + expect(await loadAlertState({ http, alertId })).toEqual(resolvedValue); + expect(http.get).toHaveBeenCalledWith(`/api/alert/${alertId}/state`); + }); + + test('should parse AlertInstances', async () => { + const alertId = uuid.v4(); + const resolvedValue = { + alertTypeState: { + some: 'value', + }, + alertInstances: { + first_instance: { + state: {}, + meta: { + lastScheduledActions: { + group: 'first_group', + date: '2020-02-09T23:15:41.941Z', + }, + }, + }, + }, + }; + http.get.mockResolvedValueOnce(resolvedValue); + + expect(await loadAlertState({ http, alertId })).toEqual({ + ...resolvedValue, + alertInstances: { + first_instance: { + state: {}, + meta: { + lastScheduledActions: { + group: 'first_group', + date: new Date('2020-02-09T23:15:41.941Z'), + }, + }, + }, + }, + }); + expect(http.get).toHaveBeenCalledWith(`/api/alert/${alertId}/state`); + }); + + test('should handle empty response from api', async () => { + const alertId = uuid.v4(); + http.get.mockResolvedValueOnce(''); + + expect(await loadAlertState({ http, alertId })).toEqual({}); + expect(http.get).toHaveBeenCalledWith(`/api/alert/${alertId}/state`); + }); +}); diff --git a/x-pack/plugins/alerting/public/alert_api.ts b/x-pack/plugins/alerting/public/alert_api.ts new file mode 100644 index 0000000000000..1df39e9f38b1d --- /dev/null +++ b/x-pack/plugins/alerting/public/alert_api.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpSetup } from 'kibana/public'; +import * as t from 'io-ts'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { findFirst } from 'fp-ts/lib/Array'; +import { isNone } from 'fp-ts/lib/Option'; + +import { i18n } from '@kbn/i18n'; +import { BASE_ALERT_API_PATH, alertStateSchema } from '../common'; +import { Alert, AlertType, AlertTaskState } from '../common'; + +export async function loadAlertTypes({ http }: { http: HttpSetup }): Promise { + return await http.get(`${BASE_ALERT_API_PATH}/types`); +} + +export async function loadAlertType({ + http, + id, +}: { + http: HttpSetup; + id: AlertType['id']; +}): Promise { + const maybeAlertType = findFirst(type => type.id === id)( + await http.get(`${BASE_ALERT_API_PATH}/types`) + ); + if (isNone(maybeAlertType)) { + throw new Error( + i18n.translate('xpack.alerting.loadAlertType.missingAlertTypeError', { + defaultMessage: 'Alert type "{id}" is not registered.', + values: { + id, + }, + }) + ); + } + return maybeAlertType.value; +} + +export async function loadAlert({ + http, + alertId, +}: { + http: HttpSetup; + alertId: string; +}): Promise { + return await http.get(`${BASE_ALERT_API_PATH}/${alertId}`); +} + +type EmptyHttpResponse = ''; +export async function loadAlertState({ + http, + alertId, +}: { + http: HttpSetup; + alertId: string; +}): Promise { + return await http + .get(`${BASE_ALERT_API_PATH}/${alertId}/state`) + .then((state: AlertTaskState | EmptyHttpResponse) => (state ? state : {})) + .then((state: AlertTaskState) => { + return pipe( + alertStateSchema.decode(state), + fold((e: t.Errors) => { + throw new Error(`Alert "${alertId}" has invalid state`); + }, t.identity) + ); + }); +} diff --git a/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.mock.ts b/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.mock.ts new file mode 100644 index 0000000000000..792bd8e885ea6 --- /dev/null +++ b/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.mock.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertNavigationRegistry } from './alert_navigation_registry'; + +type Schema = PublicMethodsOf; + +const createAlertNavigationRegistryMock = () => { + const mocked: jest.Mocked = { + has: jest.fn(), + hasDefaultHandler: jest.fn(), + hasTypedHandler: jest.fn(), + register: jest.fn(), + registerDefault: jest.fn(), + get: jest.fn(), + }; + return mocked; +}; + +export const alertNavigationRegistryMock = { + create: createAlertNavigationRegistryMock, +}; diff --git a/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.test.ts b/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.test.ts new file mode 100644 index 0000000000000..439ee9e818ef4 --- /dev/null +++ b/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.test.ts @@ -0,0 +1,184 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertNavigationRegistry } from './alert_navigation_registry'; +import { AlertType, SanitizedAlert } from '../../common'; +import uuid from 'uuid'; + +beforeEach(() => jest.resetAllMocks()); + +const mockAlertType = (id: string): AlertType => ({ + id, + name: id, + actionGroups: [], + actionVariables: [], + defaultActionGroupId: 'default', +}); + +describe('AlertNavigationRegistry', () => { + function handler(alert: SanitizedAlert, alertType: AlertType) { + return {}; + } + + describe('has()', () => { + test('returns false for unregistered consumer handlers', () => { + const registry = new AlertNavigationRegistry(); + expect(registry.has('siem', mockAlertType(uuid.v4()))).toEqual(false); + }); + + test('returns false for unregistered alert types handlers', () => { + const registry = new AlertNavigationRegistry(); + expect(registry.has('siem', mockAlertType('index_threshold'))).toEqual(false); + }); + + test('returns true for registered consumer & alert types handlers', () => { + const registry = new AlertNavigationRegistry(); + const alertType = mockAlertType('index_threshold'); + registry.register('siem', alertType, handler); + expect(registry.has('siem', alertType)).toEqual(true); + }); + + test('returns true for registered consumer with default handler', () => { + const registry = new AlertNavigationRegistry(); + const alertType = mockAlertType('index_threshold'); + registry.registerDefault('siem', handler); + expect(registry.has('siem', alertType)).toEqual(true); + }); + }); + + describe('hasDefaultHandler()', () => { + test('returns false for unregistered consumer handlers', () => { + const registry = new AlertNavigationRegistry(); + expect(registry.hasDefaultHandler('siem')).toEqual(false); + }); + + test('returns true for registered consumer handlers', () => { + const registry = new AlertNavigationRegistry(); + + registry.registerDefault('siem', handler); + expect(registry.hasDefaultHandler('siem')).toEqual(true); + }); + }); + + describe('register()', () => { + test('registers a handler by consumer & Alert Type', () => { + const registry = new AlertNavigationRegistry(); + const alertType = mockAlertType('index_threshold'); + registry.register('siem', alertType, handler); + expect(registry.has('siem', alertType)).toEqual(true); + }); + + test('allows registeration of multiple handlers for the same consumer', () => { + const registry = new AlertNavigationRegistry(); + + const indexThresholdAlertType = mockAlertType('index_threshold'); + registry.register('siem', indexThresholdAlertType, handler); + expect(registry.has('siem', indexThresholdAlertType)).toEqual(true); + + const geoAlertType = mockAlertType('geogrid'); + registry.register('siem', geoAlertType, handler); + expect(registry.has('siem', geoAlertType)).toEqual(true); + }); + + test('allows registeration of multiple handlers for the same Alert Type', () => { + const registry = new AlertNavigationRegistry(); + + const indexThresholdAlertType = mockAlertType('geogrid'); + registry.register('siem', indexThresholdAlertType, handler); + expect(registry.has('siem', indexThresholdAlertType)).toEqual(true); + + registry.register('apm', indexThresholdAlertType, handler); + expect(registry.has('apm', indexThresholdAlertType)).toEqual(true); + }); + + test('throws if an existing handler is registered', () => { + const registry = new AlertNavigationRegistry(); + const alertType = mockAlertType('index_threshold'); + registry.register('siem', alertType, handler); + expect(() => { + registry.register('siem', alertType, handler); + }).toThrowErrorMatchingInlineSnapshot( + `"Navigation for Alert type \\"index_threshold\\" within \\"siem\\" is already registered."` + ); + }); + }); + + describe('registerDefault()', () => { + test('registers a handler by consumer', () => { + const registry = new AlertNavigationRegistry(); + registry.registerDefault('siem', handler); + expect(registry.hasDefaultHandler('siem')).toEqual(true); + }); + + test('allows registeration of default and typed handlers for the same consumer', () => { + const registry = new AlertNavigationRegistry(); + + registry.registerDefault('siem', handler); + expect(registry.hasDefaultHandler('siem')).toEqual(true); + + const geoAlertType = mockAlertType('geogrid'); + registry.register('siem', geoAlertType, handler); + expect(registry.has('siem', geoAlertType)).toEqual(true); + }); + + test('throws if an existing handler is registered', () => { + const registry = new AlertNavigationRegistry(); + registry.registerDefault('siem', handler); + expect(() => { + registry.registerDefault('siem', handler); + }).toThrowErrorMatchingInlineSnapshot( + `"Default Navigation within \\"siem\\" is already registered."` + ); + }); + }); + + describe('get()', () => { + test('returns registered handlers by consumer & Alert Type', () => { + const registry = new AlertNavigationRegistry(); + + function indexThresholdHandler(alert: SanitizedAlert, alertType: AlertType) { + return {}; + } + + const indexThresholdAlertType = mockAlertType('indexThreshold'); + registry.register('siem', indexThresholdAlertType, indexThresholdHandler); + expect(registry.get('siem', indexThresholdAlertType)).toEqual(indexThresholdHandler); + }); + + test('returns default handlers by consumer when there is no handler for requested alert type', () => { + const registry = new AlertNavigationRegistry(); + + function defaultHandler(alert: SanitizedAlert, alertType: AlertType) { + return {}; + } + + registry.registerDefault('siem', defaultHandler); + expect(registry.get('siem', mockAlertType('geogrid'))).toEqual(defaultHandler); + }); + + test('returns default handlers by consumer when there are other alert type handler', () => { + const registry = new AlertNavigationRegistry(); + + registry.register('siem', mockAlertType('indexThreshold'), () => ({})); + + function defaultHandler(alert: SanitizedAlert, alertType: AlertType) { + return {}; + } + + registry.registerDefault('siem', defaultHandler); + expect(registry.get('siem', mockAlertType('geogrid'))).toEqual(defaultHandler); + }); + + test('throws if a handler isnt registered', () => { + const registry = new AlertNavigationRegistry(); + const alertType = mockAlertType('index_threshold'); + + expect(() => registry.get('siem', alertType)).toThrowErrorMatchingInlineSnapshot( + `"Navigation for Alert type \\"index_threshold\\" within \\"siem\\" is not registered."` + ); + }); + }); +}); diff --git a/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.ts b/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.ts new file mode 100644 index 0000000000000..7f1919fbea684 --- /dev/null +++ b/x-pack/plugins/alerting/public/alert_navigation_registry/alert_navigation_registry.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { i18n } from '@kbn/i18n'; +import { AlertType } from '../../common'; +import { AlertNavigationHandler } from './types'; + +const DEFAULT_HANDLER = Symbol('*'); +export class AlertNavigationRegistry { + private readonly alertNavigations: Map< + string, + Map + > = new Map(); + + public has(consumer: string, alertType: AlertType) { + return this.hasTypedHandler(consumer, alertType) || this.hasDefaultHandler(consumer); + } + + public hasTypedHandler(consumer: string, alertType: AlertType) { + return this.alertNavigations.get(consumer)?.has(alertType.id) ?? false; + } + + public hasDefaultHandler(consumer: string) { + return this.alertNavigations.get(consumer)?.has(DEFAULT_HANDLER) ?? false; + } + + private createConsumerNavigation(consumer: string) { + const consumerNavigations = new Map(); + this.alertNavigations.set(consumer, consumerNavigations); + return consumerNavigations; + } + + public registerDefault(consumer: string, handler: AlertNavigationHandler) { + if (this.hasDefaultHandler(consumer)) { + throw Boom.badRequest( + i18n.translate('xpack.alerting.alertNavigationRegistry.register.duplicateDefaultError', { + defaultMessage: 'Default Navigation within "{consumer}" is already registered.', + values: { + consumer, + }, + }) + ); + } + + const consumerNavigations = + this.alertNavigations.get(consumer) ?? this.createConsumerNavigation(consumer); + + consumerNavigations.set(DEFAULT_HANDLER, handler); + } + + public register(consumer: string, alertType: AlertType, handler: AlertNavigationHandler) { + if (this.hasTypedHandler(consumer, alertType)) { + throw Boom.badRequest( + i18n.translate('xpack.alerting.alertNavigationRegistry.register.duplicateNavigationError', { + defaultMessage: + 'Navigation for Alert type "{alertType}" within "{consumer}" is already registered.', + values: { + alertType: alertType.id, + consumer, + }, + }) + ); + } + + const consumerNavigations = + this.alertNavigations.get(consumer) ?? this.createConsumerNavigation(consumer); + + consumerNavigations.set(alertType.id, handler); + } + + public get(consumer: string, alertType: AlertType): AlertNavigationHandler { + if (this.has(consumer, alertType)) { + const consumerHandlers = this.alertNavigations.get(consumer)!; + return (consumerHandlers.get(alertType.id) ?? consumerHandlers.get(DEFAULT_HANDLER))!; + } + + throw Boom.badRequest( + i18n.translate('xpack.alerting.alertNavigationRegistry.get.missingNavigationError', { + defaultMessage: + 'Navigation for Alert type "{alertType}" within "{consumer}" is not registered.', + values: { + alertType: alertType.id, + consumer, + }, + }) + ); + } +} diff --git a/x-pack/legacy/plugins/license_management/public/legacy.ts b/x-pack/plugins/alerting/public/alert_navigation_registry/index.ts similarity index 77% rename from x-pack/legacy/plugins/license_management/public/legacy.ts rename to x-pack/plugins/alerting/public/alert_navigation_registry/index.ts index 0e7c3ae60c775..1d8b3ffce6bcf 100644 --- a/x-pack/legacy/plugins/license_management/public/legacy.ts +++ b/x-pack/plugins/alerting/public/alert_navigation_registry/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -import './management_section'; -import './register_route'; +export * from './types'; +export * from './alert_navigation_registry'; diff --git a/x-pack/plugins/alerting/public/alert_navigation_registry/types.ts b/x-pack/plugins/alerting/public/alert_navigation_registry/types.ts new file mode 100644 index 0000000000000..0038652f47f12 --- /dev/null +++ b/x-pack/plugins/alerting/public/alert_navigation_registry/types.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { JsonObject } from '../../../infra/common/typed_json'; +import { AlertType, SanitizedAlert } from '../../common'; + +export type AlertNavigationHandler = ( + alert: SanitizedAlert, + alertType: AlertType +) => JsonObject | string; diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/lib/docs_links.ts b/x-pack/plugins/alerting/public/index.ts similarity index 57% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/lib/docs_links.ts rename to x-pack/plugins/alerting/public/index.ts index 761fcd2674df6..2c3ec2fcc33c8 100644 --- a/x-pack/legacy/plugins/license_management/public/np_ready/application/lib/docs_links.ts +++ b/x-pack/plugins/alerting/public/index.ts @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -let docLinks: Record = {}; +import { AlertingPublicPlugin } from './plugin'; +export { PluginSetupContract, PluginStartContract } from './plugin'; -export const setDocLinks = (links: Record) => { - docLinks = links; -}; - -export const getDocLinks = () => docLinks; +export function plugin() { + return new AlertingPublicPlugin(); +} diff --git a/x-pack/plugins/alerting/public/mocks.ts b/x-pack/plugins/alerting/public/mocks.ts new file mode 100644 index 0000000000000..5b99b86c1b7c5 --- /dev/null +++ b/x-pack/plugins/alerting/public/mocks.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertingPublicPlugin } from './plugin'; + +export type Setup = jest.Mocked>; +export type Start = jest.Mocked>; + +const createSetupContract = (): Setup => ({ + registerNavigation: jest.fn(), + registerDefaultNavigation: jest.fn(), +}); + +const createStartContract = (): Start => ({ + getNavigation: jest.fn(), +}); + +export const alertingPluginMock = { + createSetupContract, + createStartContract, +}; diff --git a/x-pack/plugins/alerting/public/plugin.ts b/x-pack/plugins/alerting/public/plugin.ts new file mode 100644 index 0000000000000..43f84b190f410 --- /dev/null +++ b/x-pack/plugins/alerting/public/plugin.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup, Plugin, CoreStart } from 'src/core/public'; + +import { AlertNavigationRegistry, AlertNavigationHandler } from './alert_navigation_registry'; +import { loadAlert, loadAlertType } from './alert_api'; +import { Alert, AlertNavigation } from '../common'; + +export interface PluginSetupContract { + registerNavigation: ( + consumer: string, + alertType: string, + handler: AlertNavigationHandler + ) => void; + registerDefaultNavigation: (consumer: string, handler: AlertNavigationHandler) => void; +} +export interface PluginStartContract { + getNavigation: (alertId: Alert['id']) => Promise; +} + +export class AlertingPublicPlugin implements Plugin { + private alertNavigationRegistry?: AlertNavigationRegistry; + public setup(core: CoreSetup) { + this.alertNavigationRegistry = new AlertNavigationRegistry(); + + const registerNavigation = async ( + consumer: string, + alertType: string, + handler: AlertNavigationHandler + ) => + this.alertNavigationRegistry!.register( + consumer, + await loadAlertType({ http: core.http, id: alertType }), + handler + ); + + const registerDefaultNavigation = async (consumer: string, handler: AlertNavigationHandler) => + this.alertNavigationRegistry!.registerDefault(consumer, handler); + + return { + registerNavigation, + registerDefaultNavigation, + }; + } + + public start(core: CoreStart) { + return { + getNavigation: async (alertId: Alert['id']) => { + const alert = await loadAlert({ http: core.http, alertId }); + const alertType = await loadAlertType({ http: core.http, id: alert.alertTypeId }); + + if (this.alertNavigationRegistry!.has(alert.consumer, alertType)) { + const navigationHandler = this.alertNavigationRegistry!.get(alert.consumer, alertType); + const state = navigationHandler(alert, alertType); + return typeof state === 'string' ? { path: state } : { state }; + } + }, + }; + } +} diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index b4b2de19ef24f..8d54432f7d9c3 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -137,6 +137,7 @@ export class AlertingPlugin { taskRunnerFactory: this.taskRunnerFactory, }); this.alertTypeRegistry = alertTypeRegistry; + this.serverBasePath = core.http.basePath.serverBasePath; const usageCollection = plugins.usageCollection; diff --git a/x-pack/plugins/alerting/server/routes/find.test.ts b/x-pack/plugins/alerting/server/routes/find.test.ts index ba0114c99a9bd..391d6df3f9931 100644 --- a/x-pack/plugins/alerting/server/routes/find.test.ts +++ b/x-pack/plugins/alerting/server/routes/find.test.ts @@ -82,6 +82,7 @@ describe('findAlertRoute', () => { "perPage": 1, "search": undefined, "sortField": undefined, + "sortOrder": undefined, }, }, ] diff --git a/x-pack/plugins/alerting/server/routes/find.ts b/x-pack/plugins/alerting/server/routes/find.ts index efc5c3ea97183..1f8f161cf3028 100644 --- a/x-pack/plugins/alerting/server/routes/find.ts +++ b/x-pack/plugins/alerting/server/routes/find.ts @@ -26,6 +26,7 @@ const querySchema = schema.object({ }), search_fields: schema.maybe(schema.oneOf([schema.arrayOf(schema.string()), schema.string()])), sort_field: schema.maybe(schema.string()), + sort_order: schema.maybe(schema.oneOf([schema.literal('asc'), schema.literal('desc')])), has_reference: schema.maybe( // use nullable as maybe is currently broken // in config-schema @@ -70,6 +71,7 @@ export const findAlertRoute = (router: IRouter, licenseState: LicenseState) => { sortField: query.sort_field, fields: query.fields, filter: query.filter, + sortOrder: query.sort_order, }; if (query.search_fields) { diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts index b79321a8803fa..6d27f8a99dd4b 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts @@ -113,6 +113,7 @@ export function getAlertType(service: Service): AlertType { timeWindowUnit: params.timeWindowUnit, interval: undefined, }; + // console.log(`index_threshold: query: ${JSON.stringify(queryParams, null, 4)}`); const result = await service.indexThreshold.timeSeriesQuery({ logger, callCluster, @@ -121,6 +122,7 @@ export function getAlertType(service: Service): AlertType { logger.debug(`alert ${ID}:${alertId} "${name}" query result: ${JSON.stringify(result)}`); const groupResults = result.results || []; + // console.log(`index_threshold: response: ${JSON.stringify(groupResults, null, 4)}`); for (const groupResult of groupResults) { const instanceId = groupResult.group; const value = groupResult.metrics[0][1]; diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 94cc5cecb54b1..8afdb9e99c1a3 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -17,6 +17,13 @@ export const config = { schema: schema.object({ enabled: schema.boolean({ defaultValue: true }), serviceMapEnabled: schema.boolean({ defaultValue: true }), + serviceMapFingerprintBucketSize: schema.number({ defaultValue: 100 }), + serviceMapTraceIdBucketSize: schema.number({ defaultValue: 65 }), + serviceMapFingerprintGlobalBucketSize: schema.number({ + defaultValue: 1000 + }), + serviceMapTraceIdGlobalBucketSize: schema.number({ defaultValue: 6 }), + serviceMapMaxTracesPerRequest: schema.number({ defaultValue: 50 }), autocreateApmIndexPattern: schema.boolean({ defaultValue: true }), ui: schema.object({ enabled: schema.boolean({ defaultValue: true }), @@ -41,6 +48,16 @@ export function mergeConfigs( 'apm_oss.onboardingIndices': apmOssConfig.onboardingIndices, 'apm_oss.indexPattern': apmOssConfig.indexPattern, 'xpack.apm.serviceMapEnabled': apmConfig.serviceMapEnabled, + 'xpack.apm.serviceMapFingerprintBucketSize': + apmConfig.serviceMapFingerprintBucketSize, + 'xpack.apm.serviceMapTraceIdBucketSize': + apmConfig.serviceMapTraceIdBucketSize, + 'xpack.apm.serviceMapFingerprintGlobalBucketSize': + apmConfig.serviceMapFingerprintGlobalBucketSize, + 'xpack.apm.serviceMapTraceIdGlobalBucketSize': + apmConfig.serviceMapTraceIdGlobalBucketSize, + 'xpack.apm.serviceMapMaxTracesPerRequest': + apmConfig.serviceMapMaxTracesPerRequest, 'xpack.apm.ui.enabled': apmConfig.ui.enabled, 'xpack.apm.ui.maxTraceItems': apmConfig.ui.maxTraceItems, 'xpack.apm.ui.transactionGroupBucketSize': diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index eeaaddafa8e04..c59ca95b6a5b2 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -45,6 +45,7 @@ export interface SetupTimeRange { start: number; end: number; } + export interface SetupUIFilters { uiFiltersES: ESFilter[]; } diff --git a/x-pack/plugins/apm/server/lib/service_map/dedupe_connections.ts b/x-pack/plugins/apm/server/lib/service_map/dedupe_connections.ts new file mode 100644 index 0000000000000..21f48bd589999 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/service_map/dedupe_connections.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { isEqual, sortBy } from 'lodash'; +import { ValuesType } from 'utility-types'; +import { ConnectionNode, Connection } from '../../../common/service_map'; +import { ConnectionsResponse, ServicesResponse } from './get_service_map'; + +function getConnectionNodeId(node: ConnectionNode): string { + if ('destination.address' in node) { + // use a prefix to distinguish exernal destination ids from services + return `>${node['destination.address']}`; + } + return node['service.name']; +} + +function getConnectionId(connection: Connection) { + return `${getConnectionNodeId(connection.source)}~${getConnectionNodeId( + connection.destination + )}`; +} + +type ServiceMapResponse = ConnectionsResponse & { services: ServicesResponse }; + +export function dedupeConnections(response: ServiceMapResponse) { + const { discoveredServices, services, connections } = response; + + const serviceNodes = services.map(service => ({ + ...service, + id: service['service.name'] + })); + + // maps destination.address to service.name if possible + function getConnectionNode(node: ConnectionNode) { + let mappedNode: ConnectionNode | undefined; + + if ('destination.address' in node) { + mappedNode = discoveredServices.find(map => isEqual(map.from, node))?.to; + } + + if (!mappedNode) { + mappedNode = node; + } + + return { + ...mappedNode, + id: getConnectionNodeId(mappedNode) + }; + } + + // build connections with mapped nodes + const mappedConnections = connections + .map(connection => { + const source = getConnectionNode(connection.source); + const destination = getConnectionNode(connection.destination); + + return { + source, + destination, + id: getConnectionId({ source, destination }) + }; + }) + .filter(connection => connection.source.id !== connection.destination.id); + + const nodes = mappedConnections + .flatMap(connection => [connection.source, connection.destination]) + .concat(serviceNodes); + + const dedupedNodes: typeof nodes = []; + + nodes.forEach(node => { + if (!dedupedNodes.find(dedupedNode => isEqual(node, dedupedNode))) { + dedupedNodes.push(node); + } + }); + + type ConnectionWithId = ValuesType; + + const connectionsById = mappedConnections.reduce( + (connectionMap, connection) => { + return { + ...connectionMap, + [connection.id]: connection + }; + }, + {} as Record + ); + + // instead of adding connections in two directions, + // we add a `bidirectional` flag to use in styling + const dedupedConnections = (sortBy( + Object.values(connectionsById), + // make sure that order is stable + 'id' + ) as ConnectionWithId[]).reduce< + Array< + ConnectionWithId & { bidirectional?: boolean; isInverseEdge?: boolean } + > + >((prev, connection) => { + const reversedConnection = prev.find( + c => + c.destination.id === connection.source.id && + c.source.id === connection.destination.id + ); + + if (reversedConnection) { + reversedConnection.bidirectional = true; + return prev.concat({ + ...connection, + isInverseEdge: true + }); + } + + return prev.concat(connection); + }, []); + + return { + nodes: dedupedNodes, + connections: dedupedConnections + }; +} diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts index 85d71784b55c7..96acfb7986c68 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import { chunk } from 'lodash'; import { PromiseReturnType } from '../../../typings/common'; import { Setup, @@ -19,48 +19,61 @@ import { SERVICE_NAME, SERVICE_FRAMEWORK_NAME } from '../../../common/elasticsearch_fieldnames'; +import { dedupeConnections } from './dedupe_connections'; export interface IEnvOptions { setup: Setup & SetupTimeRange & SetupUIFilters; serviceName?: string; environment?: string; - after?: string; } async function getConnectionData({ setup, serviceName, - environment, - after + environment }: IEnvOptions) { - const { traceIds, after: nextAfter } = await getTraceSampleIds({ + const { traceIds } = await getTraceSampleIds({ setup, serviceName, - environment, - after + environment }); - const serviceMapData = traceIds.length - ? await getServiceMapFromTraceIds({ + const chunks = chunk( + traceIds, + setup.config['xpack.apm.serviceMapMaxTracesPerRequest'] + ); + + const init = { + connections: [], + discoveredServices: [] + }; + + if (!traceIds.length) { + return init; + } + + const chunkedResponses = await Promise.all( + chunks.map(traceIdsChunk => + getServiceMapFromTraceIds({ setup, serviceName, environment, - traceIds + traceIds: traceIdsChunk }) - : { connections: [], discoveredServices: [] }; + ) + ); - return { - after: nextAfter, - ...serviceMapData - }; + return chunkedResponses.reduce((prev, current) => { + return { + connections: prev.connections.concat(current.connections), + discoveredServices: prev.discoveredServices.concat( + current.discoveredServices + ) + }; + }); } async function getServicesData(options: IEnvOptions) { - // only return services on the first request for the global service map - if (options.after) { - return []; - } - const { setup } = options; const projection = getServicesProjection({ setup }); @@ -125,15 +138,19 @@ async function getServicesData(options: IEnvOptions) { ); } +export type ConnectionsResponse = PromiseReturnType; +export type ServicesResponse = PromiseReturnType; + export type ServiceMapAPIResponse = PromiseReturnType; + export async function getServiceMap(options: IEnvOptions) { const [connectionData, servicesData] = await Promise.all([ getConnectionData(options), getServicesData(options) ]); - return { + return dedupeConnections({ ...connectionData, services: servicesData - }; + }); } diff --git a/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts b/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts index 463fe7f2cf640..f4e12df5d6a66 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts @@ -15,27 +15,24 @@ import { PROCESSOR_EVENT, SERVICE_NAME, SERVICE_ENVIRONMENT, - SPAN_TYPE, - SPAN_SUBTYPE, + TRACE_ID, DESTINATION_ADDRESS, - TRACE_ID + SPAN_TYPE, + SPAN_SUBTYPE } from '../../../common/elasticsearch_fieldnames'; -const MAX_CONNECTIONS_PER_REQUEST = 1000; const MAX_TRACES_TO_INSPECT = 1000; export async function getTraceSampleIds({ - after, serviceName, environment, setup }: { - after?: string; serviceName?: string; environment?: string; setup: Setup & SetupTimeRange & SetupUIFilters; }) { - const { start, end, client, indices } = setup; + const { start, end, client, indices, config } = setup; const rangeQuery = { range: rangeFilter(start, end) }; @@ -65,9 +62,15 @@ export async function getTraceSampleIds({ query.bool.filter.push({ term: { [SERVICE_ENVIRONMENT]: environment } }); } - const afterObj = after - ? { after: JSON.parse(Buffer.from(after, 'base64').toString()) } - : {}; + const fingerprintBucketSize = serviceName + ? config['xpack.apm.serviceMapFingerprintBucketSize'] + : config['xpack.apm.serviceMapFingerprintGlobalBucketSize']; + + const traceIdBucketSize = serviceName + ? config['xpack.apm.serviceMapTraceIdBucketSize'] + : config['xpack.apm.serviceMapTraceIdGlobalBucketSize']; + + const samplerShardSize = traceIdBucketSize * 10; const params = { index: [indices['apm_oss.spanIndices']], @@ -77,42 +80,57 @@ export async function getTraceSampleIds({ aggs: { connections: { composite: { - size: MAX_CONNECTIONS_PER_REQUEST, - ...afterObj, sources: [ - { [SERVICE_NAME]: { terms: { field: SERVICE_NAME } } }, { - [SERVICE_ENVIRONMENT]: { - terms: { field: SERVICE_ENVIRONMENT, missing_bucket: true } + [DESTINATION_ADDRESS]: { + terms: { + field: DESTINATION_ADDRESS + } } }, { - [SPAN_TYPE]: { - terms: { field: SPAN_TYPE, missing_bucket: true } + [SERVICE_NAME]: { + terms: { + field: SERVICE_NAME + } } }, { - [SPAN_SUBTYPE]: { - terms: { field: SPAN_SUBTYPE, missing_bucket: true } + [SERVICE_ENVIRONMENT]: { + terms: { + field: SERVICE_ENVIRONMENT, + missing_bucket: true + } } }, { - [DESTINATION_ADDRESS]: { - terms: { field: DESTINATION_ADDRESS } + [SPAN_TYPE]: { + terms: { + field: SPAN_TYPE + } + } + }, + { + [SPAN_SUBTYPE]: { + terms: { + field: SPAN_SUBTYPE, + missing_bucket: true + } } } - ] + ], + size: fingerprintBucketSize }, aggs: { sample: { sampler: { - shard_size: 30 + shard_size: samplerShardSize }, aggs: { trace_ids: { terms: { field: TRACE_ID, - size: 10, + size: traceIdBucketSize, execution_hint: 'map' as const, // remove bias towards large traces by sorting on trace.id // which will be random-esque @@ -129,25 +147,9 @@ export async function getTraceSampleIds({ } }; - const tracesSampleResponse = await client.search< - { trace: { id: string } }, - typeof params - >(params); - - let nextAfter: string | undefined; - - const receivedAfterKey = - tracesSampleResponse.aggregations?.connections.after_key; - - if ( - receivedAfterKey && - (tracesSampleResponse.aggregations?.connections.buckets.length ?? 0) >= - MAX_CONNECTIONS_PER_REQUEST - ) { - nextAfter = Buffer.from(JSON.stringify(receivedAfterKey)).toString( - 'base64' - ); - } + const tracesSampleResponse = await client.search( + params + ); // make sure at least one trace per composite/connection bucket // is queried @@ -167,7 +169,6 @@ export async function getTraceSampleIds({ ); return { - after: nextAfter, traceIds }; } diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts index bead0445d6ccc..a61a61e3ccaac 100644 --- a/x-pack/plugins/apm/server/routes/service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map.ts @@ -20,10 +20,12 @@ export const serviceMapRoute = createRoute(() => ({ path: '/api/apm/service-map', params: { query: t.intersection([ - t.partial({ environment: t.string, serviceName: t.string }), + t.partial({ + environment: t.string, + serviceName: t.string + }), uiFiltersRt, - rangeRt, - t.partial({ after: t.string }) + rangeRt ]) }, handler: async ({ context, request }) => { @@ -36,9 +38,9 @@ export const serviceMapRoute = createRoute(() => ({ const setup = await setupRequest(context, request); const { - query: { serviceName, environment, after } + query: { serviceName, environment } } = context.params; - return getServiceMap({ setup, serviceName, environment, after }); + return getServiceMap({ setup, serviceName, environment }); } })); diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 68a222cb656ed..6f58e2702ec5b 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -24,6 +24,8 @@ export const CaseAttributesRt = rt.intersection([ CaseBasicRt, rt.type({ comment_ids: rt.array(rt.string), + closed_at: rt.union([rt.string, rt.null]), + closed_by: rt.union([UserRT, rt.null]), created_at: rt.string, created_by: UserRT, updated_at: rt.union([rt.string, rt.null]), diff --git a/x-pack/plugins/case/common/api/user.ts b/x-pack/plugins/case/common/api/user.ts index ed44791c4e04d..651cd08f08a02 100644 --- a/x-pack/plugins/case/common/api/user.ts +++ b/x-pack/plugins/case/common/api/user.ts @@ -7,6 +7,7 @@ import * as rt from 'io-ts'; export const UserRT = rt.type({ + email: rt.union([rt.undefined, rt.string]), full_name: rt.union([rt.undefined, rt.string]), username: rt.string, }); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts index 17a2518482637..c08dae1dc18b4 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts @@ -13,7 +13,11 @@ function createAuthenticationMock({ authc.getCurrentUser.mockReturnValue( currentUser !== undefined ? currentUser - : ({ username: 'awesome', full_name: 'Awesome D00d' } as AuthenticatedUser) + : ({ + email: 'd00d@awesome.com', + username: 'awesome', + full_name: 'Awesome D00d', + } as AuthenticatedUser) ); return authc; } diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 1e1965f83ff68..5aa8b93f17b08 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -12,10 +12,13 @@ export const mockCases: Array> = [ type: 'cases', id: 'mock-id-1', attributes: { + closed_at: null, + closed_by: null, comment_ids: ['mock-comment-1'], created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, description: 'This is a brand new case of a bad meanie defacing data', @@ -25,6 +28,7 @@ export const mockCases: Array> = [ updated_at: '2019-11-25T21:54:48.952Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -36,10 +40,13 @@ export const mockCases: Array> = [ type: 'cases', id: 'mock-id-2', attributes: { + closed_at: null, + closed_by: null, comment_ids: [], created_at: '2019-11-25T22:32:00.900Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, description: 'Oh no, a bad meanie destroying data!', @@ -49,6 +56,7 @@ export const mockCases: Array> = [ updated_at: '2019-11-25T22:32:00.900Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -60,10 +68,13 @@ export const mockCases: Array> = [ type: 'cases', id: 'mock-id-3', attributes: { + closed_at: null, + closed_by: null, comment_ids: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, description: 'Oh no, a bad meanie going LOLBins all over the place!', @@ -73,6 +84,39 @@ export const mockCases: Array> = [ updated_at: '2019-11-25T22:32:17.947Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + references: [], + updated_at: '2019-11-25T22:32:17.947Z', + version: 'WzUsMV0=', + }, + { + type: 'cases', + id: 'mock-id-4', + attributes: { + closed_at: '2019-11-25T22:32:17.947Z', + closed_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comment_ids: [], + created_at: '2019-11-25T22:32:17.947Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'Oh no, a bad meanie going LOLBins all over the place!', + title: 'Another bad one', + status: 'closed', + tags: ['LOLBins'], + updated_at: '2019-11-25T22:32:17.947Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -100,11 +144,13 @@ export const mockCaseComments: Array> = [ created_at: '2019-11-25T21:55:00.177Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, updated_at: '2019-11-25T21:55:00.177Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -126,11 +172,13 @@ export const mockCaseComments: Array> = [ created_at: '2019-11-25T21:55:14.633Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, updated_at: '2019-11-25T21:55:14.633Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, @@ -153,11 +201,13 @@ export const mockCaseComments: Array> = [ created_at: '2019-11-25T22:32:30.608Z', created_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, updated_at: '2019-11-25T22:32:30.608Z', updated_by: { full_name: 'elastic', + email: 'testemail@elastic.co', username: 'elastic', }, }, diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index 0166ba89eb76c..c14a94e84e51c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -56,14 +56,14 @@ export function initPatchCommentApi({ caseService, router }: RouteDeps) { } const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const updatedComment = await caseService.patchComment({ client: context.core.savedObjects.client, commentId: query.id, updatedAttributes: { comment: query.comment, updated_at: new Date().toISOString(), - updated_by: { full_name, username }, + updated_by: { email, full_name, username }, }, version: query.version, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts index 1da1161ab01d1..1542394fc438d 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts @@ -49,7 +49,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout } const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const updateDate = new Date().toISOString(); const patch = await caseConfigureService.patch({ @@ -58,7 +58,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout updatedAttributes: { ...queryWithoutVersion, updated_at: updateDate, - updated_by: { full_name, username }, + updated_by: { email, full_name, username }, }, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_connector.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_connector.ts deleted file mode 100644 index a9fbe0ef4f721..0000000000000 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_connector.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import Boom from 'boom'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - -import { ActionResult } from '../../../../../../actions/common'; -import { CasesConnectorConfigurationRT, throwErrors } from '../../../../../common/api'; -import { RouteDeps } from '../../types'; -import { wrapError, escapeHatch } from '../../utils'; - -export function initCaseConfigurePatchActionConnector({ caseService, router }: RouteDeps) { - router.patch( - { - path: '/api/cases/configure/connectors/{connector_id}', - validate: { - params: schema.object({ - connector_id: schema.string(), - }), - body: escapeHatch, - }, - }, - async (context, request, response) => { - try { - const query = pipe( - CasesConnectorConfigurationRT.decode(request.body), - fold(throwErrors(Boom.badRequest), identity) - ); - - const client = context.core.savedObjects.client; - const { connector_id: connectorId } = request.params; - const { cases_configuration: casesConfiguration } = query; - - const normalizedMapping = casesConfiguration.mapping.map(m => ({ - source: m.source, - target: m.target, - actionType: m.action_type, - })); - - const action = await client.get('action', connectorId); - - const { config } = action.attributes; - const res = await client.update('action', connectorId, { - config: { - ...config, - casesConfiguration: { ...casesConfiguration, mapping: normalizedMapping }, - }, - }); - - return response.ok({ - body: CasesConnectorConfigurationRT.encode({ - cases_configuration: - res.attributes.config?.casesConfiguration ?? - action.attributes.config.casesConfiguration, - }), - }); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts index a22dd8437e508..c839d36dcf4df 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts @@ -43,7 +43,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route ); } const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const creationDate = new Date().toISOString(); const post = await caseConfigureService.post({ @@ -51,7 +51,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route attributes: { ...query, created_at: creationDate, - created_by: { full_name, username }, + created_by: { email, full_name, username }, updated_at: null, updated_by: null, }, diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts index 7ce37d2569e57..8fafb1af0eb82 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts @@ -34,6 +34,6 @@ describe('GET all cases', () => { const response = await routeHandler(theContext, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.cases).toHaveLength(3); + expect(response.payload.cases).toHaveLength(4); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 7ab7212d2f436..19ff7f0734a77 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -25,7 +25,7 @@ describe('PATCH cases', () => { toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'), })); }); - it(`Patch a case`, async () => { + it(`Close a case`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', method: 'patch', @@ -50,17 +50,61 @@ describe('PATCH cases', () => { expect(response.status).toEqual(200); expect(response.payload).toEqual([ { + closed_at: '2019-11-25T21:54:48.952Z', + closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, comment_ids: ['mock-comment-1'], comments: [], created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'elastic', username: 'elastic' }, + created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'This is a brand new case of a bad meanie defacing data', id: 'mock-id-1', status: 'closed', tags: ['defacement'], title: 'Super Bad Security Issue', updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { full_name: 'Awesome D00d', username: 'awesome' }, + updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, + version: 'WzE3LDFd', + }, + ]); + }); + it(`Open a case`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'patch', + body: { + cases: [ + { + id: 'mock-id-4', + status: 'open', + version: 'WzUsMV0=', + }, + ], + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload).toEqual([ + { + closed_at: null, + closed_by: null, + comment_ids: [], + comments: [], + created_at: '2019-11-25T22:32:17.947Z', + created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, + description: 'Oh no, a bad meanie going LOLBins all over the place!', + id: 'mock-id-4', + status: 'open', + tags: ['LOLBins'], + title: 'Another bad one', + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', }, ]); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index 3fd8c2a1627ab..4aa0d8daf5b34 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -37,10 +37,23 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { client: context.core.savedObjects.client, caseIds: query.cases.map(q => q.id), }); + let nonExistingCases: CasePatchRequest[] = []; const conflictedCases = query.cases.filter(q => { const myCase = myCases.saved_objects.find(c => c.id === q.id); + + if (myCase && myCase.error) { + nonExistingCases = [...nonExistingCases, q]; + return false; + } return myCase == null || myCase?.version !== q.version; }); + if (nonExistingCases.length > 0) { + throw Boom.notFound( + `These cases ${nonExistingCases + .map(c => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } if (conflictedCases.length > 0) { throw Boom.conflict( `These cases ${conflictedCases @@ -60,18 +73,31 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { }); if (updateFilterCases.length > 0) { const updatedBy = await caseService.getUser({ request, response }); - const { full_name, username } = updatedBy; + const { email, full_name, username } = updatedBy; const updatedDt = new Date().toISOString(); const updatedCases = await caseService.patchCases({ client: context.core.savedObjects.client, cases: updateFilterCases.map(thisCase => { const { id: caseId, version, ...updateCaseAttributes } = thisCase; + let closedInfo = {}; + if (updateCaseAttributes.status && updateCaseAttributes.status === 'closed') { + closedInfo = { + closed_at: updatedDt, + closed_by: { email, full_name, username }, + }; + } else if (updateCaseAttributes.status && updateCaseAttributes.status === 'open') { + closedInfo = { + closed_at: null, + closed_by: null, + }; + } return { caseId, updatedAttributes: { ...updateCaseAttributes, + ...closedInfo, updated_at: updatedDt, - updated_by: { full_name, username }, + updated_by: { email, full_name, username }, }, version, }; diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index 956f410c9c10a..60ee57a0efea7 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -26,7 +26,6 @@ import { initGetTagsApi } from './cases/tags/get_tags'; import { RouteDeps } from './types'; import { initCaseConfigureGetActionConnector } from './cases/configure/get_connectors'; -import { initCaseConfigurePatchActionConnector } from './cases/configure/patch_connector'; import { initGetCaseConfigure } from './cases/configure/get_configure'; import { initPatchCaseConfigure } from './cases/configure/patch_configure'; import { initPostCaseConfigure } from './cases/configure/post_configure'; @@ -48,7 +47,6 @@ export function initCaseApi(deps: RouteDeps) { initPostCommentApi(deps); // Cases Configure initCaseConfigureGetActionConnector(deps); - initCaseConfigurePatchActionConnector(deps); initGetCaseConfigure(deps); initPatchCaseConfigure(deps); initPostCaseConfigure(deps); diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts index eac259cc69c5a..7af3e7b70d96f 100644 --- a/x-pack/plugins/case/server/routes/api/types.ts +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -14,7 +14,7 @@ export interface RouteDeps { } export enum SortFieldCase { + closedAt = 'closed_at', createdAt = 'created_at', status = 'status', - updatedAt = 'updated_at', } diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 27ee6fc58e20a..19dbb024d1e0b 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -26,18 +26,22 @@ import { SortFieldCase } from './types'; export const transformNewCase = ({ createdDate, - newCase, + email, full_name, + newCase, username, }: { createdDate: string; - newCase: CaseRequest; + email?: string; full_name?: string; + newCase: CaseRequest; username: string; }): CaseAttributes => ({ + closed_at: newCase.status === 'closed' ? createdDate : null, + closed_by: newCase.status === 'closed' ? { email, full_name, username } : null, comment_ids: [], created_at: createdDate, - created_by: { full_name, username }, + created_by: { email, full_name, username }, updated_at: null, updated_by: null, ...newCase, @@ -46,18 +50,20 @@ export const transformNewCase = ({ interface NewCommentArgs { comment: string; createdDate: string; + email?: string; full_name?: string; username: string; } export const transformNewComment = ({ comment, createdDate, + email, full_name, username, }: NewCommentArgs): CommentAttributes => ({ comment, created_at: createdDate, - created_by: { full_name, username }, + created_by: { email, full_name, username }, updated_at: null, updated_by: null, }); @@ -133,9 +139,9 @@ export const sortToSnake = (sortField: string): SortFieldCase => { case 'createdAt': case 'created_at': return SortFieldCase.createdAt; - case 'updatedAt': - case 'updated_at': - return SortFieldCase.updatedAt; + case 'closedAt': + case 'closed_at': + return SortFieldCase.closedAt; default: return SortFieldCase.createdAt; } diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index 2aa64528739b1..8eab040b9ca9c 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -14,6 +14,22 @@ export const caseSavedObjectType: SavedObjectsType = { namespaceAgnostic: false, mappings: { properties: { + closed_at: { + type: 'date', + }, + closed_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, comment_ids: { type: 'keyword', }, @@ -28,6 +44,9 @@ export const caseSavedObjectType: SavedObjectsType = { full_name: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, description: { @@ -53,6 +72,9 @@ export const caseSavedObjectType: SavedObjectsType = { full_name: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, }, diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts index 51c31421fec2f..f52da886e7611 100644 --- a/x-pack/plugins/case/server/saved_object_types/comments.ts +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -28,6 +28,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = { username: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, updated_at: { @@ -41,6 +44,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = { full_name: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, }, diff --git a/x-pack/plugins/case/server/scripts/README.md b/x-pack/plugins/case/server/scripts/README.md new file mode 100644 index 0000000000000..2c35eb305282a --- /dev/null +++ b/x-pack/plugins/case/server/scripts/README.md @@ -0,0 +1,90 @@ +README.md for developers working on the Case API on how to get started +using the CURL scripts in the scripts folder. + +The scripts rely on CURL and jq: + +- [CURL](https://curl.haxx.se) +- [jq](https://stedolan.github.io/jq/) + +Install curl and jq + +```sh +brew update +brew install curl +brew install jq +``` + +Open `$HOME/.zshrc` or `${HOME}.bashrc` depending on your SHELL output from `echo $SHELL` +and add these environment variables: + +```sh +export ELASTICSEARCH_USERNAME=${user} +export ELASTICSEARCH_PASSWORD=${password} +export ELASTICSEARCH_URL=https://${ip}:9200 +export KIBANA_URL=http://localhost:5601 +export TASK_MANAGER_INDEX=.kibana-task-manager-${your user id} +export KIBANA_INDEX=.kibana-${your user id} +``` + +source `$HOME/.zshrc` or `${HOME}.bashrc` to ensure variables are set: + +```sh +source ~/.zshrc +``` + +Restart Kibana and ensure that you are using `--no-base-path` as changing the base path is a feature but will +get in the way of the CURL scripts written as is. + +Go to the scripts folder `cd kibana/x-pack/plugins/case/server/scripts` and run: + +```sh +./hard_reset.sh +``` + +which will: + +- Delete any existing cases you have +- Delete any existing comments you have +- Posts the sample case from `./mock/case/post_case.json` +- Posts the sample comment from `./mock/comment/post_comment.json` to the new case + +Now you can run + +```sh +./find_cases.sh +``` + +You should see the new case created like so: + +```sh +{ + "page": 1, + "per_page": 20, + "total": 1, + "cases": [ + { + "id": "2e0afbc0-658c-11ea-85c8-1d8f792cbc08", + "version": "Wzc5NSwxXQ==", + "comments": [], + "comment_ids": [ + "2ecec0f0-658c-11ea-85c8-1d8f792cbc08" + ], + "created_at": "2020-03-14T00:38:53.004Z", + "created_by": { + "full_name": "Steph Milovic", + "username": "smilovic" + }, + "updated_at": null, + "updated_by": null, + "description": "This looks not so good", + "title": "Bad meanie defacing data", + "status": "open", + "tags": [ + "defacement" + ] + } + ], + "count_open_cases": 1, + "count_closed_cases": 1 +} +``` diff --git a/x-pack/plugins/case/server/scripts/check_env_variables.sh b/x-pack/plugins/case/server/scripts/check_env_variables.sh new file mode 100755 index 0000000000000..2f7644051debb --- /dev/null +++ b/x-pack/plugins/case/server/scripts/check_env_variables.sh @@ -0,0 +1,41 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# Add this to the start of any scripts to detect if env variables are set + +set -e + +if [ -z "${ELASTICSEARCH_USERNAME}" ]; then + echo "Set ELASTICSEARCH_USERNAME in your environment" + exit 1 +fi + +if [ -z "${ELASTICSEARCH_PASSWORD}" ]; then + echo "Set ELASTICSEARCH_PASSWORD in your environment" + exit 1 +fi + +if [ -z "${ELASTICSEARCH_URL}" ]; then + echo "Set ELASTICSEARCH_URL in your environment" + exit 1 +fi + +if [ -z "${KIBANA_URL}" ]; then + echo "Set KIBANA_URL in your environment" + exit 1 +fi + +if [ -z "${TASK_MANAGER_INDEX}" ]; then + echo "Set TASK_MANAGER_INDEX in your environment" + exit 1 +fi + +if [ -z "${KIBANA_INDEX}" ]; then + echo "Set KIBANA_INDEX in your environment" + exit 1 +fi diff --git a/x-pack/plugins/case/server/scripts/delete_cases.sh b/x-pack/plugins/case/server/scripts/delete_cases.sh new file mode 100755 index 0000000000000..c04afed5fe679 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/delete_cases.sh @@ -0,0 +1,51 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# Creates a new case and then gets it if no CASE_ID is specified + +# Example: +# ./delete_cases.sh + +# Example with CASE_ID args: +# ./delete_cases.sh 1234-example-id 5678-example-id + +set -e +./check_env_variables.sh + +if [ "$1" ]; then + ALL=("$@") + i=0 + + COUNT=${#ALL[@]} + IDS="" + for ID in "${ALL[@]}" + do + let i=i+1 + if [ $i -eq $COUNT ]; then + IDS+="%22${ID}%22" + else + IDS+="%22${ID}%22," + fi + done + + curl -s -k \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X DELETE "${KIBANA_URL}${SPACE_URL}/api/cases?ids=\[${IDS}\]" \ + | jq .; + exit 1 +else + CASE_ID=("$(./generate_case_data.sh | jq '.id' -j)") + curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X DELETE "${KIBANA_URL}${SPACE_URL}/api/cases?ids=\[%22${CASE_ID}%22\]" \ + | jq .; + exit 1 +fi diff --git a/x-pack/plugins/case/server/scripts/delete_comment.sh b/x-pack/plugins/case/server/scripts/delete_comment.sh new file mode 100755 index 0000000000000..a858d9cb11a57 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/delete_comment.sh @@ -0,0 +1,39 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# Creates a new case and then gets it if no CASE_ID is specified + +# Example: +# ./delete_comment.sh + +# Example with CASE_ID and COMMENT_ID arg: +# ./delete_comment.sh 1234-example-case-id 5678-example-comment-id + +set -e +./check_env_variables.sh + + +if [ "$1" ] && [ "$2" ]; then + curl -s -k \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X DELETE "${KIBANA_URL}${SPACE_URL}/api/cases/$1/comments/$2" \ + | jq .; + exit 1 +else + DATA="$(./generate_case_and_comment_data.sh | jq '{ caseId: .caseId, commentId: .commentId}' -j)" + CASE_ID=$(echo $DATA | jq ".caseId" -j) + COMMENT_ID=$(echo $DATA | jq ".commentId" -j) + curl -s -k \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X DELETE "${KIBANA_URL}${SPACE_URL}/api/cases/$CASE_ID/comments/$COMMENT_ID" \ + | jq .; + exit 1 +fi +./delete_case.sh [b6766a90-6559-11ea-9fd5-b52942ab389a] \ No newline at end of file diff --git a/x-pack/plugins/case/server/scripts/find_cases.sh b/x-pack/plugins/case/server/scripts/find_cases.sh new file mode 100755 index 0000000000000..bb4232b0c6c27 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/find_cases.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# Example: +# ./find_cases.sh + +set -e +./check_env_variables.sh + +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET ${KIBANA_URL}${SPACE_URL}/api/cases/_find | jq . diff --git a/x-pack/plugins/case/server/scripts/find_cases_by_filter.sh b/x-pack/plugins/case/server/scripts/find_cases_by_filter.sh new file mode 100755 index 0000000000000..433311c117e98 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/find_cases_by_filter.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# Example: +# ./find_cases_by_filter.sh + +# Example get all open cases: +# ./find_cases_by_filter.sh "cases.attributes.state:%20open" + +# Example get all the names that start with Bad* +# ./find_cases_by_filter.sh "cases.attributes.title:%20Bad*" + +# Exampe get everything that has phishing +# ./find_cases_by_filter.sh "cases.attributes.tags:phishing" + +set -e +./check_env_variables.sh + +FILTER=${1:-'cases.attributes.state:%20closed'} + +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET ${KIBANA_URL}${SPACE_URL}/api/cases/_find?filter=$FILTER | jq . diff --git a/x-pack/plugins/case/server/scripts/find_cases_sort.sh b/x-pack/plugins/case/server/scripts/find_cases_sort.sh new file mode 100755 index 0000000000000..436b475220102 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/find_cases_sort.sh @@ -0,0 +1,24 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# Example: +# ./find_cases_sort.sh + +# Example with sort args: +# ./find_cases_sort.sh createdAt desc + +set -e +./check_env_variables.sh + +SORT=${1:-'createdAt'} +ORDER=${2:-'asc'} + +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/cases/_find?sortField=$SORT&sortOrder=$ORDER" \ + | jq . diff --git a/x-pack/plugins/case/server/scripts/generate_case_and_comment_data.sh b/x-pack/plugins/case/server/scripts/generate_case_and_comment_data.sh new file mode 100755 index 0000000000000..9b6f472d798e0 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/generate_case_and_comment_data.sh @@ -0,0 +1,30 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# returns case/comment data as { commentId, commentVersion, caseId, caseVersion } +# Example: +# ./generate_case_and_comment_data.sh + +set -e +./check_env_variables.sh + +COMMENT=(${1:-./mock/comment/post_comment.json}) +CASE_ID=$(./post_case.sh | jq ".id" -j) + +POSTED_COMMENT="$(curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST "${KIBANA_URL}${SPACE_URL}/api/cases/$CASE_ID/comments" \ + -d @${COMMENT} \ + | jq '{ commentId: .id, commentVersion: .version }' +)" +POSTED_CASE=$(./get_case.sh $CASE_ID | jq '{ caseId: .id, caseVersion: .version }' -j) + +echo ${POSTED_COMMENT} ${POSTED_CASE} \ + | jq -s add; \ No newline at end of file diff --git a/x-pack/plugins/case/server/scripts/generate_case_data.sh b/x-pack/plugins/case/server/scripts/generate_case_data.sh new file mode 100755 index 0000000000000..f8f6142a5d733 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/generate_case_data.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# returns case data as { id, version } +# Example: +# ./generate_case_data.sh + +set -e +./check_env_variables.sh +./post_case.sh | jq '{ id: .id, version: .version }' -j; + diff --git a/x-pack/plugins/case/server/scripts/get_case.sh b/x-pack/plugins/case/server/scripts/get_case.sh new file mode 100755 index 0000000000000..c0106993fd81e --- /dev/null +++ b/x-pack/plugins/case/server/scripts/get_case.sh @@ -0,0 +1,38 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# Creates a new case and then gets it if no CASE_ID is specified + +# Example: +# ./get_case.sh + +# Example with CASE_ID arg: +# ./get_case.sh 1234-example-id + +set -e +./check_env_variables.sh + + +if [ "$1" ]; then + curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/cases/$1" \ + | jq .; + exit 1 +else + CASE_ID=("$(./generate_case_data.sh | jq '.id' -j)") + curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/cases/$CASE_ID" \ + | jq .; + exit 1 +fi diff --git a/x-pack/plugins/case/server/scripts/get_case_comments.sh b/x-pack/plugins/case/server/scripts/get_case_comments.sh new file mode 100755 index 0000000000000..65b7c43a68824 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/get_case_comments.sh @@ -0,0 +1,38 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# Creates a new case and comments it if no CASE_ID is specified + +# Example: +# ./get_case_comments.sh + +# Example: +# ./get_case_comments.sh 1234-example-id + +set -e +./check_env_variables.sh + + +if [ "$1" ]; then + curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/cases/$1/comments" \ + | jq .; + exit 1 +else + CASE_ID="$(./generate_case_and_comment_data.sh | jq '.caseId' -j)" + curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/cases/$CASE_ID/comments" \ + | jq .; + exit 1 +fi diff --git a/x-pack/plugins/case/server/scripts/get_comment.sh b/x-pack/plugins/case/server/scripts/get_comment.sh new file mode 100755 index 0000000000000..9b2f7d6636745 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/get_comment.sh @@ -0,0 +1,40 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# Creates a new case and then gets it if no CASE_ID is specified + +# Example: +# ./get_comment.sh + +# Example with CASE_ID and COMMENT_ID arg: +# ./get_comment.sh 1234-example-case-id 5678-example-comment-id + +set -e +./check_env_variables.sh + + +if [ "$1" ] && [ "$2" ]; then + curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/cases/$1/comments/$2" \ + | jq .; + exit 1 +else + DATA="$(./generate_case_and_comment_data.sh | jq '{ caseId: .caseId, commentId: .commentId}' -j)" + CASE_ID=$(echo $DATA | jq ".caseId" -j) + COMMENT_ID=$(echo $DATA | jq ".commentId" -j) + curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/cases/$CASE_ID/comments/$COMMENT_ID" \ + | jq .; + exit 1 +fi diff --git a/x-pack/plugins/case/server/scripts/get_reporters.sh b/x-pack/plugins/case/server/scripts/get_reporters.sh new file mode 100755 index 0000000000000..2c926269d31f8 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/get_reporters.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# Creates a new case and then gets it if no CASE_ID is specified + +# Example: +# ./get_tags.sh + + +set -e +./check_env_variables.sh + +curl -s -k \ +-H 'Content-Type: application/json' \ +-H 'kbn-xsrf: 123' \ +-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-X GET "${KIBANA_URL}${SPACE_URL}/api/cases/reporters" \ +| jq .; diff --git a/x-pack/plugins/case/server/scripts/get_status.sh b/x-pack/plugins/case/server/scripts/get_status.sh new file mode 100755 index 0000000000000..b246a2267a222 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/get_status.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# Creates a new case and then gets it if no CASE_ID is specified + +# Example: +# ./get_tags.sh + + +set -e +./check_env_variables.sh + +curl -s -k \ +-H 'Content-Type: application/json' \ +-H 'kbn-xsrf: 123' \ +-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-X GET "${KIBANA_URL}${SPACE_URL}/api/cases/status" \ +| jq .; diff --git a/x-pack/plugins/case/server/scripts/get_tags.sh b/x-pack/plugins/case/server/scripts/get_tags.sh new file mode 100755 index 0000000000000..c5fcf13405e0c --- /dev/null +++ b/x-pack/plugins/case/server/scripts/get_tags.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# Creates a new case and then gets it if no CASE_ID is specified + +# Example: +# ./get_tags.sh + + +set -e +./check_env_variables.sh + +curl -s -k \ +-H 'Content-Type: application/json' \ +-H 'kbn-xsrf: 123' \ +-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-X GET "${KIBANA_URL}${SPACE_URL}/api/cases/tags" \ +| jq .; diff --git a/x-pack/plugins/case/server/scripts/hard_reset.sh b/x-pack/plugins/case/server/scripts/hard_reset.sh new file mode 100755 index 0000000000000..e5309e0ab7f6c --- /dev/null +++ b/x-pack/plugins/case/server/scripts/hard_reset.sh @@ -0,0 +1,29 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# Deletes all current cases and comments and creates one new case with a comment +# Example: +# ./hard_reset.sh + +set -e +./check_env_variables.sh +# +ALL_CASES=$(curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/cases/_find?perPage=500" | jq '.cases' -j) + +IDS="" +for row in $(echo "${ALL_CASES}" | jq -r '.[] | @base64'); do + _jq() { + echo ${row} | base64 --decode | jq -r ${1} + } + IDS+="$(_jq '.id') " +done + +./generate_case_and_comment_data.sh +./delete_cases.sh $IDS \ No newline at end of file diff --git a/x-pack/plugins/case/server/scripts/mock/case/post_case.json b/x-pack/plugins/case/server/scripts/mock/case/post_case.json new file mode 100644 index 0000000000000..25a9780596828 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/mock/case/post_case.json @@ -0,0 +1,8 @@ +{ + "description": "This looks not so good", + "title": "Bad meanie defacing data", + "status": "open", + "tags": [ + "defacement" + ] +} diff --git a/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json b/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json new file mode 100644 index 0000000000000..cf066d2c8a1e8 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json @@ -0,0 +1,8 @@ +{ + "description": "I hope there are some good security engineers at this company...", + "title": "Another bad dude", + "status": "open", + "tags": [ + "phishing" + ] +} diff --git a/x-pack/plugins/case/server/scripts/mock/comment/post_comment.json b/x-pack/plugins/case/server/scripts/mock/comment/post_comment.json new file mode 100644 index 0000000000000..82cf3e7ce7309 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/mock/comment/post_comment.json @@ -0,0 +1,3 @@ +{ + "comment": "Solve this fast!" +} diff --git a/x-pack/plugins/case/server/scripts/mock/comment/post_comment_v2.json b/x-pack/plugins/case/server/scripts/mock/comment/post_comment_v2.json new file mode 100644 index 0000000000000..e753231e36911 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/mock/comment/post_comment_v2.json @@ -0,0 +1,3 @@ +{ + "comment": "This looks bad" +} diff --git a/x-pack/plugins/case/server/scripts/patch_cases.sh b/x-pack/plugins/case/server/scripts/patch_cases.sh new file mode 100755 index 0000000000000..2faa524daac7b --- /dev/null +++ b/x-pack/plugins/case/server/scripts/patch_cases.sh @@ -0,0 +1,24 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# A new case will be generated and the title will be updated in the PATCH call +# Example: +# ./patch_cases.sh + +set -e +./check_env_variables.sh + +PATCH_CASE="$(./generate_case_data.sh | jq '{ cases: [{ id: .id, version: .version, title: "Change the title" }] }' -j)" + +curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X PATCH ${KIBANA_URL}${SPACE_URL}/api/cases \ + -d "$PATCH_CASE" \ + | jq .; diff --git a/x-pack/plugins/case/server/scripts/patch_comment.sh b/x-pack/plugins/case/server/scripts/patch_comment.sh new file mode 100755 index 0000000000000..2f0bbe2883b0f --- /dev/null +++ b/x-pack/plugins/case/server/scripts/patch_comment.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# A new case and comment will be generated and the comment will be updated in the PATCH call +# Example: +# ./patch_comment.sh + +set -e +./check_env_variables.sh + +DATA="$(./generate_case_and_comment_data.sh | jq '{ caseId: .caseId, id: .commentId, version: .commentVersion, comment: "Update the comment" }' -j)" +CASE_ID=$(echo "${DATA}" | jq ".caseId" -j) +PATCH_COMMENT=$(echo "${DATA}" | jq 'del(.caseId)') + +curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X PATCH "${KIBANA_URL}${SPACE_URL}/api/cases/$CASE_ID/comments" \ + -d "$PATCH_COMMENT" \ + | jq .; diff --git a/x-pack/plugins/case/server/scripts/post_case.sh b/x-pack/plugins/case/server/scripts/post_case.sh new file mode 100755 index 0000000000000..fff449741fe17 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/post_case.sh @@ -0,0 +1,37 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# Example: +# ./post_case.sh + +# Example: +# ./post_case.sh ./mock/case/post_case.json + +# Example glob: +# ./post_case.sh ./mock/case/* + +set -e +./check_env_variables.sh + +# Uses a default if no argument is specified +CASES=(${@:-./mock/case/post_case.json}) + +for CASE in "${CASES[@]}" +do { + [ -e "$CASE" ] || continue + curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST ${KIBANA_URL}${SPACE_URL}/api/cases \ + -d @${CASE} \ + | jq .; +} & +done + +wait diff --git a/x-pack/plugins/case/server/scripts/post_comment.sh b/x-pack/plugins/case/server/scripts/post_comment.sh new file mode 100755 index 0000000000000..91e07f5bd110c --- /dev/null +++ b/x-pack/plugins/case/server/scripts/post_comment.sh @@ -0,0 +1,57 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +# Example: +# ./post_comment.sh + +# Example: +# ./post_comment.sh 92970bf0-64a7-11ea-9979-d394b1de38af ./mock/comment/post_comment.json + +# Example glob: +# ./post_comment.sh 92970bf0-64a7-11ea-9979-d394b1de38af ./mock/comment/* + +set -e +./check_env_variables.sh + +# Uses a default if no argument is specified +COMMENTS=(${2:-./mock/comment/post_comment.json}) + +if [ "$1" ]; then + for COMMENT in "${COMMENTS[@]}" + do { + [ -e "$COMMENT" ] || continue + curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST "${KIBANA_URL}${SPACE_URL}/api/cases/$1/comments" \ + -d @${COMMENT} \ + | jq .; + } & + done + + wait + exit 1 +else + CASE_ID=("$(./generate_case_data.sh | jq '.id' -j)") + for COMMENT in "${COMMENTS[@]}" + do { + [ -e "$COMMENT" ] || continue + curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST "${KIBANA_URL}${SPACE_URL}/api/cases/$CASE_ID/comments" \ + -d @${COMMENT} \ + | jq .; + } & + done + + wait + exit 1 +fi \ No newline at end of file diff --git a/x-pack/plugins/console_extensions/server/plugin.ts b/x-pack/plugins/console_extensions/server/plugin.ts index f4c41aa0a0ad5..8c2cb4d0db42b 100644 --- a/x-pack/plugins/console_extensions/server/plugin.ts +++ b/x-pack/plugins/console_extensions/server/plugin.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import { join } from 'path'; -import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; +import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; -import { ConsoleSetup } from '../../../../src/plugins/console/server'; +import { ConsoleSetup, ConsoleStart } from '../../../../src/plugins/console/server'; import { processors } from './spec/ingest/index'; @@ -14,19 +14,25 @@ interface SetupDependencies { console: ConsoleSetup; } +interface StartDependencies { + console: ConsoleStart; +} + +const CONSOLE_XPACK_JSON_SPEC_PATH = join(__dirname, 'spec/'); + export class ConsoleExtensionsServerPlugin implements Plugin { log: Logger; constructor(private readonly ctx: PluginInitializerContext) { this.log = this.ctx.logger.get(); } - setup( - core: CoreSetup, - { console: { addProcessorDefinition, addExtensionSpecFilePath } }: SetupDependencies - ) { - addExtensionSpecFilePath(join(__dirname, 'spec/')); + setup(core: CoreSetup, { console: { addExtensionSpecFilePath } }: SetupDependencies) { + addExtensionSpecFilePath(CONSOLE_XPACK_JSON_SPEC_PATH); + this.log.debug(`Added extension path to ${CONSOLE_XPACK_JSON_SPEC_PATH}...`); + } + + start(core: CoreStart, { console: { addProcessorDefinition } }: StartDependencies) { processors.forEach(processor => addProcessorDefinition(processor)); - this.log.debug('Installed console autocomplete extensions.'); + this.log.debug('Added processor definition extensions.'); } - start() {} } diff --git a/x-pack/plugins/data_enhanced/public/search/async_search_strategy.ts b/x-pack/plugins/data_enhanced/public/search/async_search_strategy.ts index fa5d677a53b2a..6271d7fcbeaac 100644 --- a/x-pack/plugins/data_enhanced/public/search/async_search_strategy.ts +++ b/x-pack/plugins/data_enhanced/public/search/async_search_strategy.ts @@ -6,6 +6,7 @@ import { EMPTY, fromEvent, NEVER, Observable, throwError, timer } from 'rxjs'; import { mergeMap, expand, takeUntil } from 'rxjs/operators'; +import { AbortError } from '../../../../../src/plugins/data/common'; import { IKibanaSearchResponse, ISearchContext, @@ -45,10 +46,7 @@ export const asyncSearchStrategyProvider: TSearchStrategyProvider { const config = await context.config$.pipe(first()).toPromise(); const defaultParams = getDefaultSearchParams(config); - const params = { ...defaultParams, trackTotalHits: true, ...request.params }; + const params = { ...defaultParams, ...request.params }; const response = await (request.indexType === 'rollup' ? rollupSearch(caller, { ...request, params }, options) @@ -45,11 +45,6 @@ export const enhancedEsSearchStrategyProvider: TSearchStrategyProvider) : (response as AsyncSearchResponse).response; - if (typeof rawResponse.hits.total !== 'number') { - // @ts-ignore This should be fixed as part of https://github.com/elastic/kibana/issues/26356 - rawResponse.hits.total = rawResponse.hits.total.value; - } - const id = (response as AsyncSearchResponse).id; const { total, failed, successful } = rawResponse._shards; const loaded = failed + successful; diff --git a/x-pack/plugins/endpoint/common/generate_data.ts b/x-pack/plugins/endpoint/common/generate_data.ts index 2e1d6074d0c2f..75351bb3bf07d 100644 --- a/x-pack/plugins/endpoint/common/generate_data.ts +++ b/x-pack/plugins/endpoint/common/generate_data.ts @@ -248,7 +248,7 @@ export class EndpointDocGenerator { public generateEvent(options: EventOptions = {}): EndpointEvent { return { '@timestamp': options.timestamp ? options.timestamp : new Date().getTime(), - agent: { ...this.commonInfo.agent, type: 'endgame' }, + agent: { ...this.commonInfo.agent, type: 'endpoint' }, ecs: { version: '1.4.0', }, @@ -325,7 +325,7 @@ export class EndpointDocGenerator { for (let i = 0; i < generations; i++) { const newParents: EndpointEvent[] = []; parents.forEach(element => { - const numChildren = this.randomN(maxChildrenPerNode); + const numChildren = this.randomN(maxChildrenPerNode + 1); for (let j = 0; j < numChildren; j++) { timestamp = timestamp + 1000; const child = this.generateEvent({ diff --git a/x-pack/plugins/endpoint/common/models/event.ts b/x-pack/plugins/endpoint/common/models/event.ts new file mode 100644 index 0000000000000..650486f3c3858 --- /dev/null +++ b/x-pack/plugins/endpoint/common/models/event.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EndpointEvent, LegacyEndpointEvent } from '../types'; + +export function isLegacyEvent( + event: EndpointEvent | LegacyEndpointEvent +): event is LegacyEndpointEvent { + return (event as LegacyEndpointEvent).endgame !== undefined; +} + +export function eventTimestamp( + event: EndpointEvent | LegacyEndpointEvent +): string | undefined | number { + if (isLegacyEvent(event)) { + return event.endgame.timestamp_utc; + } else { + return event['@timestamp']; + } +} + +export function eventName(event: EndpointEvent | LegacyEndpointEvent): string { + if (isLegacyEvent(event)) { + return event.endgame.process_name ? event.endgame.process_name : ''; + } else { + return event.process.name; + } +} diff --git a/x-pack/plugins/endpoint/common/types.ts b/x-pack/plugins/endpoint/common/types.ts index e423de56bf817..7e4cf3d700ec8 100644 --- a/x-pack/plugins/endpoint/common/types.ts +++ b/x-pack/plugins/endpoint/common/types.ts @@ -311,8 +311,8 @@ export interface EndpointEvent { version: string; }; event: { - category: string; - type: string; + category: string | string[]; + type: string | string[]; id: string; kind: string; }; @@ -328,6 +328,7 @@ export interface EndpointEvent { name: string; parent?: { entity_id: string; + name?: string; }; }; } diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts index 68731bb3f307f..5e9b08c09c2c7 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts @@ -165,15 +165,3 @@ export const hasSelectedAlert: (state: AlertListState) => boolean = createSelect uiQueryParams, ({ selected_alert: selectedAlert }) => selectedAlert !== undefined ); - -/** - * Determine if the alert event is most likely compatible with LegacyEndpointEvent. - */ -export const selectedAlertIsLegacyEndpointEvent: ( - state: AlertListState -) => boolean = createSelector(selectedAlertDetailsData, function(event) { - if (event === undefined) { - return false; - } - return 'endgame' in event; -}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/index.tsx index 82a4bc00a4396..0ec5a855c8615 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/index.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/index.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { memo, useMemo } from 'react'; +import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -19,87 +20,104 @@ import * as selectors from '../../../../store/alerts/selectors'; import { MetadataPanel } from './metadata_panel'; import { FormattedDate } from '../../formatted_date'; import { AlertDetailResolver } from '../../resolver'; +import { ResolverEvent } from '../../../../../../../common/types'; import { TakeActionDropdown } from './take_action_dropdown'; -export const AlertDetailsOverview = memo(() => { - const alertDetailsData = useAlertListSelector(selectors.selectedAlertDetailsData); - if (alertDetailsData === undefined) { - return null; - } - const selectedAlertIsLegacyEndpointEvent = useAlertListSelector( - selectors.selectedAlertIsLegacyEndpointEvent - ); +export const AlertDetailsOverview = styled( + memo(() => { + const alertDetailsData = useAlertListSelector(selectors.selectedAlertDetailsData); + if (alertDetailsData === undefined) { + return null; + } - const tabs: EuiTabbedContentTab[] = useMemo(() => { - return [ - { - id: 'overviewMetadata', - 'data-test-subj': 'overviewMetadata', - name: i18n.translate( - 'xpack.endpoint.application.endpoint.alertDetails.overview.tabs.overview', - { - defaultMessage: 'Overview', - } - ), - content: ( - <> - - - - ), - }, - { - id: 'overviewResolver', - name: i18n.translate( - 'xpack.endpoint.application.endpoint.alertDetails.overview.tabs.resolver', - { - defaultMessage: 'Resolver', - } - ), - content: ( - <> - - {selectedAlertIsLegacyEndpointEvent && } - - ), - }, - ]; - }, [selectedAlertIsLegacyEndpointEvent]); + const tabs: EuiTabbedContentTab[] = useMemo(() => { + return [ + { + id: 'overviewMetadata', + 'data-test-subj': 'overviewMetadata', + name: i18n.translate( + 'xpack.endpoint.application.endpoint.alertDetails.overview.tabs.overview', + { + defaultMessage: 'Overview', + } + ), + content: ( + <> + + + + ), + }, + { + id: 'overviewResolver', + 'data-test-subj': 'overviewResolverTab', + name: i18n.translate( + 'xpack.endpoint.application.endpoint.alertDetails.overview.tabs.resolver', + { + defaultMessage: 'Resolver', + } + ), + content: ( + <> + + + + ), + }, + ]; + }, [alertDetailsData]); - return ( - <> -
- -

+ return ( + <> +
+ +

+ +

+
+ + +

+ , + }} + /> +

+
+ + + Endpoint Status:{' '} + + {' '} + + + + + {' '} -

-
- - -

- , - }} - /> -

-
- - - Endpoint Status: Online - - Alert Status: Open - - - -
- - - ); -}); + + + + + + + + ); + }) +)` + height: 100%; + width: 100%; +`; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx index 52ef480bbb930..d18bc59a35f52 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx @@ -10,12 +10,12 @@ import { Provider } from 'react-redux'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import { Resolver } from '../../../../embeddables/resolver/view'; import { EndpointPluginServices } from '../../../../plugin'; -import { LegacyEndpointEvent } from '../../../../../common/types'; +import { ResolverEvent } from '../../../../../common/types'; import { storeFactory } from '../../../../embeddables/resolver/store'; export const AlertDetailResolver = styled( React.memo( - ({ className, selectedEvent }: { className?: string; selectedEvent?: LegacyEndpointEvent }) => { + ({ className, selectedEvent }: { className?: string; selectedEvent?: ResolverEvent }) => { const context = useKibana(); const { store } = storeFactory(context); @@ -33,4 +33,5 @@ export const AlertDetailResolver = styled( width: 100%; display: flex; flex-grow: 1; + min-height: 500px; `; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/models/indexed_process_tree.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/models/indexed_process_tree.ts index 6892bf11ecff2..c9a03f0a47968 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/models/indexed_process_tree.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/models/indexed_process_tree.ts @@ -6,15 +6,15 @@ import { uniquePidForProcess, uniqueParentPidForProcess } from './process_event'; import { IndexedProcessTree } from '../types'; -import { LegacyEndpointEvent } from '../../../../common/types'; +import { ResolverEvent } from '../../../../common/types'; import { levelOrder as baseLevelOrder } from '../lib/tree_sequencers'; /** * Create a new IndexedProcessTree from an array of ProcessEvents */ -export function factory(processes: LegacyEndpointEvent[]): IndexedProcessTree { - const idToChildren = new Map(); - const idToValue = new Map(); +export function factory(processes: ResolverEvent[]): IndexedProcessTree { + const idToChildren = new Map(); + const idToValue = new Map(); for (const process of processes) { idToValue.set(uniquePidForProcess(process), process); @@ -36,10 +36,7 @@ export function factory(processes: LegacyEndpointEvent[]): IndexedProcessTree { /** * Returns an array with any children `ProcessEvent`s of the passed in `process` */ -export function children( - tree: IndexedProcessTree, - process: LegacyEndpointEvent -): LegacyEndpointEvent[] { +export function children(tree: IndexedProcessTree, process: ResolverEvent): ResolverEvent[] { const id = uniquePidForProcess(process); const processChildren = tree.idToChildren.get(id); return processChildren === undefined ? [] : processChildren; @@ -50,8 +47,8 @@ export function children( */ export function parent( tree: IndexedProcessTree, - childProcess: LegacyEndpointEvent -): LegacyEndpointEvent | undefined { + childProcess: ResolverEvent +): ResolverEvent | undefined { const uniqueParentPid = uniqueParentPidForProcess(childProcess); if (uniqueParentPid === undefined) { return undefined; @@ -74,7 +71,7 @@ export function root(tree: IndexedProcessTree) { if (size(tree) === 0) { return null; } - let current: LegacyEndpointEvent = tree.idToProcess.values().next().value; + let current: ResolverEvent = tree.idToProcess.values().next().value; while (parent(tree, current) !== undefined) { current = parent(tree, current)!; } diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event.ts index 876168d2ed96a..a709d6caf46cb 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/models/process_event.ts @@ -4,36 +4,65 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyEndpointEvent } from '../../../../common/types'; +import { ResolverEvent } from '../../../../common/types'; +import * as event from '../../../../common/models/event'; +import { ResolverProcessType } from '../types'; /** * Returns true if the process's eventType is either 'processCreated' or 'processRan'. * Resolver will only render 'graphable' process events. */ -export function isGraphableProcess(passedEvent: LegacyEndpointEvent) { +export function isGraphableProcess(passedEvent: ResolverEvent) { return eventType(passedEvent) === 'processCreated' || eventType(passedEvent) === 'processRan'; } +function isValue(field: string | string[], value: string) { + if (field instanceof Array) { + return field.length === 1 && field[0] === value; + } else { + return field === value; + } +} + /** * Returns a custom event type for a process event based on the event's metadata. */ -export function eventType(passedEvent: LegacyEndpointEvent) { - const { - endgame: { event_type_full: type, event_subtype_full: subType }, - } = passedEvent; +export function eventType(passedEvent: ResolverEvent): ResolverProcessType { + if (event.isLegacyEvent(passedEvent)) { + const { + endgame: { event_type_full: type, event_subtype_full: subType }, + } = passedEvent; - if (type === 'process_event') { - if (subType === 'creation_event' || subType === 'fork_event' || subType === 'exec_event') { - return 'processCreated'; - } else if (subType === 'already_running') { - return 'processRan'; - } else if (subType === 'termination_event') { - return 'processTerminated'; - } else { - return 'unknownProcessEvent'; + if (type === 'process_event') { + if (subType === 'creation_event' || subType === 'fork_event' || subType === 'exec_event') { + return 'processCreated'; + } else if (subType === 'already_running') { + return 'processRan'; + } else if (subType === 'termination_event') { + return 'processTerminated'; + } else { + return 'unknownProcessEvent'; + } + } else if (type === 'alert_event') { + return 'processCausedAlert'; + } + } else { + const { + event: { type, category, kind }, + } = passedEvent; + if (isValue(category, 'process')) { + if (isValue(type, 'start') || isValue(type, 'change') || isValue(type, 'creation')) { + return 'processCreated'; + } else if (isValue(type, 'info')) { + return 'processRan'; + } else if (isValue(type, 'end')) { + return 'processTerminated'; + } else { + return 'unknownProcessEvent'; + } + } else if (kind === 'alert') { + return 'processCausedAlert'; } - } else if (type === 'alert_event') { - return 'processCausedAlert'; } return 'unknownEvent'; } @@ -41,13 +70,21 @@ export function eventType(passedEvent: LegacyEndpointEvent) { /** * Returns the process event's pid */ -export function uniquePidForProcess(event: LegacyEndpointEvent) { - return event.endgame.unique_pid; +export function uniquePidForProcess(passedEvent: ResolverEvent): string { + if (event.isLegacyEvent(passedEvent)) { + return String(passedEvent.endgame.unique_pid); + } else { + return passedEvent.process.entity_id; + } } /** * Returns the process event's parent pid */ -export function uniqueParentPidForProcess(event: LegacyEndpointEvent) { - return event.endgame.unique_ppid; +export function uniqueParentPidForProcess(passedEvent: ResolverEvent): string | undefined { + if (event.isLegacyEvent(passedEvent)) { + return String(passedEvent.endgame.unique_ppid); + } else { + return passedEvent.process.parent?.entity_id; + } } diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts index ecba0ec404d44..fec2078cc60c9 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts @@ -5,7 +5,7 @@ */ import { CameraAction } from './camera'; import { DataAction } from './data'; -import { LegacyEndpointEvent } from '../../../../common/types'; +import { ResolverEvent } from '../../../../common/types'; /** * When the user wants to bring a process node front-and-center on the map. @@ -16,7 +16,7 @@ interface UserBroughtProcessIntoView { /** * Used to identify the process node that should be brought into view. */ - readonly process: LegacyEndpointEvent; + readonly process: ResolverEvent; /** * The time (since epoch in milliseconds) when the action was dispatched. */ @@ -33,7 +33,7 @@ interface UserChangedSelectedEvent { /** * Optional because they could have unselected the event. */ - selectedEvent?: LegacyEndpointEvent; + readonly selectedEvent?: ResolverEvent; }; } diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts index f34d7c08ce08c..373afa89921dc 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyEndpointEvent } from '../../../../../common/types'; +import { ResolverEvent } from '../../../../../common/types'; interface ServerReturnedResolverData { readonly type: 'serverReturnedResolverData'; readonly payload: { readonly data: { readonly result: { - readonly search_results: readonly LegacyEndpointEvent[]; + readonly search_results: readonly ResolverEvent[]; }; }; }; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts index 304abbb06880b..e8007f82e30c2 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts @@ -14,7 +14,7 @@ import { ProcessWithWidthMetadata, Matrix3, } from '../../types'; -import { LegacyEndpointEvent } from '../../../../../common/types'; +import { ResolverEvent } from '../../../../../common/types'; import { Vector2 } from '../../types'; import { add as vector2Add, applyMatrix3 } from '../../lib/vector2'; import { isGraphableProcess } from '../../models/process_event'; @@ -112,7 +112,7 @@ export const graphableProcesses = createSelector( * */ function widthsOfProcessSubtrees(indexedProcessTree: IndexedProcessTree): ProcessWidths { - const widths = new Map(); + const widths = new Map(); if (size(indexedProcessTree) === 0) { return widths; @@ -313,13 +313,13 @@ function processPositions( indexedProcessTree: IndexedProcessTree, widths: ProcessWidths ): ProcessPositions { - const positions = new Map(); + const positions = new Map(); /** * This algorithm iterates the tree in level order. It keeps counters that are reset for each parent. * By keeping track of the last parent node, we can know when we are dealing with a new set of siblings and * reset the counters. */ - let lastProcessedParentNode: LegacyEndpointEvent | undefined; + let lastProcessedParentNode: ResolverEvent | undefined; /** * Nodes are positioned relative to their siblings. We walk this in level order, so we handle * children left -> right. @@ -424,7 +424,7 @@ export const processNodePositionsAndEdgeLineSegments = createSelector( * Transform the positions of nodes and edges so they seem like they are on an isometric grid. */ const transformedEdgeLineSegments: EdgeLineSegment[] = []; - const transformedPositions = new Map(); + const transformedPositions = new Map(); for (const [processEvent, position] of positions) { transformedPositions.set(processEvent, applyMatrix3(position, isometricTransformMatrix)); diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/methods.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/methods.ts index 9f06643626f50..f15307a662388 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/methods.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/methods.ts @@ -7,7 +7,7 @@ import { animatePanning } from './camera/methods'; import { processNodePositionsAndEdgeLineSegments } from './selectors'; import { ResolverState } from '../types'; -import { LegacyEndpointEvent } from '../../../../common/types'; +import { ResolverEvent } from '../../../../common/types'; const animationDuration = 1000; @@ -17,7 +17,7 @@ const animationDuration = 1000; export function animateProcessIntoView( state: ResolverState, startTime: number, - process: LegacyEndpointEvent + process: ResolverEvent ): ResolverState { const { processNodePositions } = processNodePositionsAndEdgeLineSegments(state); const position = processNodePositions.get(process); diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts index 900aece60618d..23e4a4fe7d7ed 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts @@ -8,6 +8,8 @@ import { Dispatch, MiddlewareAPI } from 'redux'; import { KibanaReactContextValue } from '../../../../../../../src/plugins/kibana_react/public'; import { EndpointPluginServices } from '../../../plugin'; import { ResolverState, ResolverAction } from '../types'; +import { ResolverEvent } from '../../../../common/types'; +import * as event from '../../../../common/models/event'; type MiddlewareFactory = ( context?: KibanaReactContextValue @@ -19,22 +21,54 @@ export const resolverMiddlewareFactory: MiddlewareFactory = context => { return api => next => async (action: ResolverAction) => { next(action); if (action.type === 'userChangedSelectedEvent') { - if (context?.services.http) { + /** + * concurrently fetches a process's details, its ancestors, and its related events. + */ + if (context?.services.http && action.payload.selectedEvent) { api.dispatch({ type: 'appRequestedResolverData' }); - const uniquePid = action.payload.selectedEvent?.endgame?.unique_pid; - const legacyEndpointID = action.payload.selectedEvent?.agent?.id; - const [{ lifecycle }, { children }, { events: relatedEvents }] = await Promise.all([ - context.services.http.get(`/api/endpoint/resolver/${uniquePid}`, { - query: { legacyEndpointID }, - }), - context.services.http.get(`/api/endpoint/resolver/${uniquePid}/children`, { - query: { legacyEndpointID }, - }), - context.services.http.get(`/api/endpoint/resolver/${uniquePid}/related`, { - query: { legacyEndpointID }, - }), - ]); - const response = [...lifecycle, ...children, ...relatedEvents]; + let response = []; + let lifecycle: ResolverEvent[]; + let childEvents: ResolverEvent[]; + let relatedEvents: ResolverEvent[]; + let children = []; + const ancestors: ResolverEvent[] = []; + const maxAncestors = 5; + if (event.isLegacyEvent(action.payload.selectedEvent)) { + const uniquePid = action.payload.selectedEvent?.endgame?.unique_pid; + const legacyEndpointID = action.payload.selectedEvent?.agent?.id; + [{ lifecycle }, { children }, { events: relatedEvents }] = await Promise.all([ + context.services.http.get(`/api/endpoint/resolver/${uniquePid}`, { + query: { legacyEndpointID }, + }), + context.services.http.get(`/api/endpoint/resolver/${uniquePid}/children`, { + query: { legacyEndpointID }, + }), + context.services.http.get(`/api/endpoint/resolver/${uniquePid}/related`, { + query: { legacyEndpointID }, + }), + ]); + childEvents = children.length > 0 ? children.map((child: any) => child.lifecycle) : []; + } else { + const uniquePid = action.payload.selectedEvent.process.entity_id; + const ppid = action.payload.selectedEvent.process.parent?.entity_id; + async function getAncestors(pid: string | undefined) { + if (ancestors.length < maxAncestors && pid !== undefined) { + const parent = await context?.services.http.get(`/api/endpoint/resolver/${pid}`); + ancestors.push(parent.lifecycle[0]); + if (parent.lifecycle[0].process?.parent?.entity_id) { + await getAncestors(parent.lifecycle[0].process.parent.entity_id); + } + } + } + [{ lifecycle }, { children }, { events: relatedEvents }] = await Promise.all([ + context.services.http.get(`/api/endpoint/resolver/${uniquePid}`), + context.services.http.get(`/api/endpoint/resolver/${uniquePid}/children`), + context.services.http.get(`/api/endpoint/resolver/${uniquePid}/related`), + getAncestors(ppid), + ]); + } + childEvents = children.length > 0 ? children.map((child: any) => child.lifecycle) : []; + response = [...lifecycle, ...childEvents, ...relatedEvents, ...ancestors]; api.dispatch({ type: 'serverReturnedResolverData', payload: { data: { result: { search_results: response } } }, diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts index 4c2a1ea5ac21f..4380d3ab98999 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts @@ -8,7 +8,7 @@ import { Store } from 'redux'; import { ResolverAction } from './store/actions'; export { ResolverAction } from './store/actions'; -import { LegacyEndpointEvent } from '../../../common/types'; +import { ResolverEvent } from '../../../common/types'; /** * Redux state for the Resolver feature. Properties on this interface are populated via multiple reducers using redux's `combineReducers`. @@ -115,7 +115,7 @@ export type CameraState = { * State for `data` reducer which handles receiving Resolver data from the backend. */ export interface DataState { - readonly results: readonly LegacyEndpointEvent[]; + readonly results: readonly ResolverEvent[]; isLoading: boolean; } @@ -184,21 +184,21 @@ export interface IndexedProcessTree { /** * Map of ID to a process's children */ - idToChildren: Map; + idToChildren: Map; /** * Map of ID to process */ - idToProcess: Map; + idToProcess: Map; } /** * A map of ProcessEvents (representing process nodes) to the 'width' of their subtrees as calculated by `widthsOfProcessSubtrees` */ -export type ProcessWidths = Map; +export type ProcessWidths = Map; /** * Map of ProcessEvents (representing process nodes) to their positions. Calculated by `processPositions` */ -export type ProcessPositions = Map; +export type ProcessPositions = Map; /** * An array of vectors2 forming an polyline. Used to connect process nodes in the graph. */ @@ -208,11 +208,11 @@ export type EdgeLineSegment = Vector2[]; * Used to provide precalculated info from `widthsOfProcessSubtrees`. These 'width' values are used in the layout of the graph. */ export type ProcessWithWidthMetadata = { - process: LegacyEndpointEvent; + process: ResolverEvent; width: number; } & ( | { - parent: LegacyEndpointEvent; + parent: ResolverEvent; parentWidth: number; isOnlyChild: boolean; firstChildWidth: number; @@ -275,4 +275,15 @@ export interface SideEffectSimulator { mock: jest.Mocked> & Pick; } +/** + * The internal types of process events used by resolver, mapped from v0 and v1 events. + */ +export type ResolverProcessType = + | 'processCreated' + | 'processRan' + | 'processTerminated' + | 'unknownProcessEvent' + | 'processCausedAlert' + | 'unknownEvent'; + export type ResolverStore = Store; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx index 52a0872f269f5..eab22f993d0a8 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx @@ -15,7 +15,7 @@ import { GraphControls } from './graph_controls'; import { ProcessEventDot } from './process_event_dot'; import { useCamera } from './use_camera'; import { ResolverAction } from '../types'; -import { LegacyEndpointEvent } from '../../../../common/types'; +import { ResolverEvent } from '../../../../common/types'; const StyledPanel = styled(Panel)` position: absolute; @@ -39,7 +39,7 @@ export const Resolver = styled( selectedEvent, }: { className?: string; - selectedEvent?: LegacyEndpointEvent; + selectedEvent?: ResolverEvent; }) { const { processNodePositions, edgeLineSegments } = useSelector( selectors.processNodePositionsAndEdgeLineSegments diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/panel.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/panel.tsx index 84c299698bb32..1250c1106b355 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/panel.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/panel.tsx @@ -11,7 +11,8 @@ import euiVars from '@elastic/eui/dist/eui_theme_light.json'; import { useSelector } from 'react-redux'; import { i18n } from '@kbn/i18n'; import { SideEffectContext } from './side_effect_context'; -import { LegacyEndpointEvent } from '../../../../common/types'; +import { ResolverEvent } from '../../../../common/types'; +import * as event from '../../../../common/models/event'; import { useResolverDispatch } from './use_resolver_dispatch'; import * as selectors from '../store/selectors'; @@ -38,7 +39,7 @@ export const Panel = memo(function Event({ className }: { className?: string }) interface ProcessTableView { name: string; timestamp?: Date; - event: LegacyEndpointEvent; + event: ResolverEvent; } const { processNodePositions } = useSelector(selectors.processNodePositionsAndEdgeLineSegments); @@ -48,14 +49,16 @@ export const Panel = memo(function Event({ className }: { className?: string }) () => [...processNodePositions.keys()].map(processEvent => { let dateTime; - if (processEvent.endgame.timestamp_utc) { - const date = new Date(processEvent.endgame.timestamp_utc); + const eventTime = event.eventTimestamp(processEvent); + const name = event.eventName(processEvent); + if (eventTime) { + const date = new Date(eventTime); if (isFinite(date.getTime())) { dateTime = date; } } return { - name: processEvent.endgame.process_name ? processEvent.endgame.process_name : '', + name, timestamp: dateTime, event: processEvent, }; @@ -115,9 +118,9 @@ export const Panel = memo(function Event({ className }: { className?: string }) }), dataType: 'date', sortable: true, - render(eventTimestamp?: Date) { - return eventTimestamp ? ( - formatter.format(eventTimestamp) + render(eventDate?: Date) { + return eventDate ? ( + formatter.format(eventDate) ) : ( {i18n.translate('xpack.endpoint.resolver.panel.tabel.row.timestampInvalidLabel', { diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx index 034780c7ba14c..2241df97291ae 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx @@ -8,7 +8,8 @@ import React from 'react'; import styled from 'styled-components'; import { applyMatrix3 } from '../lib/vector2'; import { Vector2, Matrix3 } from '../types'; -import { LegacyEndpointEvent } from '../../../../common/types'; +import { ResolverEvent } from '../../../../common/types'; +import * as eventModel from '../../../../common/models/event'; /** * A placeholder view for a process node. @@ -32,7 +33,7 @@ export const ProcessEventDot = styled( /** * An event which contains details about the process node. */ - event: LegacyEndpointEvent; + event: ResolverEvent; /** * projectionMatrix which can be used to convert `position` to screen coordinates. */ @@ -42,14 +43,13 @@ export const ProcessEventDot = styled( * Convert the position, which is in 'world' coordinates, to screen coordinates. */ const [left, top] = applyMatrix3(position, projectionMatrix); - const style = { left: (left - 20).toString() + 'px', top: (top - 20).toString() + 'px', }; return ( - - name: {event.endgame.process_name} + + name: {eventModel.eventName(event)}
x: {position[0]}
diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.test.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.test.tsx index 711e4f9a5c537..6e83fc19a922e 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.test.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/use_camera.test.tsx @@ -11,7 +11,7 @@ import { Provider } from 'react-redux'; import * as selectors from '../store/selectors'; import { storeFactory } from '../store'; import { Matrix3, ResolverAction, ResolverStore, SideEffectSimulator } from '../types'; -import { LegacyEndpointEvent } from '../../../../common/types'; +import { ResolverEvent } from '../../../../common/types'; import { SideEffectContext } from './side_effect_context'; import { applyMatrix3 } from '../lib/vector2'; import { sideEffectSimulator } from './side_effect_simulator'; @@ -133,9 +133,9 @@ describe('useCamera on an unpainted element', () => { expect(simulator.mock.requestAnimationFrame).not.toHaveBeenCalled(); }); describe('when the camera begins animation', () => { - let process: LegacyEndpointEvent; + let process: ResolverEvent; beforeEach(() => { - const events: LegacyEndpointEvent[] = []; + const events: ResolverEvent[] = []; const numberOfEvents: number = Math.floor(Math.random() * 10 + 1); for (let index = 0; index < numberOfEvents; index++) { @@ -164,7 +164,7 @@ describe('useCamera on an unpainted element', () => { act(() => { store.dispatch(serverResponseAction); }); - const processes: LegacyEndpointEvent[] = [ + const processes: ResolverEvent[] = [ ...selectors .processNodePositionsAndEdgeLineSegments(store.getState()) .processNodePositions.keys(), diff --git a/x-pack/plugins/endpoint/scripts/resolver_generator.ts b/x-pack/plugins/endpoint/scripts/resolver_generator.ts index 503999daec587..3d11ccaad005d 100644 --- a/x-pack/plugins/endpoint/scripts/resolver_generator.ts +++ b/x-pack/plugins/endpoint/scripts/resolver_generator.ts @@ -131,8 +131,13 @@ async function main() { process.exit(1); } } - - const generator = new EndpointDocGenerator(argv.seed); + let seed = argv.seed; + if (!seed) { + seed = Math.random().toString(); + // eslint-disable-next-line no-console + console.log('No seed supplied, using random seed: ' + seed); + } + const generator = new EndpointDocGenerator(seed); for (let i = 0; i < argv.numHosts; i++) { await client.index({ index: argv.metadataIndex, diff --git a/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts b/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts index 67a532d949e81..6d5ac8efdc1da 100644 --- a/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts +++ b/x-pack/plugins/endpoint/server/routes/resolver/utils/normalize.ts @@ -4,28 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ResolverEvent, LegacyEndpointEvent } from '../../../../common/types'; - -function isLegacyData(data: ResolverEvent): data is LegacyEndpointEvent { - return data.agent?.type === 'endgame'; -} +import { ResolverEvent } from '../../../../common/types'; +import { isLegacyEvent } from '../../../../common/models/event'; export function extractEventID(event: ResolverEvent) { - if (isLegacyData(event)) { + if (isLegacyEvent(event)) { return String(event.endgame.serial_event_id); } return event.event.id; } export function extractEntityID(event: ResolverEvent) { - if (isLegacyData(event)) { + if (isLegacyEvent(event)) { return String(event.endgame.unique_pid); } return event.process.entity_id; } export function extractParentEntityID(event: ResolverEvent) { - if (isLegacyData(event)) { + if (isLegacyEvent(event)) { const ppid = event.endgame.unique_ppid; return ppid && String(ppid); // if unique_ppid is undefined return undefined } diff --git a/x-pack/plugins/index_management/__mocks__/ui/notify.js b/x-pack/plugins/index_management/__mocks__/ui/notify.js index d508c3383d5f9..3d64a99232bc3 100644 --- a/x-pack/plugins/index_management/__mocks__/ui/notify.js +++ b/x-pack/plugins/index_management/__mocks__/ui/notify.js @@ -5,6 +5,7 @@ */ export const toastNotifications = { + addInfo: () => {}, addSuccess: () => {}, addDanger: () => {}, addWarning: () => {}, diff --git a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts index 97bdad23beb24..419ee021a9189 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts @@ -12,11 +12,11 @@ export const LOG_ENTRIES_PATH = '/api/log_entries/entries'; export const logEntriesBaseRequestRT = rt.intersection([ rt.type({ sourceId: rt.string, - startDate: rt.number, - endDate: rt.number, + startTimestamp: rt.number, + endTimestamp: rt.number, }), rt.partial({ - query: rt.string, + query: rt.union([rt.string, rt.null]), size: rt.number, }), ]); @@ -31,7 +31,7 @@ export const logEntriesAfterRequestRT = rt.intersection([ rt.type({ after: rt.union([logEntriesCursorRT, rt.literal('first')]) }), ]); -export const logEntriesCenteredRT = rt.intersection([ +export const logEntriesCenteredRequestRT = rt.intersection([ logEntriesBaseRequestRT, rt.type({ center: logEntriesCursorRT }), ]); @@ -40,38 +40,39 @@ export const logEntriesRequestRT = rt.union([ logEntriesBaseRequestRT, logEntriesBeforeRequestRT, logEntriesAfterRequestRT, - logEntriesCenteredRT, + logEntriesCenteredRequestRT, ]); +export type LogEntriesBaseRequest = rt.TypeOf; +export type LogEntriesBeforeRequest = rt.TypeOf; +export type LogEntriesAfterRequest = rt.TypeOf; +export type LogEntriesCenteredRequest = rt.TypeOf; export type LogEntriesRequest = rt.TypeOf; -// JSON value -const valueRT = rt.union([rt.string, rt.number, rt.boolean, rt.object, rt.null, rt.undefined]); +export const logMessageConstantPartRT = rt.type({ + constant: rt.string, +}); +export const logMessageFieldPartRT = rt.type({ + field: rt.string, + value: rt.unknown, + highlights: rt.array(rt.string), +}); -export const logMessagePartRT = rt.union([ - rt.type({ - constant: rt.string, - }), - rt.type({ - field: rt.string, - value: valueRT, - highlights: rt.array(rt.string), - }), -]); +export const logMessagePartRT = rt.union([logMessageConstantPartRT, logMessageFieldPartRT]); -export const logColumnRT = rt.union([ - rt.type({ columnId: rt.string, timestamp: rt.number }), - rt.type({ - columnId: rt.string, - field: rt.string, - value: rt.union([rt.string, rt.undefined]), - highlights: rt.array(rt.string), - }), - rt.type({ - columnId: rt.string, - message: rt.array(logMessagePartRT), - }), -]); +export const logTimestampColumnRT = rt.type({ columnId: rt.string, timestamp: rt.number }); +export const logFieldColumnRT = rt.type({ + columnId: rt.string, + field: rt.string, + value: rt.unknown, + highlights: rt.array(rt.string), +}); +export const logMessageColumnRT = rt.type({ + columnId: rt.string, + message: rt.array(logMessagePartRT), +}); + +export const logColumnRT = rt.union([logTimestampColumnRT, logFieldColumnRT, logMessageColumnRT]); export const logEntryRT = rt.type({ id: rt.string, @@ -79,15 +80,20 @@ export const logEntryRT = rt.type({ columns: rt.array(logColumnRT), }); -export type LogMessagepart = rt.TypeOf; +export type LogMessageConstantPart = rt.TypeOf; +export type LogMessageFieldPart = rt.TypeOf; +export type LogMessagePart = rt.TypeOf; +export type LogTimestampColumn = rt.TypeOf; +export type LogFieldColumn = rt.TypeOf; +export type LogMessageColumn = rt.TypeOf; export type LogColumn = rt.TypeOf; export type LogEntry = rt.TypeOf; export const logEntriesResponseRT = rt.type({ data: rt.type({ entries: rt.array(logEntryRT), - topCursor: logEntriesCursorRT, - bottomCursor: logEntriesCursorRT, + topCursor: rt.union([logEntriesCursorRT, rt.null]), + bottomCursor: rt.union([logEntriesCursorRT, rt.null]), }), }); diff --git a/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts b/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts index 516cd67f2764d..f6d61a7177b49 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts @@ -9,7 +9,7 @@ import { logEntriesBaseRequestRT, logEntriesBeforeRequestRT, logEntriesAfterRequestRT, - logEntriesCenteredRT, + logEntriesCenteredRequestRT, logEntryRT, } from './entries'; import { logEntriesCursorRT } from './common'; @@ -36,7 +36,7 @@ export const logEntriesHighlightsAfterRequestRT = rt.intersection([ ]); export const logEntriesHighlightsCenteredRequestRT = rt.intersection([ - logEntriesCenteredRT, + logEntriesCenteredRequestRT, highlightsRT, ]); diff --git a/x-pack/plugins/infra/common/http_api/log_entries/summary.ts b/x-pack/plugins/infra/common/http_api/log_entries/summary.ts index 4a2c0db0e995e..6af4b7c592ab6 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/summary.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/summary.ts @@ -10,8 +10,8 @@ export const LOG_ENTRIES_SUMMARY_PATH = '/api/log_entries/summary'; export const logEntriesSummaryRequestRT = rt.type({ sourceId: rt.string, - startDate: rt.number, - endDate: rt.number, + startTimestamp: rt.number, + endTimestamp: rt.number, bucketSize: rt.number, query: rt.union([rt.string, rt.undefined, rt.null]), }); diff --git a/x-pack/plugins/infra/public/components/logging/log_datepicker.tsx b/x-pack/plugins/infra/public/components/logging/log_datepicker.tsx new file mode 100644 index 0000000000000..e80f738eac6ba --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_datepicker.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSuperDatePicker, EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface LogDatepickerProps { + startDateExpression: string; + endDateExpression: string; + isStreaming: boolean; + onUpdateDateRange?: (range: { startDateExpression: string; endDateExpression: string }) => void; + onStartStreaming?: () => void; + onStopStreaming?: () => void; +} + +export const LogDatepicker: React.FC = ({ + startDateExpression, + endDateExpression, + isStreaming, + onUpdateDateRange, + onStartStreaming, + onStopStreaming, +}) => { + const handleTimeChange = useCallback( + ({ start, end, isInvalid }) => { + if (onUpdateDateRange && !isInvalid) { + onUpdateDateRange({ startDateExpression: start, endDateExpression: end }); + } + }, + [onUpdateDateRange] + ); + + return ( + + + + + + {isStreaming ? ( + + + + ) : ( + + + + )} + + + ); +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx index 729689e65739e..2bdb1f91a6dde 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/density_chart.tsx @@ -10,10 +10,10 @@ import { max } from 'lodash'; import * as React from 'react'; import { euiStyled } from '../../../../../observability/public'; -import { SummaryBucket } from './types'; +import { LogEntriesSummaryBucket } from '../../../../common/http_api'; interface DensityChartProps { - buckets: SummaryBucket[]; + buckets: LogEntriesSummaryBucket[]; end: number; start: number; width: number; @@ -38,36 +38,36 @@ export const DensityChart: React.FC = ({ const xMax = max(buckets.map(bucket => bucket.entriesCount)) || 0; const xScale = scaleLinear() .domain([0, xMax]) - .range([0, width * (2 / 3)]); + .range([0, width]); - const path = area() + const path = area() .x0(xScale(0)) .x1(bucket => xScale(bucket.entriesCount)) - .y(bucket => yScale((bucket.start + bucket.end) / 2)) + .y0(bucket => yScale(bucket.start)) + .y1(bucket => yScale(bucket.end)) .curve(curveMonotoneY); - const pathData = path(buckets); - const highestPathCoord = String(pathData) - .replace(/[^.0-9,]/g, ' ') - .split(/[ ,]/) - .reduce((result, num) => (Number(num) > result ? Number(num) : result), 0); + const firstBucket = buckets[0]; + const lastBucket = buckets[buckets.length - 1]; + const pathBuckets = [ + // Make sure the graph starts at the count of the first point + { start, end: start, entriesCount: firstBucket.entriesCount }, + ...buckets, + // Make sure the line ends at the height of the last point + { start: lastBucket.end, end: lastBucket.end, entriesCount: lastBucket.entriesCount }, + // If the last point is not at the end of the minimap, make sure it doesn't extend indefinitely and goes to 0 + { start: end, end, entriesCount: 0 }, + ]; + const pathData = path(pathBuckets); + return ( - - - + + ); }; -const DensityChartNegativeBackground = euiStyled.rect` - fill: ${props => props.theme.eui.euiColorEmptyShade}; -`; - const DensityChartPositiveBackground = euiStyled.rect` fill: ${props => props.theme.darkMode diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx index 2e45bcea42109..975e83e0075ff 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/highlighted_interval.tsx @@ -13,6 +13,7 @@ interface HighlightedIntervalProps { getPositionOfTime: (time: number) => number; start: number; end: number; + targetWidth: number; width: number; target: number | null; } @@ -22,6 +23,7 @@ export const HighlightedInterval: React.FC = ({ end, getPositionOfTime, start, + targetWidth, width, target, }) => { @@ -35,14 +37,14 @@ export const HighlightedInterval: React.FC = ({ )} ); diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx index e3a7e5aa30633..c67674d198a3f 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/log_minimap.tsx @@ -13,42 +13,40 @@ import { DensityChart } from './density_chart'; import { HighlightedInterval } from './highlighted_interval'; import { SearchMarkers } from './search_markers'; import { TimeRuler } from './time_ruler'; -import { SummaryBucket, SummaryHighlightBucket } from './types'; +import { + LogEntriesSummaryBucket, + LogEntriesSummaryHighlightsBucket, +} from '../../../../common/http_api'; interface Interval { end: number; start: number; } -interface DragRecord { - startY: number; - currentY: number | null; -} - interface LogMinimapProps { className?: string; height: number; highlightedInterval: Interval | null; jumpToTarget: (params: LogEntryTime) => any; - intervalSize: number; - summaryBuckets: SummaryBucket[]; - summaryHighlightBuckets?: SummaryHighlightBucket[]; + summaryBuckets: LogEntriesSummaryBucket[]; + summaryHighlightBuckets?: LogEntriesSummaryHighlightsBucket[]; target: number | null; + start: number | null; + end: number | null; width: number; } interface LogMinimapState { target: number | null; - drag: DragRecord | null; - svgPosition: ClientRect; timeCursorY: number; } -function calculateYScale(target: number | null, height: number, intervalSize: number) { - const domainStart = target ? target - intervalSize / 2 : 0; - const domainEnd = target ? target + intervalSize / 2 : 0; +// Wide enough to fit "September" +const TIMERULER_WIDTH = 50; + +function calculateYScale(start: number | null, end: number | null, height: number) { return scaleLinear() - .domain([domainStart, domainEnd]) + .domain([start || 0, end || 0]) .range([0, height]); } @@ -58,103 +56,28 @@ export class LogMinimap extends React.Component = event => { + const minimapTop = event.currentTarget.getBoundingClientRect().top; + const clickedYPosition = event.clientY - minimapTop; - public handleClick = (event: MouseEvent) => { - if (!this.dragTargetArea) return; - const svgPosition = this.dragTargetArea.getBoundingClientRect(); - const clickedYPosition = event.clientY - svgPosition.top; const clickedTime = Math.floor(this.getYScale().invert(clickedYPosition)); - this.setState({ - drag: null, - }); - this.props.jumpToTarget({ - tiebreaker: 0, - time: clickedTime, - }); - }; - - private handleMouseDown: React.MouseEventHandler = event => { - const { clientY, target } = event; - if (target === this.dragTargetArea) { - const svgPosition = event.currentTarget.getBoundingClientRect(); - this.setState({ - drag: { - startY: clientY, - currentY: null, - }, - svgPosition, - }); - window.addEventListener('mousemove', this.handleDragMove); - } - window.addEventListener('mouseup', this.handleMouseUp); - }; - - private handleMouseUp = (event: MouseEvent) => { - window.removeEventListener('mousemove', this.handleDragMove); - window.removeEventListener('mouseup', this.handleMouseUp); - const { drag, svgPosition } = this.state; - if (!drag || !drag.currentY) { - this.handleClick(event); - return; - } - const getTime = (pos: number) => Math.floor(this.getYScale().invert(pos)); - const startYPosition = drag.startY - svgPosition.top; - const endYPosition = event.clientY - svgPosition.top; - const startTime = getTime(startYPosition); - const endTime = getTime(endYPosition); - const timeDifference = endTime - startTime; - const newTime = (this.props.target || 0) - timeDifference; - this.setState({ drag: null, target: newTime }); this.props.jumpToTarget({ tiebreaker: 0, - time: newTime, - }); - }; - - private handleDragMove = (event: MouseEvent) => { - const { drag } = this.state; - if (!drag) return; - this.setState({ - drag: { - ...drag, - currentY: event.clientY, - }, + time: clickedTime, }); }; public getYScale = () => { - const { target } = this.state; - const { height, intervalSize } = this.props; - return calculateYScale(target, height, intervalSize); + const { start, end, height } = this.props; + return calculateYScale(start, end, height); }; public getPositionOfTime = (time: number) => { - const { height, intervalSize } = this.props; - - const [minTime] = this.getYScale().domain(); - - return ((time - minTime) * height) / intervalSize; // + return this.getYScale()(time); }; private updateTimeCursor: React.MouseEventHandler = event => { @@ -166,6 +89,8 @@ export class LogMinimap extends React.Component - + + + - - - + {highlightedInterval ? ( ) : null} - - { - this.dragTargetArea = node; - }} - x={0} - y={0} - width={width / 3} - height={height} - /> + ); } } -const DragTargetArea = euiStyled.rect<{ isGrabbing: boolean }>` - fill: transparent; - cursor: ${({ isGrabbing }) => (isGrabbing ? 'grabbing' : 'grab')}; -`; - const MinimapBorder = euiStyled.line` stroke: ${props => props.theme.eui.euiColorMediumShade}; stroke-width: 1px; @@ -269,9 +170,9 @@ const TimeCursor = euiStyled.line` : props.theme.eui.euiColorDarkShade}; `; -const MinimapWrapper = euiStyled.svg<{ showOverscanBoundaries: boolean }>` - background: ${props => - props.showOverscanBoundaries ? props.theme.eui.euiColorMediumShade : 'transparent'}; +const MinimapWrapper = euiStyled.svg` + cursor: pointer; + fill: ${props => props.theme.eui.euiColorEmptyShade}; & ${TimeCursor} { visibility: hidden; } diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx index 8b87aa15f16f0..18d4a3bbfc8b3 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/search_marker.tsx @@ -10,10 +10,9 @@ import * as React from 'react'; import { euiStyled, keyframes } from '../../../../../observability/public'; import { LogEntryTime } from '../../../../common/log_entry'; import { SearchMarkerTooltip } from './search_marker_tooltip'; -import { SummaryHighlightBucket } from './types'; - +import { LogEntriesSummaryHighlightsBucket } from '../../../../common/http_api'; interface SearchMarkerProps { - bucket: SummaryHighlightBucket; + bucket: LogEntriesSummaryHighlightsBucket; height: number; width: number; jumpToTarget: (target: LogEntryTime) => void; diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx index ebdc390aef11b..1e254d999036e 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/search_markers.tsx @@ -10,10 +10,10 @@ import * as React from 'react'; import { LogEntryTime } from '../../../../common/log_entry'; import { SearchMarker } from './search_marker'; -import { SummaryHighlightBucket } from './types'; +import { LogEntriesSummaryHighlightsBucket } from '../../../../common/http_api'; interface SearchMarkersProps { - buckets: SummaryHighlightBucket[]; + buckets: LogEntriesSummaryHighlightsBucket[]; className?: string; end: number; start: number; diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/time_label_formatter.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/time_label_formatter.tsx new file mode 100644 index 0000000000000..af981105d1718 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/time_label_formatter.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// The default d3-time-format is a bit strange for small ranges, so we will specify our own +export function getTimeLabelFormat(start: number, end: number): string | undefined { + const diff = Math.abs(end - start); + + // 15 seconds + if (diff < 15 * 1000) { + return ':%S.%L'; + } + + // 16 minutes + if (diff < 16 * 60 * 1000) { + return '%I:%M:%S'; + } + + // Use D3's default + return; +} diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx index b610737663e8d..454935c32fe1e 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_minimap/time_ruler.tsx @@ -8,6 +8,7 @@ import { scaleTime } from 'd3-scale'; import * as React from 'react'; import { euiStyled } from '../../../../../observability/public'; +import { getTimeLabelFormat } from './time_label_formatter'; interface TimeRulerProps { end: number; @@ -23,37 +24,19 @@ export const TimeRuler: React.FC = ({ end, height, start, tickCo .range([0, height]); const ticks = yScale.ticks(tickCount); - const formatTick = yScale.tickFormat(); - - const dateModLabel = (() => { - for (let i = 0; i < ticks.length; i++) { - const tickLabel = formatTick(ticks[i]); - if (!tickLabel[0].match(/[0-9]/)) { - return i % 12; - } - } - })(); + const formatTick = yScale.tickFormat(tickCount, getTimeLabelFormat(start, end)); return ( {ticks.map((tick, tickIndex) => { const y = yScale(tick); - const isLabeledTick = tickIndex % 12 === dateModLabel; - const tickStartX = isLabeledTick ? 0 : width / 3 - 4; + return ( - {isLabeledTick && ( - - {formatTick(tick)} - - )} - + + {formatTick(tick)} + + ); })} @@ -71,15 +54,11 @@ const TimeRulerTickLabel = euiStyled.text` pointer-events: none; `; -const TimeRulerGridLine = euiStyled.line<{ isDark: boolean }>` +const TimeRulerGridLine = euiStyled.line` stroke: ${props => - props.isDark - ? props.theme.darkMode - ? props.theme.eui.euiColorDarkestShade - : props.theme.eui.euiColorDarkShade - : props.theme.darkMode - ? props.theme.eui.euiColorDarkShade - : props.theme.eui.euiColorMediumShade}; + props.theme.darkMode + ? props.theme.eui.euiColorDarkestShade + : props.theme.eui.euiColorDarkShade}; stroke-opacity: 0.5; stroke-width: 1px; `; diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap_scale_controls.tsx b/x-pack/plugins/infra/public/components/logging/log_minimap_scale_controls.tsx deleted file mode 100644 index 41c6e554e603a..0000000000000 --- a/x-pack/plugins/infra/public/components/logging/log_minimap_scale_controls.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiFormRow, EuiRadioGroup } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import * as React from 'react'; - -interface IntervalSizeDescriptor { - label: string; - intervalSize: number; -} - -interface LogMinimapScaleControlsProps { - availableIntervalSizes: IntervalSizeDescriptor[]; - intervalSize: number; - setIntervalSize: (intervalSize: number) => any; -} - -export class LogMinimapScaleControls extends React.PureComponent { - public handleScaleChange = (intervalSizeDescriptorKey: string) => { - const { availableIntervalSizes, setIntervalSize } = this.props; - const [sizeDescriptor] = availableIntervalSizes.filter( - intervalKeyEquals(intervalSizeDescriptorKey) - ); - - if (sizeDescriptor) { - setIntervalSize(sizeDescriptor.intervalSize); - } - }; - - public render() { - const { availableIntervalSizes, intervalSize } = this.props; - const [currentSizeDescriptor] = availableIntervalSizes.filter(intervalSizeEquals(intervalSize)); - - return ( - - } - > - ({ - id: getIntervalSizeDescriptorKey(sizeDescriptor), - label: sizeDescriptor.label, - }))} - onChange={this.handleScaleChange} - idSelected={getIntervalSizeDescriptorKey(currentSizeDescriptor)} - /> - - ); - } -} - -const getIntervalSizeDescriptorKey = (sizeDescriptor: IntervalSizeDescriptor) => - `${sizeDescriptor.intervalSize}`; - -const intervalKeyEquals = (key: string) => (sizeDescriptor: IntervalSizeDescriptor) => - getIntervalSizeDescriptorKey(sizeDescriptor) === key; - -const intervalSizeEquals = (size: number) => (sizeDescriptor: IntervalSizeDescriptor) => - sizeDescriptor.intervalSize === size; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts b/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts index ca5ca9736b7b3..19e8108ee50e8 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts @@ -7,27 +7,27 @@ import { bisector } from 'd3-array'; import { compareToTimeKey, TimeKey } from '../../../../common/time'; -import { LogEntry, LogEntryHighlight } from '../../../utils/log_entry'; +import { LogEntry } from '../../../../common/http_api'; export type StreamItem = LogEntryStreamItem; export interface LogEntryStreamItem { kind: 'logEntry'; logEntry: LogEntry; - highlights: LogEntryHighlight[]; + highlights: LogEntry[]; } export function getStreamItemTimeKey(item: StreamItem) { switch (item.kind) { case 'logEntry': - return item.logEntry.key; + return item.logEntry.cursor; } } export function getStreamItemId(item: StreamItem) { switch (item.kind) { case 'logEntry': - return `${item.logEntry.key.time}:${item.logEntry.key.tiebreaker}:${item.logEntry.gid}`; + return `${item.logEntry.cursor.time}:${item.logEntry.cursor.tiebreaker}:${item.logEntry.id}`; } } diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx index 8c48d9e176d3b..5598528c0e0f5 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/loading_item_view.tsx @@ -6,144 +6,279 @@ /* eslint-disable max-classes-per-file */ -import { EuiButtonEmpty, EuiIcon, EuiProgress, EuiText } from '@elastic/eui'; -import { FormattedMessage, FormattedRelative } from '@kbn/i18n/react'; +import { + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiLoadingSpinner, + EuiButton, +} from '@elastic/eui'; +import { FormattedMessage, FormattedTime, FormattedRelative } from '@kbn/i18n/react'; import * as React from 'react'; +import { Unit } from '@elastic/datemath'; import { euiStyled } from '../../../../../observability/public'; +import { LogTextSeparator } from './log_text_separator'; +import { extendDatemath } from '../../../utils/datemath'; + +type Position = 'start' | 'end'; interface LogTextStreamLoadingItemViewProps { - alignment: 'top' | 'bottom'; + position: Position; + timestamp: number; // Either the top of the bottom's cursor timestamp + startDateExpression: string; + endDateExpression: string; className?: string; hasMore: boolean; isLoading: boolean; isStreaming: boolean; - lastStreamingUpdate: Date | null; - onLoadMore?: () => void; + onExtendRange?: (newDate: string) => void; + onStreamStart?: () => void; } +const TIMESTAMP_FORMAT = { + hour12: false, + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', +}; + export class LogTextStreamLoadingItemView extends React.PureComponent< LogTextStreamLoadingItemViewProps, {} > { public render() { const { - alignment, + position, + timestamp, + startDateExpression, + endDateExpression, className, hasMore, isLoading, isStreaming, - lastStreamingUpdate, - onLoadMore, + onExtendRange, + onStreamStart, } = this.props; - if (isStreaming) { - return ( - - - - - - - {lastStreamingUpdate ? ( - - - - - ), - }} - /> - - - ) : null} - - ); - } else if (isLoading) { - return ( - - - - - - ); - } else if (!hasMore) { - return ( - - - - - {onLoadMore ? ( - - - - ) : null} - - ); - } else { - return null; - } + const shouldShowCta = !hasMore && !isStreaming; + + const extra = ( + + {isLoading || isStreaming ? ( + + ) : shouldShowCta ? ( + + ) : null} + + ); + + return ( + + {position === 'start' ? extra : null} + + {position === 'end' ? extra : null} + + ); } } -interface ProgressEntryProps { - alignment: 'top' | 'bottom'; - className?: string; - color: 'subdued' | 'primary'; - isLoading: boolean; -} +const LoadingItemViewExtra = euiStyled(EuiFlexGroup)` + height: 40px; +`; -const ProgressEntry: React.FC = props => { - const { alignment, children, className, color, isLoading } = props; +const ProgressEntryWrapper = euiStyled.div<{ position: Position }>` + padding-left: ${props => props.theme.eui.euiSizeS}; + padding-top: ${props => + props.position === 'start' ? props.theme.eui.euiSizeL : props.theme.eui.euiSizeM}; + padding-bottom: ${props => + props.position === 'end' ? props.theme.eui.euiSizeL : props.theme.eui.euiSizeM}; +`; - // NOTE: styled-components seems to make all props in EuiProgress required, so this - // style attribute hacking replaces styled-components here for now until that can be fixed - // see: https://github.com/elastic/eui/issues/1655 - const alignmentStyle = - alignment === 'top' ? { top: 0, bottom: 'initial' } : { top: 'initial', bottom: 0 }; +type ProgressMessageProps = Pick< + LogTextStreamLoadingItemViewProps, + 'timestamp' | 'position' | 'isStreaming' +>; +const ProgressMessage: React.FC = ({ timestamp, position, isStreaming }) => { + const formattedTimestamp = + isStreaming && position === 'end' ? ( + + ) : ( + + ); - return ( - - + ) : isStreaming ? ( + + ) : ( + - {children} - + ); + + return ( + + {message} + ); }; -const ProgressEntryWrapper = euiStyled.div` - align-items: center; - display: flex; - min-height: ${props => props.theme.eui.euiSizeXXL}; - position: relative; -`; +const ProgressSpinner: React.FC<{ kind: 'streaming' | 'loading' }> = ({ kind }) => ( + <> + + + + + + {kind === 'streaming' ? ( + + ) : ( + + )} + + + +); -const ProgressMessage = euiStyled.div` - padding: 8px 16px; -`; +type ProgressCtaProps = Pick< + LogTextStreamLoadingItemViewProps, + 'position' | 'startDateExpression' | 'endDateExpression' | 'onExtendRange' | 'onStreamStart' +>; +const ProgressCta: React.FC = ({ + position, + startDateExpression, + endDateExpression, + onExtendRange, + onStreamStart, +}) => { + const rangeEdge = position === 'start' ? startDateExpression : endDateExpression; + + if (rangeEdge === 'now' && position === 'end') { + return ( + + + + ); + } + + const iconType = position === 'start' ? 'arrowUp' : 'arrowDown'; + const extendedRange = + position === 'start' + ? extendDatemath(startDateExpression, 'before', endDateExpression) + : extendDatemath(endDateExpression, 'after', startDateExpression); + if (!extendedRange || !('diffUnit' in extendedRange)) { + return null; + } + + return ( + { + if (typeof onExtendRange === 'function') { + onExtendRange(extendedRange.value); + } + }} + iconType={iconType} + size="s" + > + + + ); +}; + +const ProgressExtendMessage: React.FC<{ amount: number; unit: Unit }> = ({ amount, unit }) => { + switch (unit) { + case 'ms': + return ( + + ); + case 's': + return ( + + ); + case 'm': + return ( + + ); + case 'h': + return ( + + ); + case 'd': + return ( + + ); + case 'w': + return ( + + ); + case 'M': + return ( + + ); + case 'y': + return ( + + ); + default: + throw new TypeError('Unhandled unit: ' + unit); + } +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx index 5d295ca7e4817..5fc4606a774d5 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx @@ -8,15 +8,16 @@ import { mount } from 'enzyme'; import React from 'react'; import { EuiThemeProvider } from '../../../../../observability/public'; -import { LogEntryColumn } from '../../../utils/log_entry'; import { LogEntryFieldColumn } from './log_entry_field_column'; +import { LogColumn } from '../../../../common/http_api'; describe('LogEntryFieldColumn', () => { it('should output a
    when displaying an Array of values', () => { - const column: LogEntryColumn = { + const column: LogColumn = { columnId: 'TEST_COLUMN', field: 'TEST_FIELD', - value: JSON.stringify(['a', 'b', 'c']), + value: ['a', 'b', 'c'], + highlights: [], }; const component = mount( @@ -42,13 +43,14 @@ describe('LogEntryFieldColumn', () => { }); it('should output a text representation of a passed complex value', () => { - const column: LogEntryColumn = { + const column: LogColumn = { columnId: 'TEST_COLUMN', field: 'TEST_FIELD', - value: JSON.stringify({ + value: { lat: 1, lon: 2, - }), + }, + highlights: [], }; const component = mount( @@ -67,10 +69,11 @@ describe('LogEntryFieldColumn', () => { }); it('should output just text when passed a non-Array', () => { - const column: LogEntryColumn = { + const column: LogColumn = { columnId: 'TEST_COLUMN', field: 'TEST_FIELD', - value: JSON.stringify('foo'), + value: 'foo', + highlights: [], }; const component = mount( diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx index c6584f2fdbb6d..202108cda5ac0 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx @@ -8,14 +8,10 @@ import stringify from 'json-stable-stringify'; import React, { useMemo } from 'react'; import { euiStyled } from '../../../../../observability/public'; -import { - isFieldColumn, - isHighlightFieldColumn, - LogEntryColumn, - LogEntryHighlightColumn, -} from '../../../utils/log_entry'; +import { isFieldColumn, isHighlightFieldColumn } from '../../../utils/log_entry'; import { ActiveHighlightMarker, highlightFieldValue, HighlightMarker } from './highlighting'; import { LogEntryColumnContent } from './log_entry_column'; +import { LogColumn } from '../../../../common/http_api'; import { hoveredContentStyle, longWrappedContentStyle, @@ -25,8 +21,8 @@ import { } from './text_styles'; interface LogEntryFieldColumnProps { - columnValue: LogEntryColumn; - highlights: LogEntryHighlightColumn[]; + columnValue: LogColumn; + highlights: LogColumn[]; isActiveHighlight: boolean; isHighlighted: boolean; isHovered: boolean; @@ -41,9 +37,12 @@ export const LogEntryFieldColumn: React.FunctionComponent { - const value = useMemo(() => (isFieldColumn(columnValue) ? JSON.parse(columnValue.value) : null), [ - columnValue, - ]); + const value = useMemo(() => { + if (isFieldColumn(columnValue)) { + return columnValue.value; + } + return null; + }, [columnValue]); const formattedValue = Array.isArray(value) ? (
      {value.map((entry, i) => ( @@ -58,7 +57,7 @@ export const LogEntryFieldColumn: React.FunctionComponent ) : ( highlightFieldValue( - typeof value === 'object' && value != null ? stringify(value) : value, + typeof value === 'string' ? value : stringify(value), isHighlightFieldColumn(firstHighlight) ? firstHighlight.highlights : [], isActiveHighlight ? ActiveHighlightMarker : HighlightMarker ) diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx index 122f0fe472c6e..5ad7cba6427d1 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx @@ -5,6 +5,7 @@ */ import React, { memo, useMemo } from 'react'; +import stringify from 'json-stable-stringify'; import { euiStyled } from '../../../../../observability/public'; import { @@ -12,9 +13,7 @@ import { isFieldSegment, isHighlightMessageColumn, isMessageColumn, - LogEntryColumn, - LogEntryHighlightColumn, - LogEntryMessageSegment, + isHighlightFieldSegment, } from '../../../utils/log_entry'; import { ActiveHighlightMarker, highlightFieldValue, HighlightMarker } from './highlighting'; import { LogEntryColumnContent } from './log_entry_column'; @@ -25,10 +24,11 @@ import { unwrappedContentStyle, WrapMode, } from './text_styles'; +import { LogColumn, LogMessagePart } from '../../../../common/http_api'; interface LogEntryMessageColumnProps { - columnValue: LogEntryColumn; - highlights: LogEntryHighlightColumn[]; + columnValue: LogColumn; + highlights: LogColumn[]; isActiveHighlight: boolean; isHighlighted: boolean; isHovered: boolean; @@ -72,28 +72,39 @@ const MessageColumnContent = euiStyled(LogEntryColumnContent) messageSegments.map((messageSegment, index) => formatMessageSegment( messageSegment, - highlights.map(highlight => - isHighlightMessageColumn(highlight) ? highlight.message[index].highlights : [] - ), + highlights.map(highlight => { + if (isHighlightMessageColumn(highlight)) { + const segment = highlight.message[index]; + if (isHighlightFieldSegment(segment)) { + return segment.highlights; + } + } + return []; + }), isActiveHighlight ) ); const formatMessageSegment = ( - messageSegment: LogEntryMessageSegment, + messageSegment: LogMessagePart, [firstHighlight = []]: string[][], // we only support one highlight for now isActiveHighlight: boolean ): React.ReactNode => { if (isFieldSegment(messageSegment)) { + const value = + typeof messageSegment.value === 'string' + ? messageSegment.value + : stringify(messageSegment.value); + return highlightFieldValue( - messageSegment.value, + value, firstHighlight, isActiveHighlight ? ActiveHighlightMarker : HighlightMarker ); diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx index e5e3740f420e8..ce264245d385b 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx @@ -7,12 +7,7 @@ import React, { memo, useState, useCallback, useMemo } from 'react'; import { euiStyled } from '../../../../../observability/public'; -import { - LogEntry, - LogEntryHighlight, - LogEntryHighlightColumn, - isTimestampColumn, -} from '../../../utils/log_entry'; +import { isTimestampColumn } from '../../../utils/log_entry'; import { LogColumnConfiguration, isTimestampLogColumnConfiguration, @@ -26,12 +21,13 @@ import { LogEntryDetailsIconColumn } from './log_entry_icon_column'; import { LogEntryMessageColumn } from './log_entry_message_column'; import { LogEntryTimestampColumn } from './log_entry_timestamp_column'; import { monospaceTextStyle } from './text_styles'; +import { LogEntry, LogColumn } from '../../../../common/http_api'; interface LogEntryRowProps { boundingBoxRef?: React.Ref; columnConfigurations: LogColumnConfiguration[]; columnWidths: LogEntryColumnWidths; - highlights: LogEntryHighlight[]; + highlights: LogEntry[]; isActiveHighlight: boolean; isHighlighted: boolean; logEntry: LogEntry; @@ -63,9 +59,9 @@ export const LogEntryRow = memo( setIsHovered(false); }, []); - const openFlyout = useCallback(() => openFlyoutWithItem?.(logEntry.gid), [ + const openFlyout = useCallback(() => openFlyoutWithItem?.(logEntry.id), [ openFlyoutWithItem, - logEntry.gid, + logEntry.id, ]); const logEntryColumnsById = useMemo( @@ -85,7 +81,7 @@ export const LogEntryRow = memo( const highlightsByColumnId = useMemo( () => highlights.reduce<{ - [columnId: string]: LogEntryHighlightColumn[]; + [columnId: string]: LogColumn[]; }>( (columnsById, highlight) => highlight.columns.reduce( diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_text_separator.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_text_separator.tsx new file mode 100644 index 0000000000000..9cc91fa11e4ed --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_text_separator.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; + +/** + * Create a separator with a text on the right side + */ +export const LogTextSeparator: React.FC = ({ children }) => { + return ( + + {children} + + + + + ); +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx index 6544a32ba414c..2c389b47fa6cf 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx @@ -54,6 +54,10 @@ interface ScrollableLogTextStreamViewProps { setFlyoutVisibility: (visible: boolean) => void; highlightedItem: string | null; currentHighlightKey: UniqueTimeKey | null; + startDateExpression: string; + endDateExpression: string; + updateDateRange: (range: { startDateExpression?: string; endDateExpression?: string }) => void; + startLiveStreaming: () => void; } interface ScrollableLogTextStreamViewState { @@ -90,7 +94,7 @@ export class ScrollableLogTextStreamView extends React.PureComponent< targetId: getStreamItemId(getStreamItemBeforeTimeKey(nextProps.items, nextProps.target!)), items: nextItems, }; - } else if (!nextProps.target || !hasItems) { + } else if (!hasItems) { return { target: null, targetId: null, @@ -129,9 +133,13 @@ export class ScrollableLogTextStreamView extends React.PureComponent< isLoadingMore, isReloading, isStreaming, - lastLoadedTime, scale, wrap, + startDateExpression, + endDateExpression, + lastLoadedTime, + updateDateRange, + startLiveStreaming, } = this.props; const { targetId, items, isScrollLocked } = this.state; const hasItems = items.length > 0; @@ -184,72 +192,88 @@ export class ScrollableLogTextStreamView extends React.PureComponent< isLocked={isScrollLocked} entriesCount={items.length} > - {registerChild => ( - <> - - {items.map((item, idx) => { - const currentTimestamp = item.logEntry.key.time; - let showDate = false; + {registerChild => + items.length > 0 ? ( + <> + + updateDateRange({ startDateExpression: newDateExpression }) + } + /> + {items.map((item, idx) => { + const currentTimestamp = item.logEntry.cursor.time; + let showDate = false; - if (idx > 0) { - const prevTimestamp = items[idx - 1].logEntry.key.time; - showDate = !moment(currentTimestamp).isSame(prevTimestamp, 'day'); - } + if (idx > 0) { + const prevTimestamp = items[idx - 1].logEntry.cursor.time; + showDate = !moment(currentTimestamp).isSame(prevTimestamp, 'day'); + } - return ( - - {showDate && } - - {itemMeasureRef => ( - - )} - - - ); - })} - - {isScrollLocked && ( - + {showDate && } + + {itemMeasureRef => ( + + )} + + + ); + })} + + updateDateRange({ endDateExpression: newDateExpression }) + } + onStreamStart={() => startLiveStreaming()} /> - )} - - )} + {isScrollLocked && ( + + )} + + ) : null + } )} @@ -275,14 +299,6 @@ export class ScrollableLogTextStreamView extends React.PureComponent< } }; - private handleLoadNewerItems = () => { - const { loadNewerItems } = this.props; - - if (loadNewerItems) { - loadNewerItems(); - } - }; - // this is actually a method but not recognized as such // eslint-disable-next-line @typescript-eslint/member-ordering private handleVisibleChildrenChange = callWithoutRepeats( diff --git a/x-pack/plugins/infra/public/components/logging/log_time_controls.tsx b/x-pack/plugins/infra/public/components/logging/log_time_controls.tsx deleted file mode 100644 index 3653a6d6bbeae..0000000000000 --- a/x-pack/plugins/infra/public/components/logging/log_time_controls.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiDatePicker, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import moment, { Moment } from 'moment'; -import React from 'react'; -import { FixedDatePicker } from '../fixed_datepicker'; - -const noop = () => undefined; - -interface LogTimeControlsProps { - currentTime: number | null; - startLiveStreaming: () => any; - stopLiveStreaming: () => void; - isLiveStreaming: boolean; - jumpToTime: (time: number) => any; -} - -export class LogTimeControls extends React.PureComponent { - public render() { - const { currentTime, isLiveStreaming } = this.props; - - const currentMoment = currentTime ? moment(currentTime) : null; - if (isLiveStreaming) { - return ( - - - - - - - - - - - ); - } else { - return ( - - - - - - - - - - - ); - } - } - - private handleChangeDate = (date: Moment | null) => { - if (date !== null) { - this.props.jumpToTime(date.valueOf()); - } - }; - - private startLiveStreaming = () => { - this.props.startLiveStreaming(); - }; - - private stopLiveStreaming = () => { - this.props.stopLiveStreaming(); - }; -} diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts new file mode 100644 index 0000000000000..2a19a82892427 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entries.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { identity } from 'fp-ts/lib/function'; +import { npStart } from '../../../../legacy_singletons'; + +import { throwErrors, createPlainError } from '../../../../../common/runtime_types'; + +import { + LOG_ENTRIES_PATH, + LogEntriesRequest, + logEntriesRequestRT, + logEntriesResponseRT, +} from '../../../../../common/http_api'; + +export const fetchLogEntries = async (requestArgs: LogEntriesRequest) => { + const response = await npStart.http.fetch(LOG_ENTRIES_PATH, { + method: 'POST', + body: JSON.stringify(logEntriesRequestRT.encode(requestArgs)), + }); + + return pipe(logEntriesResponseRT.decode(response), fold(throwErrors(createPlainError), identity)); +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/gql_queries.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/gql_queries.ts deleted file mode 100644 index 83bae37c348d4..0000000000000 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/gql_queries.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { ApolloClient } from 'apollo-client'; -import { TimeKey } from '../../../../common/time'; -import { logEntriesQuery } from '../../../graphql/log_entries.gql_query'; -import { useApolloClient } from '../../../utils/apollo_context'; -import { LogEntriesResponse } from '.'; - -const LOAD_CHUNK_SIZE = 200; - -type LogEntriesGetter = ( - client: ApolloClient<{}>, - countBefore: number, - countAfter: number -) => (params: { - sourceId: string; - timeKey: TimeKey | null; - filterQuery: string | null; -}) => Promise; - -const getLogEntries: LogEntriesGetter = (client, countBefore, countAfter) => async ({ - sourceId, - timeKey, - filterQuery, -}) => { - if (!timeKey) throw new Error('TimeKey is null'); - const result = await client.query({ - query: logEntriesQuery, - variables: { - sourceId, - timeKey: { time: timeKey.time, tiebreaker: timeKey.tiebreaker }, - countBefore, - countAfter, - filterQuery, - }, - fetchPolicy: 'no-cache', - }); - // Workaround for Typescript. Since we're removing the GraphQL API in another PR or two - // 7.6 goes out I don't think it's worth the effort to actually make this - // typecheck pass - const { source } = result.data as any; - const { logEntriesAround } = source; - return { - entries: logEntriesAround.entries, - entriesStart: logEntriesAround.start, - entriesEnd: logEntriesAround.end, - hasMoreAfterEnd: logEntriesAround.hasMoreAfter, - hasMoreBeforeStart: logEntriesAround.hasMoreBefore, - lastLoadedTime: new Date(), - }; -}; - -export const useGraphQLQueries = () => { - const client = useApolloClient(); - if (!client) throw new Error('Unable to get Apollo Client from context'); - return { - getLogEntriesAround: getLogEntries(client, LOAD_CHUNK_SIZE, LOAD_CHUNK_SIZE), - getLogEntriesBefore: getLogEntries(client, LOAD_CHUNK_SIZE, 0), - getLogEntriesAfter: getLogEntries(client, 0, LOAD_CHUNK_SIZE), - }; -}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts index 04412f5fdd871..b9a5c4068e166 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts @@ -5,12 +5,18 @@ */ import { useEffect, useState, useReducer, useCallback } from 'react'; import createContainer from 'constate'; -import { pick, throttle, omit } from 'lodash'; -import { useGraphQLQueries } from './gql_queries'; +import { pick, throttle } from 'lodash'; import { TimeKey, timeKeyIsBetween } from '../../../../common/time'; -import { InfraLogEntry } from './types'; +import { + LogEntriesResponse, + LogEntry, + LogEntriesRequest, + LogEntriesBaseRequest, +} from '../../../../common/http_api'; +import { fetchLogEntries } from './api/fetch_log_entries'; const DESIRED_BUFFER_PAGES = 2; +const LIVE_STREAM_INTERVAL = 5000; enum Action { FetchingNewEntries, @@ -20,6 +26,7 @@ enum Action { ReceiveEntriesAfter, ErrorOnNewEntries, ErrorOnMoreEntries, + ExpandRange, } type ReceiveActions = @@ -29,41 +36,46 @@ type ReceiveActions = interface ReceiveEntriesAction { type: ReceiveActions; - payload: LogEntriesResponse; + payload: LogEntriesResponse['data']; +} +interface ExpandRangeAction { + type: Action.ExpandRange; + payload: { before: boolean; after: boolean }; } interface FetchOrErrorAction { - type: Exclude; + type: Exclude; } -type ActionObj = ReceiveEntriesAction | FetchOrErrorAction; +type ActionObj = ReceiveEntriesAction | FetchOrErrorAction | ExpandRangeAction; type Dispatch = (action: ActionObj) => void; interface LogEntriesProps { + startTimestamp: number; + endTimestamp: number; + timestampsLastUpdate: number; filterQuery: string | null; timeKey: TimeKey | null; pagesBeforeStart: number | null; pagesAfterEnd: number | null; sourceId: string; - isAutoReloading: boolean; + isStreaming: boolean; jumpToTargetPosition: (position: TimeKey) => void; } -type FetchEntriesParams = Omit; +type FetchEntriesParams = Omit; type FetchMoreEntriesParams = Pick; -export interface LogEntriesResponse { - entries: InfraLogEntry[]; - entriesStart: TimeKey | null; - entriesEnd: TimeKey | null; - hasMoreAfterEnd: boolean; - hasMoreBeforeStart: boolean; - lastLoadedTime: Date | null; -} - -export type LogEntriesStateParams = { +export interface LogEntriesStateParams { + entries: LogEntriesResponse['data']['entries']; + topCursor: LogEntriesResponse['data']['topCursor'] | null; + bottomCursor: LogEntriesResponse['data']['bottomCursor'] | null; + centerCursor: TimeKey | null; isReloading: boolean; isLoadingMore: boolean; -} & LogEntriesResponse; + lastLoadedTime: Date | null; + hasMoreBeforeStart: boolean; + hasMoreAfterEnd: boolean; +} export interface LogEntriesCallbacks { fetchNewerEntries: () => Promise; @@ -75,32 +87,40 @@ export const logEntriesInitialCallbacks = { export const logEntriesInitialState: LogEntriesStateParams = { entries: [], - entriesStart: null, - entriesEnd: null, - hasMoreAfterEnd: false, - hasMoreBeforeStart: false, + topCursor: null, + bottomCursor: null, + centerCursor: null, isReloading: true, isLoadingMore: false, lastLoadedTime: null, + hasMoreBeforeStart: false, + hasMoreAfterEnd: false, }; -const cleanDuplicateItems = (entriesA: InfraLogEntry[], entriesB: InfraLogEntry[]) => { - const gids = new Set(entriesB.map(item => item.gid)); - return entriesA.filter(item => !gids.has(item.gid)); +const cleanDuplicateItems = (entriesA: LogEntry[], entriesB: LogEntry[]) => { + const ids = new Set(entriesB.map(item => item.id)); + return entriesA.filter(item => !ids.has(item.id)); }; const shouldFetchNewEntries = ({ prevParams, timeKey, filterQuery, - entriesStart, - entriesEnd, -}: FetchEntriesParams & LogEntriesStateParams & { prevParams: FetchEntriesParams }) => { - if (!timeKey) return false; - const shouldLoadWithNewFilter = filterQuery !== prevParams.filterQuery; + topCursor, + bottomCursor, + startTimestamp, + endTimestamp, +}: FetchEntriesParams & LogEntriesStateParams & { prevParams: FetchEntriesParams | undefined }) => { + const shouldLoadWithNewDates = prevParams + ? (startTimestamp !== prevParams.startTimestamp && + startTimestamp > prevParams.startTimestamp) || + (endTimestamp !== prevParams.endTimestamp && endTimestamp < prevParams.endTimestamp) + : true; + const shouldLoadWithNewFilter = prevParams ? filterQuery !== prevParams.filterQuery : true; const shouldLoadAroundNewPosition = - !entriesStart || !entriesEnd || !timeKeyIsBetween(entriesStart, entriesEnd, timeKey); - return shouldLoadWithNewFilter || shouldLoadAroundNewPosition; + timeKey && (!topCursor || !bottomCursor || !timeKeyIsBetween(topCursor, bottomCursor, timeKey)); + + return shouldLoadWithNewDates || shouldLoadWithNewFilter || shouldLoadAroundNewPosition; }; enum ShouldFetchMoreEntries { @@ -124,48 +144,105 @@ const useFetchEntriesEffect = ( dispatch: Dispatch, props: LogEntriesProps ) => { - const { getLogEntriesAround, getLogEntriesBefore, getLogEntriesAfter } = useGraphQLQueries(); - - const [prevParams, cachePrevParams] = useState(props); + const [prevParams, cachePrevParams] = useState(); const [startedStreaming, setStartedStreaming] = useState(false); - const runFetchNewEntriesRequest = async (override = {}) => { + const runFetchNewEntriesRequest = async (overrides: Partial = {}) => { + if (!props.startTimestamp || !props.endTimestamp) { + return; + } + dispatch({ type: Action.FetchingNewEntries }); + try { - const payload = await getLogEntriesAround({ - ...omit(props, 'jumpToTargetPosition'), - ...override, - }); + const commonFetchArgs: LogEntriesBaseRequest = { + sourceId: overrides.sourceId || props.sourceId, + startTimestamp: overrides.startTimestamp || props.startTimestamp, + endTimestamp: overrides.endTimestamp || props.endTimestamp, + query: overrides.filterQuery || props.filterQuery, + }; + + const fetchArgs: LogEntriesRequest = props.timeKey + ? { + ...commonFetchArgs, + center: props.timeKey, + } + : { + ...commonFetchArgs, + before: 'last', + }; + + const { data: payload } = await fetchLogEntries(fetchArgs); dispatch({ type: Action.ReceiveNewEntries, payload }); + + // Move position to the bottom if it's the first load. + // Do it in the next tick to allow the `dispatch` to fire + if (!props.timeKey && payload.bottomCursor) { + setTimeout(() => { + props.jumpToTargetPosition(payload.bottomCursor!); + }); + } else if ( + props.timeKey && + payload.topCursor && + payload.bottomCursor && + !timeKeyIsBetween(payload.topCursor, payload.bottomCursor, props.timeKey) + ) { + props.jumpToTargetPosition(payload.topCursor); + } } catch (e) { dispatch({ type: Action.ErrorOnNewEntries }); } }; const runFetchMoreEntriesRequest = async (direction: ShouldFetchMoreEntries) => { - dispatch({ type: Action.FetchingMoreEntries }); + if (!props.startTimestamp || !props.endTimestamp) { + return; + } const getEntriesBefore = direction === ShouldFetchMoreEntries.Before; - const timeKey = getEntriesBefore - ? state.entries[0].key - : state.entries[state.entries.length - 1].key; - const getMoreLogEntries = getEntriesBefore ? getLogEntriesBefore : getLogEntriesAfter; + + // Control that cursors are correct + if ((getEntriesBefore && !state.topCursor) || !state.bottomCursor) { + return; + } + + dispatch({ type: Action.FetchingMoreEntries }); + try { - const payload = await getMoreLogEntries({ ...props, timeKey }); + const commonFetchArgs: LogEntriesBaseRequest = { + sourceId: props.sourceId, + startTimestamp: props.startTimestamp, + endTimestamp: props.endTimestamp, + query: props.filterQuery, + }; + + const fetchArgs: LogEntriesRequest = getEntriesBefore + ? { + ...commonFetchArgs, + before: state.topCursor!, // We already check for nullity above + } + : { + ...commonFetchArgs, + after: state.bottomCursor, + }; + + const { data: payload } = await fetchLogEntries(fetchArgs); + dispatch({ type: getEntriesBefore ? Action.ReceiveEntriesBefore : Action.ReceiveEntriesAfter, payload, }); - return payload.entriesEnd; + + return payload.bottomCursor; } catch (e) { dispatch({ type: Action.ErrorOnMoreEntries }); } }; const fetchNewEntriesEffectDependencies = Object.values( - pick(props, ['sourceId', 'filterQuery', 'timeKey']) + pick(props, ['sourceId', 'filterQuery', 'timeKey', 'startTimestamp', 'endTimestamp']) ); const fetchNewEntriesEffect = () => { - if (props.isAutoReloading) return; + if (props.isStreaming && prevParams) return; if (shouldFetchNewEntries({ ...props, ...state, prevParams })) { runFetchNewEntriesRequest(); } @@ -177,7 +254,7 @@ const useFetchEntriesEffect = ( Object.values(pick(state, ['hasMoreBeforeStart', 'hasMoreAfterEnd'])), ]; const fetchMoreEntriesEffect = () => { - if (state.isLoadingMore || props.isAutoReloading) return; + if (state.isLoadingMore || props.isStreaming) return; const direction = shouldFetchMoreEntries(props, state); switch (direction) { case ShouldFetchMoreEntries.Before: @@ -191,30 +268,25 @@ const useFetchEntriesEffect = ( const fetchNewerEntries = useCallback( throttle(() => runFetchMoreEntriesRequest(ShouldFetchMoreEntries.After), 500), - [props, state.entriesEnd] + [props, state.bottomCursor] ); const streamEntriesEffectDependencies = [ - props.isAutoReloading, + props.isStreaming, state.isLoadingMore, state.isReloading, ]; const streamEntriesEffect = () => { (async () => { - if (props.isAutoReloading && !state.isLoadingMore && !state.isReloading) { + if (props.isStreaming && !state.isLoadingMore && !state.isReloading) { if (startedStreaming) { - await new Promise(res => setTimeout(res, 5000)); + await new Promise(res => setTimeout(res, LIVE_STREAM_INTERVAL)); } else { - const nowKey = { - tiebreaker: 0, - time: Date.now(), - }; - props.jumpToTargetPosition(nowKey); + const endTimestamp = Date.now(); + props.jumpToTargetPosition({ tiebreaker: 0, time: endTimestamp }); setStartedStreaming(true); if (state.hasMoreAfterEnd) { - runFetchNewEntriesRequest({ - timeKey: nowKey, - }); + runFetchNewEntriesRequest({ endTimestamp }); return; } } @@ -222,15 +294,41 @@ const useFetchEntriesEffect = ( if (newEntriesEnd) { props.jumpToTargetPosition(newEntriesEnd); } - } else if (!props.isAutoReloading) { + } else if (!props.isStreaming) { setStartedStreaming(false); } })(); }; + const expandRangeEffect = () => { + if (!prevParams || !prevParams.startTimestamp || !prevParams.endTimestamp) { + return; + } + + if (props.timestampsLastUpdate === prevParams.timestampsLastUpdate) { + return; + } + + const shouldExpand = { + before: props.startTimestamp < prevParams.startTimestamp, + after: props.endTimestamp > prevParams.endTimestamp, + }; + + dispatch({ type: Action.ExpandRange, payload: shouldExpand }); + }; + + const expandRangeEffectDependencies = [ + prevParams?.startTimestamp, + prevParams?.endTimestamp, + props.startTimestamp, + props.endTimestamp, + props.timestampsLastUpdate, + ]; + useEffect(fetchNewEntriesEffect, fetchNewEntriesEffectDependencies); useEffect(fetchMoreEntriesEffect, fetchMoreEntriesEffectDependencies); useEffect(streamEntriesEffect, streamEntriesEffectDependencies); + useEffect(expandRangeEffect, expandRangeEffectDependencies); return { fetchNewerEntries, checkForNewEntries: runFetchNewEntriesRequest }; }; @@ -249,44 +347,87 @@ export const useLogEntriesState: ( const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: ActionObj) => { switch (action.type) { case Action.ReceiveNewEntries: - return { ...prevState, ...action.payload, isReloading: false }; + return { + ...prevState, + ...action.payload, + centerCursor: getCenterCursor(action.payload.entries), + lastLoadedTime: new Date(), + isReloading: false, + + // Be optimistic. If any of the before/after requests comes empty, set + // the corresponding flag to `false` + hasMoreBeforeStart: true, + hasMoreAfterEnd: true, + }; case Action.ReceiveEntriesBefore: { - const prevEntries = cleanDuplicateItems(prevState.entries, action.payload.entries); - const newEntries = [...action.payload.entries, ...prevEntries]; - const { hasMoreBeforeStart, entriesStart, lastLoadedTime } = action.payload; + const newEntries = action.payload.entries; + const prevEntries = cleanDuplicateItems(prevState.entries, newEntries); + const entries = [...newEntries, ...prevEntries]; + const update = { - entries: newEntries, + entries, isLoadingMore: false, - hasMoreBeforeStart, - entriesStart, - lastLoadedTime, + hasMoreBeforeStart: newEntries.length > 0, + // Keep the previous cursor if request comes empty, to easily extend the range. + topCursor: newEntries.length > 0 ? action.payload.topCursor : prevState.topCursor, + centerCursor: getCenterCursor(entries), + lastLoadedTime: new Date(), }; + return { ...prevState, ...update }; } case Action.ReceiveEntriesAfter: { - const prevEntries = cleanDuplicateItems(prevState.entries, action.payload.entries); - const newEntries = [...prevEntries, ...action.payload.entries]; - const { hasMoreAfterEnd, entriesEnd, lastLoadedTime } = action.payload; + const newEntries = action.payload.entries; + const prevEntries = cleanDuplicateItems(prevState.entries, newEntries); + const entries = [...prevEntries, ...newEntries]; + const update = { - entries: newEntries, + entries, isLoadingMore: false, - hasMoreAfterEnd, - entriesEnd, - lastLoadedTime, + hasMoreAfterEnd: newEntries.length > 0, + // Keep the previous cursor if request comes empty, to easily extend the range. + bottomCursor: newEntries.length > 0 ? action.payload.bottomCursor : prevState.bottomCursor, + centerCursor: getCenterCursor(entries), + lastLoadedTime: new Date(), }; + return { ...prevState, ...update }; } case Action.FetchingNewEntries: - return { ...prevState, isReloading: true }; + return { + ...prevState, + isReloading: true, + entries: [], + topCursor: null, + bottomCursor: null, + centerCursor: null, + hasMoreBeforeStart: true, + hasMoreAfterEnd: true, + }; case Action.FetchingMoreEntries: return { ...prevState, isLoadingMore: true }; case Action.ErrorOnNewEntries: return { ...prevState, isReloading: false }; case Action.ErrorOnMoreEntries: return { ...prevState, isLoadingMore: false }; + + case Action.ExpandRange: { + const hasMoreBeforeStart = action.payload.before ? true : prevState.hasMoreBeforeStart; + const hasMoreAfterEnd = action.payload.after ? true : prevState.hasMoreAfterEnd; + + return { + ...prevState, + hasMoreBeforeStart, + hasMoreAfterEnd, + }; + } default: throw new Error(); } }; +function getCenterCursor(entries: LogEntry[]): TimeKey | null { + return entries.length > 0 ? entries[Math.floor(entries.length / 2)].cursor : null; +} + export const LogEntriesState = createContainer(useLogEntriesState); diff --git a/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx b/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx index 5c1667a4b7680..267abe631c142 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx @@ -19,7 +19,7 @@ export enum FlyoutVisibility { visible = 'visible', } -interface FlyoutOptionsUrlState { +export interface FlyoutOptionsUrlState { flyoutId?: string | null; flyoutVisibility?: string | null; surroundingLogsId?: string | null; diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/api/fetch_log_entries_highlights.ts b/x-pack/plugins/infra/public/containers/logs/log_highlights/api/fetch_log_entries_highlights.ts new file mode 100644 index 0000000000000..030a9d180c7b5 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/api/fetch_log_entries_highlights.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { identity } from 'fp-ts/lib/function'; +import { npStart } from '../../../../legacy_singletons'; + +import { throwErrors, createPlainError } from '../../../../../common/runtime_types'; + +import { + LOG_ENTRIES_HIGHLIGHTS_PATH, + LogEntriesHighlightsRequest, + logEntriesHighlightsRequestRT, + logEntriesHighlightsResponseRT, +} from '../../../../../common/http_api'; + +export const fetchLogEntriesHighlights = async (requestArgs: LogEntriesHighlightsRequest) => { + const response = await npStart.http.fetch(LOG_ENTRIES_HIGHLIGHTS_PATH, { + method: 'POST', + body: JSON.stringify(logEntriesHighlightsRequestRT.encode(requestArgs)), + }); + + return pipe( + logEntriesHighlightsResponseRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx index 2b19958a9b1a1..7701850443768 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx @@ -6,62 +6,47 @@ import { useEffect, useMemo, useState } from 'react'; -import { getNextTimeKey, getPreviousTimeKey, TimeKey } from '../../../../common/time'; -import { LogEntryHighlightsQuery } from '../../../graphql/types'; -import { DependencyError, useApolloClient } from '../../../utils/apollo_context'; -import { LogEntryHighlightsMap } from '../../../utils/log_entry'; +import { TimeKey } from '../../../../common/time'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; -import { logEntryHighlightsQuery } from './log_entry_highlights.gql_query'; - -export type LogEntryHighlights = LogEntryHighlightsQuery.Query['source']['logEntryHighlights']; +import { fetchLogEntriesHighlights } from './api/fetch_log_entries_highlights'; +import { LogEntry, LogEntriesHighlightsResponse } from '../../../../common/http_api'; export const useLogEntryHighlights = ( sourceId: string, sourceVersion: string | undefined, - startKey: TimeKey | null, - endKey: TimeKey | null, + startTimestamp: number | null, + endTimestamp: number | null, + centerPoint: TimeKey | null, + size: number, filterQuery: string | null, highlightTerms: string[] ) => { - const apolloClient = useApolloClient(); - const [logEntryHighlights, setLogEntryHighlights] = useState([]); + const [logEntryHighlights, setLogEntryHighlights] = useState< + LogEntriesHighlightsResponse['data'] + >([]); const [loadLogEntryHighlightsRequest, loadLogEntryHighlights] = useTrackedPromise( { cancelPreviousOn: 'resolution', createPromise: async () => { - if (!apolloClient) { - throw new DependencyError('Failed to load source: No apollo client available.'); - } - if (!startKey || !endKey || !highlightTerms.length) { + if (!startTimestamp || !endTimestamp || !centerPoint || !highlightTerms.length) { throw new Error('Skipping request: Insufficient parameters'); } - return await apolloClient.query< - LogEntryHighlightsQuery.Query, - LogEntryHighlightsQuery.Variables - >({ - fetchPolicy: 'no-cache', - query: logEntryHighlightsQuery, - variables: { - sourceId, - startKey: getPreviousTimeKey(startKey), // interval boundaries are exclusive - endKey: getNextTimeKey(endKey), // interval boundaries are exclusive - filterQuery, - highlights: [ - { - query: highlightTerms[0], - countBefore: 1, - countAfter: 1, - }, - ], - }, + return await fetchLogEntriesHighlights({ + sourceId, + startTimestamp, + endTimestamp, + center: centerPoint, + size, + query: filterQuery || undefined, + highlightTerms, }); }, onResolve: response => { - setLogEntryHighlights(response.data.source.logEntryHighlights); + setLogEntryHighlights(response.data); }, }, - [apolloClient, sourceId, startKey, endKey, filterQuery, highlightTerms] + [sourceId, startTimestamp, endTimestamp, centerPoint, size, filterQuery, highlightTerms] ); useEffect(() => { @@ -71,24 +56,31 @@ export const useLogEntryHighlights = ( useEffect(() => { if ( highlightTerms.filter(highlightTerm => highlightTerm.length > 0).length && - startKey && - endKey + startTimestamp && + endTimestamp ) { loadLogEntryHighlights(); } else { setLogEntryHighlights([]); } - }, [endKey, filterQuery, highlightTerms, loadLogEntryHighlights, sourceVersion, startKey]); + }, [ + endTimestamp, + filterQuery, + highlightTerms, + loadLogEntryHighlights, + sourceVersion, + startTimestamp, + ]); const logEntryHighlightsById = useMemo( () => - logEntryHighlights.reduce( - (accumulatedLogEntryHighlightsById, { entries }) => { - return entries.reduce((singleHighlightLogEntriesById, entry) => { - const highlightsForId = singleHighlightLogEntriesById[entry.gid] || []; + logEntryHighlights.reduce>( + (accumulatedLogEntryHighlightsById, highlightData) => { + return highlightData.entries.reduce((singleHighlightLogEntriesById, entry) => { + const highlightsForId = singleHighlightLogEntriesById[entry.id] || []; return { ...singleHighlightLogEntriesById, - [entry.gid]: [...highlightsForId, entry], + [entry.id]: [...highlightsForId, entry], }; }, accumulatedLogEntryHighlightsById); }, diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx index a4a94851ad383..941e89848131b 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx @@ -6,39 +6,38 @@ import createContainer from 'constate'; import { useState, useContext } from 'react'; +import { useThrottle } from 'react-use'; import { useLogEntryHighlights } from './log_entry_highlights'; import { useLogSummaryHighlights } from './log_summary_highlights'; import { useNextAndPrevious } from './next_and_previous'; -import { useLogSummaryBufferInterval } from '../log_summary'; -import { LogViewConfiguration } from '../log_view_configuration'; import { LogPositionState } from '../log_position'; import { TimeKey } from '../../../../common/time'; +const FETCH_THROTTLE_INTERVAL = 3000; + +interface UseLogHighlightsStateProps { + sourceId: string; + sourceVersion: string | undefined; + centerCursor: TimeKey | null; + size: number; + filterQuery: string | null; +} + export const useLogHighlightsState = ({ sourceId, sourceVersion, - entriesStart, - entriesEnd, + centerCursor, + size, filterQuery, -}: { - sourceId: string; - sourceVersion: string | undefined; - entriesStart: TimeKey | null; - entriesEnd: TimeKey | null; - filterQuery: string | null; -}) => { +}: UseLogHighlightsStateProps) => { const [highlightTerms, setHighlightTerms] = useState([]); - const { visibleMidpoint, jumpToTargetPosition } = useContext(LogPositionState.Context); - const { intervalSize: summaryIntervalSize } = useContext(LogViewConfiguration.Context); - const { - start: summaryStart, - end: summaryEnd, - bucketSize: summaryBucketSize, - } = useLogSummaryBufferInterval( - visibleMidpoint ? visibleMidpoint.time : null, - summaryIntervalSize + const { visibleMidpoint, jumpToTargetPosition, startTimestamp, endTimestamp } = useContext( + LogPositionState.Context ); + const throttledStartTimestamp = useThrottle(startTimestamp, FETCH_THROTTLE_INTERVAL); + const throttledEndTimestamp = useThrottle(endTimestamp, FETCH_THROTTLE_INTERVAL); + const { logEntryHighlights, logEntryHighlightsById, @@ -46,8 +45,10 @@ export const useLogHighlightsState = ({ } = useLogEntryHighlights( sourceId, sourceVersion, - entriesStart, - entriesEnd, + throttledStartTimestamp, + throttledEndTimestamp, + centerCursor, + size, filterQuery, highlightTerms ); @@ -55,9 +56,8 @@ export const useLogHighlightsState = ({ const { logSummaryHighlights, loadLogSummaryHighlightsRequest } = useLogSummaryHighlights( sourceId, sourceVersion, - summaryStart, - summaryEnd, - summaryBucketSize, + throttledStartTimestamp, + throttledEndTimestamp, filterQuery, highlightTerms ); diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts index 81639aba411ef..41ee63bf0e23d 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_summary_highlights.ts @@ -10,13 +10,13 @@ import { debounce } from 'lodash'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; import { fetchLogSummaryHighlights } from './api/fetch_log_summary_highlights'; import { LogEntriesSummaryHighlightsResponse } from '../../../../common/http_api'; +import { useBucketSize } from '../log_summary/bucket_size'; export const useLogSummaryHighlights = ( sourceId: string, sourceVersion: string | undefined, - start: number | null, - end: number | null, - bucketSize: number, + startTimestamp: number | null, + endTimestamp: number | null, filterQuery: string | null, highlightTerms: string[] ) => { @@ -24,18 +24,20 @@ export const useLogSummaryHighlights = ( LogEntriesSummaryHighlightsResponse['data'] >([]); + const bucketSize = useBucketSize(startTimestamp, endTimestamp); + const [loadLogSummaryHighlightsRequest, loadLogSummaryHighlights] = useTrackedPromise( { cancelPreviousOn: 'resolution', createPromise: async () => { - if (!start || !end || !highlightTerms.length) { + if (!startTimestamp || !endTimestamp || !bucketSize || !highlightTerms.length) { throw new Error('Skipping request: Insufficient parameters'); } return await fetchLogSummaryHighlights({ sourceId, - startDate: start, - endDate: end, + startTimestamp, + endTimestamp, bucketSize, query: filterQuery, highlightTerms, @@ -45,7 +47,7 @@ export const useLogSummaryHighlights = ( setLogSummaryHighlights(response.data); }, }, - [sourceId, start, end, bucketSize, filterQuery, highlightTerms] + [sourceId, startTimestamp, endTimestamp, bucketSize, filterQuery, highlightTerms] ); const debouncedLoadSummaryHighlights = useMemo(() => debounce(loadLogSummaryHighlights, 275), [ @@ -57,7 +59,11 @@ export const useLogSummaryHighlights = ( }, [highlightTerms]); useEffect(() => { - if (highlightTerms.filter(highlightTerm => highlightTerm.length > 0).length && start && end) { + if ( + highlightTerms.filter(highlightTerm => highlightTerm.length > 0).length && + startTimestamp && + endTimestamp + ) { debouncedLoadSummaryHighlights(); } else { setLogSummaryHighlights([]); @@ -65,11 +71,11 @@ export const useLogSummaryHighlights = ( }, [ bucketSize, debouncedLoadSummaryHighlights, - end, filterQuery, highlightTerms, sourceVersion, - start, + startTimestamp, + endTimestamp, ]); return { diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx b/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx index 7557550883f11..689c30a52b597 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx @@ -13,7 +13,7 @@ import { getLogEntryIndexBeforeTime, getUniqueLogEntryKey, } from '../../../utils/log_entry'; -import { LogEntryHighlights } from './log_entry_highlights'; +import { LogEntriesHighlightsResponse } from '../../../../common/http_api'; export const useNextAndPrevious = ({ highlightTerms, @@ -23,7 +23,7 @@ export const useNextAndPrevious = ({ }: { highlightTerms: string[]; jumpToTargetPosition: (target: TimeKey) => void; - logEntryHighlights: LogEntryHighlights | undefined; + logEntryHighlights: LogEntriesHighlightsResponse['data'] | undefined; visibleMidpoint: TimeKey | null; }) => { const [currentTimeKey, setCurrentTimeKey] = useState(null); diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts index 1a8274024bd26..5ac34e5df70ec 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts @@ -6,10 +6,20 @@ import { useState, useMemo, useEffect, useCallback } from 'react'; import createContainer from 'constate'; +import { useSetState } from 'react-use'; import { TimeKey } from '../../../../common/time'; +import { datemathToEpochMillis, isValidDatemath } from '../../../utils/datemath'; type TimeKeyOrNull = TimeKey | null; +interface DateRange { + startDateExpression: string; + endDateExpression: string; + startTimestamp: number; + endTimestamp: number; + timestampsLastUpdate: number; +} + interface VisiblePositions { startKey: TimeKeyOrNull; middleKey: TimeKeyOrNull; @@ -19,24 +29,35 @@ interface VisiblePositions { } export interface LogPositionStateParams { + isInitialized: boolean; targetPosition: TimeKeyOrNull; - isAutoReloading: boolean; + isStreaming: boolean; firstVisiblePosition: TimeKeyOrNull; pagesBeforeStart: number; pagesAfterEnd: number; visibleMidpoint: TimeKeyOrNull; visibleMidpointTime: number | null; visibleTimeInterval: { start: number; end: number } | null; + startDateExpression: string; + endDateExpression: string; + startTimestamp: number | null; + endTimestamp: number | null; + timestampsLastUpdate: number; } export interface LogPositionCallbacks { + initialize: () => void; jumpToTargetPosition: (pos: TimeKeyOrNull) => void; jumpToTargetPositionTime: (time: number) => void; reportVisiblePositions: (visPos: VisiblePositions) => void; startLiveStreaming: () => void; stopLiveStreaming: () => void; + updateDateRange: (newDateRage: Partial) => void; } +const DEFAULT_DATE_RANGE = { startDateExpression: 'now-1d', endDateExpression: 'now' }; +const DESIRED_BUFFER_PAGES = 2; + const useVisibleMidpoint = (middleKey: TimeKeyOrNull, targetPosition: TimeKeyOrNull) => { // Of the two dependencies `middleKey` and `targetPosition`, return // whichever one was the most recently updated. This allows the UI controls @@ -60,8 +81,18 @@ const useVisibleMidpoint = (middleKey: TimeKeyOrNull, targetPosition: TimeKeyOrN }; export const useLogPositionState: () => LogPositionStateParams & LogPositionCallbacks = () => { + // Flag to determine if `LogPositionState` has been fully initialized. + // + // When the page loads, there might be initial state in the URL. We want to + // prevent the entries from showing until we have processed that initial + // state. That prevents double fetching. + const [isInitialized, setInitialized] = useState(false); + const initialize = useCallback(() => { + setInitialized(true); + }, [setInitialized]); + const [targetPosition, jumpToTargetPosition] = useState(null); - const [isAutoReloading, setIsAutoReloading] = useState(false); + const [isStreaming, setIsStreaming] = useState(false); const [visiblePositions, reportVisiblePositions] = useState({ endKey: null, middleKey: null, @@ -70,6 +101,15 @@ export const useLogPositionState: () => LogPositionStateParams & LogPositionCall pagesAfterEnd: Infinity, }); + // We group the `startDate` and `endDate` values in the same object to be able + // to set both at the same time, saving a re-render + const [dateRange, setDateRange] = useSetState({ + ...DEFAULT_DATE_RANGE, + startTimestamp: datemathToEpochMillis(DEFAULT_DATE_RANGE.startDateExpression)!, + endTimestamp: datemathToEpochMillis(DEFAULT_DATE_RANGE.endDateExpression, 'up')!, + timestampsLastUpdate: Date.now(), + }); + const { startKey, middleKey, endKey, pagesBeforeStart, pagesAfterEnd } = visiblePositions; const visibleMidpoint = useVisibleMidpoint(middleKey, targetPosition); @@ -79,26 +119,87 @@ export const useLogPositionState: () => LogPositionStateParams & LogPositionCall [startKey, endKey] ); + // Allow setting `startDate` and `endDate` separately, or together + const updateDateRange = useCallback( + (newDateRange: Partial) => { + // Prevent unnecessary re-renders + if (!('startDateExpression' in newDateRange) && !('endDateExpression' in newDateRange)) { + return; + } + + const nextStartDateExpression = + newDateRange.startDateExpression || dateRange.startDateExpression; + const nextEndDateExpression = newDateRange.endDateExpression || dateRange.endDateExpression; + + if (!isValidDatemath(nextStartDateExpression) || !isValidDatemath(nextEndDateExpression)) { + return; + } + + // Dates are valid, so the function cannot return `null` + const nextStartTimestamp = datemathToEpochMillis(nextStartDateExpression)!; + const nextEndTimestamp = datemathToEpochMillis(nextEndDateExpression, 'up')!; + + // Reset the target position if it doesn't fall within the new range. + if ( + targetPosition && + (nextStartTimestamp > targetPosition.time || nextEndTimestamp < targetPosition.time) + ) { + jumpToTargetPosition(null); + } + + setDateRange({ + ...newDateRange, + startTimestamp: nextStartTimestamp, + endTimestamp: nextEndTimestamp, + timestampsLastUpdate: Date.now(), + }); + }, + [setDateRange, dateRange, targetPosition] + ); + + // `endTimestamp` update conditions + useEffect(() => { + if (dateRange.endDateExpression !== 'now') { + return; + } + + // User is close to the bottom edge of the scroll. + if (visiblePositions.pagesAfterEnd <= DESIRED_BUFFER_PAGES) { + setDateRange({ + endTimestamp: datemathToEpochMillis(dateRange.endDateExpression, 'up')!, + timestampsLastUpdate: Date.now(), + }); + } + }, [dateRange.endDateExpression, visiblePositions, setDateRange]); + const state = { + isInitialized, targetPosition, - isAutoReloading, + isStreaming, firstVisiblePosition: startKey, pagesBeforeStart, pagesAfterEnd, visibleMidpoint, visibleMidpointTime: visibleMidpoint ? visibleMidpoint.time : null, visibleTimeInterval, + ...dateRange, }; const callbacks = { + initialize, jumpToTargetPosition, jumpToTargetPositionTime: useCallback( (time: number) => jumpToTargetPosition({ tiebreaker: 0, time }), [jumpToTargetPosition] ), reportVisiblePositions, - startLiveStreaming: useCallback(() => setIsAutoReloading(true), [setIsAutoReloading]), - stopLiveStreaming: useCallback(() => setIsAutoReloading(false), [setIsAutoReloading]), + startLiveStreaming: useCallback(() => { + setIsStreaming(true); + jumpToTargetPosition(null); + updateDateRange({ startDateExpression: 'now-1d', endDateExpression: 'now' }); + }, [setIsStreaming, updateDateRange]), + stopLiveStreaming: useCallback(() => setIsStreaming(false), [setIsStreaming]), + updateDateRange, }; return { ...state, ...callbacks }; diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx b/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx index 221dac95ef5f0..0d3586f9376f3 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx @@ -9,31 +9,40 @@ import React, { useContext, useMemo } from 'react'; import { pickTimeKey } from '../../../../common/time'; import { replaceStateKeyInQueryString, UrlStateContainer } from '../../../utils/url_state'; import { LogPositionState, LogPositionStateParams } from './log_position_state'; +import { isValidDatemath, datemathToEpochMillis } from '../../../utils/datemath'; /** * Url State */ - -interface LogPositionUrlState { - position: LogPositionStateParams['visibleMidpoint'] | undefined; +export interface LogPositionUrlState { + position?: LogPositionStateParams['visibleMidpoint']; streamLive: boolean; + start?: string; + end?: string; } +const ONE_HOUR = 3600000; + export const WithLogPositionUrlState = () => { const { visibleMidpoint, - isAutoReloading, + isStreaming, jumpToTargetPosition, - jumpToTargetPositionTime, startLiveStreaming, stopLiveStreaming, + startDateExpression, + endDateExpression, + updateDateRange, + initialize, } = useContext(LogPositionState.Context); const urlState = useMemo( () => ({ position: visibleMidpoint ? pickTimeKey(visibleMidpoint) : null, - streamLive: isAutoReloading, + streamLive: isStreaming, + start: startDateExpression, + end: endDateExpression, }), - [visibleMidpoint, isAutoReloading] + [visibleMidpoint, isStreaming, startDateExpression, endDateExpression] ); return ( { urlStateKey="logPosition" mapToUrlState={mapToUrlState} onChange={(newUrlState: LogPositionUrlState | undefined) => { - if (newUrlState && newUrlState.position) { + if (!newUrlState) { + return; + } + + if (newUrlState.start || newUrlState.end) { + updateDateRange({ + startDateExpression: newUrlState.start, + endDateExpression: newUrlState.end, + }); + } + + if (newUrlState.position) { jumpToTargetPosition(newUrlState.position); } - if (newUrlState && newUrlState.streamLive) { + + if (newUrlState.streamLive) { startLiveStreaming(); - } else if ( - newUrlState && - typeof newUrlState.streamLive !== 'undefined' && - !newUrlState.streamLive - ) { + } else if (typeof newUrlState.streamLive !== 'undefined' && !newUrlState.streamLive) { stopLiveStreaming(); } }} onInitialize={(initialUrlState: LogPositionUrlState | undefined) => { - if (initialUrlState && initialUrlState.position) { - jumpToTargetPosition(initialUrlState.position); - } else { - jumpToTargetPositionTime(Date.now()); - } - if (initialUrlState && initialUrlState.streamLive) { - startLiveStreaming(); + if (initialUrlState) { + const initialPosition = initialUrlState.position; + let initialStartDateExpression = initialUrlState.start; + let initialEndDateExpression = initialUrlState.end; + + if (!initialPosition) { + initialStartDateExpression = initialStartDateExpression || 'now-1d'; + initialEndDateExpression = initialEndDateExpression || 'now'; + } else { + const initialStartTimestamp = initialStartDateExpression + ? datemathToEpochMillis(initialStartDateExpression) + : undefined; + const initialEndTimestamp = initialEndDateExpression + ? datemathToEpochMillis(initialEndDateExpression, 'up') + : undefined; + + // Adjust the start-end range if the target position falls outside or if it's not set. + if (!initialStartTimestamp || initialStartTimestamp > initialPosition.time) { + initialStartDateExpression = new Date(initialPosition.time - ONE_HOUR).toISOString(); + } + + if (!initialEndTimestamp || initialEndTimestamp < initialPosition.time) { + initialEndDateExpression = new Date(initialPosition.time + ONE_HOUR).toISOString(); + } + + jumpToTargetPosition(initialPosition); + } + + if (initialStartDateExpression || initialEndDateExpression) { + updateDateRange({ + startDateExpression: initialStartDateExpression, + endDateExpression: initialEndDateExpression, + }); + } + + if (initialUrlState.streamLive) { + startLiveStreaming(); + } } + + initialize(); }} /> ); @@ -73,6 +123,8 @@ const mapToUrlState = (value: any): LogPositionUrlState | undefined => ? { position: mapToPositionUrlState(value.position), streamLive: mapToStreamLiveUrlState(value.streamLive), + start: mapToDate(value.start), + end: mapToDate(value.end), } : undefined; @@ -83,6 +135,7 @@ const mapToPositionUrlState = (value: any) => const mapToStreamLiveUrlState = (value: any) => (typeof value === 'boolean' ? value : false); +const mapToDate = (value: any) => (isValidDatemath(value) ? value : undefined); export const replaceLogPositionInQueryString = (time: number) => Number.isNaN(time) ? (value: string) => value diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/bucket_size.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/bucket_size.ts new file mode 100644 index 0000000000000..e46b304156f83 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/bucket_size.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useMemo } from 'react'; + +const SUMMARY_BUCKET_COUNT = 100; + +export function useBucketSize( + startTimestamp: number | null, + endTimestamp: number | null +): number | null { + const bucketSize = useMemo(() => { + if (!startTimestamp || !endTimestamp) { + return null; + } + return (endTimestamp - startTimestamp) / SUMMARY_BUCKET_COUNT; + }, [startTimestamp, endTimestamp]); + + return bucketSize; +} diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts index 20c4267000a25..dc0437fa75a31 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/index.ts @@ -5,5 +5,4 @@ */ export * from './log_summary'; -export * from './use_log_summary_buffer_interval'; export * from './with_summary'; diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx index 2bbcc22b150e4..73d0e5efdf06b 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.test.tsx @@ -9,6 +9,7 @@ import { renderHook } from '@testing-library/react-hooks'; import { useLogSummary } from './log_summary'; import { fetchLogSummary } from './api/fetch_log_summary'; +import { datemathToEpochMillis } from '../../../utils/datemath'; // Typescript doesn't know that `fetchLogSummary` is a jest mock. // We use a second variable with a type cast to help the compiler further down the line. @@ -21,20 +22,26 @@ describe('useLogSummary hook', () => { }); it('provides an empty list of buckets by default', () => { - const { result } = renderHook(() => useLogSummary('SOURCE_ID', null, 1000, null)); + const { result } = renderHook(() => useLogSummary('SOURCE_ID', null, null, null)); expect(result.current.buckets).toEqual([]); }); it('queries for new summary buckets when the source id changes', async () => { - const firstMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 1 }]); - const secondMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 2 }]); + const { startTimestamp, endTimestamp } = createMockDateRange(); + + const firstMockResponse = createMockResponse([ + { start: startTimestamp, end: endTimestamp, entriesCount: 1 }, + ]); + const secondMockResponse = createMockResponse([ + { start: startTimestamp, end: endTimestamp, entriesCount: 2 }, + ]); fetchLogSummaryMock .mockResolvedValueOnce(firstMockResponse) .mockResolvedValueOnce(secondMockResponse); const { result, waitForNextUpdate, rerender } = renderHook( - ({ sourceId }) => useLogSummary(sourceId, 100000, 1000, null), + ({ sourceId }) => useLogSummary(sourceId, startTimestamp, endTimestamp, null), { initialProps: { sourceId: 'INITIAL_SOURCE_ID' }, } @@ -63,15 +70,21 @@ describe('useLogSummary hook', () => { }); it('queries for new summary buckets when the filter query changes', async () => { - const firstMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 1 }]); - const secondMockResponse = createMockResponse([{ start: 99000, end: 101000, entriesCount: 2 }]); + const { startTimestamp, endTimestamp } = createMockDateRange(); + + const firstMockResponse = createMockResponse([ + { start: startTimestamp, end: endTimestamp, entriesCount: 1 }, + ]); + const secondMockResponse = createMockResponse([ + { start: startTimestamp, end: endTimestamp, entriesCount: 2 }, + ]); fetchLogSummaryMock .mockResolvedValueOnce(firstMockResponse) .mockResolvedValueOnce(secondMockResponse); const { result, waitForNextUpdate, rerender } = renderHook( - ({ filterQuery }) => useLogSummary('SOURCE_ID', 100000, 1000, filterQuery), + ({ filterQuery }) => useLogSummary('SOURCE_ID', startTimestamp, endTimestamp, filterQuery), { initialProps: { filterQuery: 'INITIAL_FILTER_QUERY' }, } @@ -99,15 +112,17 @@ describe('useLogSummary hook', () => { expect(result.current.buckets).toEqual(secondMockResponse.data.buckets); }); - it('queries for new summary buckets when the midpoint time changes', async () => { + it('queries for new summary buckets when the start and end date changes', async () => { fetchLogSummaryMock .mockResolvedValueOnce(createMockResponse([])) .mockResolvedValueOnce(createMockResponse([])); + const firstRange = createMockDateRange(); const { waitForNextUpdate, rerender } = renderHook( - ({ midpointTime }) => useLogSummary('SOURCE_ID', midpointTime, 1000, null), + ({ startTimestamp, endTimestamp }) => + useLogSummary('SOURCE_ID', startTimestamp, endTimestamp, null), { - initialProps: { midpointTime: 100000 }, + initialProps: firstRange, } ); @@ -115,54 +130,21 @@ describe('useLogSummary hook', () => { expect(fetchLogSummaryMock).toHaveBeenCalledTimes(1); expect(fetchLogSummaryMock).toHaveBeenLastCalledWith( expect.objectContaining({ - startDate: 98500, - endDate: 101500, - }) - ); - - rerender({ midpointTime: 200000 }); - await waitForNextUpdate(); - - expect(fetchLogSummaryMock).toHaveBeenCalledTimes(2); - expect(fetchLogSummaryMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - startDate: 198500, - endDate: 201500, + startTimestamp: firstRange.startTimestamp, + endTimestamp: firstRange.endTimestamp, }) ); - }); - it('queries for new summary buckets when the interval size changes', async () => { - fetchLogSummaryMock - .mockResolvedValueOnce(createMockResponse([])) - .mockResolvedValueOnce(createMockResponse([])); - - const { waitForNextUpdate, rerender } = renderHook( - ({ intervalSize }) => useLogSummary('SOURCE_ID', 100000, intervalSize, null), - { - initialProps: { intervalSize: 1000 }, - } - ); + const secondRange = createMockDateRange('now-20s', 'now'); - await waitForNextUpdate(); - expect(fetchLogSummaryMock).toHaveBeenCalledTimes(1); - expect(fetchLogSummaryMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - bucketSize: 10, - startDate: 98500, - endDate: 101500, - }) - ); - - rerender({ intervalSize: 2000 }); + rerender(secondRange); await waitForNextUpdate(); expect(fetchLogSummaryMock).toHaveBeenCalledTimes(2); expect(fetchLogSummaryMock).toHaveBeenLastCalledWith( expect.objectContaining({ - bucketSize: 20, - startDate: 97000, - endDate: 103000, + startTimestamp: secondRange.startTimestamp, + endTimestamp: secondRange.endTimestamp, }) ); }); @@ -171,3 +153,12 @@ describe('useLogSummary hook', () => { const createMockResponse = ( buckets: Array<{ start: number; end: number; entriesCount: number }> ) => ({ data: { buckets, start: Number.NEGATIVE_INFINITY, end: Number.POSITIVE_INFINITY } }); + +const createMockDateRange = (startDate = 'now-10s', endDate = 'now') => { + return { + startDate, + endDate, + startTimestamp: datemathToEpochMillis(startDate)!, + endTimestamp: datemathToEpochMillis(endDate, 'up')!, + }; +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx index c39b7075af325..94723125cc0ec 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/log_summary.tsx @@ -7,34 +7,31 @@ import { useState } from 'react'; import { useCancellableEffect } from '../../../utils/cancellable_effect'; -import { useLogSummaryBufferInterval } from './use_log_summary_buffer_interval'; import { fetchLogSummary } from './api/fetch_log_summary'; import { LogEntriesSummaryResponse } from '../../../../common/http_api'; +import { useBucketSize } from './bucket_size'; export type LogSummaryBuckets = LogEntriesSummaryResponse['data']['buckets']; export const useLogSummary = ( sourceId: string, - midpointTime: number | null, - intervalSize: number, + startTimestamp: number | null, + endTimestamp: number | null, filterQuery: string | null ) => { const [logSummaryBuckets, setLogSummaryBuckets] = useState([]); - const { start: bufferStart, end: bufferEnd, bucketSize } = useLogSummaryBufferInterval( - midpointTime, - intervalSize - ); + const bucketSize = useBucketSize(startTimestamp, endTimestamp); useCancellableEffect( getIsCancelled => { - if (bufferStart === null || bufferEnd === null) { + if (startTimestamp === null || endTimestamp === null || bucketSize === null) { return; } fetchLogSummary({ sourceId, - startDate: bufferStart, - endDate: bufferEnd, + startTimestamp, + endTimestamp, bucketSize, query: filterQuery, }).then(response => { @@ -43,12 +40,12 @@ export const useLogSummary = ( } }); }, - [sourceId, filterQuery, bufferStart, bufferEnd, bucketSize] + [sourceId, filterQuery, startTimestamp, endTimestamp, bucketSize] ); return { buckets: logSummaryBuckets, - start: bufferStart, - end: bufferEnd, + start: startTimestamp, + end: endTimestamp, }; }; diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/use_log_summary_buffer_interval.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/use_log_summary_buffer_interval.ts deleted file mode 100644 index 27af76b70f47a..0000000000000 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/use_log_summary_buffer_interval.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useMemo } from 'react'; - -const LOAD_BUCKETS_PER_PAGE = 100; -const UNKNOWN_BUFFER_INTERVAL = { - start: null, - end: null, - bucketSize: 0, -}; - -export const useLogSummaryBufferInterval = (midpointTime: number | null, intervalSize: number) => { - return useMemo(() => { - if (midpointTime === null || intervalSize <= 0) { - return UNKNOWN_BUFFER_INTERVAL; - } - - const halfIntervalSize = intervalSize / 2; - - return { - start: (Math.floor((midpointTime - halfIntervalSize) / intervalSize) - 0.5) * intervalSize, - end: (Math.ceil((midpointTime + halfIntervalSize) / intervalSize) + 0.5) * intervalSize, - bucketSize: intervalSize / LOAD_BUCKETS_PER_PAGE, - }; - }, [midpointTime, intervalSize]); -}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts index 4db0d2e645448..14da2b47bcfa2 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts @@ -5,14 +5,16 @@ */ import { useContext } from 'react'; +import { useThrottle } from 'react-use'; import { RendererFunction } from '../../../utils/typed_react'; import { Source } from '../../source'; -import { LogViewConfiguration } from '../log_view_configuration'; import { LogSummaryBuckets, useLogSummary } from './log_summary'; import { LogFilterState } from '../log_filter'; import { LogPositionState } from '../log_position'; +const FETCH_THROTTLE_INTERVAL = 3000; + export const WithSummary = ({ children, }: { @@ -22,15 +24,18 @@ export const WithSummary = ({ end: number | null; }>; }) => { - const { intervalSize } = useContext(LogViewConfiguration.Context); const { sourceId } = useContext(Source.Context); const { filterQuery } = useContext(LogFilterState.Context); - const { visibleMidpointTime } = useContext(LogPositionState.Context); + const { startTimestamp, endTimestamp } = useContext(LogPositionState.Context); + + // Keep it reasonably updated for the `now` case, but don't reload all the time when the user scrolls + const throttledStartTimestamp = useThrottle(startTimestamp, FETCH_THROTTLE_INTERVAL); + const throttledEndTimestamp = useThrottle(endTimestamp, FETCH_THROTTLE_INTERVAL); const { buckets, start, end } = useLogSummary( sourceId, - visibleMidpointTime, - intervalSize, + throttledStartTimestamp, + throttledEndTimestamp, filterQuery ); diff --git a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx index b6de1230d9a59..5954cb834a11d 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.test.tsx @@ -45,35 +45,10 @@ describe('useLogViewConfiguration hook', () => { }); }); - describe('intervalSize state', () => { - it('has a default value', () => { - const { getLastHookValue } = mountHook(() => useLogViewConfiguration().intervalSize); - - expect(getLastHookValue()).toEqual(86400000); - }); - - it('can be updated', () => { - const { act, getLastHookValue } = mountHook(() => useLogViewConfiguration()); - - act(({ setIntervalSize }) => { - setIntervalSize(90000000); - }); - - expect(getLastHookValue().intervalSize).toEqual(90000000); - }); - }); - it('provides the available text scales', () => { const { getLastHookValue } = mountHook(() => useLogViewConfiguration().availableTextScales); expect(getLastHookValue()).toEqual(expect.any(Array)); expect(getLastHookValue().length).toBeGreaterThan(0); }); - - it('provides the available interval sizes', () => { - const { getLastHookValue } = mountHook(() => useLogViewConfiguration().availableIntervalSizes); - - expect(getLastHookValue()).toEqual(expect.any(Array)); - expect(getLastHookValue().length).toBeGreaterThan(0); - }); }); diff --git a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx index 8837078aa4a0d..e1351ad0b17ad 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_view_configuration.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { i18n } from '@kbn/i18n'; import createContainer from 'constate'; import { useState } from 'react'; @@ -17,18 +16,12 @@ export const useLogViewConfiguration = () => { // text wrap const [textWrap, setTextWrap] = useState(true); - // minimap interval - const [intervalSize, setIntervalSize] = useState(1000 * 60 * 60 * 24); - return { - availableIntervalSizes, availableTextScales, setTextScale, setTextWrap, textScale, textWrap, - intervalSize, - setIntervalSize, }; }; @@ -39,42 +32,3 @@ export const LogViewConfiguration = createContainer(useLogViewConfiguration); */ export const availableTextScales: TextScale[] = ['large', 'medium', 'small']; - -export const availableIntervalSizes = [ - { - label: i18n.translate('xpack.infra.mapLogs.oneYearLabel', { - defaultMessage: '1 Year', - }), - intervalSize: 1000 * 60 * 60 * 24 * 365, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneMonthLabel', { - defaultMessage: '1 Month', - }), - intervalSize: 1000 * 60 * 60 * 24 * 30, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneWeekLabel', { - defaultMessage: '1 Week', - }), - intervalSize: 1000 * 60 * 60 * 24 * 7, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneDayLabel', { - defaultMessage: '1 Day', - }), - intervalSize: 1000 * 60 * 60 * 24, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneHourLabel', { - defaultMessage: '1 Hour', - }), - intervalSize: 1000 * 60 * 60, - }, - { - label: i18n.translate('xpack.infra.mapLogs.oneMinuteLabel', { - defaultMessage: '1 Minute', - }), - intervalSize: 1000 * 60, - }, -]; diff --git a/x-pack/plugins/infra/public/containers/logs/with_log_minimap.tsx b/x-pack/plugins/infra/public/containers/logs/with_log_minimap.tsx deleted file mode 100644 index 3f2b4d7cc16f9..0000000000000 --- a/x-pack/plugins/infra/public/containers/logs/with_log_minimap.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useContext, useMemo } from 'react'; - -import { UrlStateContainer } from '../../utils/url_state'; -import { LogViewConfiguration } from './log_view_configuration'; - -/** - * Url State - */ - -interface LogMinimapUrlState { - intervalSize?: number; -} - -export const WithLogMinimapUrlState = () => { - const { intervalSize, setIntervalSize } = useContext(LogViewConfiguration.Context); - - const urlState = useMemo(() => ({ intervalSize }), [intervalSize]); - - return ( - { - if (newUrlState && newUrlState.intervalSize) { - setIntervalSize(newUrlState.intervalSize); - } - }} - onInitialize={newUrlState => { - if (newUrlState && newUrlState.intervalSize) { - setIntervalSize(newUrlState.intervalSize); - } - }} - /> - ); -}; - -const mapToUrlState = (value: any): LogMinimapUrlState | undefined => - value - ? { - intervalSize: mapToIntervalSizeUrlState(value.intervalSize), - } - : undefined; - -const mapToIntervalSizeUrlState = (value: any) => - value && typeof value === 'number' ? value : undefined; diff --git a/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts b/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts index 6da9cd7513cba..5c0e245448ce5 100644 --- a/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts +++ b/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts @@ -6,12 +6,12 @@ import { useContext, useMemo } from 'react'; import { StreamItem, LogEntryStreamItem } from '../../components/logging/log_text_stream/item'; -import { LogEntry, LogEntryHighlight } from '../../utils/log_entry'; import { RendererFunction } from '../../utils/typed_react'; // deep inporting to avoid a circular import problem import { LogHighlightsState } from './log_highlights/log_highlights'; import { LogEntriesState, LogEntriesStateParams, LogEntriesCallbacks } from './log_entries'; import { UniqueTimeKey } from '../../../common/time'; +import { LogEntry } from '../../../common/http_api'; export const WithStreamItems: React.FunctionComponent<{ children: RendererFunction< @@ -30,7 +30,7 @@ export const WithStreamItems: React.FunctionComponent<{ logEntries.isReloading ? [] : logEntries.entries.map(logEntry => - createLogEntryStreamItem(logEntry, logEntryHighlightsById[logEntry.gid] || []) + createLogEntryStreamItem(logEntry, logEntryHighlightsById[logEntry.id] || []) ), [logEntries.entries, logEntries.isReloading, logEntryHighlightsById] @@ -46,7 +46,7 @@ export const WithStreamItems: React.FunctionComponent<{ const createLogEntryStreamItem = ( logEntry: LogEntry, - highlights: LogEntryHighlight[] + highlights: LogEntry[] ): LogEntryStreamItem => ({ kind: 'logEntry' as 'logEntry', logEntry, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx index 54609bcf8e2c2..023082154565c 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx @@ -44,11 +44,8 @@ export const CategoryExampleMessage: React.FunctionComponent<{ void; timeRange: TimeRange; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx index 5cb5f3a993d48..a9090a90c0b92 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx @@ -8,7 +8,7 @@ import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo, useState } from 'react'; - +import { useSet } from 'react-use'; import { euiStyled } from '../../../../../../../observability/public'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; import { @@ -64,9 +64,31 @@ export const AnomaliesTable: React.FunctionComponent<{ }); }, [results]); - const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState< - Record - >({}); + const [expandedDatasetIds, { add: expandDataset, remove: collapseDataset }] = useSet( + new Set() + ); + + const expandedDatasetRowContents = useMemo( + () => + [...expandedDatasetIds].reduce>( + (aggregatedDatasetRows, datasetId) => { + return { + ...aggregatedDatasetRows, + [getFriendlyNameForPartitionId(datasetId)]: ( + + ), + }; + }, + {} + ), + [expandedDatasetIds, jobId, results, setTimeRange, timeRange] + ); const [sorting, setSorting] = useState({ sort: { @@ -98,73 +120,43 @@ export const AnomaliesTable: React.FunctionComponent<{ return sorting.sort.direction === 'asc' ? sortedItems : sortedItems.reverse(); }, [tableItems, sorting]); - const expandItem = useCallback( - (item: TableItem) => { - const newItemIdToExpandedRowMap = { - ...itemIdToExpandedRowMap, - [item.partitionName]: ( - > = useMemo( + () => [ + { + field: 'partitionName', + name: partitionColumnName, + sortable: true, + truncateText: true, + }, + { + field: 'topAnomalyScore', + name: maxAnomalyScoreColumnName, + sortable: true, + truncateText: true, + dataType: 'number' as const, + }, + { + align: RIGHT_ALIGNMENT, + width: '40px', + isExpander: true, + render: (item: TableItem) => ( + ), - }; - setItemIdToExpandedRowMap(newItemIdToExpandedRowMap); - }, - [itemIdToExpandedRowMap, jobId, results, setTimeRange, timeRange] + }, + ], + [collapseDataset, expandDataset, expandedDatasetIds] ); - const collapseItem = useCallback( - (item: TableItem) => { - if (itemIdToExpandedRowMap[item.partitionName]) { - const { - [item.partitionName]: toggledItem, - ...remainingExpandedRowMap - } = itemIdToExpandedRowMap; - setItemIdToExpandedRowMap(remainingExpandedRowMap); - } - }, - [itemIdToExpandedRowMap] - ); - - const columns: Array> = [ - { - field: 'partitionName', - name: partitionColumnName, - sortable: true, - truncateText: true, - }, - { - field: 'topAnomalyScore', - name: maxAnomalyScoreColumnName, - sortable: true, - truncateText: true, - dataType: 'number' as const, - }, - { - align: RIGHT_ALIGNMENT, - width: '40px', - isExpander: true, - render: (item: TableItem) => ( - - ), - }, - ]; - return ( { const { source, sourceId, version } = useContext(Source.Context); - const { intervalSize, textScale, textWrap } = useContext(LogViewConfiguration.Context); + const { textScale, textWrap } = useContext(LogViewConfiguration.Context); const { setFlyoutVisibility, flyoutVisible, @@ -44,17 +43,20 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { const { logSummaryHighlights } = useContext(LogHighlightsState.Context); const { applyLogFilterQuery } = useContext(LogFilterState.Context); const { - isAutoReloading, + isStreaming, targetPosition, visibleMidpointTime, visibleTimeInterval, reportVisiblePositions, jumpToTargetPosition, + startLiveStreaming, stopLiveStreaming, + startDateExpression, + endDateExpression, + updateDateRange, } = useContext(LogPositionState.Context); return ( <> - @@ -90,7 +92,7 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { hasMoreBeforeStart={hasMoreBeforeStart} isLoadingMore={isLoadingMore} isReloading={isReloading} - isStreaming={isAutoReloading} + isStreaming={isStreaming} items={items} jumpToTarget={jumpToTargetPosition} lastLoadedTime={lastLoadedTime} @@ -104,6 +106,10 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { setFlyoutVisibility={setFlyoutVisibility} highlightedItem={surroundingLogsId ? surroundingLogsId : null} currentHighlightKey={currentHighlightKey} + startDateExpression={startDateExpression} + endDateExpression={endDateExpression} + updateDateRange={updateDateRange} + startLiveStreaming={startLiveStreaming} /> )} @@ -113,14 +119,15 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { return ( - {({ buckets }) => ( + {({ buckets, start, end }) => ( {({ isReloading }) => ( { const LogEntriesStateProvider: React.FC = ({ children }) => { const { sourceId } = useContext(Source.Context); const { + startTimestamp, + endTimestamp, + timestampsLastUpdate, targetPosition, pagesBeforeStart, pagesAfterEnd, - isAutoReloading, + isStreaming, jumpToTargetPosition, + isInitialized, } = useContext(LogPositionState.Context); const { filterQuery } = useContext(LogFilterState.Context); + // Don't render anything if the date range is incorrect. + if (!startTimestamp || !endTimestamp) { + return null; + } + const entriesProps = { + startTimestamp, + endTimestamp, + timestampsLastUpdate, timeKey: targetPosition, pagesBeforeStart, pagesAfterEnd, filterQuery, sourceId, - isAutoReloading, + isStreaming, jumpToTargetPosition, }; + + // Don't initialize the entries until the position has been fully intialized. + // See `` + if (!isInitialized) { + return null; + } + return {children}; }; const LogHighlightsStateProvider: React.FC = ({ children }) => { const { sourceId, version } = useContext(Source.Context); - const [{ entriesStart, entriesEnd }] = useContext(LogEntriesState.Context); + const [{ topCursor, bottomCursor, centerCursor, entries }] = useContext(LogEntriesState.Context); const { filterQuery } = useContext(LogFilterState.Context); + const highlightsProps = { sourceId, sourceVersion: version, - entriesStart, - entriesEnd, + entriesStart: topCursor, + entriesEnd: bottomCursor, + centerCursor, + size: entries.length, filterQuery, }; return {children}; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx index 000dfd1065f12..2f9a76fd47490 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx @@ -13,30 +13,22 @@ import { Toolbar } from '../../../components/eui'; import { LogCustomizationMenu } from '../../../components/logging/log_customization_menu'; import { LogHighlightsMenu } from '../../../components/logging/log_highlights_menu'; import { LogHighlightsState } from '../../../containers/logs/log_highlights/log_highlights'; -import { LogMinimapScaleControls } from '../../../components/logging/log_minimap_scale_controls'; import { LogTextScaleControls } from '../../../components/logging/log_text_scale_controls'; import { LogTextWrapControls } from '../../../components/logging/log_text_wrap_controls'; -import { LogTimeControls } from '../../../components/logging/log_time_controls'; import { LogFlyout } from '../../../containers/logs/log_flyout'; import { LogViewConfiguration } from '../../../containers/logs/log_view_configuration'; import { LogFilterState } from '../../../containers/logs/log_filter'; import { LogPositionState } from '../../../containers/logs/log_position'; import { Source } from '../../../containers/source'; import { WithKueryAutocompletion } from '../../../containers/with_kuery_autocompletion'; +import { LogDatepicker } from '../../../components/logging/log_datepicker'; export const LogsToolbar = () => { const { createDerivedIndexPattern } = useContext(Source.Context); const derivedIndexPattern = createDerivedIndexPattern('logs'); - const { - availableIntervalSizes, - availableTextScales, - intervalSize, - setIntervalSize, - setTextScale, - setTextWrap, - textScale, - textWrap, - } = useContext(LogViewConfiguration.Context); + const { availableTextScales, setTextScale, setTextWrap, textScale, textWrap } = useContext( + LogViewConfiguration.Context + ); const { filterQueryDraft, isFilterQueryDraftValid, @@ -55,12 +47,14 @@ export const LogsToolbar = () => { goToNextHighlight, } = useContext(LogHighlightsState.Context); const { - visibleMidpointTime, - isAutoReloading, - jumpToTargetPositionTime, + isStreaming, startLiveStreaming, stopLiveStreaming, + startDateExpression, + endDateExpression, + updateDateRange, } = useContext(LogPositionState.Context); + return ( @@ -94,11 +88,6 @@ export const LogsToolbar = () => { - { /> - { - startLiveStreaming(); - setSurroundingLogsId(null); - }} - stopLiveStreaming={stopLiveStreaming} + diff --git a/x-pack/plugins/infra/public/utils/datemath.test.ts b/x-pack/plugins/infra/public/utils/datemath.test.ts new file mode 100644 index 0000000000000..0f272733c5f97 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/datemath.test.ts @@ -0,0 +1,401 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + isValidDatemath, + datemathToEpochMillis, + extendDatemath, + convertDate, + normalizeDate, +} from './datemath'; +import sinon from 'sinon'; + +describe('isValidDatemath()', () => { + it('Returns `false` for empty strings', () => { + expect(isValidDatemath('')).toBe(false); + }); + + it('Returns `false` for invalid strings', () => { + expect(isValidDatemath('wadus')).toBe(false); + expect(isValidDatemath('nowww-')).toBe(false); + expect(isValidDatemath('now-')).toBe(false); + expect(isValidDatemath('now-1')).toBe(false); + expect(isValidDatemath('now-1d/')).toBe(false); + }); + + it('Returns `true` for valid strings', () => { + expect(isValidDatemath('now')).toBe(true); + expect(isValidDatemath('now-1d')).toBe(true); + expect(isValidDatemath('now-1d/d')).toBe(true); + }); +}); + +describe('datemathToEpochMillis()', () => { + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers(Date.now()); + }); + + afterEach(() => { + clock.restore(); + }); + + it('Returns `0` for the dawn of time', () => { + expect(datemathToEpochMillis('1970-01-01T00:00:00+00:00')).toEqual(0); + }); + + it('Returns the current timestamp when `now`', () => { + expect(datemathToEpochMillis('now')).toEqual(Date.now()); + }); +}); + +describe('extendDatemath()', () => { + it('Returns `undefined` for invalid values', () => { + expect(extendDatemath('')).toBeUndefined(); + }); + + it('Keeps `"now"` stable', () => { + expect(extendDatemath('now')).toEqual({ value: 'now' }); + expect(extendDatemath('now', 'before')).toEqual({ value: 'now' }); + expect(extendDatemath('now', 'after')).toEqual({ value: 'now' }); + }); + + describe('moving before', () => { + describe('with a negative operator', () => { + it('doubles miliseconds', () => { + expect(extendDatemath('now-250ms')).toEqual({ + value: 'now-500ms', + diffAmount: 250, + diffUnit: 'ms', + }); + }); + + it('normalizes miliseconds', () => { + expect(extendDatemath('now-500ms')).toEqual({ + value: 'now-1s', + diffAmount: 500, + diffUnit: 'ms', + }); + }); + + it('doubles seconds', () => { + expect(extendDatemath('now-10s')).toEqual({ + value: 'now-20s', + diffAmount: 10, + diffUnit: 's', + }); + }); + + it('normalizes seconds', () => { + expect(extendDatemath('now-30s')).toEqual({ + value: 'now-1m', + diffAmount: 30, + diffUnit: 's', + }); + }); + + it('doubles minutes when amount is low', () => { + expect(extendDatemath('now-1m')).toEqual({ value: 'now-2m', diffAmount: 1, diffUnit: 'm' }); + expect(extendDatemath('now-2m')).toEqual({ value: 'now-4m', diffAmount: 2, diffUnit: 'm' }); + expect(extendDatemath('now-3m')).toEqual({ value: 'now-6m', diffAmount: 3, diffUnit: 'm' }); + }); + + it('adds half the minutes when the amount is high', () => { + expect(extendDatemath('now-20m')).toEqual({ + value: 'now-30m', + diffAmount: 10, + diffUnit: 'm', + }); + }); + + it('Adds half an hour when the amount is one hour', () => { + expect(extendDatemath('now-1h')).toEqual({ + value: 'now-90m', + diffAmount: 30, + diffUnit: 'm', + }); + }); + + it('Adds one hour when the amount more than one hour', () => { + expect(extendDatemath('now-2h')).toEqual({ + value: 'now-3h', + diffAmount: 1, + diffUnit: 'h', + }); + }); + + it('Adds one hour when the amount is one day', () => { + expect(extendDatemath('now-1d')).toEqual({ + value: 'now-25h', + diffAmount: 1, + diffUnit: 'h', + }); + }); + + it('Adds one day when the amount is more than one day', () => { + expect(extendDatemath('now-2d')).toEqual({ + value: 'now-3d', + diffAmount: 1, + diffUnit: 'd', + }); + expect(extendDatemath('now-3d')).toEqual({ + value: 'now-4d', + diffAmount: 1, + diffUnit: 'd', + }); + }); + + it('Adds one day when the amount is one week', () => { + expect(extendDatemath('now-1w')).toEqual({ + value: 'now-8d', + diffAmount: 1, + diffUnit: 'd', + }); + }); + + it('Adds one week when the amount is more than one week', () => { + expect(extendDatemath('now-2w')).toEqual({ + value: 'now-3w', + diffAmount: 1, + diffUnit: 'w', + }); + }); + + it('Adds one week when the amount is one month', () => { + expect(extendDatemath('now-1M')).toEqual({ + value: 'now-5w', + diffAmount: 1, + diffUnit: 'w', + }); + }); + + it('Adds one month when the amount is more than one month', () => { + expect(extendDatemath('now-2M')).toEqual({ + value: 'now-3M', + diffAmount: 1, + diffUnit: 'M', + }); + }); + + it('Adds one month when the amount is one year', () => { + expect(extendDatemath('now-1y')).toEqual({ + value: 'now-13M', + diffAmount: 1, + diffUnit: 'M', + }); + }); + + it('Adds one year when the amount is in years', () => { + expect(extendDatemath('now-2y')).toEqual({ + value: 'now-3y', + diffAmount: 1, + diffUnit: 'y', + }); + }); + }); + + describe('with a positive Operator', () => { + it('Halves miliseconds', () => { + expect(extendDatemath('now+250ms')).toEqual({ + value: 'now+125ms', + diffAmount: 125, + diffUnit: 'ms', + }); + }); + + it('Halves seconds', () => { + expect(extendDatemath('now+10s')).toEqual({ + value: 'now+5s', + diffAmount: 5, + diffUnit: 's', + }); + }); + + it('Halves minutes when the amount is low', () => { + expect(extendDatemath('now+2m')).toEqual({ value: 'now+1m', diffAmount: 1, diffUnit: 'm' }); + expect(extendDatemath('now+4m')).toEqual({ value: 'now+2m', diffAmount: 2, diffUnit: 'm' }); + expect(extendDatemath('now+6m')).toEqual({ value: 'now+3m', diffAmount: 3, diffUnit: 'm' }); + }); + + it('Decreases minutes in half ammounts when the amount is high', () => { + expect(extendDatemath('now+30m')).toEqual({ + value: 'now+20m', + diffAmount: 10, + diffUnit: 'm', + }); + }); + + it('Decreases half an hour when the amount is one hour', () => { + expect(extendDatemath('now+1h')).toEqual({ + value: 'now+30m', + diffAmount: 30, + diffUnit: 'm', + }); + }); + + it('Removes one hour when the amount is one day', () => { + expect(extendDatemath('now+1d')).toEqual({ + value: 'now+23h', + diffAmount: 1, + diffUnit: 'h', + }); + }); + + it('Removes one day when the amount is more than one day', () => { + expect(extendDatemath('now+2d')).toEqual({ + value: 'now+1d', + diffAmount: 1, + diffUnit: 'd', + }); + expect(extendDatemath('now+3d')).toEqual({ + value: 'now+2d', + diffAmount: 1, + diffUnit: 'd', + }); + }); + + it('Removes one day when the amount is one week', () => { + expect(extendDatemath('now+1w')).toEqual({ + value: 'now+6d', + diffAmount: 1, + diffUnit: 'd', + }); + }); + + it('Removes one week when the amount is more than one week', () => { + expect(extendDatemath('now+2w')).toEqual({ + value: 'now+1w', + diffAmount: 1, + diffUnit: 'w', + }); + }); + + it('Removes one week when the amount is one month', () => { + expect(extendDatemath('now+1M')).toEqual({ + value: 'now+3w', + diffAmount: 1, + diffUnit: 'w', + }); + }); + + it('Removes one month when the amount is more than one month', () => { + expect(extendDatemath('now+2M')).toEqual({ + value: 'now+1M', + diffAmount: 1, + diffUnit: 'M', + }); + }); + + it('Removes one month when the amount is one year', () => { + expect(extendDatemath('now+1y')).toEqual({ + value: 'now+11M', + diffAmount: 1, + diffUnit: 'M', + }); + }); + + it('Adds one year when the amount is in years', () => { + expect(extendDatemath('now+2y')).toEqual({ + value: 'now+1y', + diffAmount: 1, + diffUnit: 'y', + }); + }); + }); + }); +}); + +describe('convertDate()', () => { + it('returns same value if units are the same', () => { + expect(convertDate(1, 'h', 'h')).toEqual(1); + }); + + it('converts from big units to small units', () => { + expect(convertDate(1, 's', 'ms')).toEqual(1000); + expect(convertDate(1, 'm', 'ms')).toEqual(60000); + expect(convertDate(1, 'h', 'ms')).toEqual(3600000); + expect(convertDate(1, 'd', 'ms')).toEqual(86400000); + expect(convertDate(1, 'M', 'ms')).toEqual(2592000000); + expect(convertDate(1, 'y', 'ms')).toEqual(31536000000); + }); + + it('converts from small units to big units', () => { + expect(convertDate(1000, 'ms', 's')).toEqual(1); + expect(convertDate(60000, 'ms', 'm')).toEqual(1); + expect(convertDate(3600000, 'ms', 'h')).toEqual(1); + expect(convertDate(86400000, 'ms', 'd')).toEqual(1); + expect(convertDate(2592000000, 'ms', 'M')).toEqual(1); + expect(convertDate(31536000000, 'ms', 'y')).toEqual(1); + }); + + it('Handles days to years', () => { + expect(convertDate(1, 'y', 'd')).toEqual(365); + expect(convertDate(365, 'd', 'y')).toEqual(1); + }); + + it('Handles years to months', () => { + expect(convertDate(1, 'y', 'M')).toEqual(12); + expect(convertDate(12, 'M', 'y')).toEqual(1); + }); + + it('Handles days to months', () => { + expect(convertDate(1, 'M', 'd')).toEqual(30); + expect(convertDate(30, 'd', 'M')).toEqual(1); + }); + + it('Handles days to weeks', () => { + expect(convertDate(1, 'w', 'd')).toEqual(7); + expect(convertDate(7, 'd', 'w')).toEqual(1); + }); + + it('Handles weeks to years', () => { + expect(convertDate(1, 'y', 'w')).toEqual(52); + expect(convertDate(52, 'w', 'y')).toEqual(1); + }); +}); + +describe('normalizeDate()', () => { + it('keeps units under the conversion ratio the same', () => { + expect(normalizeDate(999, 'ms')).toEqual({ amount: 999, unit: 'ms' }); + expect(normalizeDate(59, 's')).toEqual({ amount: 59, unit: 's' }); + expect(normalizeDate(59, 'm')).toEqual({ amount: 59, unit: 'm' }); + expect(normalizeDate(23, 'h')).toEqual({ amount: 23, unit: 'h' }); + expect(normalizeDate(6, 'd')).toEqual({ amount: 6, unit: 'd' }); + expect(normalizeDate(3, 'w')).toEqual({ amount: 3, unit: 'w' }); + expect(normalizeDate(11, 'M')).toEqual({ amount: 11, unit: 'M' }); + }); + + it('Moves to the next unit for values equal to the conversion ratio', () => { + expect(normalizeDate(1000, 'ms')).toEqual({ amount: 1, unit: 's' }); + expect(normalizeDate(60, 's')).toEqual({ amount: 1, unit: 'm' }); + expect(normalizeDate(60, 'm')).toEqual({ amount: 1, unit: 'h' }); + expect(normalizeDate(24, 'h')).toEqual({ amount: 1, unit: 'd' }); + expect(normalizeDate(7, 'd')).toEqual({ amount: 1, unit: 'w' }); + expect(normalizeDate(4, 'w')).toEqual({ amount: 1, unit: 'M' }); + expect(normalizeDate(12, 'M')).toEqual({ amount: 1, unit: 'y' }); + }); + + it('keeps units slightly over the conversion ratio the same', () => { + expect(normalizeDate(1001, 'ms')).toEqual({ amount: 1001, unit: 'ms' }); + expect(normalizeDate(61, 's')).toEqual({ amount: 61, unit: 's' }); + expect(normalizeDate(61, 'm')).toEqual({ amount: 61, unit: 'm' }); + expect(normalizeDate(25, 'h')).toEqual({ amount: 25, unit: 'h' }); + expect(normalizeDate(8, 'd')).toEqual({ amount: 8, unit: 'd' }); + expect(normalizeDate(5, 'w')).toEqual({ amount: 5, unit: 'w' }); + expect(normalizeDate(13, 'M')).toEqual({ amount: 13, unit: 'M' }); + }); + + it('moves to the next unit for any value higher than twice the conversion ratio', () => { + expect(normalizeDate(2001, 'ms')).toEqual({ amount: 2, unit: 's' }); + expect(normalizeDate(121, 's')).toEqual({ amount: 2, unit: 'm' }); + expect(normalizeDate(121, 'm')).toEqual({ amount: 2, unit: 'h' }); + expect(normalizeDate(49, 'h')).toEqual({ amount: 2, unit: 'd' }); + expect(normalizeDate(15, 'd')).toEqual({ amount: 2, unit: 'w' }); + expect(normalizeDate(9, 'w')).toEqual({ amount: 2, unit: 'M' }); + expect(normalizeDate(25, 'M')).toEqual({ amount: 2, unit: 'y' }); + }); +}); diff --git a/x-pack/plugins/infra/public/utils/datemath.ts b/x-pack/plugins/infra/public/utils/datemath.ts new file mode 100644 index 0000000000000..50a9b6e4f6945 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/datemath.ts @@ -0,0 +1,266 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import dateMath, { Unit } from '@elastic/datemath'; + +export function isValidDatemath(value: string): boolean { + const parsedValue = dateMath.parse(value); + return !!(parsedValue && parsedValue.isValid()); +} + +export function datemathToEpochMillis(value: string, round: 'down' | 'up' = 'down'): number | null { + const parsedValue = dateMath.parse(value, { roundUp: round === 'up' }); + if (!parsedValue || !parsedValue.isValid()) { + return null; + } + return parsedValue.valueOf(); +} + +type DatemathExtension = + | { + value: string; + diffUnit: Unit; + diffAmount: number; + } + | { value: 'now' }; + +const datemathNowExpression = /(\+|\-)(\d+)(ms|s|m|h|d|w|M|y)$/; + +/** + * Extend a datemath value + * @param value The value to extend + * @param {'before' | 'after'} direction Should the value move before or after in time + * @param oppositeEdge For absolute values, the value of the other edge of the range + */ +export function extendDatemath( + value: string, + direction: 'before' | 'after' = 'before', + oppositeEdge?: string +): DatemathExtension | undefined { + if (!isValidDatemath(value)) { + return undefined; + } + + // `now` cannot be extended + if (value === 'now') { + return { value: 'now' }; + } + + // The unit is relative + if (value.startsWith('now')) { + return extendRelativeDatemath(value, direction); + } else if (oppositeEdge && isValidDatemath(oppositeEdge)) { + return extendAbsoluteDatemath(value, direction, oppositeEdge); + } + + return undefined; +} + +function extendRelativeDatemath( + value: string, + direction: 'before' | 'after' +): DatemathExtension | undefined { + const [, operator, amount, unit] = datemathNowExpression.exec(value) || []; + if (!operator || !amount || !unit) { + return undefined; + } + + const mustIncreaseAmount = operator === '-' && direction === 'before'; + const parsedAmount = parseInt(amount, 10); + let newUnit: Unit = unit as Unit; + let newAmount: number; + + // Extend the amount + switch (unit) { + // For small units, always double or halve the amount + case 'ms': + case 's': + newAmount = mustIncreaseAmount ? parsedAmount * 2 : Math.floor(parsedAmount / 2); + break; + // For minutes, increase or decrease in doubles or halves, depending on + // the amount of minutes + case 'm': + let ratio; + const MINUTES_LARGE = 10; + if (mustIncreaseAmount) { + ratio = parsedAmount >= MINUTES_LARGE ? 0.5 : 1; + newAmount = parsedAmount + Math.floor(parsedAmount * ratio); + } else { + newAmount = + parsedAmount >= MINUTES_LARGE + ? Math.floor(parsedAmount / 1.5) + : parsedAmount - Math.floor(parsedAmount * 0.5); + } + break; + + // For hours, increase or decrease half an hour for 1 hour. Otherwise + // increase full hours + case 'h': + if (parsedAmount === 1) { + newAmount = mustIncreaseAmount ? 90 : 30; + newUnit = 'm'; + } else { + newAmount = mustIncreaseAmount ? parsedAmount + 1 : parsedAmount - 1; + } + break; + + // For the rest of units, increase or decrease one smaller unit for + // amounts of 1. Otherwise increase or decrease the unit + case 'd': + case 'w': + case 'M': + case 'y': + if (parsedAmount === 1) { + newUnit = dateMath.unitsDesc[dateMath.unitsDesc.indexOf(unit) + 1]; + newAmount = mustIncreaseAmount + ? convertDate(1, unit, newUnit) + 1 + : convertDate(1, unit, newUnit) - 1; + } else { + newAmount = mustIncreaseAmount ? parsedAmount + 1 : parsedAmount - 1; + } + break; + + default: + throw new TypeError('Unhandled datemath unit'); + } + + // normalize amount and unit (i.e. 120s -> 2m) + const { unit: normalizedUnit, amount: normalizedAmount } = normalizeDate(newAmount, newUnit); + + // How much have we changed the time? + const diffAmount = Math.abs(normalizedAmount - convertDate(parsedAmount, unit, normalizedUnit)); + // if `diffAmount` is not an integer after normalization, express the difference in the original unit + const shouldKeepDiffUnit = diffAmount % 1 !== 0; + + return { + value: `now${operator}${normalizedAmount}${normalizedUnit}`, + diffUnit: shouldKeepDiffUnit ? unit : newUnit, + diffAmount: shouldKeepDiffUnit ? Math.abs(newAmount - parsedAmount) : diffAmount, + }; +} + +function extendAbsoluteDatemath( + value: string, + direction: 'before' | 'after', + oppositeEdge: string +): DatemathExtension { + const valueTimestamp = datemathToEpochMillis(value)!; + const oppositeEdgeTimestamp = datemathToEpochMillis(oppositeEdge)!; + const actualTimestampDiff = Math.abs(valueTimestamp - oppositeEdgeTimestamp); + const normalizedDiff = normalizeDate(actualTimestampDiff, 'ms'); + const normalizedTimestampDiff = convertDate(normalizedDiff.amount, normalizedDiff.unit, 'ms'); + + const newValue = + direction === 'before' + ? valueTimestamp - normalizedTimestampDiff + : valueTimestamp + normalizedTimestampDiff; + + return { + value: new Date(newValue).toISOString(), + diffUnit: normalizedDiff.unit, + diffAmount: normalizedDiff.amount, + }; +} + +const CONVERSION_RATIOS: Record> = { + wy: [ + ['w', 52], // 1 year = 52 weeks + ['y', 1], + ], + w: [ + ['ms', 1000], + ['s', 60], + ['m', 60], + ['h', 24], + ['d', 7], // 1 week = 7 days + ['w', 4], // 1 month = 4 weeks = 28 days + ['M', 12], // 1 year = 12 months = 52 weeks = 364 days + ['y', 1], + ], + M: [ + ['ms', 1000], + ['s', 60], + ['m', 60], + ['h', 24], + ['d', 30], // 1 month = 30 days + ['M', 12], // 1 year = 12 months = 360 days + ['y', 1], + ], + default: [ + ['ms', 1000], + ['s', 60], + ['m', 60], + ['h', 24], + ['d', 365], // 1 year = 365 days + ['y', 1], + ], +}; + +function getRatioScale(from: Unit, to?: Unit) { + if ((from === 'y' && to === 'w') || (from === 'w' && to === 'y')) { + return CONVERSION_RATIOS.wy; + } else if (from === 'w' || to === 'w') { + return CONVERSION_RATIOS.w; + } else if (from === 'M' || to === 'M') { + return CONVERSION_RATIOS.M; + } else { + return CONVERSION_RATIOS.default; + } +} + +export function convertDate(value: number, from: Unit, to: Unit): number { + if (from === to) { + return value; + } + + const ratioScale = getRatioScale(from, to); + const fromIdx = ratioScale.findIndex(ratio => ratio[0] === from); + const toIdx = ratioScale.findIndex(ratio => ratio[0] === to); + + let convertedValue = value; + + if (fromIdx > toIdx) { + // `from` is the bigger unit. Multiply the value + for (let i = toIdx; i < fromIdx; i++) { + convertedValue *= ratioScale[i][1]; + } + } else { + // `from` is the smaller unit. Divide the value + for (let i = fromIdx; i < toIdx; i++) { + convertedValue /= ratioScale[i][1]; + } + } + + return convertedValue; +} + +export function normalizeDate(amount: number, unit: Unit): { amount: number; unit: Unit } { + // There is nothing after years + if (unit === 'y') { + return { amount, unit }; + } + + const nextUnit = dateMath.unitsAsc[dateMath.unitsAsc.indexOf(unit) + 1]; + const ratioScale = getRatioScale(unit, nextUnit); + const ratio = ratioScale.find(r => r[0] === unit)![1]; + + const newAmount = amount / ratio; + + // Exact conversion + if (newAmount === 1) { + return { amount: newAmount, unit: nextUnit }; + } + + // Might be able to go one unit more, so try again, rounding the value + // 7200s => 120m => 2h + // 7249s ~> 120m ~> 2h + if (newAmount >= 2) { + return normalizeDate(Math.round(newAmount), nextUnit); + } + + // Cannot go one one unit above. Return as it is + return { amount, unit }; +} diff --git a/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts b/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts index be6b8c40753ae..bb528ee5b18c5 100644 --- a/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts +++ b/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts @@ -8,23 +8,26 @@ import { bisector } from 'd3-array'; import { compareToTimeKey, getIndexAtTimeKey, TimeKey, UniqueTimeKey } from '../../../common/time'; import { InfraLogEntryFields } from '../../graphql/types'; - -export type LogEntry = InfraLogEntryFields.Fragment; - -export type LogEntryColumn = InfraLogEntryFields.Columns; -export type LogEntryMessageColumn = InfraLogEntryFields.InfraLogEntryMessageColumnInlineFragment; -export type LogEntryTimestampColumn = InfraLogEntryFields.InfraLogEntryTimestampColumnInlineFragment; -export type LogEntryFieldColumn = InfraLogEntryFields.InfraLogEntryFieldColumnInlineFragment; +import { + LogEntry, + LogColumn, + LogTimestampColumn, + LogFieldColumn, + LogMessageColumn, + LogMessagePart, + LogMessageFieldPart, + LogMessageConstantPart, +} from '../../../common/http_api'; export type LogEntryMessageSegment = InfraLogEntryFields.Message; export type LogEntryConstantMessageSegment = InfraLogEntryFields.InfraLogMessageConstantSegmentInlineFragment; export type LogEntryFieldMessageSegment = InfraLogEntryFields.InfraLogMessageFieldSegmentInlineFragment; -export const getLogEntryKey = (entry: { key: TimeKey }) => entry.key; +export const getLogEntryKey = (entry: { cursor: TimeKey }) => entry.cursor; -export const getUniqueLogEntryKey = (entry: { gid: string; key: TimeKey }): UniqueTimeKey => ({ - ...entry.key, - gid: entry.gid, +export const getUniqueLogEntryKey = (entry: { id: string; cursor: TimeKey }): UniqueTimeKey => ({ + ...entry.cursor, + gid: entry.id, }); const logEntryTimeBisector = bisector(compareToTimeKey(getLogEntryKey)); @@ -39,19 +42,17 @@ export const getLogEntryAtTime = (entries: LogEntry[], time: TimeKey) => { return entryIndex !== null ? entries[entryIndex] : null; }; -export const isTimestampColumn = (column: LogEntryColumn): column is LogEntryTimestampColumn => +export const isTimestampColumn = (column: LogColumn): column is LogTimestampColumn => column != null && 'timestamp' in column; -export const isMessageColumn = (column: LogEntryColumn): column is LogEntryMessageColumn => +export const isMessageColumn = (column: LogColumn): column is LogMessageColumn => column != null && 'message' in column; -export const isFieldColumn = (column: LogEntryColumn): column is LogEntryFieldColumn => +export const isFieldColumn = (column: LogColumn): column is LogFieldColumn => column != null && 'field' in column; -export const isConstantSegment = ( - segment: LogEntryMessageSegment -): segment is LogEntryConstantMessageSegment => 'constant' in segment; +export const isConstantSegment = (segment: LogMessagePart): segment is LogMessageConstantPart => + 'constant' in segment; -export const isFieldSegment = ( - segment: LogEntryMessageSegment -): segment is LogEntryFieldMessageSegment => 'field' in segment && 'value' in segment; +export const isFieldSegment = (segment: LogMessagePart): segment is LogMessageFieldPart => + 'field' in segment && 'value' in segment; diff --git a/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts b/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts index 3361faa23a124..abb004911214b 100644 --- a/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts +++ b/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts @@ -5,8 +5,14 @@ */ import { InfraLogEntryHighlightFields } from '../../graphql/types'; - -export type LogEntryHighlight = InfraLogEntryHighlightFields.Fragment; +import { + LogEntry, + LogColumn, + LogMessageColumn, + LogFieldColumn, + LogMessagePart, + LogMessageFieldPart, +} from '../../../common/http_api'; export type LogEntryHighlightColumn = InfraLogEntryHighlightFields.Columns; export type LogEntryHighlightMessageColumn = InfraLogEntryHighlightFields.InfraLogEntryMessageColumnInlineFragment; @@ -16,18 +22,14 @@ export type LogEntryHighlightMessageSegment = InfraLogEntryHighlightFields.Messa export type LogEntryHighlightFieldMessageSegment = InfraLogEntryHighlightFields.InfraLogMessageFieldSegmentInlineFragment; export interface LogEntryHighlightsMap { - [entryId: string]: LogEntryHighlight[]; + [entryId: string]: LogEntry[]; } -export const isHighlightMessageColumn = ( - column: LogEntryHighlightColumn -): column is LogEntryHighlightMessageColumn => column != null && 'message' in column; +export const isHighlightMessageColumn = (column: LogColumn): column is LogMessageColumn => + column != null && 'message' in column; -export const isHighlightFieldColumn = ( - column: LogEntryHighlightColumn -): column is LogEntryHighlightFieldColumn => column != null && 'field' in column; +export const isHighlightFieldColumn = (column: LogColumn): column is LogFieldColumn => + column != null && 'field' in column; -export const isHighlightFieldSegment = ( - segment: LogEntryHighlightMessageSegment -): segment is LogEntryHighlightFieldMessageSegment => +export const isHighlightFieldSegment = (segment: LogMessagePart): segment is LogMessageFieldPart => segment && 'field' in segment && 'highlights' in segment; diff --git a/x-pack/plugins/infra/server/graphql/index.ts b/x-pack/plugins/infra/server/graphql/index.ts index 82fef41db1a73..f5150972a3a65 100644 --- a/x-pack/plugins/infra/server/graphql/index.ts +++ b/x-pack/plugins/infra/server/graphql/index.ts @@ -6,14 +6,7 @@ import { rootSchema } from '../../common/graphql/root/schema.gql'; import { sharedSchema } from '../../common/graphql/shared/schema.gql'; -import { logEntriesSchema } from './log_entries/schema.gql'; import { sourceStatusSchema } from './source_status/schema.gql'; import { sourcesSchema } from './sources/schema.gql'; -export const schemas = [ - rootSchema, - sharedSchema, - logEntriesSchema, - sourcesSchema, - sourceStatusSchema, -]; +export const schemas = [rootSchema, sharedSchema, sourcesSchema, sourceStatusSchema]; diff --git a/x-pack/plugins/infra/server/graphql/log_entries/resolvers.ts b/x-pack/plugins/infra/server/graphql/log_entries/resolvers.ts deleted file mode 100644 index edbb736b2c4fd..0000000000000 --- a/x-pack/plugins/infra/server/graphql/log_entries/resolvers.ts +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - InfraLogEntryColumn, - InfraLogEntryFieldColumn, - InfraLogEntryMessageColumn, - InfraLogEntryTimestampColumn, - InfraLogMessageConstantSegment, - InfraLogMessageFieldSegment, - InfraLogMessageSegment, - InfraSourceResolvers, -} from '../../graphql/types'; -import { InfraLogEntriesDomain } from '../../lib/domains/log_entries_domain'; -import { parseFilterQuery } from '../../utils/serialized_query'; -import { ChildResolverOf, InfraResolverOf } from '../../utils/typed_resolvers'; -import { QuerySourceResolver } from '../sources/resolvers'; - -export type InfraSourceLogEntriesAroundResolver = ChildResolverOf< - InfraResolverOf, - QuerySourceResolver ->; - -export type InfraSourceLogEntriesBetweenResolver = ChildResolverOf< - InfraResolverOf, - QuerySourceResolver ->; - -export type InfraSourceLogEntryHighlightsResolver = ChildResolverOf< - InfraResolverOf, - QuerySourceResolver ->; - -export const createLogEntriesResolvers = (libs: { - logEntries: InfraLogEntriesDomain; -}): { - InfraSource: { - logEntriesAround: InfraSourceLogEntriesAroundResolver; - logEntriesBetween: InfraSourceLogEntriesBetweenResolver; - logEntryHighlights: InfraSourceLogEntryHighlightsResolver; - }; - InfraLogEntryColumn: { - __resolveType( - logEntryColumn: InfraLogEntryColumn - ): - | 'InfraLogEntryTimestampColumn' - | 'InfraLogEntryMessageColumn' - | 'InfraLogEntryFieldColumn' - | null; - }; - InfraLogMessageSegment: { - __resolveType( - messageSegment: InfraLogMessageSegment - ): 'InfraLogMessageFieldSegment' | 'InfraLogMessageConstantSegment' | null; - }; -} => ({ - InfraSource: { - async logEntriesAround(source, args, { req }) { - const countBefore = args.countBefore || 0; - const countAfter = args.countAfter || 0; - - const { entriesBefore, entriesAfter } = await libs.logEntries.getLogEntriesAround( - req, - source.id, - args.key, - countBefore + 1, - countAfter + 1, - parseFilterQuery(args.filterQuery) - ); - - const hasMoreBefore = entriesBefore.length > countBefore; - const hasMoreAfter = entriesAfter.length > countAfter; - - const entries = [ - ...(hasMoreBefore ? entriesBefore.slice(1) : entriesBefore), - ...(hasMoreAfter ? entriesAfter.slice(0, -1) : entriesAfter), - ]; - - return { - start: entries.length > 0 ? entries[0].key : null, - end: entries.length > 0 ? entries[entries.length - 1].key : null, - hasMoreBefore, - hasMoreAfter, - filterQuery: args.filterQuery, - entries, - }; - }, - async logEntriesBetween(source, args, { req }) { - const entries = await libs.logEntries.getLogEntriesBetween( - req, - source.id, - args.startKey, - args.endKey, - parseFilterQuery(args.filterQuery) - ); - - return { - start: entries.length > 0 ? entries[0].key : null, - end: entries.length > 0 ? entries[entries.length - 1].key : null, - hasMoreBefore: true, - hasMoreAfter: true, - filterQuery: args.filterQuery, - entries, - }; - }, - async logEntryHighlights(source, args, { req }) { - const highlightedLogEntrySets = await libs.logEntries.getLogEntryHighlights( - req, - source.id, - args.startKey, - args.endKey, - args.highlights.filter(highlightInput => !!highlightInput.query), - parseFilterQuery(args.filterQuery) - ); - - return highlightedLogEntrySets.map(entries => ({ - start: entries.length > 0 ? entries[0].key : null, - end: entries.length > 0 ? entries[entries.length - 1].key : null, - hasMoreBefore: true, - hasMoreAfter: true, - filterQuery: args.filterQuery, - entries, - })); - }, - }, - InfraLogEntryColumn: { - __resolveType(logEntryColumn) { - if (isTimestampColumn(logEntryColumn)) { - return 'InfraLogEntryTimestampColumn'; - } - - if (isMessageColumn(logEntryColumn)) { - return 'InfraLogEntryMessageColumn'; - } - - if (isFieldColumn(logEntryColumn)) { - return 'InfraLogEntryFieldColumn'; - } - - return null; - }, - }, - InfraLogMessageSegment: { - __resolveType(messageSegment) { - if (isConstantSegment(messageSegment)) { - return 'InfraLogMessageConstantSegment'; - } - - if (isFieldSegment(messageSegment)) { - return 'InfraLogMessageFieldSegment'; - } - - return null; - }, - }, -}); - -const isTimestampColumn = (column: InfraLogEntryColumn): column is InfraLogEntryTimestampColumn => - 'timestamp' in column; - -const isMessageColumn = (column: InfraLogEntryColumn): column is InfraLogEntryMessageColumn => - 'message' in column; - -const isFieldColumn = (column: InfraLogEntryColumn): column is InfraLogEntryFieldColumn => - 'field' in column && 'value' in column; - -const isConstantSegment = ( - segment: InfraLogMessageSegment -): segment is InfraLogMessageConstantSegment => 'constant' in segment; - -const isFieldSegment = (segment: InfraLogMessageSegment): segment is InfraLogMessageFieldSegment => - 'field' in segment && 'value' in segment && 'highlights' in segment; diff --git a/x-pack/plugins/infra/server/graphql/log_entries/schema.gql.ts b/x-pack/plugins/infra/server/graphql/log_entries/schema.gql.ts deleted file mode 100644 index 945f2f85435e5..0000000000000 --- a/x-pack/plugins/infra/server/graphql/log_entries/schema.gql.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; - * you may not use this file except in compliance with the Elastic License. - */ - -import gql from 'graphql-tag'; - -export const logEntriesSchema = gql` - "A segment of the log entry message that was derived from a field" - type InfraLogMessageFieldSegment { - "The field the segment was derived from" - field: String! - "The segment's message" - value: String! - "A list of highlighted substrings of the value" - highlights: [String!]! - } - - "A segment of the log entry message that was derived from a string literal" - type InfraLogMessageConstantSegment { - "The segment's message" - constant: String! - } - - "A segment of the log entry message" - union InfraLogMessageSegment = InfraLogMessageFieldSegment | InfraLogMessageConstantSegment - - "A special built-in column that contains the log entry's timestamp" - type InfraLogEntryTimestampColumn { - "The id of the corresponding column configuration" - columnId: ID! - "The timestamp" - timestamp: Float! - } - - "A special built-in column that contains the log entry's constructed message" - type InfraLogEntryMessageColumn { - "The id of the corresponding column configuration" - columnId: ID! - "A list of the formatted log entry segments" - message: [InfraLogMessageSegment!]! - } - - "A column that contains the value of a field of the log entry" - type InfraLogEntryFieldColumn { - "The id of the corresponding column configuration" - columnId: ID! - "The field name of the column" - field: String! - "The value of the field in the log entry" - value: String! - "A list of highlighted substrings of the value" - highlights: [String!]! - } - - "A column of a log entry" - union InfraLogEntryColumn = - InfraLogEntryTimestampColumn - | InfraLogEntryMessageColumn - | InfraLogEntryFieldColumn - - "A log entry" - type InfraLogEntry { - "A unique representation of the log entry's position in the event stream" - key: InfraTimeKey! - "The log entry's id" - gid: String! - "The source id" - source: String! - "The columns used for rendering the log entry" - columns: [InfraLogEntryColumn!]! - } - - "A highlighting definition" - input InfraLogEntryHighlightInput { - "The query to highlight by" - query: String! - "The number of highlighted documents to include beyond the beginning of the interval" - countBefore: Int! - "The number of highlighted documents to include beyond the end of the interval" - countAfter: Int! - } - - "A consecutive sequence of log entries" - type InfraLogEntryInterval { - "The key corresponding to the start of the interval covered by the entries" - start: InfraTimeKey - "The key corresponding to the end of the interval covered by the entries" - end: InfraTimeKey - "Whether there are more log entries available before the start" - hasMoreBefore: Boolean! - "Whether there are more log entries available after the end" - hasMoreAfter: Boolean! - "The query the log entries were filtered by" - filterQuery: String - "The query the log entries were highlighted with" - highlightQuery: String - "A list of the log entries" - entries: [InfraLogEntry!]! - } - - extend type InfraSource { - "A consecutive span of log entries surrounding a point in time" - logEntriesAround( - "The sort key that corresponds to the point in time" - key: InfraTimeKeyInput! - "The maximum number of preceding to return" - countBefore: Int = 0 - "The maximum number of following to return" - countAfter: Int = 0 - "The query to filter the log entries by" - filterQuery: String - ): InfraLogEntryInterval! - "A consecutive span of log entries within an interval" - logEntriesBetween( - "The sort key that corresponds to the start of the interval" - startKey: InfraTimeKeyInput! - "The sort key that corresponds to the end of the interval" - endKey: InfraTimeKeyInput! - "The query to filter the log entries by" - filterQuery: String - ): InfraLogEntryInterval! - "Sequences of log entries matching sets of highlighting queries within an interval" - logEntryHighlights( - "The sort key that corresponds to the start of the interval" - startKey: InfraTimeKeyInput! - "The sort key that corresponds to the end of the interval" - endKey: InfraTimeKeyInput! - "The query to filter the log entries by" - filterQuery: String - "The highlighting to apply to the log entries" - highlights: [InfraLogEntryHighlightInput!]! - ): [InfraLogEntryInterval!]! - } -`; diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index f058b9e52c75b..fb9dd172bf6ed 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -7,7 +7,6 @@ import { IResolvers, makeExecutableSchema } from 'graphql-tools'; import { initIpToHostName } from './routes/ip_to_hostname'; import { schemas } from './graphql'; -import { createLogEntriesResolvers } from './graphql/log_entries'; import { createSourceStatusResolvers } from './graphql/source_status'; import { createSourcesResolvers } from './graphql/sources'; import { InfraBackendLibs } from './lib/infra_types'; @@ -34,7 +33,6 @@ import { initInventoryMetaRoute } from './routes/inventory_metadata'; export const initInfraServer = (libs: InfraBackendLibs) => { const schema = makeExecutableSchema({ resolvers: [ - createLogEntriesResolvers(libs) as IResolvers, createSourcesResolvers(libs) as IResolvers, createSourceStatusResolvers(libs) as IResolvers, ], diff --git a/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts index f48c949329b04..3a5dff75f004e 100644 --- a/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts +++ b/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts @@ -8,12 +8,11 @@ import { timeMilliseconds } from 'd3-time'; import * as runtimeTypes from 'io-ts'; -import { compact, first, get, has, zip } from 'lodash'; +import { compact, first, get, has } from 'lodash'; import { pipe } from 'fp-ts/lib/pipeable'; import { map, fold } from 'fp-ts/lib/Either'; import { identity, constant } from 'fp-ts/lib/function'; import { RequestHandlerContext } from 'src/core/server'; -import { compareTimeKeys, isTimeKey, TimeKey } from '../../../../common/time'; import { JsonObject, JsonValue } from '../../../../common/typed_json'; import { LogEntriesAdapter, @@ -27,8 +26,6 @@ import { InfraSourceConfiguration } from '../../sources'; import { SortedSearchHit } from '../framework'; import { KibanaFramework } from '../framework/kibana_framework_adapter'; -const DAY_MILLIS = 24 * 60 * 60 * 1000; -const LOOKUP_OFFSETS = [0, 1, 7, 30, 365, 10000, Infinity].map(days => days * DAY_MILLIS); const TIMESTAMP_FORMAT = 'epoch_millis'; interface LogItemHit { @@ -41,53 +38,13 @@ interface LogItemHit { export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { constructor(private readonly framework: KibanaFramework) {} - public async getAdjacentLogEntryDocuments( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: TimeKey, - direction: 'asc' | 'desc', - maxCount: number, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise { - if (maxCount <= 0) { - return []; - } - - const intervals = getLookupIntervals(start.time, direction); - - let documents: LogEntryDocument[] = []; - for (const [intervalStart, intervalEnd] of intervals) { - if (documents.length >= maxCount) { - break; - } - - const documentsInInterval = await this.getLogEntryDocumentsBetween( - requestContext, - sourceConfiguration, - fields, - intervalStart, - intervalEnd, - documents.length > 0 ? documents[documents.length - 1].key : start, - maxCount - documents.length, - filterQuery, - highlightQuery - ); - - documents = [...documents, ...documentsInInterval]; - } - - return direction === 'asc' ? documents : documents.reverse(); - } - public async getLogEntries( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, fields: string[], params: LogEntriesParams ): Promise { - const { startDate, endDate, query, cursor, size, highlightTerm } = params; + const { startTimestamp, endTimestamp, query, cursor, size, highlightTerm } = params; const { sortDirection, searchAfterClause } = processCursor(cursor); @@ -133,8 +90,8 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { { range: { [sourceConfiguration.fields.timestamp]: { - gte: startDate, - lte: endDate, + gte: startTimestamp, + lte: endTimestamp, format: TIMESTAMP_FORMAT, }, }, @@ -158,40 +115,19 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { return mapHitsToLogEntryDocuments(hits, sourceConfiguration.fields.timestamp, fields); } - /** @deprecated */ - public async getContainedLogEntryDocuments( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: TimeKey, - end: TimeKey, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise { - const documents = await this.getLogEntryDocumentsBetween( - requestContext, - sourceConfiguration, - fields, - start.time, - end.time, - start, - 10000, - filterQuery, - highlightQuery - ); - - return documents.filter(document => compareTimeKeys(document.key, end) < 0); - } - public async getContainedLogSummaryBuckets( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, - start: number, - end: number, + startTimestamp: number, + endTimestamp: number, bucketSize: number, filterQuery?: LogEntryQuery ): Promise { - const bucketIntervalStarts = timeMilliseconds(new Date(start), new Date(end), bucketSize); + const bucketIntervalStarts = timeMilliseconds( + new Date(startTimestamp), + new Date(endTimestamp), + bucketSize + ); const query = { allowNoIndices: true, @@ -229,8 +165,8 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { { range: { [sourceConfiguration.fields.timestamp]: { - gte: start, - lte: end, + gte: startTimestamp, + lte: endTimestamp, format: TIMESTAMP_FORMAT, }, }, @@ -288,112 +224,6 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { } return document; } - - private async getLogEntryDocumentsBetween( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: number, - end: number, - after: TimeKey | null, - maxCount: number, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise { - if (maxCount <= 0) { - return []; - } - - const sortDirection: 'asc' | 'desc' = start <= end ? 'asc' : 'desc'; - - const startRange = { - [sortDirection === 'asc' ? 'gte' : 'lte']: start, - }; - const endRange = - end === Infinity - ? {} - : { - [sortDirection === 'asc' ? 'lte' : 'gte']: end, - }; - - const highlightClause = highlightQuery - ? { - highlight: { - boundary_scanner: 'word', - fields: fields.reduce( - (highlightFieldConfigs, fieldName) => ({ - ...highlightFieldConfigs, - [fieldName]: {}, - }), - {} - ), - fragment_size: 1, - number_of_fragments: 100, - post_tags: [''], - pre_tags: [''], - highlight_query: highlightQuery, - }, - } - : {}; - - const searchAfterClause = isTimeKey(after) - ? { - search_after: [after.time, after.tiebreaker], - } - : {}; - - const query = { - allowNoIndices: true, - index: sourceConfiguration.logAlias, - ignoreUnavailable: true, - body: { - query: { - bool: { - filter: [ - ...createQueryFilterClauses(filterQuery), - { - range: { - [sourceConfiguration.fields.timestamp]: { - ...startRange, - ...endRange, - format: TIMESTAMP_FORMAT, - }, - }, - }, - ], - }, - }, - ...highlightClause, - ...searchAfterClause, - _source: fields, - size: maxCount, - sort: [ - { [sourceConfiguration.fields.timestamp]: sortDirection }, - { [sourceConfiguration.fields.tiebreaker]: sortDirection }, - ], - track_total_hits: false, - }, - }; - - const response = await this.framework.callWithRequest( - requestContext, - 'search', - query - ); - const hits = response.hits.hits; - const documents = hits.map(convertHitToLogEntryDocument(fields)); - - return documents; - } -} - -function getLookupIntervals(start: number, direction: 'asc' | 'desc'): Array<[number, number]> { - const offsetSign = direction === 'asc' ? 1 : -1; - const translatedOffsets = LOOKUP_OFFSETS.map(offset => start + offset * offsetSign); - const intervals = zip(translatedOffsets.slice(0, -1), translatedOffsets.slice(1)) as Array< - [number, number] - >; - return intervals; } function mapHitsToLogEntryDocuments( @@ -423,28 +253,6 @@ function mapHitsToLogEntryDocuments( }); } -/** @deprecated */ -const convertHitToLogEntryDocument = (fields: string[]) => ( - hit: SortedSearchHit -): LogEntryDocument => ({ - gid: hit._id, - fields: fields.reduce( - (flattenedFields, fieldName) => - has(hit._source, fieldName) - ? { - ...flattenedFields, - [fieldName]: get(hit._source, fieldName), - } - : flattenedFields, - {} as { [fieldName: string]: string | number | object | boolean | null } - ), - highlights: hit.highlight || {}, - key: { - time: hit.sort[0], - tiebreaker: hit.sort[1], - }, -}); - const convertDateRangeBucketToSummaryBucket = ( bucket: LogSummaryDateRangeBucket ): LogSummaryBucket => ({ diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts new file mode 100644 index 0000000000000..a6b9b70feede2 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -0,0 +1,244 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; +import { Comparator, AlertStates } from './types'; +import * as mocks from './test_mocks'; +import { AlertExecutorOptions } from '../../../../../alerting/server'; + +const executor = createMetricThresholdExecutor('test') as (opts: { + params: AlertExecutorOptions['params']; + services: { callCluster: AlertExecutorOptions['params']['callCluster'] }; +}) => Promise; +const alertInstances = new Map(); + +const services = { + callCluster(_: string, { body }: any) { + const metric = body.query.bool.filter[1].exists.field; + if (body.aggs.groupings) { + if (body.aggs.groupings.composite.after) { + return mocks.compositeEndResponse; + } + if (metric === 'test.metric.2') { + return mocks.alternateCompositeResponse; + } + return mocks.basicCompositeResponse; + } + if (metric === 'test.metric.2') { + return mocks.alternateMetricResponse; + } + return mocks.basicMetricResponse; + }, + alertInstanceFactory(instanceID: string) { + let state: any; + const actionQueue: any[] = []; + const instance = { + actionQueue: [], + get state() { + return state; + }, + get mostRecentAction() { + return actionQueue.pop(); + }, + }; + alertInstances.set(instanceID, instance); + return { + instanceID, + scheduleActions(id: string, action: any) { + actionQueue.push({ id, action }); + }, + replaceState(newState: any) { + state = newState; + }, + }; + }, +}; + +const baseCriterion = { + aggType: 'avg', + metric: 'test.metric.1', + timeSize: 1, + timeUnit: 'm', + indexPattern: 'metricbeat-*', +}; +describe('The metric threshold alert type', () => { + describe('querying the entire infrastructure', () => { + const instanceID = 'test-*'; + const execute = (comparator: Comparator, threshold: number[]) => + executor({ + services, + params: { + criteria: [ + { + ...baseCriterion, + comparator, + threshold, + }, + ], + }, + }); + test('alerts as expected with the > comparator', async () => { + await execute(Comparator.GT, [0.75]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.GT, [1.5]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + test('alerts as expected with the < comparator', async () => { + await execute(Comparator.LT, [1.5]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.LT, [0.75]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + test('alerts as expected with the >= comparator', async () => { + await execute(Comparator.GT_OR_EQ, [0.75]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.GT_OR_EQ, [1.0]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.GT_OR_EQ, [1.5]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + test('alerts as expected with the <= comparator', async () => { + await execute(Comparator.LT_OR_EQ, [1.5]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.LT_OR_EQ, [1.0]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.LT_OR_EQ, [0.75]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + test('alerts as expected with the between comparator', async () => { + await execute(Comparator.BETWEEN, [0, 1.5]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.BETWEEN, [0, 0.75]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + }); + + describe('querying with a groupBy parameter', () => { + const execute = (comparator: Comparator, threshold: number[]) => + executor({ + services, + params: { + groupBy: 'something', + criteria: [ + { + ...baseCriterion, + comparator, + threshold, + }, + ], + }, + }); + const instanceIdA = 'test-a'; + const instanceIdB = 'test-b'; + test('sends an alert when all groups pass the threshold', async () => { + await execute(Comparator.GT, [0.75]); + expect(alertInstances.get(instanceIdA).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceIdA).state.alertState).toBe(AlertStates.ALERT); + expect(alertInstances.get(instanceIdB).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceIdB).state.alertState).toBe(AlertStates.ALERT); + }); + test('sends an alert when only some groups pass the threshold', async () => { + await execute(Comparator.LT, [1.5]); + expect(alertInstances.get(instanceIdA).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceIdA).state.alertState).toBe(AlertStates.ALERT); + expect(alertInstances.get(instanceIdB).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceIdB).state.alertState).toBe(AlertStates.OK); + }); + test('sends no alert when no groups pass the threshold', async () => { + await execute(Comparator.GT, [5]); + expect(alertInstances.get(instanceIdA).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceIdA).state.alertState).toBe(AlertStates.OK); + expect(alertInstances.get(instanceIdB).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceIdB).state.alertState).toBe(AlertStates.OK); + }); + }); + + describe('querying with multiple criteria', () => { + const execute = ( + comparator: Comparator, + thresholdA: number[], + thresholdB: number[], + groupBy: string = '' + ) => + executor({ + services, + params: { + groupBy, + criteria: [ + { + ...baseCriterion, + comparator, + threshold: thresholdA, + }, + { + ...baseCriterion, + comparator, + threshold: thresholdB, + metric: 'test.metric.2', + }, + ], + }, + }); + test('sends an alert when all criteria cross the threshold', async () => { + const instanceID = 'test-*'; + await execute(Comparator.GT_OR_EQ, [1.0], [3.0]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + }); + test('sends no alert when some, but not all, criteria cross the threshold', async () => { + const instanceID = 'test-*'; + await execute(Comparator.LT_OR_EQ, [1.0], [3.0]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + test('alerts only on groups that meet all criteria when querying with a groupBy parameter', async () => { + const instanceIdA = 'test-a'; + const instanceIdB = 'test-b'; + await execute(Comparator.GT_OR_EQ, [1.0], [3.0], 'something'); + expect(alertInstances.get(instanceIdA).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceIdA).state.alertState).toBe(AlertStates.ALERT); + expect(alertInstances.get(instanceIdB).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceIdB).state.alertState).toBe(AlertStates.OK); + }); + }); + describe('querying with the count aggregator', () => { + const instanceID = 'test-*'; + const execute = (comparator: Comparator, threshold: number[]) => + executor({ + services, + params: { + criteria: [ + { + ...baseCriterion, + comparator, + threshold, + aggType: 'count', + }, + ], + }, + }); + test('alerts based on the doc_count value instead of the aggregatedValue', async () => { + await execute(Comparator.GT, [2]); + expect(alertInstances.get(instanceID).mostRecentAction.id).toBe(FIRED_ACTIONS.id); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.ALERT); + await execute(Comparator.LT, [1.5]); + expect(alertInstances.get(instanceID).mostRecentAction).toBe(undefined); + expect(alertInstances.get(instanceID).state.alertState).toBe(AlertStates.OK); + }); + }); +}); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts new file mode 100644 index 0000000000000..8c509c017cf20 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -0,0 +1,255 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { mapValues } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { InfraDatabaseSearchResponse } from '../../adapters/framework/adapter_types'; +import { createAfterKeyHandler } from '../../../utils/create_afterkey_handler'; +import { getAllCompositeData } from '../../../utils/get_all_composite_data'; +import { networkTraffic } from '../../../../common/inventory_models/shared/metrics/snapshot/network_traffic'; +import { MetricExpressionParams, Comparator, AlertStates } from './types'; +import { AlertServices, AlertExecutorOptions } from '../../../../../alerting/server'; + +interface Aggregation { + aggregatedIntervals: { + buckets: Array<{ aggregatedValue: { value: number }; doc_count: number }>; + }; +} + +interface CompositeAggregationsResponse { + groupings: { + buckets: Aggregation[]; + }; +} + +const getCurrentValueFromAggregations = ( + aggregations: Aggregation, + aggType: MetricExpressionParams['aggType'] +) => { + try { + const { buckets } = aggregations.aggregatedIntervals; + if (!buckets.length) return null; // No Data state + const mostRecentBucket = buckets[buckets.length - 1]; + if (aggType === 'count') { + return mostRecentBucket.doc_count; + } + const { value } = mostRecentBucket.aggregatedValue; + return value; + } catch (e) { + return undefined; // Error state + } +}; + +const getParsedFilterQuery: ( + filterQuery: string | undefined +) => Record = filterQuery => { + if (!filterQuery) return {}; + try { + return JSON.parse(filterQuery).bool; + } catch (e) { + return { + query_string: { + query: filterQuery, + analyze_wildcard: true, + }, + }; + } +}; + +export const getElasticsearchMetricQuery = ( + { metric, aggType, timeUnit, timeSize }: MetricExpressionParams, + groupBy?: string, + filterQuery?: string +) => { + const interval = `${timeSize}${timeUnit}`; + + const aggregations = + aggType === 'count' + ? {} + : aggType === 'rate' + ? networkTraffic('aggregatedValue', metric) + : { + aggregatedValue: { + [aggType]: { + field: metric, + }, + }, + }; + + const baseAggs = { + aggregatedIntervals: { + date_histogram: { + field: '@timestamp', + fixed_interval: interval, + }, + aggregations, + }, + }; + + const aggs = groupBy + ? { + groupings: { + composite: { + size: 10, + sources: [ + { + groupBy: { + terms: { + field: groupBy, + }, + }, + }, + ], + }, + aggs: baseAggs, + }, + } + : baseAggs; + + const parsedFilterQuery = getParsedFilterQuery(filterQuery); + + return { + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: `now-${interval}`, + }, + }, + }, + { + exists: { + field: metric, + }, + }, + ], + ...parsedFilterQuery, + }, + }, + size: 0, + aggs, + }; +}; + +const getMetric: ( + services: AlertServices, + params: MetricExpressionParams, + groupBy: string | undefined, + filterQuery: string | undefined +) => Promise> = async function( + { callCluster }, + params, + groupBy, + filterQuery +) { + const { indexPattern, aggType } = params; + const searchBody = getElasticsearchMetricQuery(params, groupBy, filterQuery); + + try { + if (groupBy) { + const bucketSelector = ( + response: InfraDatabaseSearchResponse<{}, CompositeAggregationsResponse> + ) => response.aggregations?.groupings?.buckets || []; + const afterKeyHandler = createAfterKeyHandler( + 'aggs.groupings.composite.after', + response => response.aggregations?.groupings?.after_key + ); + const compositeBuckets = (await getAllCompositeData( + body => callCluster('search', { body, index: indexPattern }), + searchBody, + bucketSelector, + afterKeyHandler + )) as Array; + return compositeBuckets.reduce( + (result, bucket) => ({ + ...result, + [bucket.key.groupBy]: getCurrentValueFromAggregations(bucket, aggType), + }), + {} + ); + } + const result = await callCluster('search', { + body: searchBody, + index: indexPattern, + }); + return { '*': getCurrentValueFromAggregations(result.aggregations, aggType) }; + } catch (e) { + return { '*': undefined }; // Trigger an Error state + } +}; + +const comparatorMap = { + [Comparator.BETWEEN]: (value: number, [a, b]: number[]) => + value >= Math.min(a, b) && value <= Math.max(a, b), + // `threshold` is always an array of numbers in case the BETWEEN comparator is + // used; all other compartors will just destructure the first value in the array + [Comparator.GT]: (a: number, [b]: number[]) => a > b, + [Comparator.LT]: (a: number, [b]: number[]) => a < b, + [Comparator.GT_OR_EQ]: (a: number, [b]: number[]) => a >= b, + [Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b, +}; + +export const createMetricThresholdExecutor = (alertUUID: string) => + async function({ services, params }: AlertExecutorOptions) { + const { criteria, groupBy, filterQuery } = params as { + criteria: MetricExpressionParams[]; + groupBy: string | undefined; + filterQuery: string | undefined; + }; + + const alertResults = await Promise.all( + criteria.map(criterion => + (async () => { + const currentValues = await getMetric(services, criterion, groupBy, filterQuery); + const { threshold, comparator } = criterion; + const comparisonFunction = comparatorMap[comparator]; + return mapValues(currentValues, value => ({ + shouldFire: + value !== undefined && value !== null && comparisonFunction(value, threshold), + currentValue: value, + isNoData: value === null, + isError: value === undefined, + })); + })() + ) + ); + + const groups = Object.keys(alertResults[0]); + for (const group of groups) { + const alertInstance = services.alertInstanceFactory(`${alertUUID}-${group}`); + + // AND logic; all criteria must be across the threshold + const shouldAlertFire = alertResults.every(result => result[group].shouldFire); + // AND logic; because we need to evaluate all criteria, if one of them reports no data then the + // whole alert is in a No Data/Error state + const isNoData = alertResults.some(result => result[group].isNoData); + const isError = alertResults.some(result => result[group].isError); + if (shouldAlertFire) { + alertInstance.scheduleActions(FIRED_ACTIONS.id, { + group, + value: alertResults.map(result => result[group].currentValue), + }); + } + // Future use: ability to fetch display current alert state + alertInstance.replaceState({ + alertState: isError + ? AlertStates.ERROR + : isNoData + ? AlertStates.NO_DATA + : shouldAlertFire + ? AlertStates.ALERT + : AlertStates.OK, + }); + } + }; + +export const FIRED_ACTIONS = { + id: 'metrics.threshold.fired', + name: i18n.translate('xpack.infra.metrics.alerting.threshold.fired', { + defaultMessage: 'Fired', + }), +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index d318171f3bb48..501d7549e1712 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -4,188 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ import uuid from 'uuid'; -import { mapValues } from 'lodash'; -import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; -import { InfraDatabaseSearchResponse } from '../../adapters/framework/adapter_types'; -import { createAfterKeyHandler } from '../../../utils/create_afterkey_handler'; -import { getAllCompositeData } from '../../../utils/get_all_composite_data'; -import { networkTraffic } from '../../../../common/inventory_models/shared/metrics/snapshot/network_traffic'; -import { - MetricExpressionParams, - Comparator, - AlertStates, - METRIC_THRESHOLD_ALERT_TYPE_ID, -} from './types'; -import { AlertServices, PluginSetupContract } from '../../../../../alerting/server'; - -interface Aggregation { - aggregatedIntervals: { buckets: Array<{ aggregatedValue: { value: number } }> }; -} - -interface CompositeAggregationsResponse { - groupings: { - buckets: Aggregation[]; - }; -} - -const FIRED_ACTIONS = { - id: 'metrics.threshold.fired', - name: i18n.translate('xpack.infra.metrics.alerting.threshold.fired', { - defaultMessage: 'Fired', - }), -}; - -const getCurrentValueFromAggregations = (aggregations: Aggregation) => { - try { - const { buckets } = aggregations.aggregatedIntervals; - if (!buckets.length) return null; // No Data state - const { value } = buckets[buckets.length - 1].aggregatedValue; - return value; - } catch (e) { - return undefined; // Error state - } -}; - -const getParsedFilterQuery: ( - filterQuery: string | undefined -) => Record = filterQuery => { - if (!filterQuery) return {}; - try { - return JSON.parse(filterQuery).bool; - } catch (e) { - return { - query_string: { - query: filterQuery, - analyze_wildcard: true, - }, - }; - } -}; - -const getMetric: ( - services: AlertServices, - params: MetricExpressionParams, - groupBy: string | undefined, - filterQuery: string | undefined -) => Promise> = async function( - { callCluster }, - { metric, aggType, timeUnit, timeSize, indexPattern }, - groupBy, - filterQuery -) { - const interval = `${timeSize}${timeUnit}`; - - const aggregations = - aggType === 'rate' - ? networkTraffic('aggregatedValue', metric) - : { - aggregatedValue: { - [aggType]: { - field: metric, - }, - }, - }; - - const baseAggs = { - aggregatedIntervals: { - date_histogram: { - field: '@timestamp', - fixed_interval: interval, - }, - aggregations, - }, - }; - - const aggs = groupBy - ? { - groupings: { - composite: { - size: 10, - sources: [ - { - groupBy: { - terms: { - field: groupBy, - }, - }, - }, - ], - }, - aggs: baseAggs, - }, - } - : baseAggs; - - const parsedFilterQuery = getParsedFilterQuery(filterQuery); - - const searchBody = { - query: { - bool: { - filter: [ - { - range: { - '@timestamp': { - gte: `now-${interval}`, - }, - }, - }, - { - exists: { - field: metric, - }, - }, - ], - ...parsedFilterQuery, - }, - }, - size: 0, - aggs, - }; - - try { - if (groupBy) { - const bucketSelector = ( - response: InfraDatabaseSearchResponse<{}, CompositeAggregationsResponse> - ) => response.aggregations?.groupings?.buckets || []; - const afterKeyHandler = createAfterKeyHandler( - 'aggs.groupings.composite.after', - response => response.aggregations?.groupings?.after_key - ); - const compositeBuckets = (await getAllCompositeData( - body => callCluster('search', { body, index: indexPattern }), - searchBody, - bucketSelector, - afterKeyHandler - )) as Array; - return compositeBuckets.reduce( - (result, bucket) => ({ - ...result, - [bucket.key.groupBy]: getCurrentValueFromAggregations(bucket), - }), - {} - ); - } - const result = await callCluster('search', { - body: searchBody, - index: indexPattern, - }); - return { '*': getCurrentValueFromAggregations(result.aggregations) }; - } catch (e) { - return { '*': undefined }; // Trigger an Error state - } -}; - -const comparatorMap = { - [Comparator.BETWEEN]: (value: number, [a, b]: number[]) => - value >= Math.min(a, b) && value <= Math.max(a, b), - // `threshold` is always an array of numbers in case the BETWEEN comparator is - // used; all other compartors will just destructure the first value in the array - [Comparator.GT]: (a: number, [b]: number[]) => a > b, - [Comparator.LT]: (a: number, [b]: number[]) => a < b, - [Comparator.GT_OR_EQ]: (a: number, [b]: number[]) => a >= b, - [Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b, -}; +import { PluginSetupContract } from '../../../../../alerting/server'; +import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; +import { METRIC_THRESHOLD_ALERT_TYPE_ID } from './types'; export async function registerMetricThresholdAlertType(alertingPlugin: PluginSetupContract) { if (!alertingPlugin) { @@ -217,59 +39,6 @@ export async function registerMetricThresholdAlertType(alertingPlugin: PluginSet }, defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS], - async executor({ services, params }) { - const { criteria, groupBy, filterQuery } = params as { - criteria: MetricExpressionParams[]; - groupBy: string | undefined; - filterQuery: string | undefined; - }; - - const alertResults = await Promise.all( - criteria.map(criterion => - (async () => { - const currentValues = await getMetric(services, criterion, groupBy, filterQuery); - const { threshold, comparator } = criterion; - const comparisonFunction = comparatorMap[comparator]; - - return mapValues(currentValues, value => ({ - shouldFire: - value !== undefined && value !== null && comparisonFunction(value, threshold), - currentValue: value, - isNoData: value === null, - isError: value === undefined, - })); - })() - ) - ); - - const groups = Object.keys(alertResults[0]); - for (const group of groups) { - const alertInstance = services.alertInstanceFactory(`${alertUUID}-${group}`); - - // AND logic; all criteria must be across the threshold - const shouldAlertFire = alertResults.every(result => result[group].shouldFire); - // AND logic; because we need to evaluate all criteria, if one of them reports no data then the - // whole alert is in a No Data/Error state - const isNoData = alertResults.some(result => result[group].isNoData); - const isError = alertResults.some(result => result[group].isError); - if (shouldAlertFire) { - alertInstance.scheduleActions(FIRED_ACTIONS.id, { - group, - value: alertResults.map(result => result[group].currentValue), - }); - } - - // Future use: ability to fetch display current alert state - alertInstance.replaceState({ - alertState: isError - ? AlertStates.ERROR - : isNoData - ? AlertStates.NO_DATA - : shouldAlertFire - ? AlertStates.ALERT - : AlertStates.OK, - }); - } - }, + executor: createMetricThresholdExecutor(alertUUID), }); } diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts new file mode 100644 index 0000000000000..e87ffcfb2b912 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const bucketsA = [ + { + doc_count: 2, + aggregatedValue: { value: 0.5 }, + }, + { + doc_count: 3, + aggregatedValue: { value: 1.0 }, + }, +]; + +const bucketsB = [ + { + doc_count: 4, + aggregatedValue: { value: 2.5 }, + }, + { + doc_count: 5, + aggregatedValue: { value: 3.5 }, + }, +]; + +export const basicMetricResponse = { + aggregations: { + aggregatedIntervals: { + buckets: bucketsA, + }, + }, +}; + +export const alternateMetricResponse = { + aggregations: { + aggregatedIntervals: { + buckets: bucketsB, + }, + }, +}; + +export const basicCompositeResponse = { + aggregations: { + groupings: { + after_key: 'foo', + buckets: [ + { + key: { + groupBy: 'a', + }, + aggregatedIntervals: { + buckets: bucketsA, + }, + }, + { + key: { + groupBy: 'b', + }, + aggregatedIntervals: { + buckets: bucketsB, + }, + }, + ], + }, + }, + hits: { + total: { + value: 2, + }, + }, +}; + +export const alternateCompositeResponse = { + aggregations: { + groupings: { + after_key: 'foo', + buckets: [ + { + key: { + groupBy: 'a', + }, + aggregatedIntervals: { + buckets: bucketsB, + }, + }, + { + key: { + groupBy: 'b', + }, + aggregatedIntervals: { + buckets: bucketsA, + }, + }, + ], + }, + }, + hits: { + total: { + value: 2, + }, + }, +}; + +export const compositeEndResponse = { + aggregations: {}, + hits: { total: { value: 0 } }, +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts index e247eb8a3f889..07739c9d81bc4 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts @@ -33,5 +33,4 @@ export interface MetricExpressionParams { indexPattern: string; threshold: number[]; comparator: Comparator; - filterQuery: string; } diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts index 2f71d56e1e0e3..9a2631e3c2f76 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import stringify from 'json-stable-stringify'; import { sortBy } from 'lodash'; import { RequestHandlerContext } from 'src/core/server'; @@ -18,13 +17,10 @@ import { LogEntriesCursor, LogColumn, } from '../../../../common/http_api'; -import { InfraLogEntry, InfraLogMessageSegment } from '../../../graphql/types'; import { InfraSourceConfiguration, InfraSources, SavedSourceConfigurationFieldColumnRuntimeType, - SavedSourceConfigurationMessageColumnRuntimeType, - SavedSourceConfigurationTimestampColumnRuntimeType, } from '../../sources'; import { getBuiltinRules } from './builtin_rules'; import { convertDocumentSourceToLogItemFields } from './convert_document_source_to_log_item_fields'; @@ -36,16 +32,16 @@ import { } from './message'; export interface LogEntriesParams { - startDate: number; - endDate: number; + startTimestamp: number; + endTimestamp: number; size?: number; query?: JsonObject; cursor?: { before: LogEntriesCursor | 'last' } | { after: LogEntriesCursor | 'first' }; highlightTerm?: string; } export interface LogEntriesAroundParams { - startDate: number; - endDate: number; + startTimestamp: number; + endTimestamp: number; size?: number; center: LogEntriesCursor; query?: JsonObject; @@ -67,7 +63,7 @@ export class InfraLogEntriesDomain { sourceId: string, params: LogEntriesAroundParams ) { - const { startDate, endDate, center, query, size, highlightTerm } = params; + const { startTimestamp, endTimestamp, center, query, size, highlightTerm } = params; /* * For odd sizes we will round this value down for the first half, and up @@ -80,8 +76,8 @@ export class InfraLogEntriesDomain { const halfSize = (size || LOG_ENTRIES_PAGE_SIZE) / 2; const entriesBefore = await this.getLogEntries(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query, cursor: { before: center }, size: Math.floor(halfSize), @@ -101,8 +97,8 @@ export class InfraLogEntriesDomain { : { time: center.time - 1, tiebreaker: 0 }; const entriesAfter = await this.getLogEntries(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query, cursor: { after: cursorAfter }, size: Math.ceil(halfSize), @@ -112,71 +108,6 @@ export class InfraLogEntriesDomain { return [...entriesBefore, ...entriesAfter]; } - /** @deprecated */ - public async getLogEntriesAround( - requestContext: RequestHandlerContext, - sourceId: string, - key: TimeKey, - maxCountBefore: number, - maxCountAfter: number, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise<{ entriesBefore: InfraLogEntry[]; entriesAfter: InfraLogEntry[] }> { - if (maxCountBefore <= 0 && maxCountAfter <= 0) { - return { - entriesBefore: [], - entriesAfter: [], - }; - } - - const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, - sourceId - ); - const messageFormattingRules = compileFormattingRules( - getBuiltinRules(configuration.fields.message) - ); - const requiredFields = getRequiredFields(configuration, messageFormattingRules); - - const documentsBefore = await this.adapter.getAdjacentLogEntryDocuments( - requestContext, - configuration, - requiredFields, - key, - 'desc', - Math.max(maxCountBefore, 1), - filterQuery, - highlightQuery - ); - const lastKeyBefore = - documentsBefore.length > 0 - ? documentsBefore[documentsBefore.length - 1].key - : { - time: key.time - 1, - tiebreaker: 0, - }; - - const documentsAfter = await this.adapter.getAdjacentLogEntryDocuments( - requestContext, - configuration, - requiredFields, - lastKeyBefore, - 'asc', - maxCountAfter, - filterQuery, - highlightQuery - ); - - return { - entriesBefore: (maxCountBefore > 0 ? documentsBefore : []).map( - convertLogDocumentToEntry(sourceId, configuration.logColumns, messageFormattingRules.format) - ), - entriesAfter: documentsAfter.map( - convertLogDocumentToEntry(sourceId, configuration.logColumns, messageFormattingRules.format) - ), - }; - } - public async getLogEntries( requestContext: RequestHandlerContext, sourceId: string, @@ -220,7 +151,7 @@ export class InfraLogEntriesDomain { return { columnId: column.fieldColumn.id, field: column.fieldColumn.field, - value: stringify(doc.fields[column.fieldColumn.field]), + value: doc.fields[column.fieldColumn.field], highlights: doc.highlights[column.fieldColumn.field] || [], }; } @@ -232,116 +163,6 @@ export class InfraLogEntriesDomain { return entries; } - /** @deprecated */ - public async getLogEntriesBetween( - requestContext: RequestHandlerContext, - sourceId: string, - startKey: TimeKey, - endKey: TimeKey, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise { - const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, - sourceId - ); - const messageFormattingRules = compileFormattingRules( - getBuiltinRules(configuration.fields.message) - ); - const requiredFields = getRequiredFields(configuration, messageFormattingRules); - const documents = await this.adapter.getContainedLogEntryDocuments( - requestContext, - configuration, - requiredFields, - startKey, - endKey, - filterQuery, - highlightQuery - ); - const entries = documents.map( - convertLogDocumentToEntry(sourceId, configuration.logColumns, messageFormattingRules.format) - ); - return entries; - } - - /** @deprecated */ - public async getLogEntryHighlights( - requestContext: RequestHandlerContext, - sourceId: string, - startKey: TimeKey, - endKey: TimeKey, - highlights: Array<{ - query: string; - countBefore: number; - countAfter: number; - }>, - filterQuery?: LogEntryQuery - ): Promise { - const { configuration } = await this.libs.sources.getSourceConfiguration( - requestContext, - sourceId - ); - const messageFormattingRules = compileFormattingRules( - getBuiltinRules(configuration.fields.message) - ); - const requiredFields = getRequiredFields(configuration, messageFormattingRules); - - const documentSets = await Promise.all( - highlights.map(async highlight => { - const highlightQuery = createHighlightQueryDsl(highlight.query, requiredFields); - const query = filterQuery - ? { - bool: { - filter: [filterQuery, highlightQuery], - }, - } - : highlightQuery; - const [documentsBefore, documents, documentsAfter] = await Promise.all([ - this.adapter.getAdjacentLogEntryDocuments( - requestContext, - configuration, - requiredFields, - startKey, - 'desc', - highlight.countBefore, - query, - highlightQuery - ), - this.adapter.getContainedLogEntryDocuments( - requestContext, - configuration, - requiredFields, - startKey, - endKey, - query, - highlightQuery - ), - this.adapter.getAdjacentLogEntryDocuments( - requestContext, - configuration, - requiredFields, - endKey, - 'asc', - highlight.countAfter, - query, - highlightQuery - ), - ]); - const entries = [...documentsBefore, ...documents, ...documentsAfter].map( - convertLogDocumentToEntry( - sourceId, - configuration.logColumns, - messageFormattingRules.format - ) - ); - - return entries; - }) - ); - - return documentSets; - } - public async getLogSummaryBucketsBetween( requestContext: RequestHandlerContext, sourceId: string, @@ -368,8 +189,8 @@ export class InfraLogEntriesDomain { public async getLogSummaryHighlightBucketsBetween( requestContext: RequestHandlerContext, sourceId: string, - start: number, - end: number, + startTimestamp: number, + endTimestamp: number, bucketSize: number, highlightQueries: string[], filterQuery?: LogEntryQuery @@ -396,8 +217,8 @@ export class InfraLogEntriesDomain { const summaryBuckets = await this.adapter.getContainedLogSummaryBuckets( requestContext, configuration, - start, - end, + startTimestamp, + endTimestamp, bucketSize, query ); @@ -445,17 +266,6 @@ interface LogItemHit { } export interface LogEntriesAdapter { - getAdjacentLogEntryDocuments( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: TimeKey, - direction: 'asc' | 'desc', - maxCount: number, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise; - getLogEntries( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, @@ -463,21 +273,11 @@ export interface LogEntriesAdapter { params: LogEntriesParams ): Promise; - getContainedLogEntryDocuments( - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - fields: string[], - start: TimeKey, - end: TimeKey, - filterQuery?: LogEntryQuery, - highlightQuery?: LogEntryQuery - ): Promise; - getContainedLogSummaryBuckets( requestContext: RequestHandlerContext, sourceConfiguration: InfraSourceConfiguration, - start: number, - end: number, + startTimestamp: number, + endTimestamp: number, bucketSize: number, filterQuery?: LogEntryQuery ): Promise; @@ -505,37 +305,6 @@ export interface LogSummaryBucket { topEntryKeys: TimeKey[]; } -/** @deprecated */ -const convertLogDocumentToEntry = ( - sourceId: string, - logColumns: InfraSourceConfiguration['logColumns'], - formatLogMessage: (fields: Fields, highlights: Highlights) => InfraLogMessageSegment[] -) => (document: LogEntryDocument): InfraLogEntry => ({ - key: document.key, - gid: document.gid, - source: sourceId, - columns: logColumns.map(logColumn => { - if (SavedSourceConfigurationTimestampColumnRuntimeType.is(logColumn)) { - return { - columnId: logColumn.timestampColumn.id, - timestamp: document.key.time, - }; - } else if (SavedSourceConfigurationMessageColumnRuntimeType.is(logColumn)) { - return { - columnId: logColumn.messageColumn.id, - message: formatLogMessage(document.fields, document.highlights), - }; - } else { - return { - columnId: logColumn.fieldColumn.id, - field: logColumn.fieldColumn.field, - highlights: document.highlights[logColumn.fieldColumn.field] || [], - value: stringify(document.fields[logColumn.fieldColumn.field] || null), - }; - } - }), -}); - const logSummaryBucketHasEntries = (bucket: LogSummaryBucket) => bucket.entriesCount > 0 && bucket.topEntryKeys.length > 0; diff --git a/x-pack/plugins/infra/server/routes/log_entries/entries.ts b/x-pack/plugins/infra/server/routes/log_entries/entries.ts index 93802468dd267..f33dfa71fedcd 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/entries.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/entries.ts @@ -38,13 +38,19 @@ export const initLogEntriesRoute = ({ framework, logEntries }: InfraBackendLibs) fold(throwErrors(Boom.badRequest), identity) ); - const { startDate, endDate, sourceId, query, size } = payload; + const { + startTimestamp: startTimestamp, + endTimestamp: endTimestamp, + sourceId, + query, + size, + } = payload; let entries; if ('center' in payload) { entries = await logEntries.getLogEntriesAround__new(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query: parseFilterQuery(query), center: payload.center, size, @@ -58,20 +64,22 @@ export const initLogEntriesRoute = ({ framework, logEntries }: InfraBackendLibs) } entries = await logEntries.getLogEntries(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query: parseFilterQuery(query), cursor, size, }); } + const hasEntries = entries.length > 0; + return response.ok({ body: logEntriesResponseRT.encode({ data: { entries, - topCursor: entries[0].cursor, - bottomCursor: entries[entries.length - 1].cursor, + topCursor: hasEntries ? entries[0].cursor : null, + bottomCursor: hasEntries ? entries[entries.length - 1].cursor : null, }, }), }); diff --git a/x-pack/plugins/infra/server/routes/log_entries/highlights.ts b/x-pack/plugins/infra/server/routes/log_entries/highlights.ts index 8ee412d5acdd5..2e581d96cab9c 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/highlights.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/highlights.ts @@ -38,7 +38,7 @@ export const initLogEntriesHighlightsRoute = ({ framework, logEntries }: InfraBa fold(throwErrors(Boom.badRequest), identity) ); - const { startDate, endDate, sourceId, query, size, highlightTerms } = payload; + const { startTimestamp, endTimestamp, sourceId, query, size, highlightTerms } = payload; let entriesPerHighlightTerm; @@ -46,8 +46,8 @@ export const initLogEntriesHighlightsRoute = ({ framework, logEntries }: InfraBa entriesPerHighlightTerm = await Promise.all( highlightTerms.map(highlightTerm => logEntries.getLogEntriesAround__new(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query: parseFilterQuery(query), center: payload.center, size, @@ -66,8 +66,8 @@ export const initLogEntriesHighlightsRoute = ({ framework, logEntries }: InfraBa entriesPerHighlightTerm = await Promise.all( highlightTerms.map(highlightTerm => logEntries.getLogEntries(requestContext, sourceId, { - startDate, - endDate, + startTimestamp, + endTimestamp, query: parseFilterQuery(query), cursor, size, diff --git a/x-pack/plugins/infra/server/routes/log_entries/summary.ts b/x-pack/plugins/infra/server/routes/log_entries/summary.ts index 3f5bc8e364a58..aa4421374ec12 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/summary.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/summary.ts @@ -36,13 +36,13 @@ export const initLogEntriesSummaryRoute = ({ framework, logEntries }: InfraBacke logEntriesSummaryRequestRT.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const { sourceId, startDate, endDate, bucketSize, query } = payload; + const { sourceId, startTimestamp, endTimestamp, bucketSize, query } = payload; const buckets = await logEntries.getLogSummaryBucketsBetween( requestContext, sourceId, - startDate, - endDate, + startTimestamp, + endTimestamp, bucketSize, parseFilterQuery(query) ); @@ -50,8 +50,8 @@ export const initLogEntriesSummaryRoute = ({ framework, logEntries }: InfraBacke return response.ok({ body: logEntriesSummaryResponseRT.encode({ data: { - start: startDate, - end: endDate, + start: startTimestamp, + end: endTimestamp, buckets, }, }), diff --git a/x-pack/plugins/infra/server/routes/log_entries/summary_highlights.ts b/x-pack/plugins/infra/server/routes/log_entries/summary_highlights.ts index 6c6f7a5a3dcd3..d92cddcdc415d 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/summary_highlights.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/summary_highlights.ts @@ -39,13 +39,20 @@ export const initLogEntriesSummaryHighlightsRoute = ({ logEntriesSummaryHighlightsRequestRT.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const { sourceId, startDate, endDate, bucketSize, query, highlightTerms } = payload; + const { + sourceId, + startTimestamp, + endTimestamp, + bucketSize, + query, + highlightTerms, + } = payload; const bucketsPerHighlightTerm = await logEntries.getLogSummaryHighlightBucketsBetween( requestContext, sourceId, - startDate, - endDate, + startTimestamp, + endTimestamp, bucketSize, highlightTerms, parseFilterQuery(query) @@ -54,8 +61,8 @@ export const initLogEntriesSummaryHighlightsRoute = ({ return response.ok({ body: logEntriesSummaryHighlightsResponseRT.encode({ data: bucketsPerHighlightTerm.map(buckets => ({ - start: startDate, - end: endDate, + start: startTimestamp, + end: endTimestamp, buckets, })), }), diff --git a/x-pack/plugins/ingest_manager/common/constants/routes.ts b/x-pack/plugins/ingest_manager/common/constants/routes.ts index 1dc98f9bc8947..5bf7c910168c0 100644 --- a/x-pack/plugins/ingest_manager/common/constants/routes.ts +++ b/x-pack/plugins/ingest_manager/common/constants/routes.ts @@ -50,6 +50,7 @@ export const AGENT_API_ROUTES = { EVENTS_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/events`, CHECKIN_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/checkin`, ACKS_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/acks`, + ACTIONS_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/actions`, ENROLL_PATTERN: `${FLEET_API_ROOT}/agents/enroll`, UNENROLL_PATTERN: `${FLEET_API_ROOT}/agents/unenroll`, STATUS_PATTERN: `${FLEET_API_ROOT}/agent-status`, diff --git a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts index 9201cdcb6bbac..7b4e4adc4e4fc 100644 --- a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts +++ b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { NewDatasource } from '../types'; +import { NewDatasource, DatasourceInput } from '../types'; import { storedDatasourceToAgentDatasource } from './datasource_to_agent_datasource'; describe('Ingest Manager - storedDatasourceToAgentDatasource', () => { @@ -17,7 +17,7 @@ describe('Ingest Manager - storedDatasourceToAgentDatasource', () => { inputs: [], }; - const mockInput = { + const mockInput: DatasourceInput = { type: 'test-logs', enabled: true, streams: [ @@ -25,13 +25,29 @@ describe('Ingest Manager - storedDatasourceToAgentDatasource', () => { id: 'test-logs-foo', enabled: true, dataset: 'foo', - config: { fooVar: 'foo-value', fooVar2: [1, 2] }, + config: { fooVar: { value: 'foo-value' }, fooVar2: { value: [1, 2] } }, }, { id: 'test-logs-bar', enabled: false, dataset: 'bar', - config: { barVar: 'bar-value', barVar2: [1, 2] }, + config: { + barVar: { value: 'bar-value' }, + barVar2: { value: [1, 2] }, + barVar3: { + type: 'yaml', + value: + '- namespace: mockNamespace\n #disabledProp: ["test"]\n anotherProp: test\n- namespace: mockNamespace2\n #disabledProp: ["test2"]\n anotherProp: test2', + }, + barVar4: { + type: 'yaml', + value: '', + }, + barVar5: { + type: 'yaml', + value: 'testField: test\n invalidSpacing: foo', + }, + }, }, ], }; @@ -91,6 +107,16 @@ describe('Ingest Manager - storedDatasourceToAgentDatasource', () => { dataset: 'bar', barVar: 'bar-value', barVar2: [1, 2], + barVar3: [ + { + namespace: 'mockNamespace', + anotherProp: 'test', + }, + { + namespace: 'mockNamespace2', + anotherProp: 'test2', + }, + ], }, ], }, diff --git a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts index 57627fa60fe43..ea048b84afef3 100644 --- a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts +++ b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { safeLoad } from 'js-yaml'; import { Datasource, NewDatasource, FullAgentConfigDatasource } from '../types'; import { DEFAULT_OUTPUT } from '../constants'; @@ -23,12 +24,26 @@ export const storedDatasourceToAgentDatasource = ( if (stream.config) { const fullStream = { ...stream, - ...Object.entries(stream.config).reduce((acc, [configName, configValue]) => { - if (configValue !== undefined) { - acc[configName] = configValue; - } - return acc; - }, {} as { [key: string]: any }), + ...Object.entries(stream.config).reduce( + (acc, [configName, { type: configType, value: configValue }]) => { + if (configValue !== undefined) { + if (configType === 'yaml') { + try { + const yamlValue = safeLoad(configValue); + if (yamlValue) { + acc[configName] = yamlValue; + } + } catch (e) { + // Silently swallow parsing error + } + } else { + acc[configName] = configValue; + } + } + return acc; + }, + {} as { [key: string]: any } + ), }; delete fullStream.config; return fullStream; diff --git a/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts b/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts index d312e7aa35cc0..e54e59dd24df3 100644 --- a/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts +++ b/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts @@ -108,8 +108,14 @@ describe('Ingest Manager - packageToConfig', () => { { type: 'bar', streams: [ - { dataset: 'bar', vars: [{ default: 'bar-var-value', name: 'var-name' }] }, - { dataset: 'bar2', vars: [{ default: 'bar2-var-value', name: 'var-name' }] }, + { + dataset: 'bar', + vars: [{ default: 'bar-var-value', name: 'var-name', type: 'text' }], + }, + { + dataset: 'bar2', + vars: [{ default: 'bar2-var-value', name: 'var-name', type: 'yaml' }], + }, ], }, ], @@ -125,7 +131,7 @@ describe('Ingest Manager - packageToConfig', () => { id: 'foo-foo', enabled: true, dataset: 'foo', - config: { 'var-name': 'foo-var-value' }, + config: { 'var-name': { value: 'foo-var-value' } }, }, ], }, @@ -137,13 +143,13 @@ describe('Ingest Manager - packageToConfig', () => { id: 'bar-bar', enabled: true, dataset: 'bar', - config: { 'var-name': 'bar-var-value' }, + config: { 'var-name': { type: 'text', value: 'bar-var-value' } }, }, { id: 'bar-bar2', enabled: true, dataset: 'bar2', - config: { 'var-name': 'bar2-var-value' }, + config: { 'var-name': { type: 'yaml', value: 'bar2-var-value' } }, }, ], }, @@ -204,10 +210,10 @@ describe('Ingest Manager - packageToConfig', () => { enabled: true, dataset: 'foo', config: { - 'var-name': 'foo-var-value', - 'foo-input-var-name': 'foo-input-var-value', - 'foo-input2-var-name': 'foo-input2-var-value', - 'foo-input3-var-name': undefined, + 'var-name': { value: 'foo-var-value' }, + 'foo-input-var-name': { value: 'foo-input-var-value' }, + 'foo-input2-var-name': { value: 'foo-input2-var-value' }, + 'foo-input3-var-name': { value: undefined }, }, }, ], @@ -221,9 +227,9 @@ describe('Ingest Manager - packageToConfig', () => { enabled: true, dataset: 'bar', config: { - 'var-name': 'bar-var-value', - 'bar-input-var-name': ['value1', 'value2'], - 'bar-input2-var-name': 123456, + 'var-name': { value: 'bar-var-value' }, + 'bar-input-var-name': { value: ['value1', 'value2'] }, + 'bar-input2-var-name': { value: 123456 }, }, }, { @@ -231,9 +237,9 @@ describe('Ingest Manager - packageToConfig', () => { enabled: true, dataset: 'bar2', config: { - 'var-name': 'bar2-var-value', - 'bar-input-var-name': ['value1', 'value2'], - 'bar-input2-var-name': 123456, + 'var-name': { value: 'bar2-var-value' }, + 'bar-input-var-name': { value: ['value1', 'value2'] }, + 'bar-input2-var-name': { value: 123456 }, }, }, ], @@ -247,7 +253,7 @@ describe('Ingest Manager - packageToConfig', () => { enabled: false, dataset: 'disabled', config: { - 'var-name': [], + 'var-name': { value: [] }, }, }, { diff --git a/x-pack/plugins/ingest_manager/common/services/package_to_config.ts b/x-pack/plugins/ingest_manager/common/services/package_to_config.ts index 9785edbff1112..6de75a004303e 100644 --- a/x-pack/plugins/ingest_manager/common/services/package_to_config.ts +++ b/x-pack/plugins/ingest_manager/common/services/package_to_config.ts @@ -41,9 +41,9 @@ export const packageToConfigDatasourceInputs = (packageInfo: PackageInfo): Datas streamVar: RegistryVarsEntry ): DatasourceInputStream['config'] => { if (!streamVar.default && streamVar.multi) { - configObject![streamVar.name] = []; + configObject![streamVar.name] = { type: streamVar.type, value: [] }; } else { - configObject![streamVar.name] = streamVar.default; + configObject![streamVar.name] = { type: streamVar.type, value: streamVar.default }; } return configObject; }; diff --git a/x-pack/plugins/ingest_manager/common/services/routes.ts b/x-pack/plugins/ingest_manager/common/services/routes.ts index 7ad3944096a5f..01b3b1983486c 100644 --- a/x-pack/plugins/ingest_manager/common/services/routes.ts +++ b/x-pack/plugins/ingest_manager/common/services/routes.ts @@ -56,6 +56,10 @@ export const datasourceRouteService = { getUpdatePath: (datasourceId: string) => { return DATASOURCE_API_ROUTES.UPDATE_PATTERN.replace('{datasourceId}', datasourceId); }, + + getDeletePath: () => { + return DATASOURCE_API_ROUTES.DELETE_PATTERN; + }, }; export const agentConfigRouteService = { diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent.ts b/x-pack/plugins/ingest_manager/common/types/models/agent.ts index 179cc3fc9eb55..aa5729a101e11 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent.ts @@ -14,14 +14,17 @@ export type AgentType = export type AgentStatus = 'offline' | 'error' | 'online' | 'inactive' | 'warning'; -export interface AgentAction extends SavedObjectAttributes { +export interface NewAgentAction { type: 'CONFIG_CHANGE' | 'DATA_DUMP' | 'RESUME' | 'PAUSE'; - id: string; - created_at: string; data?: string; sent_at?: string; } +export type AgentAction = NewAgentAction & { + id: string; + created_at: string; +} & SavedObjectAttributes; + export interface AgentEvent { type: 'STATE' | 'ERROR' | 'ACTION_RESULT' | 'ACTION'; subtype: // State diff --git a/x-pack/plugins/ingest_manager/common/types/models/datasource.ts b/x-pack/plugins/ingest_manager/common/types/models/datasource.ts index 3503bbdcd40e3..3ad7a15d0c739 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/datasource.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/datasource.ts @@ -15,7 +15,13 @@ export interface DatasourceInputStream { enabled: boolean; dataset: string; processors?: string[]; - config?: Record; + config?: Record< + string, + { + type?: string; + value: any; + } + >; } export interface DatasourceInput { diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts index 7bbaf42422bb2..21ab41740ce3e 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Agent, AgentAction, AgentEvent, AgentStatus, AgentType } from '../models'; +import { Agent, AgentAction, AgentEvent, AgentStatus, AgentType, NewAgentAction } from '../models'; export interface GetAgentsRequest { query: { @@ -81,6 +81,20 @@ export interface PostAgentAcksResponse { success: boolean; } +export interface PostNewAgentActionRequest { + body: { + action: NewAgentAction; + }; + params: { + agentId: string; + }; +} + +export interface PostNewAgentActionResponse { + success: boolean; + item: AgentAction; +} + export interface PostAgentUnenrollRequest { body: { kuery: string } | { ids: string[] }; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/package_icon.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/package_icon.tsx new file mode 100644 index 0000000000000..c5a0e600b7d50 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/package_icon.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useEffect, useMemo, useState } from 'react'; +import { ICON_TYPES, EuiIcon, EuiIconProps } from '@elastic/eui'; +import { PackageInfo, PackageListItem } from '../../../../common/types/models'; +import { useLinks } from '../sections/epm/hooks'; +import { epmRouteService } from '../../../../common/services'; +import { sendRequest } from '../hooks/use_request'; +import { GetInfoResponse } from '../types'; +type Package = PackageInfo | PackageListItem; + +const CACHED_ICONS = new Map(); + +export const PackageIcon: React.FunctionComponent<{ + packageName: string; + version?: string; + icons?: Package['icons']; +} & Omit> = ({ packageName, version, icons, ...euiIconProps }) => { + const iconType = usePackageIcon(packageName, version, icons); + return ; +}; + +const usePackageIcon = (packageName: string, version?: string, icons?: Package['icons']) => { + const { toImage } = useLinks(); + const [iconType, setIconType] = useState(''); // FIXME: use `empty` icon during initialization - see: https://github.com/elastic/kibana/issues/60622 + const pkgKey = `${packageName}-${version ?? ''}`; + + // Generates an icon path or Eui Icon name based on an icon list from the package + // or by using the package name against logo icons from Eui + const fromInput = useMemo(() => { + return (iconList?: Package['icons']) => { + const svgIcons = iconList?.filter(iconDef => iconDef.type === 'image/svg+xml'); + const localIconSrc = Array.isArray(svgIcons) && svgIcons[0]?.src; + if (localIconSrc) { + CACHED_ICONS.set(pkgKey, toImage(localIconSrc)); + setIconType(CACHED_ICONS.get(pkgKey) as string); + return; + } + + const euiLogoIcon = ICON_TYPES.find(key => key.toLowerCase() === `logo${packageName}`); + if (euiLogoIcon) { + CACHED_ICONS.set(pkgKey, euiLogoIcon); + setIconType(euiLogoIcon); + return; + } + + CACHED_ICONS.set(pkgKey, 'package'); + setIconType('package'); + }; + }, [packageName, pkgKey, toImage]); + + useEffect(() => { + if (CACHED_ICONS.has(pkgKey)) { + setIconType(CACHED_ICONS.get(pkgKey) as string); + return; + } + + // Use API to see if package has icons defined + if (!icons && version !== undefined) { + fromPackageInfo(pkgKey) + .catch(() => undefined) // ignore API errors + .then(fromInput); + } else { + fromInput(icons); + } + }, [icons, toImage, packageName, version, fromInput, pkgKey]); + + return iconType; +}; + +const fromPackageInfo = async (pkgKey: string) => { + const { data } = await sendRequest({ + path: epmRouteService.getInfoPath(pkgKey), + method: 'get', + }); + return data?.response?.icons; +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts index 60fbb9f0d2afa..d0072f0355993 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts @@ -6,6 +6,10 @@ import { sendRequest } from './use_request'; import { datasourceRouteService } from '../../services'; import { CreateDatasourceRequest, CreateDatasourceResponse } from '../../types'; +import { + DeleteDatasourcesRequest, + DeleteDatasourcesResponse, +} from '../../../../../common/types/rest_spec'; export const sendCreateDatasource = (body: CreateDatasourceRequest['body']) => { return sendRequest({ @@ -14,3 +18,11 @@ export const sendCreateDatasource = (body: CreateDatasourceRequest['body']) => { body: JSON.stringify(body), }); }; + +export const sendDeleteDatasource = (body: DeleteDatasourcesRequest['body']) => { + return sendRequest({ + path: datasourceRouteService.getDeletePath(), + method: 'post', + body: JSON.stringify(body), + }); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/danger_eui_context_menu_item.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/danger_eui_context_menu_item.tsx new file mode 100644 index 0000000000000..bc4d28ba0e313 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/danger_eui_context_menu_item.tsx @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import styled from 'styled-components'; +import { EuiContextMenuItem } from '@elastic/eui'; + +export const DangerEuiContextMenuItem = styled(EuiContextMenuItem)` + color: ${props => props.theme.eui.textColors.danger}; +`; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/datasource_delete_provider.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/datasource_delete_provider.tsx new file mode 100644 index 0000000000000..089b0631c2090 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/datasource_delete_provider.tsx @@ -0,0 +1,237 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useMemo, useRef, useState } from 'react'; +import { EuiCallOut, EuiConfirmModal, EuiOverlayMask, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useCore, sendRequest, sendDeleteDatasource, useConfig } from '../../../hooks'; +import { AGENT_API_ROUTES } from '../../../../../../common/constants'; +import { AgentConfig } from '../../../../../../common/types/models'; + +interface Props { + agentConfig: AgentConfig; + children: (deleteDatasourcePrompt: DeleteAgentConfigDatasourcePrompt) => React.ReactElement; +} + +export type DeleteAgentConfigDatasourcePrompt = ( + datasourcesToDelete: string[], + onSuccess?: OnSuccessCallback +) => void; + +type OnSuccessCallback = (datasourcesDeleted: string[]) => void; + +export const DatasourceDeleteProvider: React.FunctionComponent = ({ + agentConfig, + children, +}) => { + const { notifications } = useCore(); + const { + fleet: { enabled: isFleetEnabled }, + } = useConfig(); + const [datasources, setDatasources] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isLoadingAgentsCount, setIsLoadingAgentsCount] = useState(false); + const [agentsCount, setAgentsCount] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const onSuccessCallback = useRef(null); + + const fetchAgentsCount = useMemo( + () => async () => { + if (isLoadingAgentsCount || !isFleetEnabled) { + return; + } + setIsLoadingAgentsCount(true); + const { data } = await sendRequest<{ total: number }>({ + path: AGENT_API_ROUTES.LIST_PATTERN, + method: 'get', + query: { + page: 1, + perPage: 1, + kuery: `agents.config_id : ${agentConfig.id}`, + }, + }); + setAgentsCount(data?.total || 0); + setIsLoadingAgentsCount(false); + }, + [agentConfig.id, isFleetEnabled, isLoadingAgentsCount] + ); + + const deleteDatasourcesPrompt = useMemo( + (): DeleteAgentConfigDatasourcePrompt => (datasourcesToDelete, onSuccess = () => undefined) => { + if (!Array.isArray(datasourcesToDelete) || datasourcesToDelete.length === 0) { + throw new Error('No datasources specified for deletion'); + } + setIsModalOpen(true); + setDatasources(datasourcesToDelete); + fetchAgentsCount(); + onSuccessCallback.current = onSuccess; + }, + [fetchAgentsCount] + ); + + const closeModal = useMemo( + () => () => { + setDatasources([]); + setIsLoading(false); + setIsLoadingAgentsCount(false); + setIsModalOpen(false); + }, + [] + ); + + const deleteDatasources = useMemo( + () => async () => { + setIsLoading(true); + + try { + const { data } = await sendDeleteDatasource({ datasourceIds: datasources }); + const successfulResults = data?.filter(result => result.success) || []; + const failedResults = data?.filter(result => !result.success) || []; + + if (successfulResults.length) { + const hasMultipleSuccesses = successfulResults.length > 1; + const successMessage = hasMultipleSuccesses + ? i18n.translate( + 'xpack.ingestManager.deleteDatasource.successMultipleNotificationTitle', + { + defaultMessage: 'Deleted {count} data sources', + values: { count: successfulResults.length }, + } + ) + : i18n.translate( + 'xpack.ingestManager.deleteDatasource.successSingleNotificationTitle', + { + defaultMessage: "Deleted data source '{id}'", + values: { id: successfulResults[0].id }, + } + ); + notifications.toasts.addSuccess(successMessage); + } + + if (failedResults.length) { + const hasMultipleFailures = failedResults.length > 1; + const failureMessage = hasMultipleFailures + ? i18n.translate( + 'xpack.ingestManager.deleteDatasource.failureMultipleNotificationTitle', + { + defaultMessage: 'Error deleting {count} data sources', + values: { count: failedResults.length }, + } + ) + : i18n.translate( + 'xpack.ingestManager.deleteDatasource.failureSingleNotificationTitle', + { + defaultMessage: "Error deleting data source '{id}'", + values: { id: failedResults[0].id }, + } + ); + notifications.toasts.addDanger(failureMessage); + } + + if (onSuccessCallback.current) { + onSuccessCallback.current(successfulResults.map(result => result.id)); + } + } catch (e) { + notifications.toasts.addDanger( + i18n.translate('xpack.ingestManager.deleteDatasource.fatalErrorNotificationTitle', { + defaultMessage: 'Error deleting data source', + }) + ); + } + closeModal(); + }, + [closeModal, datasources, notifications.toasts] + ); + + const renderModal = () => { + if (!isModalOpen) { + return null; + } + + return ( + + + } + onCancel={closeModal} + onConfirm={deleteDatasources} + cancelButtonText={ + + } + confirmButtonText={ + isLoading || isLoadingAgentsCount ? ( + + ) : ( + + ) + } + buttonColor="danger" + confirmButtonDisabled={isLoading || isLoadingAgentsCount} + > + {isLoadingAgentsCount ? ( + + ) : agentsCount ? ( + <> + + } + > + {agentConfig.name}, + }} + /> + + + + ) : null} + {!isLoadingAgentsCount && ( + + )} + + + ); + }; + + return ( + + {children(deleteDatasourcesPrompt)} + {renderModal()} + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/table_row_actions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/table_row_actions.tsx new file mode 100644 index 0000000000000..2f9a11ef76704 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/table_row_actions.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback, useState } from 'react'; +import { EuiButtonIcon, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { EuiContextMenuPanelProps } from '@elastic/eui/src/components/context_menu/context_menu_panel'; + +export const TableRowActions = React.memo<{ items: EuiContextMenuPanelProps['items'] }>( + ({ items }) => { + const [isOpen, setIsOpen] = useState(false); + const handleCloseMenu = useCallback(() => setIsOpen(false), [setIsOpen]); + const handleToggleMenu = useCallback(() => setIsOpen(!isOpen), [isOpen]); + + return ( + + } + isOpen={isOpen} + closePopover={handleCloseMenu} + > + + + ); + } +); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_config.tsx index 69d2194638441..1128f25818d7c 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_config.tsx @@ -63,8 +63,8 @@ export const DatasourceInputConfig: React.FunctionComponent<{ {requiredVars.map(varDef => { - const varName = varDef.name; - const value = datasourceInput.streams[0].config![varName]; + const { name: varName, type: varType } = varDef; + const value = datasourceInput.streams[0].config![varName].value; return ( {isShowingAdvanced ? advancedVars.map(varDef => { - const varName = varDef.name; - const value = datasourceInput.streams[0].config![varName]; + const { name: varName, type: varType } = varDef; + const value = datasourceInput.streams[0].config![varName].value; return ( {requiredVars.map(varDef => { - const varName = varDef.name; - const value = datasourceInputStream.config![varName]; + const { name: varName, type: varType } = varDef; + const value = datasourceInputStream.config![varName].value; return ( {isShowingAdvanced ? advancedVars.map(varDef => { - const varName = varDef.name; - const value = datasourceInputStream.config![varName]; + const { name: varName, type: varType } = varDef; + const value = datasourceInputStream.config![varName].value; return ( void; }> = ({ varDef, value, onChange }) => { + const renderField = () => { + if (varDef.multi) { + return ( + ({ label: val }))} + onCreateOption={(newVal: any) => { + onChange([...value, newVal]); + }} + onChange={(newVals: any[]) => { + onChange(newVals.map(val => val.label)); + }} + /> + ); + } + if (varDef.type === 'yaml') { + return ( + onChange(newVal)} + /> + ); + } + return ( + onChange(e.target.value)} + /> + ); + }; + return ( } > - {varDef.multi ? ( - ({ label: val }))} - onCreateOption={(newVal: any) => { - onChange([...value, newVal]); - }} - onChange={(newVals: any[]) => { - onChange(newVals.map(val => val.label)); - }} - /> - ) : ( - onChange(e.target.value)} /> - )} + {renderField()} ); }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx index c063155c571d2..8bb7b2553c1b1 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx @@ -17,7 +17,7 @@ import { } from '@elastic/eui'; import { WithHeaderLayout } from '../../../../layouts'; import { AgentConfig, PackageInfo } from '../../../../types'; -import { PackageIcon } from '../../../epm/components'; +import { PackageIcon } from '../../../../components/package_icon'; import { CreateDatasourceFrom, CreateDatasourceStep } from '../types'; import { CreateDatasourceStepsNavigation } from './navigation'; @@ -94,7 +94,12 @@ export const CreateDatasourcePageLayout: React.FunctionComponent<{ - + {packageInfo?.title || packageInfo?.name || '-'} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx index f90e7f0ab0460..0b48020c3cac1 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx @@ -18,7 +18,7 @@ import { import { Error } from '../../../components'; import { AgentConfig, PackageInfo } from '../../../types'; import { useGetOneAgentConfig, useGetPackages, sendGetPackageInfoByKey } from '../../../hooks'; -import { PackageIcon } from '../../epm/components'; +import { PackageIcon } from '../../../components/package_icon'; export const StepSelectPackage: React.FunctionComponent<{ agentConfigId: string; @@ -125,12 +125,12 @@ export const StepSelectPackage: React.FunctionComponent<{ allowExclusions={false} singleSelection={true} isLoading={isPackagesLoading} - options={packages.map(({ title, name, version }) => { + options={packages.map(({ title, name, version, icons }) => { const pkgkey = `${name}-${version}`; return { label: title || name, key: pkgkey, - prepend: , + prepend: , checked: selectedPkgKey === pkgkey ? 'on' : undefined, }; })} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources/datasources_table.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources/datasources_table.tsx new file mode 100644 index 0000000000000..49285707457e1 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources/datasources_table.tsx @@ -0,0 +1,310 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiInMemoryTable, + EuiInMemoryTableProps, + EuiBadge, + EuiTextColor, + EuiContextMenuItem, + EuiButton, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { AgentConfig, Datasource } from '../../../../../types'; +import { TableRowActions } from '../../../components/table_row_actions'; +import { DangerEuiContextMenuItem } from '../../../components/danger_eui_context_menu_item'; +import { useCapabilities } from '../../../../../hooks'; +import { useAgentConfigLink } from '../../hooks/use_details_uri'; +import { DatasourceDeleteProvider } from '../../../components/datasource_delete_provider'; +import { useConfigRefresh } from '../../hooks/use_config'; +import { PackageIcon } from '../../../../../components/package_icon'; + +interface InMemoryDatasource extends Datasource { + streams: { total: number; enabled: number }; + inputTypes: string[]; + packageName?: string; + packageTitle?: string; + packageVersion?: string; +} + +interface Props { + datasources: Datasource[]; + config: AgentConfig; + // Pass through props to InMemoryTable + loading?: EuiInMemoryTableProps['loading']; + message?: EuiInMemoryTableProps['message']; +} + +interface FilterOption { + name: string; + value: string; +} + +const stringSortAscending = (a: string, b: string): number => a.localeCompare(b); +const toFilterOption = (value: string): FilterOption => ({ name: value, value }); + +export const DatasourcesTable: React.FunctionComponent = ({ + datasources: originalDatasources, + config, + ...rest +}) => { + const hasWriteCapabilities = useCapabilities().write; + const addDatasourceLink = useAgentConfigLink('add-datasource', { configId: config.id }); + const refreshConfig = useConfigRefresh(); + + // With the datasources provided on input, generate the list of datasources + // used in the InMemoryTable (flattens some values for search) as well as + // the list of options that will be used in the filters dropdowns + const [datasources, namespaces, inputTypes] = useMemo((): [ + InMemoryDatasource[], + FilterOption[], + FilterOption[] + ] => { + const namespacesValues: string[] = []; + const inputTypesValues: string[] = []; + const mappedDatasources = originalDatasources.map(datasource => { + if (datasource.namespace && !namespacesValues.includes(datasource.namespace)) { + namespacesValues.push(datasource.namespace); + } + + const dsInputTypes: string[] = []; + const streams = datasource.inputs.reduce( + (streamSummary, input) => { + if (!inputTypesValues.includes(input.type)) { + inputTypesValues.push(input.type); + } + if (!dsInputTypes.includes(input.type)) { + dsInputTypes.push(input.type); + } + + streamSummary.total += input.streams.length; + streamSummary.enabled += input.enabled + ? input.streams.filter(stream => stream.enabled).length + : 0; + + return streamSummary; + }, + { total: 0, enabled: 0 } + ); + + dsInputTypes.sort(stringSortAscending); + + return { + ...datasource, + streams, + inputTypes: dsInputTypes, + packageName: datasource.package?.name ?? '', + packageTitle: datasource.package?.title ?? '', + packageVersion: datasource.package?.version ?? '', + }; + }); + + namespacesValues.sort(stringSortAscending); + inputTypesValues.sort(stringSortAscending); + + return [ + mappedDatasources, + namespacesValues.map(toFilterOption), + inputTypesValues.map(toFilterOption), + ]; + }, [originalDatasources]); + + const columns = useMemo( + (): EuiInMemoryTableProps['columns'] => [ + { + field: 'name', + name: i18n.translate('xpack.ingestManager.configDetails.datasourcesTable.nameColumnTitle', { + defaultMessage: 'Data source', + }), + }, + { + field: 'description', + name: i18n.translate( + 'xpack.ingestManager.configDetails.datasourcesTable.descriptionColumnTitle', + { + defaultMessage: 'Description', + } + ), + truncateText: true, + }, + { + field: 'packageTitle', + name: i18n.translate( + 'xpack.ingestManager.configDetails.datasourcesTable.packageNameColumnTitle', + { + defaultMessage: 'Package', + } + ), + render(packageTitle: string, datasource: InMemoryDatasource) { + return ( + + {datasource.package && ( + + + + )} + {packageTitle} + + ); + }, + }, + { + field: 'namespace', + name: i18n.translate( + 'xpack.ingestManager.configDetails.datasourcesTable.namespaceColumnTitle', + { + defaultMessage: 'Namespace', + } + ), + render: (namespace: InMemoryDatasource['namespace']) => { + return namespace ? {namespace} : ''; + }, + }, + { + field: 'streams', + name: i18n.translate( + 'xpack.ingestManager.configDetails.datasourcesTable.streamsCountColumnTitle', + { + defaultMessage: 'Streams', + } + ), + render: (streams: InMemoryDatasource['streams']) => { + return ( + <> + {streams.enabled} +  / {streams.total} + + ); + }, + }, + { + name: i18n.translate( + 'xpack.ingestManager.configDetails.datasourcesTable.actionsColumnTitle', + { + defaultMessage: 'Actions', + } + ), + actions: [ + { + render: (datasource: InMemoryDatasource) => ( + {}} + key="datasourceView" + > + + , + // FIXME: implement Edit datasource action + {}} + key="datasourceEdit" + > + + , + // FIXME: implement Copy datasource action + {}} key="datasourceCopy"> + + , + + {deleteDatasourcePrompt => { + return ( + { + deleteDatasourcePrompt([datasource.id], refreshConfig); + }} + > + + + ); + }} + , + ]} + /> + ), + }, + ], + }, + ], + [config, hasWriteCapabilities, refreshConfig] + ); + + return ( + + itemId="id" + items={datasources} + columns={columns} + sorting={{ + sort: { + field: 'name', + direction: 'asc', + }, + }} + {...rest} + search={{ + toolsRight: [ + + + , + ], + box: { + incremental: true, + schema: true, + }, + filters: [ + { + type: 'field_value_selection', + field: 'namespace', + name: 'Namespace', + options: namespaces, + multiSelect: 'or', + }, + { + type: 'field_value_selection', + field: 'inputTypes', + name: 'Input types', + options: inputTypes, + multiSelect: 'or', + }, + ], + }} + isSelectable={false} + /> + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources/index.tsx new file mode 100644 index 0000000000000..346ccde45f3f0 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources/index.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { AgentConfig, Datasource } from '../../../../../../../../common/types/models'; +import { NoDatasources } from './no_datasources'; +import { DatasourcesTable } from './datasources_table'; + +export const ConfigDatasourcesView = memo<{ config: AgentConfig }>(({ config }) => { + if (config.datasources.length === 0) { + return ; + } + + return ( + + ); +}); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources/no_datasources.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources/no_datasources.tsx new file mode 100644 index 0000000000000..2d8f73e67cf96 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources/no_datasources.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; +import React, { memo } from 'react'; +import { useCapabilities } from '../../../../../hooks'; +import { useAgentConfigLink } from '../../hooks/use_details_uri'; + +export const NoDatasources = memo<{ configId: string }>(({ configId }) => { + const hasWriteCapabilities = useCapabilities().write; + const addDatasourceLink = useAgentConfigLink('add-datasource', { configId }); + + return ( + + + + } + body={ + + } + actions={ + + + + } + /> + ); +}); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources_table.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources_table.tsx deleted file mode 100644 index 3c982747e1d22..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources_table.tsx +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiInMemoryTable, EuiInMemoryTableProps, EuiBadge } from '@elastic/eui'; -import { Datasource } from '../../../../types'; - -type DatasourceWithConfig = Datasource & { configs?: string[] }; - -interface InMemoryDatasource { - id: string; - name: string; - streams: number; - packageName?: string; - packageTitle?: string; - packageVersion?: string; - configs: number; -} - -interface Props { - datasources?: DatasourceWithConfig[]; - withConfigsCount?: boolean; - loading?: EuiInMemoryTableProps['loading']; - message?: EuiInMemoryTableProps['message']; - search?: EuiInMemoryTableProps['search']; - selection?: EuiInMemoryTableProps['selection']; - isSelectable?: EuiInMemoryTableProps['isSelectable']; -} - -export const DatasourcesTable: React.FunctionComponent = ( - { datasources: originalDatasources, withConfigsCount, ...rest } = { - datasources: [], - withConfigsCount: false, - } -) => { - // Flatten some values so that they can be searched via in-memory table search - const datasources = - originalDatasources?.map(({ id, name, inputs, package: datasourcePackage, configs }) => ({ - id, - name, - streams: inputs.reduce( - (streamsCount, input) => - streamsCount + - (input.enabled ? input.streams.filter(stream => stream.enabled).length : 0), - 0 - ), - packageName: datasourcePackage?.name, - packageTitle: datasourcePackage?.title, - packageVersion: datasourcePackage?.version, - configs: configs?.length || 0, - })) || []; - - const columns: EuiInMemoryTableProps['columns'] = [ - { - field: 'name', - name: i18n.translate('xpack.ingestManager.configDetails.datasourcesTable.nameColumnTitle', { - defaultMessage: 'Name', - }), - }, - { - field: 'packageTitle', - name: i18n.translate( - 'xpack.ingestManager.configDetails.datasourcesTable.packageNameColumnTitle', - { - defaultMessage: 'Package', - } - ), - }, - { - field: 'packageVersion', - name: i18n.translate( - 'xpack.ingestManager.configDetails.datasourcesTable.packageVersionColumnTitle', - { - defaultMessage: 'Version', - } - ), - }, - { - field: 'streams', - name: i18n.translate( - 'xpack.ingestManager.configDetails.datasourcesTable.streamsCountColumnTitle', - { - defaultMessage: 'Streams', - } - ), - }, - ]; - - if (withConfigsCount) { - columns.splice(columns.length - 1, 0, { - field: 'configs', - name: i18n.translate( - 'xpack.ingestManager.configDetails.datasourcesTable.configsColumnTitle', - { - defaultMessage: 'Configs', - } - ), - render: (configs: number) => { - return configs === 0 ? ( - - - - ) : ( - configs - ); - }, - }); - } - - return ( - - itemId="id" - items={datasources || ([] as InMemoryDatasource[])} - columns={columns} - sorting={{ - sort: { - field: 'name', - direction: 'asc', - }, - }} - {...rest} - /> - ); -}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/index.ts index 51834268ffa5b..918b361a60d79 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/index.ts @@ -3,6 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -export { DatasourcesTable } from './datasources_table'; +export { DatasourcesTable } from './datasources/datasources_table'; export { DonutChart } from './donut_chart'; export { EditConfigFlyout } from './edit_config'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/use_details_uri.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/use_details_uri.ts index df43d8e908e41..9332ce3e0f909 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/use_details_uri.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/use_details_uri.ts @@ -4,29 +4,51 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useMemo } from 'react'; import { generatePath } from 'react-router-dom'; import { useLink } from '../../../../hooks'; import { AGENT_CONFIG_PATH } from '../../../../constants'; import { DETAILS_ROUTER_PATH, DETAILS_ROUTER_SUB_PATH } from '../constants'; -export const useDetailsUri = (configId: string) => { - const BASE_URI = useLink(''); - return useMemo(() => { - const AGENT_CONFIG_DETAILS = `${BASE_URI}${generatePath(DETAILS_ROUTER_PATH, { configId })}`; +type AgentConfigUriArgs = + | ['list'] + | ['details', { configId: string }] + | ['details-yaml', { configId: string }] + | ['details-settings', { configId: string }] + | ['datasource', { configId: string; datasourceId: string }] + | ['add-datasource', { configId: string }]; + +/** + * Returns a Uri that starts at the Agent Config Route path (`/configs/`). + * These are good for use when needing to use React Router's redirect or + * `history.push(routePath)`. + * @param args + */ +export const useAgentConfigUri = (...args: AgentConfigUriArgs) => { + switch (args[0]) { + case 'list': + return AGENT_CONFIG_PATH; + case 'details': + return generatePath(DETAILS_ROUTER_PATH, args[1]); + case 'details-yaml': + return `${generatePath(DETAILS_ROUTER_SUB_PATH, { ...args[1], tabId: 'yaml' })}`; + case 'details-settings': + return `${generatePath(DETAILS_ROUTER_SUB_PATH, { ...args[1], tabId: 'settings' })}`; + case 'add-datasource': + return `${generatePath(DETAILS_ROUTER_SUB_PATH, { ...args[1], tabId: 'add-datasource' })}`; + case 'datasource': + const [, options] = args; + return `${generatePath(DETAILS_ROUTER_PATH, options)}?datasourceId=${options.datasourceId}`; + } + return '/'; +}; - return { - ADD_DATASOURCE: `${AGENT_CONFIG_DETAILS}/add-datasource`, - AGENT_CONFIG_LIST: `${BASE_URI}${AGENT_CONFIG_PATH}`, - AGENT_CONFIG_DETAILS, - AGENT_CONFIG_DETAILS_YAML: `${BASE_URI}${generatePath(DETAILS_ROUTER_SUB_PATH, { - configId, - tabId: 'yaml', - })}`, - AGENT_CONFIG_DETAILS_SETTINGS: `${BASE_URI}${generatePath(DETAILS_ROUTER_SUB_PATH, { - configId, - tabId: 'settings', - })}`, - }; - }, [BASE_URI, configId]); +/** + * Returns a full Link that includes Kibana basepath (ex. `/app/ingestManager#/configs`). + * These are good for use in `href` properties + * @param args + */ +export const useAgentConfigLink = (...args: AgentConfigUriArgs) => { + const BASE_URI = useLink(''); + const AGENT_CONFIG_ROUTE = useAgentConfigUri(...args); + return `${BASE_URI}${AGENT_CONFIG_ROUTE}`; }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx index 6f72977cb333f..efb96f6459254 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx @@ -14,9 +14,7 @@ import { EuiText, EuiSpacer, EuiTitle, - EuiButton, EuiButtonEmpty, - EuiEmptyPrompt, EuiI18nNumber, EuiDescriptionList, EuiDescriptionListTitle, @@ -24,15 +22,15 @@ import { } from '@elastic/eui'; import { Props as EuiTabProps } from '@elastic/eui/src/components/tabs/tab'; import styled from 'styled-components'; -import { useCapabilities, useGetOneAgentConfig } from '../../../hooks'; -import { Datasource } from '../../../types'; +import { useGetOneAgentConfig } from '../../../hooks'; import { Loading } from '../../../components'; import { WithHeaderLayout } from '../../../layouts'; import { ConfigRefreshContext, useGetAgentStatus, AgentStatusRefreshContext } from './hooks'; -import { DatasourcesTable, EditConfigFlyout } from './components'; +import { EditConfigFlyout } from './components'; import { LinkedAgentCount } from '../components'; -import { useDetailsUri } from './hooks/use_details_uri'; +import { useAgentConfigLink } from './hooks/use_details_uri'; import { DETAILS_ROUTER_PATH, DETAILS_ROUTER_SUB_PATH } from './constants'; +import { ConfigDatasourcesView } from './components/datasources'; const Divider = styled.div` width: 0; @@ -57,7 +55,6 @@ export const AgentConfigDetailsLayout: React.FunctionComponent = () => { const { params: { configId, tabId = '' }, } = useRouteMatch<{ configId: string; tabId?: string }>(); - const hasWriteCapabilites = useCapabilities().write; const agentConfigRequest = useGetOneAgentConfig(configId); const agentConfig = agentConfigRequest.data ? agentConfigRequest.data.item : null; const { isLoading, error, sendRequest: refreshAgentConfig } = agentConfigRequest; @@ -65,7 +62,12 @@ export const AgentConfigDetailsLayout: React.FunctionComponent = () => { const agentStatusRequest = useGetAgentStatus(configId); const { refreshAgentStatus } = agentStatusRequest; const agentStatus = agentStatusRequest.data?.results; - const URI = useDetailsUri(configId); + + // Links + const configListLink = useAgentConfigLink('list'); + const configDetailsLink = useAgentConfigLink('details', { configId }); + const configDetailsYamlLink = useAgentConfigLink('details-yaml', { configId }); + const configDetailsSettingsLink = useAgentConfigLink('details-settings', { configId }); // Flyout states const [isEditConfigFlyoutOpen, setIsEditConfigFlyoutOpen] = useState(false); @@ -83,12 +85,7 @@ export const AgentConfigDetailsLayout: React.FunctionComponent = () => {
      - + { ), - [URI.AGENT_CONFIG_LIST, agentConfig, configId] + [configListLink, agentConfig, configId] ); const headerRightContent = useMemo( @@ -134,7 +131,7 @@ export const AgentConfigDetailsLayout: React.FunctionComponent = () => { label: i18n.translate('xpack.ingestManager.configDetails.summary.revision', { defaultMessage: 'Revision', }), - content: '999', // FIXME: implement version - see: https://github.com/elastic/kibana/issues/56750 + content: agentConfig?.revision ?? 0, }, { isDivider: true }, { @@ -201,15 +198,15 @@ export const AgentConfigDetailsLayout: React.FunctionComponent = () => { name: i18n.translate('xpack.ingestManager.configDetails.subTabs.datasouces', { defaultMessage: 'Data sources', }), - href: URI.AGENT_CONFIG_DETAILS, - isSelected: tabId === '', + href: configDetailsLink, + isSelected: tabId === '' || tabId === 'datasources', }, { id: 'yaml', name: i18n.translate('xpack.ingestManager.configDetails.subTabs.yamlFile', { defaultMessage: 'YAML File', }), - href: URI.AGENT_CONFIG_DETAILS_YAML, + href: configDetailsYamlLink, isSelected: tabId === 'yaml', }, { @@ -217,16 +214,11 @@ export const AgentConfigDetailsLayout: React.FunctionComponent = () => { name: i18n.translate('xpack.ingestManager.configDetails.subTabs.settings', { defaultMessage: 'Settings', }), - href: URI.AGENT_CONFIG_DETAILS_SETTINGS, + href: configDetailsSettingsLink, isSelected: tabId === 'settings', }, ]; - }, [ - URI.AGENT_CONFIG_DETAILS, - URI.AGENT_CONFIG_DETAILS_SETTINGS, - URI.AGENT_CONFIG_DETAILS_YAML, - tabId, - ]); + }, [configDetailsLink, configDetailsSettingsLink, configDetailsYamlLink, tabId]); if (redirectToAgentConfigList) { return ; @@ -304,57 +296,7 @@ export const AgentConfigDetailsLayout: React.FunctionComponent = () => { { - return ( - - - - } - actions={ - - - - } - /> - ) : null - } - search={{ - toolsRight: [ - - - , - ], - box: { - incremental: true, - schema: true, - }, - }} - isSelectable={false} - /> - ); + return ; }} /> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx index 31c86d0a4cbf0..0498e814440c7 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { CSSProperties, useCallback, useMemo, useState } from 'react'; +import React, { CSSProperties, memo, useCallback, useMemo, useState } from 'react'; import { EuiSpacer, EuiText, @@ -16,14 +16,10 @@ import { EuiTableActionsColumnType, EuiTableFieldDataColumnType, EuiTextColor, - EuiPopover, - EuiContextMenuPanel, EuiContextMenuItem, - EuiButtonIcon, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage, FormattedDate } from '@kbn/i18n/react'; -import styled from 'styled-components'; import { useHistory } from 'react-router-dom'; import { AgentConfig } from '../../../types'; import { @@ -44,6 +40,9 @@ import { AgentConfigDeleteProvider } from '../components'; import { CreateAgentConfigFlyout } from './components'; import { SearchBar } from '../../../components/search_bar'; import { LinkedAgentCount } from '../components'; +import { useAgentConfigLink } from '../details_page/hooks/use_details_uri'; +import { TableRowActions } from '../components/table_row_actions'; +import { DangerEuiContextMenuItem } from '../components/danger_eui_context_menu_item'; const NO_WRAP_TRUNCATE_STYLE: CSSProperties = Object.freeze({ overflow: 'hidden', @@ -82,83 +81,59 @@ const AgentConfigListPageLayout: React.FunctionComponent = ({ children }) => ( ); -const DangerEuiContextMenuItem = styled(EuiContextMenuItem)` - color: ${props => props.theme.eui.textColors.danger}; -`; - -const RowActions = React.memo<{ config: AgentConfig; onDelete: () => void }>( +const ConfigRowActions = memo<{ config: AgentConfig; onDelete: () => void }>( ({ config, onDelete }) => { - const hasWriteCapabilites = useCapabilities().write; - const DETAILS_URI = useLink(`${AGENT_CONFIG_DETAILS_PATH}${config.id}`); - const ADD_DATASOURCE_URI = `${DETAILS_URI}/add-datasource`; - - const [isOpen, setIsOpen] = useState(false); - const handleCloseMenu = useCallback(() => setIsOpen(false), [setIsOpen]); - const handleToggleMenu = useCallback(() => setIsOpen(!isOpen), [isOpen]); + const hasWriteCapabilities = useCapabilities().write; + const detailsLink = useAgentConfigLink('details', { configId: config.id }); + const addDatasourceLink = useAgentConfigLink('add-datasource', { configId: config.id }); return ( - - } - isOpen={isOpen} - closePopover={handleCloseMenu} - > - - - , + + + , - - - , + + + , - - - , + + + , - - {deleteAgentConfigsPrompt => { - return ( - deleteAgentConfigsPrompt([config.id], onDelete)} - > - - - ); - }} - , - ]} - /> - + + {deleteAgentConfigsPrompt => { + return ( + deleteAgentConfigsPrompt([config.id], onDelete)} + > + + + ); + }} + , + ]} + /> ); } ); @@ -287,7 +262,7 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => { actions: [ { render: (config: AgentConfig) => ( - sendRequest()} /> + sendRequest()} /> ), }, ], @@ -330,10 +305,10 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => { /> } - actions={hasWriteCapabilites ?? createAgentConfigButton} + actions={createAgentConfigButton} /> ), - [hasWriteCapabilites, createAgentConfigButton] + [createAgentConfigButton] ); return ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts index 2cb940e2ff40c..41bc2aa258807 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts @@ -3,5 +3,3 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -export { PackageIcon } from './package_icon'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx index d1d7cfc180cad..8ad081cbbabe4 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx @@ -8,7 +8,7 @@ import styled from 'styled-components'; import { EuiCard } from '@elastic/eui'; import { PackageInfo, PackageListItem } from '../../../types'; import { useLinks } from '../hooks'; -import { PackageIcon } from './package_icon'; +import { PackageIcon } from '../../../components/package_icon'; export interface BadgeProps { showInstalledBadge?: boolean; @@ -40,7 +40,7 @@ export function PackageCard({ layout="horizontal" title={title || ''} description={description} - icon={} + icon={} href={url} /> ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_icon.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_icon.tsx deleted file mode 100644 index dd2f46adc3188..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_icon.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; -import { ICON_TYPES, EuiIcon, EuiIconProps } from '@elastic/eui'; -import { PackageInfo, PackageListItem } from '../../../types'; -import { useLinks } from '../hooks'; - -type Package = PackageInfo | PackageListItem; - -export const PackageIcon: React.FunctionComponent<{ - packageName: string; - icons?: Package['icons']; -} & Omit> = ({ packageName, icons, ...euiIconProps }) => { - const { toImage } = useLinks(); - // try to find a logo in EUI - const euiLogoIcon = ICON_TYPES.find(key => key.toLowerCase() === `logo${packageName}`); - const svgIcons = icons?.filter(icon => icon.type === 'image/svg+xml'); - const localIcon = svgIcons && Array.isArray(svgIcons) && svgIcons[0]; - const pathToLocal = localIcon && toImage(localIcon.src); - const euiIconType = pathToLocal || euiLogoIcon || 'package'; - - return ; -}; diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.test.ts b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.test.ts new file mode 100644 index 0000000000000..a20ba4a880537 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { NewAgentActionSchema } from '../../types/models'; +import { + KibanaResponseFactory, + RequestHandlerContext, + SavedObjectsClientContract, +} from 'kibana/server'; +import { savedObjectsClientMock } from '../../../../../../src/core/server/saved_objects/service/saved_objects_client.mock'; +import { httpServerMock } from '../../../../../../src/core/server/http/http_server.mocks'; +import { ActionsService } from '../../services/agents'; +import { AgentAction } from '../../../common/types/models'; +import { postNewAgentActionHandlerBuilder } from './actions_handlers'; +import { + PostNewAgentActionRequest, + PostNewAgentActionResponse, +} from '../../../common/types/rest_spec'; + +describe('test actions handlers schema', () => { + it('validate that new agent actions schema is valid', async () => { + expect( + NewAgentActionSchema.validate({ + type: 'CONFIG_CHANGE', + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }) + ).toBeTruthy(); + }); + + it('validate that new agent actions schema is invalid when required properties are not provided', async () => { + expect(() => { + NewAgentActionSchema.validate({ + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }); + }).toThrowError(); + }); +}); + +describe('test actions handlers', () => { + let mockResponse: jest.Mocked; + let mockSavedObjectsClient: jest.Mocked; + + beforeEach(() => { + mockSavedObjectsClient = savedObjectsClientMock.create(); + mockResponse = httpServerMock.createResponseFactory(); + }); + + it('should succeed on valid new agent action', async () => { + const postNewAgentActionRequest: PostNewAgentActionRequest = { + body: { + action: { + type: 'CONFIG_CHANGE', + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }, + }, + params: { + agentId: 'id', + }, + }; + + const mockRequest = httpServerMock.createKibanaRequest(postNewAgentActionRequest); + + const agentAction = ({ + type: 'CONFIG_CHANGE', + id: 'action1', + sent_at: '2020-03-14T19:45:02.620Z', + timestamp: '2019-01-04T14:32:03.36764-05:00', + created_at: '2020-03-14T19:45:02.620Z', + } as unknown) as AgentAction; + + const actionsService: ActionsService = { + getAgent: jest.fn().mockReturnValueOnce({ + id: 'agent', + }), + updateAgentActions: jest.fn().mockReturnValueOnce(agentAction), + } as jest.Mocked; + + const postNewAgentActionHandler = postNewAgentActionHandlerBuilder(actionsService); + await postNewAgentActionHandler( + ({ + core: { + savedObjects: { + client: mockSavedObjectsClient, + }, + }, + } as unknown) as RequestHandlerContext, + mockRequest, + mockResponse + ); + + const expectedAgentActionResponse = (mockResponse.ok.mock.calls[0][0] + ?.body as unknown) as PostNewAgentActionResponse; + + expect(expectedAgentActionResponse.item).toEqual(agentAction); + expect(expectedAgentActionResponse.success).toEqual(true); + }); +}); diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts new file mode 100644 index 0000000000000..2b9c230803593 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// handlers that handle agent actions request + +import { RequestHandler } from 'kibana/server'; +import { TypeOf } from '@kbn/config-schema'; +import { PostNewAgentActionRequestSchema } from '../../types/rest_spec'; +import { ActionsService } from '../../services/agents'; +import { NewAgentAction } from '../../../common/types/models'; +import { PostNewAgentActionResponse } from '../../../common/types/rest_spec'; + +export const postNewAgentActionHandlerBuilder = function( + actionsService: ActionsService +): RequestHandler< + TypeOf, + undefined, + TypeOf +> { + return async (context, request, response) => { + try { + const soClient = context.core.savedObjects.client; + + const agent = await actionsService.getAgent(soClient, request.params.agentId); + + const newAgentAction = request.body.action as NewAgentAction; + + const savedAgentAction = await actionsService.updateAgentActions( + soClient, + agent, + newAgentAction + ); + + const body: PostNewAgentActionResponse = { + success: true, + item: savedAgentAction, + }; + + return response.ok({ body }); + } catch (e) { + if (e.isBoom) { + return response.customError({ + statusCode: e.output.statusCode, + body: { message: e.message }, + }); + } + + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } + }; +}; diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts index 414d2d79e9067..d461027017842 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts @@ -22,6 +22,7 @@ import { PostAgentAcksRequestSchema, PostAgentUnenrollRequestSchema, GetAgentStatusRequestSchema, + PostNewAgentActionRequestSchema, } from '../../types'; import { getAgentsHandler, @@ -37,6 +38,7 @@ import { } from './handlers'; import { postAgentAcksHandlerBuilder } from './acks_handlers'; import * as AgentService from '../../services/agents'; +import { postNewAgentActionHandlerBuilder } from './actions_handlers'; export const registerRoutes = (router: IRouter) => { // Get one @@ -111,6 +113,19 @@ export const registerRoutes = (router: IRouter) => { }) ); + // Agent actions + router.post( + { + path: AGENT_API_ROUTES.ACTIONS_PATTERN, + validate: PostNewAgentActionRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + postNewAgentActionHandlerBuilder({ + getAgent: AgentService.getAgent, + updateAgentActions: AgentService.updateAgentActions, + }) + ); + router.post( { path: AGENT_API_ROUTES.UNENROLL_PATTERN, diff --git a/x-pack/plugins/ingest_manager/server/services/agents/actions.test.ts b/x-pack/plugins/ingest_manager/server/services/agents/actions.test.ts new file mode 100644 index 0000000000000..b500aeb825fec --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/actions.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createAgentAction, updateAgentActions } from './actions'; +import { Agent, AgentAction, NewAgentAction } from '../../../common/types/models'; +import { savedObjectsClientMock } from '../../../../../../src/core/server/saved_objects/service/saved_objects_client.mock'; +import { AGENT_TYPE_PERMANENT } from '../../../common/constants'; + +interface UpdatedActions { + actions: AgentAction[]; +} + +describe('test agent actions services', () => { + it('should update agent current actions with new action', async () => { + const mockSavedObjectsClient = savedObjectsClientMock.create(); + + const newAgentAction: NewAgentAction = { + type: 'CONFIG_CHANGE', + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }; + + await updateAgentActions( + mockSavedObjectsClient, + ({ + id: 'id', + type: AGENT_TYPE_PERMANENT, + actions: [ + { + type: 'CONFIG_CHANGE', + id: 'action1', + sent_at: '2020-03-14T19:45:02.620Z', + timestamp: '2019-01-04T14:32:03.36764-05:00', + created_at: '2020-03-14T19:45:02.620Z', + }, + ], + } as unknown) as Agent, + newAgentAction + ); + + const updatedAgentActions = (mockSavedObjectsClient.update.mock + .calls[0][2] as unknown) as UpdatedActions; + + expect(updatedAgentActions.actions.length).toEqual(2); + const actualAgentAction = updatedAgentActions.actions.find(action => action?.data === 'data'); + expect(actualAgentAction?.type).toEqual(newAgentAction.type); + expect(actualAgentAction?.data).toEqual(newAgentAction.data); + expect(actualAgentAction?.sent_at).toEqual(newAgentAction.sent_at); + }); + + it('should create agent action from new agent action model', async () => { + const newAgentAction: NewAgentAction = { + type: 'CONFIG_CHANGE', + data: 'data', + sent_at: '2020-03-14T19:45:02.620Z', + }; + const now = new Date(); + const agentAction = createAgentAction(now, newAgentAction); + + expect(agentAction.type).toEqual(newAgentAction.type); + expect(agentAction.data).toEqual(newAgentAction.data); + expect(agentAction.sent_at).toEqual(newAgentAction.sent_at); + }); +}); diff --git a/x-pack/plugins/ingest_manager/server/services/agents/actions.ts b/x-pack/plugins/ingest_manager/server/services/agents/actions.ts new file mode 100644 index 0000000000000..2f8ed9f504453 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/actions.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract } from 'kibana/server'; +import uuid from 'uuid'; +import { + Agent, + AgentAction, + AgentSOAttributes, + NewAgentAction, +} from '../../../common/types/models'; +import { AGENT_SAVED_OBJECT_TYPE } from '../../../common/constants'; + +export async function updateAgentActions( + soClient: SavedObjectsClientContract, + agent: Agent, + newAgentAction: NewAgentAction +): Promise { + const agentAction = createAgentAction(new Date(), newAgentAction); + + agent.actions.push(agentAction); + + await soClient.update(AGENT_SAVED_OBJECT_TYPE, agent.id, { + actions: agent.actions, + }); + + return agentAction; +} + +export function createAgentAction(createdAt: Date, newAgentAction: NewAgentAction): AgentAction { + const agentAction = { + id: uuid.v4(), + created_at: createdAt.toISOString(), + }; + + return Object.assign(agentAction, newAgentAction); +} + +export interface ActionsService { + getAgent: (soClient: SavedObjectsClientContract, agentId: string) => Promise; + + updateAgentActions: ( + soClient: SavedObjectsClientContract, + agent: Agent, + newAgentAction: NewAgentAction + ) => Promise; +} diff --git a/x-pack/plugins/ingest_manager/server/services/agents/index.ts b/x-pack/plugins/ingest_manager/server/services/agents/index.ts index 477f081d1900b..c95c9ecc2a1d8 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/index.ts @@ -12,3 +12,4 @@ export * from './unenroll'; export * from './status'; export * from './crud'; export * from './update'; +export * from './actions'; diff --git a/x-pack/plugins/ingest_manager/server/services/api_keys/enrollment_api_key.ts b/x-pack/plugins/ingest_manager/server/services/api_keys/enrollment_api_key.ts index d81b998d5a752..5960441635524 100644 --- a/x-pack/plugins/ingest_manager/server/services/api_keys/enrollment_api_key.ts +++ b/x-pack/plugins/ingest_manager/server/services/api_keys/enrollment_api_key.ts @@ -93,7 +93,19 @@ export async function generateEnrollmentAPIKey( const name = providedKeyName ? `${providedKeyName} (${id})` : id; - const key = await createAPIKey(soClient, name, {}); + const key = await createAPIKey(soClient, name, { + // Useless role to avoid to have the privilege of the user that created the key + 'fleet-apikey-enroll': { + cluster: [], + applications: [ + { + application: '.fleet', + privileges: ['no-privileges'], + resources: ['*'], + }, + ], + }, + }); if (!key) { throw new Error('Unable to create an enrollment api key'); diff --git a/x-pack/plugins/ingest_manager/server/services/api_keys/index.ts b/x-pack/plugins/ingest_manager/server/services/api_keys/index.ts index 9b0182b86fc88..5c05d5612e200 100644 --- a/x-pack/plugins/ingest_manager/server/services/api_keys/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/api_keys/index.ts @@ -42,7 +42,17 @@ export async function generateAccessApiKey( configId: string ) { const key = await createAPIKey(soClient, agentId, { - 'fleet-agent': {}, + // Useless role to avoid to have the privilege of the user that created the key + 'fleet-apikey-access': { + cluster: [], + applications: [ + { + application: '.fleet', + privileges: ['no-privileges'], + resources: ['*'], + }, + ], + }, }); if (!key) { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap index ad4d636164d71..0e239c24dd9cf 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`tests loading fields.yml: base.yml 1`] = ` +exports[`tests loading base.yml: base.yml 1`] = ` { "order": 1, "index_patterns": [ @@ -47,10 +47,12 @@ exports[`tests loading fields.yml: base.yml 1`] = ` "user": { "properties": { "auid": { - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "euid": { - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 } } }, @@ -59,7 +61,10 @@ exports[`tests loading fields.yml: base.yml 1`] = ` "nested": { "properties": { "foo": { - "type": "keyword" + "type": "text" + }, + "bar": { + "type": "long" } } } @@ -68,7 +73,1593 @@ exports[`tests loading fields.yml: base.yml 1`] = ` "nested": { "properties": { "bar": { + "type": "keyword", + "ignore_above": 1024 + }, + "baz": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "myalias": { + "type": "alias", + "path": "user.euid" + }, + "validarray": { + "type": "integer" + } + } + }, + "aliases": {} +} +`; + +exports[`tests loading coredns.logs.yml: coredns.logs.yml 1`] = ` +{ + "order": 1, + "index_patterns": [ + "foo-*" + ], + "settings": { + "index": { + "lifecycle": { + "name": "logs-default" + }, + "codec": "best_compression", + "mapping": { + "total_fields": { + "limit": "10000" + } + }, + "refresh_interval": "5s", + "number_of_shards": "1", + "query": { + "default_field": [ + "message" + ] + }, + "number_of_routing_shards": "30" + } + }, + "mappings": { + "_meta": { + "package": "foo" + }, + "dynamic_templates": [ + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], + "date_detection": false, + "properties": { + "coredns": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "query": { + "properties": { + "size": { + "type": "long" + }, + "class": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "response": { + "properties": { + "code": { + "type": "keyword", + "ignore_above": 1024 + }, + "flags": { + "type": "keyword", + "ignore_above": 1024 + }, + "size": { + "type": "long" + } + } + }, + "dnssec_ok": { + "type": "boolean" + } + } + } + } + }, + "aliases": {} +} +`; + +exports[`tests loading system.yml: system.yml 1`] = ` +{ + "order": 1, + "index_patterns": [ + "whatsthis-*" + ], + "settings": { + "index": { + "lifecycle": { + "name": "metrics-default" + }, + "codec": "best_compression", + "mapping": { + "total_fields": { + "limit": "10000" + } + }, + "refresh_interval": "5s", + "number_of_shards": "1", + "query": { + "default_field": [ + "message" + ] + }, + "number_of_routing_shards": "30" + } + }, + "mappings": { + "_meta": { + "package": "foo" + }, + "dynamic_templates": [ + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], + "date_detection": false, + "properties": { + "system": { + "properties": { + "core": { + "properties": { + "id": { + "type": "long" + }, + "user": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "ticks": { + "type": "long" + } + } + }, + "system": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "ticks": { + "type": "long" + } + } + }, + "nice": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "ticks": { + "type": "long" + } + } + }, + "idle": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "ticks": { + "type": "long" + } + } + }, + "iowait": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "ticks": { + "type": "long" + } + } + }, + "irq": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "ticks": { + "type": "long" + } + } + }, + "softirq": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "ticks": { + "type": "long" + } + } + }, + "steal": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "ticks": { + "type": "long" + } + } + } + } + }, + "cpu": { + "properties": { + "cores": { + "type": "long" + }, + "user": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + }, + "ticks": { + "type": "long" + } + } + }, + "system": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + }, + "ticks": { + "type": "long" + } + } + }, + "nice": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + }, + "ticks": { + "type": "long" + } + } + }, + "idle": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + }, + "ticks": { + "type": "long" + } + } + }, + "iowait": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + }, + "ticks": { + "type": "long" + } + } + }, + "irq": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + }, + "ticks": { + "type": "long" + } + } + }, + "softirq": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + }, + "ticks": { + "type": "long" + } + } + }, + "steal": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + }, + "ticks": { + "type": "long" + } + } + }, + "total": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + } + } + } + } + }, + "diskio": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "serial_number": { + "type": "keyword", + "ignore_above": 1024 + }, + "read": { + "properties": { + "count": { + "type": "long" + }, + "bytes": { + "type": "long" + }, + "time": { + "type": "long" + } + } + }, + "write": { + "properties": { + "count": { + "type": "long" + }, + "bytes": { + "type": "long" + }, + "time": { + "type": "long" + } + } + }, + "io": { + "properties": { + "time": { + "type": "long" + } + } + }, + "iostat": { + "properties": { + "read": { + "properties": { + "request": { + "properties": { + "merges_per_sec": { + "type": "float" + }, + "per_sec": { + "type": "float" + } + } + }, + "per_sec": { + "properties": { + "bytes": { + "type": "float" + } + } + }, + "await": { + "type": "float" + } + } + }, + "write": { + "properties": { + "request": { + "properties": { + "merges_per_sec": { + "type": "float" + }, + "per_sec": { + "type": "float" + } + } + }, + "per_sec": { + "properties": { + "bytes": { + "type": "float" + } + } + }, + "await": { + "type": "float" + } + } + }, + "request": { + "properties": { + "avg_size": { + "type": "float" + } + } + }, + "queue": { + "properties": { + "avg_size": { + "type": "float" + } + } + }, + "await": { + "type": "float" + }, + "service_time": { + "type": "float" + }, + "busy": { + "type": "float" + } + } + } + } + }, + "entropy": { + "properties": { + "available_bits": { + "type": "long" + }, + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + }, + "filesystem": { + "properties": { + "available": { + "type": "long" + }, + "device_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "mount_point": { + "type": "keyword", + "ignore_above": 1024 + }, + "files": { + "type": "long" + }, + "free": { + "type": "long" + }, + "free_files": { + "type": "long" + }, + "total": { + "type": "long" + }, + "used": { + "properties": { + "bytes": { + "type": "long" + }, + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + } + } + }, + "fsstat": { + "properties": { + "count": { + "type": "long" + }, + "total_files": { + "type": "long" + }, + "total_size": { + "properties": { + "free": { + "type": "long" + }, + "used": { + "type": "long" + }, + "total": { + "type": "long" + } + } + } + } + }, + "load": { + "properties": { + "1": { + "type": "scaled_float", + "scaling_factor": 100 + }, + "5": { + "type": "scaled_float", + "scaling_factor": 100 + }, + "15": { + "type": "scaled_float", + "scaling_factor": 100 + }, + "norm": { + "properties": { + "1": { + "type": "scaled_float", + "scaling_factor": 100 + }, + "5": { + "type": "scaled_float", + "scaling_factor": 100 + }, + "15": { + "type": "scaled_float", + "scaling_factor": 100 + } + } + }, + "cores": { + "type": "long" + } + } + }, + "memory": { + "properties": { + "total": { + "type": "long" + }, + "used": { + "properties": { + "bytes": { + "type": "long" + }, + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + }, + "free": { + "type": "long" + }, + "actual": { + "properties": { + "used": { + "properties": { + "bytes": { + "type": "long" + }, + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + }, + "free": { + "type": "long" + } + } + }, + "swap": { + "properties": { + "total": { + "type": "long" + }, + "used": { + "properties": { + "bytes": { + "type": "long" + }, + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + }, + "free": { + "type": "long" + }, + "out": { + "properties": { + "pages": { + "type": "long" + } + } + }, + "in": { + "properties": { + "pages": { + "type": "long" + } + } + }, + "readahead": { + "properties": { + "pages": { + "type": "long" + }, + "cached": { + "type": "long" + } + } + } + } + }, + "hugepages": { + "properties": { + "total": { + "type": "long" + }, + "used": { + "properties": { + "bytes": { + "type": "long" + }, + "pct": { + "type": "long" + } + } + }, + "free": { + "type": "long" + }, + "reserved": { + "type": "long" + }, + "surplus": { + "type": "long" + }, + "default_size": { + "type": "long" + }, + "swap": { + "properties": { + "out": { + "properties": { + "pages": { + "type": "long" + }, + "fallback": { + "type": "long" + } + } + } + } + } + } + } + } + }, + "network": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "out": { + "properties": { + "bytes": { + "type": "long" + }, + "packets": { + "type": "long" + }, + "errors": { + "type": "long" + }, + "dropped": { + "type": "long" + } + } + }, + "in": { + "properties": { + "bytes": { + "type": "long" + }, + "packets": { + "type": "long" + }, + "errors": { + "type": "long" + }, + "dropped": { + "type": "long" + } + } + } + } + }, + "network_summary": { + "properties": { + "ip": { + "properties": { + "*": { + "type": "object" + } + } + }, + "tcp": { + "properties": { + "*": { + "type": "object" + } + } + }, + "udp": { + "properties": { + "*": { + "type": "object" + } + } + }, + "udp_lite": { + "properties": { + "*": { + "type": "object" + } + } + }, + "icmp": { + "properties": { + "*": { + "type": "object" + } + } + } + } + }, + "process": { + "properties": { + "state": { + "type": "keyword", + "ignore_above": 1024 + }, + "cmdline": { + "type": "keyword", + "ignore_above": 2048 + }, + "env": { + "type": "object" + }, + "cpu": { + "properties": { + "user": { + "properties": { + "ticks": { + "type": "long" + } + } + }, + "total": { + "properties": { + "value": { + "type": "long" + }, + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + }, + "ticks": { + "type": "long" + } + } + }, + "system": { + "properties": { + "ticks": { + "type": "long" + } + } + }, + "start_time": { + "type": "date" + } + } + }, + "memory": { + "properties": { + "size": { + "type": "long" + }, + "rss": { + "properties": { + "bytes": { + "type": "long" + }, + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + }, + "share": { + "type": "long" + } + } + }, + "fd": { + "properties": { + "open": { + "type": "long" + }, + "limit": { + "properties": { + "soft": { + "type": "long" + }, + "hard": { + "type": "long" + } + } + } + } + }, + "cgroup": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "path": { + "type": "keyword", + "ignore_above": 1024 + }, + "cpu": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "path": { + "type": "keyword", + "ignore_above": 1024 + }, + "cfs": { + "properties": { + "period": { + "properties": { + "us": { + "type": "long" + } + } + }, + "quota": { + "properties": { + "us": { + "type": "long" + } + } + }, + "shares": { + "type": "long" + } + } + }, + "rt": { + "properties": { + "period": { + "properties": { + "us": { + "type": "long" + } + } + }, + "runtime": { + "properties": { + "us": { + "type": "long" + } + } + } + } + }, + "stats": { + "properties": { + "periods": { + "type": "long" + }, + "throttled": { + "properties": { + "periods": { + "type": "long" + }, + "ns": { + "type": "long" + } + } + } + } + } + } + }, + "cpuacct": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "path": { + "type": "keyword", + "ignore_above": 1024 + }, + "total": { + "properties": { + "ns": { + "type": "long" + } + } + }, + "stats": { + "properties": { + "user": { + "properties": { + "ns": { + "type": "long" + } + } + }, + "system": { + "properties": { + "ns": { + "type": "long" + } + } + } + } + }, + "percpu": { + "type": "object" + } + } + }, + "memory": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "path": { + "type": "keyword", + "ignore_above": 1024 + }, + "mem": { + "properties": { + "usage": { + "properties": { + "bytes": { + "type": "long" + }, + "max": { + "properties": { + "bytes": { + "type": "long" + } + } + } + } + }, + "limit": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "failures": { + "type": "long" + } + } + }, + "memsw": { + "properties": { + "usage": { + "properties": { + "bytes": { + "type": "long" + }, + "max": { + "properties": { + "bytes": { + "type": "long" + } + } + } + } + }, + "limit": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "failures": { + "type": "long" + } + } + }, + "kmem": { + "properties": { + "usage": { + "properties": { + "bytes": { + "type": "long" + }, + "max": { + "properties": { + "bytes": { + "type": "long" + } + } + } + } + }, + "limit": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "failures": { + "type": "long" + } + } + }, + "kmem_tcp": { + "properties": { + "usage": { + "properties": { + "bytes": { + "type": "long" + }, + "max": { + "properties": { + "bytes": { + "type": "long" + } + } + } + } + }, + "limit": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "failures": { + "type": "long" + } + } + }, + "stats": { + "properties": { + "active_anon": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "active_file": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "cache": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "hierarchical_memory_limit": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "hierarchical_memsw_limit": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "inactive_anon": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "inactive_file": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "mapped_file": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "page_faults": { + "type": "long" + }, + "major_page_faults": { + "type": "long" + }, + "pages_in": { + "type": "long" + }, + "pages_out": { + "type": "long" + }, + "rss": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "rss_huge": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "swap": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "unevictable": { + "properties": { + "bytes": { + "type": "long" + } + } + } + } + } + } + }, + "blkio": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "path": { + "type": "keyword", + "ignore_above": 1024 + }, + "total": { + "properties": { + "bytes": { + "type": "long" + }, + "ios": { + "type": "long" + } + } + } + } + } + } + }, + "summary": { + "properties": { + "total": { + "type": "long" + }, + "running": { + "type": "long" + }, + "idle": { + "type": "long" + }, + "sleeping": { + "type": "long" + }, + "stopped": { + "type": "long" + }, + "zombie": { + "type": "long" + }, + "dead": { + "type": "long" + }, + "unknown": { + "type": "long" + } + } + } + } + }, + "raid": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "status": { + "type": "keyword", + "ignore_above": 1024 + }, + "level": { + "type": "keyword", + "ignore_above": 1024 + }, + "sync_action": { + "type": "keyword", + "ignore_above": 1024 + }, + "disks": { + "properties": { + "active": { + "type": "long" + }, + "total": { + "type": "long" + }, + "spare": { + "type": "long" + }, + "failed": { + "type": "long" + }, + "states": { + "properties": { + "*": { + "type": "object" + } + } + } + } + }, + "blocks": { + "properties": { + "total": { + "type": "long" + }, + "synced": { + "type": "long" + } + } + } + } + }, + "socket": { + "properties": { + "local": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "remote": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + }, + "host": { + "type": "keyword", + "ignore_above": 1024 + }, + "etld_plus_one": { + "type": "keyword", + "ignore_above": 1024 + }, + "host_error": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "process": { + "properties": { + "cmdline": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "user": { + "properties": {} + }, + "summary": { + "properties": { + "all": { + "properties": { + "count": { + "type": "long" + }, + "listening": { + "type": "long" + } + } + }, + "tcp": { + "properties": { + "memory": { + "type": "long" + }, + "all": { + "properties": { + "orphan": { + "type": "long" + }, + "count": { + "type": "long" + }, + "listening": { + "type": "long" + }, + "established": { + "type": "long" + }, + "close_wait": { + "type": "long" + }, + "time_wait": { + "type": "long" + }, + "syn_sent": { + "type": "long" + }, + "syn_recv": { + "type": "long" + }, + "fin_wait1": { + "type": "long" + }, + "fin_wait2": { + "type": "long" + }, + "last_ack": { + "type": "long" + }, + "closing": { + "type": "long" + } + } + } + } + }, + "udp": { + "properties": { + "memory": { + "type": "long" + }, + "all": { + "properties": { + "count": { + "type": "long" + } + } + } + } + } + } + } + } + }, + "uptime": { + "properties": { + "duration": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "users": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "seat": { + "type": "keyword", + "ignore_above": 1024 + }, + "path": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "service": { + "type": "keyword", + "ignore_above": 1024 + }, + "remote": { + "type": "boolean" + }, + "state": { + "type": "keyword", + "ignore_above": 1024 + }, + "scope": { + "type": "keyword", + "ignore_above": 1024 + }, + "leader": { + "type": "long" + }, + "remote_host": { + "type": "keyword", + "ignore_above": 1024 + } + } } } } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts index 005bb78e458e3..de4ba25590c98 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts @@ -12,7 +12,7 @@ import { ElasticsearchAssetType, } from '../../../../types'; import { CallESAsCurrentUser } from '../../../../types'; -import { Field, loadFieldsFromYaml } from '../../fields/field'; +import { Field, loadFieldsFromYaml, processFields } from '../../fields/field'; import { getPipelineNameForInstallation } from '../ingest_pipeline/install'; import { generateMappings, generateTemplateName, getTemplate } from './template'; import * as Registry from '../../registry'; @@ -98,7 +98,7 @@ export async function installTemplate({ dataset: Dataset; packageVersion: string; }): Promise { - const mappings = generateMappings(fields); + const mappings = generateMappings(processFields(fields)); const templateName = generateTemplateName(dataset); let pipelineName; if (dataset.ingest_pipeline) { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts index aa5be59b6a5cd..f4e13748641ed 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts @@ -28,15 +28,38 @@ test('get template', () => { expect(template.index_patterns).toStrictEqual([`${templateName}-*`]); }); -test('tests loading fields.yml', () => { - // Load fields.yml file +test('tests loading base.yml', () => { const ymlPath = path.join(__dirname, '../../fields/tests/base.yml'); const fieldsYML = readFileSync(ymlPath, 'utf-8'); const fields: Field[] = safeLoad(fieldsYML); - processFields(fields); - const mappings = generateMappings(fields); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); const template = getTemplate('logs', 'foo', mappings); expect(template).toMatchSnapshot(path.basename(ymlPath)); }); + +test('tests loading coredns.logs.yml', () => { + const ymlPath = path.join(__dirname, '../../fields/tests/coredns.logs.yml'); + const fieldsYML = readFileSync(ymlPath, 'utf-8'); + const fields: Field[] = safeLoad(fieldsYML); + + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + const template = getTemplate('logs', 'foo', mappings); + + expect(template).toMatchSnapshot(path.basename(ymlPath)); +}); + +test('tests loading system.yml', () => { + const ymlPath = path.join(__dirname, '../../fields/tests/system.yml'); + const fieldsYML = readFileSync(ymlPath, 'utf-8'); + const fields: Field[] = safeLoad(fieldsYML); + + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + const template = getTemplate('metrics', 'whatsthis', mappings); + + expect(template).toMatchSnapshot(path.basename(ymlPath)); +}); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts index f075771e9808a..71c9acc6c10da 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts @@ -14,6 +14,10 @@ interface Properties { interface Mappings { properties: any; } + +const DEFAULT_SCALING_FACTOR = 1000; +const DEFAULT_IGNORE_ABOVE = 1024; + /** * getTemplate retrieves the default template but overwrites the index pattern with the given value. * @@ -33,31 +37,98 @@ export function getTemplate( } /** - * Generate mapping takes the given fields array and creates the Elasticsearch + * Generate mapping takes the given nested fields array and creates the Elasticsearch * mapping properties out of it. * + * This assumes that all fields with dotted.names have been expanded in a previous step. + * * @param fields */ export function generateMappings(fields: Field[]): Mappings { const props: Properties = {}; - fields.forEach(field => { - // Are there more fields inside this field? Build them recursively - if (field.fields && field.fields.length > 0) { - props[field.name] = generateMappings(field.fields); - return; - } + // TODO: this can happen when the fields property in fields.yml is present but empty + // Maybe validation should be moved to fields/field.ts + if (fields) { + fields.forEach(field => { + // If type is not defined, assume keyword + const type = field.type || 'keyword'; + + let fieldProps = getDefaultProperties(field); + + switch (type) { + case 'group': + fieldProps = generateMappings(field.fields!); + break; + case 'integer': + fieldProps.type = 'long'; + break; + case 'scaled_float': + fieldProps.type = 'scaled_float'; + fieldProps.scaling_factor = field.scaling_factor || DEFAULT_SCALING_FACTOR; + break; + case 'text': + fieldProps.type = 'text'; + if (field.analyzer) { + fieldProps.analyzer = field.analyzer; + } + if (field.search_analyzer) { + fieldProps.search_analyzer = field.search_analyzer; + } + break; + case 'keyword': + fieldProps.type = 'keyword'; + if (field.ignore_above) { + fieldProps.ignore_above = field.ignore_above; + } else { + fieldProps.ignore_above = DEFAULT_IGNORE_ABOVE; + } + break; + // TODO move handling of multi_fields here? + case 'object': + // TODO improve + fieldProps.type = 'object'; + break; + case 'array': + // this assumes array fields were validated in an earlier step + // adding an array field with no object_type would result in an error + // when the template is added to ES + if (field.object_type) { + fieldProps.type = field.object_type; + } + break; + case 'alias': + // this assumes alias fields were validated in an earlier step + // adding a path to a field that doesn't exist would result in an error + // when the template is added to ES. + fieldProps.type = 'alias'; + fieldProps.path = field.path; + break; + default: + fieldProps.type = type; + } + props[field.name] = fieldProps; + }); + } - // If not type is defined, take keyword - const type = field.type || 'keyword'; - // Only add keyword fields for now - // TODO: add support for other field types - if (type === 'keyword') { - props[field.name] = { type }; - } - }); return { properties: props }; } +function getDefaultProperties(field: Field): Properties { + const properties: Properties = {}; + + if (field.index) { + properties.index = field.index; + } + if (field.doc_values) { + properties.doc_values = field.doc_values; + } + if (field.copy_to) { + properties.copy_to = field.copy_to; + } + + return properties; +} + /** * Generates the template name out of the given information */ diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/__snapshots__/field.test.ts.snap b/x-pack/plugins/ingest_manager/server/services/epm/fields/__snapshots__/field.test.ts.snap index 76991bde77008..5c402b896093a 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/fields/__snapshots__/field.test.ts.snap +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/__snapshots__/field.test.ts.snap @@ -23,7 +23,12 @@ exports[`tests loading fields.yml: base.yml 1`] = ` "type": "group", "fields": [ { - "name": "foo" + "name": "foo", + "type": "text" + }, + { + "name": "bar", + "type": "integer" } ] } @@ -35,8 +40,21 @@ exports[`tests loading fields.yml: base.yml 1`] = ` "fields": [ { "name": "bar" + }, + { + "name": "baz" } ] + }, + { + "name": "myalias", + "type": "alias", + "path": "user.euid" + }, + { + "name": "validarray", + "type": "array", + "object_type": "integer" } ] `; @@ -54,46 +72,2395 @@ exports[`tests loading fields.yml: coredns.logs.yml 1`] = ` "description": "id of the DNS transaction\\n" }, { - "name": "query.size", - "type": "integer", - "format": "bytes", - "description": "size of the DNS query\\n" + "name": "query", + "type": "group", + "fields": [ + { + "name": "size", + "type": "integer", + "format": "bytes", + "description": "size of the DNS query\\n" + }, + { + "name": "class", + "type": "keyword", + "description": "DNS query class\\n" + }, + { + "name": "name", + "type": "keyword", + "description": "DNS query name\\n" + }, + { + "name": "type", + "type": "keyword", + "description": "DNS query type\\n" + } + ] }, { - "name": "query.class", - "type": "keyword", - "description": "DNS query class\\n" + "name": "response", + "type": "group", + "fields": [ + { + "name": "code", + "type": "keyword", + "description": "DNS response code\\n" + }, + { + "name": "flags", + "type": "keyword", + "description": "DNS response flags\\n" + }, + { + "name": "size", + "type": "integer", + "format": "bytes", + "description": "size of the DNS response\\n" + } + ] }, { - "name": "query.name", - "type": "keyword", - "description": "DNS query name\\n" + "name": "dnssec_ok", + "type": "boolean", + "description": "dnssec flag\\n" + } + ] + } +] +`; + +exports[`tests loading fields.yml: system.yml 1`] = ` +[ + { + "name": "system", + "type": "group", + "fields": [ + { + "name": "core", + "type": "group", + "description": "\`system-core\` contains CPU metrics for a single core of a multi-core system.\\n", + "fields": [ + { + "name": "id", + "type": "long", + "description": "CPU Core number.\\n" + }, + { + "name": "user", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent in user space.\\n" + }, + { + "name": "ticks", + "type": "long", + "description": "The amount of CPU time spent in user space.\\n" + } + ] + }, + { + "name": "system", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent in kernel space.\\n" + }, + { + "name": "ticks", + "type": "long", + "description": "The amount of CPU time spent in kernel space.\\n" + } + ] + }, + { + "name": "nice", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent on low-priority processes.\\n" + }, + { + "name": "ticks", + "type": "long", + "description": "The amount of CPU time spent on low-priority processes.\\n" + } + ] + }, + { + "name": "idle", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent idle.\\n" + }, + { + "name": "ticks", + "type": "long", + "description": "The amount of CPU time spent idle.\\n" + } + ] + }, + { + "name": "iowait", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent in wait (on disk).\\n" + }, + { + "name": "ticks", + "type": "long", + "description": "The amount of CPU time spent in wait (on disk).\\n" + } + ] + }, + { + "name": "irq", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent servicing and handling hardware interrupts.\\n" + }, + { + "name": "ticks", + "type": "long", + "description": "The amount of CPU time spent servicing and handling hardware interrupts.\\n" + } + ] + }, + { + "name": "softirq", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent servicing and handling software interrupts.\\n" + }, + { + "name": "ticks", + "type": "long", + "description": "The amount of CPU time spent servicing and handling software interrupts.\\n" + } + ] + }, + { + "name": "steal", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent in involuntary wait by the virtual CPU while the hypervisor was servicing another processor. Available only on Unix.\\n" + }, + { + "name": "ticks", + "type": "long", + "description": "The amount of CPU time spent in involuntary wait by the virtual CPU while the hypervisor was servicing another processor. Available only on Unix.\\n" + } + ] + } + ] }, { - "name": "query.type", - "type": "keyword", - "description": "DNS query type\\n" + "name": "cpu", + "type": "group", + "description": "\`cpu\` contains local CPU stats.\\n", + "release": "ga", + "fields": [ + { + "name": "cores", + "type": "long", + "description": "The number of CPU cores present on the host. The non-normalized percentages will have a maximum value of \`100% * cores\`. The normalized percentages already take this value into account and have a maximum value of 100%.\\n" + }, + { + "name": "user", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent in user space. On multi-core systems, you can have percentages that are greater than 100%. For example, if 3 cores are at 60% use, then the \`system.cpu.user.pct\` will be 180%.\\n" + }, + { + "name": "norm", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent in user space.\\n" + } + ] + }, + { + "name": "ticks", + "type": "long", + "description": "The amount of CPU time spent in user space.\\n" + } + ] + }, + { + "name": "system", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent in kernel space.\\n" + }, + { + "name": "norm", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent in kernel space.\\n" + } + ] + }, + { + "name": "ticks", + "type": "long", + "description": "The amount of CPU time spent in kernel space.\\n" + } + ] + }, + { + "name": "nice", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent on low-priority processes.\\n" + }, + { + "name": "norm", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent on low-priority processes.\\n" + } + ] + }, + { + "name": "ticks", + "type": "long", + "description": "The amount of CPU time spent on low-priority processes.\\n" + } + ] + }, + { + "name": "idle", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent idle.\\n" + }, + { + "name": "norm", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent idle.\\n" + } + ] + }, + { + "name": "ticks", + "type": "long", + "description": "The amount of CPU time spent idle.\\n" + } + ] + }, + { + "name": "iowait", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent in wait (on disk).\\n" + }, + { + "name": "norm", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent in wait (on disk).\\n" + } + ] + }, + { + "name": "ticks", + "type": "long", + "description": "The amount of CPU time spent in wait (on disk).\\n" + } + ] + }, + { + "name": "irq", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent servicing and handling hardware interrupts.\\n" + }, + { + "name": "norm", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent servicing and handling hardware interrupts.\\n" + } + ] + }, + { + "name": "ticks", + "type": "long", + "description": "The amount of CPU time spent servicing and handling hardware interrupts.\\n" + } + ] + }, + { + "name": "softirq", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent servicing and handling software interrupts.\\n" + }, + { + "name": "norm", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent servicing and handling software interrupts.\\n" + } + ] + }, + { + "name": "ticks", + "type": "long", + "description": "The amount of CPU time spent servicing and handling software interrupts.\\n" + } + ] + }, + { + "name": "steal", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent in involuntary wait by the virtual CPU while the hypervisor was servicing another processor. Available only on Unix.\\n" + }, + { + "name": "norm", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent in involuntary wait by the virtual CPU while the hypervisor was servicing another processor. Available only on Unix.\\n" + } + ] + }, + { + "name": "ticks", + "type": "long", + "description": "The amount of CPU time spent in involuntary wait by the virtual CPU while the hypervisor was servicing another processor. Available only on Unix.\\n" + } + ] + }, + { + "name": "total", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent in states other than Idle and IOWait.\\n" + }, + { + "name": "norm", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time in states other than Idle and IOWait, normalised by the number of cores.\\n" + } + ] + } + ] + } + ] }, { - "name": "response.code", - "type": "keyword", - "description": "DNS response code\\n" + "name": "diskio", + "type": "group", + "description": "\`disk\` contains disk IO metrics collected from the operating system.\\n", + "release": "ga", + "fields": [ + { + "name": "name", + "type": "keyword", + "example": "sda1", + "description": "The disk name.\\n" + }, + { + "name": "serial_number", + "type": "keyword", + "description": "The disk's serial number. This may not be provided by all operating systems.\\n" + }, + { + "name": "read", + "type": "group", + "fields": [ + { + "name": "count", + "type": "long", + "description": "The total number of reads completed successfully.\\n" + }, + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "The total number of bytes read successfully. On Linux this is the number of sectors read multiplied by an assumed sector size of 512.\\n" + }, + { + "name": "time", + "type": "long", + "description": "The total number of milliseconds spent by all reads.\\n" + } + ] + }, + { + "name": "write", + "type": "group", + "fields": [ + { + "name": "count", + "type": "long", + "description": "The total number of writes completed successfully.\\n" + }, + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "The total number of bytes written successfully. On Linux this is the number of sectors written multiplied by an assumed sector size of 512.\\n" + }, + { + "name": "time", + "type": "long", + "description": "The total number of milliseconds spent by all writes.\\n" + } + ] + }, + { + "name": "io", + "type": "group", + "fields": [ + { + "name": "time", + "type": "long", + "description": "The total number of of milliseconds spent doing I/Os.\\n" + } + ] + }, + { + "name": "iostat", + "type": "group", + "fields": [ + { + "name": "read", + "type": "group", + "fields": [ + { + "name": "request", + "type": "group", + "fields": [ + { + "name": "merges_per_sec", + "type": "float", + "description": "The number of read requests merged per second that were queued to the device.\\n" + }, + { + "name": "per_sec", + "type": "float", + "description": "The number of read requests that were issued to the device per second\\n" + } + ] + }, + { + "name": "per_sec", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "float", + "description": "The number of Bytes read from the device per second.\\n", + "format": "bytes" + } + ] + }, + { + "name": "await", + "type": "float", + "description": "The average time spent for read requests issued to the device to be served.\\n" + } + ] + }, + { + "name": "write", + "type": "group", + "fields": [ + { + "name": "request", + "type": "group", + "fields": [ + { + "name": "merges_per_sec", + "type": "float", + "description": "The number of write requests merged per second that were queued to the device.\\n" + }, + { + "name": "per_sec", + "type": "float", + "description": "The number of write requests that were issued to the device per second\\n" + } + ] + }, + { + "name": "per_sec", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "float", + "description": "The number of Bytes write from the device per second.\\n", + "format": "bytes" + } + ] + }, + { + "name": "await", + "type": "float", + "description": "The average time spent for write requests issued to the device to be served.\\n" + } + ] + }, + { + "name": "request", + "type": "group", + "fields": [ + { + "name": "avg_size", + "type": "float", + "description": "The average size (in bytes) of the requests that were issued to the device.\\n" + } + ] + }, + { + "name": "queue", + "type": "group", + "fields": [ + { + "name": "avg_size", + "type": "float", + "description": "The average queue length of the requests that were issued to the device.\\n" + } + ] + }, + { + "name": "await", + "type": "float", + "description": "The average time spent for requests issued to the device to be served.\\n" + }, + { + "name": "service_time", + "type": "float", + "description": "The average service time (in milliseconds) for I/O requests that were issued to the device.\\n" + }, + { + "name": "busy", + "type": "float", + "description": "Percentage of CPU time during which I/O requests were issued to the device (bandwidth utilization for the device). Device saturation occurs when this value is close to 100%.\\n" + } + ] + } + ] }, { - "name": "response.flags", - "type": "keyword", - "description": "DNS response flags\\n" + "name": "entropy", + "type": "group", + "description": "Available system entropy\\n", + "release": "ga", + "fields": [ + { + "name": "available_bits", + "type": "long", + "description": "The available bits of entropy\\n" + }, + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of available entropy, relative to the pool size of 4096\\n" + } + ] }, { - "name": "response.size", - "type": "integer", - "format": "bytes", - "description": "size of the DNS response\\n" + "name": "filesystem", + "type": "group", + "description": "\`filesystem\` contains local filesystem stats.\\n", + "release": "ga", + "fields": [ + { + "name": "available", + "type": "long", + "format": "bytes", + "description": "The disk space available to an unprivileged user in bytes.\\n" + }, + { + "name": "device_name", + "type": "keyword", + "description": "The disk name. For example: \`/dev/disk1\`\\n" + }, + { + "name": "type", + "type": "keyword", + "description": "The disk type. For example: \`ext4\`\\n" + }, + { + "name": "mount_point", + "type": "keyword", + "description": "The mounting point. For example: \`/\`\\n" + }, + { + "name": "files", + "type": "long", + "description": "The total number of file nodes in the file system.\\n" + }, + { + "name": "free", + "type": "long", + "format": "bytes", + "description": "The disk space available in bytes.\\n" + }, + { + "name": "free_files", + "type": "long", + "description": "The number of free file nodes in the file system.\\n" + }, + { + "name": "total", + "type": "long", + "format": "bytes", + "description": "The total disk space in bytes.\\n" + }, + { + "name": "used", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "The used disk space in bytes.\\n" + }, + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of used disk space.\\n" + } + ] + } + ] }, { - "name": "dnssec_ok", - "type": "boolean", - "description": "dnssec flag\\n" + "name": "fsstat", + "type": "group", + "description": "\`system.fsstat\` contains filesystem metrics aggregated from all mounted filesystems.\\n", + "release": "ga", + "fields": [ + { + "name": "count", + "type": "long", + "description": "Number of file systems found." + }, + { + "name": "total_files", + "type": "long", + "description": "Total number of files." + }, + { + "name": "total_size", + "format": "bytes", + "type": "group", + "description": "Nested file system docs.", + "fields": [ + { + "name": "free", + "type": "long", + "format": "bytes", + "description": "Total free space.\\n" + }, + { + "name": "used", + "type": "long", + "format": "bytes", + "description": "Total used space.\\n" + }, + { + "name": "total", + "type": "long", + "format": "bytes", + "description": "Total space (used plus free).\\n" + } + ] + } + ] + }, + { + "name": "load", + "type": "group", + "description": "CPU load averages.\\n", + "release": "ga", + "fields": [ + { + "name": "1", + "type": "scaled_float", + "scaling_factor": 100, + "description": "Load average for the last minute.\\n" + }, + { + "name": "5", + "type": "scaled_float", + "scaling_factor": 100, + "description": "Load average for the last 5 minutes.\\n" + }, + { + "name": "15", + "type": "scaled_float", + "scaling_factor": 100, + "description": "Load average for the last 15 minutes.\\n" + }, + { + "name": "norm", + "type": "group", + "fields": [ + { + "name": "1", + "type": "scaled_float", + "scaling_factor": 100, + "description": "Load for the last minute divided by the number of cores.\\n" + }, + { + "name": "5", + "type": "scaled_float", + "scaling_factor": 100, + "description": "Load for the last 5 minutes divided by the number of cores.\\n" + }, + { + "name": "15", + "type": "scaled_float", + "scaling_factor": 100, + "description": "Load for the last 15 minutes divided by the number of cores.\\n" + } + ] + }, + { + "name": "cores", + "type": "long", + "description": "The number of CPU cores present on the host.\\n" + } + ] + }, + { + "name": "memory", + "type": "group", + "description": "\`memory\` contains local memory stats.\\n", + "release": "ga", + "fields": [ + { + "name": "total", + "type": "long", + "format": "bytes", + "description": "Total memory.\\n" + }, + { + "name": "used", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "Used memory.\\n" + }, + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of used memory.\\n" + } + ] + }, + { + "name": "free", + "type": "long", + "format": "bytes", + "description": "The total amount of free memory in bytes. This value does not include memory consumed by system caches and buffers (see system.memory.actual.free).\\n" + }, + { + "name": "actual", + "type": "group", + "description": "Actual memory used and free.\\n", + "fields": [ + { + "name": "used", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "Actual used memory in bytes. It represents the difference between the total and the available memory. The available memory depends on the OS. For more details, please check \`system.actual.free\`.\\n" + }, + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of actual used memory.\\n" + } + ] + }, + { + "name": "free", + "type": "long", + "format": "bytes", + "description": "Actual free memory in bytes. It is calculated based on the OS. On Linux it consists of the free memory plus caches and buffers. On OSX it is a sum of free memory and the inactive memory. On Windows, it is equal to \`system.memory.free\`.\\n" + } + ] + }, + { + "name": "swap", + "type": "group", + "prefix": "[float]", + "description": "This group contains statistics related to the swap memory usage on the system.", + "fields": [ + { + "name": "total", + "type": "long", + "format": "bytes", + "description": "Total swap memory.\\n" + }, + { + "name": "used", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "Used swap memory.\\n" + }, + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of used swap memory.\\n" + } + ] + }, + { + "name": "free", + "type": "long", + "format": "bytes", + "description": "Available swap memory.\\n" + }, + { + "name": "out", + "type": "group", + "fields": [ + { + "name": "pages", + "type": "long", + "description": "count of pages swapped out" + } + ] + }, + { + "name": "in", + "type": "group", + "fields": [ + { + "name": "pages", + "type": "long", + "description": "count of pages swapped in" + } + ] + }, + { + "name": "readahead", + "type": "group", + "fields": [ + { + "name": "pages", + "type": "long", + "description": "swap readahead pages" + }, + { + "name": "cached", + "type": "long", + "description": "swap readahead cache hits" + } + ] + } + ] + }, + { + "name": "hugepages", + "type": "group", + "prefix": "[float]", + "description": "This group contains statistics related to huge pages usage on the system.", + "fields": [ + { + "name": "total", + "type": "long", + "format": "number", + "description": "Number of huge pages in the pool.\\n" + }, + { + "name": "used", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "Memory used in allocated huge pages.\\n" + }, + { + "name": "pct", + "type": "long", + "format": "percent", + "description": "Percentage of huge pages used.\\n" + } + ] + }, + { + "name": "free", + "type": "long", + "format": "number", + "description": "Number of available huge pages in the pool.\\n" + }, + { + "name": "reserved", + "type": "long", + "format": "number", + "description": "Number of reserved but not allocated huge pages in the pool.\\n" + }, + { + "name": "surplus", + "type": "long", + "format": "number", + "description": "Number of overcommited huge pages.\\n" + }, + { + "name": "default_size", + "type": "long", + "format": "bytes", + "description": "Default size for huge pages.\\n" + }, + { + "name": "swap", + "type": "group", + "fields": [ + { + "name": "out", + "type": "group", + "description": "huge pages swapped out", + "fields": [ + { + "name": "pages", + "type": "long", + "description": "pages swapped out" + }, + { + "name": "fallback", + "type": "long", + "description": "Count of huge pages that must be split before swapout" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "network", + "type": "group", + "description": "\`network\` contains network IO metrics for a single network interface.\\n", + "release": "ga", + "fields": [ + { + "name": "name", + "type": "keyword", + "example": "eth0", + "description": "The network interface name.\\n" + }, + { + "name": "out", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "The number of bytes sent.\\n" + }, + { + "name": "packets", + "type": "long", + "description": "The number of packets sent.\\n" + }, + { + "name": "errors", + "type": "long", + "description": "The number of errors while sending.\\n" + }, + { + "name": "dropped", + "type": "long", + "description": "The number of outgoing packets that were dropped. This value is always 0 on Darwin and BSD because it is not reported by the operating system.\\n" + } + ] + }, + { + "name": "in", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "The number of bytes received.\\n" + }, + { + "name": "packets", + "type": "long", + "description": "The number or packets received.\\n" + }, + { + "name": "errors", + "type": "long", + "description": "The number of errors while receiving.\\n" + }, + { + "name": "dropped", + "type": "long", + "description": "The number of incoming packets that were dropped.\\n" + } + ] + } + ] + }, + { + "name": "network_summary", + "type": "group", + "release": "beta", + "description": "Metrics relating to global network activity\\n", + "fields": [ + { + "name": "ip", + "type": "group", + "fields": [ + { + "name": "*", + "type": "object", + "description": "IP counters\\n" + } + ] + }, + { + "name": "tcp", + "type": "group", + "fields": [ + { + "name": "*", + "type": "object", + "description": "TCP counters\\n" + } + ] + }, + { + "name": "udp", + "type": "group", + "fields": [ + { + "name": "*", + "type": "object", + "description": "UDP counters\\n" + } + ] + }, + { + "name": "udp_lite", + "type": "group", + "fields": [ + { + "name": "*", + "type": "object", + "description": "UDP Lite counters\\n" + } + ] + }, + { + "name": "icmp", + "type": "group", + "fields": [ + { + "name": "*", + "type": "object", + "description": "ICMP counters\\n" + } + ] + } + ] + }, + { + "name": "process", + "type": "group", + "description": "\`process\` contains process metadata, CPU metrics, and memory metrics.\\n", + "release": "ga", + "fields": [ + { + "name": "state", + "type": "keyword", + "description": "The process state. For example: \\"running\\".\\n" + }, + { + "name": "cmdline", + "type": "keyword", + "description": "The full command-line used to start the process, including the arguments separated by space.\\n", + "ignore_above": 2048 + }, + { + "name": "env", + "type": "object", + "object_type": "keyword", + "description": "The environment variables used to start the process. The data is available on FreeBSD, Linux, and OS X.\\n" + }, + { + "name": "cpu", + "type": "group", + "prefix": "[float]", + "description": "CPU-specific statistics per process.", + "fields": [ + { + "name": "user", + "type": "group", + "fields": [ + { + "name": "ticks", + "type": "long", + "description": "The amount of CPU time the process spent in user space.\\n" + } + ] + }, + { + "name": "total", + "type": "group", + "fields": [ + { + "name": "value", + "type": "long", + "description": "The value of CPU usage since starting the process.\\n" + }, + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent by the process since the last update. Its value is similar to the %CPU value of the process displayed by the top command on Unix systems.\\n" + }, + { + "name": "norm", + "type": "group", + "fields": [ + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of CPU time spent by the process since the last event. This value is normalized by the number of CPU cores and it ranges from 0 to 100%.\\n" + } + ] + }, + { + "name": "ticks", + "type": "long", + "description": "The total CPU time spent by the process.\\n" + } + ] + }, + { + "name": "system", + "type": "group", + "fields": [ + { + "name": "ticks", + "type": "long", + "description": "The amount of CPU time the process spent in kernel space.\\n" + } + ] + }, + { + "name": "start_time", + "type": "date", + "description": "The time when the process was started.\\n" + } + ] + }, + { + "name": "memory", + "type": "group", + "description": "Memory-specific statistics per process.", + "prefix": "[float]", + "fields": [ + { + "name": "size", + "type": "long", + "format": "bytes", + "description": "The total virtual memory the process has.\\n" + }, + { + "name": "rss", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "The Resident Set Size. The amount of memory the process occupied in main memory (RAM).\\n" + }, + { + "name": "pct", + "type": "scaled_float", + "format": "percent", + "description": "The percentage of memory the process occupied in main memory (RAM).\\n" + } + ] + }, + { + "name": "share", + "type": "long", + "format": "bytes", + "description": "The shared memory the process uses.\\n" + } + ] + }, + { + "name": "fd", + "type": "group", + "description": "File descriptor usage metrics. This set of metrics is available for Linux and FreeBSD.\\n", + "prefix": "[float]", + "fields": [ + { + "name": "open", + "type": "long", + "description": "The number of file descriptors open by the process." + }, + { + "name": "limit", + "type": "group", + "fields": [ + { + "name": "soft", + "type": "long", + "description": "The soft limit on the number of file descriptors opened by the process. The soft limit can be changed by the process at any time.\\n" + }, + { + "name": "hard", + "type": "long", + "description": "The hard limit on the number of file descriptors opened by the process. The hard limit can only be raised by root.\\n" + } + ] + } + ] + }, + { + "name": "cgroup", + "type": "group", + "description": "Metrics and limits from the cgroup of which the task is a member. cgroup metrics are reported when the process has membership in a non-root cgroup. These metrics are only available on Linux.\\n", + "fields": [ + { + "name": "id", + "type": "keyword", + "description": "The ID common to all cgroups associated with this task. If there isn't a common ID used by all cgroups this field will be absent.\\n" + }, + { + "name": "path", + "type": "keyword", + "description": "The path to the cgroup relative to the cgroup subsystem's mountpoint. If there isn't a common path used by all cgroups this field will be absent.\\n" + }, + { + "name": "cpu", + "type": "group", + "description": "The cpu subsystem schedules CPU access for tasks in the cgroup. Access can be controlled by two separate schedulers, CFS and RT. CFS stands for completely fair scheduler which proportionally divides the CPU time between cgroups based on weight. RT stands for real time scheduler which sets a maximum amount of CPU time that processes in the cgroup can consume during a given period.\\n", + "fields": [ + { + "name": "id", + "type": "keyword", + "description": "ID of the cgroup." + }, + { + "name": "path", + "type": "keyword", + "description": "Path to the cgroup relative to the cgroup subsystem's mountpoint.\\n" + }, + { + "name": "cfs", + "type": "group", + "fields": [ + { + "name": "period", + "type": "group", + "fields": [ + { + "name": "us", + "type": "long", + "description": "Period of time in microseconds for how regularly a cgroup's access to CPU resources should be reallocated.\\n" + } + ] + }, + { + "name": "quota", + "type": "group", + "fields": [ + { + "name": "us", + "type": "long", + "description": "Total amount of time in microseconds for which all tasks in a cgroup can run during one period (as defined by cfs.period.us).\\n" + } + ] + }, + { + "name": "shares", + "type": "long", + "description": "An integer value that specifies a relative share of CPU time available to the tasks in a cgroup. The value specified in the cpu.shares file must be 2 or higher.\\n" + } + ] + }, + { + "name": "rt", + "type": "group", + "fields": [ + { + "name": "period", + "type": "group", + "fields": [ + { + "name": "us", + "type": "long", + "description": "Period of time in microseconds for how regularly a cgroup's access to CPU resources is reallocated.\\n" + } + ] + }, + { + "name": "runtime", + "type": "group", + "fields": [ + { + "name": "us", + "type": "long", + "description": "Period of time in microseconds for the longest continuous period in which the tasks in a cgroup have access to CPU resources.\\n" + } + ] + } + ] + }, + { + "name": "stats", + "type": "group", + "fields": [ + { + "name": "periods", + "type": "long", + "description": "Number of period intervals (as specified in cpu.cfs.period.us) that have elapsed.\\n" + }, + { + "name": "throttled", + "type": "group", + "fields": [ + { + "name": "periods", + "type": "long", + "description": "Number of times tasks in a cgroup have been throttled (that is, not allowed to run because they have exhausted all of the available time as specified by their quota).\\n" + }, + { + "name": "ns", + "type": "long", + "description": "The total time duration (in nanoseconds) for which tasks in a cgroup have been throttled.\\n" + } + ] + } + ] + } + ] + }, + { + "name": "cpuacct", + "type": "group", + "description": "CPU accounting metrics.", + "fields": [ + { + "name": "id", + "type": "keyword", + "description": "ID of the cgroup." + }, + { + "name": "path", + "type": "keyword", + "description": "Path to the cgroup relative to the cgroup subsystem's mountpoint.\\n" + }, + { + "name": "total", + "type": "group", + "fields": [ + { + "name": "ns", + "type": "long", + "description": "Total CPU time in nanoseconds consumed by all tasks in the cgroup.\\n" + } + ] + }, + { + "name": "stats", + "type": "group", + "fields": [ + { + "name": "user", + "type": "group", + "fields": [ + { + "name": "ns", + "type": "long", + "description": "CPU time consumed by tasks in user mode." + } + ] + }, + { + "name": "system", + "type": "group", + "fields": [ + { + "name": "ns", + "type": "long", + "description": "CPU time consumed by tasks in user (kernel) mode." + } + ] + } + ] + }, + { + "name": "percpu", + "type": "object", + "object_type": "long", + "description": "CPU time (in nanoseconds) consumed on each CPU by all tasks in this cgroup.\\n" + } + ] + }, + { + "name": "memory", + "type": "group", + "description": "Memory limits and metrics.", + "fields": [ + { + "name": "id", + "type": "keyword", + "description": "ID of the cgroup." + }, + { + "name": "path", + "type": "keyword", + "description": "Path to the cgroup relative to the cgroup subsystem's mountpoint.\\n" + }, + { + "name": "mem", + "type": "group", + "fields": [ + { + "name": "usage", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "Total memory usage by processes in the cgroup (in bytes).\\n" + }, + { + "name": "max", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "The maximum memory used by processes in the cgroup (in bytes).\\n" + } + ] + } + ] + }, + { + "name": "limit", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "The maximum amount of user memory in bytes (including file cache) that tasks in the cgroup are allowed to use.\\n" + } + ] + }, + { + "name": "failures", + "type": "long", + "description": "The number of times that the memory limit (mem.limit.bytes) was reached.\\n" + } + ] + }, + { + "name": "memsw", + "type": "group", + "fields": [ + { + "name": "usage", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "The sum of current memory usage plus swap space used by processes in the cgroup (in bytes).\\n" + }, + { + "name": "max", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "The maximum amount of memory and swap space used by processes in the cgroup (in bytes).\\n" + } + ] + } + ] + }, + { + "name": "limit", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "The maximum amount for the sum of memory and swap usage that tasks in the cgroup are allowed to use.\\n" + } + ] + }, + { + "name": "failures", + "type": "long", + "description": "The number of times that the memory plus swap space limit (memsw.limit.bytes) was reached.\\n" + } + ] + }, + { + "name": "kmem", + "type": "group", + "fields": [ + { + "name": "usage", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "Total kernel memory usage by processes in the cgroup (in bytes).\\n" + }, + { + "name": "max", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "The maximum kernel memory used by processes in the cgroup (in bytes).\\n" + } + ] + } + ] + }, + { + "name": "limit", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "The maximum amount of kernel memory that tasks in the cgroup are allowed to use.\\n" + } + ] + }, + { + "name": "failures", + "type": "long", + "description": "The number of times that the memory limit (kmem.limit.bytes) was reached.\\n" + } + ] + }, + { + "name": "kmem_tcp", + "type": "group", + "fields": [ + { + "name": "usage", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "Total memory usage for TCP buffers in bytes.\\n" + }, + { + "name": "max", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "The maximum memory used for TCP buffers by processes in the cgroup (in bytes).\\n" + } + ] + } + ] + }, + { + "name": "limit", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "The maximum amount of memory for TCP buffers that tasks in the cgroup are allowed to use.\\n" + } + ] + }, + { + "name": "failures", + "type": "long", + "description": "The number of times that the memory limit (kmem_tcp.limit.bytes) was reached.\\n" + } + ] + }, + { + "name": "stats", + "type": "group", + "fields": [ + { + "name": "active_anon", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "Anonymous and swap cache on active least-recently-used (LRU) list, including tmpfs (shmem), in bytes.\\n" + } + ] + }, + { + "name": "active_file", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "File-backed memory on active LRU list, in bytes." + } + ] + }, + { + "name": "cache", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "Page cache, including tmpfs (shmem), in bytes." + } + ] + }, + { + "name": "hierarchical_memory_limit", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "Memory limit for the hierarchy that contains the memory cgroup, in bytes.\\n" + } + ] + }, + { + "name": "hierarchical_memsw_limit", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "Memory plus swap limit for the hierarchy that contains the memory cgroup, in bytes.\\n" + } + ] + }, + { + "name": "inactive_anon", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "Anonymous and swap cache on inactive LRU list, including tmpfs (shmem), in bytes\\n" + } + ] + }, + { + "name": "inactive_file", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "File-backed memory on inactive LRU list, in bytes.\\n" + } + ] + }, + { + "name": "mapped_file", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "Size of memory-mapped mapped files, including tmpfs (shmem), in bytes.\\n" + } + ] + }, + { + "name": "page_faults", + "type": "long", + "description": "Number of times that a process in the cgroup triggered a page fault.\\n" + }, + { + "name": "major_page_faults", + "type": "long", + "description": "Number of times that a process in the cgroup triggered a major fault. \\"Major\\" faults happen when the kernel actually has to read the data from disk.\\n" + }, + { + "name": "pages_in", + "type": "long", + "description": "Number of pages paged into memory. This is a counter.\\n" + }, + { + "name": "pages_out", + "type": "long", + "description": "Number of pages paged out of memory. This is a counter.\\n" + }, + { + "name": "rss", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "Anonymous and swap cache (includes transparent hugepages), not including tmpfs (shmem), in bytes.\\n" + } + ] + }, + { + "name": "rss_huge", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "Number of bytes of anonymous transparent hugepages.\\n" + } + ] + }, + { + "name": "swap", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "Swap usage, in bytes.\\n" + } + ] + }, + { + "name": "unevictable", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "Memory that cannot be reclaimed, in bytes.\\n" + } + ] + } + ] + } + ] + }, + { + "name": "blkio", + "type": "group", + "description": "Block IO metrics.", + "fields": [ + { + "name": "id", + "type": "keyword", + "description": "ID of the cgroup." + }, + { + "name": "path", + "type": "keyword", + "description": "Path to the cgroup relative to the cgroup subsystems mountpoint.\\n" + }, + { + "name": "total", + "type": "group", + "fields": [ + { + "name": "bytes", + "type": "long", + "format": "bytes", + "description": "Total number of bytes transferred to and from all block devices by processes in the cgroup.\\n" + }, + { + "name": "ios", + "type": "long", + "description": "Total number of I/O operations performed on all devices by processes in the cgroup as seen by the throttling policy.\\n" + } + ] + } + ] + } + ] + }, + { + "name": "summary", + "title": "Process Summary", + "type": "group", + "description": "Summary metrics for the processes running on the host.\\n", + "release": "ga", + "fields": [ + { + "name": "total", + "type": "long", + "description": "Total number of processes on this host.\\n" + }, + { + "name": "running", + "type": "long", + "description": "Number of running processes on this host.\\n" + }, + { + "name": "idle", + "type": "long", + "description": "Number of idle processes on this host.\\n" + }, + { + "name": "sleeping", + "type": "long", + "description": "Number of sleeping processes on this host.\\n" + }, + { + "name": "stopped", + "type": "long", + "description": "Number of stopped processes on this host.\\n" + }, + { + "name": "zombie", + "type": "long", + "description": "Number of zombie processes on this host.\\n" + }, + { + "name": "dead", + "type": "long", + "description": "Number of dead processes on this host. It's very unlikely that it will appear but in some special situations it may happen.\\n" + }, + { + "name": "unknown", + "type": "long", + "description": "Number of processes for which the state couldn't be retrieved or is unknown.\\n" + } + ] + } + ] + }, + { + "name": "raid", + "type": "group", + "description": "raid\\n", + "release": "ga", + "fields": [ + { + "name": "name", + "type": "keyword", + "description": "Name of the device.\\n" + }, + { + "name": "status", + "type": "keyword", + "description": "activity-state of the device.\\n" + }, + { + "name": "level", + "type": "keyword", + "description": "The raid level of the device\\n" + }, + { + "name": "sync_action", + "type": "keyword", + "description": "Current sync action, if the RAID array is redundant\\n" + }, + { + "name": "disks", + "type": "group", + "fields": [ + { + "name": "active", + "type": "long", + "description": "Number of active disks.\\n" + }, + { + "name": "total", + "type": "long", + "description": "Total number of disks the device consists of.\\n" + }, + { + "name": "spare", + "type": "long", + "description": "Number of spared disks.\\n" + }, + { + "name": "failed", + "type": "long", + "description": "Number of failed disks.\\n" + }, + { + "name": "states", + "type": "group", + "fields": [ + { + "name": "*", + "type": "object", + "object_type": "keyword", + "description": "map of raw disk states\\n" + } + ] + } + ] + }, + { + "name": "blocks", + "type": "group", + "fields": [ + { + "name": "total", + "type": "long", + "description": "Number of blocks the device holds, in 1024-byte blocks.\\n" + }, + { + "name": "synced", + "type": "long", + "description": "Number of blocks on the device that are in sync, in 1024-byte blocks.\\n" + } + ] + } + ] + }, + { + "name": "socket", + "type": "group", + "description": "TCP sockets that are active.\\n", + "release": "ga", + "fields": [ + { + "name": "local", + "type": "group", + "fields": [ + { + "name": "ip", + "type": "ip", + "example": "192.0.2.1 or 2001:0DB8:ABED:8536::1", + "description": "Local IP address. This can be an IPv4 or IPv6 address.\\n" + }, + { + "name": "port", + "type": "long", + "example": 22, + "description": "Local port.\\n" + } + ] + }, + { + "name": "remote", + "type": "group", + "fields": [ + { + "name": "ip", + "type": "ip", + "example": "192.0.2.1 or 2001:0DB8:ABED:8536::1", + "description": "Remote IP address. This can be an IPv4 or IPv6 address.\\n" + }, + { + "name": "port", + "type": "long", + "example": 22, + "description": "Remote port.\\n" + }, + { + "name": "host", + "type": "keyword", + "example": "76-211-117-36.nw.example.com.", + "description": "PTR record associated with the remote IP. It is obtained via reverse IP lookup.\\n" + }, + { + "name": "etld_plus_one", + "type": "keyword", + "example": "example.com.", + "description": "The effective top-level domain (eTLD) of the remote host plus one more label. For example, the eTLD+1 for \\"foo.bar.golang.org.\\" is \\"golang.org.\\". The data for determining the eTLD comes from an embedded copy of the data from http://publicsuffix.org.\\n" + }, + { + "name": "host_error", + "type": "keyword", + "description": "Error describing the cause of the reverse lookup failure.\\n" + } + ] + }, + { + "name": "process", + "type": "group", + "fields": [ + { + "name": "cmdline", + "type": "keyword", + "description": "Full command line\\n" + } + ] + }, + { + "name": "user", + "type": "group", + "fields": [] + }, + { + "name": "summary", + "title": "Socket summary", + "type": "group", + "description": "Summary metrics of open sockets in the host system\\n", + "release": "ga", + "fields": [ + { + "name": "all", + "type": "group", + "description": "All connections\\n", + "fields": [ + { + "name": "count", + "type": "integer", + "description": "All open connections\\n" + }, + { + "name": "listening", + "type": "integer", + "description": "All listening ports\\n" + } + ] + }, + { + "name": "tcp", + "type": "group", + "description": "All TCP connections\\n", + "fields": [ + { + "name": "memory", + "type": "integer", + "format": "bytes", + "description": "Memory used by TCP sockets in bytes, based on number of allocated pages and system page size. Corresponds to limits set in /proc/sys/net/ipv4/tcp_mem. Only available on Linux.\\n" + }, + { + "name": "all", + "type": "group", + "description": "All TCP connections\\n", + "fields": [ + { + "name": "orphan", + "type": "integer", + "description": "A count of all orphaned tcp sockets. Only available on Linux.\\n" + }, + { + "name": "count", + "type": "integer", + "description": "All open TCP connections\\n" + }, + { + "name": "listening", + "type": "integer", + "description": "All TCP listening ports\\n" + }, + { + "name": "established", + "type": "integer", + "description": "Number of established TCP connections\\n" + }, + { + "name": "close_wait", + "type": "integer", + "description": "Number of TCP connections in _close_wait_ state\\n" + }, + { + "name": "time_wait", + "type": "integer", + "description": "Number of TCP connections in _time_wait_ state\\n" + }, + { + "name": "syn_sent", + "type": "integer", + "description": "Number of TCP connections in _syn_sent_ state\\n" + }, + { + "name": "syn_recv", + "type": "integer", + "description": "Number of TCP connections in _syn_recv_ state\\n" + }, + { + "name": "fin_wait1", + "type": "integer", + "description": "Number of TCP connections in _fin_wait1_ state\\n" + }, + { + "name": "fin_wait2", + "type": "integer", + "description": "Number of TCP connections in _fin_wait2_ state\\n" + }, + { + "name": "last_ack", + "type": "integer", + "description": "Number of TCP connections in _last_ack_ state\\n" + }, + { + "name": "closing", + "type": "integer", + "description": "Number of TCP connections in _closing_ state\\n" + } + ] + } + ] + }, + { + "name": "udp", + "type": "group", + "description": "All UDP connections\\n", + "fields": [ + { + "name": "memory", + "type": "integer", + "format": "bytes", + "description": "Memory used by UDP sockets in bytes, based on number of allocated pages and system page size. Corresponds to limits set in /proc/sys/net/ipv4/udp_mem. Only available on Linux.\\n" + }, + { + "name": "all", + "type": "group", + "description": "All UDP connections\\n", + "fields": [ + { + "name": "count", + "type": "integer", + "description": "All open UDP connections\\n" + } + ] + } + ] + } + ] + } + ] + }, + { + "name": "uptime", + "type": "group", + "description": "\`uptime\` contains the operating system uptime metric.\\n", + "release": "ga", + "fields": [ + { + "name": "duration", + "type": "group", + "fields": [ + { + "name": "ms", + "type": "long", + "format": "duration", + "input_format": "milliseconds", + "description": "The OS uptime in milliseconds.\\n" + } + ] + } + ] + }, + { + "name": "users", + "type": "group", + "release": "beta", + "description": "Logged-in user session data\\n", + "fields": [ + { + "name": "id", + "type": "keyword", + "description": "The ID of the session\\n" + }, + { + "name": "seat", + "type": "keyword", + "description": "An associated logind seat\\n" + }, + { + "name": "path", + "type": "keyword", + "description": "The DBus object path of the session\\n" + }, + { + "name": "type", + "type": "keyword", + "description": "The type of the user session\\n" + }, + { + "name": "service", + "type": "keyword", + "description": "A session associated with the service\\n" + }, + { + "name": "remote", + "type": "boolean", + "description": "A bool indicating a remote session\\n" + }, + { + "name": "state", + "type": "keyword", + "description": "The current state of the session\\n" + }, + { + "name": "scope", + "type": "keyword", + "description": "The associated systemd scope\\n" + }, + { + "name": "leader", + "type": "long", + "description": "The root PID of the session\\n" + }, + { + "name": "remote_host", + "type": "keyword", + "description": "A remote host address for the session\\n" + } + ] } ] } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts index 3cdf011d9d0e3..929f2518ee748 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts @@ -8,7 +8,7 @@ import { readFileSync } from 'fs'; import glob from 'glob'; import { safeLoad } from 'js-yaml'; import path from 'path'; -import { Field, processFields } from './field'; +import { Field, Fields, getField, processFields } from './field'; // Add our own serialiser to just do JSON.stringify expect.addSnapshotSerializer({ @@ -27,9 +27,56 @@ test('tests loading fields.yml', () => { for (const file of files) { const fieldsYML = readFileSync(file, 'utf-8'); const fields: Field[] = safeLoad(fieldsYML); - processFields(fields); + const processedFields = processFields(fields); // Check that content file and generated file are equal - expect(fields).toMatchSnapshot(path.basename(file)); + expect(processedFields).toMatchSnapshot(path.basename(file)); } }); + +describe('getField searches recursively for nested field in fields given an array of path parts', () => { + const searchFields: Fields = [ + { + name: '1', + fields: [ + { + name: '1-1', + }, + { + name: '1-2', + }, + ], + }, + { + name: '2', + fields: [ + { + name: '2-1', + }, + { + name: '2-2', + fields: [ + { + name: '2-2-1', + }, + { + name: '2-2-2', + }, + ], + }, + ], + }, + ]; + test('returns undefined when the field does not exist', () => { + expect(getField(searchFields, ['0'])).toBe(undefined); + }); + test('returns undefined if the field is not a leaf node', () => { + expect(getField(searchFields, ['1'])?.name).toBe(undefined); + }); + test('returns undefined searching for a nested field that does not exist', () => { + expect(getField(searchFields, ['1', '1-3'])?.name).toBe(undefined); + }); + test('returns nested field that is a leaf node', () => { + expect(getField(searchFields, ['2', '2-2', '2-2-1'])?.name).toBe('2-2-1'); + }); +}); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts index eb515f5652f36..4a1a84baf6599 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts @@ -21,6 +21,12 @@ export interface Field { required?: boolean; multi_fields?: Fields; doc_values?: boolean; + copy_to?: string; + analyzer?: string; + search_analyzer?: string; + ignore_above?: number; + object_type?: string; + scaling_factor?: number; // Kibana specific analyzed?: boolean; @@ -43,44 +49,140 @@ export interface Field { export type Fields = Field[]; /** - * ProcessFields takes the given fields read from yaml and expands it. + * expandFields takes the given fields read from yaml and expands them. * There are dotted fields in the field.yml like `foo.bar`. These should - * be stored as an object inside an object and is the main purpose of this - * preprocessing. + * be stored as an field within a 'group' field. * - * Note: This function modifies the passed field param. + * Note: This function modifies the passed fields array. */ -export function processFields(fields: Fields) { +export function expandFields(fields: Fields) { fields.forEach((field, key) => { const fieldName = field.name; - // If the field name contains a dot, it means we need to create sub objects + // If the field name contains a dot, it means we need to + // - take the first part of the name + // - create a field of type 'group' with this first part + // - put the original field, named with the rest of the original name in the fields property of the new group field if (fieldName.includes('.')) { // Split up the name by dots to extract first and other parts const nameParts = fieldName.split('.'); // Getting first part of the name for the new field - const newNameTop = nameParts[0]; - delete nameParts[0]; + const groupFieldName = nameParts[0]; // Put back together the parts again for the new field name - const newName = nameParts.length === 1 ? nameParts[0] : nameParts.slice(1).join('.'); + const restFieldName = nameParts.slice(1).join('.'); - field.name = newName; + // keep all properties of the original field, but give it the shortened name + field.name = restFieldName; - // Create the new field with the old field inside - const newField: Field = { - name: newNameTop, + // create a new field of type group with the original field in the fields array + const groupField: Field = { + name: groupFieldName, type: 'group', fields: [field], }; - // Replace the old field in the array - fields[key] = newField; - if (newField.fields) { - processFields(newField.fields); + // check child fields further down the tree + if (groupField.fields) { + expandFields(groupField.fields); } + // Replace the original field in the array with the new one + fields[key] = groupField; + } else { + // even if this field doesn't have dots to expand, its child fields further down the tree might + if (field.fields) { + expandFields(field.fields); + } + } + }); +} +/** + * dedupFields takes the given fields and merges sibling fields with the + * same name together. + * These can result from expandFields when the input contains dotted field + * names that share parts of their hierarchy. + */ +function dedupFields(fields: Fields): Fields { + const dedupedFields: Fields = []; + fields.forEach(field => { + const found = dedupedFields.find(f => { + return f.name === field.name; + }); + if (found) { + if (found.type === 'group' && field.type === 'group' && found.fields && field.fields) { + found.fields = dedupFields(found.fields.concat(field.fields)); + } else { + // only 'group' fields can be merged in this way + // XXX: don't abort on error for now + // see discussion in https://github.com/elastic/kibana/pull/59894 + // throw new Error( + // "Can't merge fields " + JSON.stringify(found) + ' and ' + JSON.stringify(field) + // ); + } + } else { + if (field.fields) { + field.fields = dedupFields(field.fields); + } + dedupedFields.push(field); } }); + return dedupedFields; +} + +/** validateFields takes the given fields and verifies: + * + * - all fields of type alias point to existing fields. + * - all fields of type array have a property object_type + * + * Invalid fields are silently removed. + */ + +function validateFields(fields: Fields, allFields: Fields): Fields { + const validatedFields: Fields = []; + + fields.forEach(field => { + if (field.type === 'alias') { + if (field.path && getField(allFields, field.path.split('.'))) { + validatedFields.push(field); + } + } else if (field.type === 'array') { + if (field.object_type) { + validatedFields.push(field); + } + } else { + validatedFields.push(field); + } + if (field.fields) { + field.fields = validateFields(field.fields, allFields); + } + }); + return validatedFields; +} + +export const getField = (fields: Fields, pathNames: string[]): Field | undefined => { + if (!pathNames.length) return undefined; + // get the first rest of path names + const [name, ...restPathNames] = pathNames; + for (const field of fields) { + if (field.name === name) { + // check field's fields, passing in the remaining path names + if (field.fields && field.fields.length > 0) { + return getField(field.fields, restPathNames); + } + // no nested fields to search, but still more names - not found + if (restPathNames.length) { + return undefined; + } + return field; + } + } + return undefined; +}; + +export function processFields(fields: Fields): Fields { + expandFields(fields); + const dedupedFields = dedupFields(fields); + return validateFields(dedupedFields, dedupedFields); } const isFields = (path: string) => { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/tests/base.yml b/x-pack/plugins/ingest_manager/server/services/epm/fields/tests/base.yml index 86b61245aa3b8..5a71c7dee54dc 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/fields/tests/base.yml +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/tests/base.yml @@ -4,4 +4,20 @@ - name: auid - name: euid - name: long.nested.foo + type: text +- name: long.nested.bar + type: integer - name: nested.bar +- name: nested.baz +- name: myalias + type: alias + path: user.euid +- name: invalidalias + type: alias + path: euid +- name: validarray + type: array + object_type: integer +- name: invalidarray + type: array + diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/tests/system.yml b/x-pack/plugins/ingest_manager/server/services/epm/fields/tests/system.yml new file mode 100644 index 0000000000000..609914616a683 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/tests/system.yml @@ -0,0 +1,1625 @@ +- name: system.core + type: group + description: > + `system-core` contains CPU metrics for a single core of a multi-core system. + fields: + - name: id + type: long + description: > + CPU Core number. + + # Percentages + - name: user.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent in user space. + + - name: user.ticks + type: long + description: > + The amount of CPU time spent in user space. + + - name: system.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent in kernel space. + + - name: system.ticks + type: long + description: > + The amount of CPU time spent in kernel space. + + - name: nice.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent on low-priority processes. + + - name: nice.ticks + type: long + description: > + The amount of CPU time spent on low-priority processes. + + - name: idle.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent idle. + + - name: idle.ticks + type: long + description: > + The amount of CPU time spent idle. + + - name: iowait.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent in wait (on disk). + + - name: iowait.ticks + type: long + description: > + The amount of CPU time spent in wait (on disk). + + - name: irq.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent servicing and handling hardware interrupts. + + - name: irq.ticks + type: long + description: > + The amount of CPU time spent servicing and handling hardware interrupts. + + - name: softirq.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent servicing and handling software interrupts. + + - name: softirq.ticks + type: long + description: > + The amount of CPU time spent servicing and handling software interrupts. + + - name: steal.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent in involuntary wait by the virtual CPU while the hypervisor + was servicing another processor. + Available only on Unix. + + - name: steal.ticks + type: long + description: > + The amount of CPU time spent in involuntary wait by the virtual CPU while the hypervisor + was servicing another processor. + Available only on Unix. +- name: system.cpu + type: group + description: > + `cpu` contains local CPU stats. + release: ga + fields: + - name: cores + type: long + description: > + The number of CPU cores present on the host. The non-normalized + percentages will have a maximum value of `100% * cores`. The + normalized percentages already take this value into account and have + a maximum value of 100%. + + # Percentages + - name: user.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent in user space. On multi-core systems, + you can have percentages that are greater than 100%. For example, if 3 + cores are at 60% use, then the `system.cpu.user.pct` will be 180%. + + - name: system.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent in kernel space. + + - name: nice.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent on low-priority processes. + + - name: idle.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent idle. + + - name: iowait.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent in wait (on disk). + + - name: irq.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent servicing and handling hardware interrupts. + + - name: softirq.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent servicing and handling software interrupts. + + - name: steal.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent in involuntary wait by the virtual CPU while the hypervisor + was servicing another processor. + Available only on Unix. + + - name: total.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent in states other than Idle and IOWait. + + # Normalized Percentages + - name: user.norm.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent in user space. + + - name: system.norm.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent in kernel space. + + - name: nice.norm.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent on low-priority processes. + + - name: idle.norm.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent idle. + + - name: iowait.norm.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent in wait (on disk). + + - name: irq.norm.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent servicing and handling hardware interrupts. + + - name: softirq.norm.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent servicing and handling software interrupts. + + - name: steal.norm.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent in involuntary wait by the virtual CPU while the hypervisor + was servicing another processor. + Available only on Unix. + + - name: total.norm.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time in states other than Idle and IOWait, normalised by the number of cores. + + + # Ticks + - name: user.ticks + type: long + description: > + The amount of CPU time spent in user space. + + - name: system.ticks + type: long + description: > + The amount of CPU time spent in kernel space. + + - name: nice.ticks + type: long + description: > + The amount of CPU time spent on low-priority processes. + + - name: idle.ticks + type: long + description: > + The amount of CPU time spent idle. + + - name: iowait.ticks + type: long + description: > + The amount of CPU time spent in wait (on disk). + + - name: irq.ticks + type: long + description: > + The amount of CPU time spent servicing and handling hardware interrupts. + + - name: softirq.ticks + type: long + description: > + The amount of CPU time spent servicing and handling software interrupts. + + - name: steal.ticks + type: long + description: > + The amount of CPU time spent in involuntary wait by the virtual CPU while the hypervisor + was servicing another processor. + Available only on Unix. +- name: system.diskio + type: group + description: > + `disk` contains disk IO metrics collected from the operating system. + release: ga + fields: + - name: name + type: keyword + example: sda1 + description: > + The disk name. + + - name: serial_number + type: keyword + description: > + The disk's serial number. This may not be provided by all operating + systems. + + - name: read.count + type: long + description: > + The total number of reads completed successfully. + + - name: write.count + type: long + description: > + The total number of writes completed successfully. + + - name: read.bytes + type: long + format: bytes + description: > + The total number of bytes read successfully. On Linux this is + the number of sectors read multiplied by an assumed sector size of 512. + + - name: write.bytes + type: long + format: bytes + description: > + The total number of bytes written successfully. On Linux this is + the number of sectors written multiplied by an assumed sector size of + 512. + + - name: read.time + type: long + description: > + The total number of milliseconds spent by all reads. + + - name: write.time + type: long + description: > + The total number of milliseconds spent by all writes. + + - name: io.time + type: long + description: > + The total number of of milliseconds spent doing I/Os. + + - name: iostat.read.request.merges_per_sec + type: float + description: > + The number of read requests merged per second that were queued to the device. + + - name: iostat.write.request.merges_per_sec + type: float + description: > + The number of write requests merged per second that were queued to the device. + + - name: iostat.read.request.per_sec + type: float + description: > + The number of read requests that were issued to the device per second + + - name: iostat.write.request.per_sec + type: float + description: > + The number of write requests that were issued to the device per second + + - name: iostat.read.per_sec.bytes + type: float + description: > + The number of Bytes read from the device per second. + format: bytes + + - name: iostat.read.await + type: float + description: > + The average time spent for read requests issued to the device to be served. + + - name: iostat.write.per_sec.bytes + type: float + description: > + The number of Bytes write from the device per second. + format: bytes + + - name: iostat.write.await + type: float + description: > + The average time spent for write requests issued to the device to be served. + + - name: iostat.request.avg_size + type: float + description: > + The average size (in bytes) of the requests that were issued to the device. + + - name: iostat.queue.avg_size + type: float + description: > + The average queue length of the requests that were issued to the device. + + - name: iostat.await + type: float + description: > + The average time spent for requests issued to the device to be served. + + - name: iostat.service_time + type: float + description: > + The average service time (in milliseconds) for I/O requests that were issued to the device. + + - name: iostat.busy + type: float + description: > + Percentage of CPU time during which I/O requests were issued to the device (bandwidth utilization for the device). Device saturation occurs when this value is close to 100%. +- name: system.entropy + type: group + description: > + Available system entropy + release: ga + fields: + - name: available_bits + type: long + description: > + The available bits of entropy + - name: pct + type: scaled_float + format: percent + description: > + The percentage of available entropy, relative to the pool size of 4096 +- name: system.filesystem + type: group + description: > + `filesystem` contains local filesystem stats. + release: ga + fields: + - name: available + type: long + format: bytes + description: > + The disk space available to an unprivileged user in bytes. + - name: device_name + type: keyword + description: > + The disk name. For example: `/dev/disk1` + - name: type + type: keyword + description: > + The disk type. For example: `ext4` + - name: mount_point + type: keyword + description: > + The mounting point. For example: `/` + - name: files + type: long + description: > + The total number of file nodes in the file system. + - name: free + type: long + format: bytes + description: > + The disk space available in bytes. + - name: free_files + type: long + description: > + The number of free file nodes in the file system. + - name: total + type: long + format: bytes + description: > + The total disk space in bytes. + - name: used.bytes + type: long + format: bytes + description: > + The used disk space in bytes. + - name: used.pct + type: scaled_float + format: percent + description: > + The percentage of used disk space. +- name: system.fsstat + type: group + description: > + `system.fsstat` contains filesystem metrics aggregated from all mounted + filesystems. + release: ga + fields: + - name: count + type: long + description: Number of file systems found. + - name: total_files + type: long + description: Total number of files. + - name: total_size + format: bytes + type: group + description: Nested file system docs. + fields: + - name: free + type: long + format: bytes + description: > + Total free space. + - name: used + type: long + format: bytes + description: > + Total used space. + - name: total + type: long + format: bytes + description: > + Total space (used plus free). +- name: system.load + type: group + description: > + CPU load averages. + release: ga + fields: + - name: "1" + type: scaled_float + scaling_factor: 100 + description: > + Load average for the last minute. + - name: "5" + type: scaled_float + scaling_factor: 100 + description: > + Load average for the last 5 minutes. + - name: "15" + type: scaled_float + scaling_factor: 100 + description: > + Load average for the last 15 minutes. + + - name: "norm.1" + type: scaled_float + scaling_factor: 100 + description: > + Load for the last minute divided by the number of cores. + + - name: "norm.5" + type: scaled_float + scaling_factor: 100 + description: > + Load for the last 5 minutes divided by the number of cores. + + - name: "norm.15" + type: scaled_float + scaling_factor: 100 + description: > + Load for the last 15 minutes divided by the number of cores. + + - name: "cores" + type: long + description: > + The number of CPU cores present on the host. +- name: system.memory + type: group + description: > + `memory` contains local memory stats. + release: ga + fields: + - name: total + type: long + format: bytes + description: > + Total memory. + + - name: used.bytes + type: long + format: bytes + description: > + Used memory. + + - name: free + type: long + format: bytes + description: > + The total amount of free memory in bytes. This value does not include memory consumed by system caches and + buffers (see system.memory.actual.free). + + - name: used.pct + type: scaled_float + format: percent + description: > + The percentage of used memory. + + - name: actual + type: group + description: > + Actual memory used and free. + fields: + + - name: used.bytes + type: long + format: bytes + description: > + Actual used memory in bytes. It represents the difference between the total and the available memory. The + available memory depends on the OS. For more details, please check `system.actual.free`. + + - name: free + type: long + format: bytes + description: > + Actual free memory in bytes. It is calculated based on the OS. On Linux it consists of the free memory + plus caches and buffers. On OSX it is a sum of free memory and the inactive memory. On Windows, it is equal + to `system.memory.free`. + + - name: used.pct + type: scaled_float + format: percent + description: > + The percentage of actual used memory. + + - name: swap + type: group + prefix: "[float]" + description: This group contains statistics related to the swap memory usage on the system. + fields: + - name: total + type: long + format: bytes + description: > + Total swap memory. + + - name: used.bytes + type: long + format: bytes + description: > + Used swap memory. + + - name: free + type: long + format: bytes + description: > + Available swap memory. + + - name: out.pages + type: long + description: count of pages swapped out + + - name: in.pages + type: long + description: count of pages swapped in + + - name: readahead.pages + type: long + description: swap readahead pages + + - name: readahead.cached + type: long + description: swap readahead cache hits + + - name: used.pct + type: scaled_float + format: percent + description: > + The percentage of used swap memory. + + - name: hugepages + type: group + prefix: "[float]" + description: This group contains statistics related to huge pages usage on the system. + fields: + - name: total + type: long + format: number + description: > + Number of huge pages in the pool. + + - name: used.bytes + type: long + format: bytes + description: > + Memory used in allocated huge pages. + + - name: used.pct + type: long + format: percent + description: > + Percentage of huge pages used. + + - name: free + type: long + format: number + description: > + Number of available huge pages in the pool. + + - name: reserved + type: long + format: number + description: > + Number of reserved but not allocated huge pages in the pool. + + - name: surplus + type: long + format: number + description: > + Number of overcommited huge pages. + + - name: default_size + type: long + format: bytes + description: > + Default size for huge pages. + + - name: swap.out + type: group + description: huge pages swapped out + fields: + - name: pages + type: long + description: pages swapped out + - name: fallback + type: long + description: Count of huge pages that must be split before swapout +- name: system.network + type: group + description: > + `network` contains network IO metrics for a single network interface. + release: ga + fields: + - name: name + type: keyword + example: eth0 + description: > + The network interface name. + + - name: out.bytes + type: long + format: bytes + description: > + The number of bytes sent. + + - name: in.bytes + type: long + format: bytes + description: > + The number of bytes received. + + - name: out.packets + type: long + description: > + The number of packets sent. + + - name: in.packets + type: long + description: > + The number or packets received. + + - name: in.errors + type: long + description: > + The number of errors while receiving. + + - name: out.errors + type: long + description: > + The number of errors while sending. + + - name: in.dropped + type: long + description: > + The number of incoming packets that were dropped. + + - name: out.dropped + type: long + description: > + The number of outgoing packets that were dropped. This value is always + 0 on Darwin and BSD because it is not reported by the operating system. +- name: system.network_summary + type: group + release: beta + description: > + Metrics relating to global network activity + fields: + - name: ip.* + type: object + description: > + IP counters + - name: tcp.* + type: object + description: > + TCP counters + - name: udp.* + type: object + description: > + UDP counters + - name: udp_lite.* + type: object + description: > + UDP Lite counters + - name: icmp.* + type: object + description: > + ICMP counters +- name: system.process + type: group + description: > + `process` contains process metadata, CPU metrics, and memory metrics. + release: ga + fields: + - name: name + type: alias + path: process.name + migration: true + - name: state + type: keyword + description: > + The process state. For example: "running". + - name: pid + type: alias + path: process.pid + migration: true + - name: ppid + type: alias + path: process.ppid + migration: true + - name: pgid + type: alias + path: process.pgid + migration: true + - name: cmdline + type: keyword + description: > + The full command-line used to start the process, including the + arguments separated by space. + ignore_above: 2048 + - name: username + type: alias + path: user.name + migration: true + - name: cwd + type: alias + path: process.working_directory + migration: true + - name: env + type: object + object_type: keyword + description: > + The environment variables used to start the process. The data is + available on FreeBSD, Linux, and OS X. + - name: cpu + type: group + prefix: "[float]" + description: CPU-specific statistics per process. + fields: + - name: user.ticks + type: long + description: > + The amount of CPU time the process spent in user space. + - name: total.value + type: long + description: > + The value of CPU usage since starting the process. + - name: total.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent by the process since the last update. Its value is similar to the + %CPU value of the process displayed by the top command on Unix systems. + - name: total.norm.pct + type: scaled_float + format: percent + description: > + The percentage of CPU time spent by the process since the last event. + This value is normalized by the number of CPU cores and it ranges + from 0 to 100%. + - name: system.ticks + type: long + description: > + The amount of CPU time the process spent in kernel space. + - name: total.ticks + type: long + description: > + The total CPU time spent by the process. + - name: start_time + type: date + description: > + The time when the process was started. + - name: memory + type: group + description: Memory-specific statistics per process. + prefix: "[float]" + fields: + - name: size + type: long + format: bytes + description: > + The total virtual memory the process has. + - name: rss.bytes + type: long + format: bytes + description: > + The Resident Set Size. The amount of memory the process occupied in main memory (RAM). + - name: rss.pct + type: scaled_float + format: percent + description: > + The percentage of memory the process occupied in main memory (RAM). + - name: share + type: long + format: bytes + description: > + The shared memory the process uses. + - name: fd + type: group + description: > + File descriptor usage metrics. This set of metrics is available for + Linux and FreeBSD. + prefix: "[float]" + fields: + - name: open + type: long + description: The number of file descriptors open by the process. + - name: limit.soft + type: long + description: > + The soft limit on the number of file descriptors opened by the + process. The soft limit can be changed by the process at any time. + - name: limit.hard + type: long + description: > + The hard limit on the number of file descriptors opened by the + process. The hard limit can only be raised by root. + - name: cgroup + type: group + description: > + Metrics and limits from the cgroup of which the task is a member. + cgroup metrics are reported when the process has membership in a + non-root cgroup. These metrics are only available on Linux. + fields: + - name: id + type: keyword + description: > + The ID common to all cgroups associated with this task. + If there isn't a common ID used by all cgroups this field will be + absent. + + - name: path + type: keyword + description: > + The path to the cgroup relative to the cgroup subsystem's mountpoint. + If there isn't a common path used by all cgroups this field will be + absent. + + - name: cpu + type: group + description: > + The cpu subsystem schedules CPU access for tasks in the cgroup. + Access can be controlled by two separate schedulers, CFS and RT. + CFS stands for completely fair scheduler which proportionally + divides the CPU time between cgroups based on weight. RT stands for + real time scheduler which sets a maximum amount of CPU time that + processes in the cgroup can consume during a given period. + + fields: + - name: id + type: keyword + description: ID of the cgroup. + + - name: path + type: keyword + description: > + Path to the cgroup relative to the cgroup subsystem's + mountpoint. + + - name: cfs.period.us + type: long + description: > + Period of time in microseconds for how regularly a + cgroup's access to CPU resources should be reallocated. + + - name: cfs.quota.us + type: long + description: > + Total amount of time in microseconds for which all + tasks in a cgroup can run during one period (as defined by + cfs.period.us). + + - name: cfs.shares + type: long + description: > + An integer value that specifies a relative share of CPU time + available to the tasks in a cgroup. The value specified in the + cpu.shares file must be 2 or higher. + + - name: rt.period.us + type: long + description: > + Period of time in microseconds for how regularly a cgroup's + access to CPU resources is reallocated. + + - name: rt.runtime.us + type: long + description: > + Period of time in microseconds for the longest continuous period + in which the tasks in a cgroup have access to CPU resources. + + - name: stats.periods + type: long + description: > + Number of period intervals (as specified in cpu.cfs.period.us) + that have elapsed. + + - name: stats.throttled.periods + type: long + description: > + Number of times tasks in a cgroup have been throttled (that is, + not allowed to run because they have exhausted all of the + available time as specified by their quota). + + - name: stats.throttled.ns + type: long + description: > + The total time duration (in nanoseconds) for which tasks in a + cgroup have been throttled. + + - name: cpuacct + type: group + description: CPU accounting metrics. + fields: + - name: id + type: keyword + description: ID of the cgroup. + + - name: path + type: keyword + description: > + Path to the cgroup relative to the cgroup subsystem's + mountpoint. + + - name: total.ns + type: long + description: > + Total CPU time in nanoseconds consumed by all tasks in the + cgroup. + + - name: stats.user.ns + type: long + description: CPU time consumed by tasks in user mode. + + - name: stats.system.ns + type: long + description: CPU time consumed by tasks in user (kernel) mode. + + - name: percpu + type: object + object_type: long + description: > + CPU time (in nanoseconds) consumed on each CPU by all tasks in + this cgroup. + + - name: memory + type: group + description: Memory limits and metrics. + fields: + - name: id + type: keyword + description: ID of the cgroup. + + - name: path + type: keyword + description: > + Path to the cgroup relative to the cgroup subsystem's mountpoint. + + - name: mem.usage.bytes + type: long + format: bytes + description: > + Total memory usage by processes in the cgroup (in bytes). + + - name: mem.usage.max.bytes + type: long + format: bytes + description: > + The maximum memory used by processes in the cgroup (in bytes). + + - name: mem.limit.bytes + type: long + format: bytes + description: > + The maximum amount of user memory in bytes (including file + cache) that tasks in the cgroup are allowed to use. + + - name: mem.failures + type: long + description: > + The number of times that the memory limit (mem.limit.bytes) was + reached. + + - name: memsw.usage.bytes + type: long + format: bytes + description: > + The sum of current memory usage plus swap space used by + processes in the cgroup (in bytes). + + - name: memsw.usage.max.bytes + type: long + format: bytes + description: > + The maximum amount of memory and swap space used by processes in + the cgroup (in bytes). + + - name: memsw.limit.bytes + type: long + format: bytes + description: > + The maximum amount for the sum of memory and swap usage + that tasks in the cgroup are allowed to use. + + - name: memsw.failures + type: long + description: > + The number of times that the memory plus swap space limit + (memsw.limit.bytes) was reached. + + - name: kmem.usage.bytes + type: long + format: bytes + description: > + Total kernel memory usage by processes in the cgroup (in bytes). + + - name: kmem.usage.max.bytes + type: long + format: bytes + description: > + The maximum kernel memory used by processes in the cgroup (in + bytes). + + - name: kmem.limit.bytes + type: long + format: bytes + description: > + The maximum amount of kernel memory that tasks in the cgroup are + allowed to use. + + - name: kmem.failures + type: long + description: > + The number of times that the memory limit (kmem.limit.bytes) was + reached. + + - name: kmem_tcp.usage.bytes + type: long + format: bytes + description: > + Total memory usage for TCP buffers in bytes. + + - name: kmem_tcp.usage.max.bytes + type: long + format: bytes + description: > + The maximum memory used for TCP buffers by processes in the + cgroup (in bytes). + + - name: kmem_tcp.limit.bytes + type: long + format: bytes + description: > + The maximum amount of memory for TCP buffers that tasks in the + cgroup are allowed to use. + + - name: kmem_tcp.failures + type: long + description: > + The number of times that the memory limit (kmem_tcp.limit.bytes) + was reached. + + - name: stats.active_anon.bytes + type: long + format: bytes + description: > + Anonymous and swap cache on active least-recently-used (LRU) + list, including tmpfs (shmem), in bytes. + + - name: stats.active_file.bytes + type: long + format: bytes + description: File-backed memory on active LRU list, in bytes. + + - name: stats.cache.bytes + type: long + format: bytes + description: Page cache, including tmpfs (shmem), in bytes. + + - name: stats.hierarchical_memory_limit.bytes + type: long + format: bytes + description: > + Memory limit for the hierarchy that contains the memory cgroup, + in bytes. + + - name: stats.hierarchical_memsw_limit.bytes + type: long + format: bytes + description: > + Memory plus swap limit for the hierarchy that contains the + memory cgroup, in bytes. + + - name: stats.inactive_anon.bytes + type: long + format: bytes + description: > + Anonymous and swap cache on inactive LRU list, including tmpfs + (shmem), in bytes + + - name: stats.inactive_file.bytes + type: long + format: bytes + description: > + File-backed memory on inactive LRU list, in bytes. + + - name: stats.mapped_file.bytes + type: long + format: bytes + description: > + Size of memory-mapped mapped files, including tmpfs (shmem), + in bytes. + + - name: stats.page_faults + type: long + description: > + Number of times that a process in the cgroup triggered a page + fault. + + - name: stats.major_page_faults + type: long + description: > + Number of times that a process in the cgroup triggered a major + fault. "Major" faults happen when the kernel actually has to + read the data from disk. + + - name: stats.pages_in + type: long + description: > + Number of pages paged into memory. This is a counter. + + - name: stats.pages_out + type: long + description: > + Number of pages paged out of memory. This is a counter. + + - name: stats.rss.bytes + type: long + format: bytes + description: > + Anonymous and swap cache (includes transparent hugepages), not + including tmpfs (shmem), in bytes. + + - name: stats.rss_huge.bytes + type: long + format: bytes + description: > + Number of bytes of anonymous transparent hugepages. + + - name: stats.swap.bytes + type: long + format: bytes + description: > + Swap usage, in bytes. + + - name: stats.unevictable.bytes + type: long + format: bytes + description: > + Memory that cannot be reclaimed, in bytes. + + - name: blkio + type: group + description: Block IO metrics. + fields: + - name: id + type: keyword + description: ID of the cgroup. + + - name: path + type: keyword + description: > + Path to the cgroup relative to the cgroup subsystems mountpoint. + + - name: total.bytes + type: long + format: bytes + description: > + Total number of bytes transferred to and from all block devices + by processes in the cgroup. + + - name: total.ios + type: long + description: > + Total number of I/O operations performed on all devices + by processes in the cgroup as seen by the throttling policy. +- name: system.process.summary + title: Process Summary + type: group + description: > + Summary metrics for the processes running on the host. + release: ga + fields: + - name: total + type: long + description: > + Total number of processes on this host. + - name: running + type: long + description: > + Number of running processes on this host. + - name: idle + type: long + description: > + Number of idle processes on this host. + - name: sleeping + type: long + description: > + Number of sleeping processes on this host. + - name: stopped + type: long + description: > + Number of stopped processes on this host. + - name: zombie + type: long + description: > + Number of zombie processes on this host. + - name: dead + type: long + description: > + Number of dead processes on this host. It's very unlikely that it will appear but in some special situations it may happen. + - name: unknown + type: long + description: > + Number of processes for which the state couldn't be retrieved or is unknown. +- name: system.raid + type: group + description: > + raid + release: ga + fields: + - name: name + type: keyword + description: > + Name of the device. + - name: status + type: keyword + description: > + activity-state of the device. + - name: level + type: keyword + description: > + The raid level of the device + - name: sync_action + type: keyword + description: > + Current sync action, if the RAID array is redundant + - name: disks.active + type: long + description: > + Number of active disks. + - name: disks.total + type: long + description: > + Total number of disks the device consists of. + - name: disks.spare + type: long + description: > + Number of spared disks. + - name: disks.failed + type: long + description: > + Number of failed disks. + - name: disks.states.* + type: object + object_type: keyword + description: > + map of raw disk states + - name: blocks.total + type: long + description: > + Number of blocks the device holds, in 1024-byte blocks. + - name: blocks.synced + type: long + description: > + Number of blocks on the device that are in sync, in 1024-byte blocks. +- name: system.socket + type: group + description: > + TCP sockets that are active. + release: ga + fields: + - name: direction + type: alias + path: network.direction + migration: true + + - name: family + type: alias + path: network.type + migration: true + + - name: local.ip + type: ip + example: 192.0.2.1 or 2001:0DB8:ABED:8536::1 + description: > + Local IP address. This can be an IPv4 or IPv6 address. + + - name: local.port + type: long + example: 22 + description: > + Local port. + + - name: remote.ip + type: ip + example: 192.0.2.1 or 2001:0DB8:ABED:8536::1 + description: > + Remote IP address. This can be an IPv4 or IPv6 address. + + - name: remote.port + type: long + example: 22 + description: > + Remote port. + + - name: remote.host + type: keyword + example: 76-211-117-36.nw.example.com. + description: > + PTR record associated with the remote IP. It is obtained via reverse + IP lookup. + + - name: remote.etld_plus_one + type: keyword + example: example.com. + description: > + The effective top-level domain (eTLD) of the remote host plus one more + label. For example, the eTLD+1 for "foo.bar.golang.org." is "golang.org.". + The data for determining the eTLD comes from an embedded copy of the data + from http://publicsuffix.org. + + - name: remote.host_error + type: keyword + description: > + Error describing the cause of the reverse lookup failure. + + - name: process.pid + type: alias + path: process.pid + migration: true + + - name: process.command + type: alias + path: process.name + migration: true + + - name: process.cmdline + type: keyword + description: > + Full command line + + - name: process.exe + type: alias + path: process.executable + migration: true + + - name: user.id + type: alias + path: user.id + migration: true + + - name: user.name + type: alias + path: user.full_name + migration: true +- name: system.socket.summary + title: Socket summary + type: group + description: > + Summary metrics of open sockets in the host system + release: ga + fields: + - name: all + type: group + description: > + All connections + fields: + - name: count + type: integer + description: > + All open connections + - name: listening + type: integer + description: > + All listening ports + - name: tcp + type: group + description: > + All TCP connections + fields: + - name: memory + type: integer + format: bytes + description: > + Memory used by TCP sockets in bytes, based on number of allocated pages and system page size. Corresponds to limits set in /proc/sys/net/ipv4/tcp_mem. Only available on Linux. + - name: all + type: group + description: > + All TCP connections + fields: + - name: orphan + type: integer + description: > + A count of all orphaned tcp sockets. Only available on Linux. + - name: count + type: integer + description: > + All open TCP connections + - name: listening + type: integer + description: > + All TCP listening ports + - name: established + type: integer + description: > + Number of established TCP connections + - name: close_wait + type: integer + description: > + Number of TCP connections in _close_wait_ state + - name: time_wait + type: integer + description: > + Number of TCP connections in _time_wait_ state + - name: syn_sent + type: integer + description: > + Number of TCP connections in _syn_sent_ state + - name: syn_recv + type: integer + description: > + Number of TCP connections in _syn_recv_ state + - name: fin_wait1 + type: integer + description: > + Number of TCP connections in _fin_wait1_ state + - name: fin_wait2 + type: integer + description: > + Number of TCP connections in _fin_wait2_ state + - name: last_ack + type: integer + description: > + Number of TCP connections in _last_ack_ state + - name: closing + type: integer + description: > + Number of TCP connections in _closing_ state + - name: udp + type: group + description: > + All UDP connections + fields: + - name: memory + type: integer + format: bytes + description: > + Memory used by UDP sockets in bytes, based on number of allocated pages and system page size. Corresponds to limits set in /proc/sys/net/ipv4/udp_mem. Only available on Linux. + - name: all + type: group + description: > + All UDP connections + fields: + - name: count + type: integer + description: > + All open UDP connections +- name: system.uptime + type: group + description: > + `uptime` contains the operating system uptime metric. + release: ga + fields: + - name: duration.ms + type: long + format: duration + input_format: milliseconds + description: > + The OS uptime in milliseconds. +- name: system.users + type: group + release: beta + description: > + Logged-in user session data + fields: + - name: id + type: keyword + description: > + The ID of the session + - name: seat + type: keyword + description: > + An associated logind seat + - name: path + type: keyword + description: > + The DBus object path of the session + - name: type + type: keyword + description: > + The type of the user session + - name: service + type: keyword + description: > + A session associated with the service + - name: remote + type: boolean + description: > + A bool indicating a remote session + - name: state + type: keyword + description: > + The current state of the session + - name: scope + type: keyword + description: > + The associated systemd scope + - name: leader + type: long + description: > + The root PID of the session + - name: remote_host + type: keyword + description: > + A remote host address for the session + + + + + diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts index 1f11136360465..7aecc408e05fe 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts @@ -95,10 +95,7 @@ export async function installIndexPatterns( // if this is an update because a package is being unisntalled (no pkgkey argument passed) and no other packages are installed, remove the index pattern if (!pkgkey && installedPackages.length === 0) { try { - await savedObjectsClient.delete( - INDEX_PATTERN_SAVED_OBJECT_TYPE, - `epm-ip-${indexPatternType}` - ); + await savedObjectsClient.delete(INDEX_PATTERN_SAVED_OBJECT_TYPE, `${indexPatternType}-*`); } catch (err) { // index pattern was probably deleted by the user already } @@ -111,7 +108,7 @@ export async function installIndexPatterns( const kibanaIndexPattern = createIndexPattern(indexPatternType, fields); // create or overwrite the index pattern await savedObjectsClient.create(INDEX_PATTERN_SAVED_OBJECT_TYPE, kibanaIndexPattern, { - id: `epm-ip-${indexPatternType}`, + id: `${indexPatternType}-*`, overwrite: true, }); }); diff --git a/x-pack/plugins/ingest_manager/server/types/models/agent.ts b/x-pack/plugins/ingest_manager/server/types/models/agent.ts index e0d252faaaf87..f70b3cf0ed092 100644 --- a/x-pack/plugins/ingest_manager/server/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/models/agent.ts @@ -52,3 +52,14 @@ export const AckEventSchema = schema.object({ export const AgentEventSchema = schema.object({ ...AgentEventBase, }); + +export const NewAgentActionSchema = schema.object({ + type: schema.oneOf([ + schema.literal('CONFIG_CHANGE'), + schema.literal('DATA_DUMP'), + schema.literal('RESUME'), + schema.literal('PAUSE'), + ]), + data: schema.maybe(schema.string()), + sent_at: schema.maybe(schema.string()), +}); diff --git a/x-pack/plugins/ingest_manager/server/types/models/datasource.ts b/x-pack/plugins/ingest_manager/server/types/models/datasource.ts index 94d0a1cc1aabf..51687016f6aad 100644 --- a/x-pack/plugins/ingest_manager/server/types/models/datasource.ts +++ b/x-pack/plugins/ingest_manager/server/types/models/datasource.ts @@ -31,7 +31,13 @@ const DatasourceBaseSchema = { enabled: schema.boolean(), dataset: schema.string(), processors: schema.maybe(schema.arrayOf(schema.string())), - config: schema.recordOf(schema.string(), schema.any()), + config: schema.recordOf( + schema.string(), + schema.object({ + type: schema.maybe(schema.string()), + value: schema.any(), + }) + ), }) ), }) diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts index 9fe84c12521ad..f94c02ccee40b 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts @@ -5,7 +5,7 @@ */ import { schema } from '@kbn/config-schema'; -import { AckEventSchema, AgentEventSchema, AgentTypeSchema } from '../models'; +import { AckEventSchema, AgentEventSchema, AgentTypeSchema, NewAgentActionSchema } from '../models'; export const GetAgentsRequestSchema = { query: schema.object({ @@ -52,6 +52,15 @@ export const PostAgentAcksRequestSchema = { }), }; +export const PostNewAgentActionRequestSchema = { + body: schema.object({ + action: NewAgentActionSchema, + }), + params: schema.object({ + agentId: schema.string(), + }), +}; + export const PostAgentUnenrollRequestSchema = { body: schema.oneOf([ schema.object({ diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts index 57f2a633e4524..16ae1b8da752b 100644 --- a/x-pack/plugins/lens/common/constants.ts +++ b/x-pack/plugins/lens/common/constants.ts @@ -5,6 +5,7 @@ */ export const PLUGIN_ID = 'lens'; +export const LENS_EMBEDDABLE_TYPE = 'lens'; export const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; export const BASE_APP_URL = '/app/kibana'; export const BASE_API_URL = '/api/lens'; diff --git a/x-pack/legacy/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap rename to x-pack/plugins/license_management/__jest__/__snapshots__/add_license.test.js.snap diff --git a/x-pack/legacy/plugins/license_management/__jest__/__snapshots__/license_status.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/license_status.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/license_management/__jest__/__snapshots__/license_status.test.js.snap rename to x-pack/plugins/license_management/__jest__/__snapshots__/license_status.test.js.snap diff --git a/x-pack/legacy/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap rename to x-pack/plugins/license_management/__jest__/__snapshots__/request_trial_extension.test.js.snap diff --git a/x-pack/legacy/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap rename to x-pack/plugins/license_management/__jest__/__snapshots__/revert_to_basic.test.js.snap diff --git a/x-pack/legacy/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap rename to x-pack/plugins/license_management/__jest__/__snapshots__/start_trial.test.js.snap diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap new file mode 100644 index 0000000000000..5a7d136180808 --- /dev/null +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap @@ -0,0 +1,2891 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UploadLicense should display a modal when license requires acknowledgement 1`] = ` + + + + + + +
      + +
      + +

      + + Upload your license + +

      +
      + +
      + + + +
      +
      +
      +
      + +
      +
      +
      + Confirm License Upload +
      +
      +
      +
      +
      +
      +
      + Some functionality will be lost if you replace your TRIAL license with a BASIC license. Review the list of features below. +
      +
      +
        +
      • + Watcher will be disabled +
      • +
      +
      +
      +
      +
      +
      +
      + + +
      +
      +
      +
      +
      +
      + } + > + + } + confirmButtonText={ + + } + onCancel={[Function]} + onConfirm={[Function]} + title={ + + } + > + + + +
      +
      +
      + +
      + +
      +
      +
      + Confirm License Upload +
      +
      +
      +
      +
      +
      +
      + Some functionality will be lost if you replace your TRIAL license with a BASIC license. Review the list of features below. +
      +
      +
        +
      • + Watcher will be disabled +
      • +
      +
      +
      +
      +
      +
      +
      + + +
      +
      +
      +
      + } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + > + +
      + +
      +
      +
      + Confirm License Upload +
      +
      +
      +
      +
      +
      +
      + Some functionality will be lost if you replace your TRIAL license with a BASIC license. Review the list of features below. +
      +
      +
        +
      • + Watcher will be disabled +
      • +
      +
      +
      +
      +
      +
      +
      + + +
      +
      +
      +
      + } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + /> + +
      + + + + + +
      + +
      + +
      + + Confirm License Upload + +
      +
      +
      +
      + +
      +
      + +
      +
      + +
      + Some functionality will be lost if you replace your TRIAL license with a BASIC license. Review the list of features below. +
      +
      + +
      +
        +
      • + Watcher will be disabled +
      • +
      +
      +
      +
      +
      +
      +
      +
      +
      + +
      + + + + + + +
      +
      +
      +
      +
      +
      + + + + + + + +
      +

      + + Your license key is a JSON file with a signature attached. + +

      +

      + + + , + } + } + > + Uploading a license will replace your current + + license. + +

      +
      +
      + +
      + + +
      + +
      + +
      + +
      + + } + onChange={[Function]} + > + +
      +
      + + + +
      +
      +
      + + +
      + +
      + +
      + + +
      + + +
      + + +
      + + + + +
      + + + +
      +
      +
      +
      +
      + +
      + +
      + + + + + + +`; + +exports[`UploadLicense should display an error when ES says license is expired 1`] = ` + + + + + + +
      + +
      + +

      + + Upload your license + +

      +
      + +
      + + +
      +

      + + Your license key is a JSON file with a signature attached. + +

      +

      + + + , + } + } + > + Uploading a license will replace your current + + license. + +

      +
      + + +
      + + +
      + + +
      +
      + + Please address the errors in your form. + +
      + +
      +
        +
      • + The supplied license has expired. +
      • +
      +
      +
      +
      +
      +
      + +
      + +
      + +
      + + } + onChange={[Function]} + > + +
      +
      + + + +
      +
      +
      + + +
      + +
      + +
      + + +
      + + +
      + + +
      + + + + +
      + + + +
      +
      +
      +
      +
      + +
      + +
      + + + + + + +`; + +exports[`UploadLicense should display an error when ES says license is invalid 1`] = ` + + + + + + +
      + +
      + +

      + + Upload your license + +

      +
      + +
      + + +
      +

      + + Your license key is a JSON file with a signature attached. + +

      +

      + + + , + } + } + > + Uploading a license will replace your current + + license. + +

      +
      + + +
      + + +
      + + +
      +
      + + Please address the errors in your form. + +
      + +
      +
        +
      • + The supplied license is not valid for this product. +
      • +
      +
      +
      +
      +
      +
      + +
      + +
      + +
      + + } + onChange={[Function]} + > + +
      +
      + + + +
      +
      +
      + + +
      + +
      + +
      + + +
      + + +
      + + +
      + + + + +
      + + + +
      +
      +
      +
      +
      + +
      + +
      + + + + + + +`; + +exports[`UploadLicense should display an error when submitting invalid JSON 1`] = ` + + + + + + +
      + +
      + +

      + + Upload your license + +

      +
      + +
      + + +
      +

      + + Your license key is a JSON file with a signature attached. + +

      +

      + + + , + } + } + > + Uploading a license will replace your current + + license. + +

      +
      + + +
      + + +
      + + +
      +
      + + Please address the errors in your form. + +
      + +
      +
        +
      • + Error encountered uploading license: Check your license file. +
      • +
      +
      +
      +
      +
      +
      + +
      + +
      + +
      + + } + onChange={[Function]} + > + +
      +
      + + + +
      +
      +
      + + +
      + +
      + +
      + + +
      + + +
      + + +
      + + + + +
      + + + +
      +
      +
      +
      +
      + +
      + +
      + + + + + + +`; + +exports[`UploadLicense should display error when ES returns error 1`] = ` + + + + + + +
      + +
      + +

      + + Upload your license + +

      +
      + +
      + + +
      +

      + + Your license key is a JSON file with a signature attached. + +

      +

      + + + , + } + } + > + Uploading a license will replace your current + + license. + +

      +
      + + +
      + + +
      + + +
      +
      + + Please address the errors in your form. + +
      + +
      +
        +
      • + Error encountered uploading license: Can not upgrade to a production license unless TLS is configured or security is disabled +
      • +
      +
      +
      +
      +
      +
      + +
      + +
      + +
      + + } + onChange={[Function]} + > + +
      +
      + + + +
      +
      +
      + + +
      + +
      + +
      + + +
      + + +
      + + +
      + + + + +
      + + + +
      +
      +
      +
      +
      + +
      + +
      + + + + + + +`; diff --git a/x-pack/legacy/plugins/license_management/__jest__/add_license.test.js b/x-pack/plugins/license_management/__jest__/add_license.test.js similarity index 90% rename from x-pack/legacy/plugins/license_management/__jest__/add_license.test.js rename to x-pack/plugins/license_management/__jest__/add_license.test.js index 6ffb43025ff59..070d4df98a90a 100644 --- a/x-pack/legacy/plugins/license_management/__jest__/add_license.test.js +++ b/x-pack/plugins/license_management/__jest__/add_license.test.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AddLicense } from '../public/np_ready/application/sections/license_dashboard/add_license'; +import { AddLicense } from '../public/application/sections/license_dashboard/add_license'; import { createMockLicense, getComponent } from './util'; jest.mock(`@elastic/eui/lib/components/form/form_row/make_id`, () => () => `generated-id`); diff --git a/x-pack/legacy/plugins/license_management/__jest__/api_responses/index.js b/x-pack/plugins/license_management/__jest__/api_responses/index.js similarity index 100% rename from x-pack/legacy/plugins/license_management/__jest__/api_responses/index.js rename to x-pack/plugins/license_management/__jest__/api_responses/index.js diff --git a/x-pack/legacy/plugins/license_management/__jest__/api_responses/upload_license.js b/x-pack/plugins/license_management/__jest__/api_responses/upload_license.js similarity index 100% rename from x-pack/legacy/plugins/license_management/__jest__/api_responses/upload_license.js rename to x-pack/plugins/license_management/__jest__/api_responses/upload_license.js diff --git a/x-pack/legacy/plugins/license_management/__jest__/license_status.test.js b/x-pack/plugins/license_management/__jest__/license_status.test.js similarity index 88% rename from x-pack/legacy/plugins/license_management/__jest__/license_status.test.js rename to x-pack/plugins/license_management/__jest__/license_status.test.js index f44d5c1f138b7..dc7dc7d00f49e 100644 --- a/x-pack/legacy/plugins/license_management/__jest__/license_status.test.js +++ b/x-pack/plugins/license_management/__jest__/license_status.test.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LicenseStatus } from '../public/np_ready/application/sections/license_dashboard/license_status'; +import { LicenseStatus } from '../public/application/sections/license_dashboard/license_status'; import { createMockLicense, getComponent } from './util'; describe('LicenseStatus component', () => { diff --git a/x-pack/legacy/plugins/license_management/__jest__/request_trial_extension.test.js b/x-pack/plugins/license_management/__jest__/request_trial_extension.test.js similarity index 96% rename from x-pack/legacy/plugins/license_management/__jest__/request_trial_extension.test.js rename to x-pack/plugins/license_management/__jest__/request_trial_extension.test.js index a74a7b16185c6..6d5a9fdd3fb38 100644 --- a/x-pack/legacy/plugins/license_management/__jest__/request_trial_extension.test.js +++ b/x-pack/plugins/license_management/__jest__/request_trial_extension.test.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RequestTrialExtension } from '../public/np_ready/application/sections/license_dashboard/request_trial_extension'; +import { RequestTrialExtension } from '../public/application/sections/license_dashboard/request_trial_extension'; import { createMockLicense, getComponent } from './util'; jest.mock(`@elastic/eui/lib/components/form/form_row/make_id`, () => () => `generated-id`); diff --git a/x-pack/legacy/plugins/license_management/__jest__/revert_to_basic.test.js b/x-pack/plugins/license_management/__jest__/revert_to_basic.test.js similarity index 94% rename from x-pack/legacy/plugins/license_management/__jest__/revert_to_basic.test.js rename to x-pack/plugins/license_management/__jest__/revert_to_basic.test.js index 488279d87ece0..c223c39a8f12c 100644 --- a/x-pack/legacy/plugins/license_management/__jest__/revert_to_basic.test.js +++ b/x-pack/plugins/license_management/__jest__/revert_to_basic.test.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RevertToBasic } from '../public/np_ready/application/sections/license_dashboard/revert_to_basic'; +import { RevertToBasic } from '../public/application/sections/license_dashboard/revert_to_basic'; import { createMockLicense, getComponent } from './util'; jest.mock(`@elastic/eui/lib/components/form/form_row/make_id`, () => () => `generated-id`); diff --git a/x-pack/legacy/plugins/license_management/__jest__/start_trial.test.js b/x-pack/plugins/license_management/__jest__/start_trial.test.js similarity index 96% rename from x-pack/legacy/plugins/license_management/__jest__/start_trial.test.js rename to x-pack/plugins/license_management/__jest__/start_trial.test.js index 5436a51a2632b..5bd005bc1adbd 100644 --- a/x-pack/legacy/plugins/license_management/__jest__/start_trial.test.js +++ b/x-pack/plugins/license_management/__jest__/start_trial.test.js @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { StartTrial } from '../public/np_ready/application/sections/license_dashboard/start_trial'; +import { StartTrial } from '../public/application/sections/license_dashboard/start_trial'; import { createMockLicense, getComponent } from './util'; -jest.mock('ui/new_platform'); + jest.mock(`@elastic/eui/lib/components/form/form_row/make_id`, () => () => `generated-id`); describe('StartTrial component when trial is allowed', () => { diff --git a/x-pack/legacy/plugins/license_management/__jest__/upload_license.test.tsx b/x-pack/plugins/license_management/__jest__/upload_license.test.tsx similarity index 55% rename from x-pack/legacy/plugins/license_management/__jest__/upload_license.test.tsx rename to x-pack/plugins/license_management/__jest__/upload_license.test.tsx index ca9b5b0db9ca1..ad2fbd288e9f4 100644 --- a/x-pack/legacy/plugins/license_management/__jest__/upload_license.test.tsx +++ b/x-pack/plugins/license_management/__jest__/upload_license.test.tsx @@ -4,21 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { httpServiceMock, chromeServiceMock } from '../../../../../src/core/public/mocks'; -import { mountWithIntl } from '../../../../test_utils/enzyme_helpers'; import React from 'react'; import { Provider } from 'react-redux'; - -jest.mock('ui/new_platform'); +import { httpServiceMock } from '../../../../src/core/public/mocks'; +import { mountWithIntl } from '../../../test_utils/enzyme_helpers'; // @ts-ignore -import { uploadLicense } from '../public/np_ready/application/store/actions/upload_license'; +import { uploadLicense } from '../public/application/store/actions/upload_license'; // @ts-ignore -import { licenseManagementStore } from '../public/np_ready/application/store/store'; +import { licenseManagementStore } from '../public/application/store/store'; // @ts-ignore -import { UploadLicense } from '../public/np_ready/application/sections/upload_license'; +import { UploadLicense } from '../public/application/sections/upload_license'; +import { AppContextProvider } from '../public/application/app_context'; import { UPLOAD_LICENSE_EXPIRED, @@ -33,36 +32,43 @@ window.location.reload = () => {}; let store: any = null; let component: any = null; -const services = { - legacy: { - xPackInfo: { + +const appDependencies = { + plugins: { + licensing: { refresh: jest.fn(), - get: () => { - return { license: { type: 'basic' } }; - }, }, - refreshXpack: jest.fn(), }, + docLinks: {}, +}; + +const thunkServices = { http: httpServiceMock.createSetupContract(), - chrome: chromeServiceMock.createStartContract(), history: { replace: jest.fn(), }, + breadcrumbService: { + setBreadcrumbs() {}, + }, + licensing: appDependencies.plugins.licensing, }; describe('UploadLicense', () => { beforeEach(() => { - store = licenseManagementStore({}, services); + store = licenseManagementStore({}, thunkServices); component = ( - - - + + + + + ); + appDependencies.plugins.licensing.refresh.mockResolvedValue({}); }); afterEach(() => { - services.legacy.xPackInfo.refresh.mockReset(); - services.history.replace.mockReset(); + appDependencies.plugins.licensing.refresh.mockReset(); + thunkServices.history.replace.mockReset(); jest.clearAllMocks(); }); @@ -74,46 +80,46 @@ describe('UploadLicense', () => { }); it('should display an error when ES says license is invalid', async () => { - services.http.put.mockResolvedValue(JSON.parse(UPLOAD_LICENSE_INVALID[2])); + thunkServices.http.put.mockResolvedValue(JSON.parse(UPLOAD_LICENSE_INVALID[2])); const rendered = mountWithIntl(component); const invalidLicense = JSON.stringify({ license: { type: 'basic' } }); - await uploadLicense(invalidLicense)(store.dispatch, null, services); + await uploadLicense(invalidLicense)(store.dispatch, null, thunkServices); rendered.update(); expect(rendered).toMatchSnapshot(); }); it('should display an error when ES says license is expired', async () => { - services.http.put.mockResolvedValue(JSON.parse(UPLOAD_LICENSE_EXPIRED[2])); + thunkServices.http.put.mockResolvedValue(JSON.parse(UPLOAD_LICENSE_EXPIRED[2])); const rendered = mountWithIntl(component); const invalidLicense = JSON.stringify({ license: { type: 'basic' } }); - await uploadLicense(invalidLicense)(store.dispatch, null, services); + await uploadLicense(invalidLicense)(store.dispatch, null, thunkServices); rendered.update(); expect(rendered).toMatchSnapshot(); }); it('should display a modal when license requires acknowledgement', async () => { - services.http.put.mockResolvedValue(JSON.parse(UPLOAD_LICENSE_REQUIRES_ACK[2])); + thunkServices.http.put.mockResolvedValue(JSON.parse(UPLOAD_LICENSE_REQUIRES_ACK[2])); const unacknowledgedLicense = JSON.stringify({ license: { type: 'basic' }, }); - await uploadLicense(unacknowledgedLicense, 'trial')(store.dispatch, null, services); + await uploadLicense(unacknowledgedLicense, 'trial')(store.dispatch, null, thunkServices); const rendered = mountWithIntl(component); expect(rendered).toMatchSnapshot(); }); it('should refresh xpack info and navigate to BASE_PATH when ES accepts new license', async () => { - services.http.put.mockResolvedValue(JSON.parse(UPLOAD_LICENSE_SUCCESS[2])); + thunkServices.http.put.mockResolvedValue(JSON.parse(UPLOAD_LICENSE_SUCCESS[2])); const validLicense = JSON.stringify({ license: { type: 'basic' } }); - await uploadLicense(validLicense)(store.dispatch, null, services); - expect(services.legacy.refreshXpack).toHaveBeenCalled(); - expect(services.history.replace).toHaveBeenCalled(); + await uploadLicense(validLicense)(store.dispatch, null, thunkServices); + expect(appDependencies.plugins.licensing.refresh).toHaveBeenCalled(); + expect(thunkServices.history.replace).toHaveBeenCalled(); }); it('should display error when ES returns error', async () => { - services.http.put.mockResolvedValue(JSON.parse(UPLOAD_LICENSE_TLS_NOT_ENABLED[2])); + thunkServices.http.put.mockResolvedValue(JSON.parse(UPLOAD_LICENSE_TLS_NOT_ENABLED[2])); const rendered = mountWithIntl(component); const license = JSON.stringify({ license: { type: 'basic' } }); - await uploadLicense(license)(store.dispatch, null, services); + await uploadLicense(license)(store.dispatch, null, thunkServices); rendered.update(); expect(rendered).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/license_management/__jest__/util/index.js b/x-pack/plugins/license_management/__jest__/util/index.js similarity index 100% rename from x-pack/legacy/plugins/license_management/__jest__/util/index.js rename to x-pack/plugins/license_management/__jest__/util/index.js diff --git a/x-pack/legacy/plugins/license_management/__jest__/util/util.js b/x-pack/plugins/license_management/__jest__/util/util.js similarity index 60% rename from x-pack/legacy/plugins/license_management/__jest__/util/util.js rename to x-pack/plugins/license_management/__jest__/util/util.js index 93b97c51b24da..5a7e49c8c3315 100644 --- a/x-pack/legacy/plugins/license_management/__jest__/util/util.js +++ b/x-pack/plugins/license_management/__jest__/util/util.js @@ -3,15 +3,22 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable @kbn/eslint/no-restricted-paths */ -import { Provider } from 'react-redux'; -import { licenseManagementStore } from '../../public/np_ready/application/store/store'; import React from 'react'; -import { mountWithIntl } from '../../../../../test_utils/enzyme_helpers'; -import { httpServiceMock } from '../../../../../../src/core/public/mocks'; +import { Provider } from 'react-redux'; + +import { mountWithIntl } from '../../../../test_utils/enzyme_helpers'; +import { httpServiceMock } from '../../../../../src/core/public/mocks'; +import { licenseManagementStore } from '../../public/application/store/store'; +import { AppContextProvider } from '../../public/application/app_context'; const highExpirationMillis = new Date('October 13, 2099 00:00:00Z').getTime(); +const appDependencies = { + docLinks: {}, +}; + export const createMockLicense = (type, expiryDateInMillis = highExpirationMillis) => { return { type, @@ -19,14 +26,17 @@ export const createMockLicense = (type, expiryDateInMillis = highExpirationMilli isActive: new Date().getTime() < expiryDateInMillis, }; }; + export const getComponent = (initialState, Component) => { const services = { http: httpServiceMock.createSetupContract(), }; const store = licenseManagementStore(initialState, services); return mountWithIntl( - - - + + + + + ); }; diff --git a/x-pack/legacy/plugins/license_management/__mocks__/focus-trap-react.js b/x-pack/plugins/license_management/__mocks__/focus-trap-react.js similarity index 100% rename from x-pack/legacy/plugins/license_management/__mocks__/focus-trap-react.js rename to x-pack/plugins/license_management/__mocks__/focus-trap-react.js diff --git a/x-pack/legacy/plugins/license_management/common/constants/base_path.ts b/x-pack/plugins/license_management/common/constants/base_path.ts similarity index 87% rename from x-pack/legacy/plugins/license_management/common/constants/base_path.ts rename to x-pack/plugins/license_management/common/constants/base_path.ts index 9b24ab561dba8..7b981ec8727e6 100644 --- a/x-pack/legacy/plugins/license_management/common/constants/base_path.ts +++ b/x-pack/plugins/license_management/common/constants/base_path.ts @@ -5,3 +5,5 @@ */ export const BASE_PATH = '/management/elasticsearch/license_management/'; + +export const API_BASE_PATH = '/api/license'; diff --git a/x-pack/legacy/plugins/license_management/common/constants/external_links.ts b/x-pack/plugins/license_management/common/constants/external_links.ts similarity index 100% rename from x-pack/legacy/plugins/license_management/common/constants/external_links.ts rename to x-pack/plugins/license_management/common/constants/external_links.ts diff --git a/x-pack/legacy/plugins/license_management/common/constants/index.ts b/x-pack/plugins/license_management/common/constants/index.ts similarity index 87% rename from x-pack/legacy/plugins/license_management/common/constants/index.ts rename to x-pack/plugins/license_management/common/constants/index.ts index c115fb7b69c0e..ec411fea4b7a9 100644 --- a/x-pack/legacy/plugins/license_management/common/constants/index.ts +++ b/x-pack/plugins/license_management/common/constants/index.ts @@ -5,6 +5,6 @@ */ export { PLUGIN } from './plugin'; -export { BASE_PATH } from './base_path'; +export { BASE_PATH, API_BASE_PATH } from './base_path'; export { EXTERNAL_LINKS } from './external_links'; export { APP_PERMISSION } from './permissions'; diff --git a/x-pack/legacy/plugins/license_management/common/constants/permissions.ts b/x-pack/plugins/license_management/common/constants/permissions.ts similarity index 100% rename from x-pack/legacy/plugins/license_management/common/constants/permissions.ts rename to x-pack/plugins/license_management/common/constants/permissions.ts diff --git a/x-pack/legacy/plugins/license_management/common/constants/plugin.ts b/x-pack/plugins/license_management/common/constants/plugin.ts similarity index 79% rename from x-pack/legacy/plugins/license_management/common/constants/plugin.ts rename to x-pack/plugins/license_management/common/constants/plugin.ts index 14b591e3834ef..406ac867a77b5 100644 --- a/x-pack/legacy/plugins/license_management/common/constants/plugin.ts +++ b/x-pack/plugins/license_management/common/constants/plugin.ts @@ -6,8 +6,8 @@ import { i18n } from '@kbn/i18n'; export const PLUGIN = { - TITLE: i18n.translate('xpack.licenseMgmt.managementSectionDisplayName', { + title: i18n.translate('xpack.licenseMgmt.managementSectionDisplayName', { defaultMessage: 'License Management', }), - ID: 'license_management', + id: 'license_management', }; diff --git a/x-pack/plugins/license_management/kibana.json b/x-pack/plugins/license_management/kibana.json new file mode 100644 index 0000000000000..be28c8e978d8a --- /dev/null +++ b/x-pack/plugins/license_management/kibana.json @@ -0,0 +1,9 @@ +{ + "id": "licenseManagement", + "version": "kibana", + "server": true, + "ui": true, + "requiredPlugins": ["home", "licensing", "management"], + "optionalPlugins": ["telemetry"], + "configPath": ["xpack", "license_management"] +} diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/_license_management.scss b/x-pack/plugins/license_management/public/application/_license_management.scss similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/_license_management.scss rename to x-pack/plugins/license_management/public/application/_license_management.scss diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/app.container.js b/x-pack/plugins/license_management/public/application/app.container.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/app.container.js rename to x-pack/plugins/license_management/public/application/app.container.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/app.js b/x-pack/plugins/license_management/public/application/app.js similarity index 97% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/app.js rename to x-pack/plugins/license_management/public/application/app.js index 6a6c38fa6abb6..1bc8e9cd563e2 100644 --- a/x-pack/legacy/plugins/license_management/public/np_ready/application/app.js +++ b/x-pack/plugins/license_management/public/application/app.js @@ -8,7 +8,7 @@ import React, { Component } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { LicenseDashboard, UploadLicense } from './sections'; import { Switch, Route } from 'react-router-dom'; -import { APP_PERMISSION, BASE_PATH } from '../../../common/constants'; +import { APP_PERMISSION, BASE_PATH } from '../../common/constants'; import { EuiPageBody, EuiEmptyPrompt, EuiText, EuiLoadingSpinner, EuiCallOut } from '@elastic/eui'; export class App extends Component { diff --git a/x-pack/plugins/license_management/public/application/app_context.tsx b/x-pack/plugins/license_management/public/application/app_context.tsx new file mode 100644 index 0000000000000..1e90f4c907b8c --- /dev/null +++ b/x-pack/plugins/license_management/public/application/app_context.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { createContext, useContext } from 'react'; + +import { CoreStart } from '../../../../../src/core/public'; +import { LicensingPluginSetup, ILicense } from '../../../licensing/public'; +import { TelemetryPluginSetup } from '../../../../../src/plugins/telemetry/public'; +import { ClientConfigType } from '../types'; +import { BreadcrumbService } from './breadcrumbs'; + +const AppContext = createContext(undefined); + +export interface AppDependencies { + core: CoreStart; + services: { + breadcrumbService: BreadcrumbService; + }; + plugins: { + licensing: LicensingPluginSetup; + telemetry?: TelemetryPluginSetup; + }; + docLinks: { + security: string; + }; + store: { + initialLicense: ILicense; + }; + config: ClientConfigType; +} + +export const AppContextProvider = ({ + children, + value, +}: { + value: AppDependencies; + children: React.ReactNode; +}) => { + return {children}; +}; + +export const AppContextConsumer = AppContext.Consumer; + +export const useAppContext = () => { + const ctx = useContext(AppContext); + if (!ctx) { + throw new Error('"useAppContext" can only be called inside of AppContext.Provider!'); + } + return ctx; +}; diff --git a/x-pack/plugins/license_management/public/application/app_providers.tsx b/x-pack/plugins/license_management/public/application/app_providers.tsx new file mode 100644 index 0000000000000..9f9fd2a8275df --- /dev/null +++ b/x-pack/plugins/license_management/public/application/app_providers.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import * as history from 'history'; +import { Provider } from 'react-redux'; + +import { BASE_PATH } from '../../common/constants'; +import { AppContextProvider, AppDependencies } from './app_context'; +// @ts-ignore +import { licenseManagementStore } from './store'; + +interface Props { + appDependencies: AppDependencies; + children: React.ReactNode; +} + +export const AppProviders = ({ appDependencies, children }: Props) => { + const { + core, + plugins, + services, + store: { initialLicense }, + } = appDependencies; + + const { + http, + notifications: { toasts }, + i18n: { Context: I18nContext }, + } = core; + + // Setup Redux store + const thunkServices = { + // So we can imperatively control the hash route + history: history.createHashHistory({ basename: BASE_PATH }), + toasts, + http, + telemetry: plugins.telemetry, + licensing: plugins.licensing, + breadcrumbService: services.breadcrumbService, + }; + const initialState = { license: initialLicense }; + + const store = licenseManagementStore(initialState, thunkServices); + + return ( + + + {children} + + + ); +}; diff --git a/x-pack/plugins/license_management/public/application/breadcrumbs.ts b/x-pack/plugins/license_management/public/application/breadcrumbs.ts new file mode 100644 index 0000000000000..b1773a10f01ba --- /dev/null +++ b/x-pack/plugins/license_management/public/application/breadcrumbs.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +import { ManagementAppMountParams } from '../../../../../src/plugins/management/public'; +import { BASE_PATH } from '../../common/constants'; + +type SetBreadcrumbs = ManagementAppMountParams['setBreadcrumbs']; + +export class BreadcrumbService { + private breadcrumbs: { + [key: string]: Array<{ + text: string; + href?: string; + }>; + } = { + dashboard: [], + upload: [], + }; + private setBreadcrumbsHandler?: SetBreadcrumbs; + + public setup(setBreadcrumbsHandler: SetBreadcrumbs): void { + this.setBreadcrumbsHandler = setBreadcrumbsHandler; + + // Home and sections + this.breadcrumbs.dashboard = [ + { + text: i18n.translate('xpack.licenseMgmt.dashboard.breadcrumb', { + defaultMessage: 'License management', + }), + href: `#${BASE_PATH}home`, + }, + ]; + + this.breadcrumbs.upload = [ + ...this.breadcrumbs.dashboard, + { + text: i18n.translate('xpack.licenseMgmt.upload.breadcrumb', { + defaultMessage: 'Upload', + }), + }, + ]; + } + + public setBreadcrumbs(type: 'dashboard' | 'upload'): void { + if (!this.setBreadcrumbsHandler) { + throw new Error(`BreadcrumbService#setup() must be called first!`); + } + + const newBreadcrumbs = this.breadcrumbs[type] + ? [...this.breadcrumbs[type]] + : [...this.breadcrumbs.home]; + + // Pop off last breadcrumb + const lastBreadcrumb = newBreadcrumbs.pop() as { + text: string; + href?: string; + }; + + // Put last breadcrumb back without href + newBreadcrumbs.push({ + ...lastBreadcrumb, + href: undefined, + }); + + this.setBreadcrumbsHandler(newBreadcrumbs); + } +} diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/components/telemetry_opt_in/index.ts b/x-pack/plugins/license_management/public/application/components/telemetry_opt_in/index.ts similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/components/telemetry_opt_in/index.ts rename to x-pack/plugins/license_management/public/application/components/telemetry_opt_in/index.ts diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/components/telemetry_opt_in/telemetry_opt_in.tsx b/x-pack/plugins/license_management/public/application/components/telemetry_opt_in/telemetry_opt_in.tsx similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/components/telemetry_opt_in/telemetry_opt_in.tsx rename to x-pack/plugins/license_management/public/application/components/telemetry_opt_in/telemetry_opt_in.tsx diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/index.scss b/x-pack/plugins/license_management/public/application/index.scss similarity index 63% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/index.scss rename to x-pack/plugins/license_management/public/application/index.scss index 4fb8aafcca93c..92150eea40219 100644 --- a/x-pack/legacy/plugins/license_management/public/np_ready/application/index.scss +++ b/x-pack/plugins/license_management/public/application/index.scss @@ -1,7 +1,4 @@ -// EUI globals -@import 'src/legacy/ui/public/styles/styling_constants'; - -// License amnagement plugin styles +// License management plugin styles // Prefix all styles with "lic" to avoid conflicts. // Examples diff --git a/x-pack/plugins/license_management/public/application/index.tsx b/x-pack/plugins/license_management/public/application/index.tsx new file mode 100644 index 0000000000000..75f2f98f51e6e --- /dev/null +++ b/x-pack/plugins/license_management/public/application/index.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { HashRouter } from 'react-router-dom'; + +import { AppDependencies } from './app_context'; +import { AppProviders } from './app_providers'; +// @ts-ignore +import { App } from './app.container'; + +const AppWithRouter = (props: { [key: string]: any }) => ( + + + +); + +export const renderApp = (element: Element, dependencies: AppDependencies) => { + render( + + + , + element + ); + + return () => { + unmountComponentAtNode(element); + }; +}; + +export { AppDependencies }; diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/lib/es.ts b/x-pack/plugins/license_management/public/application/lib/es.ts similarity index 79% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/lib/es.ts rename to x-pack/plugins/license_management/public/application/lib/es.ts index 3924de2202d51..52df5c2509226 100644 --- a/x-pack/legacy/plugins/license_management/public/np_ready/application/lib/es.ts +++ b/x-pack/plugins/license_management/public/application/lib/es.ts @@ -5,11 +5,10 @@ */ import { HttpSetup } from 'src/core/public'; - -const BASE_PATH = '/api/license'; +import { API_BASE_PATH } from '../../../common/constants'; export function putLicense(http: HttpSetup, license: string, acknowledge: boolean) { - return http.put(BASE_PATH, { + return http.put(API_BASE_PATH, { query: { acknowledge: acknowledge ? 'true' : '', }, @@ -22,7 +21,7 @@ export function putLicense(http: HttpSetup, license: string, acknowledge: boolea } export function startBasic(http: HttpSetup, acknowledge: boolean) { - return http.post(`${BASE_PATH}/start_basic`, { + return http.post(`${API_BASE_PATH}/start_basic`, { query: { acknowledge: acknowledge ? 'true' : '', }, @@ -35,7 +34,7 @@ export function startBasic(http: HttpSetup, acknowledge: boolean) { } export function startTrial(http: HttpSetup) { - return http.post(`${BASE_PATH}/start_trial`, { + return http.post(`${API_BASE_PATH}/start_trial`, { headers: { contentType: 'application/json', }, @@ -44,7 +43,7 @@ export function startTrial(http: HttpSetup) { } export function canStartTrial(http: HttpSetup) { - return http.get(`${BASE_PATH}/start_trial`, { + return http.get(`${API_BASE_PATH}/start_trial`, { headers: { contentType: 'application/json', }, @@ -53,7 +52,7 @@ export function canStartTrial(http: HttpSetup) { } export function getPermissions(http: HttpSetup) { - return http.post(`${BASE_PATH}/permissions`, { + return http.post(`${API_BASE_PATH}/permissions`, { headers: { contentType: 'application/json', }, diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/lib/telemetry.ts b/x-pack/plugins/license_management/public/application/lib/telemetry.ts similarity index 69% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/lib/telemetry.ts rename to x-pack/plugins/license_management/public/application/lib/telemetry.ts index 9cc4ec5978fdc..1d90fce6f6b9a 100644 --- a/x-pack/legacy/plugins/license_management/public/np_ready/application/lib/telemetry.ts +++ b/x-pack/plugins/license_management/public/application/lib/telemetry.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TelemetryPluginSetup } from '../../../../../../../../src/plugins/telemetry/public'; +import { TelemetryPluginSetup } from '../../../../../../src/plugins/telemetry/public'; -export { OptInExampleFlyout } from '../../../../../../../../src/plugins/telemetry/public/components'; -export { PRIVACY_STATEMENT_URL } from '../../../../../../../../src/plugins/telemetry/common/constants'; +export { OptInExampleFlyout } from '../../../../../../src/plugins/telemetry/public/components'; +export { PRIVACY_STATEMENT_URL } from '../../../../../../src/plugins/telemetry/common/constants'; export { TelemetryPluginSetup, shouldShowTelemetryOptIn }; function shouldShowTelemetryOptIn( diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/index.js b/x-pack/plugins/license_management/public/application/sections/index.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/index.js rename to x-pack/plugins/license_management/public/application/sections/index.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/add_license/add_license.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/add_license/add_license.js similarity index 94% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/add_license/add_license.js rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/add_license/add_license.js index d2f44bfc701f7..158702e1286ae 100644 --- a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/add_license/add_license.js +++ b/x-pack/plugins/license_management/public/application/sections/license_dashboard/add_license/add_license.js @@ -5,7 +5,7 @@ */ import React from 'react'; -import { BASE_PATH } from '../../../../../../common/constants'; +import { BASE_PATH } from '../../../../../common/constants'; import { EuiCard, EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/add_license/index.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/add_license/index.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/add_license/index.js rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/add_license/index.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/index.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/index.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/index.js rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/index.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/license_dashboard.container.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_dashboard.container.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/license_dashboard.container.js rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/license_dashboard.container.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/license_dashboard.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_dashboard.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/license_dashboard.js rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/license_dashboard.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/license_status/index.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/index.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/license_status/index.js rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/index.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/license_status/license_status.container.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.container.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/license_status/license_status.container.js rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.container.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/license_status/license_status.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/license_status/license_status.js rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/license_status/license_status.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/request_trial_extension/index.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/index.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/request_trial_extension/index.js rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/index.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/request_trial_extension/request_trial_extension.container.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/request_trial_extension.container.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/request_trial_extension/request_trial_extension.container.js rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/request_trial_extension.container.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/request_trial_extension/request_trial_extension.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/request_trial_extension.js similarity index 96% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/request_trial_extension/request_trial_extension.js rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/request_trial_extension.js index fae454cbaac50..fb1ea026abaa0 100644 --- a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/request_trial_extension/request_trial_extension.js +++ b/x-pack/plugins/license_management/public/application/sections/license_dashboard/request_trial_extension/request_trial_extension.js @@ -8,7 +8,7 @@ import React from 'react'; import { EuiFlexItem, EuiCard, EuiLink, EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EXTERNAL_LINKS } from '../../../../../../common/constants'; +import { EXTERNAL_LINKS } from '../../../../../common/constants'; export const RequestTrialExtension = ({ shouldShowRequestTrialExtension }) => { if (!shouldShowRequestTrialExtension) { diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/revert_to_basic/index.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/revert_to_basic/index.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/revert_to_basic/index.js rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/revert_to_basic/index.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/revert_to_basic/revert_to_basic.container.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/revert_to_basic/revert_to_basic.container.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/revert_to_basic/revert_to_basic.container.js rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/revert_to_basic/revert_to_basic.container.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/revert_to_basic/revert_to_basic.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/revert_to_basic/revert_to_basic.js similarity index 98% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/revert_to_basic/revert_to_basic.js rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/revert_to_basic/revert_to_basic.js index 9115e82833ee7..2424e336fe6e6 100644 --- a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/revert_to_basic/revert_to_basic.js +++ b/x-pack/plugins/license_management/public/application/sections/license_dashboard/revert_to_basic/revert_to_basic.js @@ -16,7 +16,7 @@ import { EuiText, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EXTERNAL_LINKS } from '../../../../../../common/constants'; +import { EXTERNAL_LINKS } from '../../../../../common/constants'; export class RevertToBasic extends React.PureComponent { cancel = () => { diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/start_trial/index.ts b/x-pack/plugins/license_management/public/application/sections/license_dashboard/start_trial/index.ts similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/start_trial/index.ts rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/start_trial/index.ts diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/start_trial/start_trial.container.js b/x-pack/plugins/license_management/public/application/sections/license_dashboard/start_trial/start_trial.container.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/start_trial/start_trial.container.js rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/start_trial/start_trial.container.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/start_trial/start_trial.tsx b/x-pack/plugins/license_management/public/application/sections/license_dashboard/start_trial/start_trial.tsx similarity index 92% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/start_trial/start_trial.tsx rename to x-pack/plugins/license_management/public/application/sections/license_dashboard/start_trial/start_trial.tsx index e0f8ade8e45da..25cbfb7242239 100644 --- a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/license_dashboard/start_trial/start_trial.tsx +++ b/x-pack/plugins/license_management/public/application/sections/license_dashboard/start_trial/start_trial.tsx @@ -24,8 +24,8 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { TelemetryOptIn } from '../../../components/telemetry_opt_in'; -import { EXTERNAL_LINKS } from '../../../../../../common/constants'; -import { getDocLinks } from '../../../lib/docs_links'; +import { EXTERNAL_LINKS } from '../../../../../common/constants'; +import { AppContextConsumer, AppDependencies } from '../../../app_context'; import { TelemetryPluginSetup, shouldShowTelemetryOptIn } from '../../../lib/telemetry'; interface Props { @@ -68,7 +68,7 @@ export class StartTrial extends Component { cancel = () => { this.setState({ showConfirmation: false }); }; - acknowledgeModal() { + acknowledgeModal(docLinks: AppDependencies['docLinks']) { const { showConfirmation, isOptingInToTelemetry } = this.state; const { telemetry } = this.props; @@ -148,7 +148,7 @@ export class StartTrial extends Component { values={{ authenticationTypeList: 'AD/LDAP, SAML, PKI, SAML/SSO', securityDocumentationLinkText: ( - + { ); return ( - - {this.acknowledgeModal()} - + {dependencies => ( + + {this.acknowledgeModal(dependencies!.docLinks)} + + } + description={description} + footer={footer} /> - } - description={description} - footer={footer} - /> - + + )} + ); } } diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/upload_license/index.js b/x-pack/plugins/license_management/public/application/sections/upload_license/index.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/upload_license/index.js rename to x-pack/plugins/license_management/public/application/sections/upload_license/index.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/upload_license/upload_license.container.js b/x-pack/plugins/license_management/public/application/sections/upload_license/upload_license.container.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/upload_license/upload_license.container.js rename to x-pack/plugins/license_management/public/application/sections/upload_license/upload_license.container.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/upload_license/upload_license.js b/x-pack/plugins/license_management/public/application/sections/upload_license/upload_license.js similarity index 99% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/sections/upload_license/upload_license.js rename to x-pack/plugins/license_management/public/application/sections/upload_license/upload_license.js index e8dd9495a8c2d..49f2474f83911 100644 --- a/x-pack/legacy/plugins/license_management/public/np_ready/application/sections/upload_license/upload_license.js +++ b/x-pack/plugins/license_management/public/application/sections/upload_license/upload_license.js @@ -5,7 +5,7 @@ */ import React, { Fragment } from 'react'; -import { BASE_PATH } from '../../../../../common/constants'; +import { BASE_PATH } from '../../../../common/constants'; import { EuiButton, EuiButtonEmpty, diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/add_error_message.js b/x-pack/plugins/license_management/public/application/store/actions/add_error_message.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/add_error_message.js rename to x-pack/plugins/license_management/public/application/store/actions/add_error_message.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/add_license.js b/x-pack/plugins/license_management/public/application/store/actions/add_license.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/add_license.js rename to x-pack/plugins/license_management/public/application/store/actions/add_license.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/index.js b/x-pack/plugins/license_management/public/application/store/actions/index.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/index.js rename to x-pack/plugins/license_management/public/application/store/actions/index.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/permissions.js b/x-pack/plugins/license_management/public/application/store/actions/permissions.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/permissions.js rename to x-pack/plugins/license_management/public/application/store/actions/permissions.js diff --git a/x-pack/plugins/license_management/public/application/store/actions/set_breadcrumb.ts b/x-pack/plugins/license_management/public/application/store/actions/set_breadcrumb.ts new file mode 100644 index 0000000000000..2c6a726203bc1 --- /dev/null +++ b/x-pack/plugins/license_management/public/application/store/actions/set_breadcrumb.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ThunkAction } from 'redux-thunk'; +import { BreadcrumbService } from '../../breadcrumbs'; + +export const setBreadcrumb = ( + section: 'dashboard' | 'upload' +): ThunkAction => ( + dispatch, + getState, + { breadcrumbService } +) => { + breadcrumbService.setBreadcrumbs(section); +}; diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/start_basic.js b/x-pack/plugins/license_management/public/application/store/actions/start_basic.js similarity index 95% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/start_basic.js rename to x-pack/plugins/license_management/public/application/store/actions/start_basic.js index 5bc9e8fad07be..93c722c1f8968 100644 --- a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/start_basic.js +++ b/x-pack/plugins/license_management/public/application/store/actions/start_basic.js @@ -19,7 +19,7 @@ export const cancelStartBasicLicense = createAction( export const startBasicLicense = (currentLicenseType, ack) => async ( dispatch, getState, - { legacy: { refreshXpack }, toasts, http } + { licensing, toasts, http } ) => { /*eslint camelcase: 0*/ const { acknowledged, basic_was_started, error_message, acknowledge } = await startBasic( @@ -28,7 +28,7 @@ export const startBasicLicense = (currentLicenseType, ack) => async ( ); if (acknowledged) { if (basic_was_started) { - await refreshXpack(); + await licensing.refresh(); // reload necessary to get left nav to refresh with proper links window.location.reload(); } else { diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/start_trial.js b/x-pack/plugins/license_management/public/application/store/actions/start_trial.js similarity index 85% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/start_trial.js rename to x-pack/plugins/license_management/public/application/store/actions/start_trial.js index c8ec538e846ec..3bae271b213c0 100644 --- a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/start_trial.js +++ b/x-pack/plugins/license_management/public/application/store/actions/start_trial.js @@ -14,15 +14,11 @@ export const loadTrialStatus = () => async (dispatch, getState, { http }) => { dispatch(trialStatusLoaded(trialOK)); }; -export const startLicenseTrial = () => async ( - dispatch, - getState, - { legacy: { refreshXpack }, toasts, http } -) => { +export const startLicenseTrial = () => async (dispatch, getState, { licensing, toasts, http }) => { /*eslint camelcase: 0*/ const { trial_was_started, error_message } = await startTrial(http); if (trial_was_started) { - await refreshXpack(); + await licensing.refresh(); // reload necessary to get left nav to refresh with proper links window.location.reload(); } else { diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/upload_license.js b/x-pack/plugins/license_management/public/application/store/actions/upload_license.js similarity index 96% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/upload_license.js rename to x-pack/plugins/license_management/public/application/store/actions/upload_license.js index 51b3af2b6308f..376a22d3d1efa 100644 --- a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/actions/upload_license.js +++ b/x-pack/plugins/license_management/public/application/store/actions/upload_license.js @@ -24,7 +24,7 @@ const dispatchFromResponse = async ( dispatch, currentLicenseType, newLicenseType, - { history, legacy: { xPackInfo, refreshXpack } } + { history, licensing } ) => { const { error, acknowledged, license_status: licenseStatus, acknowledge } = response; if (error) { @@ -50,8 +50,8 @@ const dispatchFromResponse = async ( ) ); } else { - await refreshXpack(); - dispatch(addLicense(xPackInfo.get('license'))); + const updatedLicense = await licensing.refresh(); + dispatch(addLicense(updatedLicense)); dispatch(uploadLicenseStatus({})); history.replace('/home'); // reload necessary to get left nav to refresh with proper links diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/index.js b/x-pack/plugins/license_management/public/application/store/index.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/store/index.js rename to x-pack/plugins/license_management/public/application/store/index.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/reducers/index.js b/x-pack/plugins/license_management/public/application/store/reducers/index.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/store/reducers/index.js rename to x-pack/plugins/license_management/public/application/store/reducers/index.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/reducers/license.js b/x-pack/plugins/license_management/public/application/store/reducers/license.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/store/reducers/license.js rename to x-pack/plugins/license_management/public/application/store/reducers/license.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/reducers/license_management.js b/x-pack/plugins/license_management/public/application/store/reducers/license_management.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/store/reducers/license_management.js rename to x-pack/plugins/license_management/public/application/store/reducers/license_management.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/reducers/permissions.js b/x-pack/plugins/license_management/public/application/store/reducers/permissions.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/store/reducers/permissions.js rename to x-pack/plugins/license_management/public/application/store/reducers/permissions.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/reducers/start_basic_license_status.js b/x-pack/plugins/license_management/public/application/store/reducers/start_basic_license_status.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/store/reducers/start_basic_license_status.js rename to x-pack/plugins/license_management/public/application/store/reducers/start_basic_license_status.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/reducers/trial_status.js b/x-pack/plugins/license_management/public/application/store/reducers/trial_status.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/store/reducers/trial_status.js rename to x-pack/plugins/license_management/public/application/store/reducers/trial_status.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/reducers/upload_error_message.js b/x-pack/plugins/license_management/public/application/store/reducers/upload_error_message.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/store/reducers/upload_error_message.js rename to x-pack/plugins/license_management/public/application/store/reducers/upload_error_message.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/reducers/upload_status.js b/x-pack/plugins/license_management/public/application/store/reducers/upload_status.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/store/reducers/upload_status.js rename to x-pack/plugins/license_management/public/application/store/reducers/upload_status.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/application/store/store.js b/x-pack/plugins/license_management/public/application/store/store.js similarity index 100% rename from x-pack/legacy/plugins/license_management/public/np_ready/application/store/store.js rename to x-pack/plugins/license_management/public/application/store/store.js diff --git a/x-pack/legacy/plugins/license_management/public/np_ready/index.ts b/x-pack/plugins/license_management/public/index.ts similarity index 86% rename from x-pack/legacy/plugins/license_management/public/np_ready/index.ts rename to x-pack/plugins/license_management/public/index.ts index 59e2f02d8cb52..3c76549ebdc16 100644 --- a/x-pack/legacy/plugins/license_management/public/np_ready/index.ts +++ b/x-pack/plugins/license_management/public/index.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import { PluginInitializerContext } from 'src/core/public'; + import { LicenseManagementUIPlugin } from './plugin'; +import './application/index.scss'; -export const plugin = (ctx: PluginInitializerContext) => new LicenseManagementUIPlugin(); +export const plugin = (ctx: PluginInitializerContext) => new LicenseManagementUIPlugin(ctx); diff --git a/x-pack/plugins/license_management/public/plugin.ts b/x-pack/plugins/license_management/public/plugin.ts new file mode 100644 index 0000000000000..00d353bc97e04 --- /dev/null +++ b/x-pack/plugins/license_management/public/plugin.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { first } from 'rxjs/operators'; +import { CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; + +import { TelemetryPluginSetup } from '../../../../src/plugins/telemetry/public'; +import { ManagementSetup } from '../../../../src/plugins/management/public'; +import { LicensingPluginSetup } from '../../../plugins/licensing/public'; +import { PLUGIN } from '../common/constants'; +import { ClientConfigType } from './types'; +import { AppDependencies } from './application'; +import { BreadcrumbService } from './application/breadcrumbs'; + +interface PluginsDependencies { + management: ManagementSetup; + licensing: LicensingPluginSetup; + telemetry?: TelemetryPluginSetup; +} + +export class LicenseManagementUIPlugin implements Plugin { + private breadcrumbService = new BreadcrumbService(); + + constructor(private readonly initializerContext: PluginInitializerContext) {} + + setup(coreSetup: CoreSetup, plugins: PluginsDependencies) { + const config = this.initializerContext.config.get(); + + if (!config.ui.enabled) { + // No need to go any further + return; + } + + const { getStartServices } = coreSetup; + const { management, telemetry, licensing } = plugins; + + management.sections.getSection('elasticsearch')!.registerApp({ + id: PLUGIN.id, + title: PLUGIN.title, + order: 99, + mount: async ({ element, setBreadcrumbs }) => { + const [core] = await getStartServices(); + const initialLicense = await plugins.licensing.license$.pipe(first()).toPromise(); + + // Setup documentation links + const { docLinks } = core; + const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks; + const esBase = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; + const appDocLinks = { + security: `${esBase}/security-settings.html`, + }; + + // Setup services + this.breadcrumbService.setup(setBreadcrumbs); + + const appDependencies: AppDependencies = { + core, + config, + plugins: { + licensing, + telemetry, + }, + services: { + breadcrumbService: this.breadcrumbService, + }, + store: { + initialLicense, + }, + docLinks: appDocLinks, + }; + + const { renderApp } = await import('./application'); + + return renderApp(element, appDependencies); + }, + }); + } + + start() {} + stop() {} +} diff --git a/x-pack/plugins/infra/server/graphql/log_entries/index.ts b/x-pack/plugins/license_management/public/types.ts similarity index 78% rename from x-pack/plugins/infra/server/graphql/log_entries/index.ts rename to x-pack/plugins/license_management/public/types.ts index 21134862663ec..4213203bf42cc 100644 --- a/x-pack/plugins/infra/server/graphql/log_entries/index.ts +++ b/x-pack/plugins/license_management/public/types.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export { createLogEntriesResolvers } from './resolvers'; +export interface ClientConfigType { + ui: { enabled: boolean }; +} diff --git a/x-pack/plugins/license_management/server/config.ts b/x-pack/plugins/license_management/server/config.ts new file mode 100644 index 0000000000000..9bc39204a7c31 --- /dev/null +++ b/x-pack/plugins/license_management/server/config.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), +}); + +export type LicenseManagementConfig = TypeOf; diff --git a/x-pack/plugins/license_management/server/index.ts b/x-pack/plugins/license_management/server/index.ts new file mode 100644 index 0000000000000..b378fffbce7e7 --- /dev/null +++ b/x-pack/plugins/license_management/server/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server'; + +import { LicenseManagementServerPlugin } from './plugin'; +import { configSchema, LicenseManagementConfig } from './config'; + +export const plugin = (ctx: PluginInitializerContext) => new LicenseManagementServerPlugin(); + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToBrowser: { + ui: true, + }, +}; diff --git a/x-pack/plugins/license_management/server/lib/is_es_error.ts b/x-pack/plugins/license_management/server/lib/is_es_error.ts new file mode 100644 index 0000000000000..4137293cf39c0 --- /dev/null +++ b/x-pack/plugins/license_management/server/lib/is_es_error.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as legacyElasticsearch from 'elasticsearch'; + +const esErrorsParent = legacyElasticsearch.errors._Abstract; + +export function isEsError(err: Error) { + return err instanceof esErrorsParent; +} diff --git a/x-pack/legacy/plugins/license_management/server/np_ready/lib/license.ts b/x-pack/plugins/license_management/server/lib/license.ts similarity index 50% rename from x-pack/legacy/plugins/license_management/server/np_ready/lib/license.ts rename to x-pack/plugins/license_management/server/lib/license.ts index b52c9d50170b9..d36365eb62a7e 100644 --- a/x-pack/legacy/plugins/license_management/server/np_ready/lib/license.ts +++ b/x-pack/plugins/license_management/server/lib/license.ts @@ -3,29 +3,39 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest } from 'src/core/server'; -import { ElasticsearchPlugin } from '../../../../../../../src/legacy/core_plugins/elasticsearch'; +import { LicensingPluginSetup } from '../../../licensing/server'; +import { CallAsCurrentUser } from '../types'; + const getLicensePath = (acknowledge: boolean) => `/_license${acknowledge ? '?acknowledge=true' : ''}`; -export async function putLicense( - req: KibanaRequest, - elasticsearch: ElasticsearchPlugin, - xpackInfo: any -) { - const { acknowledge } = req.query; - const { callWithRequest } = elasticsearch.getCluster('admin'); +interface PutLicenseArg { + acknowledge: boolean; + callAsCurrentUser: CallAsCurrentUser; + licensing: LicensingPluginSetup; + license: { [key: string]: any }; +} + +export async function putLicense({ + acknowledge, + callAsCurrentUser, + licensing, + license, +}: PutLicenseArg) { const options = { method: 'POST', - path: getLicensePath(Boolean(acknowledge)), - body: req.body, + path: getLicensePath(acknowledge), + body: license, }; + try { - const response = await callWithRequest(req as any, 'transport.request', options); + const response = await callAsCurrentUser('transport.request', options); const { acknowledged, license_status: licenseStatus } = response; + if (acknowledged && licenseStatus === 'valid') { - await xpackInfo.refreshNow(); + await licensing.refresh(); } + return response; } catch (error) { return error.body; diff --git a/x-pack/legacy/plugins/license_management/server/np_ready/lib/permissions.ts b/x-pack/plugins/license_management/server/lib/permissions.ts similarity index 53% rename from x-pack/legacy/plugins/license_management/server/np_ready/lib/permissions.ts rename to x-pack/plugins/license_management/server/lib/permissions.ts index 84cd92821797f..a1ecc2e7b4034 100644 --- a/x-pack/legacy/plugins/license_management/server/np_ready/lib/permissions.ts +++ b/x-pack/plugins/license_management/server/lib/permissions.ts @@ -4,23 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest } from 'src/core/server'; -import { ElasticsearchPlugin } from '../../../../../../../src/legacy/core_plugins/elasticsearch'; +import { CallAsCurrentUser } from '../types'; -export async function getPermissions( - req: KibanaRequest, - elasticsearch: ElasticsearchPlugin, - xpackInfo: any -) { - const securityInfo = xpackInfo && xpackInfo.isAvailable() && xpackInfo.feature('security'); - if (!securityInfo || !securityInfo.isAvailable() || !securityInfo.isEnabled()) { +interface GetPermissionsArg { + isSecurityEnabled: boolean; + callAsCurrentUser: CallAsCurrentUser; +} + +export async function getPermissions({ isSecurityEnabled, callAsCurrentUser }: GetPermissionsArg) { + if (!isSecurityEnabled) { // If security isn't enabled, let the user use license management return { hasPermission: true, }; } - const { callWithRequest } = elasticsearch.getCluster('admin'); const options = { method: 'POST', path: '/_security/user/_has_privileges', @@ -30,7 +28,7 @@ export async function getPermissions( }; try { - const response = await callWithRequest(req as any, 'transport.request', options); + const response = await callAsCurrentUser('transport.request', options); return { hasPermission: response.cluster.manage, }; diff --git a/x-pack/legacy/plugins/license_management/server/np_ready/lib/start_basic.ts b/x-pack/plugins/license_management/server/lib/start_basic.ts similarity index 50% rename from x-pack/legacy/plugins/license_management/server/np_ready/lib/start_basic.ts rename to x-pack/plugins/license_management/server/lib/start_basic.ts index ba042be132d68..d48192c6ca32e 100644 --- a/x-pack/legacy/plugins/license_management/server/np_ready/lib/start_basic.ts +++ b/x-pack/plugins/license_management/server/lib/start_basic.ts @@ -3,29 +3,28 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { KibanaRequest } from 'kibana/server'; -import { ElasticsearchPlugin } from '../../../../../../../src/legacy/core_plugins/elasticsearch'; +import { LicensingPluginSetup } from '../../../licensing/server'; +import { CallAsCurrentUser } from '../types'; const getStartBasicPath = (acknowledge: boolean) => `/_license/start_basic${acknowledge ? '?acknowledge=true' : ''}`; -export async function startBasic( - req: KibanaRequest, - elasticsearch: ElasticsearchPlugin, - xpackInfo: any -) { - const { acknowledge } = req.query; - const { callWithRequest } = elasticsearch.getCluster('admin'); +interface StartBasicArg { + acknowledge: boolean; + callAsCurrentUser: CallAsCurrentUser; + licensing: LicensingPluginSetup; +} + +export async function startBasic({ acknowledge, callAsCurrentUser, licensing }: StartBasicArg) { const options = { method: 'POST', - path: getStartBasicPath(Boolean(acknowledge)), + path: getStartBasicPath(acknowledge), }; try { - const response = await callWithRequest(req as any, 'transport.request', options); + const response = await callAsCurrentUser('transport.request', options); const { basic_was_started: basicWasStarted } = response; if (basicWasStarted) { - await xpackInfo.refreshNow(); + await licensing.refresh(); } return response; } catch (error) { diff --git a/x-pack/plugins/license_management/server/lib/start_trial.ts b/x-pack/plugins/license_management/server/lib/start_trial.ts new file mode 100644 index 0000000000000..d3e2ba37ec203 --- /dev/null +++ b/x-pack/plugins/license_management/server/lib/start_trial.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LicensingPluginSetup } from '../../../licensing/server'; +import { CallAsCurrentUser } from '../types'; + +export async function canStartTrial(callAsCurrentUser: CallAsCurrentUser) { + const options = { + method: 'GET', + path: '/_license/trial_status', + }; + try { + const response = await callAsCurrentUser('transport.request', options); + return response.eligible_to_start_trial; + } catch (error) { + return error.body; + } +} + +interface StartTrialArg { + callAsCurrentUser: CallAsCurrentUser; + licensing: LicensingPluginSetup; +} + +export async function startTrial({ callAsCurrentUser, licensing }: StartTrialArg) { + const options = { + method: 'POST', + path: '/_license/start_trial?acknowledge=true', + }; + try { + const response = await callAsCurrentUser('transport.request', options); + const { trial_was_started: trialWasStarted } = response; + + if (trialWasStarted) { + await licensing.refresh(); + } + + return response; + } catch (error) { + return error.body; + } +} diff --git a/x-pack/plugins/license_management/server/plugin.ts b/x-pack/plugins/license_management/server/plugin.ts new file mode 100644 index 0000000000000..9546f5b1ef88a --- /dev/null +++ b/x-pack/plugins/license_management/server/plugin.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Plugin, CoreSetup } from 'kibana/server'; + +import { ApiRoutes } from './routes'; +import { isEsError } from './lib/is_es_error'; +import { Dependencies } from './types'; + +export class LicenseManagementServerPlugin implements Plugin { + private readonly apiRoutes = new ApiRoutes(); + + setup({ http }: CoreSetup, { licensing, security }: Dependencies) { + const router = http.createRouter(); + + this.apiRoutes.setup({ + router, + plugins: { + licensing, + }, + lib: { + isEsError, + }, + config: { + isSecurityEnabled: security !== undefined, + }, + }); + } + + start() {} + stop() {} +} diff --git a/x-pack/legacy/plugins/license_management/server/np_ready/routes/api/license/index.ts b/x-pack/plugins/license_management/server/routes/api/license/index.ts similarity index 100% rename from x-pack/legacy/plugins/license_management/server/np_ready/routes/api/license/index.ts rename to x-pack/plugins/license_management/server/routes/api/license/index.ts diff --git a/x-pack/legacy/plugins/license_management/server/np_ready/routes/api/license/register_license_route.ts b/x-pack/plugins/license_management/server/routes/api/license/register_license_route.ts similarity index 50% rename from x-pack/legacy/plugins/license_management/server/np_ready/routes/api/license/register_license_route.ts rename to x-pack/plugins/license_management/server/routes/api/license/register_license_route.ts index 03ec583a34166..0f426764f68ee 100644 --- a/x-pack/legacy/plugins/license_management/server/np_ready/routes/api/license/register_license_route.ts +++ b/x-pack/plugins/license_management/server/routes/api/license/register_license_route.ts @@ -6,12 +6,13 @@ import { schema } from '@kbn/config-schema'; import { putLicense } from '../../../lib/license'; -import { Legacy, Server } from '../../../types'; +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../../helpers'; -export function registerLicenseRoute(server: Server, legacy: Legacy, xpackInfo: any) { - server.router.put( +export function registerLicenseRoute({ router, plugins: { licensing } }: RouteDependencies) { + router.put( { - path: '/api/license', + path: addBasePath(''), validate: { query: schema.object({ acknowledge: schema.string() }), body: schema.object({ @@ -19,13 +20,19 @@ export function registerLicenseRoute(server: Server, legacy: Legacy, xpackInfo: }), }, }, - async (ctx, request, response) => { + async (ctx, req, res) => { + const { callAsCurrentUser } = ctx.core.elasticsearch.adminClient; try { - return response.ok({ - body: await putLicense(request, legacy.plugins.elasticsearch, xpackInfo), + return res.ok({ + body: await putLicense({ + acknowledge: Boolean(req.query.acknowledge), + callAsCurrentUser, + licensing, + license: req.body, + }), }); } catch (e) { - return response.internalError({ body: e }); + return res.internalError({ body: e }); } } ); diff --git a/x-pack/plugins/license_management/server/routes/api/license/register_permissions_route.ts b/x-pack/plugins/license_management/server/routes/api/license/register_permissions_route.ts new file mode 100644 index 0000000000000..7aa3c4733acfd --- /dev/null +++ b/x-pack/plugins/license_management/server/routes/api/license/register_permissions_route.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getPermissions } from '../../../lib/permissions'; +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../../helpers'; + +export function registerPermissionsRoute({ + router, + config: { isSecurityEnabled }, +}: RouteDependencies) { + router.post({ path: addBasePath('/permissions'), validate: false }, async (ctx, req, res) => { + const { callAsCurrentUser } = ctx.core.elasticsearch.adminClient; + + try { + return res.ok({ + body: await getPermissions({ callAsCurrentUser, isSecurityEnabled }), + }); + } catch (e) { + return res.internalError({ body: e }); + } + }); +} diff --git a/x-pack/plugins/license_management/server/routes/api/license/register_start_basic_route.ts b/x-pack/plugins/license_management/server/routes/api/license/register_start_basic_route.ts new file mode 100644 index 0000000000000..ebfa283872e60 --- /dev/null +++ b/x-pack/plugins/license_management/server/routes/api/license/register_start_basic_route.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { startBasic } from '../../../lib/start_basic'; +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../../helpers'; + +export function registerStartBasicRoute({ router, plugins: { licensing } }: RouteDependencies) { + router.post( + { + path: addBasePath('/start_basic'), + validate: { query: schema.object({ acknowledge: schema.string() }) }, + }, + async (ctx, req, res) => { + const { callAsCurrentUser } = ctx.core.elasticsearch.adminClient; + try { + return res.ok({ + body: await startBasic({ + acknowledge: Boolean(req.query.acknowledge), + callAsCurrentUser, + licensing, + }), + }); + } catch (e) { + return res.internalError({ body: e }); + } + } + ); +} diff --git a/x-pack/plugins/license_management/server/routes/api/license/register_start_trial_routes.ts b/x-pack/plugins/license_management/server/routes/api/license/register_start_trial_routes.ts new file mode 100644 index 0000000000000..e418c390aaab6 --- /dev/null +++ b/x-pack/plugins/license_management/server/routes/api/license/register_start_trial_routes.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { canStartTrial, startTrial } from '../../../lib/start_trial'; +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../../helpers'; + +export function registerStartTrialRoutes({ router, plugins: { licensing } }: RouteDependencies) { + router.get({ path: addBasePath('/start_trial'), validate: false }, async (ctx, req, res) => { + const { callAsCurrentUser } = ctx.core.elasticsearch.adminClient; + try { + return res.ok({ body: await canStartTrial(callAsCurrentUser) }); + } catch (e) { + return res.internalError({ body: e }); + } + }); + + router.post({ path: addBasePath('/start_trial'), validate: false }, async (ctx, req, res) => { + const { callAsCurrentUser } = ctx.core.elasticsearch.adminClient; + try { + return res.ok({ + body: await startTrial({ callAsCurrentUser, licensing }), + }); + } catch (e) { + return res.internalError({ body: e }); + } + }); +} diff --git a/x-pack/plugins/license_management/server/routes/helpers.ts b/x-pack/plugins/license_management/server/routes/helpers.ts new file mode 100644 index 0000000000000..f1bbfd5fd4497 --- /dev/null +++ b/x-pack/plugins/license_management/server/routes/helpers.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { API_BASE_PATH } from '../../common/constants'; + +export const addBasePath = (uri: string): string => API_BASE_PATH + uri; diff --git a/x-pack/plugins/license_management/server/routes/index.ts b/x-pack/plugins/license_management/server/routes/index.ts new file mode 100644 index 0000000000000..9d196b6673e55 --- /dev/null +++ b/x-pack/plugins/license_management/server/routes/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RouteDependencies } from '../types'; + +import { + registerLicenseRoute, + registerStartTrialRoutes, + registerStartBasicRoute, + registerPermissionsRoute, +} from './api/license'; + +export class ApiRoutes { + setup(dependencies: RouteDependencies) { + registerLicenseRoute(dependencies); + registerStartTrialRoutes(dependencies); + registerStartBasicRoute(dependencies); + registerPermissionsRoute(dependencies); + } +} diff --git a/x-pack/plugins/license_management/server/types.ts b/x-pack/plugins/license_management/server/types.ts new file mode 100644 index 0000000000000..37f4781ba1e02 --- /dev/null +++ b/x-pack/plugins/license_management/server/types.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ScopedClusterClient, IRouter } from 'kibana/server'; + +import { LicensingPluginSetup } from '../../licensing/server'; +import { SecurityPluginSetup } from '../../security/server'; +import { isEsError } from './lib/is_es_error'; + +export interface Dependencies { + licensing: LicensingPluginSetup; + security?: SecurityPluginSetup; +} + +export interface RouteDependencies { + router: IRouter; + plugins: { + licensing: LicensingPluginSetup; + }; + lib: { + isEsError: typeof isEsError; + }; + config: { + isSecurityEnabled: boolean; + }; +} + +export type CallAsCurrentUser = ScopedClusterClient['callAsCurrentUser']; + +export type CallAsInternalUser = ScopedClusterClient['callAsInternalUser']; diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index b608151d26ae8..3b1513f4bb95d 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -48,6 +48,7 @@ export const LAYER_TYPE = { VECTOR: 'VECTOR', VECTOR_TILE: 'VECTOR_TILE', HEATMAP: 'HEATMAP', + BLENDED_VECTOR: 'BLENDED_VECTOR', }; export enum SORT_ORDER { @@ -188,3 +189,9 @@ export enum LABEL_BORDER_SIZES { } export const DEFAULT_ICON = 'airfield'; + +export enum SCALING_TYPES { + LIMIT = 'LIMIT', + CLUSTERS = 'CLUSTERS', + TOP_HITS = 'TOP_HITS', +} diff --git a/x-pack/plugins/maps/public/reducers/map.js b/x-pack/plugins/maps/public/reducers/map.js index 7e07569b44b83..1e20df89c8fad 100644 --- a/x-pack/plugins/maps/public/reducers/map.js +++ b/x-pack/plugins/maps/public/reducers/map.js @@ -74,7 +74,7 @@ const updateLayerInList = (state, layerId, attribute, newValue) => { return { ...state, layerList: updatedList }; }; -const updateLayerSourceDescriptorProp = (state, layerId, propName, value) => { +const updateLayerSourceDescriptorProp = (state, layerId, propName, value, newLayerType) => { const { layerList } = state; const layerIdx = getLayerIndex(layerList, layerId); const updatedLayer = { @@ -84,6 +84,9 @@ const updateLayerSourceDescriptorProp = (state, layerId, propName, value) => { [propName]: value, }, }; + if (newLayerType) { + updatedLayer.type = newLayerType; + } const updatedList = [ ...layerList.slice(0, layerIdx), updatedLayer, @@ -258,7 +261,13 @@ export function map(state = INITIAL_STATE, action) { case UPDATE_LAYER_PROP: return updateLayerInList(state, action.id, action.propName, action.newValue); case UPDATE_SOURCE_PROP: - return updateLayerSourceDescriptorProp(state, action.layerId, action.propName, action.value); + return updateLayerSourceDescriptorProp( + state, + action.layerId, + action.propName, + action.value, + action.newLayerType + ); case SET_JOINS: const layerDescriptor = state.layerList.find( descriptor => descriptor.id === action.layer.getId() diff --git a/x-pack/plugins/ml/common/constants/categorization_job.ts b/x-pack/plugins/ml/common/constants/categorization_job.ts new file mode 100644 index 0000000000000..c1c65e4bf15b8 --- /dev/null +++ b/x-pack/plugins/ml/common/constants/categorization_job.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { VALIDATION_RESULT } from '../types/categories'; + +export const NUMBER_OF_CATEGORY_EXAMPLES = 5; +export const CATEGORY_EXAMPLES_SAMPLE_SIZE = 1000; +export const CATEGORY_EXAMPLES_WARNING_LIMIT = 0.75; +export const CATEGORY_EXAMPLES_ERROR_LIMIT = 0.02; + +export const VALID_TOKEN_COUNT = 3; +export const MEDIAN_LINE_LENGTH_LIMIT = 400; +export const NULL_COUNT_PERCENT_LIMIT = 0.75; + +export enum CATEGORY_EXAMPLES_VALIDATION_STATUS { + VALID = 'valid', + PARTIALLY_VALID = 'partially_valid', + INVALID = 'invalid', +} + +export const VALIDATION_CHECK_DESCRIPTION = { + [VALIDATION_RESULT.NO_EXAMPLES]: i18n.translate( + 'xpack.ml.models.jobService.categorization.messages.validNoDataFound', + { + defaultMessage: 'Examples were successfully loaded.', + } + ), + [VALIDATION_RESULT.FAILED_TO_TOKENIZE]: i18n.translate( + 'xpack.ml.models.jobService.categorization.messages.validFailureToGetTokens', + { + defaultMessage: 'The examples loaded were tokenized successfully.', + } + ), + [VALIDATION_RESULT.TOKEN_COUNT]: i18n.translate( + 'xpack.ml.models.jobService.categorization.messages.validTokenLength', + { + defaultMessage: + 'More than {tokenCount} tokens per example were found in over {percentage}% of the examples loaded.', + values: { + percentage: Math.floor(CATEGORY_EXAMPLES_WARNING_LIMIT * 100), + tokenCount: VALID_TOKEN_COUNT, + }, + } + ), + [VALIDATION_RESULT.MEDIAN_LINE_LENGTH]: i18n.translate( + 'xpack.ml.models.jobService.categorization.messages.validMedianLineLength', + { + defaultMessage: + 'The median line length of the examples loaded was less than {medianCharCount} characters.', + values: { + medianCharCount: MEDIAN_LINE_LENGTH_LIMIT, + }, + } + ), + [VALIDATION_RESULT.NULL_VALUES]: i18n.translate( + 'xpack.ml.models.jobService.categorization.messages.validNullValues', + { + defaultMessage: 'Less than {percentage}% of the examples loaded were null.', + values: { + percentage: Math.floor(100 - NULL_COUNT_PERCENT_LIMIT * 100), + }, + } + ), + [VALIDATION_RESULT.TOO_MANY_TOKENS]: i18n.translate( + 'xpack.ml.models.jobService.categorization.messages.validTooManyTokens', + { + defaultMessage: 'Less than 10000 tokens were found in total in the examples loaded.', + } + ), + [VALIDATION_RESULT.INSUFFICIENT_PRIVILEGES]: i18n.translate( + 'xpack.ml.models.jobService.categorization.messages.validUserPrivileges', + { + defaultMessage: 'The user has sufficient privileges to perform the checks.', + } + ), +}; diff --git a/x-pack/plugins/ml/common/constants/new_job.ts b/x-pack/plugins/ml/common/constants/new_job.ts index 862fa72d11fdb..751413bb6485a 100644 --- a/x-pack/plugins/ml/common/constants/new_job.ts +++ b/x-pack/plugins/ml/common/constants/new_job.ts @@ -25,15 +25,3 @@ export const DEFAULT_RARE_BUCKET_SPAN = '1h'; export const DEFAULT_QUERY_DELAY = '60s'; export const SHARED_RESULTS_INDEX_NAME = 'shared'; - -// Categorization -export const NUMBER_OF_CATEGORY_EXAMPLES = 5; -export const CATEGORY_EXAMPLES_SAMPLE_SIZE = 1000; -export const CATEGORY_EXAMPLES_WARNING_LIMIT = 0.75; -export const CATEGORY_EXAMPLES_ERROR_LIMIT = 0.02; - -export enum CATEGORY_EXAMPLES_VALIDATION_STATUS { - VALID = 'valid', - PARTIALLY_VALID = 'partially_valid', - INVALID = 'invalid', -} diff --git a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts index 823d27e4617b2..bf8e3031db975 100644 --- a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts +++ b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/job.ts @@ -81,7 +81,7 @@ export interface ModelPlotConfig { // TODO, finish this when it's needed export interface CustomRule { - actions: any; - scope: object; - conditions: object; + actions: string[]; + scope?: object; + conditions: any[]; } diff --git a/x-pack/plugins/ml/common/types/categories.ts b/x-pack/plugins/ml/common/types/categories.ts index 862ad8e194a0b..5d4c3eab53ee8 100644 --- a/x-pack/plugins/ml/common/types/categories.ts +++ b/x-pack/plugins/ml/common/types/categories.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../constants/new_job'; +import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../constants/categorization_job'; export type CategoryId = number; @@ -39,12 +39,12 @@ export interface CategoryFieldExample { } export enum VALIDATION_RESULT { + NO_EXAMPLES, + FAILED_TO_TOKENIZE, + TOO_MANY_TOKENS, TOKEN_COUNT, MEDIAN_LINE_LENGTH, NULL_VALUES, - NO_EXAMPLES, - TOO_MANY_TOKENS, - FAILED_TO_TOKENIZE, INSUFFICIENT_PRIVILEGES, } diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 2597715488399..6269c11fca896 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -9,6 +9,8 @@ import ReactDOM from 'react-dom'; import { AppMountParameters, CoreStart } from 'kibana/public'; +import { Storage } from '../../../../../src/plugins/kibana_utils/public'; + import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { setDependencyCache, clearCache } from './util/dependency_cache'; import { setLicenseCache } from './license'; @@ -24,6 +26,8 @@ interface AppProps { appMountParams: AppMountParameters; } +const localStorage = new Storage(window.localStorage); + const App: FC = ({ coreStart, deps, appMountParams }) => { setDependencyCache({ indexPatterns: deps.data.indexPatterns, @@ -62,6 +66,7 @@ const App: FC = ({ coreStart, deps, appMountParams }) => { appName: 'ML', data: deps.data, security: deps.security, + storage: localStorage, ...coreStart, }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/about_panel/about_panel.js b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/about_panel/about_panel.js index 99cdc816dfe3d..edecc925591d3 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/about_panel/about_panel.js +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/about_panel/about_panel.js @@ -25,7 +25,7 @@ import { WelcomeContent } from './welcome_content'; export function AboutPanel({ onFilePickerChange }) { return ( - + @@ -58,7 +58,7 @@ export function AboutPanel({ onFilePickerChange }) { export function LoadingPanel() { return ( - +
      diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/bottom_bar/bottom_bar.tsx b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/bottom_bar/bottom_bar.tsx index 72e4f88062789..e28386093abe0 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/bottom_bar/bottom_bar.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/bottom_bar/bottom_bar.tsx @@ -48,6 +48,7 @@ export const BottomBar: FC = ({ mode, onChangeMode, onCancel, di fill isDisabled={disableImport} onClick={() => onChangeMode(DATAVISUALIZER_MODE.IMPORT)} + data-test-subj="mlFileDataVisOpenImportPageButton" > {errorText} @@ -79,6 +80,7 @@ export function FileCouldNotBeRead({ error, loaded }) { } color="danger" iconType="cross" + data-test-subj="mlFileUploadErrorCallout fileCouldNotBeRead" > {error !== undefined &&

      {error}

      } {loaded && ( diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/simple.js b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/simple.js index 8c6f569bf8605..271b9493aa1f3 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/simple.js +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/simple.js @@ -47,6 +47,7 @@ export const SimpleSettings = ({ defaultMessage: 'Index name, required field', } )} + data-test-subj="mlFileDataVisIndexNameInput" /> @@ -63,6 +64,7 @@ export const SimpleSettings = ({ checked={createIndexPattern === true} disabled={initialized === true} onChange={onCreateIndexPatternChange} + data-test-subj="mlFileDataVisCreateIndexPatternCheckbox" /> ); diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_summary/import_summary.js b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_summary/import_summary.js index d79ce020c8071..0e67807a39fd9 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_summary/import_summary.js +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_summary/import_summary.js @@ -39,6 +39,7 @@ export function ImportSummary({ } color="success" iconType="check" + data-test-subj="mlFileImportSuccessCallout" > diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js index bdfc27099a185..94627b688b03a 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js @@ -462,7 +462,7 @@ export class ImportView extends Component { initialized === true; return ( - + @@ -470,7 +470,7 @@ export class ImportView extends Component { - +

      { ]; return ( - + -

      {fileName}

      +

      {fileName}

      - + { - + @@ -70,7 +70,7 @@ export const ResultsView = ({ data, fileName, results, showEditFlyout }) => { - + {}} />
      diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js index 3bf128f84aa78..39cd25ba87d8c 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.js @@ -66,6 +66,10 @@ export function createUrlOverrides(overrides, originalSettings) { ) { formattedOverrides.format = originalSettings.format; } + + if (Array.isArray(formattedOverrides.column_names)) { + formattedOverrides.column_names = formattedOverrides.column_names.join(); + } } if (formattedOverrides.format === '' && originalSettings.format === 'semi_structured_text') { @@ -82,11 +86,6 @@ export function createUrlOverrides(overrides, originalSettings) { formattedOverrides.column_names = ''; } - // escape grok pattern as it can contain bad characters - if (formattedOverrides.grok_pattern !== '') { - formattedOverrides.grok_pattern = encodeURIComponent(formattedOverrides.grok_pattern); - } - if (formattedOverrides.lines_to_sample === '') { formattedOverrides.lines_to_sample = overrides.linesToSample; } diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_card/content_types/number_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_card/content_types/number_content.tsx index 29be9d2e1e2a4..e2c156fc66ded 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_card/content_types/number_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_card/content_types/number_content.tsx @@ -157,7 +157,7 @@ export const NumberContent: FC = ({ config }) => { buttonSize="compressed" /> - {detailsMode === DETAILS_MODE.DISTRIBUTION && ( + {distribution && detailsMode === DETAILS_MODE.DISTRIBUTION && ( diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/search_panel/search_panel.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/search_panel/search_panel.tsx index 527cd31ed91d4..50c76725f5245 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/search_panel/search_panel.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/search_panel/search_panel.tsx @@ -4,18 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC } from 'react'; +import React, { FC, useState } from 'react'; -import { - EuiFieldSearch, - EuiFlexItem, - EuiFlexGroup, - EuiForm, - EuiFormRow, - EuiIconTip, - EuiSuperSelect, - EuiText, -} from '@elastic/eui'; +import { EuiFlexItem, EuiFlexGroup, EuiIconTip, EuiSuperSelect, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -23,18 +14,24 @@ import { i18n } from '@kbn/i18n'; import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; import { SEARCH_QUERY_LANGUAGE } from '../../../../../../common/constants/search'; -import { SavedSearchQuery } from '../../../../contexts/ml'; -// @ts-ignore -import { KqlFilterBar } from '../../../../components/kql_filter_bar/index'; +import { + esKuery, + esQuery, + Query, + QueryStringInput, +} from '../../../../../../../../../src/plugins/data/public'; + +import { getToastNotifications } from '../../../../util/dependency_cache'; interface Props { indexPattern: IndexPattern; - searchString: string | SavedSearchQuery; - setSearchString(s: string): void; - searchQuery: string | SavedSearchQuery; - setSearchQuery(q: string | SavedSearchQuery): void; + searchString: Query['query']; + setSearchString(s: Query['query']): void; + searchQuery: Query['query']; + setSearchQuery(q: Query['query']): void; searchQueryLanguage: SEARCH_QUERY_LANGUAGE; + setSearchQueryLanguage(q: any): void; samplerShardSize: number; setSamplerShardSize(s: number): void; totalCount: number; @@ -59,6 +56,20 @@ const searchSizeOptions = [1000, 5000, 10000, 100000, -1].map(v => { }; }); +const kqlSyntaxErrorMessage = i18n.translate( + 'xpack.ml.datavisualizer.invalidKqlSyntaxErrorMessage', + { + defaultMessage: + 'Invalid syntax in search bar. The input must be valid Kibana Query Language (KQL)', + } +); +const luceneSyntaxErrorMessage = i18n.translate( + 'xpack.ml.datavisualizer.invalidLuceneSyntaxErrorMessage', + { + defaultMessage: 'Invalid syntax in search bar. The input must be valid Lucene', + } +); + export const SearchPanel: FC = ({ indexPattern, searchString, @@ -66,44 +77,65 @@ export const SearchPanel: FC = ({ searchQuery, setSearchQuery, searchQueryLanguage, + setSearchQueryLanguage, samplerShardSize, setSamplerShardSize, totalCount, }) => { - const searchHandler = (d: Record) => { - setSearchQuery(d.filterQuery); + // The internal state of the input query bar updated on every key stroke. + const [searchInput, setSearchInput] = useState({ + query: searchString || '', + language: searchQueryLanguage, + }); + + const searchHandler = (query: Query) => { + let filterQuery; + try { + if (query.language === SEARCH_QUERY_LANGUAGE.KUERY) { + filterQuery = esKuery.toElasticsearchQuery( + esKuery.fromKueryExpression(query.query), + indexPattern + ); + } else if (query.language === SEARCH_QUERY_LANGUAGE.LUCENE) { + filterQuery = esQuery.luceneStringToDsl(query.query); + } else { + filterQuery = {}; + } + + setSearchQuery(filterQuery); + setSearchString(query.query); + setSearchQueryLanguage(query.language); + } catch (e) { + console.log('Invalid syntax', e); // eslint-disable-line no-console + const toastNotifications = getToastNotifications(); + const notification = + query.language === SEARCH_QUERY_LANGUAGE.KUERY + ? kqlSyntaxErrorMessage + : luceneSyntaxErrorMessage; + toastNotifications.addDanger(notification); + } }; + const searchChangeHandler = (query: Query) => setSearchInput(query); return ( - {searchQueryLanguage === SEARCH_QUERY_LANGUAGE.KUERY ? ( - - ) : ( - - - - - - )} + diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx index b66d12b6c9ebe..3a37274edbc16 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx @@ -24,6 +24,7 @@ import { import { IFieldType, KBN_FIELD_TYPES, + Query, esQuery, esKuery, } from '../../../../../../../src/plugins/data/public'; @@ -36,7 +37,7 @@ import { checkPermission } from '../../privilege/check_privilege'; import { mlNodesAvailable } from '../../ml_nodes_check/check_ml_nodes'; import { FullTimeRangeSelector } from '../../components/full_time_range_selector'; import { mlTimefilterRefresh$ } from '../../services/timefilter_refresh_service'; -import { useMlContext, SavedSearchQuery } from '../../contexts/ml'; +import { useMlContext } from '../../contexts/ml'; import { kbnTypeToMLJobType } from '../../util/field_types_utils'; import { useTimefilter } from '../../contexts/kibana'; import { timeBasedIndexCheck, getQueryFromSavedSearch } from '../../util/index_utils'; @@ -49,8 +50,8 @@ import { SearchPanel } from './components/search_panel'; import { DataLoader } from './data_loader'; interface DataVisualizerPageState { - searchQuery: string | SavedSearchQuery; - searchString: string | SavedSearchQuery; + searchQuery: Query['query']; + searchString: Query['query']; searchQueryLanguage: SEARCH_QUERY_LANGUAGE; samplerShardSize: number; overallStats: any; @@ -160,7 +161,7 @@ export const Page: FC = () => { const [searchString, setSearchString] = useState(initSearchString); const [searchQuery, setSearchQuery] = useState(initSearchQuery); - const [searchQueryLanguage] = useState(initQueryLanguage); + const [searchQueryLanguage, setSearchQueryLanguage] = useState(initQueryLanguage); const [samplerShardSize, setSamplerShardSize] = useState(defaults.samplerShardSize); // TODO - type overallStats and stats @@ -676,6 +677,7 @@ export const Page: FC = () => { searchQuery={searchQuery} setSearchQuery={setSearchQuery} searchQueryLanguage={searchQueryLanguage} + setSearchQueryLanguage={setSearchQueryLanguage} samplerShardSize={samplerShardSize} setSamplerShardSize={setSamplerShardSize} totalCount={overallStats.totalCount} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/categorization_job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/categorization_job_creator.ts index 7407a43aa9d5e..95fd9df892cab 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/categorization_job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/categorization_job_creator.ts @@ -16,8 +16,8 @@ import { CREATED_BY_LABEL, DEFAULT_BUCKET_SPAN, DEFAULT_RARE_BUCKET_SPAN, - CATEGORY_EXAMPLES_VALIDATION_STATUS, } from '../../../../../../common/constants/new_job'; +import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../../../common/constants/categorization_job'; import { ML_JOB_AGGREGATION } from '../../../../../../common/constants/aggregation_types'; import { CategorizationAnalyzer, diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts index 31155b0a96ed4..f115c203624eb 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts @@ -88,16 +88,13 @@ export class MultiMetricJobCreator extends JobCreator { // called externally to set the model memory limit based current detector configuration public async calculateModelMemoryLimit() { - if (this._splitField === null) { - // not split field, use the default + if (this.jobConfig.analysis_config.detectors.length === 0) { this.modelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT; } else { const { modelMemoryLimit } = await ml.calculateModelMemoryLimit({ + analysisConfig: this.jobConfig.analysis_config, indexPattern: this._indexPatternTitle, - splitFieldName: this._splitField.name, query: this._datafeed_config.query, - fieldNames: this.fields.map(f => f.id), - influencerNames: this._influencers, timeFieldName: this._job_config.data_description.time_field, earliestMs: this._start, latestMs: this._end, diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_validator/job_validator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_validator/job_validator.ts index 8f6b16c407fb6..82e5e15a24d5c 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_validator/job_validator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_validator/job_validator.ts @@ -16,7 +16,7 @@ import { JobCreator, JobCreatorType, isCategorizationJobCreator } from '../job_c import { populateValidationMessages, checkForExistingJobAndGroupIds } from './util'; import { ExistingJobsAndGroups } from '../../../../services/job_service'; import { cardinalityValidator, CardinalityValidatorResult } from './validators'; -import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../../../common/constants/new_job'; +import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../../../common/constants/categorization_job'; // delay start of validation to allow the user to make changes // e.g. if they are typing in a new value, try not to validate diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts index 8f3a56b6b2b90..de550f61858e6 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts @@ -11,7 +11,7 @@ import { ml } from '../../../../services/ml_api_service'; import { NUMBER_OF_CATEGORY_EXAMPLES, CATEGORY_EXAMPLES_VALIDATION_STATUS, -} from '../../../../../../common/constants/new_job'; +} from '../../../../../../common/constants/categorization_job'; export class CategorizationExamplesLoader { private _jobCreator: CategorizationJobCreator; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/additional_section.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/additional_section.tsx index dd287d10ab2c8..75856d5276fdf 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/additional_section.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/additional_section/additional_section.tsx @@ -5,11 +5,17 @@ */ import React, { FC, Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; import { EuiAccordion, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { CalendarsSelection } from './components/calendars'; import { CustomUrlsSelection } from './components/custom_urls'; -const ButtonContent = Additional settings; +const buttonContent = i18n.translate( + 'xpack.ml.newJob.wizard.jobDetailsStep.additionalSectionButton', + { + defaultMessage: 'Additional settings', + } +); interface Props { additionalExpanded: boolean; @@ -22,7 +28,7 @@ export const AdditionalSection: FC = ({ additionalExpanded, setAdditional = ({ advancedExpanded, setAdvancedExpand = ({ overallValidStatus, validationChecks, @@ -66,6 +84,10 @@ export const ExamplesValidCallout: FC = ({ ))} {analyzerUsed} + + + + ); }; @@ -96,3 +118,28 @@ const AnalyzerUsed: FC<{ categorizationAnalyzer: CategorizationAnalyzer }> = ({ ); }; + +const AllValidationChecks: FC<{ validationChecks: FieldExampleCheck[] }> = ({ + validationChecks, +}) => { + const list: EuiListGroupItemProps[] = Object.keys(VALIDATION_CHECK_DESCRIPTION).map((k, i) => { + const failedCheck = validationChecks.find(vc => vc.id === i); + if ( + failedCheck !== undefined && + failedCheck?.valid !== CATEGORY_EXAMPLES_VALIDATION_STATUS.VALID + ) { + return { + iconType: 'cross', + label: failedCheck.message, + size: 's', + }; + } + return { + iconType: 'check', + label: VALIDATION_CHECK_DESCRIPTION[i as VALIDATION_RESULT], + size: 's', + }; + }); + + return ; +}; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection.tsx index 411f6e898bd48..f5c3e90d63418 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection.tsx @@ -18,7 +18,7 @@ import { CategoryFieldExample, FieldExampleCheck, } from '../../../../../../../../../common/types/categories'; -import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../../../../../../common/constants/new_job'; +import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../../../../../../common/constants/categorization_job'; import { LoadingWrapper } from '../../../charts/loading_wrapper'; interface Props { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/top_categories.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/top_categories.tsx index 3bade07250b46..227c93dc2d86b 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/top_categories.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/top_categories.tsx @@ -11,7 +11,7 @@ import { JobCreatorContext } from '../../../job_creator_context'; import { CategorizationJobCreator } from '../../../../../common/job_creator'; import { Results } from '../../../../../common/results_loader'; import { ml } from '../../../../../../../services/ml_api_service'; -import { NUMBER_OF_CATEGORY_EXAMPLES } from '../../../../../../../../../common/constants/new_job'; +import { NUMBER_OF_CATEGORY_EXAMPLES } from '../../../../../../../../../common/constants/categorization_job'; export const TopCategories: FC = () => { const { jobCreator: jc, resultsLoader } = useContext(JobCreatorContext); diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index b8e21898a4bb3..cd4a97bd10ed4 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -22,6 +22,7 @@ import { Datafeed, CombinedJob, Detector, + AnalysisConfig, } from '../../../../common/types/anomaly_detection_jobs'; import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; import { FieldRequestConfig } from '../../datavisualizer/index_based/common'; @@ -532,30 +533,24 @@ export const ml = { }, calculateModelMemoryLimit({ + analysisConfig, indexPattern, - splitFieldName, query, - fieldNames, - influencerNames, timeFieldName, earliestMs, latestMs, }: { + analysisConfig: AnalysisConfig; indexPattern: string; - splitFieldName: string; query: any; - fieldNames: string[]; - influencerNames: string[]; timeFieldName: string; earliestMs: number; latestMs: number; }) { const body = JSON.stringify({ + analysisConfig, indexPattern, - splitFieldName, query, - fieldNames, - influencerNames, timeFieldName, earliestMs, latestMs, diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts index bcceffb14123e..16e25067fd91e 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts @@ -17,7 +17,7 @@ import { CategoryFieldExample, FieldExampleCheck, } from '../../../../common/types/categories'; -import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../common/constants/new_job'; +import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../common/constants/categorization_job'; import { Category } from '../../../../common/types/categories'; export const jobs = { diff --git a/x-pack/plugins/ml/server/client/elasticsearch_ml.ts b/x-pack/plugins/ml/server/client/elasticsearch_ml.ts index 1d09a6c765e29..ed4dc64cde3bd 100644 --- a/x-pack/plugins/ml/server/client/elasticsearch_ml.ts +++ b/x-pack/plugins/ml/server/client/elasticsearch_ml.ts @@ -413,6 +413,14 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'POST', }); + ml.estimateModelMemory = ca({ + url: { + fmt: '/_ml/anomaly_detectors/_estimate_model_memory', + }, + needBody: true, + method: 'POST', + }); + ml.datafeedPreview = ca({ url: { fmt: '/_ml/datafeeds/<%=datafeedId%>/_preview', diff --git a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.d.ts b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.d.ts deleted file mode 100644 index 927728040bdd7..0000000000000 --- a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { APICaller } from 'kibana/server'; - -export function calculateModelMemoryLimitProvider( - callAsCurrentUser: APICaller -): ( - indexPattern: string, - splitFieldName: string, - query: any, - fieldNames: any, - influencerNames: any, // string[] ? - timeFieldName: string, - earliestMs: number, - latestMs: number -) => Promise; diff --git a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.js b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.js deleted file mode 100644 index 8a06895762dc2..0000000000000 --- a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.js +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -// calculates the size of the model memory limit used in the job config -// based on the cardinality of the field being used to split the data. -// the limit should be 10MB plus 20kB per series, rounded up to the nearest MB. -import numeral from '@elastic/numeral'; -import { fieldsServiceProvider } from '../fields_service'; - -export function calculateModelMemoryLimitProvider(callAsCurrentUser) { - const fieldsService = fieldsServiceProvider(callAsCurrentUser); - - return function calculateModelMemoryLimit( - indexPattern, - splitFieldName, - query, - fieldNames, - influencerNames, - timeFieldName, - earliestMs, - latestMs, - allowMMLGreaterThanMax = false - ) { - return new Promise((response, reject) => { - const limits = {}; - callAsCurrentUser('ml.info') - .then(resp => { - if (resp.limits !== undefined && resp.limits.max_model_memory_limit !== undefined) { - limits.max_model_memory_limit = resp.limits.max_model_memory_limit; - } - }) - .catch(error => { - reject(error); - }); - - // find the cardinality of the split field - function splitFieldCardinality() { - return fieldsService.getCardinalityOfFields( - indexPattern, - [splitFieldName], - query, - timeFieldName, - earliestMs, - latestMs - ); - } - - // find the cardinality of an influencer field - function influencerCardinality(influencerName) { - return fieldsService.getCardinalityOfFields( - indexPattern, - [influencerName], - query, - timeFieldName, - earliestMs, - latestMs - ); - } - - const calculations = [ - splitFieldCardinality(), - ...influencerNames.map(inf => influencerCardinality(inf)), - ]; - - Promise.all(calculations) - .then(responses => { - let mmlMB = 0; - const MB = 1000; - responses.forEach((resp, i) => { - let mmlKB = 0; - if (i === 0) { - // first in the list is the basic calculation. - // a base of 10MB plus 64KB per series per detector - // i.e. 10000KB + (64KB * cardinality of split field * number or detectors) - const cardinality = resp[splitFieldName]; - mmlKB = 10000; - const SERIES_MULTIPLIER = 64; - const numberOfFields = fieldNames.length; - - if (cardinality !== undefined) { - mmlKB += SERIES_MULTIPLIER * cardinality * numberOfFields; - } - } else { - // the rest of the calculations are for influencers fields - // 10KB per series of influencer field - // i.e. 10KB * cardinality of influencer field - const cardinality = resp[splitFieldName]; - mmlKB = 0; - const SERIES_MULTIPLIER = 10; - if (cardinality !== undefined) { - mmlKB = SERIES_MULTIPLIER * cardinality; - } - } - // convert the total to MB, rounding up. - mmlMB += Math.ceil(mmlKB / MB); - }); - - // if max_model_memory_limit has been set, - // make sure the estimated value is not greater than it. - if (allowMMLGreaterThanMax === false && limits.max_model_memory_limit !== undefined) { - const maxBytes = numeral(limits.max_model_memory_limit.toUpperCase()).value(); - const mmlBytes = numeral(`${mmlMB}MB`).value(); - if (mmlBytes > maxBytes) { - mmlMB = Math.floor(maxBytes / numeral('1MB').value()); - } - } - response({ modelMemoryLimit: `${mmlMB}MB` }); - }) - .catch(error => { - reject(error); - }); - }); - }; -} diff --git a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts new file mode 100644 index 0000000000000..c97bbe07fffda --- /dev/null +++ b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import numeral from '@elastic/numeral'; +import { APICaller } from 'kibana/server'; +import { AnalysisConfig } from '../../../common/types/anomaly_detection_jobs'; +import { fieldsServiceProvider } from '../fields_service'; + +interface ModelMemoryEstimationResult { + /** + * Result model memory limit + */ + modelMemoryLimit: string; + /** + * Estimated model memory by elasticsearch ml endpoint + */ + estimatedModelMemoryLimit: string; + /** + * Maximum model memory limit + */ + maxModelMemoryLimit?: string; +} + +/** + * Response of the _estimate_model_memory endpoint. + */ +export interface ModelMemoryEstimate { + model_memory_estimate: string; +} + +/** + * Retrieves overall and max bucket cardinalities. + */ +async function getCardinalities( + callAsCurrentUser: APICaller, + analysisConfig: AnalysisConfig, + indexPattern: string, + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number +): Promise<{ + overallCardinality: { [key: string]: number }; + maxBucketCardinality: { [key: string]: number }; +}> { + /** + * Fields not involved in cardinality check + */ + const excludedKeywords = new Set( + /** + * The keyword which is used to mean the output of categorization, + * so it will have cardinality zero in the actual input data. + */ + 'mlcategory' + ); + + const fieldsService = fieldsServiceProvider(callAsCurrentUser); + + const { detectors, influencers, bucket_span: bucketSpan } = analysisConfig; + + let overallCardinality = {}; + let maxBucketCardinality = {}; + const overallCardinalityFields: Set = detectors.reduce( + ( + acc, + { + by_field_name: byFieldName, + partition_field_name: partitionFieldName, + over_field_name: overFieldName, + } + ) => { + [byFieldName, partitionFieldName, overFieldName] + .filter(field => field !== undefined && field !== '' && !excludedKeywords.has(field)) + .forEach(key => { + acc.add(key as string); + }); + return acc; + }, + new Set() + ); + + const maxBucketFieldCardinalities: string[] = influencers.filter( + influencerField => + typeof influencerField === 'string' && + !excludedKeywords.has(influencerField) && + !!influencerField && + !overallCardinalityFields.has(influencerField) + ) as string[]; + + if (overallCardinalityFields.size > 0) { + overallCardinality = await fieldsService.getCardinalityOfFields( + indexPattern, + [...overallCardinalityFields], + query, + timeFieldName, + earliestMs, + latestMs + ); + } + + if (maxBucketFieldCardinalities.length > 0) { + maxBucketCardinality = await fieldsService.getMaxBucketCardinalities( + indexPattern, + maxBucketFieldCardinalities, + query, + timeFieldName, + earliestMs, + latestMs, + bucketSpan + ); + } + + return { + overallCardinality, + maxBucketCardinality, + }; +} + +export function calculateModelMemoryLimitProvider(callAsCurrentUser: APICaller) { + /** + * Retrieves an estimated size of the model memory limit used in the job config + * based on the cardinality of the fields being used to split the data + * and influencers. + */ + return async function calculateModelMemoryLimit( + analysisConfig: AnalysisConfig, + indexPattern: string, + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number, + allowMMLGreaterThanMax = false + ): Promise { + let maxModelMemoryLimit; + try { + const resp = await callAsCurrentUser('ml.info'); + if (resp?.limits?.max_model_memory_limit !== undefined) { + maxModelMemoryLimit = resp.limits.max_model_memory_limit.toUpperCase(); + } + } catch (e) { + throw new Error('Unable to retrieve max model memory limit'); + } + + const { overallCardinality, maxBucketCardinality } = await getCardinalities( + callAsCurrentUser, + analysisConfig, + indexPattern, + query, + timeFieldName, + earliestMs, + latestMs + ); + + const estimatedModelMemoryLimit = ( + await callAsCurrentUser('ml.estimateModelMemory', { + body: { + analysis_config: analysisConfig, + overall_cardinality: overallCardinality, + max_bucket_cardinality: maxBucketCardinality, + }, + }) + ).model_memory_estimate.toUpperCase(); + + let modelMemoryLimit: string = estimatedModelMemoryLimit; + // if max_model_memory_limit has been set, + // make sure the estimated value is not greater than it. + if (!allowMMLGreaterThanMax && maxModelMemoryLimit !== undefined) { + // @ts-ignore + const maxBytes = numeral(maxModelMemoryLimit).value(); + // @ts-ignore + const mmlBytes = numeral(estimatedModelMemoryLimit).value(); + if (mmlBytes > maxBytes) { + // @ts-ignore + modelMemoryLimit = `${Math.floor(maxBytes / numeral('1MB').value())}MB`; + } + } + + return { + estimatedModelMemoryLimit, + modelMemoryLimit, + ...(maxModelMemoryLimit ? { maxModelMemoryLimit } : {}), + }; + }; +} diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_service.d.ts b/x-pack/plugins/ml/server/models/fields_service/fields_service.d.ts deleted file mode 100644 index 4a7e57d290b17..0000000000000 --- a/x-pack/plugins/ml/server/models/fields_service/fields_service.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { APICaller } from 'kibana/server'; - -export function fieldsServiceProvider( - callAsCurrentUser: APICaller -): { - getCardinalityOfFields: ( - index: string[] | string, - fieldNames: string[], - query: any, - timeFieldName: string, - earliestMs: number, - latestMs: number - ) => Promise; - getTimeFieldRange: (index: string[] | string, timeFieldName: string, query: any) => Promise; -}; diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_service.js b/x-pack/plugins/ml/server/models/fields_service/fields_service.js deleted file mode 100644 index a538693a92aba..0000000000000 --- a/x-pack/plugins/ml/server/models/fields_service/fields_service.js +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -// Service for carrying out queries to obtain data -// specific to fields in Elasticsearch indices. - -export function fieldsServiceProvider(callAsCurrentUser) { - // Obtains the cardinality of one or more fields. - // Returns an Object whose keys are the names of the fields, - // with values equal to the cardinality of the field. - // Any of the supplied fieldNames which are not aggregatable will - // be omitted from the returned Object. - function getCardinalityOfFields(index, fieldNames, query, timeFieldName, earliestMs, latestMs) { - // First check that each of the supplied fieldNames are aggregatable, - // then obtain the cardinality for each of the aggregatable fields. - return new Promise((resolve, reject) => { - callAsCurrentUser('fieldCaps', { - index, - fields: fieldNames, - }) - .then(fieldCapsResp => { - const aggregatableFields = []; - fieldNames.forEach(fieldName => { - const fieldInfo = fieldCapsResp.fields[fieldName]; - const typeKeys = fieldInfo !== undefined ? Object.keys(fieldInfo) : []; - if (typeKeys.length > 0) { - const fieldType = typeKeys[0]; - const isFieldAggregatable = fieldInfo[fieldType].aggregatable; - if (isFieldAggregatable === true) { - aggregatableFields.push(fieldName); - } - } - }); - - if (aggregatableFields.length > 0) { - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range and the datafeed config query. - const mustCriteria = [ - { - range: { - [timeFieldName]: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - if (query) { - mustCriteria.push(query); - } - - const aggs = aggregatableFields.reduce((obj, field) => { - obj[field] = { cardinality: { field } }; - return obj; - }, {}); - - const body = { - query: { - bool: { - must: mustCriteria, - }, - }, - size: 0, - _source: { - excludes: [], - }, - aggs, - }; - - callAsCurrentUser('search', { - index, - body, - }) - .then(resp => { - const aggregations = resp.aggregations; - if (aggregations !== undefined) { - const results = aggregatableFields.reduce((obj, field) => { - obj[field] = (aggregations[field] || { value: 0 }).value; - return obj; - }, {}); - resolve(results); - } else { - resolve({}); - } - }) - .catch(resp => { - reject(resp); - }); - } else { - // None of the fields are aggregatable. Return empty Object. - resolve({}); - } - }) - .catch(resp => { - reject(resp); - }); - }); - } - - function getTimeFieldRange(index, timeFieldName, query) { - return new Promise((resolve, reject) => { - const obj = { success: true, start: { epoch: 0, string: '' }, end: { epoch: 0, string: '' } }; - - callAsCurrentUser('search', { - index, - size: 0, - body: { - query, - aggs: { - earliest: { - min: { - field: timeFieldName, - }, - }, - latest: { - max: { - field: timeFieldName, - }, - }, - }, - }, - }) - .then(resp => { - if (resp.aggregations && resp.aggregations.earliest && resp.aggregations.latest) { - obj.start.epoch = resp.aggregations.earliest.value; - obj.start.string = resp.aggregations.earliest.value_as_string; - - obj.end.epoch = resp.aggregations.latest.value; - obj.end.string = resp.aggregations.latest.value_as_string; - } - resolve(obj); - }) - .catch(resp => { - reject(resp); - }); - }); - } - - return { - getCardinalityOfFields, - getTimeFieldRange, - }; -} diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_service.ts b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts new file mode 100644 index 0000000000000..d16984abc5d2a --- /dev/null +++ b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts @@ -0,0 +1,296 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { APICaller } from 'kibana/server'; +import { parseInterval } from '../../../common/util/parse_interval'; + +/** + * Service for carrying out queries to obtain data + * specific to fields in Elasticsearch indices. + */ +export function fieldsServiceProvider(callAsCurrentUser: APICaller) { + /** + * Gets aggregatable fields. + */ + async function getAggregatableFields( + index: string | string[], + fieldNames: string[] + ): Promise { + const fieldCapsResp = await callAsCurrentUser('fieldCaps', { + index, + fields: fieldNames, + }); + const aggregatableFields: string[] = []; + fieldNames.forEach(fieldName => { + const fieldInfo = fieldCapsResp.fields[fieldName]; + const typeKeys = fieldInfo !== undefined ? Object.keys(fieldInfo) : []; + if (typeKeys.length > 0) { + const fieldType = typeKeys[0]; + const isFieldAggregatable = fieldInfo[fieldType].aggregatable; + if (isFieldAggregatable === true) { + aggregatableFields.push(fieldName); + } + } + }); + return aggregatableFields; + } + + // Obtains the cardinality of one or more fields. + // Returns an Object whose keys are the names of the fields, + // with values equal to the cardinality of the field. + // Any of the supplied fieldNames which are not aggregatable will + // be omitted from the returned Object. + async function getCardinalityOfFields( + index: string[] | string, + fieldNames: string[], + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number + ): Promise<{ [key: string]: number }> { + const aggregatableFields = await getAggregatableFields(index, fieldNames); + + if (aggregatableFields.length === 0) { + return {}; + } + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range and the datafeed config query. + const mustCriteria = [ + { + range: { + [timeFieldName]: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + ]; + + if (query) { + mustCriteria.push(query); + } + + const aggs = aggregatableFields.reduce((obj, field) => { + obj[field] = { cardinality: { field } }; + return obj; + }, {} as { [field: string]: { cardinality: { field: string } } }); + + const body = { + query: { + bool: { + must: mustCriteria, + }, + }, + size: 0, + _source: { + excludes: [], + }, + aggs, + }; + + const aggregations = ( + await callAsCurrentUser('search', { + index, + body, + }) + )?.aggregations; + + if (!aggregations) { + return {}; + } + + return aggregatableFields.reduce((obj, field) => { + obj[field] = (aggregations[field] || { value: 0 }).value; + return obj; + }, {} as { [field: string]: number }); + } + + function getTimeFieldRange( + index: string[] | string, + timeFieldName: string, + query: any + ): Promise { + return new Promise((resolve, reject) => { + const obj = { success: true, start: { epoch: 0, string: '' }, end: { epoch: 0, string: '' } }; + + callAsCurrentUser('search', { + index, + size: 0, + body: { + query, + aggs: { + earliest: { + min: { + field: timeFieldName, + }, + }, + latest: { + max: { + field: timeFieldName, + }, + }, + }, + }, + }) + .then(resp => { + if (resp.aggregations && resp.aggregations.earliest && resp.aggregations.latest) { + obj.start.epoch = resp.aggregations.earliest.value; + obj.start.string = resp.aggregations.earliest.value_as_string; + + obj.end.epoch = resp.aggregations.latest.value; + obj.end.string = resp.aggregations.latest.value_as_string; + } + resolve(obj); + }) + .catch(resp => { + reject(resp); + }); + }); + } + + /** + * Caps provided time boundaries based on the interval. + * @param earliestMs + * @param latestMs + * @param interval + */ + function getSafeTimeRange( + earliestMs: number, + latestMs: number, + interval: string + ): { start: number; end: number } { + const maxNumberOfBuckets = 1000; + const end = latestMs; + + const intervalDuration = parseInterval(interval); + + if (intervalDuration === null) { + throw Boom.badRequest('Interval is invalid'); + } + + const start = Math.max( + earliestMs, + latestMs - maxNumberOfBuckets * intervalDuration.asMilliseconds() + ); + + return { start, end }; + } + + /** + * Retrieves max cardinalities for provided fields from date interval buckets + * using max bucket pipeline aggregation. + * + * @param index + * @param fieldNames - fields to perform cardinality aggregation on + * @param query + * @param timeFieldName + * @param earliestMs + * @param latestMs + * @param interval - a fixed interval for the date histogram aggregation + */ + async function getMaxBucketCardinalities( + index: string[] | string, + fieldNames: string[], + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number, + interval: string | undefined + ): Promise<{ [key: string]: number }> { + if (!interval) { + throw new Error('Interval is required to retrieve max bucket cardinalities.'); + } + + const aggregatableFields = await getAggregatableFields(index, fieldNames); + + if (aggregatableFields.length === 0) { + return {}; + } + + const { start, end } = getSafeTimeRange(earliestMs, latestMs, interval); + + const mustCriteria = [ + { + range: { + [timeFieldName]: { + gte: start, + lte: end, + format: 'epoch_millis', + }, + }, + }, + ]; + + if (query) { + mustCriteria.push(query); + } + + const dateHistogramAggKey = 'bucket_span_buckets'; + /** + * Replace any non-word characters + */ + const getSafeAggName = (field: string) => field.replace(/\W/g, ''); + const getMaxBucketAggKey = (field: string) => `max_bucket_${field}`; + + const fieldsCardinalityAggs = aggregatableFields.reduce((obj, field) => { + obj[getSafeAggName(field)] = { cardinality: { field } }; + return obj; + }, {} as { [field: string]: { cardinality: { field: string } } }); + + const maxBucketCardinalitiesAggs = Object.keys(fieldsCardinalityAggs).reduce((acc, field) => { + acc[getMaxBucketAggKey(field)] = { + max_bucket: { + buckets_path: `${dateHistogramAggKey}>${field}`, + }, + }; + return acc; + }, {} as { [key: string]: { max_bucket: { buckets_path: string } } }); + + const body = { + query: { + bool: { + filter: mustCriteria, + }, + }, + size: 0, + aggs: { + [dateHistogramAggKey]: { + date_histogram: { + field: timeFieldName, + fixed_interval: interval, + }, + aggs: fieldsCardinalityAggs, + }, + ...maxBucketCardinalitiesAggs, + }, + }; + + const aggregations = ( + await callAsCurrentUser('search', { + index, + body, + }) + )?.aggregations; + + if (!aggregations) { + return {}; + } + + return aggregatableFields.reduce((obj, field) => { + obj[field] = (aggregations[getMaxBucketAggKey(field)] || { value: 0 }).value ?? 0; + return obj; + }, {} as { [field: string]: number }); + } + + return { + getCardinalityOfFields, + getTimeFieldRange, + getMaxBucketCardinalities, + }; +} diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts index ea2c71b04f56d..b209dc5681563 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts @@ -6,7 +6,7 @@ import { chunk } from 'lodash'; import { SearchResponse } from 'elasticsearch'; -import { CATEGORY_EXAMPLES_SAMPLE_SIZE } from '../../../../../common/constants/new_job'; +import { CATEGORY_EXAMPLES_SAMPLE_SIZE } from '../../../../../common/constants/categorization_job'; import { Token, CategorizationAnalyzer, diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts index 34e63eabb405e..e3b37fffa9c77 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/validation_results.ts @@ -6,10 +6,13 @@ import { i18n } from '@kbn/i18n'; import { + VALID_TOKEN_COUNT, + MEDIAN_LINE_LENGTH_LIMIT, + NULL_COUNT_PERCENT_LIMIT, CATEGORY_EXAMPLES_VALIDATION_STATUS, CATEGORY_EXAMPLES_ERROR_LIMIT, CATEGORY_EXAMPLES_WARNING_LIMIT, -} from '../../../../../common/constants/new_job'; +} from '../../../../../common/constants/categorization_job'; import { FieldExampleCheck, CategoryFieldExample, @@ -17,10 +20,6 @@ import { } from '../../../../../common/types/categories'; import { getMedianStringLength } from '../../../../../common/util/string_utils'; -const VALID_TOKEN_COUNT = 3; -const MEDIAN_LINE_LENGTH_LIMIT = 400; -const NULL_COUNT_PERCENT_LIMIT = 0.75; - export class ValidationResults { private _results: FieldExampleCheck[] = []; @@ -187,7 +186,6 @@ export class ValidationResults { valid: CATEGORY_EXAMPLES_VALIDATION_STATUS.PARTIALLY_VALID, message, }); - return; } } diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_job_object.js b/x-pack/plugins/ml/server/models/job_validation/validate_job_object.ts similarity index 96% rename from x-pack/plugins/ml/server/models/job_validation/validate_job_object.js rename to x-pack/plugins/ml/server/models/job_validation/validate_job_object.ts index 3205aba4fac4d..b0271fb5b4f45 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_job_object.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_job_object.ts @@ -5,8 +5,9 @@ */ import { i18n } from '@kbn/i18n'; +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; -export function validateJobObject(job) { +export function validateJobObject(job: CombinedJob | null) { if (job === null || typeof job !== 'object') { throw new Error( i18n.translate('xpack.ml.models.jobValidation.validateJobObject.jobIsNotObjectErrorMessage', { diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.js b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.js deleted file mode 100644 index 733ed9c3c22c6..0000000000000 --- a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.js +++ /dev/null @@ -1,170 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import numeral from '@elastic/numeral'; -import { validateJobObject } from './validate_job_object'; -import { calculateModelMemoryLimitProvider } from '../../models/calculate_model_memory_limit'; -import { ALLOWED_DATA_UNITS } from '../../../common/constants/validation'; - -// The minimum value the backend expects is 1MByte -const MODEL_MEMORY_LIMIT_MINIMUM_BYTES = 1048576; - -export async function validateModelMemoryLimit(callWithRequest, job, duration) { - validateJobObject(job); - - // retrieve the max_model_memory_limit value from the server - // this will be unset unless the user has set this on their cluster - const mlInfo = await callWithRequest('ml.info'); - const maxModelMemoryLimit = - typeof mlInfo.limits === 'undefined' ? undefined : mlInfo.limits.max_model_memory_limit; - - // retrieve the model memory limit specified by the user in the job config. - // note, this will probably be the auto generated value, unless the user has - // over written it. - const mml = - typeof job.analysis_limits !== 'undefined' && - typeof job.analysis_limits.model_memory_limit !== 'undefined' - ? job.analysis_limits.model_memory_limit.toUpperCase() - : null; - - const splitFieldNames = {}; - let splitFieldName = ''; - const fieldNames = []; - let runCalcModelMemoryTest = true; - let validModelMemoryLimit = true; - - // extract the field names and partition field names from the detectors - // we only want to estimate the mml for multi-metric jobs. - // a multi-metric job will have one partition field, one or more field names - // and no over or by fields - job.analysis_config.detectors.forEach(d => { - if (typeof d.field_name !== 'undefined') { - fieldNames.push(d.field_name); - } - - // create a deduplicated list of partition field names. - if (typeof d.partition_field_name !== 'undefined') { - splitFieldNames[d.partition_field_name] = null; - } - - // if an over or by field is present, do not run the estimate test - if (typeof d.over_field_name !== 'undefined' || typeof d.by_field_name !== 'undefined') { - runCalcModelMemoryTest = false; - } - }); - - // if there are no or more than one partition fields, do not run the test - if (Object.keys(splitFieldNames).length === 1) { - splitFieldName = Object.keys(splitFieldNames)[0]; - } else { - runCalcModelMemoryTest = false; - } - - // if there is no duration, do not run the estimate test - if ( - typeof duration === 'undefined' || - typeof duration.start === 'undefined' || - typeof duration.end === 'undefined' - ) { - runCalcModelMemoryTest = false; - } - - const messages = []; - - // check that mml is a valid data format - if (mml !== null) { - const mmlSplit = mml.match(/\d+(\w+)/); - const unit = mmlSplit && mmlSplit.length === 2 ? mmlSplit[1] : null; - - if (ALLOWED_DATA_UNITS.indexOf(unit) === -1) { - messages.push({ - id: 'mml_value_invalid', - mml, - }); - // mml is not a valid data format. - // abort all other tests - validModelMemoryLimit = false; - } - } - - if (validModelMemoryLimit) { - if (runCalcModelMemoryTest) { - const mmlEstimate = await calculateModelMemoryLimitProvider(callWithRequest)( - job.datafeed_config.indices.join(','), - splitFieldName, - job.datafeed_config.query, - fieldNames, - job.analysis_config.influencers, - job.data_description.time_field, - duration.start, - duration.end, - true - ); - const mmlEstimateBytes = numeral(mmlEstimate.modelMemoryLimit).value(); - - let runEstimateGreaterThenMml = true; - // if max_model_memory_limit has been set, - // make sure the estimated value is not greater than it. - if (typeof maxModelMemoryLimit !== 'undefined') { - const maxMmlBytes = numeral(maxModelMemoryLimit.toUpperCase()).value(); - if (mmlEstimateBytes > maxMmlBytes) { - runEstimateGreaterThenMml = false; - messages.push({ - id: 'estimated_mml_greater_than_max_mml', - maxModelMemoryLimit, - mmlEstimate, - }); - } - } - - // check to see if the estimated mml is greater that the user - // specified mml - // do not run this if we've already found that it's larger than - // the max mml - if (runEstimateGreaterThenMml && mml !== null) { - const mmlBytes = numeral(mml).value(); - if (mmlBytes < MODEL_MEMORY_LIMIT_MINIMUM_BYTES) { - messages.push({ - id: 'mml_value_invalid', - mml, - }); - } else if (mmlEstimateBytes / 2 > mmlBytes) { - messages.push({ - id: 'half_estimated_mml_greater_than_mml', - maxModelMemoryLimit, - mml, - }); - } else if (mmlEstimateBytes > mmlBytes) { - messages.push({ - id: 'estimated_mml_greater_than_mml', - maxModelMemoryLimit, - mml, - }); - } - } - } - - // if max_model_memory_limit has been set, - // make sure the user defined MML is not greater than it - if (maxModelMemoryLimit !== undefined && mml !== null) { - const maxMmlBytes = numeral(maxModelMemoryLimit.toUpperCase()).value(); - const mmlBytes = numeral(mml).value(); - if (mmlBytes > maxMmlBytes) { - messages.push({ - id: 'mml_greater_than_max_mml', - maxModelMemoryLimit, - mml, - }); - } - } - } - - if (messages.length === 0 && runCalcModelMemoryTest === true) { - messages.push({ id: 'success_mml' }); - } - - return Promise.resolve(messages); -} diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_model_memory_limit.js b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts similarity index 60% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/validate_model_memory_limit.js rename to x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts index f2459fa339005..6b5d5614325bf 100644 --- a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_model_memory_limit.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts @@ -4,8 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { validateModelMemoryLimit } from '../validate_model_memory_limit'; +import { APICaller } from 'kibana/server'; +import { CombinedJob, Detector } from '../../../common/types/anomaly_detection_jobs'; +import { ModelMemoryEstimate } from '../calculate_model_memory_limit/calculate_model_memory_limit'; +import { validateModelMemoryLimit } from './validate_model_memory_limit'; describe('ML - validateModelMemoryLimit', () => { // mock info endpoint response @@ -61,29 +63,43 @@ describe('ML - validateModelMemoryLimit', () => { }, }; + // mock estimate model memory + const modelMemoryEstimateResponse: ModelMemoryEstimate = { + model_memory_estimate: '40mb', + }; + + interface MockAPICallResponse { + 'ml.estimateModelMemory'?: ModelMemoryEstimate; + } + // mock callWithRequest // used in three places: // - to retrieve the info endpoint // - to search for cardinality of split field // - to retrieve field capabilities used in search for split field cardinality - function callWithRequest(call) { - if (typeof call === undefined) { - return Promise.reject(); - } - - let response = {}; - if (call === 'ml.info') { - response = mlInfoResponse; - } else if (call === 'search') { - response = cardinalitySearchResponse; - } else if (call === 'fieldCaps') { - response = fieldCapsResponse; - } - return Promise.resolve(response); - } - - function getJobConfig(influencers = [], detectors = []) { - return { + const getMockCallWithRequest = ({ + 'ml.estimateModelMemory': estimateModelMemory, + }: MockAPICallResponse = {}) => + ((call: string) => { + if (typeof call === undefined) { + return Promise.reject(); + } + + let response = {}; + if (call === 'ml.info') { + response = mlInfoResponse; + } else if (call === 'search') { + response = cardinalitySearchResponse; + } else if (call === 'fieldCaps') { + response = fieldCapsResponse; + } else if (call === 'ml.estimateModelMemory') { + response = estimateModelMemory || modelMemoryEstimateResponse; + } + return Promise.resolve(response); + }) as APICaller; + + function getJobConfig(influencers: string[] = [], detectors: Detector[] = []) { + return ({ analysis_config: { detectors, influencers }, data_description: { time_field: '@timestamp' }, datafeed_config: { @@ -92,11 +108,11 @@ describe('ML - validateModelMemoryLimit', () => { analysis_limits: { model_memory_limit: '20mb', }, - }; + } as unknown) as CombinedJob; } // create a specified number of mock detectors - function createDetectors(numberOfDetectors) { + function createDetectors(numberOfDetectors: number): Detector[] { const dtrs = []; for (let i = 0; i < numberOfDetectors; i++) { dtrs.push({ @@ -105,28 +121,28 @@ describe('ML - validateModelMemoryLimit', () => { partition_field_name: 'instance', }); } - return dtrs; + return dtrs as Detector[]; } - // tests it('Called with no duration or split and mml within limit', () => { const job = getJobConfig(); const duration = undefined; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([]); + expect(ids).toEqual([]); }); }); it('Called with no duration or split and mml above limit', () => { const job = getJobConfig(); const duration = undefined; + // @ts-ignore job.analysis_limits.model_memory_limit = '31mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_greater_than_max_mml']); + expect(ids).toEqual(['mml_greater_than_max_mml']); }); }); @@ -134,11 +150,16 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(10); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '20mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit( + getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '66mb' } }), + job, + duration + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['estimated_mml_greater_than_max_mml']); + expect(ids).toEqual(['estimated_mml_greater_than_max_mml']); }); }); @@ -146,11 +167,16 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(2); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '30mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit( + getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '24mb' } }), + job, + duration + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_mml']); + expect(ids).toEqual(['success_mml']); }); }); @@ -158,11 +184,16 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(2); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '10mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit( + getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '22mb' } }), + job, + duration + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['half_estimated_mml_greater_than_mml']); + expect(ids).toEqual(['half_estimated_mml_greater_than_mml']); }); }); @@ -171,11 +202,12 @@ describe('ML - validateModelMemoryLimit', () => { const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; delete mlInfoResponse.limits.max_model_memory_limit; + // @ts-ignore job.analysis_limits.model_memory_limit = '10mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['half_estimated_mml_greater_than_mml']); + expect(ids).toEqual(['half_estimated_mml_greater_than_mml']); }); }); @@ -183,11 +215,16 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '20mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit( + getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '19mb' } }), + job, + duration + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_mml']); + expect(ids).toEqual(['success_mml']); }); }); @@ -195,11 +232,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '0mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_value_invalid']); + expect(ids).toEqual(['mml_value_invalid']); }); }); @@ -207,11 +245,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '10mbananas'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_value_invalid']); + expect(ids).toEqual(['mml_value_invalid']); }); }); @@ -219,11 +258,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '10'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_value_invalid']); + expect(ids).toEqual(['mml_value_invalid']); }); }); @@ -231,11 +271,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = 'mb'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_value_invalid']); + expect(ids).toEqual(['mml_value_invalid']); }); }); @@ -243,11 +284,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = 'asdf'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_value_invalid']); + expect(ids).toEqual(['mml_value_invalid']); }); }); @@ -255,11 +297,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '1023KB'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['mml_value_invalid']); + expect(ids).toEqual(['mml_value_invalid']); }); }); @@ -267,11 +310,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '1024KB'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['half_estimated_mml_greater_than_mml']); + expect(ids).toEqual(['half_estimated_mml_greater_than_mml']); }); }); @@ -279,11 +323,12 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '6MB'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['half_estimated_mml_greater_than_mml']); + expect(ids).toEqual(['half_estimated_mml_greater_than_mml']); }); }); @@ -291,11 +336,16 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; + // @ts-ignore job.analysis_limits.model_memory_limit = '20MB'; - return validateModelMemoryLimit(callWithRequest, job, duration).then(messages => { + return validateModelMemoryLimit( + getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '20mb' } }), + job, + duration + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_mml']); + expect(ids).toEqual(['success_mml']); }); }); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts new file mode 100644 index 0000000000000..0c431f6a07563 --- /dev/null +++ b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import numeral from '@elastic/numeral'; +import { APICaller } from 'kibana/server'; +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; +import { validateJobObject } from './validate_job_object'; +import { calculateModelMemoryLimitProvider } from '../calculate_model_memory_limit'; +import { ALLOWED_DATA_UNITS } from '../../../common/constants/validation'; + +// The minimum value the backend expects is 1MByte +const MODEL_MEMORY_LIMIT_MINIMUM_BYTES = 1048576; + +export async function validateModelMemoryLimit( + callWithRequest: APICaller, + job: CombinedJob, + duration?: { start?: number; end?: number } +) { + validateJobObject(job); + + // retrieve the model memory limit specified by the user in the job config. + // note, this will probably be the auto generated value, unless the user has + // over written it. + const mml = job?.analysis_limits?.model_memory_limit?.toUpperCase() ?? null; + + const messages = []; + + // check that mml is a valid data format + if (mml !== null) { + const mmlSplit = mml.match(/\d+(\w+)/); + const unit = mmlSplit && mmlSplit.length === 2 ? mmlSplit[1] : null; + + if (unit === null || !ALLOWED_DATA_UNITS.includes(unit)) { + messages.push({ + id: 'mml_value_invalid', + mml, + }); + // mml is not a valid data format. + // abort all other tests + return messages; + } + } + + // if there is no duration, do not run the estimate test + const runCalcModelMemoryTest = + duration && typeof duration?.start !== undefined && duration?.end !== undefined; + + // retrieve the max_model_memory_limit value from the server + // this will be unset unless the user has set this on their cluster + const maxModelMemoryLimit: string | undefined = ( + await callWithRequest('ml.info') + )?.limits?.max_model_memory_limit?.toUpperCase(); + + if (runCalcModelMemoryTest) { + const { modelMemoryLimit } = await calculateModelMemoryLimitProvider(callWithRequest)( + job.analysis_config, + job.datafeed_config.indices.join(','), + job.datafeed_config.query, + job.data_description.time_field, + duration!.start as number, + duration!.end as number, + true + ); + // @ts-ignore + const mmlEstimateBytes: number = numeral(modelMemoryLimit).value(); + + let runEstimateGreaterThenMml = true; + // if max_model_memory_limit has been set, + // make sure the estimated value is not greater than it. + if (typeof maxModelMemoryLimit !== 'undefined') { + // @ts-ignore + const maxMmlBytes: number = numeral(maxModelMemoryLimit).value(); + if (mmlEstimateBytes > maxMmlBytes) { + runEstimateGreaterThenMml = false; + messages.push({ + id: 'estimated_mml_greater_than_max_mml', + maxModelMemoryLimit, + modelMemoryLimit, + }); + } + } + + // check to see if the estimated mml is greater that the user + // specified mml + // do not run this if we've already found that it's larger than + // the max mml + if (runEstimateGreaterThenMml && mml !== null) { + // @ts-ignore + const mmlBytes: number = numeral(mml).value(); + if (mmlBytes < MODEL_MEMORY_LIMIT_MINIMUM_BYTES) { + messages.push({ + id: 'mml_value_invalid', + mml, + }); + } else if (mmlEstimateBytes / 2 > mmlBytes) { + messages.push({ + id: 'half_estimated_mml_greater_than_mml', + maxModelMemoryLimit, + mml, + }); + } else if (mmlEstimateBytes > mmlBytes) { + messages.push({ + id: 'estimated_mml_greater_than_mml', + maxModelMemoryLimit, + mml, + }); + } + } + } + + // if max_model_memory_limit has been set, + // make sure the user defined MML is not greater than it + if (maxModelMemoryLimit !== undefined && mml !== null) { + // @ts-ignore + const maxMmlBytes = numeral(maxModelMemoryLimit).value(); + // @ts-ignore + const mmlBytes = numeral(mml).value(); + if (mmlBytes > maxMmlBytes) { + messages.push({ + id: 'mml_greater_than_max_mml', + maxModelMemoryLimit, + mml, + }); + } + } + + if (messages.length === 0 && runCalcModelMemoryTest === true) { + messages.push({ id: 'success_mml' }); + } + + return messages; +} diff --git a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts index c6bb62aa34916..d03e76072c315 100644 --- a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts +++ b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts @@ -148,7 +148,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { params: schema.object({ jobId: schema.string(), }), - body: schema.object({ ...anomalyDetectionJobSchema }), + body: schema.object(anomalyDetectionJobSchema), }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { diff --git a/x-pack/plugins/ml/server/routes/job_validation.ts b/x-pack/plugins/ml/server/routes/job_validation.ts index fd5c8dc7e9a7a..75d9cdf375049 100644 --- a/x-pack/plugins/ml/server/routes/job_validation.ts +++ b/x-pack/plugins/ml/server/routes/job_validation.ts @@ -7,6 +7,7 @@ import Boom from 'boom'; import { RequestHandlerContext } from 'kibana/server'; import { schema, TypeOf } from '@kbn/config-schema'; +import { AnalysisConfig } from '../../common/types/anomaly_detection_jobs'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; import { @@ -29,23 +30,12 @@ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, context: RequestHandlerContext, payload: CalculateModelMemoryLimitPayload ) { - const { - indexPattern, - splitFieldName, - query, - fieldNames, - influencerNames, - timeFieldName, - earliestMs, - latestMs, - } = payload; + const { analysisConfig, indexPattern, query, timeFieldName, earliestMs, latestMs } = payload; return calculateModelMemoryLimitProvider(context.ml!.mlClient.callAsCurrentUser)( + analysisConfig as AnalysisConfig, indexPattern, - splitFieldName, query, - fieldNames, - influencerNames, timeFieldName, earliestMs, latestMs @@ -102,7 +92,7 @@ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, * * @api {post} /api/ml/validate/calculate_model_memory_limit Calculates model memory limit * @apiName CalculateModelMemoryLimit - * @apiDescription Calculates the model memory limit + * @apiDescription Calls _estimate_model_memory endpoint to retrieve model memory estimation. * * @apiSuccess {String} modelMemoryLimit */ diff --git a/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts b/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts index a46ccd8664a62..6002bb218c41b 100644 --- a/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/anomaly_detectors_schema.ts @@ -63,14 +63,16 @@ export const anomalyDetectionUpdateJobSchema = { groups: schema.maybe(schema.arrayOf(schema.maybe(schema.string()))), }; +export const analysisConfigSchema = schema.object({ + bucket_span: schema.maybe(schema.string()), + summary_count_field_name: schema.maybe(schema.string()), + detectors: schema.arrayOf(detectorSchema), + influencers: schema.arrayOf(schema.maybe(schema.string())), + categorization_field_name: schema.maybe(schema.string()), +}); + export const anomalyDetectionJobSchema = { - analysis_config: schema.object({ - bucket_span: schema.maybe(schema.string()), - summary_count_field_name: schema.maybe(schema.string()), - detectors: schema.arrayOf(detectorSchema), - influencers: schema.arrayOf(schema.maybe(schema.string())), - categorization_field_name: schema.maybe(schema.string()), - }), + analysis_config: analysisConfigSchema, analysis_limits: schema.maybe( schema.object({ categorization_examples_limit: schema.maybe(schema.number()), diff --git a/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts b/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts index 5da825a905e8d..3ded6e770eed5 100644 --- a/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts @@ -5,7 +5,7 @@ */ import { schema } from '@kbn/config-schema'; -import { anomalyDetectionJobSchema } from './anomaly_detectors_schema'; +import { analysisConfigSchema, anomalyDetectionJobSchema } from './anomaly_detectors_schema'; import { datafeedConfigSchema } from './datafeeds_schema'; export const estimateBucketSpanSchema = schema.object({ @@ -20,11 +20,9 @@ export const estimateBucketSpanSchema = schema.object({ }); export const modelMemoryLimitSchema = schema.object({ + analysisConfig: analysisConfigSchema, indexPattern: schema.string(), - splitFieldName: schema.string(), query: schema.any(), - fieldNames: schema.arrayOf(schema.string()), - influencerNames: schema.arrayOf(schema.maybe(schema.string())), timeFieldName: schema.string(), earliestMs: schema.number(), latestMs: schema.number(), diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap index 88b869b1d1d8f..b5bf4057f0e36 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap @@ -126,7 +126,7 @@ exports[`RemoteClusterForm proxy mode renders correct connection settings when u @@ -209,11 +209,11 @@ exports[`RemoteClusterForm proxy mode renders correct connection settings when u className="euiTextColor euiTextColor--subdued" > - A unique name for the remote cluster. + A unique name for the cluster.

      @@ -364,7 +364,7 @@ exports[`RemoteClusterForm proxy mode renders correct connection settings when u description={ @@ -374,25 +374,6 @@ exports[`RemoteClusterForm proxy mode renders correct connection settings when u fullWidth={true} hasChildLabel={true} hasEmptyLabelSpace={true} - helpText={ - - - , - } - } - /> - } labelType="label" > @@ -488,11 +469,11 @@ exports[`RemoteClusterForm proxy mode renders correct connection settings when u className="euiTextColor euiTextColor--subdued" > - Remote cluster connections work by configuring a remote cluster and connecting only to a limited number of nodes in that remote cluster. + Use seed nodes by default, or switch to a single proxy address. - - , - } - } - /> - } labelType="label" >
      @@ -549,7 +510,6 @@ exports[`RemoteClusterForm proxy mode renders correct connection settings when u >
      - -
      - - - , - } - } - > - Configure a remote cluster with a single proxy address. - - - - -
      -
      @@ -685,7 +600,7 @@ exports[`RemoteClusterForm proxy mode renders correct connection settings when u hasEmptyLabelSpace={false} helpText={ @@ -786,11 +701,11 @@ exports[`RemoteClusterForm proxy mode renders correct connection settings when u id="mockId-help" > - The address used for all remote connections. + The address to use for remote connections.
      @@ -920,14 +835,26 @@ exports[`RemoteClusterForm proxy mode renders correct connection settings when u hasEmptyLabelSpace={false} helpText={ + + , + } + } /> } label={ @@ -953,11 +880,11 @@ exports[`RemoteClusterForm proxy mode renders correct connection settings when u htmlFor="mockId" > - Server name + Server name (optional) @@ -1010,11 +937,39 @@ exports[`RemoteClusterForm proxy mode renders correct connection settings when u id="mockId-help" > + + , + } + } > - An optional hostname string which will be sent in the server_name field of the TLS Server Name Indication extension if TLS is enabled. + A string sent in the server_name field of the TLS Server Name Indication extension if TLS is enabled. + + +
      @@ -1033,7 +988,7 @@ exports[`RemoteClusterForm proxy mode renders correct connection settings when u

      - By default, a request fails if any of the queried remote clusters are unavailable. To continue sending a request to other remote clusters if this cluster is unavailable, enable + A request fails if any of the queried remote clusters are unavailable. To send requests to other remote clusters if this cluster is unavailable, enable - A unique name for the remote cluster. + A unique name for the cluster.

      @@ -1534,7 +1489,7 @@ Array [
      - Remote cluster connections work by configuring a remote cluster and connecting only to a limited number of nodes in that remote cluster. + Use seed nodes by default, or switch to a single proxy address.
      -
      - Configure a remote cluster with a single proxy address. - -
      @@ -1717,7 +1659,7 @@ Array [ class="euiFormHelpText euiFormRow__text" id="mockId-help" > - The number of gateway nodes to connect to. + The number of gateway nodes to connect to for this cluster.
      @@ -1751,7 +1693,7 @@ Array [ class="euiTextColor euiTextColor--subdued" >

      - By default, a request fails if any of the queried remote clusters are unavailable. To continue sending a request to other remote clusters if this cluster is unavailable, enable + A request fails if any of the queried remote clusters are unavailable. To send requests to other remote clusters if this cluster is unavailable, enable Skip if unavailable diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.js b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.js index 358ffc03da783..94d6ca4ebb648 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_form/remote_cluster_form.js @@ -37,7 +37,7 @@ import { import { skippingDisconnectedClustersUrl, transportPortUrl, - proxyModeUrl, + proxySettingsUrl, } from '../../../services/documentation'; import { RequestFlyout } from './request_flyout'; @@ -328,7 +328,7 @@ export class RemoteClusterForm extends Component { helpText={ } fullWidth @@ -363,7 +363,7 @@ export class RemoteClusterForm extends Component { helpText={ } isInvalid={Boolean(areErrorsVisible && errorProxyAddress)} @@ -414,13 +414,23 @@ export class RemoteClusterForm extends Component { label={ } helpText={ + + + ), + }} /> } fullWidth @@ -456,33 +466,14 @@ export class RemoteClusterForm extends Component { <> - - - - ), - }} - /> - } - > + } checked={mode === PROXY_MODE} @@ -523,9 +514,7 @@ export class RemoteClusterForm extends Component {

      @@ -839,7 +828,7 @@ export class RemoteClusterForm extends Component { description={ } fullWidth diff --git a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_page_title/remote_cluster_page_title.js b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_page_title/remote_cluster_page_title.js index 82d8a7b0cfa8b..5a3b1faedad2b 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_page_title/remote_cluster_page_title.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/components/remote_cluster_page_title/remote_cluster_page_title.js @@ -7,23 +7,22 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; +import { remoteClustersUrl } from '../../../services/documentation'; import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, - EuiPageContentHeader, EuiSpacer, + EuiText, EuiTitle, } from '@elastic/eui'; -import { remoteClustersUrl } from '../../../services/documentation'; - -export const RemoteClusterPageTitle = ({ title }) => ( +export const RemoteClusterPageTitle = ({ title, description }) => ( - + @@ -47,10 +46,23 @@ export const RemoteClusterPageTitle = ({ title }) => ( - + + + {description ? ( + <> + + + + {description} + + + ) : null} + + ); RemoteClusterPageTitle.propTypes = { title: PropTypes.node.isRequired, + description: PropTypes.node, }; diff --git a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js index 4a861695c0eb3..0531310bd097b 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_add/remote_cluster_add.js @@ -65,6 +65,12 @@ export class RemoteClusterAdd extends PureComponent { defaultMessage="Add remote cluster" /> } + description={ + + } /> diff --git a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/detail_panel/detail_panel.js b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/detail_panel/detail_panel.js index 89a48927f6833..4006422d3df50 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/detail_panel/detail_panel.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/detail_panel/detail_panel.js @@ -125,7 +125,7 @@ export class DetailPanel extends Component { title={ - - - - ) : ( - - - - ), - }} - /> + {/* A remote cluster is not editable if configured in elasticsearch.yml, so we direct the user to documentation instead */} + {isConfiguredByNode ? ( + + + + ), + }} + /> + ) : ( + + + + ), + }} + /> + )} @@ -249,7 +259,7 @@ export class DetailPanel extends Component { @@ -363,7 +373,7 @@ export class DetailPanel extends Component { diff --git a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/remote_cluster_table/remote_cluster_table.js b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/remote_cluster_table/remote_cluster_table.js index ec20805ccd919..73f32fe8bca5b 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/remote_cluster_table/remote_cluster_table.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/remote_cluster_table/remote_cluster_table.js @@ -141,7 +141,7 @@ export class RemoteClusterTable extends Component { content={ } /> diff --git a/x-pack/plugins/remote_clusters/public/application/services/documentation.ts b/x-pack/plugins/remote_clusters/public/application/services/documentation.ts index f6f5dc987c2eb..76744e90096da 100644 --- a/x-pack/plugins/remote_clusters/public/application/services/documentation.ts +++ b/x-pack/plugins/remote_clusters/public/application/services/documentation.ts @@ -10,6 +10,7 @@ export let skippingDisconnectedClustersUrl: string; export let remoteClustersUrl: string; export let transportPortUrl: string; export let proxyModeUrl: string; +export let proxySettingsUrl: string; export function init(docLinks: DocLinksStart): void { const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docLinks; @@ -19,4 +20,5 @@ export function init(docLinks: DocLinksStart): void { remoteClustersUrl = `${esDocBasePath}/modules-remote-clusters.html`; transportPortUrl = `${esDocBasePath}/modules-transport.html`; proxyModeUrl = `${esDocBasePath}/modules-remote-clusters.html#proxy-mode`; + proxySettingsUrl = `${esDocBasePath}/modules-remote-clusters.html#remote-cluster-proxy-settings`; } diff --git a/x-pack/plugins/reporting/public/components/__snapshots__/report_listing.test.tsx.snap b/x-pack/plugins/reporting/public/components/__snapshots__/report_listing.test.tsx.snap index b5304c6020c43..1da95dd0ba197 100644 --- a/x-pack/plugins/reporting/public/components/__snapshots__/report_listing.test.tsx.snap +++ b/x-pack/plugins/reporting/public/components/__snapshots__/report_listing.test.tsx.snap @@ -2,6 +2,526 @@ exports[`ReportListing Report job listing with some items 1`] = ` Array [ + +

      + + +
      + +
      + + + +
      +
      + + + + +
      + + +
      +
      + + + +
      + +
      + + + +
      + + +
      +
      + +
      + +
      + +
      + + +
      + +
      + +
      + + +
      + + +
      + +
      + +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      + +
      +
      + + +
      + +
      +
      + + +
      +
      +
      + + Report + +
      +
      +
      + + Created at + +
      +
      +
      + + Status + +
      +
      +
      + + Actions + +
      +
      +
      + + Loading reports + +
      +
      +
      +
      +
      + +
      + ,
      + > + + +
      + +
      + +
      + + +
      + + +
      + + +
      + +
      +
      + + +
      + +
      + > + + +
      + +
      + +
      + + +
      + + +
      + + +
      + +
      +
      + + +
      + + Promise; +type Props = { jobsToDelete: Job[]; performDelete: DeleteFn } & ListingProps; +interface State { + showConfirm: boolean; +} + +export class ReportDeleteButton extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { showConfirm: false }; + } + + private hideConfirm() { + this.setState({ showConfirm: false }); + } + + private showConfirm() { + this.setState({ showConfirm: true }); + } + + private renderConfirm() { + const { intl, jobsToDelete } = this.props; + + const title = + jobsToDelete.length > 1 + ? intl.formatMessage( + { + id: 'xpack.reporting.listing.table.deleteNumConfirmTitle', + defaultMessage: `Delete {num} reports?`, + }, + { num: jobsToDelete.length } + ) + : intl.formatMessage( + { + id: 'xpack.reporting.listing.table.deleteConfirmTitle', + defaultMessage: `Delete the "{name}" report?`, + }, + { name: jobsToDelete[0].object_title } + ); + const message = intl.formatMessage({ + id: 'xpack.reporting.listing.table.deleteConfirmMessage', + defaultMessage: `You can't recover deleted reports.`, + }); + const confirmButtonText = intl.formatMessage({ + id: 'xpack.reporting.listing.table.deleteConfirmButton', + defaultMessage: `Delete`, + }); + const cancelButtonText = intl.formatMessage({ + id: 'xpack.reporting.listing.table.deleteCancelButton', + defaultMessage: `Cancel`, + }); + + return ( + + this.hideConfirm()} + onConfirm={() => this.props.performDelete()} + confirmButtonText={confirmButtonText} + cancelButtonText={cancelButtonText} + defaultFocusedButton="confirm" + buttonColor="danger" + > + {message} + + + ); + } + + public render() { + const { jobsToDelete, intl } = this.props; + if (jobsToDelete.length === 0) return null; + + return ( + + this.showConfirm()} iconType="trash" color={'danger'}> + {intl.formatMessage( + { + id: 'xpack.reporting.listing.table.deleteReportButton', + defaultMessage: `Delete ({num})`, + }, + { num: jobsToDelete.length } + )} + + {this.state.showConfirm ? this.renderConfirm() : null} + + ); + } +} diff --git a/x-pack/plugins/reporting/public/components/buttons/report_download_button.tsx b/x-pack/plugins/reporting/public/components/buttons/report_download_button.tsx new file mode 100644 index 0000000000000..b0674c149609d --- /dev/null +++ b/x-pack/plugins/reporting/public/components/buttons/report_download_button.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import React, { FunctionComponent } from 'react'; +import { JobStatuses } from '../../../constants'; +import { Job as ListingJob, Props as ListingProps } from '../report_listing'; + +type Props = { record: ListingJob } & ListingProps; + +export const ReportDownloadButton: FunctionComponent = (props: Props) => { + const { record, apiClient, intl } = props; + + if (record.status !== JobStatuses.COMPLETED) { + return null; + } + + const button = ( + apiClient.downloadReport(record.id)} + iconType="importAction" + aria-label={intl.formatMessage({ + id: 'xpack.reporting.listing.table.downloadReportAriaLabel', + defaultMessage: 'Download report', + })} + /> + ); + + if (record.csv_contains_formulas) { + return ( + + {button} + + ); + } + + if (record.max_size_reached) { + return ( + + {button} + + ); + } + + return ( + + {button} + + ); +}; diff --git a/x-pack/plugins/reporting/public/components/report_error_button.tsx b/x-pack/plugins/reporting/public/components/buttons/report_error_button.tsx similarity index 83% rename from x-pack/plugins/reporting/public/components/report_error_button.tsx rename to x-pack/plugins/reporting/public/components/buttons/report_error_button.tsx index 252dee9c619a9..1e33cc0188b8c 100644 --- a/x-pack/plugins/reporting/public/components/report_error_button.tsx +++ b/x-pack/plugins/reporting/public/components/buttons/report_error_button.tsx @@ -7,12 +7,14 @@ import { EuiButtonIcon, EuiCallOut, EuiPopover } from '@elastic/eui'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; import React, { Component } from 'react'; -import { JobContent, ReportingAPIClient } from '../lib/reporting_api_client'; +import { JobStatuses } from '../../../constants'; +import { JobContent, ReportingAPIClient } from '../../lib/reporting_api_client'; +import { Job as ListingJob } from '../report_listing'; interface Props { - jobId: string; intl: InjectedIntl; apiClient: ReportingAPIClient; + record: ListingJob; } interface State { @@ -39,12 +41,18 @@ class ReportErrorButtonUi extends Component { } public render() { + const { record, intl } = this.props; + + if (record.status !== JobStatuses.FAILED) { + return null; + } + const button = ( { }; private loadError = async () => { + const { record, apiClient, intl } = this.props; + this.setState({ isLoading: true }); try { - const reportContent: JobContent = await this.props.apiClient.getContent(this.props.jobId); + const reportContent: JobContent = await apiClient.getContent(record.id); if (this.mounted) { this.setState({ isLoading: false, error: reportContent.content }); } @@ -99,7 +109,7 @@ class ReportErrorButtonUi extends Component { if (this.mounted) { this.setState({ isLoading: false, - calloutTitle: this.props.intl.formatMessage({ + calloutTitle: intl.formatMessage({ id: 'xpack.reporting.errorButton.unableToFetchReportContentTitle', defaultMessage: 'Unable to fetch report content', }), diff --git a/x-pack/plugins/reporting/public/components/report_info_button.test.tsx b/x-pack/plugins/reporting/public/components/buttons/report_info_button.test.tsx similarity index 94% rename from x-pack/plugins/reporting/public/components/report_info_button.test.tsx rename to x-pack/plugins/reporting/public/components/buttons/report_info_button.test.tsx index 2edd59e6de7a3..028a8e960040a 100644 --- a/x-pack/plugins/reporting/public/components/report_info_button.test.tsx +++ b/x-pack/plugins/reporting/public/components/buttons/report_info_button.test.tsx @@ -7,9 +7,10 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ReportInfoButton } from './report_info_button'; -import { ReportingAPIClient } from '../lib/reporting_api_client'; -jest.mock('../lib/reporting_api_client'); +jest.mock('../../lib/reporting_api_client'); + +import { ReportingAPIClient } from '../../lib/reporting_api_client'; const httpSetup = {} as any; const apiClient = new ReportingAPIClient(httpSetup); diff --git a/x-pack/plugins/reporting/public/components/report_info_button.tsx b/x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx similarity index 98% rename from x-pack/plugins/reporting/public/components/report_info_button.tsx rename to x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx index 81a5af3b87957..941baa5af6776 100644 --- a/x-pack/plugins/reporting/public/components/report_info_button.tsx +++ b/x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx @@ -17,8 +17,8 @@ import { } from '@elastic/eui'; import React, { Component, Fragment } from 'react'; import { get } from 'lodash'; -import { USES_HEADLESS_JOB_TYPES } from '../../constants'; -import { JobInfo, ReportingAPIClient } from '../lib/reporting_api_client'; +import { USES_HEADLESS_JOB_TYPES } from '../../../constants'; +import { JobInfo, ReportingAPIClient } from '../../lib/reporting_api_client'; interface Props { jobId: string; diff --git a/x-pack/plugins/reporting/public/components/download_button.tsx b/x-pack/plugins/reporting/public/components/job_download_button.tsx similarity index 100% rename from x-pack/plugins/reporting/public/components/download_button.tsx rename to x-pack/plugins/reporting/public/components/job_download_button.tsx diff --git a/x-pack/plugins/reporting/public/components/job_success.tsx b/x-pack/plugins/reporting/public/components/job_success.tsx index c2feac382ca7a..ad16a506aeb70 100644 --- a/x-pack/plugins/reporting/public/components/job_success.tsx +++ b/x-pack/plugins/reporting/public/components/job_success.tsx @@ -9,8 +9,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { JobId, JobSummary } from '../../index.d'; +import { DownloadButton } from './job_download_button'; import { ReportLink } from './report_link'; -import { DownloadButton } from './download_button'; export const getSuccessToast = ( job: JobSummary, diff --git a/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx b/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx index 22f656dbe738c..8717ae16d1ba1 100644 --- a/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx +++ b/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx @@ -9,8 +9,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { JobId, JobSummary } from '../../index.d'; +import { DownloadButton } from './job_download_button'; import { ReportLink } from './report_link'; -import { DownloadButton } from './download_button'; export const getWarningFormulasToast = ( job: JobSummary, diff --git a/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx b/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx index 1abba8888bb81..83fa129f0715a 100644 --- a/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx +++ b/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx @@ -9,8 +9,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { ToastInput } from 'src/core/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { JobId, JobSummary } from '../../index.d'; +import { DownloadButton } from './job_download_button'; import { ReportLink } from './report_link'; -import { DownloadButton } from './download_button'; export const getWarningMaxSizeToast = ( job: JobSummary, diff --git a/x-pack/plugins/reporting/public/components/report_listing.test.tsx b/x-pack/plugins/reporting/public/components/report_listing.test.tsx index 5cf894580eae0..9b541261a690b 100644 --- a/x-pack/plugins/reporting/public/components/report_listing.test.tsx +++ b/x-pack/plugins/reporting/public/components/report_listing.test.tsx @@ -5,12 +5,15 @@ */ import React from 'react'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { ReportListing } from './report_listing'; import { Observable } from 'rxjs'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ILicense } from '../../../licensing/public'; import { ReportingAPIClient } from '../lib/reporting_api_client'; +jest.mock('@elastic/eui/lib/components/form/form_row/make_id', () => () => 'generated-id'); + +import { ReportListing } from './report_listing'; + const reportingAPIClient = { list: () => Promise.resolve([ diff --git a/x-pack/plugins/reporting/public/components/report_listing.tsx b/x-pack/plugins/reporting/public/components/report_listing.tsx index 13fca019f3284..af7ff5941304a 100644 --- a/x-pack/plugins/reporting/public/components/report_listing.tsx +++ b/x-pack/plugins/reporting/public/components/report_listing.tsx @@ -4,34 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ -import { i18n } from '@kbn/i18n'; -import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; -import { get } from 'lodash'; -import moment from 'moment'; -import React, { Component } from 'react'; -import { Subscription } from 'rxjs'; - import { - EuiBasicTable, - EuiButtonIcon, + EuiInMemoryTable, EuiPageContent, EuiSpacer, EuiText, EuiTextColor, EuiTitle, - EuiToolTip, } from '@elastic/eui'; - -import { ToastsSetup, ApplicationStart } from 'src/core/public'; -import { LicensingPluginSetup, ILicense } from '../../../licensing/public'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; +import { get } from 'lodash'; +import moment from 'moment'; +import { Component, default as React } from 'react'; +import { Subscription } from 'rxjs'; +import { ApplicationStart, ToastsSetup } from 'src/core/public'; +import { ILicense, LicensingPluginSetup } from '../../../licensing/public'; import { Poller } from '../../common/poller'; import { JobStatuses, JOB_COMPLETION_NOTIFICATIONS_POLLER_CONFIG } from '../../constants'; -import { ReportingAPIClient, JobQueueEntry } from '../lib/reporting_api_client'; import { checkLicense } from '../lib/license_check'; -import { ReportErrorButton } from './report_error_button'; -import { ReportInfoButton } from './report_info_button'; +import { JobQueueEntry, ReportingAPIClient } from '../lib/reporting_api_client'; +import { + ReportDeleteButton, + ReportDownloadButton, + ReportErrorButton, + ReportInfoButton, +} from './buttons'; -interface Job { +export interface Job { id: string; type: string; object_type: string; @@ -49,7 +49,7 @@ interface Job { warnings: string[]; } -interface Props { +export interface Props { intl: InjectedIntl; apiClient: ReportingAPIClient; license$: LicensingPluginSetup['license$']; @@ -61,6 +61,7 @@ interface State { page: number; total: number; jobs: Job[]; + selectedJobs: Job[]; isLoading: boolean; showLinks: boolean; enableLinks: boolean; @@ -113,6 +114,7 @@ class ReportListingUi extends Component { page: 0, total: 0, jobs: [], + selectedJobs: [], isLoading: false, showLinks: false, enableLinks: false, @@ -182,6 +184,140 @@ class ReportListingUi extends Component { }); }; + private onSelectionChange = (jobs: Job[]) => { + this.setState(current => ({ ...current, selectedJobs: jobs })); + }; + + private removeRecord = (record: Job) => { + const { jobs } = this.state; + const filtered = jobs.filter(j => j.id !== record.id); + this.setState(current => ({ ...current, jobs: filtered })); + }; + + private renderDeleteButton = () => { + const { selectedJobs } = this.state; + if (selectedJobs.length === 0) return null; + + const performDelete = async () => { + for (const record of selectedJobs) { + try { + await this.props.apiClient.deleteReport(record.id); + this.removeRecord(record); + this.props.toasts.addSuccess( + this.props.intl.formatMessage( + { + id: 'xpack.reporting.listing.table.deleteConfim', + defaultMessage: `The {reportTitle} report was deleted`, + }, + { reportTitle: record.object_title } + ) + ); + } catch (error) { + this.props.toasts.addDanger( + this.props.intl.formatMessage( + { + id: 'xpack.reporting.listing.table.deleteFailedErrorMessage', + defaultMessage: `The report was not deleted: {error}`, + }, + { error } + ) + ); + throw error; + } + } + }; + + return ( + + ); + }; + + private onTableChange = ({ page }: { page: { index: number } }) => { + const { index: pageIndex } = page; + this.setState(() => ({ page: pageIndex }), this.fetchJobs); + }; + + private fetchJobs = async () => { + // avoid page flicker when poller is updating table - only display loading screen on first load + if (this.isInitialJobsFetch) { + this.setState(() => ({ isLoading: true })); + } + + let jobs: JobQueueEntry[]; + let total: number; + try { + jobs = await this.props.apiClient.list(this.state.page); + total = await this.props.apiClient.total(); + this.isInitialJobsFetch = false; + } catch (fetchError) { + if (!this.licenseAllowsToShowThisPage()) { + this.props.toasts.addDanger(this.state.badLicenseMessage); + this.props.redirect('kibana#/management'); + return; + } + + if (fetchError.message === 'Failed to fetch') { + this.props.toasts.addDanger( + fetchError.message || + this.props.intl.formatMessage({ + id: 'xpack.reporting.listing.table.requestFailedErrorMessage', + defaultMessage: 'Request failed', + }) + ); + } + if (this.mounted) { + this.setState(() => ({ isLoading: false, jobs: [], total: 0 })); + } + return; + } + + if (this.mounted) { + this.setState(() => ({ + isLoading: false, + total, + jobs: jobs.map( + (job: JobQueueEntry): Job => { + const { _source: source } = job; + return { + id: job._id, + type: source.jobtype, + object_type: source.payload.objectType, + object_title: source.payload.title, + created_by: source.created_by, + created_at: source.created_at, + started_at: source.started_at, + completed_at: source.completed_at, + status: source.status, + statusLabel: jobStatusLabelsMap.get(source.status as JobStatuses) || source.status, + max_size_reached: source.output ? source.output.max_size_reached : false, + attempts: source.attempts, + max_attempts: source.max_attempts, + csv_contains_formulas: get(source, 'output.csv_contains_formulas'), + warnings: source.output ? source.output.warnings : undefined, + }; + } + ), + })); + } + }; + + private licenseAllowsToShowThisPage = () => { + return this.state.showLinks && this.state.enableLinks; + }; + + private formatDate(timestamp: string) { + try { + return moment(timestamp).format('YYYY-MM-DD @ hh:mm A'); + } catch (error) { + // ignore parse error and display unformatted value + return timestamp; + } + } + private renderTable() { const { intl } = this.props; @@ -317,9 +453,9 @@ class ReportListingUi extends Component { render: (record: Job) => { return (
      - {this.renderDownloadButton(record)} - {this.renderReportErrorButton(record)} - {this.renderInfoButton(record)} + + +
      ); }, @@ -335,13 +471,22 @@ class ReportListingUi extends Component { hidePerPageOptions: true, }; + const selection = { + itemId: 'id', + onSelectionChange: this.onSelectionChange, + }; + + const search = { + toolsRight: this.renderDeleteButton(), + }; + return ( - { }) } pagination={pagination} + selection={selection} + search={search} + isSelectable={true} onChange={this.onTableChange} data-test-subj="reportJobListing" /> ); } - - private renderDownloadButton = (record: Job) => { - if (record.status !== JobStatuses.COMPLETED) { - return; - } - - const { intl } = this.props; - const button = ( - this.props.apiClient.downloadReport(record.id)} - iconType="importAction" - aria-label={intl.formatMessage({ - id: 'xpack.reporting.listing.table.downloadReportAriaLabel', - defaultMessage: 'Download report', - })} - /> - ); - - if (record.csv_contains_formulas) { - return ( - - {button} - - ); - } - - if (record.max_size_reached) { - return ( - - {button} - - ); - } - - return button; - }; - - private renderReportErrorButton = (record: Job) => { - if (record.status !== JobStatuses.FAILED) { - return; - } - - return ; - }; - - private renderInfoButton = (record: Job) => { - return ; - }; - - private onTableChange = ({ page }: { page: { index: number } }) => { - const { index: pageIndex } = page; - this.setState(() => ({ page: pageIndex }), this.fetchJobs); - }; - - private fetchJobs = async () => { - // avoid page flicker when poller is updating table - only display loading screen on first load - if (this.isInitialJobsFetch) { - this.setState(() => ({ isLoading: true })); - } - - let jobs: JobQueueEntry[]; - let total: number; - try { - jobs = await this.props.apiClient.list(this.state.page); - total = await this.props.apiClient.total(); - this.isInitialJobsFetch = false; - } catch (fetchError) { - if (!this.licenseAllowsToShowThisPage()) { - this.props.toasts.addDanger(this.state.badLicenseMessage); - this.props.redirect('kibana#/management'); - return; - } - - if (fetchError.message === 'Failed to fetch') { - this.props.toasts.addDanger( - fetchError.message || - this.props.intl.formatMessage({ - id: 'xpack.reporting.listing.table.requestFailedErrorMessage', - defaultMessage: 'Request failed', - }) - ); - } - if (this.mounted) { - this.setState(() => ({ isLoading: false, jobs: [], total: 0 })); - } - return; - } - - if (this.mounted) { - this.setState(() => ({ - isLoading: false, - total, - jobs: jobs.map( - (job: JobQueueEntry): Job => { - const { _source: source } = job; - return { - id: job._id, - type: source.jobtype, - object_type: source.payload.objectType, - object_title: source.payload.title, - created_by: source.created_by, - created_at: source.created_at, - started_at: source.started_at, - completed_at: source.completed_at, - status: source.status, - statusLabel: jobStatusLabelsMap.get(source.status as JobStatuses) || source.status, - max_size_reached: source.output ? source.output.max_size_reached : false, - attempts: source.attempts, - max_attempts: source.max_attempts, - csv_contains_formulas: get(source, 'output.csv_contains_formulas'), - warnings: source.output ? source.output.warnings : undefined, - }; - } - ), - })); - } - }; - - private licenseAllowsToShowThisPage = () => { - return this.state.showLinks && this.state.enableLinks; - }; - - private formatDate(timestamp: string) { - try { - return moment(timestamp).format('YYYY-MM-DD @ hh:mm A'); - } catch (error) { - // ignore parse error and display unformatted value - return timestamp; - } - } } export const ReportListing = injectI18n(ReportListingUi); diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client.ts index ddfeb144d3cd7..cddfcd3ec855a 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client.ts +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client.ts @@ -85,6 +85,12 @@ export class ReportingAPIClient { window.open(location); } + public async deleteReport(jobId: string) { + return await this.http.delete(`${API_LIST_URL}/delete/${jobId}`, { + asSystemRequest: true, + }); + } + public list = (page = 0, jobIds: string[] = []): Promise => { const query = { page } as any; if (jobIds.length > 0) { diff --git a/x-pack/plugins/searchprofiler/public/application/editor/editor.tsx b/x-pack/plugins/searchprofiler/public/application/editor/editor.tsx index 5f8ab776a7672..ece22905c64d9 100644 --- a/x-pack/plugins/searchprofiler/public/application/editor/editor.tsx +++ b/x-pack/plugins/searchprofiler/public/application/editor/editor.tsx @@ -8,7 +8,7 @@ import React, { memo, useRef, useEffect, useState } from 'react'; import { Editor as AceEditor } from 'brace'; import { initializeEditor } from './init_editor'; -import { useUIAceKeyboardMode } from './use_ui_ace_keyboard_mode'; +import { useUIAceKeyboardMode } from '../../../../../../src/plugins/es_ui_shared/public'; type EditorShim = ReturnType; diff --git a/x-pack/plugins/searchprofiler/public/application/editor/use_ui_ace_keyboard_mode.tsx b/x-pack/plugins/searchprofiler/public/application/editor/use_ui_ace_keyboard_mode.tsx deleted file mode 100644 index edf31c2e7c07f..0000000000000 --- a/x-pack/plugins/searchprofiler/public/application/editor/use_ui_ace_keyboard_mode.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/** - * Copied from Console plugin - */ - -import React, { useEffect, useRef } from 'react'; -import * as ReactDOM from 'react-dom'; -import { keyCodes, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -const OverlayText = () => ( - // The point of this element is for accessibility purposes, so ignore eslint error - // in this case - // - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions - <> - - {i18n.translate('xpack.searchProfiler.aceAccessibilityOverlayInstructionEnter', { - defaultMessage: 'Press Enter to start editing.', - })} - - - {i18n.translate('xpack.searchProfiler.aceAccessibilityOverlayInstructionExit', { - defaultMessage: `When you are done, press Escape to stop editing.`, - })} - - -); - -export function useUIAceKeyboardMode(aceTextAreaElement: HTMLTextAreaElement | null) { - const overlayMountNode = useRef(null); - const autoCompleteVisibleRef = useRef(false); - - useEffect(() => { - function onDismissOverlay(event: KeyboardEvent) { - if (event.keyCode === keyCodes.ENTER) { - event.preventDefault(); - aceTextAreaElement!.focus(); - } - } - - function enableOverlay() { - if (overlayMountNode.current) { - overlayMountNode.current.focus(); - } - } - - const isAutoCompleteVisible = () => { - const autoCompleter = document.querySelector('.ace_autocomplete'); - if (!autoCompleter) { - return false; - } - // The autoComplete is just hidden when it's closed, not removed from the DOM. - return autoCompleter.style.display !== 'none'; - }; - - const documentKeyDownListener = () => { - autoCompleteVisibleRef.current = isAutoCompleteVisible(); - }; - - const aceKeydownListener = (event: KeyboardEvent) => { - if (event.keyCode === keyCodes.ESCAPE && !autoCompleteVisibleRef.current) { - event.preventDefault(); - event.stopPropagation(); - enableOverlay(); - } - }; - - if (aceTextAreaElement) { - // We don't control HTML elements inside of ace so we imperatively create an element - // that acts as a container and insert it just before ace's textarea element - // so that the overlay lives at the correct spot in the DOM hierarchy. - overlayMountNode.current = document.createElement('div'); - overlayMountNode.current.className = 'kbnUiAceKeyboardHint'; - overlayMountNode.current.setAttribute('role', 'application'); - overlayMountNode.current.tabIndex = 0; - overlayMountNode.current.addEventListener('focus', enableOverlay); - overlayMountNode.current.addEventListener('keydown', onDismissOverlay); - - ReactDOM.render(, overlayMountNode.current); - - aceTextAreaElement.parentElement!.insertBefore(overlayMountNode.current, aceTextAreaElement); - aceTextAreaElement.setAttribute('tabindex', '-1'); - - // Order of events: - // 1. Document capture event fires first and we check whether an autocomplete menu is open on keydown - // (not ideal because this is scoped to the entire document). - // 2. Ace changes it's state (like hiding or showing autocomplete menu) - // 3. We check what button was pressed and whether autocomplete was visible then determine - // whether it should act like a dismiss or if we should display an overlay. - document.addEventListener('keydown', documentKeyDownListener, { capture: true }); - aceTextAreaElement.addEventListener('keydown', aceKeydownListener); - } - return () => { - if (aceTextAreaElement) { - document.removeEventListener('keydown', documentKeyDownListener); - aceTextAreaElement.removeEventListener('keydown', aceKeydownListener); - const textAreaContainer = aceTextAreaElement.parentElement; - if (textAreaContainer && textAreaContainer.contains(overlayMountNode.current!)) { - textAreaContainer.removeChild(overlayMountNode.current!); - } - } - }; - }, [aceTextAreaElement]); -} diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts index acde73dcd8190..eedd63e228523 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts @@ -28,7 +28,7 @@ describe('Put payload schema', () => { kibana: [{ feature: { foo: ['!foo'] } }], }) ).toThrowErrorMatchingInlineSnapshot( - `"[kibana.0.feature.foo]: only a-z, A-Z, 0-9, '_', and '-' are allowed"` + `"[kibana.0.feature.foo.0]: only a-z, A-Z, 0-9, '_', and '-' are allowed"` ); }); diff --git a/x-pack/plugins/spaces/public/space_selector/__snapshots__/space_selector.test.tsx.snap b/x-pack/plugins/spaces/public/space_selector/__snapshots__/space_selector.test.tsx.snap index e1cfafd897234..a9fd636776a4f 100644 --- a/x-pack/plugins/spaces/public/space_selector/__snapshots__/space_selector.test.tsx.snap +++ b/x-pack/plugins/spaces/public/space_selector/__snapshots__/space_selector.test.tsx.snap @@ -17,7 +17,7 @@ exports[`it renders without crashing 1`] = ` > { - + diff --git a/x-pack/plugins/transform/public/app/common/index.ts b/x-pack/plugins/transform/public/app/common/index.ts index e81fadddbea69..ee026e2e590a4 100644 --- a/x-pack/plugins/transform/public/app/common/index.ts +++ b/x-pack/plugins/transform/public/app/common/index.ts @@ -36,6 +36,7 @@ export { TRANSFORM_MODE, } from './transform_stats'; export { getDiscoverUrl } from './navigation'; +export { GetTransformsResponse, PreviewData, PreviewMappings } from './pivot_preview'; export { getEsAggFromAggConfig, isPivotAggsConfigWithUiSupport, diff --git a/x-pack/plugins/transform/public/app/common/pivot_preview.ts b/x-pack/plugins/transform/public/app/common/pivot_preview.ts new file mode 100644 index 0000000000000..14368a80b0131 --- /dev/null +++ b/x-pack/plugins/transform/public/app/common/pivot_preview.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/public'; + +import { Dictionary } from '../../../common/types/common'; + +interface EsMappingType { + type: ES_FIELD_TYPES; +} + +export type PreviewItem = Dictionary; +export type PreviewData = PreviewItem[]; +export interface PreviewMappings { + properties: Dictionary; +} + +export interface GetTransformsResponse { + preview: PreviewData; + generated_dest_index: { + mappings: PreviewMappings; + // Not in use yet + aliases: any; + settings: any; + }; +} diff --git a/x-pack/plugins/transform/public/app/components/pivot_preview/use_pivot_preview_data.ts b/x-pack/plugins/transform/public/app/components/pivot_preview/use_pivot_preview_data.ts index c3ccddbfc2906..83fa7ba189ff0 100644 --- a/x-pack/plugins/transform/public/app/components/pivot_preview/use_pivot_preview_data.ts +++ b/x-pack/plugins/transform/public/app/components/pivot_preview/use_pivot_preview_data.ts @@ -9,8 +9,7 @@ import { useEffect, useState } from 'react'; import { dictionaryToArray } from '../../../../common/types/common'; import { useApi } from '../../hooks/use_api'; -import { Dictionary } from '../../../../common/types/common'; -import { IndexPattern, ES_FIELD_TYPES } from '../../../../../../../src/plugins/data/public'; +import { IndexPattern } from '../../../../../../../src/plugins/data/public'; import { getPreviewRequestBody, @@ -18,6 +17,8 @@ import { PivotAggsConfigDict, PivotGroupByConfigDict, PivotQuery, + PreviewData, + PreviewMappings, } from '../../common'; export enum PIVOT_PREVIEW_STATUS { @@ -27,16 +28,6 @@ export enum PIVOT_PREVIEW_STATUS { ERROR, } -interface EsMappingType { - type: ES_FIELD_TYPES; -} - -export type PreviewItem = Dictionary; -type PreviewData = PreviewItem[]; -interface PreviewMappings { - properties: Dictionary; -} - export interface UsePivotPreviewDataReturnType { errorMessage: string; status: PIVOT_PREVIEW_STATUS; @@ -45,11 +36,6 @@ export interface UsePivotPreviewDataReturnType { previewRequest: PreviewRequestBody; } -export interface GetTransformsResponse { - preview: PreviewData; - mappings: PreviewMappings; -} - export const usePivotPreviewData = ( indexPatternTitle: IndexPattern['title'], query: PivotQuery, @@ -77,9 +63,9 @@ export const usePivotPreviewData = ( setStatus(PIVOT_PREVIEW_STATUS.LOADING); try { - const resp: GetTransformsResponse = await api.getTransformsPreview(previewRequest); + const resp = await api.getTransformsPreview(previewRequest); setPreviewData(resp.preview); - setPreviewMappings(resp.mappings); + setPreviewMappings(resp.generated_dest_index.mappings); setStatus(PIVOT_PREVIEW_STATUS.LOADED); } catch (e) { setErrorMessage(JSON.stringify(e, null, 2)); diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index c503051ed90af..39341dd1add65 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -8,7 +8,7 @@ import { TransformId, TransformEndpointRequest, TransformEndpointResult } from ' import { API_BASE_PATH } from '../../../common/constants'; import { useAppDependencies } from '../app_dependencies'; -import { PreviewRequestBody } from '../common'; +import { GetTransformsResponse, PreviewRequestBody } from '../common'; import { EsIndex } from './use_api_types'; @@ -37,7 +37,7 @@ export const useApi = () => { body: JSON.stringify(transformsInfo), }); }, - getTransformsPreview(obj: PreviewRequestBody): Promise { + getTransformsPreview(obj: PreviewRequestBody): Promise { return http.post(`${API_BASE_PATH}transforms/_preview`, { body: JSON.stringify(obj) }); }, startTransforms(transformsInfo: TransformEndpointRequest[]): Promise { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index eb208e67ccfec..95fa13c028c30 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1340,7 +1340,7 @@ "kbn.management.landing.text": "すべてのツールの一覧は、左のメニューにあります。", "kbn.management.objects.confirmModalOptions.deleteButtonLabel": "削除", "kbn.management.objects.confirmModalOptions.modalDescription": "削除されたオブジェクトは復元できません", - "kbn.management.objects.confirmModalOptions.modalTitle": "保存された Kibana オブジェクトを削除しますか?", + "kbn.management.objects.confirmModalOptions.modalTitle": "{title} を削除しますか?", "kbn.management.objects.deleteSavedObjectsConfirmModalDescription": "この操作は次の保存されたオブジェクトを削除します:", "kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.cancelButtonLabel": "キャンセル", "kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.deleteButtonLabel": "削除", @@ -2380,7 +2380,6 @@ "visTypeVislib.vislib.legend.toggleLegendButtonAriaLabel": "凡例を切り替える", "visTypeVislib.vislib.legend.toggleLegendButtonTitle": "凡例を切り替える", "visTypeVislib.vislib.legend.toggleOptionsButtonAriaLabel": "{legendDataLabel}、トグルオプション", - "kibana-react.exitFullScreenButton.exitFullScreenModeButtonLabel": "全画面を終了", "kibana-react.exitFullScreenButton.fullScreenModeDescription": "ESC キーで全画面モードを終了します。", "newsfeed.emptyPrompt.noNewsText": "Kibanaインスタンスがインターネットにアクセスできない場合、管理者にこの機能を無効にするように依頼してください。そうでない場合は、ニュースを取り込み続けます。", "newsfeed.emptyPrompt.noNewsTitle": "ニュースがない場合", @@ -4465,22 +4464,6 @@ "xpack.canvas.expressionTypes.argTypes.seriesStyle.styleLabel": "スタイル", "xpack.canvas.expressionTypes.argTypes.seriesStyleLabel": "選択された名前付きの数列のスタイルを設定", "xpack.canvas.expressionTypes.argTypes.seriesStyleTitle": "数列スタイル", - "xpack.canvas.expressionTypes.datasources.esdocs.ascendingDropDown": "昇順", - "xpack.canvas.expressionTypes.datasources.esdocs.descendingDropDown": "降順", - "xpack.canvas.expressionTypes.datasources.esdocs.fieldsLabel": "抽出するフィールドです。Kibana スクリプトフィールドは現在利用できません", - "xpack.canvas.expressionTypes.datasources.esdocs.fieldsTitle": "フィールド", - "xpack.canvas.expressionTypes.datasources.esdocs.fieldsWarningLabel": "このデータソースは、10 個以下のフィールドで最も高い性能を発揮します", - "xpack.canvas.expressionTypes.datasources.esdocs.indexLabel": "インデックス名を入力するか、インデックスパターンを選択してください", - "xpack.canvas.expressionTypes.datasources.esdocs.indexTitle": "インデックス", - "xpack.canvas.expressionTypes.datasources.esdocs.queryLabel": "{lucene} クエリ文字列の構文", - "xpack.canvas.expressionTypes.datasources.esdocs.queryTitle": "クエリ", - "xpack.canvas.expressionTypes.datasources.esdocs.sortFieldLabel": "ドキュメントソートフィールド", - "xpack.canvas.expressionTypes.datasources.esdocs.sortFieldTitle": "ソートフィールド", - "xpack.canvas.expressionTypes.datasources.esdocs.sortOrderLabel": "ドキュメントの並べ替え順", - "xpack.canvas.expressionTypes.datasources.esdocs.sortOrderTitle": "並べ替え順", - "xpack.canvas.expressionTypes.datasources.esdocs.warningTitle": "ご注意ください", - "xpack.canvas.expressionTypes.datasources.esdocsLabel": "Elasticsearch から未加工のドキュメントを読み込みます", - "xpack.canvas.expressionTypes.datasources.esdocsTitle": "Elasticsearch 未加工ドキュメント", "xpack.canvas.functionForm.contextError": "エラー: {errorMessage}", "xpack.canvas.functionForm.functionUnknown.unknownArgumentTypeError": "未知の表現タイプ「{expressionType}」", "xpack.canvas.functions.all.args.conditionHelpText": "確認する条件です。", @@ -4991,13 +4974,29 @@ "xpack.canvas.uis.arguments.textareaTitle": "テキストエリア", "xpack.canvas.uis.arguments.toggleLabel": "true/false トグルスイッチ", "xpack.canvas.uis.arguments.toggleTitle": "切り替え", + "xpack.canvas.uis.dataSources.esdocs.ascendingDropDown": "昇順", + "xpack.canvas.uis.dataSources.esdocs.descendingDropDown": "降順", + "xpack.canvas.uis.dataSources.esdocs.fieldsLabel": "抽出するフィールドです。Kibana スクリプトフィールドは現在利用できません", + "xpack.canvas.uis.dataSources.esdocs.fieldsTitle": "フィールド", + "xpack.canvas.uis.dataSources.esdocs.fieldsWarningLabel": "このデータソースは、10 個以下のフィールドで最も高い性能を発揮します", + "xpack.canvas.uis.dataSources.esdocs.indexLabel": "インデックス名を入力するか、インデックスパターンを選択してください", + "xpack.canvas.uis.dataSources.esdocs.indexTitle": "インデックス", + "xpack.canvas.uis.dataSources.esdocs.queryLabel": "{lucene} クエリ文字列の構文", + "xpack.canvas.uis.dataSources.esdocs.queryTitle": "クエリ", + "xpack.canvas.uis.dataSources.esdocs.sortFieldLabel": "ドキュメントソートフィールド", + "xpack.canvas.uis.dataSources.esdocs.sortFieldTitle": "ソートフィールド", + "xpack.canvas.uis.dataSources.esdocs.sortOrderLabel": "ドキュメントの並べ替え順", + "xpack.canvas.uis.dataSources.esdocs.sortOrderTitle": "並べ替え順", + "xpack.canvas.uis.dataSources.esdocs.warningTitle": "ご注意ください", + "xpack.canvas.uis.dataSources.esdocsLabel": "{elasticsearch} から未加工のドキュメントを読み込みます", + "xpack.canvas.uis.dataSources.esdocsTitle": "{elasticsearch} 未加工ドキュメント", "xpack.canvas.uis.dataSources.demoData.headingTitle": "デモデータを使用中です", "xpack.canvas.uis.dataSources.demoDataLabel": "ユーザー名、価格、プロジェクト、国、フェーズを含む模擬データセット", "xpack.canvas.uis.dataSources.demoDataTitle": "デモデータ", "xpack.canvas.uis.dataSources.essqlLabel": "{elasticsearch} {sql} でデータ表を取得します", "xpack.canvas.uis.dataSources.essqlTitle": "{elasticsearch} {sql}", "xpack.canvas.uis.dataSources.timelion.intervalTitle": "間隔", - "xpack.canvas.uis.dataSources.timelion.queryLabel": "{lucene} クエリ文字列の構文", + "xpack.canvas.uis.dataSources.timelion.queryLabel": "{timelion} クエリ文字列の構文", "xpack.canvas.uis.dataSources.timelion.queryTitle": "クエリ", "xpack.canvas.uis.dataSources.timelion.tips.functions": "{functionExample} などの一部 {timelion} 関数は {canvas} データ表に変換できません。データ操作に関する機能は正常に動作するはずです。", "xpack.canvas.uis.dataSources.timelion.tips.time": "{timelion} には時間範囲が必要です。ページのどこかに時間フィルターを追加するか、コードエディターで時間フィルターを渡す必要があります。", @@ -6410,7 +6409,6 @@ "xpack.infra.logs.analysisPage.unavailable.mlAppLink": "機械学習アプリ", "xpack.infra.logs.customizeLogs.customizeButtonLabel": "カスタマイズ", "xpack.infra.logs.customizeLogs.lineWrappingFormRowLabel": "改行", - "xpack.infra.logs.customizeLogs.minimapScaleFormRowLabel": "ミニマップスケール", "xpack.infra.logs.customizeLogs.textSizeFormRowLabel": "テキストサイズ", "xpack.infra.logs.customizeLogs.textSizeRadioGroup": "{textScale, select, small {小さい} 中くらい {Medium} 大きい {Large} その他の {{textScale}} }", "xpack.infra.logs.customizeLogs.wrapLongLinesSwitchLabel": "長い行を改行", @@ -6425,19 +6423,12 @@ "xpack.infra.logs.index.settingsTabTitle": "設定", "xpack.infra.logs.index.streamTabTitle": "ストリーム", "xpack.infra.logs.jumpToTailText": "最も新しいエントリーに移動", - "xpack.infra.logs.lastStreamingUpdateText": " 最終更新 {lastUpdateTime}", - "xpack.infra.logs.loadAgainButtonLabel": "再読み込み", - "xpack.infra.logs.loadingAdditionalEntriesText": "追加エントリーを読み込み中", - "xpack.infra.logs.noAdditionalEntriesFoundText": "追加エントリーが見つかりません", "xpack.infra.logs.scrollableLogTextStreamView.loadingEntriesLabel": "エントリーを読み込み中", "xpack.infra.logs.search.nextButtonLabel": "次へ", "xpack.infra.logs.search.previousButtonLabel": "前へ", "xpack.infra.logs.search.searchInLogsAriaLabel": "検索", "xpack.infra.logs.search.searchInLogsPlaceholder": "検索", "xpack.infra.logs.searchResultTooltip": "{bucketCount, plural, one {# 件のハイライトされたエントリー} other {# 件のハイライトされたエントリー}}", - "xpack.infra.logs.startStreamingButtonLabel": "ライブストリーム", - "xpack.infra.logs.stopStreamingButtonLabel": "ストリーム停止", - "xpack.infra.logs.streamingDescription": "新しいエントリーをストリーム中...", "xpack.infra.logs.streamingNewEntriesText": "新しいエントリーをストリーム中", "xpack.infra.logs.streamPage.documentTitle": "{previousTitle} | ストリーム", "xpack.infra.logsPage.noLoggingIndicesDescription": "追加しましょう!", @@ -6445,12 +6436,6 @@ "xpack.infra.logsPage.noLoggingIndicesTitle": "ログインデックスがないようです。", "xpack.infra.logsPage.toolbar.kqlSearchFieldAriaLabel": "ログエントリーを検索", "xpack.infra.logsPage.toolbar.kqlSearchFieldPlaceholder": "ログエントリーを検索中… (例: host.name:host-1)", - "xpack.infra.mapLogs.oneDayLabel": "1 日", - "xpack.infra.mapLogs.oneHourLabel": "1 時間", - "xpack.infra.mapLogs.oneMinuteLabel": "1 分", - "xpack.infra.mapLogs.oneMonthLabel": "1 か月", - "xpack.infra.mapLogs.oneWeekLabel": "1 週間", - "xpack.infra.mapLogs.oneYearLabel": "1 年", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.percentSeriesLabel": "パーセント", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.sectionLabel": "CPU 使用状況", "xpack.infra.metricDetailPage.awsMetricsLayout.diskioBytesSection.readsSeriesLabel": "読み取り", @@ -7692,7 +7677,6 @@ "xpack.ml.datavisualizer.page.fieldsPanelTitle": "フィールド", "xpack.ml.datavisualizer.page.metricsPanelTitle": "メトリック", "xpack.ml.datavisualizer.searchPanel.allOptionLabel": "すべて", - "xpack.ml.datavisualizer.searchPanel.kqlEditOnlyLabel": "現在 KQAL で保存された検索のみ編集できます。", "xpack.ml.datavisualizer.searchPanel.queryBarPlaceholder": "小さいサンプルサイズを選択することで、クエリの実行時間を短縮しクラスターへの負荷を軽減できます。", "xpack.ml.datavisualizer.searchPanel.queryBarPlaceholderText": "検索… (例: status:200 AND extension:\"PHP\")", "xpack.ml.datavisualizer.searchPanel.sampleSizeAriaLabel": "サンプリングするドキュメント数を選択してください", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f85714a5913ad..9f9b5cc442b9a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1340,7 +1340,7 @@ "kbn.management.landing.text": "在左侧菜单中可找到完整工具列表", "kbn.management.objects.confirmModalOptions.deleteButtonLabel": "删除", "kbn.management.objects.confirmModalOptions.modalDescription": "您无法恢复删除的对象", - "kbn.management.objects.confirmModalOptions.modalTitle": "删除已保存 Kibana 对象?", + "kbn.management.objects.confirmModalOptions.modalTitle": "删除 {title}?", "kbn.management.objects.deleteSavedObjectsConfirmModalDescription": "此操作将删除以下已保存对象:", "kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.cancelButtonLabel": "取消", "kbn.management.objects.objectsTable.deleteSavedObjectsConfirmModal.deleteButtonLabel": "删除", @@ -2381,7 +2381,6 @@ "visTypeVislib.vislib.legend.toggleLegendButtonAriaLabel": "切换图例", "visTypeVislib.vislib.legend.toggleLegendButtonTitle": "切换图例", "visTypeVislib.vislib.legend.toggleOptionsButtonAriaLabel": "{legendDataLabel}切换选项", - "kibana-react.exitFullScreenButton.exitFullScreenModeButtonLabel": "退出全屏", "kibana-react.exitFullScreenButton.fullScreenModeDescription": "在全屏模式下,按 ESC 键可退出。", "newsfeed.emptyPrompt.noNewsText": "如果您的 Kibana 实例没有 Internet 连接,请让您的管理员禁用此功能。否则,我们将不断尝试获取新闻。", "newsfeed.emptyPrompt.noNewsTitle": "无新闻?", @@ -4466,22 +4465,6 @@ "xpack.canvas.expressionTypes.argTypes.seriesStyle.styleLabel": "样式", "xpack.canvas.expressionTypes.argTypes.seriesStyleLabel": "设置选定已命名序列的样式", "xpack.canvas.expressionTypes.argTypes.seriesStyleTitle": "序列样式", - "xpack.canvas.expressionTypes.datasources.esdocs.ascendingDropDown": "升序", - "xpack.canvas.expressionTypes.datasources.esdocs.descendingDropDown": "降序", - "xpack.canvas.expressionTypes.datasources.esdocs.fieldsLabel": "要提取的字段。Kibana 脚本字段当前不可用", - "xpack.canvas.expressionTypes.datasources.esdocs.fieldsTitle": "字段", - "xpack.canvas.expressionTypes.datasources.esdocs.fieldsWarningLabel": "字段不超过 10 个时,此数据源性能最佳", - "xpack.canvas.expressionTypes.datasources.esdocs.indexLabel": "输入索引名称或选择索引模式", - "xpack.canvas.expressionTypes.datasources.esdocs.indexTitle": "索引", - "xpack.canvas.expressionTypes.datasources.esdocs.queryLabel": "{lucene} 查询字符串语法", - "xpack.canvas.expressionTypes.datasources.esdocs.queryTitle": "查询", - "xpack.canvas.expressionTypes.datasources.esdocs.sortFieldLabel": "文档排序字段", - "xpack.canvas.expressionTypes.datasources.esdocs.sortFieldTitle": "排序字段", - "xpack.canvas.expressionTypes.datasources.esdocs.sortOrderLabel": "文档排序顺序", - "xpack.canvas.expressionTypes.datasources.esdocs.sortOrderTitle": "排序顺序", - "xpack.canvas.expressionTypes.datasources.esdocs.warningTitle": "务必谨慎操作", - "xpack.canvas.expressionTypes.datasources.esdocsLabel": "从 Elasticsearch 拉取原始文档", - "xpack.canvas.expressionTypes.datasources.esdocsTitle": "Elasticsearch 原始文档", "xpack.canvas.functionForm.contextError": "错误:{errorMessage}", "xpack.canvas.functionForm.functionUnknown.unknownArgumentTypeError": "表达式类型“{expressionType}”未知", "xpack.canvas.functions.all.args.conditionHelpText": "要检查的条件。", @@ -4992,13 +4975,29 @@ "xpack.canvas.uis.arguments.textareaTitle": "文本区域", "xpack.canvas.uis.arguments.toggleLabel": "True/False 切换开关", "xpack.canvas.uis.arguments.toggleTitle": "切换", + "xpack.canvas.uis.dataSources.esdocs.ascendingDropDown": "升序", + "xpack.canvas.uis.dataSources.esdocs.descendingDropDown": "降序", + "xpack.canvas.uis.dataSources.esdocs.fieldsLabel": "要提取的字段。Kibana 脚本字段当前不可用", + "xpack.canvas.uis.dataSources.esdocs.fieldsTitle": "字段", + "xpack.canvas.uis.dataSources.esdocs.fieldsWarningLabel": "字段不超过 10 个时,此数据源性能最佳", + "xpack.canvas.uis.dataSources.esdocs.indexLabel": "输入索引名称或选择索引模式", + "xpack.canvas.uis.dataSources.esdocs.indexTitle": "索引", + "xpack.canvas.uis.dataSources.esdocs.queryLabel": "{lucene} 查询字符串语法", + "xpack.canvas.uis.dataSources.esdocs.queryTitle": "查询", + "xpack.canvas.uis.dataSources.esdocs.sortFieldLabel": "文档排序字段", + "xpack.canvas.uis.dataSources.esdocs.sortFieldTitle": "排序字段", + "xpack.canvas.uis.dataSources.esdocs.sortOrderLabel": "文档排序顺序", + "xpack.canvas.uis.dataSources.esdocs.sortOrderTitle": "排序顺序", + "xpack.canvas.uis.dataSources.esdocs.warningTitle": "务必谨慎操作", + "xpack.canvas.uis.dataSources.esdocsLabel": "从 {elasticsearch} 拉取原始文档", + "xpack.canvas.uis.dataSources.esdocsTitle": "{elasticsearch} 原始文档", "xpack.canvas.uis.dataSources.demoData.headingTitle": "您正在使用演示数据", "xpack.canvas.uis.dataSources.demoDataLabel": "使用用户名、价格、项目、国家/地区和阶段模拟数据集", "xpack.canvas.uis.dataSources.demoDataTitle": "演示数据", "xpack.canvas.uis.dataSources.essqlLabel": "使用 {elasticsearch} {sql} 以获取数据表", "xpack.canvas.uis.dataSources.essqlTitle": "{elasticsearch} {sql}", "xpack.canvas.uis.dataSources.timelion.intervalTitle": "时间间隔", - "xpack.canvas.uis.dataSources.timelion.queryLabel": "{lucene} 查询字符串语法", + "xpack.canvas.uis.dataSources.timelion.queryLabel": "{timelion} 查询字符串语法", "xpack.canvas.uis.dataSources.timelion.queryTitle": "查询", "xpack.canvas.uis.dataSources.timelion.tips.functions": "一些 {timelion} 函数(如 {functionExample})不转换成 {canvas} 数据表。任何与数据操作有关的内容都适用。", "xpack.canvas.uis.dataSources.timelion.tips.time": "{timelion} 需要时间范围,您应将时间筛选元素添加到页面上的某个位置,或使用代码编辑器传入时间筛选。", @@ -6410,7 +6409,6 @@ "xpack.infra.logs.analysisPage.unavailable.mlAppLink": "Machine Learning 应用", "xpack.infra.logs.customizeLogs.customizeButtonLabel": "定制", "xpack.infra.logs.customizeLogs.lineWrappingFormRowLabel": "换行", - "xpack.infra.logs.customizeLogs.minimapScaleFormRowLabel": "迷你地图比例", "xpack.infra.logs.customizeLogs.textSizeFormRowLabel": "文本大小", "xpack.infra.logs.customizeLogs.textSizeRadioGroup": "{textScale, select, small {小} medium {Medium} large {Large} other {{textScale}} }", "xpack.infra.logs.customizeLogs.wrapLongLinesSwitchLabel": "长行换行", @@ -6425,19 +6423,12 @@ "xpack.infra.logs.index.settingsTabTitle": "设置", "xpack.infra.logs.index.streamTabTitle": "流式传输", "xpack.infra.logs.jumpToTailText": "跳到最近的条目", - "xpack.infra.logs.lastStreamingUpdateText": " 最后更新时间:{lastUpdateTime}", - "xpack.infra.logs.loadAgainButtonLabel": "重新加载", - "xpack.infra.logs.loadingAdditionalEntriesText": "正在加载其他条目", - "xpack.infra.logs.noAdditionalEntriesFoundText": "找不到其他条目", "xpack.infra.logs.scrollableLogTextStreamView.loadingEntriesLabel": "正在加载条目", "xpack.infra.logs.search.nextButtonLabel": "下一个", "xpack.infra.logs.search.previousButtonLabel": "上一页", "xpack.infra.logs.search.searchInLogsAriaLabel": "搜索", "xpack.infra.logs.search.searchInLogsPlaceholder": "搜索", "xpack.infra.logs.searchResultTooltip": "{bucketCount, plural, one {# 个高亮条目} other {# 个高亮条目}}", - "xpack.infra.logs.startStreamingButtonLabel": "实时流式传输", - "xpack.infra.logs.stopStreamingButtonLabel": "停止流式传输", - "xpack.infra.logs.streamingDescription": "正在流式传输新条目……", "xpack.infra.logs.streamingNewEntriesText": "正在流式传输新条目", "xpack.infra.logs.streamPage.documentTitle": "{previousTitle} | 流式传输", "xpack.infra.logsPage.noLoggingIndicesDescription": "让我们添加一些!", @@ -6445,12 +6436,6 @@ "xpack.infra.logsPage.noLoggingIndicesTitle": "似乎您没有任何日志索引。", "xpack.infra.logsPage.toolbar.kqlSearchFieldAriaLabel": "搜索日志条目", "xpack.infra.logsPage.toolbar.kqlSearchFieldPlaceholder": "搜索日志条目……(例如 host.name:host-1)", - "xpack.infra.mapLogs.oneDayLabel": "1 日", - "xpack.infra.mapLogs.oneHourLabel": "1 小时", - "xpack.infra.mapLogs.oneMinuteLabel": "1 分钟", - "xpack.infra.mapLogs.oneMonthLabel": "1 个月", - "xpack.infra.mapLogs.oneWeekLabel": "1 周", - "xpack.infra.mapLogs.oneYearLabel": "1 年", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.percentSeriesLabel": "百分比", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.sectionLabel": "CPU 使用率", "xpack.infra.metricDetailPage.awsMetricsLayout.diskioBytesSection.readsSeriesLabel": "读取数", @@ -7692,7 +7677,6 @@ "xpack.ml.datavisualizer.page.fieldsPanelTitle": "字段", "xpack.ml.datavisualizer.page.metricsPanelTitle": "指标", "xpack.ml.datavisualizer.searchPanel.allOptionLabel": "全部", - "xpack.ml.datavisualizer.searchPanel.kqlEditOnlyLabel": "当前仅可以编辑 KQL 已保存搜索", "xpack.ml.datavisualizer.searchPanel.queryBarPlaceholder": "选择较小的样例大小将减少查询运行时间和集群上的负载。", "xpack.ml.datavisualizer.searchPanel.queryBarPlaceholderText": "搜索……(例如,status:200 AND extension:\"PHP\")", "xpack.ml.datavisualizer.searchPanel.sampleSizeAriaLabel": "选择要采样的文档数目", diff --git a/x-pack/plugins/triggers_actions_ui/README.md b/x-pack/plugins/triggers_actions_ui/README.md index 0d667f477f936..e6af63ecd4359 100644 --- a/x-pack/plugins/triggers_actions_ui/README.md +++ b/x-pack/plugins/triggers_actions_ui/README.md @@ -7,12 +7,6 @@ As a developer you can reuse and extend built-in alerts and actions UI functiona - Create and register a new Action Type. - Embed the Create Alert flyout within any Kibana plugin. -To enable Alerts and Actions UIs, the following configuration settings are needed: -``` -xpack.triggers_actions_ui.enabled: true -xpack.triggers_actions_ui.createAlertUiEnabled: true -``` - ----- diff --git a/x-pack/plugins/triggers_actions_ui/kibana.json b/x-pack/plugins/triggers_actions_ui/kibana.json index 6883faa5ee230..d11f2b3e51c9d 100644 --- a/x-pack/plugins/triggers_actions_ui/kibana.json +++ b/x-pack/plugins/triggers_actions_ui/kibana.json @@ -1,8 +1,8 @@ { - "id": "triggers_actions_ui", - "version": "kibana", - "server": false, - "ui": true, - "optionalPlugins": ["alerting", "alertingBuiltins"], - "requiredPlugins": ["management", "charts", "data"] - } + "id": "triggers_actions_ui", + "version": "kibana", + "server": false, + "ui": true, + "optionalPlugins": ["alerting", "alertingBuiltins"], + "requiredPlugins": ["management", "charts", "data"] +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx index 51ed3c1ebafad..0593940a0d105 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx @@ -13,6 +13,7 @@ import { IUiSettingsClient, ApplicationStart, ChromeBreadcrumb, + CoreStart, } from 'kibana/public'; import { BASE_PATH, Section, routeToAlertDetails } from './constants'; import { TriggersActionsUIHome } from './home'; @@ -23,14 +24,16 @@ import { TypeRegistry } from './type_registry'; import { AlertDetailsRouteWithApi as AlertDetailsRoute } from './sections/alert_details/components/alert_details_route'; import { ChartsPluginStart } from '../../../../../src/plugins/charts/public'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; +import { PluginStartContract as AlertingStart } from '../../../alerting/public'; export interface AppDeps { dataPlugin: DataPublicPluginStart; charts: ChartsPluginStart; chrome: ChromeStart; + alerting?: AlertingStart; + navigateToApp: CoreStart['application']['navigateToApp']; docLinks: DocLinksStart; toastNotifications: ToastsSetup; - injectedMetadata: any; http: HttpSetup; uiSettings: IUiSettingsClient; setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx index 5c7f48de81f75..728418bf3c336 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx @@ -50,7 +50,7 @@ const DEFAULT_VALUES = { THRESHOLD_COMPARATOR: COMPARATORS.GREATER_THAN, TIME_WINDOW_SIZE: 5, TIME_WINDOW_UNIT: 'm', - THRESHOLD: [1000, 5000], + THRESHOLD: [1000], GROUP_BY: 'all', }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts index a94d2319d6e4d..ecf60e995d1a1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts @@ -3,11 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { i18n } from '@kbn/i18n'; -import { AlertTypeModel, ValidationResult } from '../../../../types'; +import { AlertTypeModel } from '../../../../types'; import { IndexThresholdAlertTypeExpression } from './expression'; -import { IndexThresholdAlertParams } from './types'; -import { builtInGroupByTypes, builtInAggregationTypes } from '../../../../common/constants'; +import { validateExpression } from './validation'; export function getAlertType(): AlertTypeModel { return { @@ -15,91 +13,6 @@ export function getAlertType(): AlertTypeModel { name: 'Index Threshold', iconClass: 'alert', alertParamsExpression: IndexThresholdAlertTypeExpression, - validate: (alertParams: IndexThresholdAlertParams): ValidationResult => { - const { - index, - timeField, - aggType, - aggField, - groupBy, - termSize, - termField, - threshold, - timeWindowSize, - } = alertParams; - const validationResult = { errors: {} }; - const errors = { - aggField: new Array(), - termSize: new Array(), - termField: new Array(), - timeWindowSize: new Array(), - threshold0: new Array(), - threshold1: new Array(), - index: new Array(), - timeField: new Array(), - }; - validationResult.errors = errors; - if (!index || index.length === 0) { - errors.index.push( - i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredIndexText', { - defaultMessage: 'Index is required.', - }) - ); - } - if (!timeField) { - errors.timeField.push( - i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredTimeFieldText', { - defaultMessage: 'Time field is required.', - }) - ); - } - if (aggType && builtInAggregationTypes[aggType].fieldRequired && !aggField) { - errors.aggField.push( - i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredAggFieldText', { - defaultMessage: 'Aggregation field is required.', - }) - ); - } - if (!termSize) { - errors.termSize.push( - i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredTermSizedText', { - defaultMessage: 'Term size is required.', - }) - ); - } - if (!termField && groupBy && builtInGroupByTypes[groupBy].sizeRequired) { - errors.termField.push( - i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredtTermFieldText', { - defaultMessage: 'Term field is required.', - }) - ); - } - if (!timeWindowSize) { - errors.timeWindowSize.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAlert.error.requiredTimeWindowSizeText', - { - defaultMessage: 'Time window size is required.', - } - ) - ); - } - if (threshold && threshold.length > 0 && !threshold[0]) { - errors.threshold0.push( - i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredThreshold0Text', { - defaultMessage: 'Threshold0, is required.', - }) - ); - } - if (threshold && threshold.length > 1 && !threshold[1]) { - errors.threshold1.push( - i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredThreshold1Text', { - defaultMessage: 'Threshold1 is required.', - }) - ); - } - return validationResult; - }, - defaultActionMessage: 'Alert [{{ctx.metadata.name}}] has exceeded the threshold', + validate: validateExpression, }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/validation.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/validation.test.ts new file mode 100644 index 0000000000000..1f24a094d0ece --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/validation.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IndexThresholdAlertParams } from './types'; +import { validateExpression } from './validation'; + +describe('expression params validation', () => { + test('if index property is invalid should return proper error message', () => { + const initialParams: IndexThresholdAlertParams = { + index: [], + aggType: 'count', + threshold: [], + timeWindowSize: 1, + timeWindowUnit: 's', + }; + expect(validateExpression(initialParams).errors.index.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.index[0]).toBe('Index is required.'); + }); + test('if timeField property is not defined should return proper error message', () => { + const initialParams: IndexThresholdAlertParams = { + index: ['test'], + aggType: 'count', + threshold: [], + timeWindowSize: 1, + timeWindowUnit: 's', + }; + expect(validateExpression(initialParams).errors.timeField.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.timeField[0]).toBe('Time field is required.'); + }); + test('if aggField property is invalid should return proper error message', () => { + const initialParams: IndexThresholdAlertParams = { + index: ['test'], + aggType: 'avg', + threshold: [], + timeWindowSize: 1, + timeWindowUnit: 's', + }; + expect(validateExpression(initialParams).errors.aggField.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.aggField[0]).toBe( + 'Aggregation field is required.' + ); + }); + test('if termSize property is not set should return proper error message', () => { + const initialParams: IndexThresholdAlertParams = { + index: ['test'], + aggType: 'count', + groupBy: 'top', + threshold: [], + timeWindowSize: 1, + timeWindowUnit: 's', + }; + expect(validateExpression(initialParams).errors.termSize.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.termSize[0]).toBe('Term size is required.'); + }); + test('if termField property is not set should return proper error message', () => { + const initialParams: IndexThresholdAlertParams = { + index: ['test'], + aggType: 'count', + groupBy: 'top', + threshold: [], + timeWindowSize: 1, + timeWindowUnit: 's', + }; + expect(validateExpression(initialParams).errors.termField.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.termField[0]).toBe('Term field is required.'); + }); + test('if threshold0 property is not set should return proper error message', () => { + const initialParams: IndexThresholdAlertParams = { + index: ['test'], + aggType: 'count', + groupBy: 'top', + threshold: [], + timeWindowSize: 1, + timeWindowUnit: 's', + thresholdComparator: '<', + }; + expect(validateExpression(initialParams).errors.threshold0.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.threshold0[0]).toBe('Threshold0 is required.'); + }); + test('if threshold1 property is not set should return proper error message', () => { + const initialParams: IndexThresholdAlertParams = { + index: ['test'], + aggType: 'count', + groupBy: 'top', + threshold: [1], + timeWindowSize: 1, + timeWindowUnit: 's', + thresholdComparator: 'between', + }; + expect(validateExpression(initialParams).errors.threshold1.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.threshold1[0]).toBe('Threshold1 is required.'); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/validation.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/validation.ts new file mode 100644 index 0000000000000..3912b2fffae1e --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/validation.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { ValidationResult } from '../../../../types'; +import { IndexThresholdAlertParams } from './types'; +import { + builtInGroupByTypes, + builtInAggregationTypes, + builtInComparators, +} from '../../../../common/constants'; + +export const validateExpression = (alertParams: IndexThresholdAlertParams): ValidationResult => { + const { + index, + timeField, + aggType, + aggField, + groupBy, + termSize, + termField, + threshold, + timeWindowSize, + thresholdComparator, + } = alertParams; + const validationResult = { errors: {} }; + const errors = { + aggField: new Array(), + termSize: new Array(), + termField: new Array(), + timeWindowSize: new Array(), + threshold0: new Array(), + threshold1: new Array(), + index: new Array(), + timeField: new Array(), + }; + validationResult.errors = errors; + if (!index || index.length === 0) { + errors.index.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredIndexText', { + defaultMessage: 'Index is required.', + }) + ); + } + if (!timeField) { + errors.timeField.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredTimeFieldText', { + defaultMessage: 'Time field is required.', + }) + ); + } + if (aggType && builtInAggregationTypes[aggType].fieldRequired && !aggField) { + errors.aggField.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredAggFieldText', { + defaultMessage: 'Aggregation field is required.', + }) + ); + } + if ( + groupBy && + builtInGroupByTypes[groupBy] && + builtInGroupByTypes[groupBy].sizeRequired && + !termSize + ) { + errors.termSize.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredTermSizedText', { + defaultMessage: 'Term size is required.', + }) + ); + } + if ( + groupBy && + builtInGroupByTypes[groupBy].validNormalizedTypes && + builtInGroupByTypes[groupBy].validNormalizedTypes.length > 0 && + !termField + ) { + errors.termField.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredtTermFieldText', { + defaultMessage: 'Term field is required.', + }) + ); + } + if (!timeWindowSize) { + errors.timeWindowSize.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredTimeWindowSizeText', { + defaultMessage: 'Time window size is required.', + }) + ); + } + if (!threshold || threshold.length === 0 || threshold[0] === undefined) { + errors.threshold0.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredThreshold0Text', { + defaultMessage: 'Threshold0 is required.', + }) + ); + } + if ( + thresholdComparator && + builtInComparators[thresholdComparator].requiredValues > 1 && + (!threshold || + threshold[1] === undefined || + (threshold && threshold.length < builtInComparators[thresholdComparator!].requiredValues)) + ) { + errors.threshold1.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredThreshold1Text', { + defaultMessage: 'Threshold1 is required.', + }) + ); + } + if (threshold && threshold.length === 2 && threshold[0] > threshold[1]) { + errors.threshold1.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.greaterThenThreshold0Text', { + defaultMessage: 'Threshold1 should be > Threshold0.', + }) + ); + } + return validationResult; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts index d469651b48b04..2f5172e8b386a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +export { BASE_ALERT_API_PATH } from '../../../../alerting/common'; +export { BASE_ACTION_API_PATH } from '../../../../actions/common'; + export const BASE_PATH = '/management/kibana/triggersActions'; -export const BASE_ACTION_API_PATH = '/api/action'; -export const BASE_ALERT_API_PATH = '/api/alert'; export type Section = 'connectors' | 'alerts'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api.test.ts index f568e0a71d0cf..62e7b1cf022bb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api.test.ts @@ -57,6 +57,8 @@ describe('loadAllActions', () => { Object { "query": Object { "per_page": 10000, + "sort_field": "name.keyword", + "sort_order": "asc", }, }, ] diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api.ts index 5b2b59603d281..26ad97f05849d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api.ts @@ -30,6 +30,8 @@ export async function loadAllActions({ return await http.get(`${BASE_ACTION_API_PATH}/_find`, { query: { per_page: MAX_ACTIONS_RETURNED, + sort_field: 'name.keyword', + sort_order: 'asc', }, }); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts index 0b06982828446..0555823d0245e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts @@ -170,6 +170,8 @@ describe('loadAlerts', () => { "per_page": 10, "search": undefined, "search_fields": undefined, + "sort_field": "name.keyword", + "sort_order": "asc", }, }, ] @@ -188,20 +190,22 @@ describe('loadAlerts', () => { const result = await loadAlerts({ http, searchText: 'apples', page: { index: 0, size: 10 } }); expect(result).toEqual(resolvedValue); expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alert/_find", - Object { - "query": Object { - "default_search_operator": "AND", - "filter": undefined, - "page": 1, - "per_page": 10, - "search": "apples", - "search_fields": "[\\"name\\",\\"tags\\"]", - }, - }, - ] - `); + Array [ + "/api/alert/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": undefined, + "page": 1, + "per_page": 10, + "search": "apples", + "search_fields": "[\\"name\\",\\"tags\\"]", + "sort_field": "name.keyword", + "sort_order": "asc", + }, + }, + ] + `); }); test('should call find API with actionTypesFilter', async () => { @@ -220,20 +224,22 @@ describe('loadAlerts', () => { }); expect(result).toEqual(resolvedValue); expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alert/_find", - Object { - "query": Object { - "default_search_operator": "AND", - "filter": undefined, - "page": 1, - "per_page": 10, - "search": "foo", - "search_fields": "[\\"name\\",\\"tags\\"]", - }, - }, - ] - `); + Array [ + "/api/alert/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": undefined, + "page": 1, + "per_page": 10, + "search": "foo", + "search_fields": "[\\"name\\",\\"tags\\"]", + "sort_field": "name.keyword", + "sort_order": "asc", + }, + }, + ] + `); }); test('should call find API with typesFilter', async () => { @@ -252,20 +258,22 @@ describe('loadAlerts', () => { }); expect(result).toEqual(resolvedValue); expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alert/_find", - Object { - "query": Object { - "default_search_operator": "AND", - "filter": "alert.attributes.alertTypeId:(foo or bar)", - "page": 1, - "per_page": 10, - "search": undefined, - "search_fields": undefined, - }, - }, - ] - `); + Array [ + "/api/alert/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.alertTypeId:(foo or bar)", + "page": 1, + "per_page": 10, + "search": undefined, + "search_fields": undefined, + "sort_field": "name.keyword", + "sort_order": "asc", + }, + }, + ] + `); }); test('should call find API with actionTypesFilter and typesFilter', async () => { @@ -285,20 +293,22 @@ describe('loadAlerts', () => { }); expect(result).toEqual(resolvedValue); expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alert/_find", - Object { - "query": Object { - "default_search_operator": "AND", - "filter": "alert.attributes.alertTypeId:(foo or bar)", - "page": 1, - "per_page": 10, - "search": "baz", - "search_fields": "[\\"name\\",\\"tags\\"]", - }, - }, - ] - `); + Array [ + "/api/alert/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.alertTypeId:(foo or bar)", + "page": 1, + "per_page": 10, + "search": "baz", + "search_fields": "[\\"name\\",\\"tags\\"]", + "sort_field": "name.keyword", + "sort_order": "asc", + }, + }, + ] + `); }); test('should call find API with searchText and tagsFilter and typesFilter', async () => { @@ -318,20 +328,22 @@ describe('loadAlerts', () => { }); expect(result).toEqual(resolvedValue); expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "/api/alert/_find", - Object { - "query": Object { - "default_search_operator": "AND", - "filter": "alert.attributes.alertTypeId:(foo or bar)", - "page": 1, - "per_page": 10, - "search": "apples, foo, baz", - "search_fields": "[\\"name\\",\\"tags\\"]", - }, - }, - ] - `); + Array [ + "/api/alert/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.alertTypeId:(foo or bar)", + "page": 1, + "per_page": 10, + "search": "apples, foo, baz", + "search_fields": "[\\"name\\",\\"tags\\"]", + "sort_field": "name.keyword", + "sort_order": "asc", + }, + }, + ] + `); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts index ff6b4ba17c6d9..1b18460ba11cb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts @@ -87,6 +87,8 @@ export async function loadAlerts({ search: searchText, filter: filters.length ? filters.join(' and ') : undefined, default_search_operator: 'AND', + sort_field: 'name.keyword', + sort_order: 'asc', }, }); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index a43aa22026710..64be161fc90b3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -117,16 +117,14 @@ export const ActionForm = ({ const actionsResponse = await loadAllActions({ http }); setConnectors(actionsResponse.data); } catch (e) { - if (toastNotifications) { - toastNotifications.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionsMessage', - { - defaultMessage: 'Unable to load connectors', - } - ), - }); - } + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionsMessage', + { + defaultMessage: 'Unable to load connectors', + } + ), + }); } } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx index 509bd7131394e..9187836d52462 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx @@ -13,6 +13,7 @@ import { actionTypeRegistryMock } from '../../../action_type_registry.mock'; import { AppContextProvider } from '../../../app_context'; import { chartPluginMock } from '../../../../../../../../src/plugins/charts/public/mocks'; import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; +import { alertingPluginMock } from '../../../../../../alerting/public/mocks'; jest.mock('../../../lib/action_connector_api', () => ({ loadAllActions: jest.fn(), @@ -49,7 +50,7 @@ describe('actions_connectors_list component empty', () => { { chrome, docLinks, - application: { capabilities }, + application: { capabilities, navigateToApp }, }, ] = await mockes.getStartServices(); const deps = { @@ -57,10 +58,11 @@ describe('actions_connectors_list component empty', () => { docLinks, dataPlugin: dataPluginMock.createStartContract(), charts: chartPluginMock.createStartContract(), + alerting: alertingPluginMock.createStartContract(), toastNotifications: mockes.notifications.toasts, - injectedMetadata: mockes.injectedMetadata, http: mockes.http, uiSettings: mockes.uiSettings, + navigateToApp, capabilities: { ...capabilities, siem: { @@ -146,7 +148,7 @@ describe('actions_connectors_list component with items', () => { { chrome, docLinks, - application: { capabilities }, + application: { capabilities, navigateToApp }, }, ] = await mockes.getStartServices(); const deps = { @@ -154,10 +156,11 @@ describe('actions_connectors_list component with items', () => { docLinks, dataPlugin: dataPluginMock.createStartContract(), charts: chartPluginMock.createStartContract(), + alerting: alertingPluginMock.createStartContract(), toastNotifications: mockes.notifications.toasts, - injectedMetadata: mockes.injectedMetadata, http: mockes.http, uiSettings: mockes.uiSettings, + navigateToApp, capabilities: { ...capabilities, siem: { @@ -230,7 +233,7 @@ describe('actions_connectors_list component empty with show only capability', () { chrome, docLinks, - application: { capabilities }, + application: { capabilities, navigateToApp }, }, ] = await mockes.getStartServices(); const deps = { @@ -238,10 +241,11 @@ describe('actions_connectors_list component empty with show only capability', () docLinks, dataPlugin: dataPluginMock.createStartContract(), charts: chartPluginMock.createStartContract(), + alerting: alertingPluginMock.createStartContract(), toastNotifications: mockes.notifications.toasts, - injectedMetadata: mockes.injectedMetadata, http: mockes.http, uiSettings: mockes.uiSettings, + navigateToApp, capabilities: { ...capabilities, siem: { @@ -319,7 +323,7 @@ describe('actions_connectors_list with show only capability', () => { { chrome, docLinks, - application: { capabilities }, + application: { capabilities, navigateToApp }, }, ] = await mockes.getStartServices(); const deps = { @@ -327,10 +331,11 @@ describe('actions_connectors_list with show only capability', () => { docLinks, dataPlugin: dataPluginMock.createStartContract(), charts: chartPluginMock.createStartContract(), + alerting: alertingPluginMock.createStartContract(), toastNotifications: mockes.notifications.toasts, - injectedMetadata: mockes.injectedMetadata, http: mockes.http, uiSettings: mockes.uiSettings, + navigateToApp, capabilities: { ...capabilities, siem: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index c142f0c6d3a50..92b3e4eb9679f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -19,6 +19,7 @@ import { import { times, random } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { ViewInApp } from './view_in_app'; jest.mock('../../../app_context', () => ({ useAppDependencies: jest.fn(() => ({ @@ -247,14 +248,7 @@ describe('alert_details', () => { expect( shallow( - ).containsMatchingElement( - - - - ) + ).containsMatchingElement() ).toBeTruthy(); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx index 30016637dc182..49e818ebc7ee4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx @@ -33,6 +33,7 @@ import { withBulkAlertOperations, } from '../../common/components/with_bulk_alert_api_operations'; import { AlertInstancesRouteWithApi } from './alert_instances_route'; +import { ViewInApp } from './view_in_app'; type AlertDetailsProps = { alert: Alert; @@ -95,12 +96,7 @@ export const AlertDetails: React.FunctionComponent = ({ - - - + diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx new file mode 100644 index 0000000000000..18825d58aa055 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as React from 'react'; +import uuid from 'uuid'; +import { mount, ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; + +import { Alert } from '../../../../types'; +import { ViewInApp } from './view_in_app'; +import { useAppDependencies } from '../../../app_context'; + +jest.mock('../../../app_context', () => { + const alerting = { + getNavigation: jest.fn(async id => (id === 'alert-with-nav' ? { path: '/alert' } : undefined)), + }; + const navigateToApp = jest.fn(); + return { + useAppDependencies: jest.fn(() => ({ + http: jest.fn(), + navigateToApp, + alerting, + legacy: { + capabilities: { + get: jest.fn(() => ({})), + }, + }, + })), + }; +}); + +jest.mock('../../../lib/capabilities', () => ({ + hasSaveAlertsCapability: jest.fn(() => true), +})); + +describe('alert_details', () => { + describe('link to the app that created the alert', () => { + it('is disabled when there is no navigation', async () => { + const alert = mockAlert(); + const { alerting } = useAppDependencies(); + + let component: ReactWrapper; + await act(async () => { + // use mount as we need useEffect to run + component = mount(); + + await waitForUseEffect(); + + expect(component!.find('button').prop('disabled')).toBe(true); + expect(component!.text()).toBe('View in app'); + + expect(alerting!.getNavigation).toBeCalledWith(alert.id); + }); + }); + + it('enabled when there is navigation', async () => { + const alert = mockAlert({ id: 'alert-with-nav', consumer: 'siem' }); + const { navigateToApp } = useAppDependencies(); + + let component: ReactWrapper; + act(async () => { + // use mount as we need useEffect to run + component = mount(); + + await waitForUseEffect(); + + expect(component!.find('button').prop('disabled')).toBe(undefined); + + component!.find('button').prop('onClick')!({ + currentTarget: {}, + } as React.MouseEvent<{}, MouseEvent>); + + expect(navigateToApp).toBeCalledWith('siem', '/alert'); + }); + }); + }); +}); + +function waitForUseEffect() { + return new Promise(resolve => { + setTimeout(resolve, 0); + }); +} + +function mockAlert(overloads: Partial = {}): Alert { + return { + id: uuid.v4(), + enabled: true, + name: `alert-${uuid.v4()}`, + tags: [], + alertTypeId: '.noop', + consumer: 'consumer', + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + apiKeyOwner: null, + throttle: null, + muteAll: false, + mutedInstanceIds: [], + ...overloads, + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.tsx new file mode 100644 index 0000000000000..337b355ce129c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { CoreStart } from 'kibana/public'; +import { fromNullable, fold } from 'fp-ts/lib/Option'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { useAppDependencies } from '../../../app_context'; + +import { + AlertNavigation, + AlertStateNavigation, + AlertUrlNavigation, +} from '../../../../../../alerting/common'; +import { Alert } from '../../../../types'; + +export interface ViewInAppProps { + alert: Alert; +} + +const NO_NAVIGATION = false; + +type AlertNavigationLoadingState = AlertNavigation | false | null; + +export const ViewInApp: React.FunctionComponent = ({ alert }) => { + const { navigateToApp, alerting: maybeAlerting } = useAppDependencies(); + + const [alertNavigation, setAlertNavigation] = useState(null); + useEffect(() => { + pipe( + fromNullable(maybeAlerting), + fold( + /** + * If the alerting plugin is disabled, + * navigation isn't supported + */ + () => setAlertNavigation(NO_NAVIGATION), + alerting => + alerting + .getNavigation(alert.id) + .then(nav => (nav ? setAlertNavigation(nav) : setAlertNavigation(NO_NAVIGATION))) + .catch(() => { + setAlertNavigation(NO_NAVIGATION); + }) + ) + ); + }, [alert.id, maybeAlerting]); + + return ( + + + + ); +}; + +function hasNavigation( + alertNavigation: AlertNavigationLoadingState +): alertNavigation is AlertStateNavigation | AlertUrlNavigation { + return alertNavigation + ? alertNavigation.hasOwnProperty('state') || alertNavigation.hasOwnProperty('path') + : NO_NAVIGATION; +} + +function getNavigationHandler( + alertNavigation: AlertNavigationLoadingState, + alert: Alert, + navigateToApp: CoreStart['application']['navigateToApp'] +): object { + return hasNavigation(alertNavigation) + ? { + onClick: () => { + navigateToApp(alert.consumer, alertNavigation); + }, + } + : {}; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx index 1177b41788bd6..fc524debe7443 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx @@ -43,7 +43,6 @@ describe('alert_add', () => { const mockes = coreMock.createSetup(); deps = { toastNotifications: mockes.notifications.toasts, - injectedMetadata: mockes.injectedMetadata, http: mockes.http, uiSettings: mockes.uiSettings, dataPlugin: dataPluginMock.createStartContract(), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index aa56b565ef324..1fa620c5394a1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -155,6 +155,7 @@ export const AlertForm = ({ alert, canChangeTrigger = true, dispatch, errors }: onClick={() => { setAlertProperty('alertTypeId', item.id); setAlertTypeModel(item); + setAlertProperty('params', {}); if (alertTypesIndex && alertTypesIndex[item.id]) { setDefaultActionGroupId(alertTypesIndex[item.id].defaultActionGroupId); } @@ -194,6 +195,7 @@ export const AlertForm = ({ alert, canChangeTrigger = true, dispatch, errors }: onClick={() => { setAlertProperty('alertTypeId', null); setAlertTypeModel(null); + setAlertProperty('params', {}); }} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx index f8f0c278c81e2..108cc724aa407 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -15,6 +15,7 @@ import { ValidationResult } from '../../../../types'; import { AppContextProvider } from '../../../app_context'; import { chartPluginMock } from '../../../../../../../../src/plugins/charts/public/mocks'; import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; +import { alertingPluginMock } from '../../../../../../alerting/public/mocks'; jest.mock('../../../lib/action_connector_api', () => ({ loadActionTypes: jest.fn(), @@ -83,7 +84,7 @@ describe('alerts_list component empty', () => { { chrome, docLinks, - application: { capabilities }, + application: { capabilities, navigateToApp }, }, ] = await mockes.getStartServices(); const deps = { @@ -91,16 +92,11 @@ describe('alerts_list component empty', () => { docLinks, dataPlugin: dataPluginMock.createStartContract(), charts: chartPluginMock.createStartContract(), + alerting: alertingPluginMock.createStartContract(), toastNotifications: mockes.notifications.toasts, - injectedMetadata: { - getInjectedVar(name: string) { - if (name === 'createAlertUiEnabled') { - return true; - } - }, - } as any, http: mockes.http, uiSettings: mockes.uiSettings, + navigateToApp, capabilities: { ...capabilities, siem: { @@ -211,7 +207,7 @@ describe('alerts_list component with items', () => { { chrome, docLinks, - application: { capabilities }, + application: { capabilities, navigateToApp }, }, ] = await mockes.getStartServices(); const deps = { @@ -219,16 +215,11 @@ describe('alerts_list component with items', () => { docLinks, dataPlugin: dataPluginMock.createStartContract(), charts: chartPluginMock.createStartContract(), + alerting: alertingPluginMock.createStartContract(), toastNotifications: mockes.notifications.toasts, - injectedMetadata: { - getInjectedVar(name: string) { - if (name === 'createAlertUiEnabled') { - return true; - } - }, - } as any, http: mockes.http, uiSettings: mockes.uiSettings, + navigateToApp, capabilities: { ...capabilities, siem: { @@ -306,7 +297,7 @@ describe('alerts_list component empty with show only capability', () => { { chrome, docLinks, - application: { capabilities }, + application: { capabilities, navigateToApp }, }, ] = await mockes.getStartServices(); const deps = { @@ -314,16 +305,11 @@ describe('alerts_list component empty with show only capability', () => { docLinks, dataPlugin: dataPluginMock.createStartContract(), charts: chartPluginMock.createStartContract(), + alerting: alertingPluginMock.createStartContract(), toastNotifications: mockes.notifications.toasts, - injectedMetadata: { - getInjectedVar(name: string) { - if (name === 'createAlertUiEnabled') { - return true; - } - }, - } as any, http: mockes.http, uiSettings: mockes.uiSettings, + navigateToApp, capabilities: { ...capabilities, siem: { @@ -430,7 +416,7 @@ describe('alerts_list with show only capability', () => { { chrome, docLinks, - application: { capabilities }, + application: { capabilities, navigateToApp }, }, ] = await mockes.getStartServices(); const deps = { @@ -438,16 +424,11 @@ describe('alerts_list with show only capability', () => { docLinks, dataPlugin: dataPluginMock.createStartContract(), charts: chartPluginMock.createStartContract(), + alerting: alertingPluginMock.createStartContract(), toastNotifications: mockes.notifications.toasts, - injectedMetadata: { - getInjectedVar(name: string) { - if (name === 'createAlertUiEnabled') { - return true; - } - }, - } as any, http: mockes.http, uiSettings: mockes.uiSettings, + navigateToApp, capabilities: { ...capabilities, siem: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index 8d8fc177b57a0..4bcfef78abd71 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -52,7 +52,6 @@ export const AlertsList: React.FunctionComponent = () => { const history = useHistory(); const { http, - injectedMetadata, toastNotifications, capabilities, alertTypeRegistry, @@ -63,7 +62,6 @@ export const AlertsList: React.FunctionComponent = () => { } = useAppDependencies(); const canDelete = hasDeleteAlertsCapability(capabilities); const canSave = hasSaveAlertsCapability(capabilities); - const createAlertUiEnabled = injectedMetadata.getInjectedVar('createAlertUiEnabled'); const [actionTypes, setActionTypes] = useState([]); const [selectedIds, setSelectedIds] = useState([]); @@ -273,7 +271,7 @@ export const AlertsList: React.FunctionComponent = () => { />, ]; - if (canSave && createAlertUiEnabled) { + if (canSave) { toolsRight.push( { addFlyoutVisible={alertFlyoutVisible} setAddFlyoutVisibility={setAlertFlyoutVisibility} /> - {editedAlertItem ? ( + {editFlyoutVisible && editedAlertItem ? ( { const wrapper = shallow( @@ -59,7 +59,7 @@ describe('threshold expression', () => { const wrapper = shallow( diff --git a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/threshold.tsx b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/threshold.tsx index ecbf0aee63e2d..d0de7ae77a81e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/threshold.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/threshold.tsx @@ -105,6 +105,11 @@ export const ThresholdExpression = ({ value={thresholdComparator} onChange={e => { onChangeSelectedThresholdComparator(e.target.value); + const thresholdValues = threshold.slice( + 0, + comparators[e.target.value].requiredValues + ); + onChangeSelectedThreshold(thresholdValues); }} options={Object.values(comparators).map(({ text, value }) => { return { text, value }; @@ -123,18 +128,23 @@ export const ThresholdExpression = ({ ) : null} - + 0 || !threshold[i]} + error={errors[`threshold${i}`]} + > 0 || !threshold[i]} onChange={e => { const { value } = e.target; const thresholdVal = value !== '' ? parseFloat(value) : undefined; const newThreshold = [...threshold]; - if (thresholdVal) { + if (thresholdVal !== undefined) { newThreshold[i] = thresholdVal; + } else { + delete newThreshold[i]; } onChangeSelectedThreshold(newThreshold); }} diff --git a/x-pack/plugins/triggers_actions_ui/public/common/index_controls/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/index_controls/index.ts index 32fb35d6adebb..f5da3918bf101 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/index_controls/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/index_controls/index.ts @@ -10,6 +10,7 @@ import { loadIndexPatterns, getMatchingIndicesForThresholdAlertType, getThresholdAlertTypeFields, + getSavedObjectsClient, } from '../lib/index_threshold_api'; export interface IOption { @@ -18,8 +19,12 @@ export interface IOption { } export const getIndexPatterns = async () => { - const indexPatternObjects = await loadIndexPatterns(); - return indexPatternObjects.map((indexPattern: any) => indexPattern.attributes.title); + // TODO: Implement a possibility to retrive index patterns different way to be able to expose this in consumer plugins + if (getSavedObjectsClient()) { + const indexPatternObjects = await loadIndexPatterns(); + return indexPatternObjects.map((indexPattern: any) => indexPattern.attributes.title); + } + return []; }; export const getIndexOptions = async ( diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index fbffd5c2f999d..668a8802d1461 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -11,7 +11,7 @@ export { AlertsContextProvider } from './application/context/alerts_context'; export { ActionsConnectorsContextProvider } from './application/context/actions_connectors_context'; export { AlertAdd } from './application/sections/alert_form'; export { ActionForm } from './application/sections/action_connector_form'; -export { AlertAction, Alert } from './types'; +export { AlertAction, Alert, AlertTypeModel } from './types'; export { ConnectorAddFlyout, ConnectorEditFlyout, diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index 9f975cba3c0d1..99a3d65589e8e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -15,6 +15,7 @@ import { TypeRegistry } from './application/type_registry'; import { ManagementStart } from '../../../../src/plugins/management/public'; import { boot } from './application/boot'; import { ChartsPluginStart } from '../../../../src/plugins/charts/public'; +import { PluginStartContract as AlertingStart } from '../../alerting/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; export interface TriggersAndActionsUIPublicPluginSetup { @@ -31,6 +32,8 @@ interface PluginsStart { data: DataPublicPluginStart; charts: ChartsPluginStart; management: ManagementStart; + alerting?: AlertingStart; + navigateToApp: CoreStart['application']['navigateToApp']; } export class Plugin @@ -80,9 +83,9 @@ export class Plugin boot({ dataPlugin: plugins.data, charts: plugins.charts, + alerting: plugins.alerting, element: params.element, toastNotifications: core.notifications.toasts, - injectedMetadata: core.injectedMetadata, http: core.http, uiSettings: core.uiSettings, docLinks: core.docLinks, @@ -90,6 +93,7 @@ export class Plugin savedObjects: core.savedObjects.client, I18nContext: core.i18n.Context, capabilities: core.application.capabilities, + navigateToApp: core.application.navigateToApp, setBreadcrumbs: params.setBreadcrumbs, actionTypeRegistry: this.actionTypeRegistry, alertTypeRegistry: this.alertTypeRegistry, diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index dd61716325afc..603cfac316b2d 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -2,7 +2,7 @@ "configPath": ["xpack"], "id": "uptime", "kibanaVersion": "kibana", - "requiredPlugins": ["features", "licensing", "usageCollection"], + "requiredPlugins": ["alerting", "features", "licensing", "usageCollection"], "server": true, "ui": false, "version": "8.0.0" diff --git a/x-pack/plugins/uptime/server/kibana.index.ts b/x-pack/plugins/uptime/server/kibana.index.ts index c7ac3a70c0494..2c1f34aa8a8e7 100644 --- a/x-pack/plugins/uptime/server/kibana.index.ts +++ b/x-pack/plugins/uptime/server/kibana.index.ts @@ -55,5 +55,5 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor }, }); - initUptimeServer(libs); + initUptimeServer(server, libs, plugins); }; diff --git a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts index 8dde6050d5d36..6fc488e949e9c 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts @@ -31,6 +31,8 @@ export interface UptimeCoreSetup { export interface UptimeCorePlugins { features: PluginSetupContract; + alerting: any; + elasticsearch: any; usageCollection: UsageCollectionSetup; } diff --git a/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts new file mode 100644 index 0000000000000..8a11270a4740a --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts @@ -0,0 +1,587 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + contextMessage, + uniqueMonitorIds, + updateState, + statusCheckAlertFactory, + fullListByIdAndLocation, +} from '../status_check'; +import { GetMonitorStatusResult } from '../../requests'; +import { AlertType } from '../../../../../alerting/server'; +import { IRouter } from 'kibana/server'; +import { UMServerLibs } from '../../lib'; +import { UptimeCoreSetup } from '../../adapters'; + +/** + * The alert takes some dependencies as parameters; these are things like + * kibana core services and plugins. This function helps reduce the amount of + * boilerplate required. + * @param customRequests client tests can use this paramter to provide their own request mocks, + * so we don't have to mock them all for each test. + */ +const bootstrapDependencies = (customRequests?: any) => { + const route: IRouter = {} as IRouter; + // these server/libs parameters don't have any functionality, which is fine + // because we aren't testing them here + const server: UptimeCoreSetup = { route }; + const libs: UMServerLibs = { requests: {} } as UMServerLibs; + libs.requests = { ...libs.requests, ...customRequests }; + return { server, libs }; +}; + +/** + * This function aims to provide an easy way to give mock props that will + * reduce boilerplate for tests. + * @param params the params received at alert creation time + * @param services the core services provided by kibana/alerting platforms + * @param state the state the alert maintains + */ +const mockOptions = ( + params = { numTimes: 5, locations: [], timerange: { from: 'now-15m', to: 'now' } }, + services = { callCluster: 'mockESFunction' }, + state = {} +): any => ({ + params, + services, + state, +}); + +describe('status check alert', () => { + describe('executor', () => { + it('does not trigger when there are no monitors down', async () => { + expect.assertions(4); + const mockGetter = jest.fn(); + mockGetter.mockReturnValue([]); + const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter }); + const alert = statusCheckAlertFactory(server, libs); + // @ts-ignore the executor can return `void`, but ours never does + const state: Record = await alert.executor(mockOptions()); + + expect(state).not.toBeUndefined(); + expect(state?.isTriggered).toBe(false); + expect(mockGetter).toHaveBeenCalledTimes(1); + expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "callES": "mockESFunction", + "locations": Array [], + "numTimes": 5, + "timerange": Object { + "from": "now-15m", + "to": "now", + }, + }, + ] + `); + }); + + it('triggers when monitors are down and provides expected state', async () => { + const mockGetter = jest.fn(); + mockGetter.mockReturnValue([ + { + monitor_id: 'first', + location: 'harrisburg', + count: 234, + status: 'down', + }, + { + monitor_id: 'first', + location: 'fairbanks', + count: 234, + status: 'down', + }, + ]); + const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter }); + const alert = statusCheckAlertFactory(server, libs); + const mockInstanceFactory = jest.fn(); + const mockReplaceState = jest.fn(); + const mockScheduleActions = jest.fn(); + mockInstanceFactory.mockReturnValue({ + replaceState: mockReplaceState, + scheduleActions: mockScheduleActions, + }); + const options = mockOptions(); + options.services = { + ...options.services, + alertInstanceFactory: mockInstanceFactory, + }; + // @ts-ignore the executor can return `void`, but ours never does + const state: Record = await alert.executor(options); + expect(mockGetter).toHaveBeenCalledTimes(1); + expect(mockInstanceFactory).toHaveBeenCalledTimes(1); + expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "callES": "mockESFunction", + "locations": Array [], + "numTimes": 5, + "timerange": Object { + "from": "now-15m", + "to": "now", + }, + }, + ] + `); + expect(mockReplaceState).toHaveBeenCalledTimes(1); + expect(mockReplaceState.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "monitors": Array [ + Object { + "count": 234, + "location": "fairbanks", + "monitor_id": "first", + "status": "down", + }, + Object { + "count": 234, + "location": "harrisburg", + "monitor_id": "first", + "status": "down", + }, + ], + }, + ] + `); + expect(mockScheduleActions).toHaveBeenCalledTimes(1); + expect(mockScheduleActions.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "xpack.uptime.alerts.actionGroups.monitorStatus", + Object { + "completeIdList": "first from fairbanks; first from harrisburg; ", + "message": "Down monitor: first", + "server": Object { + "route": Object {}, + }, + }, + ] + `); + }); + }); + + describe('fullListByIdAndLocation', () => { + it('renders a list of all monitors', () => { + const statuses: GetMonitorStatusResult[] = [ + { + location: 'harrisburg', + monitor_id: 'first', + status: 'down', + count: 34, + }, + { + location: 'fairbanks', + monitor_id: 'second', + status: 'down', + count: 23, + }, + { + location: 'fairbanks', + monitor_id: 'first', + status: 'down', + count: 23, + }, + { + location: 'harrisburg', + monitor_id: 'second', + status: 'down', + count: 34, + }, + ]; + expect(fullListByIdAndLocation(statuses)).toMatchInlineSnapshot( + `"first from fairbanks; first from harrisburg; second from fairbanks; second from harrisburg; "` + ); + }); + + it('renders a list of monitors when greater than limit', () => { + const statuses: GetMonitorStatusResult[] = [ + { + location: 'fairbanks', + monitor_id: 'second', + status: 'down', + count: 23, + }, + { + location: 'fairbanks', + monitor_id: 'first', + status: 'down', + count: 23, + }, + { + location: 'harrisburg', + monitor_id: 'first', + status: 'down', + count: 34, + }, + { + location: 'harrisburg', + monitor_id: 'second', + status: 'down', + count: 34, + }, + ]; + expect(fullListByIdAndLocation(statuses.slice(0, 2), 1)).toMatchInlineSnapshot( + `"first from fairbanks; ...and 1 other monitor/location"` + ); + }); + + it('renders expected list of monitors when limit difference > 1', () => { + const statuses: GetMonitorStatusResult[] = [ + { + location: 'fairbanks', + monitor_id: 'second', + status: 'down', + count: 23, + }, + { + location: 'harrisburg', + monitor_id: 'first', + status: 'down', + count: 34, + }, + { + location: 'harrisburg', + monitor_id: 'second', + status: 'down', + count: 34, + }, + { + location: 'harrisburg', + monitor_id: 'third', + status: 'down', + count: 34, + }, + { + location: 'fairbanks', + monitor_id: 'third', + status: 'down', + count: 23, + }, + { + location: 'fairbanks', + monitor_id: 'first', + status: 'down', + count: 23, + }, + ]; + expect(fullListByIdAndLocation(statuses, 4)).toMatchInlineSnapshot( + `"first from fairbanks; first from harrisburg; second from fairbanks; second from harrisburg; ...and 2 other monitors/locations"` + ); + }); + }); + + describe('alert factory', () => { + let alert: AlertType; + + beforeEach(() => { + const { server, libs } = bootstrapDependencies(); + alert = statusCheckAlertFactory(server, libs); + }); + + it('creates an alert with expected params', () => { + // @ts-ignore the `props` key here isn't described + expect(Object.keys(alert.validate?.params?.props ?? {})).toMatchInlineSnapshot(` + Array [ + "filters", + "numTimes", + "timerange", + "locations", + ] + `); + }); + + it('contains the expected static fields like id, name, etc.', () => { + expect(alert.id).toBe('xpack.uptime.alerts.monitorStatus'); + expect(alert.name).toBe('Uptime Monitor Status'); + expect(alert.defaultActionGroupId).toBe('xpack.uptime.alerts.actionGroups.monitorStatus'); + expect(alert.actionGroups).toMatchInlineSnapshot(` + Array [ + Object { + "id": "xpack.uptime.alerts.actionGroups.monitorStatus", + "name": "Uptime Down Monitor", + }, + ] + `); + }); + }); + + describe('updateState', () => { + let spy: jest.SpyInstance; + beforeEach(() => { + spy = jest.spyOn(Date.prototype, 'toISOString'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('sets initial state values', () => { + spy.mockImplementation(() => 'foo date string'); + const result = updateState({}, false); + expect(spy).toHaveBeenCalledTimes(1); + expect(result).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "foo date string", + "firstTriggeredAt": undefined, + "isTriggered": false, + "lastCheckedAt": "foo date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": undefined, + } + `); + }); + + it('updates the correct field in subsequent calls', () => { + spy + .mockImplementationOnce(() => 'first date string') + .mockImplementationOnce(() => 'second date string'); + const firstState = updateState({}, false); + const secondState = updateState(firstState, true); + expect(spy).toHaveBeenCalledTimes(2); + expect(firstState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": undefined, + "isTriggered": false, + "lastCheckedAt": "first date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": undefined, + } + `); + expect(secondState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "second date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": true, + "lastCheckedAt": "second date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "second date string", + } + `); + }); + + it('correctly marks resolution times', () => { + spy + .mockImplementationOnce(() => 'first date string') + .mockImplementationOnce(() => 'second date string') + .mockImplementationOnce(() => 'third date string'); + const firstState = updateState({}, true); + const secondState = updateState(firstState, true); + const thirdState = updateState(secondState, false); + expect(spy).toHaveBeenCalledTimes(3); + expect(firstState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "first date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "first date string", + "isTriggered": true, + "lastCheckedAt": "first date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "first date string", + } + `); + expect(secondState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "first date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "first date string", + "isTriggered": true, + "lastCheckedAt": "second date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "second date string", + } + `); + expect(thirdState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": "first date string", + "isTriggered": false, + "lastCheckedAt": "third date string", + "lastResolvedAt": "third date string", + "lastTriggeredAt": "second date string", + } + `); + }); + + it('correctly marks state fields across multiple triggers/resolutions', () => { + spy + .mockImplementationOnce(() => 'first date string') + .mockImplementationOnce(() => 'second date string') + .mockImplementationOnce(() => 'third date string') + .mockImplementationOnce(() => 'fourth date string') + .mockImplementationOnce(() => 'fifth date string'); + const firstState = updateState({}, false); + const secondState = updateState(firstState, true); + const thirdState = updateState(secondState, false); + const fourthState = updateState(thirdState, true); + const fifthState = updateState(fourthState, false); + expect(spy).toHaveBeenCalledTimes(5); + expect(firstState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": undefined, + "isTriggered": false, + "lastCheckedAt": "first date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": undefined, + } + `); + expect(secondState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "second date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": true, + "lastCheckedAt": "second date string", + "lastResolvedAt": undefined, + "lastTriggeredAt": "second date string", + } + `); + expect(thirdState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": false, + "lastCheckedAt": "third date string", + "lastResolvedAt": "third date string", + "lastTriggeredAt": "second date string", + } + `); + expect(fourthState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": "fourth date string", + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": true, + "lastCheckedAt": "fourth date string", + "lastResolvedAt": "third date string", + "lastTriggeredAt": "fourth date string", + } + `); + expect(fifthState).toMatchInlineSnapshot(` + Object { + "currentTriggerStarted": undefined, + "firstCheckedAt": "first date string", + "firstTriggeredAt": "second date string", + "isTriggered": false, + "lastCheckedAt": "fifth date string", + "lastResolvedAt": "fifth date string", + "lastTriggeredAt": "fourth date string", + } + `); + }); + }); + + describe('uniqueMonitorIds', () => { + let items: GetMonitorStatusResult[]; + beforeEach(() => { + items = [ + { + monitor_id: 'first', + location: 'harrisburg', + count: 234, + status: 'down', + }, + { + monitor_id: 'first', + location: 'fairbanks', + count: 312, + status: 'down', + }, + { + monitor_id: 'second', + location: 'harrisburg', + count: 325, + status: 'down', + }, + { + monitor_id: 'second', + location: 'fairbanks', + count: 331, + status: 'down', + }, + { + monitor_id: 'third', + location: 'harrisburg', + count: 331, + status: 'down', + }, + { + monitor_id: 'third', + location: 'fairbanks', + count: 342, + status: 'down', + }, + { + monitor_id: 'fourth', + location: 'harrisburg', + count: 355, + status: 'down', + }, + { + monitor_id: 'fourth', + location: 'fairbanks', + count: 342, + status: 'down', + }, + { + monitor_id: 'fifth', + location: 'harrisburg', + count: 342, + status: 'down', + }, + { + monitor_id: 'fifth', + location: 'fairbanks', + count: 342, + status: 'down', + }, + ]; + }); + + it('creates a set of unique IDs from a list of composite-unique objects', () => { + expect(uniqueMonitorIds(items)).toEqual( + new Set(['first', 'second', 'third', 'fourth', 'fifth']) + ); + }); + }); + + describe('contextMessage', () => { + let ids: string[]; + beforeEach(() => { + ids = ['first', 'second', 'third', 'fourth', 'fifth']; + }); + + it('creates a message with appropriate number of monitors', () => { + expect(contextMessage(ids, 3)).toMatchInlineSnapshot( + `"Down monitors: first, second, third... and 2 other monitors"` + ); + }); + + it('throws an error if `max` is less than 2', () => { + expect(() => contextMessage(ids, 1)).toThrowErrorMatchingInlineSnapshot( + '"Maximum value must be greater than 2, received 1."' + ); + }); + + it('returns only the ids if length < max', () => { + expect(contextMessage(ids.slice(0, 2), 3)).toMatchInlineSnapshot( + `"Down monitors: first, second"` + ); + }); + + it('returns a default message when no monitors are provided', () => { + expect(contextMessage([], 3)).toMatchInlineSnapshot(`"No down monitor IDs received"`); + }); + }); +}); diff --git a/x-pack/plugins/uptime/server/lib/alerts/index.ts b/x-pack/plugins/uptime/server/lib/alerts/index.ts new file mode 100644 index 0000000000000..0e61fd70e0024 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UptimeAlertTypeFactory } from './types'; +import { statusCheckAlertFactory } from './status_check'; + +export const uptimeAlertTypeFactories: UptimeAlertTypeFactory[] = [statusCheckAlertFactory]; diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts new file mode 100644 index 0000000000000..3e90d2ce95a10 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts @@ -0,0 +1,234 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { isRight } from 'fp-ts/lib/Either'; +import { ThrowReporter } from 'io-ts/lib/ThrowReporter'; +import { i18n } from '@kbn/i18n'; +import { AlertExecutorOptions } from '../../../../alerting/server'; +import { ACTION_GROUP_DEFINITIONS } from '../../../../../legacy/plugins/uptime/common/constants'; +import { UptimeAlertTypeFactory } from './types'; +import { GetMonitorStatusResult } from '../requests'; +import { + StatusCheckExecutorParamsType, + StatusCheckAlertStateType, + StatusCheckAlertState, +} from '../../../../../legacy/plugins/uptime/common/runtime_types'; + +const { MONITOR_STATUS } = ACTION_GROUP_DEFINITIONS; + +/** + * Reduce a composite-key array of status results to a set of unique IDs. + * @param items to reduce + */ +export const uniqueMonitorIds = (items: GetMonitorStatusResult[]): Set => + items.reduce((acc, { monitor_id }) => { + acc.add(monitor_id); + return acc; + }, new Set()); + +/** + * Generates a message to include in contexts of alerts. + * @param monitors the list of monitors to include in the message + * @param max + */ +export const contextMessage = (monitorIds: string[], max: number): string => { + const MIN = 2; + if (max < MIN) throw new Error(`Maximum value must be greater than ${MIN}, received ${max}.`); + + // generate the message + let message; + if (monitorIds.length === 1) { + message = i18n.translate('xpack.uptime.alerts.message.singularTitle', { + defaultMessage: 'Down monitor: ', + }); + } else if (monitorIds.length) { + message = i18n.translate('xpack.uptime.alerts.message.multipleTitle', { + defaultMessage: 'Down monitors: ', + }); + } + // this shouldn't happen because the function should only be called + // when > 0 monitors are down + else { + message = i18n.translate('xpack.uptime.alerts.message.emptyTitle', { + defaultMessage: 'No down monitor IDs received', + }); + } + + for (let i = 0; i < monitorIds.length; i++) { + const id = monitorIds[i]; + if (i === max) { + return ( + message + + i18n.translate('xpack.uptime.alerts.message.overflowBody', { + defaultMessage: `... and {overflowCount} other monitors`, + values: { + overflowCount: monitorIds.length - i, + }, + }) + ); + } else if (i === 0) { + message = message + id; + } else { + message = message + `, ${id}`; + } + } + + return message; +}; + +/** + * Creates an exhaustive list of all the down monitors. + * @param list all the monitors that are down + * @param sizeLimit the max monitors, we shouldn't allow an arbitrarily long string + */ +export const fullListByIdAndLocation = ( + list: GetMonitorStatusResult[], + sizeLimit: number = 1000 +) => { + return ( + list + // sort by id, then location + .sort((a, b) => { + if (a.monitor_id > b.monitor_id) { + return 1; + } else if (a.monitor_id < b.monitor_id) { + return -1; + } else if (a.location > b.location) { + return 1; + } + return -1; + }) + .slice(0, sizeLimit) + .reduce((cur, { monitor_id: id, location }) => cur + `${id} from ${location}; `, '') + + (sizeLimit < list.length + ? i18n.translate('xpack.uptime.alerts.message.fullListOverflow', { + defaultMessage: '...and {overflowCount} other {pluralizedMonitor}', + values: { + pluralizedMonitor: + list.length - sizeLimit === 1 ? 'monitor/location' : 'monitors/locations', + overflowCount: list.length - sizeLimit, + }, + }) + : '') + ); +}; + +export const updateState = ( + state: Record, + isTriggeredNow: boolean +): StatusCheckAlertState => { + const now = new Date().toISOString(); + const decoded = StatusCheckAlertStateType.decode(state); + if (!isRight(decoded)) { + const triggerVal = isTriggeredNow ? now : undefined; + return { + currentTriggerStarted: triggerVal, + firstCheckedAt: now, + firstTriggeredAt: triggerVal, + isTriggered: isTriggeredNow, + lastTriggeredAt: triggerVal, + lastCheckedAt: now, + lastResolvedAt: undefined, + }; + } + const { + currentTriggerStarted, + firstCheckedAt, + firstTriggeredAt, + lastTriggeredAt, + // this is the stale trigger status, we're naming it `wasTriggered` + // to differentiate it from the `isTriggeredNow` param + isTriggered: wasTriggered, + lastResolvedAt, + } = decoded.right; + + let cts: string | undefined; + if (isTriggeredNow && !currentTriggerStarted) { + cts = now; + } else if (isTriggeredNow) { + cts = currentTriggerStarted; + } + + return { + currentTriggerStarted: cts, + firstCheckedAt: firstCheckedAt ?? now, + firstTriggeredAt: isTriggeredNow && !firstTriggeredAt ? now : firstTriggeredAt, + lastCheckedAt: now, + lastTriggeredAt: isTriggeredNow ? now : lastTriggeredAt, + lastResolvedAt: !isTriggeredNow && wasTriggered ? now : lastResolvedAt, + isTriggered: isTriggeredNow, + }; +}; + +// Right now the maximum number of monitors shown in the message is hardcoded here. +// we might want to make this a parameter in the future +const DEFAULT_MAX_MESSAGE_ROWS = 3; + +export const statusCheckAlertFactory: UptimeAlertTypeFactory = (server, libs) => ({ + id: 'xpack.uptime.alerts.monitorStatus', + name: i18n.translate('xpack.uptime.alerts.monitorStatus', { + defaultMessage: 'Uptime Monitor Status', + }), + validate: { + params: schema.object({ + filters: schema.maybe(schema.string()), + numTimes: schema.number(), + timerange: schema.object({ + from: schema.string(), + to: schema.string(), + }), + locations: schema.arrayOf(schema.string()), + }), + }, + defaultActionGroupId: MONITOR_STATUS.id, + actionGroups: [ + { + id: MONITOR_STATUS.id, + name: MONITOR_STATUS.name, + }, + ], + async executor(options: AlertExecutorOptions) { + const { params: rawParams } = options; + const decoded = StatusCheckExecutorParamsType.decode(rawParams); + if (!isRight(decoded)) { + ThrowReporter.report(decoded); + return { + error: 'Alert param types do not conform to required shape.', + }; + } + + const params = decoded.right; + + /* This is called `monitorsByLocation` but it's really + * monitors by location by status. The query we run to generate this + * filters on the status field, so effectively there should be one and only one + * status represented in the result set. */ + const monitorsByLocation = await libs.requests.getMonitorStatus({ + callES: options.services.callCluster, + ...params, + }); + + // if no monitors are down for our query, we don't need to trigger an alert + if (monitorsByLocation.length) { + const uniqueIds = uniqueMonitorIds(monitorsByLocation); + const alertInstance = options.services.alertInstanceFactory(MONITOR_STATUS.id); + alertInstance.replaceState({ + ...options.state, + monitors: monitorsByLocation, + }); + alertInstance.scheduleActions(MONITOR_STATUS.id, { + message: contextMessage(Array.from(uniqueIds.keys()), DEFAULT_MAX_MESSAGE_ROWS), + server, + completeIdList: fullListByIdAndLocation(monitorsByLocation), + }); + } + + // this stateful data is at the cluster level, not an alert instance level, + // so any alert of this type will flush/overwrite the state when they return + return updateState(options.state, monitorsByLocation.length > 0); + }, +}); diff --git a/x-pack/plugins/infra/public/components/logging/log_minimap/types.ts b/x-pack/plugins/uptime/server/lib/alerts/types.ts similarity index 50% rename from x-pack/plugins/infra/public/components/logging/log_minimap/types.ts rename to x-pack/plugins/uptime/server/lib/alerts/types.ts index d8197935dafa7..bc1e82224f7b0 100644 --- a/x-pack/plugins/infra/public/components/logging/log_minimap/types.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/types.ts @@ -4,14 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TimeKey } from '../../../../common/time'; +import { AlertType } from '../../../../alerting/server'; +import { UptimeCoreSetup } from '../adapters'; +import { UMServerLibs } from '../lib'; -export interface SummaryBucket { - start: number; - end: number; - entriesCount: number; -} - -export interface SummaryHighlightBucket extends SummaryBucket { - representativeKey: TimeKey; -} +export type UptimeAlertTypeFactory = (server: UptimeCoreSetup, libs: UMServerLibs) => AlertType; diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts new file mode 100644 index 0000000000000..74b8c352c8553 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts @@ -0,0 +1,553 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { elasticsearchServiceMock } from '../../../../../../../src/core/server/mocks'; +import { getMonitorStatus } from '../get_monitor_status'; +import { ScopedClusterClient } from 'src/core/server/elasticsearch'; + +interface BucketItemCriteria { + monitor_id: string; + status: string; + location: string; + doc_count: number; +} + +interface BucketKey { + monitor_id: string; + status: string; + location: string; +} + +interface BucketItem { + key: BucketKey; + doc_count: number; +} + +interface MultiPageCriteria { + after_key?: BucketKey; + bucketCriteria: BucketItemCriteria[]; +} + +const genBucketItem = ({ + monitor_id, + status, + location, + doc_count, +}: BucketItemCriteria): BucketItem => ({ + key: { + monitor_id, + status, + location, + }, + doc_count, +}); + +type MockCallES = (method: any, params: any) => Promise; + +const setupMock = ( + criteria: MultiPageCriteria[] +): [MockCallES, jest.Mocked>] => { + const esMock = elasticsearchServiceMock.createScopedClusterClient(); + + criteria.forEach(({ after_key, bucketCriteria }) => { + const mockResponse = { + aggregations: { + monitors: { + after_key, + buckets: bucketCriteria.map(item => genBucketItem(item)), + }, + }, + }; + esMock.callAsCurrentUser.mockResolvedValueOnce(mockResponse); + }); + return [(method: any, params: any) => esMock.callAsCurrentUser(method, params), esMock]; +}; + +describe('getMonitorStatus', () => { + it('applies bool filters to params', async () => { + const [callES, esMock] = setupMock([]); + const exampleFilter = `{ + "bool": { + "should": [ + { + "bool": { + "should": [ + { + "match_phrase": { + "monitor.id": "apm-dev" + } + } + ], + "minimum_should_match": 1 + } + }, + { + "bool": { + "should": [ + { + "match_phrase": { + "monitor.id": "auto-http-0X8D6082B94BBE3B8A" + } + } + ], + "minimum_should_match": 1 + } + } + ], + "minimum_should_match": 1 + } + }`; + await getMonitorStatus({ + callES, + filters: exampleFilter, + locations: [], + numTimes: 5, + timerange: { + from: 'now-10m', + to: 'now-1m', + }, + }); + expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); + const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(params).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "composite": Object { + "size": 2000, + "sources": Array [ + Object { + "monitor_id": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "status": Object { + "terms": Object { + "field": "monitor.status", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.status": "down", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-10m", + "lte": "now-1m", + }, + }, + }, + ], + "minimum_should_match": 1, + "should": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "monitor.id": "apm-dev", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "monitor.id": "auto-http-0X8D6082B94BBE3B8A", + }, + }, + ], + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + } + `); + }); + + it('applies locations to params', async () => { + const [callES, esMock] = setupMock([]); + await getMonitorStatus({ + callES, + locations: ['fairbanks', 'harrisburg'], + numTimes: 1, + timerange: { + from: 'now-2m', + to: 'now', + }, + }); + expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); + const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(params).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "composite": Object { + "size": 2000, + "sources": Array [ + Object { + "monitor_id": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "status": Object { + "terms": Object { + "field": "monitor.status", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.status": "down", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-2m", + "lte": "now", + }, + }, + }, + Object { + "bool": Object { + "should": Array [ + Object { + "term": Object { + "observer.geo.name": "fairbanks", + }, + }, + Object { + "term": Object { + "observer.geo.name": "harrisburg", + }, + }, + ], + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + } + `); + }); + + it('fetches single page of results', async () => { + const [callES, esMock] = setupMock([ + { + bucketCriteria: [ + { + monitor_id: 'foo', + status: 'down', + location: 'fairbanks', + doc_count: 43, + }, + { + monitor_id: 'bar', + status: 'down', + location: 'harrisburg', + doc_count: 53, + }, + { + monitor_id: 'foo', + status: 'down', + location: 'harrisburg', + doc_count: 44, + }, + ], + }, + ]); + const clientParameters = { + filters: undefined, + locations: [], + numTimes: 5, + timerange: { + from: 'now-12m', + to: 'now-2m', + }, + }; + const result = await getMonitorStatus({ + callES, + ...clientParameters, + }); + expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1); + const [method, params] = esMock.callAsCurrentUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(params).toMatchInlineSnapshot(` + Object { + "body": Object { + "aggs": Object { + "monitors": Object { + "composite": Object { + "size": 2000, + "sources": Array [ + Object { + "monitor_id": Object { + "terms": Object { + "field": "monitor.id", + }, + }, + }, + Object { + "status": Object { + "terms": Object { + "field": "monitor.status", + }, + }, + }, + Object { + "location": Object { + "terms": Object { + "field": "observer.geo.name", + "missing_bucket": true, + }, + }, + }, + ], + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.status": "down", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "gte": "now-12m", + "lte": "now-2m", + }, + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "heartbeat-8*", + } + `); + + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "count": 43, + "location": "fairbanks", + "monitor_id": "foo", + "status": "down", + }, + Object { + "count": 53, + "location": "harrisburg", + "monitor_id": "bar", + "status": "down", + }, + Object { + "count": 44, + "location": "harrisburg", + "monitor_id": "foo", + "status": "down", + }, + ] + `); + }); + + it('fetches multiple pages of results in the thing', async () => { + const criteria = [ + { + after_key: { + monitor_id: 'foo', + location: 'harrisburg', + status: 'down', + }, + bucketCriteria: [ + { + monitor_id: 'foo', + status: 'down', + location: 'fairbanks', + doc_count: 43, + }, + { + monitor_id: 'bar', + status: 'down', + location: 'harrisburg', + doc_count: 53, + }, + { + monitor_id: 'foo', + status: 'down', + location: 'harrisburg', + doc_count: 44, + }, + ], + }, + { + after_key: { + monitor_id: 'bar', + status: 'down', + location: 'fairbanks', + }, + bucketCriteria: [ + { + monitor_id: 'sna', + status: 'down', + location: 'fairbanks', + doc_count: 21, + }, + { + monitor_id: 'fu', + status: 'down', + location: 'fairbanks', + doc_count: 21, + }, + { + monitor_id: 'bar', + status: 'down', + location: 'fairbanks', + doc_count: 45, + }, + ], + }, + { + bucketCriteria: [ + { + monitor_id: 'sna', + status: 'down', + location: 'harrisburg', + doc_count: 21, + }, + { + monitor_id: 'fu', + status: 'down', + location: 'harrisburg', + doc_count: 21, + }, + ], + }, + ]; + const [callES] = setupMock(criteria); + const result = await getMonitorStatus({ + callES, + locations: [], + numTimes: 5, + timerange: { + from: 'now-10m', + to: 'now-1m', + }, + }); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "count": 43, + "location": "fairbanks", + "monitor_id": "foo", + "status": "down", + }, + Object { + "count": 53, + "location": "harrisburg", + "monitor_id": "bar", + "status": "down", + }, + Object { + "count": 44, + "location": "harrisburg", + "monitor_id": "foo", + "status": "down", + }, + Object { + "count": 21, + "location": "fairbanks", + "monitor_id": "sna", + "status": "down", + }, + Object { + "count": 21, + "location": "fairbanks", + "monitor_id": "fu", + "status": "down", + }, + Object { + "count": 45, + "location": "fairbanks", + "monitor_id": "bar", + "status": "down", + }, + Object { + "count": 21, + "location": "harrisburg", + "monitor_id": "sna", + "status": "down", + }, + Object { + "count": 21, + "location": "harrisburg", + "monitor_id": "fu", + "status": "down", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts new file mode 100644 index 0000000000000..2cebd532fd29b --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UMElasticsearchQueryFn } from '../adapters'; +import { INDEX_NAMES } from '../../../../../legacy/plugins/uptime/common/constants'; + +export interface GetMonitorStatusParams { + filters?: string; + locations: string[]; + numTimes: number; + timerange: { from: string; to: string }; +} + +export interface GetMonitorStatusResult { + monitor_id: string; + status: string; + location: string; + count: number; +} + +interface MonitorStatusKey { + monitor_id: string; + status: string; + location: string; +} + +const formatBuckets = async ( + buckets: any[], + numTimes: number +): Promise => { + return buckets + .filter((monitor: any) => monitor?.doc_count > numTimes) + .map(({ key, doc_count }: any) => ({ ...key, count: doc_count })); +}; + +const getLocationClause = (locations: string[]) => ({ + bool: { + should: [ + ...locations.map(location => ({ + term: { + 'observer.geo.name': location, + }, + })), + ], + }, +}); + +export const getMonitorStatus: UMElasticsearchQueryFn< + GetMonitorStatusParams, + GetMonitorStatusResult[] +> = async ({ callES, filters, locations, numTimes, timerange: { from, to } }) => { + const queryResults: Array> = []; + let afterKey: MonitorStatusKey | undefined; + + do { + // today this value is hardcoded. In the future we may support + // multiple status types for this alert, and this will become a parameter + const STATUS = 'down'; + const esParams: any = { + index: INDEX_NAMES.HEARTBEAT, + body: { + query: { + bool: { + filter: [ + { + term: { + 'monitor.status': STATUS, + }, + }, + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ], + }, + }, + size: 0, + aggs: { + monitors: { + composite: { + size: 2000, + sources: [ + { + monitor_id: { + terms: { + field: 'monitor.id', + }, + }, + }, + { + status: { + terms: { + field: 'monitor.status', + }, + }, + }, + { + location: { + terms: { + field: 'observer.geo.name', + missing_bucket: true, + }, + }, + }, + ], + }, + }, + }, + }, + }; + + /** + * `filters` are an unparsed JSON string. We parse them and append the bool fields of the query + * to the bool of the parsed filters. + */ + if (filters) { + const parsedFilters = JSON.parse(filters); + esParams.body.query.bool = Object.assign({}, esParams.body.query.bool, parsedFilters.bool); + } + + /** + * Perform a logical `and` against the selected location filters. + */ + if (locations.length) { + esParams.body.query.bool.filter.push(getLocationClause(locations)); + } + + /** + * We "paginate" results by utilizing the `afterKey` field + * to tell Elasticsearch where it should start on subsequent queries. + */ + if (afterKey) { + esParams.body.aggs.monitors.composite.after = afterKey; + } + + const result = await callES('search', esParams); + afterKey = result?.aggregations?.monitors?.after_key; + + queryResults.push(formatBuckets(result?.aggregations?.monitors?.buckets || [], numTimes)); + } while (afterKey !== undefined); + + return (await Promise.all(queryResults)).reduce((acc, cur) => acc.concat(cur), []); +}; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_snapshot_counts.ts b/x-pack/plugins/uptime/server/lib/requests/get_snapshot_counts.ts index ca9da51cc7ba8..1783c6e91df34 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_snapshot_counts.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_snapshot_counts.ts @@ -89,7 +89,7 @@ const statusCountBody = (filters: any): any => { String id = doc["monitor.id"][0]; String idLenDelim = Integer.toHexString(id.length()) + ":" + id; String idLoc = loc == null ? idLenDelim : idLenDelim + loc; - + String status = doc["summary.down"][0] > 0 ? "d" : "u"; String timeAndStatus = doc["@timestamp"][0].toInstant().toEpochMilli().toString() + status; state.locStatus[idLoc] = timeAndStatus; @@ -111,7 +111,7 @@ const statusCountBody = (filters: any): any => { locStatus.merge(entry.getKey(), entry.getValue(), (a,b) -> a.compareTo(b) > 0 ? a : b) } } - + HashMap locTotals = new HashMap(); int total = 0; int down = 0; @@ -130,7 +130,7 @@ const statusCountBody = (filters: any): any => { String id = idLoc.substring(colonIndex + 1, idEnd); String loc = idLoc.substring(idEnd, idLoc.length()); String status = timeStatus.substring(timeStatus.length() - 1); - + // Here we increment counters for the up/down key per location // We also create a new hashmap in locTotals if we've never seen this location // before. @@ -141,7 +141,7 @@ const statusCountBody = (filters: any): any => { res.put('up', 0); res.put('down', 0); } - + if (status == 'u') { res.up++; } else { @@ -150,8 +150,8 @@ const statusCountBody = (filters: any): any => { return res; }); - - + + // We've encountered a new ID if (curId != id) { total++; @@ -171,7 +171,7 @@ const statusCountBody = (filters: any): any => { } } } - + Map result = new HashMap(); result.total = total; result.location_totals = locTotals; diff --git a/x-pack/plugins/uptime/server/lib/requests/index.ts b/x-pack/plugins/uptime/server/lib/requests/index.ts index b1d7ff2c2ce02..7225d329d3c7f 100644 --- a/x-pack/plugins/uptime/server/lib/requests/index.ts +++ b/x-pack/plugins/uptime/server/lib/requests/index.ts @@ -12,6 +12,8 @@ export { getMonitorDurationChart, GetMonitorChartsParams } from './get_monitor_d export { getMonitorDetails, GetMonitorDetailsParams } from './get_monitor_details'; export { getMonitorLocations, GetMonitorLocationsParams } from './get_monitor_locations'; export { getMonitorStates, GetMonitorStatesParams } from './get_monitor_states'; +export { getMonitorStatus, GetMonitorStatusParams } from './get_monitor_status'; +export * from './get_monitor_status'; export { getPings, GetPingsParams } from './get_pings'; export { getPingHistogram, GetPingHistogramParams } from './get_ping_histogram'; export { UptimeRequests } from './uptime_requests'; diff --git a/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts b/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts index 7f192994bd075..ddf506786f145 100644 --- a/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts +++ b/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts @@ -16,6 +16,8 @@ import { GetMonitorStatesParams, GetPingsParams, GetPingHistogramParams, + GetMonitorStatusParams, + GetMonitorStatusResult, } from '.'; import { OverviewFilters, @@ -42,6 +44,7 @@ export interface UptimeRequests { getMonitorDetails: ESQ; getMonitorLocations: ESQ; getMonitorStates: ESQ; + getMonitorStatus: ESQ; getPings: ESQ; getPingHistogram: ESQ; getSnapshotCount: ESQ; diff --git a/x-pack/plugins/uptime/server/rest_api/index_state/get_index_pattern.ts b/x-pack/plugins/uptime/server/rest_api/index_state/get_index_pattern.ts index cc65749153c1d..806d6e789a890 100644 --- a/x-pack/plugins/uptime/server/rest_api/index_state/get_index_pattern.ts +++ b/x-pack/plugins/uptime/server/rest_api/index_state/get_index_pattern.ts @@ -6,10 +6,11 @@ import { UMServerLibs } from '../../lib/lib'; import { UMRestApiRouteFactory } from '../types'; +import { API_URLS } from '../../../../../legacy/plugins/uptime/common/constants/rest_api'; export const createGetIndexPatternRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', - path: '/api/uptime/index_pattern', + path: API_URLS.INDEX_PATTERN, validate: false, options: { tags: ['access:uptime'], diff --git a/x-pack/plugins/uptime/server/rest_api/index_state/get_index_status.ts b/x-pack/plugins/uptime/server/rest_api/index_state/get_index_status.ts index 44799aa19c140..d4d76c86870ee 100644 --- a/x-pack/plugins/uptime/server/rest_api/index_state/get_index_status.ts +++ b/x-pack/plugins/uptime/server/rest_api/index_state/get_index_status.ts @@ -6,11 +6,11 @@ import { UMServerLibs } from '../../lib/lib'; import { UMRestApiRouteFactory } from '../types'; -import { REST_API_URLS } from '../../../../../legacy/plugins/uptime/common/constants/rest_api'; +import { API_URLS } from '../../../../../legacy/plugins/uptime/common/constants'; export const createGetIndexStatusRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', - path: REST_API_URLS.INDEX_STATUS, + path: API_URLS.INDEX_STATUS, validate: false, options: { tags: ['access:uptime'], diff --git a/x-pack/plugins/uptime/server/rest_api/monitors/monitor_locations.ts b/x-pack/plugins/uptime/server/rest_api/monitors/monitor_locations.ts index f8c7666f53f7d..131b3cbe2ab44 100644 --- a/x-pack/plugins/uptime/server/rest_api/monitors/monitor_locations.ts +++ b/x-pack/plugins/uptime/server/rest_api/monitors/monitor_locations.ts @@ -7,10 +7,11 @@ import { schema } from '@kbn/config-schema'; import { UMServerLibs } from '../../lib/lib'; import { UMRestApiRouteFactory } from '../types'; +import { API_URLS } from '../../../../../legacy/plugins/uptime/common/constants/rest_api'; export const createGetMonitorLocationsRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', - path: '/api/uptime/monitor/locations', + path: API_URLS.MONITOR_LOCATIONS, validate: { query: schema.object({ monitorId: schema.string(), diff --git a/x-pack/plugins/uptime/server/rest_api/monitors/monitors_details.ts b/x-pack/plugins/uptime/server/rest_api/monitors/monitors_details.ts index ca88dd965c1ad..66e952813eb3e 100644 --- a/x-pack/plugins/uptime/server/rest_api/monitors/monitors_details.ts +++ b/x-pack/plugins/uptime/server/rest_api/monitors/monitors_details.ts @@ -7,10 +7,11 @@ import { schema } from '@kbn/config-schema'; import { UMServerLibs } from '../../lib/lib'; import { UMRestApiRouteFactory } from '../types'; +import { API_URLS } from '../../../../../legacy/plugins/uptime/common/constants/rest_api'; export const createGetMonitorDetailsRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', - path: '/api/uptime/monitor/details', + path: API_URLS.MONITOR_DETAILS, validate: { query: schema.object({ monitorId: schema.string(), diff --git a/x-pack/plugins/uptime/server/rest_api/monitors/monitors_durations.ts b/x-pack/plugins/uptime/server/rest_api/monitors/monitors_durations.ts index 63e74175609ad..f4a4cadc99976 100644 --- a/x-pack/plugins/uptime/server/rest_api/monitors/monitors_durations.ts +++ b/x-pack/plugins/uptime/server/rest_api/monitors/monitors_durations.ts @@ -7,10 +7,12 @@ import { schema } from '@kbn/config-schema'; import { UMServerLibs } from '../../lib/lib'; import { UMRestApiRouteFactory } from '../types'; +import { API_URLS } from '../../../../../legacy/plugins/uptime/common/constants/rest_api'; export const createGetMonitorDurationRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', - path: '/api/uptime/monitor/duration', + path: API_URLS.MONITOR_DURATION, + validate: { query: schema.object({ monitorId: schema.string(), diff --git a/x-pack/plugins/uptime/server/rest_api/monitors/status.ts b/x-pack/plugins/uptime/server/rest_api/monitors/status.ts index 8dac50c9f5905..08cbc2d70e515 100644 --- a/x-pack/plugins/uptime/server/rest_api/monitors/status.ts +++ b/x-pack/plugins/uptime/server/rest_api/monitors/status.ts @@ -7,10 +7,12 @@ import { schema } from '@kbn/config-schema'; import { UMServerLibs } from '../../lib/lib'; import { UMRestApiRouteFactory } from '../types'; +import { API_URLS } from '../../../../../legacy/plugins/uptime/common/constants/rest_api'; export const createGetMonitorRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', - path: '/api/uptime/monitor/selected', + path: API_URLS.MONITOR_SELECTED, + validate: { query: schema.object({ monitorId: schema.string(), @@ -32,7 +34,8 @@ export const createGetMonitorRoute: UMRestApiRouteFactory = (libs: UMServerLibs) export const createGetStatusBarRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', - path: '/api/uptime/monitor/status', + path: API_URLS.MONITOR_STATUS, + validate: { query: schema.object({ monitorId: schema.string(), diff --git a/x-pack/plugins/uptime/server/rest_api/overview_filters/get_overview_filters.ts b/x-pack/plugins/uptime/server/rest_api/overview_filters/get_overview_filters.ts index 02e54cb441838..5525771539c63 100644 --- a/x-pack/plugins/uptime/server/rest_api/overview_filters/get_overview_filters.ts +++ b/x-pack/plugins/uptime/server/rest_api/overview_filters/get_overview_filters.ts @@ -8,6 +8,7 @@ import { schema } from '@kbn/config-schema'; import { UMServerLibs } from '../../lib/lib'; import { UMRestApiRouteFactory } from '../types'; import { objectValuesToArrays } from '../../lib/helper'; +import { API_URLS } from '../../../../../legacy/plugins/uptime/common/constants/rest_api'; const arrayOrStringType = schema.maybe( schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) @@ -15,7 +16,7 @@ const arrayOrStringType = schema.maybe( export const createGetOverviewFilters: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', - path: '/api/uptime/filters', + path: API_URLS.FILTERS, validate: { query: schema.object({ dateRangeStart: schema.string(), diff --git a/x-pack/plugins/uptime/server/rest_api/pings/get_all.ts b/x-pack/plugins/uptime/server/rest_api/pings/get_all.ts index 21168edfc9744..e301a2cbf9af9 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/get_all.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/get_all.ts @@ -7,10 +7,11 @@ import { schema } from '@kbn/config-schema'; import { UMServerLibs } from '../../lib/lib'; import { UMRestApiRouteFactory } from '../types'; +import { API_URLS } from '../../../../../legacy/plugins/uptime/common/constants/rest_api'; export const createGetAllRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', - path: '/api/uptime/pings', + path: API_URLS.PINGS, validate: { query: schema.object({ dateRangeStart: schema.string(), diff --git a/x-pack/plugins/uptime/server/rest_api/pings/get_ping_histogram.ts b/x-pack/plugins/uptime/server/rest_api/pings/get_ping_histogram.ts index 93ba4490fa31f..dfaabcdf93a06 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/get_ping_histogram.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/get_ping_histogram.ts @@ -7,10 +7,11 @@ import { schema } from '@kbn/config-schema'; import { UMServerLibs } from '../../lib/lib'; import { UMRestApiRouteFactory } from '../types'; +import { API_URLS } from '../../../../../legacy/plugins/uptime/common/constants/rest_api'; export const createGetPingHistogramRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', - path: '/api/uptime/ping/histogram', + path: API_URLS.PING_HISTOGRAM, validate: { query: schema.object({ dateStart: schema.string(), diff --git a/x-pack/plugins/uptime/server/rest_api/pings/get_pings.ts b/x-pack/plugins/uptime/server/rest_api/pings/get_pings.ts index e57951c98b6fc..458107dd87a77 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/get_pings.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/get_pings.ts @@ -7,10 +7,11 @@ import { schema } from '@kbn/config-schema'; import { UMServerLibs } from '../../lib/lib'; import { UMRestApiRouteFactory } from '../types'; +import { API_URLS } from '../../../../../legacy/plugins/uptime/common/constants/rest_api'; export const createGetPingsRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', - path: '/api/uptime/pings', + path: API_URLS.PINGS, validate: { query: schema.object({ dateRangeStart: schema.string(), diff --git a/x-pack/plugins/uptime/server/rest_api/snapshot/get_snapshot_count.ts b/x-pack/plugins/uptime/server/rest_api/snapshot/get_snapshot_count.ts index c51806e323307..697c49dc8300b 100644 --- a/x-pack/plugins/uptime/server/rest_api/snapshot/get_snapshot_count.ts +++ b/x-pack/plugins/uptime/server/rest_api/snapshot/get_snapshot_count.ts @@ -7,10 +7,11 @@ import { schema } from '@kbn/config-schema'; import { UMServerLibs } from '../../lib/lib'; import { UMRestApiRouteFactory } from '../types'; +import { API_URLS } from '../../../../../legacy/plugins/uptime/common/constants/rest_api'; export const createGetSnapshotCount: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', - path: '/api/uptime/snapshot/count', + path: API_URLS.SNAPSHOT_COUNT, validate: { query: schema.object({ dateRangeStart: schema.string(), diff --git a/x-pack/plugins/uptime/server/uptime_server.ts b/x-pack/plugins/uptime/server/uptime_server.ts index 4dfa1373db8d9..d4b38b8ad27a0 100644 --- a/x-pack/plugins/uptime/server/uptime_server.ts +++ b/x-pack/plugins/uptime/server/uptime_server.ts @@ -8,12 +8,22 @@ import { makeExecutableSchema } from 'graphql-tools'; import { DEFAULT_GRAPHQL_PATH, resolvers, typeDefs } from './graphql'; import { UMServerLibs } from './lib/lib'; import { createRouteWithAuth, restApiRoutes, uptimeRouteWrapper } from './rest_api'; +import { UptimeCoreSetup, UptimeCorePlugins } from './lib/adapters'; +import { uptimeAlertTypeFactories } from './lib/alerts'; -export const initUptimeServer = (libs: UMServerLibs) => { +export const initUptimeServer = ( + server: UptimeCoreSetup, + libs: UMServerLibs, + plugins: UptimeCorePlugins +) => { restApiRoutes.forEach(route => libs.framework.registerRoute(uptimeRouteWrapper(createRouteWithAuth(libs, route))) ); + uptimeAlertTypeFactories.forEach(alertTypeFactory => + plugins.alerting.registerType(alertTypeFactory(server, libs)) + ); + const graphQLSchema = makeExecutableSchema({ resolvers: resolvers.map(createResolversFn => createResolversFn(libs)), typeDefs, diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/servicenow_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/servicenow_simulation.ts index 3f1a095238939..329262044357b 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/servicenow_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/servicenow_simulation.ts @@ -9,95 +9,72 @@ import Hapi from 'hapi'; interface ServiceNowRequest extends Hapi.Request { payload: { - caseId: string; - title?: string; + short_description: string; description?: string; - comments?: Array<{ commentId: string; version: string; comment: string }>; + comments?: string; }; } export function initPlugin(server: Hapi.Server, path: string) { server.route({ method: 'POST', - path, + path: `${path}/api/now/v2/table/incident`, options: { auth: false, - validate: { - options: { abortEarly: false }, - payload: Joi.object().keys({ - caseId: Joi.string(), - title: Joi.string(), - description: Joi.string(), - comments: Joi.array().items( - Joi.object({ - commentId: Joi.string(), - version: Joi.string(), - comment: Joi.string(), - }) - ), - }), - }, }, - handler: servicenowHandler, + handler: createHandler, }); server.route({ - method: 'POST', - path: `${path}/api/now/v2/table/incident`, + method: 'PATCH', + path: `${path}/api/now/v2/table/incident/{id}`, options: { auth: false, validate: { - options: { abortEarly: false }, - payload: Joi.object().keys({ - caseId: Joi.string(), - title: Joi.string(), - description: Joi.string(), - comments: Joi.array().items( - Joi.object({ - commentId: Joi.string(), - version: Joi.string(), - comment: Joi.string(), - }) - ), + params: Joi.object({ + id: Joi.string(), }), }, }, - handler: servicenowHandler, + handler: updateHandler, }); server.route({ - method: 'PATCH', + method: 'GET', path: `${path}/api/now/v2/table/incident`, options: { auth: false, - validate: { - options: { abortEarly: false }, - payload: Joi.object().keys({ - caseId: Joi.string(), - title: Joi.string(), - description: Joi.string(), - comments: Joi.array().items( - Joi.object({ - commentId: Joi.string(), - version: Joi.string(), - comment: Joi.string(), - }) - ), - }), - }, }, - handler: servicenowHandler, + handler: getHandler, }); } + // ServiceNow simulator: create a servicenow action pointing here, and you can get // different responses based on the message posted. See the README.md for // more info. - -function servicenowHandler(request: ServiceNowRequest, h: any) { +function createHandler(request: ServiceNowRequest, h: any) { return jsonResponse(h, 200, { result: { sys_id: '123', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' }, }); } +function updateHandler(request: ServiceNowRequest, h: any) { + return jsonResponse(h, 200, { + result: { sys_id: '123', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' }, + }); +} + +function getHandler(request: ServiceNowRequest, h: any) { + return jsonResponse(h, 200, { + result: { + sys_id: '123', + number: 'INC01', + sys_created_on: '2020-03-10 12:24:20', + short_description: 'title', + description: 'description', + }, + }); +} + function jsonResponse(h: any, code: number, object?: any) { if (object == null) { return h.response('').code(code); diff --git a/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts b/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts index ccd7748d9e899..999a8686e0ee7 100644 --- a/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts +++ b/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts @@ -4,20 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -export const ES_TEST_INDEX_NAME = '.kibaka-alerting-test-data'; +export const ES_TEST_INDEX_NAME = '.kibana-alerting-test-data'; export class ESTestIndexTool { - private readonly es: any; - private readonly retry: any; - - constructor(es: any, retry: any) { - this.es = es; - this.retry = retry; - } + constructor( + private readonly es: any, + private readonly retry: any, + private readonly index: string = ES_TEST_INDEX_NAME + ) {} async setup() { return await this.es.indices.create({ - index: ES_TEST_INDEX_NAME, + index: this.index, body: { mappings: { properties: { @@ -56,12 +54,13 @@ export class ESTestIndexTool { } async destroy() { - return await this.es.indices.delete({ index: ES_TEST_INDEX_NAME, ignore: [404] }); + return await this.es.indices.delete({ index: this.index, ignore: [404] }); } async search(source: string, reference: string) { return await this.es.search({ - index: ES_TEST_INDEX_NAME, + index: this.index, + size: 1000, body: { query: { bool: { @@ -86,7 +85,7 @@ export class ESTestIndexTool { async waitForDocs(source: string, reference: string, numDocs: number = 1) { return await this.retry.try(async () => { const searchResult = await this.search(source, reference); - if (searchResult.hits.total.value !== numDocs) { + if (searchResult.hits.total.value < numDocs) { throw new Error(`Expected ${numDocs} but received ${searchResult.hits.total.value}.`); } return searchResult.hits.hits; diff --git a/x-pack/test/alerting_api_integration/common/lib/task_manager_utils.ts b/x-pack/test/alerting_api_integration/common/lib/task_manager_utils.ts index 3a1d035a023c2..8eb0d11bbb569 100644 --- a/x-pack/test/alerting_api_integration/common/lib/task_manager_utils.ts +++ b/x-pack/test/alerting_api_integration/common/lib/task_manager_utils.ts @@ -13,7 +13,7 @@ export class TaskManagerUtils { this.retry = retry; } - async waitForIdle(taskRunAtFilter: Date) { + async waitForEmpty(taskRunAtFilter: Date) { return await this.retry.try(async () => { const searchResult = await this.es.search({ index: '.kibana_task_manager', @@ -44,6 +44,44 @@ export class TaskManagerUtils { }); } + async waitForAllTasksIdle(taskRunAtFilter: Date) { + return await this.retry.try(async () => { + const searchResult = await this.es.search({ + index: '.kibana_task_manager', + body: { + query: { + bool: { + must: [ + { + terms: { + 'task.scope': ['actions', 'alerting'], + }, + }, + { + range: { + 'task.scheduledAt': { + gte: taskRunAtFilter, + }, + }, + }, + ], + must_not: [ + { + term: { + 'task.status': 'idle', + }, + }, + ], + }, + }, + }, + }); + if (searchResult.hits.total.value) { + throw new Error(`Expected 0 non-idle tasks but received ${searchResult.hits.total.value}`); + } + }); + } + async waitForActionTaskParamsToBeCleanedUp(createdAtFilter: Date): Promise { return await this.retry.try(async () => { const searchResult = await this.es.search({ diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts index 63c118966cfae..48f348e1b834d 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts @@ -18,18 +18,18 @@ import { const mapping = [ { source: 'title', - target: 'description', - actionType: 'nothing', + target: 'short_description', + actionType: 'overwrite', }, { source: 'description', - target: 'short_description', - actionType: 'nothing', + target: 'description', + actionType: 'append', }, { source: 'comments', target: 'comments', - actionType: 'nothing', + actionType: 'append', }, ]; @@ -49,19 +49,23 @@ export default function servicenowTest({ getService }: FtrProviderContext) { username: 'changeme', }, params: { - caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', - title: 'A title', - description: 'A description', + caseId: '123', + title: 'a title', + description: 'a description', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + incidentId: null, comments: [ - { - commentId: '123', - version: 'WzU3LDFd', - comment: 'A comment', - }, { commentId: '456', - version: 'WzU5LVFd', - comment: 'Another comment', + version: 'WzU3LDFd', + comment: 'first comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, }, ], }, @@ -283,14 +287,19 @@ export default function servicenowTest({ getService }: FtrProviderContext) { .post(`/api/action/${simulatedActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ - params: { caseId: 'success' }, + params: { ...mockServiceNow.params, title: 'success', comments: [] }, }) .expect(200); expect(result).to.eql({ status: 'ok', actionId: simulatedActionId, - data: { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z' }, + data: { + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: `${servicenowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, + }, }); }); @@ -311,5 +320,113 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }); }); }); + + it('should handle failing with a simulated success without title', async () => { + await supertest + .post(`/api/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { caseId: 'success' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: [title]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should handle failing with a simulated success without createdAt', async () => { + await supertest + .post(`/api/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { caseId: 'success', title: 'success' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: [createdAt]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should handle failing with a simulated success without commentId', async () => { + await supertest + .post(`/api/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + caseId: 'success', + title: 'success', + createdAt: 'success', + createdBy: { username: 'elastic' }, + comments: [{}], + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: [comments.0.commentId]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should handle failing with a simulated success without comment message', async () => { + await supertest + .post(`/api/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + caseId: 'success', + title: 'success', + createdAt: 'success', + createdBy: { username: 'elastic' }, + comments: [{ commentId: 'success' }], + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: [comments.0.comment]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should handle failing with a simulated success without comment.createdAt', async () => { + await supertest + .post(`/api/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + caseId: 'success', + title: 'success', + createdAt: 'success', + createdBy: { username: 'elastic' }, + comments: [{ commentId: 'success', comment: 'success' }], + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: [comments.0.createdAt]: expected value of type [string] but got [undefined]', + }); + }); + }); }); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/slack.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/slack.ts index 5dcff8712a28d..8afa43bfea21e 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/slack.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/slack.ts @@ -159,6 +159,20 @@ export default function slackTest({ getService }: FtrProviderContext) { expect(result.status).to.eql('ok'); }); + it('should handle an empty message error', async () => { + const { body: result } = await supertest + .post(`/api/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + message: '', + }, + }) + .expect(200); + expect(result.status).to.eql('error'); + expect(result.message).to.match(/error validating action params: \[message\]: /); + }); + it('should handle a 40x slack error', async () => { const { body: result } = await supertest .post(`/api/action/${simulatedActionId}/_execute`) diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts index 6766705f688a6..6eed28cc381dd 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -26,9 +26,7 @@ export default function alertTests({ getService }: FtrProviderContext) { const esTestIndexTool = new ESTestIndexTool(es, retry); const taskManagerUtils = new TaskManagerUtils(es, retry); - // FLAKY: https://github.com/elastic/kibana/issues/58643 - // FLAKY: https://github.com/elastic/kibana/issues/58991 - describe.skip('alerts', () => { + describe('alerts', () => { const authorizationIndex = '.kibana-test-authorization'; const objectRemover = new ObjectRemover(supertest); @@ -99,9 +97,11 @@ export default function alertTests({ getService }: FtrProviderContext) { // Wait for the action to index a document before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('action:test.index-record', reference); + await taskManagerUtils.waitForAllTasksIdle(testStart); + const alertId = response.body.id; await alertUtils.disable(alertId); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 1 alert executed with proper params const alertSearchResult = await esTestIndexTool.search( @@ -166,17 +166,23 @@ instanceStateValue: true }); it('should pass updated alert params to executor', async () => { + const testStart = new Date(); // create an alert const reference = alertUtils.generateReference(); - const overwrites = { - throttle: '1s', - schedule: { interval: '1s' }, - }; - const response = await alertUtils.createAlwaysFiringAction({ reference, overwrites }); + const response = await alertUtils.createAlwaysFiringAction({ + reference, + overwrites: { throttle: null }, + }); // only need to test creation success paths if (response.statusCode !== 200) return; + // Wait for the action to index a document before disabling the alert and waiting for tasks to finish + await esTestIndexTool.waitForDocs('action:test.index-record', reference); + + // Avoid invalidating an API key while the alert is executing + await taskManagerUtils.waitForAllTasksIdle(testStart); + // update the alert with super user const alertId = response.body.id; const reference2 = alertUtils.generateReference(); @@ -188,8 +194,8 @@ instanceStateValue: true overwrites: { name: 'def', tags: ['fee', 'fi', 'fo'], - throttle: '1s', - schedule: { interval: '1s' }, + // This will cause the task to re-run on update + schedule: { interval: '59s' }, }, }); @@ -197,6 +203,9 @@ instanceStateValue: true // make sure alert info passed to executor is correct await esTestIndexTool.waitForDocs('alert:test.always-firing', reference2); + + await taskManagerUtils.waitForAllTasksIdle(testStart); + await alertUtils.disable(alertId); const alertSearchResult = await esTestIndexTool.search( 'alert:test.always-firing', @@ -359,7 +368,7 @@ instanceStateValue: true // Wait for test.authorization to index a document before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('alert:test.authorization', reference); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 1 document exists with proper params searchResult = await esTestIndexTool.search('alert:test.authorization', reference); @@ -387,7 +396,7 @@ instanceStateValue: true // Wait for test.authorization to index a document before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('alert:test.authorization', reference); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 1 document exists with proper params searchResult = await esTestIndexTool.search('alert:test.authorization', reference); @@ -467,7 +476,7 @@ instanceStateValue: true // Ensure test.authorization indexed 1 document before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('action:test.authorization', reference); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 1 document with proper params exists searchResult = await esTestIndexTool.search('action:test.authorization', reference); @@ -495,7 +504,7 @@ instanceStateValue: true // Ensure test.authorization indexed 1 document before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('action:test.authorization', reference); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 1 document with proper params exists searchResult = await esTestIndexTool.search('action:test.authorization', reference); @@ -544,7 +553,7 @@ instanceStateValue: true // Wait until alerts scheduled actions 3 times before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('alert:test.always-firing', reference, 3); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure actions only executed once const searchResult = await esTestIndexTool.search( @@ -610,7 +619,7 @@ instanceStateValue: true // Wait for actions to execute twice before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('action:test.index-record', reference, 2); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 2 actions with proper params exists const searchResult = await esTestIndexTool.search( @@ -660,7 +669,7 @@ instanceStateValue: true // Actions should execute twice before widning things down await esTestIndexTool.waitForDocs('action:test.index-record', reference, 2); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Ensure only 2 actions are executed const searchResult = await esTestIndexTool.search( @@ -705,7 +714,7 @@ instanceStateValue: true // execution once before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('alert:test.always-firing', reference, 2); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Should not have executed any action const executedActionsResult = await esTestIndexTool.search( @@ -750,7 +759,7 @@ instanceStateValue: true // once before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('alert:test.always-firing', reference, 2); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Should not have executed any action const executedActionsResult = await esTestIndexTool.search( @@ -796,7 +805,7 @@ instanceStateValue: true // Ensure actions are executed once before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('action:test.index-record', reference, 1); await alertUtils.disable(response.body.id); - await taskManagerUtils.waitForIdle(testStart); + await taskManagerUtils.waitForEmpty(testStart); // Should have one document indexed by the action const searchResult = await esTestIndexTool.search( diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts new file mode 100644 index 0000000000000..13f3a4971183c --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts @@ -0,0 +1,398 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { Spaces } from '../../../../scenarios'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { + ESTestIndexTool, + ES_TEST_INDEX_NAME, + getUrlPrefix, + ObjectRemover, +} from '../../../../../common/lib'; +import { createEsDocuments } from './create_test_data'; + +const ALERT_TYPE_ID = '.index-threshold'; +const ACTION_TYPE_ID = '.index'; +const ES_TEST_INDEX_SOURCE = 'builtin-alert:index-threshold'; +const ES_TEST_INDEX_REFERENCE = '-na-'; +const ES_TEST_OUTPUT_INDEX_NAME = `${ES_TEST_INDEX_NAME}-output`; + +const ALERT_INTERVALS_TO_WRITE = 5; +const ALERT_INTERVAL_SECONDS = 3; +const ALERT_INTERVAL_MILLIS = ALERT_INTERVAL_SECONDS * 1000; + +// eslint-disable-next-line import/no-default-export +export default function alertTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + const es = getService('legacyEs'); + const esTestIndexTool = new ESTestIndexTool(es, retry); + const esTestIndexToolOutput = new ESTestIndexTool(es, retry, ES_TEST_OUTPUT_INDEX_NAME); + + describe('alert', async () => { + let endDate: string; + let actionId: string; + const objectRemover = new ObjectRemover(supertest); + + beforeEach(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + + await esTestIndexToolOutput.destroy(); + await esTestIndexToolOutput.setup(); + + actionId = await createAction(supertest, objectRemover); + + // write documents in the future, figure out the end date + const endDateMillis = Date.now() + (ALERT_INTERVALS_TO_WRITE - 1) * ALERT_INTERVAL_MILLIS; + endDate = new Date(endDateMillis).toISOString(); + + // write documents from now to the future end date in 3 groups + createEsDocumentsInGroups(3); + }); + + afterEach(async () => { + await objectRemover.removeAll(); + await esTestIndexTool.destroy(); + await esTestIndexToolOutput.destroy(); + }); + + // The tests below create two alerts, one that will fire, one that will + // never fire; the tests ensure the ones that should fire, do fire, and + // those that shouldn't fire, do not fire. + it('runs correctly: count all < >', async () => { + await createAlert({ + name: 'never fire', + aggType: 'count', + groupBy: 'all', + thresholdComparator: '<', + threshold: [0], + }); + + await createAlert({ + name: 'always fire', + aggType: 'count', + groupBy: 'all', + thresholdComparator: '>', + threshold: [-1], + }); + + const docs = await waitForDocs(2); + for (const doc of docs) { + const { group } = doc._source; + const { name, value, title, message } = doc._source.params; + + expect(name).to.be('always fire'); + expect(group).to.be('all documents'); + + // we'll check title and message in this test, but not subsequent ones + expect(title).to.be('alert always fire group all documents exceeded threshold'); + + const expectedPrefix = `alert always fire group all documents value ${value} exceeded threshold count > -1 over`; + const messagePrefix = message.substr(0, expectedPrefix.length); + expect(messagePrefix).to.be(expectedPrefix); + } + }); + + it('runs correctly: count grouped <= =>', async () => { + // create some more documents in the first group + createEsDocumentsInGroups(1); + + await createAlert({ + name: 'never fire', + aggType: 'count', + groupBy: 'top', + termField: 'group', + termSize: 2, + thresholdComparator: '<=', + threshold: [-1], + }); + + await createAlert({ + name: 'always fire', + aggType: 'count', + groupBy: 'top', + termField: 'group', + termSize: 2, // two actions will fire each interval + thresholdComparator: '>=', + threshold: [0], + }); + + const docs = await waitForDocs(4); + let inGroup0 = 0; + + for (const doc of docs) { + const { group } = doc._source; + const { name } = doc._source.params; + + expect(name).to.be('always fire'); + if (group === 'group-0') inGroup0++; + } + + // there should be 2 docs in group-0, rando split between others + expect(inGroup0).to.be(2); + }); + + it('runs correctly: sum all between', async () => { + // create some more documents in the first group + createEsDocumentsInGroups(1); + + await createAlert({ + name: 'never fire', + aggType: 'sum', + aggField: 'testedValue', + groupBy: 'all', + thresholdComparator: 'between', + threshold: [-2, -1], + }); + + await createAlert({ + name: 'always fire', + aggType: 'sum', + aggField: 'testedValue', + groupBy: 'all', + thresholdComparator: 'between', + threshold: [0, 1000000], + }); + + const docs = await waitForDocs(2); + for (const doc of docs) { + const { name } = doc._source.params; + + expect(name).to.be('always fire'); + } + }); + + it('runs correctly: avg all', async () => { + // create some more documents in the first group + createEsDocumentsInGroups(1); + + await createAlert({ + name: 'never fire', + aggType: 'avg', + aggField: 'testedValue', + groupBy: 'all', + thresholdComparator: '<', + threshold: [0], + }); + + await createAlert({ + name: 'always fire', + aggType: 'avg', + aggField: 'testedValue', + groupBy: 'all', + thresholdComparator: '>=', + threshold: [0], + }); + + const docs = await waitForDocs(4); + for (const doc of docs) { + const { name } = doc._source.params; + + expect(name).to.be('always fire'); + } + }); + + it('runs correctly: max grouped', async () => { + // create some more documents in the first group + createEsDocumentsInGroups(1); + + await createAlert({ + name: 'never fire', + aggType: 'max', + aggField: 'testedValue', + groupBy: 'top', + termField: 'group', + termSize: 2, + thresholdComparator: '<', + threshold: [0], + }); + + await createAlert({ + name: 'always fire', + aggType: 'max', + aggField: 'testedValue', + groupBy: 'top', + termField: 'group', + termSize: 2, // two actions will fire each interval + thresholdComparator: '>=', + threshold: [0], + }); + + const docs = await waitForDocs(4); + let inGroup2 = 0; + + for (const doc of docs) { + const { group } = doc._source; + const { name } = doc._source.params; + + expect(name).to.be('always fire'); + if (group === 'group-2') inGroup2++; + } + + // there should be 2 docs in group-2, rando split between others + expect(inGroup2).to.be(2); + }); + + it('runs correctly: min grouped', async () => { + // create some more documents in the first group + createEsDocumentsInGroups(1); + + await createAlert({ + name: 'never fire', + aggType: 'min', + aggField: 'testedValue', + groupBy: 'top', + termField: 'group', + termSize: 2, + thresholdComparator: '<', + threshold: [0], + }); + + await createAlert({ + name: 'always fire', + aggType: 'min', + aggField: 'testedValue', + groupBy: 'top', + termField: 'group', + termSize: 2, // two actions will fire each interval + thresholdComparator: '>=', + threshold: [0], + }); + + const docs = await waitForDocs(4); + let inGroup0 = 0; + + for (const doc of docs) { + const { group } = doc._source; + const { name } = doc._source.params; + + expect(name).to.be('always fire'); + if (group === 'group-0') inGroup0++; + } + + // there should be 2 docs in group-0, rando split between others + expect(inGroup0).to.be(2); + }); + + async function createEsDocumentsInGroups(groups: number) { + await createEsDocuments( + es, + esTestIndexTool, + endDate, + ALERT_INTERVALS_TO_WRITE, + ALERT_INTERVAL_MILLIS, + groups + ); + } + + async function waitForDocs(count: number): Promise { + return await esTestIndexToolOutput.waitForDocs( + ES_TEST_INDEX_SOURCE, + ES_TEST_INDEX_REFERENCE, + count + ); + } + + interface CreateAlertParams { + name: string; + aggType: string; + aggField?: string; + groupBy: 'all' | 'top'; + termField?: string; + termSize?: number; + thresholdComparator: string; + threshold: number[]; + } + + async function createAlert(params: CreateAlertParams): Promise { + const action = { + id: actionId, + group: 'threshold met', + params: { + documents: [ + { + source: ES_TEST_INDEX_SOURCE, + reference: ES_TEST_INDEX_REFERENCE, + params: { + name: '{{{alertName}}}', + value: '{{{context.value}}}', + title: '{{{context.title}}}', + message: '{{{context.message}}}', + }, + date: '{{{context.date}}}', + // TODO: I wanted to write the alert value here, but how? + // We only mustache interpolate string values ... + // testedValue: '{{{context.value}}}', + group: '{{{context.group}}}', + }, + ], + }, + }; + + const { statusCode, body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alert`) + .set('kbn-xsrf', 'foo') + .send({ + name: params.name, + consumer: 'function test', + enabled: true, + alertTypeId: ALERT_TYPE_ID, + schedule: { interval: `${ALERT_INTERVAL_SECONDS}s` }, + actions: [action], + params: { + index: ES_TEST_INDEX_NAME, + timeField: 'date', + aggType: params.aggType, + aggField: params.aggField, + groupBy: params.groupBy, + termField: params.termField, + termSize: params.termSize, + timeWindowSize: ALERT_INTERVAL_SECONDS * 5, + timeWindowUnit: 's', + thresholdComparator: params.thresholdComparator, + threshold: params.threshold, + }, + }); + + // will print the error body, if an error occurred + // if (statusCode !== 200) console.log(createdAlert); + + expect(statusCode).to.be(200); + + const alertId = createdAlert.id; + objectRemover.add(Spaces.space1.id, alertId, 'alert'); + + return alertId; + } + }); +} + +async function createAction(supertest: any, objectRemover: ObjectRemover): Promise { + const { statusCode, body: createdAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'index action for index threshold FT', + actionTypeId: ACTION_TYPE_ID, + config: { + index: ES_TEST_OUTPUT_INDEX_NAME, + }, + secrets: {}, + }); + + // will print the error body, if an error occurred + // if (statusCode !== 200) console.log(createdAction); + + expect(statusCode).to.be(200); + + const actionId = createdAction.id; + objectRemover.add(Spaces.space1.id, actionId, 'action'); + + return actionId; +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts index 523c348e26049..21f73ac9b9833 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts @@ -8,53 +8,50 @@ import { times } from 'lodash'; import { v4 as uuid } from 'uuid'; import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '../../../../../common/lib'; -// date to start writing data -export const START_DATE = '2020-01-01T00:00:00Z'; +// default end date +export const END_DATE = '2020-01-01T00:00:00Z'; -const DOCUMENT_SOURCE = 'queryDataEndpointTests'; +export const DOCUMENT_SOURCE = 'queryDataEndpointTests'; +export const DOCUMENT_REFERENCE = '-na-'; // Create a set of es documents to run the queries against. -// Will create 2 documents for each interval. +// Will create `groups` documents for each interval. // The difference between the dates of the docs will be intervalMillis. // The date of the last documents will be startDate - intervalMillis / 2. -// So there will be 2 documents written in the middle of each interval range. -// The data value written to each doc is a power of 2, with 2^0 as the value -// of the last documents, the values increasing for older documents. The -// second document for each time value will be power of 2 + 1 +// So the documents will be written in the middle of each interval range. +// The data value written to each doc is a power of 2 + the group index, with +// 2^0 as the value of the last documents, the values increasing for older +// documents. export async function createEsDocuments( es: any, esTestIndexTool: ESTestIndexTool, - startDate: string = START_DATE, + endDate: string = END_DATE, intervals: number = 1, - intervalMillis: number = 1000 + intervalMillis: number = 1000, + groups: number = 2 ) { - const totalDocuments = intervals * 2; - const startDateMillis = Date.parse(startDate) - intervalMillis / 2; + const endDateMillis = Date.parse(endDate) - intervalMillis / 2; times(intervals, interval => { - const date = startDateMillis - interval * intervalMillis; + const date = endDateMillis - interval * intervalMillis; - // base value for each window is 2^window + // base value for each window is 2^interval const testedValue = 2 ** interval; // don't need await on these, wait at the end of the function - createEsDocument(es, '-na-', date, testedValue, 'groupA'); - createEsDocument(es, '-na-', date, testedValue + 1, 'groupB'); + times(groups, group => { + createEsDocument(es, date, testedValue + group, `group-${group}`); + }); }); - await esTestIndexTool.waitForDocs(DOCUMENT_SOURCE, '-na-', totalDocuments); + const totalDocuments = intervals * groups; + await esTestIndexTool.waitForDocs(DOCUMENT_SOURCE, DOCUMENT_REFERENCE, totalDocuments); } -async function createEsDocument( - es: any, - reference: string, - epochMillis: number, - testedValue: number, - group: string -) { +async function createEsDocument(es: any, epochMillis: number, testedValue: number, group: string) { const document = { source: DOCUMENT_SOURCE, - reference, + reference: DOCUMENT_REFERENCE, date: new Date(epochMillis).toISOString(), testedValue, group, @@ -65,6 +62,7 @@ async function createEsDocument( index: ES_TEST_INDEX_NAME, body: document, }); + // console.log(`writing document to ${ES_TEST_INDEX_NAME}:`, JSON.stringify(document, null, 4)); if (response.result !== 'created') { throw new Error(`document not created: ${JSON.stringify(response)}`); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts index 9158954f23364..507548f94aaf3 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts @@ -12,5 +12,6 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./time_series_query_endpoint')); loadTestFile(require.resolve('./fields_endpoint')); loadTestFile(require.resolve('./indices_endpoint')); + loadTestFile(require.resolve('./alert')); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/time_series_query_endpoint.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/time_series_query_endpoint.ts index 1aa1d3d21f00d..c9b488da5dec5 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/time_series_query_endpoint.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/time_series_query_endpoint.ts @@ -39,12 +39,12 @@ const START_DATE_MINUS_2INTERVALS = getStartDate(-2 * INTERVAL_MILLIS); are offset from the top of the minute by 30 seconds, the queries always run from the top of the hour. - { "date":"2019-12-31T23:59:30.000Z", "testedValue":1, "group":"groupA" } - { "date":"2019-12-31T23:59:30.000Z", "testedValue":2, "group":"groupB" } - { "date":"2019-12-31T23:58:30.000Z", "testedValue":2, "group":"groupA" } - { "date":"2019-12-31T23:58:30.000Z", "testedValue":3, "group":"groupB" } - { "date":"2019-12-31T23:57:30.000Z", "testedValue":4, "group":"groupA" } - { "date":"2019-12-31T23:57:30.000Z", "testedValue":5, "group":"groupB" } + { "date":"2019-12-31T23:59:30.000Z", "testedValue":1, "group":"group-0" } + { "date":"2019-12-31T23:59:30.000Z", "testedValue":2, "group":"group-1" } + { "date":"2019-12-31T23:58:30.000Z", "testedValue":2, "group":"group-0" } + { "date":"2019-12-31T23:58:30.000Z", "testedValue":3, "group":"group-1" } + { "date":"2019-12-31T23:57:30.000Z", "testedValue":4, "group":"group-0" } + { "date":"2019-12-31T23:57:30.000Z", "testedValue":5, "group":"group-1" } */ // eslint-disable-next-line import/no-default-export @@ -162,7 +162,7 @@ export default function timeSeriesQueryEndpointTests({ getService }: FtrProvider const expected = { results: [ { - group: 'groupA', + group: 'group-0', metrics: [ [START_DATE_MINUS_2INTERVALS, 1], [START_DATE_MINUS_1INTERVALS, 2], @@ -170,7 +170,7 @@ export default function timeSeriesQueryEndpointTests({ getService }: FtrProvider ], }, { - group: 'groupB', + group: 'group-1', metrics: [ [START_DATE_MINUS_2INTERVALS, 1], [START_DATE_MINUS_1INTERVALS, 2], @@ -197,7 +197,7 @@ export default function timeSeriesQueryEndpointTests({ getService }: FtrProvider const expected = { results: [ { - group: 'groupB', + group: 'group-1', metrics: [ [START_DATE_MINUS_2INTERVALS, 5 / 1], [START_DATE_MINUS_1INTERVALS, (5 + 3) / 2], @@ -205,7 +205,7 @@ export default function timeSeriesQueryEndpointTests({ getService }: FtrProvider ], }, { - group: 'groupA', + group: 'group-0', metrics: [ [START_DATE_MINUS_2INTERVALS, 4 / 1], [START_DATE_MINUS_1INTERVALS, (4 + 2) / 2], @@ -230,7 +230,7 @@ export default function timeSeriesQueryEndpointTests({ getService }: FtrProvider }); const result = await runQueryExpect(query, 200); expect(result.results.length).to.be(1); - expect(result.results[0].group).to.be('groupB'); + expect(result.results[0].group).to.be('group-1'); }); it('should return correct sorted group for min', async () => { @@ -245,7 +245,7 @@ export default function timeSeriesQueryEndpointTests({ getService }: FtrProvider }); const result = await runQueryExpect(query, 200); expect(result.results.length).to.be(1); - expect(result.results[0].group).to.be('groupA'); + expect(result.results[0].group).to.be('group-0'); }); it('should return an error when passed invalid input', async () => { diff --git a/x-pack/test/api_integration/apis/endpoint/alerts.ts b/x-pack/test/api_integration/apis/endpoint/alerts.ts index 140d8ca813694..568c30aa5484f 100644 --- a/x-pack/test/api_integration/apis/endpoint/alerts.ts +++ b/x-pack/test/api_integration/apis/endpoint/alerts.ts @@ -215,7 +215,7 @@ export default function({ getService }: FtrProviderContext) { expect(body.result_from_index).to.eql(0); }); - it('should return alert details by id', async () => { + it('should return alert details by id, getting last alert', async () => { const documentID = 'zbNm0HABdD75WLjLYgcB'; const prevDocumentID = '2rNm0HABdD75WLjLYgcU'; const { body } = await supertest @@ -227,6 +227,18 @@ export default function({ getService }: FtrProviderContext) { expect(body.next).to.eql(null); // last alert, no more beyond this }); + it('should return alert details by id, getting first alert', async () => { + const documentID = 'p7Nm0HABdD75WLjLYghv'; + const nextDocumentID = 'mbNm0HABdD75WLjLYgho'; + const { body } = await supertest + .get(`/api/endpoint/alerts/${documentID}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + expect(body.id).to.eql(documentID); + expect(body.next).to.eql(`/api/endpoint/alerts/${nextDocumentID}`); + expect(body.prev).to.eql(null); // first alert, no more before this + }); + it('should return 404 when alert is not found', async () => { await supertest .get('/api/endpoint/alerts/does-not-exist') diff --git a/x-pack/test/api_integration/apis/fleet/agents/acks.ts b/x-pack/test/api_integration/apis/fleet/agents/acks.ts index a2eba2c23c39d..db925813b90c4 100644 --- a/x-pack/test/api_integration/apis/fleet/agents/acks.ts +++ b/x-pack/test/api_integration/apis/fleet/agents/acks.ts @@ -18,7 +18,8 @@ export default function(providerContext: FtrProviderContext) { const supertest = getSupertestWithoutAuth(providerContext); let apiKey: { id: string; api_key: string }; - describe('fleet_agents_acks', () => { + // FLAKY: https://github.com/elastic/kibana/issues/60471 + describe.skip('fleet_agents_acks', () => { before(async () => { await esArchiver.loadIfNeeded('fleet/agents'); diff --git a/x-pack/test/api_integration/apis/fleet/agents/actions.ts b/x-pack/test/api_integration/apis/fleet/agents/actions.ts new file mode 100644 index 0000000000000..f27b932cff5cb --- /dev/null +++ b/x-pack/test/api_integration/apis/fleet/agents/actions.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function(providerContext: FtrProviderContext) { + const { getService } = providerContext; + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + + describe('fleet_agents_actions', () => { + before(async () => { + await esArchiver.loadIfNeeded('fleet/agents'); + }); + after(async () => { + await esArchiver.unload('fleet/agents'); + }); + + it('should return a 200 if this a valid actions request', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/actions`) + .set('kbn-xsrf', 'xx') + .send({ + action: { + type: 'CONFIG_CHANGE', + data: 'action_data', + sent_at: '2020-03-18T19:45:02.620Z', + }, + }) + .expect(200); + + expect(apiResponse.success).to.be(true); + expect(apiResponse.item.data).to.be('action_data'); + expect(apiResponse.item.sent_at).to.be('2020-03-18T19:45:02.620Z'); + + const { body: agentResponse } = await supertest + .get(`/api/ingest_manager/fleet/agents/agent1`) + .set('kbn-xsrf', 'xx') + .expect(200); + + const updatedAction = agentResponse.item.actions.find( + (itemAction: Record) => itemAction?.data === 'action_data' + ); + + expect(updatedAction.type).to.be('CONFIG_CHANGE'); + expect(updatedAction.data).to.be('action_data'); + expect(updatedAction.sent_at).to.be('2020-03-18T19:45:02.620Z'); + }); + + it('should return a 400 when request does not have type information', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/actions`) + .set('kbn-xsrf', 'xx') + .send({ + action: { + data: 'action_data', + sent_at: '2020-03-18T19:45:02.620Z', + }, + }) + .expect(400); + expect(apiResponse.message).to.eql( + '[request body.action.type]: expected at least one defined value but got [undefined]' + ); + }); + + it('should return a 404 when agent does not exist', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/agents/agent100/actions`) + .set('kbn-xsrf', 'xx') + .send({ + action: { + type: 'CONFIG_CHANGE', + data: 'action_data', + sent_at: '2020-03-18T19:45:02.620Z', + }, + }) + .expect(404); + expect(apiResponse.message).to.eql('Saved object [agents/agent100] not found'); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/fleet/agents/enroll.ts b/x-pack/test/api_integration/apis/fleet/agents/enroll.ts index 666d97452ad3d..d8e9749744ea4 100644 --- a/x-pack/test/api_integration/apis/fleet/agents/enroll.ts +++ b/x-pack/test/api_integration/apis/fleet/agents/enroll.ts @@ -6,8 +6,9 @@ import expect from '@kbn/expect'; import uuid from 'uuid'; + import { FtrProviderContext } from '../../../ftr_provider_context'; -import { getSupertestWithoutAuth, setupIngest } from './services'; +import { getSupertestWithoutAuth, setupIngest, getEsClientForAPIKey } from './services'; export default function(providerContext: FtrProviderContext) { const { getService } = providerContext; @@ -104,5 +105,61 @@ export default function(providerContext: FtrProviderContext) { expect(apiResponse.success).to.eql(true); expect(apiResponse.item).to.have.keys('id', 'active', 'access_api_key', 'type', 'config_id'); }); + + it('when enrolling an agent it should generate an access api key with limited privileges', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/agents/enroll`) + .set('kbn-xsrf', 'xxx') + .set( + 'Authorization', + `ApiKey ${Buffer.from(`${apiKey.id}:${apiKey.api_key}`).toString('base64')}` + ) + .send({ + type: 'PERMANENT', + metadata: { + local: {}, + user_provided: {}, + }, + }) + .expect(200); + expect(apiResponse.success).to.eql(true); + const { body: privileges } = await getEsClientForAPIKey( + providerContext, + apiResponse.item.access_api_key + ).security.hasPrivileges({ + body: { + cluster: ['all', 'monitor', 'manage_api_key'], + index: [ + { + names: ['log-*', 'metrics-*', 'events-*', '*'], + privileges: ['write', 'create_index'], + }, + ], + }, + }); + expect(privileges.cluster).to.eql({ + all: false, + monitor: false, + manage_api_key: false, + }); + expect(privileges.index).to.eql({ + '*': { + create_index: false, + write: false, + }, + 'events-*': { + create_index: false, + write: false, + }, + 'log-*': { + create_index: false, + write: false, + }, + 'metrics-*': { + create_index: false, + write: false, + }, + }); + }); }); } diff --git a/x-pack/test/api_integration/apis/fleet/agents/services.ts b/x-pack/test/api_integration/apis/fleet/agents/services.ts index 5c111b8ea9a84..9946135568e36 100644 --- a/x-pack/test/api_integration/apis/fleet/agents/services.ts +++ b/x-pack/test/api_integration/apis/fleet/agents/services.ts @@ -5,7 +5,8 @@ */ import supertestAsPromised from 'supertest-as-promised'; -import url from 'url'; +import { Client } from '@elastic/elasticsearch'; +import { format as formatUrl } from 'url'; import { FtrProviderContext } from '../../../ftr_provider_context'; @@ -15,7 +16,19 @@ export function getSupertestWithoutAuth({ getService }: FtrProviderContext) { kibanaUrl.auth = null; kibanaUrl.password = null; - return supertestAsPromised(url.format(kibanaUrl)); + return supertestAsPromised(formatUrl(kibanaUrl)); +} + +export function getEsClientForAPIKey({ getService }: FtrProviderContext, esApiKey: string) { + const config = getService('config'); + const url = formatUrl({ ...config.get('servers.elasticsearch'), auth: false }); + return new Client({ + nodes: [url], + auth: { + apiKey: esApiKey, + }, + requestTimeout: config.get('timeouts.esRequestTimeout'), + }); } export function setupIngest({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/fleet/enrollment_api_keys/crud.ts b/x-pack/test/api_integration/apis/fleet/enrollment_api_keys/crud.ts index 800e0147528e5..89e05573da1c6 100644 --- a/x-pack/test/api_integration/apis/fleet/enrollment_api_keys/crud.ts +++ b/x-pack/test/api_integration/apis/fleet/enrollment_api_keys/crud.ts @@ -7,11 +7,12 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { setupIngest } from '../agents/services'; +import { setupIngest, getEsClientForAPIKey } from '../agents/services'; const ENROLLMENT_KEY_ID = 'ed22ca17-e178-4cfe-8b02-54ea29fbd6d0'; -export default function({ getService }: FtrProviderContext) { +export default function(providerContext: FtrProviderContext) { + const { getService } = providerContext; const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); @@ -78,6 +79,54 @@ export default function({ getService }: FtrProviderContext) { expect(apiResponse.success).to.eql(true); expect(apiResponse.item).to.have.keys('id', 'api_key', 'api_key_id', 'name', 'config_id'); }); + + it('should create an ES ApiKey with limited privileges', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/enrollment-api-keys`) + .set('kbn-xsrf', 'xxx') + .send({ + config_id: 'policy1', + }) + .expect(200); + expect(apiResponse.success).to.eql(true); + const { body: privileges } = await getEsClientForAPIKey( + providerContext, + apiResponse.item.api_key + ).security.hasPrivileges({ + body: { + cluster: ['all', 'monitor', 'manage_api_key'], + index: [ + { + names: ['log-*', 'metrics-*', 'events-*', '*'], + privileges: ['write', 'create_index'], + }, + ], + }, + }); + expect(privileges.cluster).to.eql({ + all: false, + monitor: false, + manage_api_key: false, + }); + expect(privileges.index).to.eql({ + '*': { + create_index: false, + write: false, + }, + 'events-*': { + create_index: false, + write: false, + }, + 'log-*': { + create_index: false, + write: false, + }, + 'metrics-*': { + create_index: false, + write: false, + }, + }); + }); }); }); } diff --git a/x-pack/test/api_integration/apis/fleet/index.js b/x-pack/test/api_integration/apis/fleet/index.js index 69d30291f030b..547bbb8c7c6ee 100644 --- a/x-pack/test/api_integration/apis/fleet/index.js +++ b/x-pack/test/api_integration/apis/fleet/index.js @@ -15,5 +15,6 @@ export default function loadTests({ loadTestFile }) { loadTestFile(require.resolve('./agents/acks')); loadTestFile(require.resolve('./enrollment_api_keys/crud')); loadTestFile(require.resolve('./install')); + loadTestFile(require.resolve('./agents/actions')); }); } diff --git a/x-pack/test/api_integration/apis/infra/index.js b/x-pack/test/api_integration/apis/infra/index.js index fad387130e044..8bb3475da6cc9 100644 --- a/x-pack/test/api_integration/apis/infra/index.js +++ b/x-pack/test/api_integration/apis/infra/index.js @@ -10,12 +10,13 @@ export default function({ loadTestFile }) { loadTestFile(require.resolve('./log_analysis')); loadTestFile(require.resolve('./log_entries')); loadTestFile(require.resolve('./log_entry_highlights')); - loadTestFile(require.resolve('./log_summary')); loadTestFile(require.resolve('./logs_without_millis')); + loadTestFile(require.resolve('./log_summary')); loadTestFile(require.resolve('./metrics')); loadTestFile(require.resolve('./sources')); loadTestFile(require.resolve('./waffle')); loadTestFile(require.resolve('./log_item')); + loadTestFile(require.resolve('./metrics_alerting')); loadTestFile(require.resolve('./metrics_explorer')); loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./ip_to_hostname')); diff --git a/x-pack/test/api_integration/apis/infra/log_entries.ts b/x-pack/test/api_integration/apis/infra/log_entries.ts index 75e7750058a87..4f447d518a751 100644 --- a/x-pack/test/api_integration/apis/infra/log_entries.ts +++ b/x-pack/test/api_integration/apis/infra/log_entries.ts @@ -5,8 +5,6 @@ */ import expect from '@kbn/expect'; -import { ascending, pairs } from 'd3-array'; -import gql from 'graphql-tag'; import { v4 as uuidv4 } from 'uuid'; import { pipe } from 'fp-ts/lib/pipeable'; @@ -19,10 +17,11 @@ import { LOG_ENTRIES_PATH, logEntriesRequestRT, logEntriesResponseRT, + LogTimestampColumn, + LogFieldColumn, + LogMessageColumn, } from '../../../../plugins/infra/common/http_api'; -import { sharedFragments } from '../../../../plugins/infra/common/graphql/shared'; -import { InfraTimeKey } from '../../../../plugins/infra/public/graphql/types'; import { FtrProviderContext } from '../../ftr_provider_context'; const KEY_WITHIN_DATA_RANGE = { @@ -38,75 +37,12 @@ const LATEST_KEY_WITH_DATA = { tiebreaker: 5603910, }; -const logEntriesAroundQuery = gql` - query LogEntriesAroundQuery( - $timeKey: InfraTimeKeyInput! - $countBefore: Int = 0 - $countAfter: Int = 0 - $filterQuery: String - ) { - source(id: "default") { - id - logEntriesAround( - key: $timeKey - countBefore: $countBefore - countAfter: $countAfter - filterQuery: $filterQuery - ) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - hasMoreBefore - hasMoreAfter - entries { - ...InfraLogEntryFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryFields} -`; - -const logEntriesBetweenQuery = gql` - query LogEntriesBetweenQuery( - $startKey: InfraTimeKeyInput! - $endKey: InfraTimeKeyInput! - $filterQuery: String - ) { - source(id: "default") { - id - logEntriesBetween(startKey: $startKey, endKey: $endKey, filterQuery: $filterQuery) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - hasMoreBefore - hasMoreAfter - entries { - ...InfraLogEntryFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryFields} -`; - const COMMON_HEADERS = { 'kbn-xsrf': 'some-xsrf-token', }; export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); - const client = getService('infraOpsGraphQLClient'); const supertest = getService('supertest'); const sourceConfigurationService = getService('infraOpsSourceConfiguration'); @@ -126,8 +62,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: KEY_WITHIN_DATA_RANGE.time, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: KEY_WITHIN_DATA_RANGE.time, }) ) .expect(200); @@ -154,6 +90,42 @@ export default function({ getService }: FtrProviderContext) { expect(lastEntry.cursor.time <= KEY_WITHIN_DATA_RANGE.time).to.be(true); }); + it('Returns the default columns', async () => { + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, + center: KEY_WITHIN_DATA_RANGE, + }) + ) + .expect(200); + + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); + + const entries = logEntriesResponse.data.entries; + const entry = entries[0]; + expect(entry.columns).to.have.length(3); + + const timestampColumn = entry.columns[0] as LogTimestampColumn; + expect(timestampColumn).to.have.property('timestamp'); + + const eventDatasetColumn = entry.columns[1] as LogFieldColumn; + expect(eventDatasetColumn).to.have.property('field'); + expect(eventDatasetColumn.field).to.be('event.dataset'); + expect(eventDatasetColumn).to.have.property('value'); + + const messageColumn = entry.columns[2] as LogMessageColumn; + expect(messageColumn).to.have.property('message'); + expect(messageColumn.message.length).to.be.greaterThan(0); + }); + it('Paginates correctly with `after`', async () => { const { body: firstPageBody } = await supertest .post(LOG_ENTRIES_PATH) @@ -161,8 +133,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: KEY_WITHIN_DATA_RANGE.time, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: KEY_WITHIN_DATA_RANGE.time, size: 10, }) ); @@ -177,9 +149,9 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: KEY_WITHIN_DATA_RANGE.time, - after: firstPage.data.bottomCursor, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: KEY_WITHIN_DATA_RANGE.time, + after: firstPage.data.bottomCursor!, size: 10, }) ); @@ -194,8 +166,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: KEY_WITHIN_DATA_RANGE.time, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: KEY_WITHIN_DATA_RANGE.time, size: 20, }) ); @@ -220,8 +192,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: KEY_WITHIN_DATA_RANGE.time, - endDate: LATEST_KEY_WITH_DATA.time, + startTimestamp: KEY_WITHIN_DATA_RANGE.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, before: 'last', size: 10, }) @@ -237,9 +209,9 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: KEY_WITHIN_DATA_RANGE.time, - endDate: LATEST_KEY_WITH_DATA.time, - before: lastPage.data.topCursor, + startTimestamp: KEY_WITHIN_DATA_RANGE.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, + before: lastPage.data.topCursor!, size: 10, }) ); @@ -254,8 +226,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: KEY_WITHIN_DATA_RANGE.time, - endDate: LATEST_KEY_WITH_DATA.time, + startTimestamp: KEY_WITHIN_DATA_RANGE.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, before: 'last', size: 20, }) @@ -281,8 +253,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesRequestRT.encode({ sourceId: 'default', - startDate: EARLIEST_KEY_WITH_DATA.time, - endDate: LATEST_KEY_WITH_DATA.time, + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, center: KEY_WITHIN_DATA_RANGE, }) ) @@ -300,101 +272,31 @@ export default function({ getService }: FtrProviderContext) { expect(firstEntry.cursor.time >= EARLIEST_KEY_WITH_DATA.time).to.be(true); expect(lastEntry.cursor.time <= LATEST_KEY_WITH_DATA.time).to.be(true); }); - }); - }); - - describe('logEntriesAround', () => { - describe('with the default source', () => { - before(() => esArchiver.load('empty_kibana')); - after(() => esArchiver.unload('empty_kibana')); - - it('should return newer and older log entries when present', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countBefore: 100, - countAfter: 100, - }, - }); - expect(logEntriesAround).to.have.property('entries'); - expect(logEntriesAround.entries).to.have.length(200); - expect(isSorted(ascendingTimeKey)(logEntriesAround.entries)).to.equal(true); + it('Handles empty responses', async () => { + const startTimestamp = Date.now() + 1000; + const endTimestamp = Date.now() + 5000; - expect(logEntriesAround.hasMoreBefore).to.equal(true); - expect(logEntriesAround.hasMoreAfter).to.equal(true); - }); - - it('should indicate if no older entries are present', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: EARLIEST_KEY_WITH_DATA, - countBefore: 100, - countAfter: 100, - }, - }); - - expect(logEntriesAround.hasMoreBefore).to.equal(false); - expect(logEntriesAround.hasMoreAfter).to.equal(true); - }); - - it('should indicate if no newer entries are present', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: LATEST_KEY_WITH_DATA, - countBefore: 100, - countAfter: 100, - }, - }); - - expect(logEntriesAround.hasMoreBefore).to.equal(true); - expect(logEntriesAround.hasMoreAfter).to.equal(false); - }); + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp, + endTimestamp, + }) + ) + .expect(200); - it('should return the default columns', async () => { - const { - data: { - source: { - logEntriesAround: { - entries: [entry], - }, - }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countAfter: 1, - }, - }); + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); - expect(entry.columns).to.have.length(3); - expect(entry.columns[0]).to.have.property('timestamp'); - expect(entry.columns[0].timestamp).to.be.a('number'); - expect(entry.columns[1]).to.have.property('field'); - expect(entry.columns[1].field).to.be('event.dataset'); - expect(entry.columns[1]).to.have.property('value'); - expect(JSON.parse) - .withArgs(entry.columns[1].value) - .to.not.throwException(); - expect(entry.columns[2]).to.have.property('message'); - expect(entry.columns[2].message).to.be.an('array'); - expect(entry.columns[2].message.length).to.be.greaterThan(0); + expect(logEntriesResponse.data.entries).to.have.length(0); + expect(logEntriesResponse.data.topCursor).to.be(null); + expect(logEntriesResponse.data.bottomCursor).to.be(null); }); }); @@ -431,120 +333,48 @@ export default function({ getService }: FtrProviderContext) { }); after(() => esArchiver.unload('empty_kibana')); - it('should return the configured columns', async () => { - const { - data: { - source: { - logEntriesAround: { - entries: [entry], - }, - }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countAfter: 1, - }, - }); + it('returns the configured columns', async () => { + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp: EARLIEST_KEY_WITH_DATA.time, + endTimestamp: LATEST_KEY_WITH_DATA.time, + center: KEY_WITHIN_DATA_RANGE, + }) + ) + .expect(200); - expect(entry.columns).to.have.length(4); - expect(entry.columns[0]).to.have.property('timestamp'); - expect(entry.columns[0].timestamp).to.be.a('number'); - expect(entry.columns[1]).to.have.property('field'); - expect(entry.columns[1].field).to.be('host.name'); - expect(entry.columns[1]).to.have.property('value'); - expect(JSON.parse) - .withArgs(entry.columns[1].value) - .to.not.throwException(); - expect(entry.columns[2]).to.have.property('field'); - expect(entry.columns[2].field).to.be('event.dataset'); - expect(entry.columns[2]).to.have.property('value'); - expect(JSON.parse) - .withArgs(entry.columns[2].value) - .to.not.throwException(); - expect(entry.columns[3]).to.have.property('message'); - expect(entry.columns[3].message).to.be.an('array'); - expect(entry.columns[3].message.length).to.be.greaterThan(0); - }); - }); - }); + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); - describe('logEntriesBetween', () => { - describe('with the default source', () => { - before(() => esArchiver.load('empty_kibana')); - after(() => esArchiver.unload('empty_kibana')); + const entries = logEntriesResponse.data.entries; + const entry = entries[0]; - it('should return log entries between the start and end keys', async () => { - const { - data: { - source: { logEntriesBetween }, - }, - } = await client.query({ - query: logEntriesBetweenQuery, - variables: { - startKey: EARLIEST_KEY_WITH_DATA, - endKey: KEY_WITHIN_DATA_RANGE, - }, - }); + expect(entry.columns).to.have.length(4); - expect(logEntriesBetween).to.have.property('entries'); - expect(logEntriesBetween.entries).to.not.be.empty(); - expect(isSorted(ascendingTimeKey)(logEntriesBetween.entries)).to.equal(true); - - expect( - ascendingTimeKey(logEntriesBetween.entries[0], { key: EARLIEST_KEY_WITH_DATA }) - ).to.be.above(-1); - expect( - ascendingTimeKey(logEntriesBetween.entries[logEntriesBetween.entries.length - 1], { - key: KEY_WITHIN_DATA_RANGE, - }) - ).to.be.below(1); - }); + const timestampColumn = entry.columns[0] as LogTimestampColumn; + expect(timestampColumn).to.have.property('timestamp'); - it('should return results consistent with logEntriesAround', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countBefore: 100, - countAfter: 100, - }, - }); + const hostNameColumn = entry.columns[1] as LogFieldColumn; + expect(hostNameColumn).to.have.property('field'); + expect(hostNameColumn.field).to.be('host.name'); + expect(hostNameColumn).to.have.property('value'); - const { - data: { - source: { logEntriesBetween }, - }, - } = await client.query({ - query: logEntriesBetweenQuery, - variables: { - startKey: { - time: logEntriesAround.start.time, - tiebreaker: logEntriesAround.start.tiebreaker - 1, - }, - endKey: { - time: logEntriesAround.end.time, - tiebreaker: logEntriesAround.end.tiebreaker + 1, - }, - }, - }); + const eventDatasetColumn = entry.columns[2] as LogFieldColumn; + expect(eventDatasetColumn).to.have.property('field'); + expect(eventDatasetColumn.field).to.be('event.dataset'); + expect(eventDatasetColumn).to.have.property('value'); - expect(logEntriesBetween).to.eql(logEntriesAround); + const messageColumn = entry.columns[3] as LogMessageColumn; + expect(messageColumn).to.have.property('message'); + expect(messageColumn.message.length).to.be.greaterThan(0); }); }); }); }); } - -const isSorted = (comparator: (first: Value, second: Value) => number) => ( - values: Value[] -) => pairs(values, comparator).every(order => order <= 0); - -const ascendingTimeKey = (first: { key: InfraTimeKey }, second: { key: InfraTimeKey }) => - ascending(first.key.time, second.key.time) || - ascending(first.key.tiebreaker, second.key.tiebreaker); diff --git a/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts b/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts index a34cd89eb3262..94f9d31ae8923 100644 --- a/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts +++ b/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts @@ -5,8 +5,6 @@ */ import expect from '@kbn/expect'; -import { ascending, pairs } from 'd3-array'; -import gql from 'graphql-tag'; import { pipe } from 'fp-ts/lib/pipeable'; import { identity } from 'fp-ts/lib/function'; @@ -21,21 +19,11 @@ import { } from '../../../../plugins/infra/common/http_api'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { sharedFragments } from '../../../../plugins/infra/common/graphql/shared'; -import { InfraTimeKey } from '../../../../plugins/infra/public/graphql/types'; const KEY_BEFORE_START = { time: new Date('2000-01-01T00:00:00.000Z').valueOf(), tiebreaker: -1, }; -const KEY_AFTER_START = { - time: new Date('2000-01-01T00:00:04.000Z').valueOf(), - tiebreaker: -1, -}; -const KEY_BEFORE_END = { - time: new Date('2000-01-01T00:00:06.001Z').valueOf(), - tiebreaker: 0, -}; const KEY_AFTER_END = { time: new Date('2000-01-01T00:00:09.001Z').valueOf(), tiebreaker: 0, @@ -48,7 +36,6 @@ const COMMON_HEADERS = { export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); - const client = getService('infraOpsGraphQLClient'); describe('log highlight apis', () => { before(() => esArchiver.load('infra/simple_logs')); @@ -66,8 +53,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesHighlightsRequestRT.encode({ sourceId: 'default', - startDate: KEY_BEFORE_START.time, - endDate: KEY_AFTER_END.time, + startTimestamp: KEY_BEFORE_START.time, + endTimestamp: KEY_AFTER_END.time, highlightTerms: ['message of document 0'], }) ) @@ -116,8 +103,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesHighlightsRequestRT.encode({ sourceId: 'default', - startDate: KEY_BEFORE_START.time, - endDate: KEY_AFTER_END.time, + startTimestamp: KEY_BEFORE_START.time, + endTimestamp: KEY_AFTER_END.time, highlightTerms: ['generate_test_data/simple_logs'], }) ) @@ -152,8 +139,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesHighlightsRequestRT.encode({ sourceId: 'default', - startDate: KEY_BEFORE_START.time, - endDate: KEY_AFTER_END.time, + startTimestamp: KEY_BEFORE_START.time, + endTimestamp: KEY_AFTER_END.time, query: JSON.stringify({ multi_match: { query: 'host-a', type: 'phrase', lenient: true }, }), @@ -185,236 +172,5 @@ export default function({ getService }: FtrProviderContext) { }); }); }); - - describe('logEntryHighlights', () => { - describe('with the default source', () => { - before(() => esArchiver.load('empty_kibana')); - after(() => esArchiver.unload('empty_kibana')); - - it('should return log highlights in the built-in message column', async () => { - const { - data: { - source: { logEntryHighlights }, - }, - } = await client.query({ - query: logEntryHighlightsQuery, - variables: { - sourceId: 'default', - startKey: KEY_BEFORE_START, - endKey: KEY_AFTER_END, - highlights: [ - { - query: 'message of document 0', - countBefore: 0, - countAfter: 0, - }, - ], - }, - }); - - expect(logEntryHighlights).to.have.length(1); - - const [logEntryHighlightSet] = logEntryHighlights; - expect(logEntryHighlightSet).to.have.property('entries'); - // ten bundles with one highlight each - expect(logEntryHighlightSet.entries).to.have.length(10); - expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); - - for (const logEntryHighlight of logEntryHighlightSet.entries) { - expect(logEntryHighlight.columns).to.have.length(3); - expect(logEntryHighlight.columns[1]).to.have.property('field'); - expect(logEntryHighlight.columns[1]).to.have.property('highlights'); - expect(logEntryHighlight.columns[1].highlights).to.eql([]); - expect(logEntryHighlight.columns[2]).to.have.property('message'); - expect(logEntryHighlight.columns[2].message).to.be.an('array'); - expect(logEntryHighlight.columns[2].message.length).to.be(1); - expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([ - 'message', - 'of', - 'document', - '0', - ]); - } - }); - - // https://github.com/elastic/kibana/issues/49959 - it.skip('should return log highlights in a field column', async () => { - const { - data: { - source: { logEntryHighlights }, - }, - } = await client.query({ - query: logEntryHighlightsQuery, - variables: { - sourceId: 'default', - startKey: KEY_BEFORE_START, - endKey: KEY_AFTER_END, - highlights: [ - { - query: 'generate_test_data/simple_logs', - countBefore: 0, - countAfter: 0, - }, - ], - }, - }); - - expect(logEntryHighlights).to.have.length(1); - - const [logEntryHighlightSet] = logEntryHighlights; - expect(logEntryHighlightSet).to.have.property('entries'); - // ten bundles with five highlights each - expect(logEntryHighlightSet.entries).to.have.length(50); - expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); - - for (const logEntryHighlight of logEntryHighlightSet.entries) { - expect(logEntryHighlight.columns).to.have.length(3); - expect(logEntryHighlight.columns[1]).to.have.property('field'); - expect(logEntryHighlight.columns[1]).to.have.property('highlights'); - expect(logEntryHighlight.columns[1].highlights).to.eql([ - 'generate_test_data/simple_logs', - ]); - expect(logEntryHighlight.columns[2]).to.have.property('message'); - expect(logEntryHighlight.columns[2].message).to.be.an('array'); - expect(logEntryHighlight.columns[2].message.length).to.be(1); - expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([]); - } - }); - - it('should apply the filter query in addition to the highlight query', async () => { - const { - data: { - source: { logEntryHighlights }, - }, - } = await client.query({ - query: logEntryHighlightsQuery, - variables: { - sourceId: 'default', - startKey: KEY_BEFORE_START, - endKey: KEY_AFTER_END, - filterQuery: JSON.stringify({ - multi_match: { query: 'host-a', type: 'phrase', lenient: true }, - }), - highlights: [ - { - query: 'message', - countBefore: 0, - countAfter: 0, - }, - ], - }, - }); - - expect(logEntryHighlights).to.have.length(1); - - const [logEntryHighlightSet] = logEntryHighlights; - expect(logEntryHighlightSet).to.have.property('entries'); - // half of the documenst - expect(logEntryHighlightSet.entries).to.have.length(25); - expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); - - for (const logEntryHighlight of logEntryHighlightSet.entries) { - expect(logEntryHighlight.columns).to.have.length(3); - expect(logEntryHighlight.columns[1]).to.have.property('field'); - expect(logEntryHighlight.columns[1]).to.have.property('highlights'); - expect(logEntryHighlight.columns[1].highlights).to.eql([]); - expect(logEntryHighlight.columns[2]).to.have.property('message'); - expect(logEntryHighlight.columns[2].message).to.be.an('array'); - expect(logEntryHighlight.columns[2].message.length).to.be(1); - expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([ - 'message', - 'message', - ]); - } - }); - - it('should return highlights outside of the interval when requested', async () => { - const { - data: { - source: { logEntryHighlights }, - }, - } = await client.query({ - query: logEntryHighlightsQuery, - variables: { - sourceId: 'default', - startKey: KEY_AFTER_START, - endKey: KEY_BEFORE_END, - highlights: [ - { - query: 'message of document 0', - countBefore: 2, - countAfter: 2, - }, - ], - }, - }); - - expect(logEntryHighlights).to.have.length(1); - - const [logEntryHighlightSet] = logEntryHighlights; - expect(logEntryHighlightSet).to.have.property('entries'); - // three bundles with one highlight each plus two beyond each interval boundary - expect(logEntryHighlightSet.entries).to.have.length(3 + 4); - expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); - - for (const logEntryHighlight of logEntryHighlightSet.entries) { - expect(logEntryHighlight.columns).to.have.length(3); - expect(logEntryHighlight.columns[1]).to.have.property('field'); - expect(logEntryHighlight.columns[1]).to.have.property('highlights'); - expect(logEntryHighlight.columns[1].highlights).to.eql([]); - expect(logEntryHighlight.columns[2]).to.have.property('message'); - expect(logEntryHighlight.columns[2].message).to.be.an('array'); - expect(logEntryHighlight.columns[2].message.length).to.be(1); - expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([ - 'message', - 'of', - 'document', - '0', - ]); - } - }); - }); - }); }); } - -const logEntryHighlightsQuery = gql` - query LogEntryHighlightsQuery( - $sourceId: ID = "default" - $startKey: InfraTimeKeyInput! - $endKey: InfraTimeKeyInput! - $filterQuery: String - $highlights: [InfraLogEntryHighlightInput!]! - ) { - source(id: $sourceId) { - id - logEntryHighlights( - startKey: $startKey - endKey: $endKey - filterQuery: $filterQuery - highlights: $highlights - ) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - entries { - ...InfraLogEntryHighlightFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryHighlightFields} -`; - -const isSorted = (comparator: (first: Value, second: Value) => number) => ( - values: Value[] -) => pairs(values, comparator).every(order => order <= 0); - -const ascendingTimeKey = (first: { key: InfraTimeKey }, second: { key: InfraTimeKey }) => - ascending(first.key.time, second.key.time) || - ascending(first.key.tiebreaker, second.key.tiebreaker); diff --git a/x-pack/test/api_integration/apis/infra/log_summary.ts b/x-pack/test/api_integration/apis/infra/log_summary.ts index 15e503f7b4a5a..1f1b65fca6e5f 100644 --- a/x-pack/test/api_integration/apis/infra/log_summary.ts +++ b/x-pack/test/api_integration/apis/infra/log_summary.ts @@ -38,9 +38,10 @@ export default function({ getService }: FtrProviderContext) { after(() => esArchiver.unload('infra/metrics_and_logs')); it('should return empty and non-empty consecutive buckets', async () => { - const startDate = EARLIEST_TIME_WITH_DATA; - const endDate = LATEST_TIME_WITH_DATA + (LATEST_TIME_WITH_DATA - EARLIEST_TIME_WITH_DATA); - const bucketSize = Math.ceil((endDate - startDate) / 10); + const startTimestamp = EARLIEST_TIME_WITH_DATA; + const endTimestamp = + LATEST_TIME_WITH_DATA + (LATEST_TIME_WITH_DATA - EARLIEST_TIME_WITH_DATA); + const bucketSize = Math.ceil((endTimestamp - startTimestamp) / 10); const { body } = await supertest .post(LOG_ENTRIES_SUMMARY_PATH) @@ -48,8 +49,8 @@ export default function({ getService }: FtrProviderContext) { .send( logEntriesSummaryRequestRT.encode({ sourceId: 'default', - startDate, - endDate, + startTimestamp, + endTimestamp, bucketSize, query: null, }) diff --git a/x-pack/test/api_integration/apis/infra/logs_without_millis.ts b/x-pack/test/api_integration/apis/infra/logs_without_millis.ts index 9295380cfbec1..642f4fb42d324 100644 --- a/x-pack/test/api_integration/apis/infra/logs_without_millis.ts +++ b/x-pack/test/api_integration/apis/infra/logs_without_millis.ts @@ -5,8 +5,6 @@ */ import expect from '@kbn/expect'; -import { ascending, pairs } from 'd3-array'; -import gql from 'graphql-tag'; import { pipe } from 'fp-ts/lib/pipeable'; import { identity } from 'fp-ts/lib/function'; @@ -15,21 +13,18 @@ import { fold } from 'fp-ts/lib/Either'; import { createPlainError, throwErrors } from '../../../../plugins/infra/common/runtime_types'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { sharedFragments } from '../../../../plugins/infra/common/graphql/shared'; -import { InfraTimeKey } from '../../../../plugins/infra/public/graphql/types'; import { LOG_ENTRIES_SUMMARY_PATH, logEntriesSummaryRequestRT, logEntriesSummaryResponseRT, + LOG_ENTRIES_PATH, + logEntriesRequestRT, + logEntriesResponseRT, } from '../../../../plugins/infra/common/http_api/log_entries'; const COMMON_HEADERS = { 'kbn-xsrf': 'some-xsrf-token', }; -const KEY_WITHIN_DATA_RANGE = { - time: new Date('2019-01-06T00:00:00.000Z').valueOf(), - tiebreaker: 0, -}; const EARLIEST_KEY_WITH_DATA = { time: new Date('2019-01-05T23:59:23.000Z').valueOf(), tiebreaker: -1, @@ -38,153 +33,97 @@ const LATEST_KEY_WITH_DATA = { time: new Date('2019-01-06T23:59:23.000Z').valueOf(), tiebreaker: 2, }; +const KEY_WITHIN_DATA_RANGE = { + time: new Date('2019-01-06T00:00:00.000Z').valueOf(), + tiebreaker: 0, +}; export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); - const client = getService('infraOpsGraphQLClient'); const supertest = getService('supertest'); describe('logs without epoch_millis format', () => { before(() => esArchiver.load('infra/logs_without_epoch_millis')); after(() => esArchiver.unload('infra/logs_without_epoch_millis')); - it('logEntriesAround should return log entries', async () => { - const { - data: { - source: { logEntriesAround }, - }, - } = await client.query({ - query: logEntriesAroundQuery, - variables: { - timeKey: KEY_WITHIN_DATA_RANGE, - countBefore: 1, - countAfter: 1, - }, + describe('/log_entries/summary', () => { + it('returns non-empty buckets', async () => { + const startTimestamp = EARLIEST_KEY_WITH_DATA.time; + const endTimestamp = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive + const bucketSize = Math.ceil((endTimestamp - startTimestamp) / 10); + + const { body } = await supertest + .post(LOG_ENTRIES_SUMMARY_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesSummaryRequestRT.encode({ + sourceId: 'default', + startTimestamp, + endTimestamp, + bucketSize, + query: null, + }) + ) + .expect(200); + + const logSummaryResponse = pipe( + logEntriesSummaryResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); + + expect( + logSummaryResponse.data.buckets.filter((bucket: any) => bucket.entriesCount > 0) + ).to.have.length(2); }); - - expect(logEntriesAround).to.have.property('entries'); - expect(logEntriesAround.entries).to.have.length(2); - expect(isSorted(ascendingTimeKey)(logEntriesAround.entries)).to.equal(true); - - expect(logEntriesAround.hasMoreBefore).to.equal(false); - expect(logEntriesAround.hasMoreAfter).to.equal(false); }); - it('logEntriesBetween should return log entries', async () => { - const { - data: { - source: { logEntriesBetween }, - }, - } = await client.query({ - query: logEntriesBetweenQuery, - variables: { - startKey: EARLIEST_KEY_WITH_DATA, - endKey: LATEST_KEY_WITH_DATA, - }, + describe('/log_entries/entries', () => { + it('returns log entries', async () => { + const startTimestamp = EARLIEST_KEY_WITH_DATA.time; + const endTimestamp = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive + + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp, + endTimestamp, + }) + ) + .expect(200); + + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); + expect(logEntriesResponse.data.entries).to.have.length(2); }); - expect(logEntriesBetween).to.have.property('entries'); - expect(logEntriesBetween.entries).to.have.length(2); - expect(isSorted(ascendingTimeKey)(logEntriesBetween.entries)).to.equal(true); - }); - - it('logSummaryBetween should return non-empty buckets', async () => { - const startDate = EARLIEST_KEY_WITH_DATA.time; - const endDate = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive - const bucketSize = Math.ceil((endDate - startDate) / 10); - - const { body } = await supertest - .post(LOG_ENTRIES_SUMMARY_PATH) - .set(COMMON_HEADERS) - .send( - logEntriesSummaryRequestRT.encode({ - sourceId: 'default', - startDate, - endDate, - bucketSize, - query: null, - }) - ) - .expect(200); - - const logSummaryResponse = pipe( - logEntriesSummaryResponseRT.decode(body), - fold(throwErrors(createPlainError), identity) - ); - - expect( - logSummaryResponse.data.buckets.filter((bucket: any) => bucket.entriesCount > 0) - ).to.have.length(2); + it('returns log entries when centering around a point', async () => { + const startTimestamp = EARLIEST_KEY_WITH_DATA.time; + const endTimestamp = LATEST_KEY_WITH_DATA.time + 1; // the interval end is exclusive + + const { body } = await supertest + .post(LOG_ENTRIES_PATH) + .set(COMMON_HEADERS) + .send( + logEntriesRequestRT.encode({ + sourceId: 'default', + startTimestamp, + endTimestamp, + center: KEY_WITHIN_DATA_RANGE, + }) + ) + .expect(200); + + const logEntriesResponse = pipe( + logEntriesResponseRT.decode(body), + fold(throwErrors(createPlainError), identity) + ); + expect(logEntriesResponse.data.entries).to.have.length(2); + }); }); }); } - -const logEntriesAroundQuery = gql` - query LogEntriesAroundQuery( - $timeKey: InfraTimeKeyInput! - $countBefore: Int = 0 - $countAfter: Int = 0 - $filterQuery: String - ) { - source(id: "default") { - id - logEntriesAround( - key: $timeKey - countBefore: $countBefore - countAfter: $countAfter - filterQuery: $filterQuery - ) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - hasMoreBefore - hasMoreAfter - entries { - ...InfraLogEntryFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryFields} -`; - -const logEntriesBetweenQuery = gql` - query LogEntriesBetweenQuery( - $startKey: InfraTimeKeyInput! - $endKey: InfraTimeKeyInput! - $filterQuery: String - ) { - source(id: "default") { - id - logEntriesBetween(startKey: $startKey, endKey: $endKey, filterQuery: $filterQuery) { - start { - ...InfraTimeKeyFields - } - end { - ...InfraTimeKeyFields - } - hasMoreBefore - hasMoreAfter - entries { - ...InfraLogEntryFields - } - } - } - } - - ${sharedFragments.InfraTimeKey} - ${sharedFragments.InfraLogEntryFields} -`; - -const isSorted = (comparator: (first: Value, second: Value) => number) => ( - values: Value[] -) => pairs(values, comparator).every(order => order <= 0); - -const ascendingTimeKey = (first: { key: InfraTimeKey }, second: { key: InfraTimeKey }) => - ascending(first.key.time, second.key.time) || - ascending(first.key.tiebreaker, second.key.tiebreaker); diff --git a/x-pack/test/api_integration/apis/infra/metrics_alerting.ts b/x-pack/test/api_integration/apis/infra/metrics_alerting.ts new file mode 100644 index 0000000000000..09f5a498ddc00 --- /dev/null +++ b/x-pack/test/api_integration/apis/infra/metrics_alerting.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { getElasticsearchMetricQuery } from '../../../../plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor'; +import { MetricExpressionParams } from '../../../../plugins/infra/server/lib/alerting/metric_threshold/types'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const client = getService('legacyEs'); + const index = 'test-index'; + const baseParams = { + metric: 'test.metric', + timeUnit: 'm', + timeSize: 5, + }; + describe('Metrics Threshold Alerts', () => { + before(async () => { + await client.index({ + index, + body: {}, + }); + }); + const aggs = ['avg', 'min', 'max', 'rate', 'cardinality', 'count']; + + describe('querying the entire infrastructure', () => { + for (const aggType of aggs) { + it(`should work with the ${aggType} aggregator`, async () => { + const searchBody = getElasticsearchMetricQuery({ + ...baseParams, + aggType, + } as MetricExpressionParams); + const result = await client.search({ + index, + body: searchBody, + }); + expect(result.error).to.not.be.ok(); + expect(result.hits).to.be.ok(); + }); + } + it('should work with a filterQuery', async () => { + const searchBody = getElasticsearchMetricQuery( + { + ...baseParams, + aggType: 'avg', + } as MetricExpressionParams, + undefined, + '{"bool":{"should":[{"match_phrase":{"agent.hostname":"foo"}}],"minimum_should_match":1}}' + ); + const result = await client.search({ + index, + body: searchBody, + }); + expect(result.error).to.not.be.ok(); + expect(result.hits).to.be.ok(); + }); + }); + describe('querying with a groupBy parameter', () => { + for (const aggType of aggs) { + it(`should work with the ${aggType} aggregator`, async () => { + const searchBody = getElasticsearchMetricQuery( + { + ...baseParams, + aggType, + } as MetricExpressionParams, + 'agent.id' + ); + const result = await client.search({ + index, + body: searchBody, + }); + expect(result.error).to.not.be.ok(); + expect(result.hits).to.be.ok(); + }); + } + it('should work with a filterQuery', async () => { + const searchBody = getElasticsearchMetricQuery( + { + ...baseParams, + aggType: 'avg', + } as MetricExpressionParams, + 'agent.id', + '{"bool":{"should":[{"match_phrase":{"agent.hostname":"foo"}}],"minimum_should_match":1}}' + ); + const result = await client.search({ + index, + body: searchBody, + }); + expect(result.error).to.not.be.ok(); + expect(result.hits).to.be.ok(); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/management/cross_cluster_replication/auto_follow_pattern.js b/x-pack/test/api_integration/apis/management/cross_cluster_replication/auto_follow_pattern.js index 30459be6ee1dd..3efb4d6600f7f 100644 --- a/x-pack/test/api_integration/apis/management/cross_cluster_replication/auto_follow_pattern.js +++ b/x-pack/test/api_integration/apis/management/cross_cluster_replication/auto_follow_pattern.js @@ -41,7 +41,7 @@ export default function({ getService }) { payload.remoteCluster = 'unknown-cluster'; const { body } = await createAutoFollowPattern(undefined, payload).expect(404); - expect(body.cause[0]).to.contain('no such remote cluster'); + expect(body.attributes.cause[0]).to.contain('no such remote cluster'); }); }); @@ -52,6 +52,7 @@ export default function({ getService }) { it('should create an auto-follow pattern when cluster is known', async () => { const name = getRandomString(); const { body } = await createAutoFollowPattern(name).expect(200); + console.log(body); expect(body.acknowledged).to.eql(true); }); @@ -62,7 +63,7 @@ export default function({ getService }) { const name = getRandomString(); const { body } = await getAutoFollowPattern(name).expect(404); - expect(body.cause).not.to.be(undefined); + expect(body.attributes.cause).not.to.be(undefined); }); it('should return an auto-follow pattern that was created', async () => { diff --git a/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js b/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js index a5b12668ad9b9..5f9ebbd2a0a3f 100644 --- a/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js +++ b/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js @@ -47,13 +47,13 @@ export default function({ getService }) { payload.remoteCluster = 'unknown-cluster'; const { body } = await createFollowerIndex(undefined, payload).expect(404); - expect(body.cause[0]).to.contain('no such remote cluster'); + expect(body.attributes.cause[0]).to.contain('no such remote cluster'); }); it('should throw a 404 error trying to follow an unknown index', async () => { const payload = getFollowerIndexPayload(); const { body } = await createFollowerIndex(undefined, payload).expect(404); - expect(body.cause[0]).to.contain('no such index'); + expect(body.attributes.cause[0]).to.contain('no such index'); }); it('should create a follower index that follows an existing remote index', async () => { @@ -75,7 +75,7 @@ export default function({ getService }) { const name = getRandomString(); const { body } = await getFollowerIndex(name).expect(404); - expect(body.cause[0]).to.contain('no such index'); + expect(body.attributes.cause[0]).to.contain('no such index'); }); it('should return a follower index that was created', async () => { diff --git a/x-pack/test/api_integration/apis/ml/calculate_model_memory_limit.ts b/x-pack/test/api_integration/apis/ml/calculate_model_memory_limit.ts index e67d87ca37c01..5d1a52e3c2c21 100644 --- a/x-pack/test/api_integration/apis/ml/calculate_model_memory_limit.ts +++ b/x-pack/test/api_integration/apis/ml/calculate_model_memory_limit.ts @@ -21,14 +21,20 @@ export default ({ getService }: FtrProviderContext) => { const testDataList = [ { - testTitleSuffix: 'with 0 metrics, 0 influencers and no split field', + testTitleSuffix: 'when no partition field is provided with regular function', user: USER.ML_POWERUSER, requestBody: { indexPattern: 'ecommerce', - splitFieldName: '', + analysisConfig: { + bucket_span: '15m', + detectors: [ + { + function: 'mean', + }, + ], + influencers: [], + }, query: { bool: { must: [{ match_all: {} }], filter: [], must_not: [] } }, - fieldNames: ['__ml_event_rate_count__'], - influencerNames: [], timeFieldName: 'order_date', earliestMs: 1560297859000, latestMs: 1562975136000, @@ -38,7 +44,8 @@ export default ({ getService }: FtrProviderContext) => { responseBody: { statusCode: 400, error: 'Bad Request', - message: "[illegal_argument_exception] specified fields can't be null or empty", + message: + '[status_exception] Unless a count or temporal function is used one of field_name, by_field_name or over_field_name must be set', }, }, }, @@ -47,72 +54,79 @@ export default ({ getService }: FtrProviderContext) => { user: USER.ML_POWERUSER, requestBody: { indexPattern: 'ecommerce', - splitFieldName: 'geoip.city_name', - query: { bool: { must: [{ match_all: {} }], filter: [], must_not: [] } }, - fieldNames: ['products.base_price'], - influencerNames: ['geoip.city_name'], - timeFieldName: 'order_date', - earliestMs: 1560297859000, - latestMs: 1562975136000, - }, - expected: { - responseCode: 200, - responseBody: { modelMemoryLimit: '12MB' }, - }, - }, - { - testTitleSuffix: 'with 3 metrics, 3 influencers, split by city', - user: USER.ML_POWERUSER, - requestBody: { - indexPattern: 'ecommerce', - splitFieldName: 'geoip.city_name', + analysisConfig: { + bucket_span: '15m', + detectors: [ + { + function: 'avg', + field_name: 'geoip.city_name', + by_field_name: 'geoip.city_name', + }, + ], + influencers: ['geoip.city_name'], + }, query: { bool: { must: [{ match_all: {} }], filter: [], must_not: [] } }, - fieldNames: ['products.base_price', 'taxful_total_price', 'products.discount_amount'], - influencerNames: ['geoip.city_name', 'customer_gender', 'customer_full_name.keyword'], timeFieldName: 'order_date', earliestMs: 1560297859000, latestMs: 1562975136000, }, expected: { responseCode: 200, - responseBody: { modelMemoryLimit: '14MB' }, + responseBody: { modelMemoryLimit: '11MB', estimatedModelMemoryLimit: '11MB' }, }, }, { - testTitleSuffix: 'with 4 metrics, 4 influencers, split by customer_id', + testTitleSuffix: 'with 3 influencers, split by city', user: USER.ML_POWERUSER, requestBody: { indexPattern: 'ecommerce', - splitFieldName: 'customer_id', + analysisConfig: { + bucket_span: '15m', + detectors: [ + { + function: 'mean', + by_field_name: 'geoip.city_name', + field_name: 'geoip.city_name', + }, + ], + influencers: ['geoip.city_name', 'customer_gender', 'customer_full_name.keyword'], + }, query: { bool: { must: [{ match_all: {} }], filter: [], must_not: [] } }, - fieldNames: [ - 'geoip.country_iso_code', - 'taxless_total_price', - 'taxful_total_price', - 'products.discount_amount', - ], - influencerNames: [ - 'customer_id', - 'geoip.country_iso_code', - 'products.discount_percentage', - 'products.discount_amount', - ], timeFieldName: 'order_date', earliestMs: 1560297859000, latestMs: 1562975136000, }, expected: { responseCode: 200, - responseBody: { modelMemoryLimit: '23MB' }, + responseBody: { estimatedModelMemoryLimit: '11MB', modelMemoryLimit: '11MB' }, }, }, { - testTitleSuffix: - 'with 4 metrics, 4 influencers, split by customer_id and filtering by country code', + testTitleSuffix: '4 influencers, split by customer_id and filtering by country code', user: USER.ML_POWERUSER, requestBody: { indexPattern: 'ecommerce', - splitFieldName: 'customer_id', + analysisConfig: { + bucket_span: '2d', + detectors: [ + { + function: 'mean', + by_field_name: 'customer_id.city_name', + field_name: 'customer_id.city_name', + }, + { + function: 'avg', + by_field_name: 'manufacturer.keyword', + field_name: 'manufacturer.keyword', + }, + ], + influencers: [ + 'geoip.country_iso_code', + 'products.discount_percentage', + 'products.discount_amount', + 'day_of_week', + ], + }, query: { bool: { filter: { @@ -122,25 +136,13 @@ export default ({ getService }: FtrProviderContext) => { }, }, }, - fieldNames: [ - 'geoip.country_iso_code', - 'taxless_total_price', - 'taxful_total_price', - 'products.discount_amount', - ], - influencerNames: [ - 'customer_id', - 'geoip.country_iso_code', - 'products.discount_percentage', - 'products.discount_amount', - ], timeFieldName: 'order_date', earliestMs: 1560297859000, latestMs: 1562975136000, }, expected: { responseCode: 200, - responseBody: { modelMemoryLimit: '14MB' }, + responseBody: { estimatedModelMemoryLimit: '12MB', modelMemoryLimit: '12MB' }, }, }, ]; diff --git a/x-pack/test/api_integration/apis/ml/categorization_field_examples.ts b/x-pack/test/api_integration/apis/ml/categorization_field_examples.ts index ba7b9c31ad64c..aab7a65a7c122 100644 --- a/x-pack/test/api_integration/apis/ml/categorization_field_examples.ts +++ b/x-pack/test/api_integration/apis/ml/categorization_field_examples.ts @@ -96,7 +96,7 @@ export default ({ getService }: FtrProviderContext) => { exampleLength: 5, validationChecks: [ { - id: 0, + id: 3, valid: 'valid', message: '1000 field values analyzed, 95% contain 3 or more tokens.', }, @@ -117,12 +117,12 @@ export default ({ getService }: FtrProviderContext) => { exampleLength: 5, validationChecks: [ { - id: 1, + id: 4, valid: 'partially_valid', message: 'The median length for the field values analyzed is over 400 characters.', }, { - id: 4, + id: 2, valid: 'invalid', message: 'Tokenization of field value examples has failed due to more than 10000 tokens being found in a sample of 50 values.', @@ -144,12 +144,12 @@ export default ({ getService }: FtrProviderContext) => { exampleLength: 5, validationChecks: [ { - id: 0, + id: 3, valid: 'valid', message: '250 field values analyzed, 95% contain 3 or more tokens.', }, { - id: 2, + id: 5, valid: 'partially_valid', message: 'More than 75% of field values are null.', }, @@ -170,12 +170,12 @@ export default ({ getService }: FtrProviderContext) => { exampleLength: 5, validationChecks: [ { - id: 0, + id: 3, valid: 'valid', message: '500 field values analyzed, 100% contain 3 or more tokens.', }, { - id: 1, + id: 4, valid: 'partially_valid', message: 'The median length for the field values analyzed is over 400 characters.', }, @@ -196,7 +196,7 @@ export default ({ getService }: FtrProviderContext) => { exampleLength: 0, validationChecks: [ { - id: 3, + id: 0, valid: 'invalid', message: 'No examples for this field could be found. Please ensure the selected date range contains data.', @@ -218,7 +218,7 @@ export default ({ getService }: FtrProviderContext) => { exampleLength: 5, validationChecks: [ { - id: 0, + id: 3, valid: 'invalid', message: '1000 field values analyzed, 0% contain 3 or more tokens.', }, @@ -242,7 +242,7 @@ export default ({ getService }: FtrProviderContext) => { exampleLength: 5, validationChecks: [ { - id: 0, + id: 3, valid: 'valid', message: '1000 field values analyzed, 100% contain 3 or more tokens.', }, @@ -263,7 +263,7 @@ export default ({ getService }: FtrProviderContext) => { exampleLength: 5, validationChecks: [ { - id: 0, + id: 3, valid: 'partially_valid', message: '1000 field values analyzed, 50% contain 3 or more tokens.', }, diff --git a/x-pack/test/api_integration/apis/uptime/feature_controls.ts b/x-pack/test/api_integration/apis/uptime/feature_controls.ts index 15666acab2335..91ea1bedb061a 100644 --- a/x-pack/test/api_integration/apis/uptime/feature_controls.ts +++ b/x-pack/test/api_integration/apis/uptime/feature_controls.ts @@ -7,7 +7,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; import { PINGS_DATE_RANGE_END, PINGS_DATE_RANGE_START } from './constants'; -import { REST_API_URLS } from '../../../../legacy/plugins/uptime/common/constants'; +import { API_URLS } from '../../../../legacy/plugins/uptime/common/constants'; export default function featureControlsTests({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); @@ -30,7 +30,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext) const basePath = spaceId ? `/s/${spaceId}` : ''; return await supertest - .get(basePath + REST_API_URLS.INDEX_STATUS) + .get(basePath + API_URLS.INDEX_STATUS) .auth(username, password) .set('kbn-xsrf', 'foo') .then((response: any) => ({ error: undefined, response })) diff --git a/x-pack/test/api_integration/apis/uptime/rest/doc_count.ts b/x-pack/test/api_integration/apis/uptime/rest/doc_count.ts index 1f5322f581b39..3f42511dd165c 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/doc_count.ts +++ b/x-pack/test/api_integration/apis/uptime/rest/doc_count.ts @@ -5,14 +5,14 @@ */ import { FtrProviderContext } from '../../../ftr_provider_context'; import { expectFixtureEql } from '../graphql/helpers/expect_fixture_eql'; -import { REST_API_URLS } from '../../../../../legacy/plugins/uptime/common/constants'; +import { API_URLS } from '../../../../../legacy/plugins/uptime/common/constants'; export default function({ getService }: FtrProviderContext) { describe('docCount query', () => { const supertest = getService('supertest'); it(`will fetch the index's count`, async () => { - const apiResponse = await supertest.get(REST_API_URLS.INDEX_STATUS); + const apiResponse = await supertest.get(API_URLS.INDEX_STATUS); const data = apiResponse.body; expectFixtureEql(data, 'doc_count'); }); diff --git a/x-pack/test/detection_engine_api_integration/common/config.ts b/x-pack/test/detection_engine_api_integration/common/config.ts index d2bfeeb6433d3..89ebd902834b9 100644 --- a/x-pack/test/detection_engine_api_integration/common/config.ts +++ b/x-pack/test/detection_engine_api_integration/common/config.ts @@ -8,6 +8,7 @@ import path from 'path'; import { CA_CERT_PATH } from '@kbn/dev-utils'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; import { services } from './services'; +import { listsEnvFeatureFlagName } from '../../../legacy/plugins/siem/server/lib/detection_engine/feature_flags'; interface CreateTestConfigOptions { license: string; @@ -31,6 +32,10 @@ const enabledActionTypes = [ 'test.rate-limit', ]; +// Temporary feature flag for the lists feature +// TODO: Remove this once lists land in a Kibana version +process.env[listsEnvFeatureFlagName] = 'true'; + // eslint-disable-next-line import/no-default-export export function createTestConfig(name: string, options: CreateTestConfigOptions) { const { license = 'trial', disabledPlugins = [], ssl = false } = options; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts index d6a238e5b0940..91088acb7a51c 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts @@ -18,6 +18,8 @@ import { getSimpleRuleWithoutRuleId, removeServerGeneratedProperties, removeServerGeneratedPropertiesIncludingRuleId, + getSimpleMlRule, + getSimpleMlRuleOutput, } from './utils'; // eslint-disable-next-line import/no-default-export @@ -63,6 +65,20 @@ export default ({ getService }: FtrProviderContext) => { expect(bodyToCompare).to.eql(getSimpleRuleOutput()); }); + it('should create a single rule without an input index', async () => { + const { index, ...payload } = getSimpleRule(); + const { index: _index, ...expected } = getSimpleRuleOutput(); + + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(payload) + .expect(200); + + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(expected); + }); + it('should create a single rule without a rule_id', async () => { const { body } = await supertest .post(DETECTION_ENGINE_RULES_URL) @@ -74,6 +90,17 @@ export default ({ getService }: FtrProviderContext) => { expect(bodyToCompare).to.eql(getSimpleRuleOutputWithoutRuleId()); }); + it('should create a single Machine Learning rule', async () => { + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(getSimpleMlRule()) + .expect(200); + + const bodyToCompare = removeServerGeneratedProperties(body); + expect(bodyToCompare).to.eql(getSimpleMlRuleOutput()); + }); + it('should cause a 409 conflict if we attempt to create the same rule_id twice', async () => { await supertest .post(DETECTION_ENGINE_RULES_URL) diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts index 1570124cdb92b..c92e351ed5918 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/utils.ts @@ -49,10 +49,26 @@ export const getSimpleRule = (ruleId = 'rule-1'): Partial = risk_score: 1, rule_id: ruleId, severity: 'high', + index: ['auditbeat-*'], type: 'query', query: 'user.name: root or user.name: admin', }); +/** + * This is a representative ML rule payload as expected by the server + * @param ruleId + */ +export const getSimpleMlRule = (ruleId = 'rule-1'): Partial => ({ + name: 'Simple ML Rule', + description: 'Simple Machine Learning Rule', + anomaly_threshold: 44, + risk_score: 1, + rule_id: ruleId, + severity: 'high', + machine_learning_job_id: 'some_job_id', + type: 'machine_learning', +}); + export const getSignalStatus = () => ({ aggs: { statuses: { terms: { field: 'signal.status', size: 10 } } }, }); @@ -112,12 +128,14 @@ export const binaryToString = (res: any, callback: any): void => { * This is the typical output of a simple rule that Kibana will output with all the defaults. */ export const getSimpleRuleOutput = (ruleId = 'rule-1'): Partial => ({ + actions: [], created_by: 'elastic', description: 'Simple Rule Query', enabled: true, false_positives: [], from: 'now-6m', immutable: false, + index: ['auditbeat-*'], interval: '5m', rule_id: ruleId, language: 'kuery', @@ -133,6 +151,7 @@ export const getSimpleRuleOutput = (ruleId = 'rule-1'): Partial => { + const rule = getSimpleRuleOutput(ruleId); + const { query, language, index, ...rest } = rule; + + return { + ...rest, + name: 'Simple ML Rule', + description: 'Simple Machine Learning Rule', + anomaly_threshold: 44, + machine_learning_job_id: 'some_job_id', + type: 'machine_learning', + }; +}; + /** * Remove all alerts from the .kibana index * @param es The ElasticSearch handle @@ -212,6 +245,7 @@ export const ruleToNdjson = (rule: Partial): Buffer => { * @param ruleId The ruleId to set which is optional and defaults to rule-1 */ export const getComplexRule = (ruleId = 'rule-1'): Partial => ({ + actions: [], name: 'Complex Rule Query', description: 'Complex Rule Query', false_positives: [ @@ -295,6 +329,7 @@ export const getComplexRule = (ruleId = 'rule-1'): Partial * @param ruleId The ruleId to set which is optional and defaults to rule-1 */ export const getComplexRuleOutput = (ruleId = 'rule-1'): Partial => ({ + actions: [], created_by: 'elastic', name: 'Complex Rule Query', description: 'Complex Rule Query', @@ -374,4 +409,5 @@ export const getComplexRuleOutput = (ruleId = 'rule-1'): Partial { + // FLAKY: https://github.com/elastic/kibana/issues/60535 + describe.skip('security', () => { before(async () => { await esArchiver.load('discover/feature_controls/security'); await esArchiver.loadIfNeeded('logstash_functional'); diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts index 4bedc757f0b57..f33b8b4899d16 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts @@ -24,7 +24,8 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRange(); } - describe('spaces', () => { + // FLAKY: https://github.com/elastic/kibana/issues/60559 + describe.skip('spaces', () => { before(async () => { await esArchiver.loadIfNeeded('logstash_functional'); }); diff --git a/x-pack/test/functional/apps/endpoint/alerts.ts b/x-pack/test/functional/apps/endpoint/alerts.ts index 1ce7eb41e6690..759574702c0f1 100644 --- a/x-pack/test/functional/apps/endpoint/alerts.ts +++ b/x-pack/test/functional/apps/endpoint/alerts.ts @@ -18,8 +18,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await esArchiver.load('endpoint/alerts/api_feature'); await pageObjects.common.navigateToUrlWithBrowserHistory('endpoint', '/alerts'); }); - - it('loads in the browser', async () => { + it('loads the Alert List Page', async () => { await testSubjects.existOrFail('alertListPage'); }); it('contains the Alert List Page title', async () => { @@ -57,6 +56,12 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('loads the Alert List Flyout correctly', async () => { await testSubjects.existOrFail('alertDetailFlyout'); }); + + it('loads the resolver component and renders at least a single node', async () => { + await testSubjects.click('overviewResolverTab'); + await testSubjects.existOrFail('alertResolver'); + await testSubjects.existOrFail('resolverNode'); + }); }); after(async () => { diff --git a/x-pack/test/functional/apps/infra/constants.ts b/x-pack/test/functional/apps/infra/constants.ts index 947131a22d39b..cd91867faf9df 100644 --- a/x-pack/test/functional/apps/infra/constants.ts +++ b/x-pack/test/functional/apps/infra/constants.ts @@ -22,5 +22,9 @@ export const DATES = { withData: '10/17/2018 7:58:03 PM', withoutData: '10/09/2018 10:00:00 PM', }, + stream: { + startWithData: '2018-10-17T19:42:22.000Z', + endWithData: '2018-10-17T19:57:21.000Z', + }, }, }; diff --git a/x-pack/test/functional/apps/infra/link_to.ts b/x-pack/test/functional/apps/infra/link_to.ts index da41bf285c3e4..7e79f42ac94cb 100644 --- a/x-pack/test/functional/apps/infra/link_to.ts +++ b/x-pack/test/functional/apps/infra/link_to.ts @@ -7,22 +7,29 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +const ONE_HOUR = 60 * 60 * 1000; + export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['common']); const retry = getService('retry'); const browser = getService('browser'); + const timestamp = Date.now(); + const startDate = new Date(timestamp - ONE_HOUR).toISOString(); + const endDate = new Date(timestamp + ONE_HOUR).toISOString(); + + const traceId = '433b4651687e18be2c6c8e3b11f53d09'; + describe('Infra link-to', function() { this.tags('smoke'); it('redirects to the logs app and parses URL search params correctly', async () => { const location = { hash: '', pathname: '/link-to/logs', - search: 'time=1565707203194&filter=trace.id:433b4651687e18be2c6c8e3b11f53d09', + search: `time=${timestamp}&filter=trace.id:${traceId}`, state: undefined, }; - const expectedSearchString = - "logFilter=(expression:'trace.id:433b4651687e18be2c6c8e3b11f53d09',kind:kuery)&logPosition=(position:(tiebreaker:0,time:1565707203194),streamLive:!f)&sourceId=default"; + const expectedSearchString = `logFilter=(expression:'trace.id:${traceId}',kind:kuery)&logPosition=(end:'${endDate}',position:(tiebreaker:0,time:${timestamp}),start:'${startDate}',streamLive:!f)&sourceId=default`; const expectedRedirectPath = '/logs/stream?'; await pageObjects.common.navigateToUrlWithBrowserHistory( diff --git a/x-pack/test/functional/apps/infra/logs_source_configuration.ts b/x-pack/test/functional/apps/infra/logs_source_configuration.ts index ecad5a40ec42e..f40c908f23c80 100644 --- a/x-pack/test/functional/apps/infra/logs_source_configuration.ts +++ b/x-pack/test/functional/apps/infra/logs_source_configuration.ts @@ -5,6 +5,7 @@ */ import expect from '@kbn/expect'; +import { DATES } from './constants'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -74,7 +75,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('renders the default log columns with their headers', async () => { - await logsUi.logStreamPage.navigateTo(); + await logsUi.logStreamPage.navigateTo({ + logPosition: { + start: DATES.metricsAndLogs.stream.startWithData, + end: DATES.metricsAndLogs.stream.endWithData, + }, + }); await retry.try(async () => { const columnHeaderLabels = await logsUi.logStreamPage.getColumnHeaderLabels(); @@ -108,7 +114,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('renders the changed log columns with their headers', async () => { - await logsUi.logStreamPage.navigateTo(); + await logsUi.logStreamPage.navigateTo({ + logPosition: { + start: DATES.metricsAndLogs.stream.startWithData, + end: DATES.metricsAndLogs.stream.endWithData, + }, + }); await retry.try(async () => { const columnHeaderLabels = await logsUi.logStreamPage.getColumnHeaderLabels(); diff --git a/x-pack/test/functional/apps/machine_learning/anomaly_detection/categorization_job.ts b/x-pack/test/functional/apps/machine_learning/anomaly_detection/categorization_job.ts index 80f020f66c0ed..9fa53d6e546ba 100644 --- a/x-pack/test/functional/apps/machine_learning/anomaly_detection/categorization_job.ts +++ b/x-pack/test/functional/apps/machine_learning/anomaly_detection/categorization_job.ts @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../../plugins/ml/common/constants/new_job'; +import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../../plugins/ml/common/constants/categorization_job'; // eslint-disable-next-line import/no-default-export export default function({ getService }: FtrProviderContext) { diff --git a/x-pack/test/functional/apps/machine_learning/data_visualizer/file_data_visualizer.ts b/x-pack/test/functional/apps/machine_learning/data_visualizer/file_data_visualizer.ts new file mode 100644 index 0000000000000..94b28e5035edf --- /dev/null +++ b/x-pack/test/functional/apps/machine_learning/data_visualizer/file_data_visualizer.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import path from 'path'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService }: FtrProviderContext) { + const ml = getService('ml'); + + const testDataListPositive = [ + { + suiteSuffix: 'with an artificial server log', + filePath: path.join(__dirname, 'files_to_import', 'artificial_server_log'), + indexName: 'user-import_1', + createIndexPattern: false, + expected: { + results: { + title: 'artificial_server_log', + }, + }, + }, + ]; + + const testDataListNegative = [ + { + suiteSuffix: 'with a non-log file', + filePath: path.join(__dirname, 'files_to_import', 'not_a_log_file'), + }, + ]; + + describe('file based', function() { + this.tags(['smoke', 'mlqa']); + before(async () => { + await ml.securityUI.loginAsMlPowerUser(); + await ml.navigation.navigateToMl(); + }); + + for (const testData of testDataListPositive) { + describe(testData.suiteSuffix, function() { + after(async () => { + await ml.api.deleteIndices(testData.indexName); + }); + + it('loads the data visualizer selector page', async () => { + await ml.navigation.navigateToDataVisualizer(); + }); + + it('loads the file upload page', async () => { + await ml.dataVisualizer.navigateToFileUpload(); + }); + + it('selects a file and loads visualizer results', async () => { + await ml.dataVisualizerFileBased.selectFile(testData.filePath); + }); + + it('displays the components of the file details page', async () => { + await ml.dataVisualizerFileBased.assertFileTitle(testData.expected.results.title); + await ml.dataVisualizerFileBased.assertFileContentPanelExists(); + await ml.dataVisualizerFileBased.assertSummaryPanelExists(); + await ml.dataVisualizerFileBased.assertFileStatsPanelExists(); + }); + + it('loads the import settings page', async () => { + await ml.dataVisualizerFileBased.navigateToFileImport(); + }); + + it('sets the index name', async () => { + await ml.dataVisualizerFileBased.setIndexName(testData.indexName); + }); + + it('sets the create index pattern checkbox', async () => { + await ml.dataVisualizerFileBased.setCreateIndexPatternCheckboxState( + testData.createIndexPattern + ); + }); + + it('imports the file', async () => { + await ml.dataVisualizerFileBased.startImportAndWaitForProcessing(); + }); + }); + } + + for (const testData of testDataListNegative) { + describe(testData.suiteSuffix, function() { + it('loads the data visualizer selector page', async () => { + await ml.navigation.navigateToDataVisualizer(); + }); + + it('loads the file upload page', async () => { + await ml.dataVisualizer.navigateToFileUpload(); + }); + + it('selects a file and displays an error', async () => { + await ml.dataVisualizerFileBased.selectFile(testData.filePath, true); + }); + }); + } + }); +} diff --git a/x-pack/test/functional/apps/machine_learning/data_visualizer/files_to_import/artificial_server_log b/x-pack/test/functional/apps/machine_learning/data_visualizer/files_to_import/artificial_server_log new file mode 100644 index 0000000000000..3571d3c9b5e42 --- /dev/null +++ b/x-pack/test/functional/apps/machine_learning/data_visualizer/files_to_import/artificial_server_log @@ -0,0 +1,19 @@ +2018-01-06 16:56:14.295748 INFO host:'Server A' Incoming connection from ip 123.456.789.0 +2018-01-06 16:56:15.295748 INFO host:'Server A' Incoming connection from ip 123.456.789.1 +2018-01-06 16:56:16.295748 INFO host:'Server A' Incoming connection from ip 123.456.789.2 +2018-01-06 16:56:17.295748 INFO host:'Server A' Incoming connection from ip 123.456.789.3 +2018-01-06 16:56:18.295748 INFO host:'Server B' Incoming connection from ip 123.456.789.0 +2018-01-06 16:56:19.295748 INFO host:'Server B' Incoming connection from ip 123.456.789.2 +2018-01-06 16:56:20.295748 INFO host:'Server B' Incoming connection from ip 123.456.789.3 +2018-01-06 16:56:21.295748 INFO host:'Server B' Incoming connection from ip 123.456.789.4 +2018-01-06 16:56:22.295748 WARN host:'Server A' Disk watermark 80% +2018-01-06 17:16:23.295748 WARN host:'Server A' Disk watermark 90% +2018-01-06 17:36:10.295748 ERROR host:'Server A' Main process crashed +2018-01-06 17:36:14.295748 INFO host:'Server A' Connection from ip 123.456.789.0 closed +2018-01-06 17:36:15.295748 INFO host:'Server A' Connection from ip 123.456.789.1 closed +2018-01-06 17:36:16.295748 INFO host:'Server A' Connection from ip 123.456.789.2 closed +2018-01-06 17:36:17.295748 INFO host:'Server A' Connection from ip 123.456.789.3 closed +2018-01-06 17:46:11.295748 INFO host:'Server B' Some special characters °!"§$%&/()=?`'^²³{[]}\+*~#'-_.:,;µ|<>äöüß +2018-01-06 17:46:12.295748 INFO host:'Server B' Shutting down + + diff --git a/x-pack/test/functional/apps/machine_learning/data_visualizer/files_to_import/not_a_log_file b/x-pack/test/functional/apps/machine_learning/data_visualizer/files_to_import/not_a_log_file new file mode 100644 index 0000000000000..a580466c5c2a7 --- /dev/null +++ b/x-pack/test/functional/apps/machine_learning/data_visualizer/files_to_import/not_a_log_file @@ -0,0 +1,8 @@ +This +is +not +a +log +file + + diff --git a/x-pack/test/functional/apps/machine_learning/data_visualizer/index.ts b/x-pack/test/functional/apps/machine_learning/data_visualizer/index.ts index fa4b5e76ae728..4a1eb06d7a487 100644 --- a/x-pack/test/functional/apps/machine_learning/data_visualizer/index.ts +++ b/x-pack/test/functional/apps/machine_learning/data_visualizer/index.ts @@ -10,5 +10,6 @@ export default function({ loadTestFile }: FtrProviderContext) { this.tags(['skipFirefox']); loadTestFile(require.resolve('./index_data_visualizer')); + loadTestFile(require.resolve('./file_data_visualizer')); }); } diff --git a/x-pack/test/functional/apps/maps/blended_vector_layer.js b/x-pack/test/functional/apps/maps/blended_vector_layer.js new file mode 100644 index 0000000000000..a01f796fe3455 --- /dev/null +++ b/x-pack/test/functional/apps/maps/blended_vector_layer.js @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +export default function({ getPageObjects, getService }) { + const PageObjects = getPageObjects(['maps']); + const inspector = getService('inspector'); + + describe('blended vector layer', () => { + before(async () => { + await PageObjects.maps.loadSavedMap('blended document example'); + }); + + it('should request documents when zoomed to smaller regions showing less data', async () => { + const hits = await PageObjects.maps.getHits(); + expect(hits).to.equal('33'); + }); + + it('should request clusters when zoomed to larger regions showing lots of data', async () => { + await PageObjects.maps.setView(20, -90, 2); + await inspector.open(); + await inspector.openInspectorRequestsView(); + const requestStats = await inspector.getTableData(); + const hits = PageObjects.maps.getInspectorStatRowHit(requestStats, 'Hits'); + const totalHits = PageObjects.maps.getInspectorStatRowHit(requestStats, 'Hits (total)'); + await inspector.close(); + + expect(hits).to.equal('0'); + expect(totalHits).to.equal('14000'); + }); + + it('should request documents when query narrows data', async () => { + await PageObjects.maps.setAndSubmitQuery('bytes > 19000'); + const hits = await PageObjects.maps.getHits(); + expect(hits).to.equal('75'); + }); + }); +} diff --git a/x-pack/test/functional/apps/maps/index.js b/x-pack/test/functional/apps/maps/index.js index 44a7c4c9a5f86..ae7de986cf867 100644 --- a/x-pack/test/functional/apps/maps/index.js +++ b/x-pack/test/functional/apps/maps/index.js @@ -30,6 +30,7 @@ export default function({ loadTestFile, getService }) { describe('', function() { this.tags('ciGroup7'); loadTestFile(require.resolve('./documents_source')); + loadTestFile(require.resolve('./blended_vector_layer')); loadTestFile(require.resolve('./saved_object_management')); loadTestFile(require.resolve('./sample_data')); loadTestFile(require.resolve('./feature_controls/maps_security')); diff --git a/x-pack/test/functional/es_archives/maps/kibana/data.json b/x-pack/test/functional/es_archives/maps/kibana/data.json index e50ec593cc990..cb3598652a39a 100644 --- a/x-pack/test/functional/es_archives/maps/kibana/data.json +++ b/x-pack/test/functional/es_archives/maps/kibana/data.json @@ -288,7 +288,7 @@ "title" : "document example top hits split with scripted field", "description" : "", "mapStateJSON" : "{\"zoom\":4.1,\"center\":{\"lon\":-100.61091,\"lat\":33.23887},\"timeFilters\":{\"from\":\"2015-09-20T00:00:00.000Z\",\"to\":\"2015-09-24T01:00:00.000Z\"},\"refreshConfig\":{\"isPaused\":true,\"interval\":1000},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[]}", - "layerListJSON" : "[{\"id\":\"0hmz5\",\"sourceDescriptor\":{\"type\":\"EMS_TMS\",\"id\":\"road_map\"},\"visible\":true,\"temporary\":false,\"style\":{\"type\":\"TILE\",\"properties\":{}},\"type\":\"VECTOR_TILE\",\"minZoom\":0,\"maxZoom\":24},{\"id\":\"z52lq\",\"label\":\"logstash\",\"minZoom\":0,\"maxZoom\":24,\"sourceDescriptor\":{\"id\":\"e1a5e1a6-676c-4a89-8ea9-0d91d64b73c6\",\"type\":\"ES_SEARCH\",\"geoField\":\"geo.coordinates\",\"limit\":2048,\"filterByMapBounds\":true,\"showTooltip\":true,\"tooltipProperties\":[],\"useTopHits\":true,\"topHitsSplitField\":\"hour_of_day\",\"topHitsSize\":1,\"sortField\":\"@timestamp\",\"sortOrder\":\"desc\",\"applyGlobalQuery\":true,\"indexPatternRefName\":\"layer_1_source_index_pattern\"},\"visible\":true,\"temporary\":false,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#e6194b\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"airfield\"}}},\"previousStyle\":null},\"type\":\"VECTOR\"}]", + "layerListJSON" : "[{\"id\":\"0hmz5\",\"sourceDescriptor\":{\"type\":\"EMS_TMS\",\"id\":\"road_map\"},\"visible\":true,\"temporary\":false,\"style\":{\"type\":\"TILE\",\"properties\":{}},\"type\":\"VECTOR_TILE\",\"minZoom\":0,\"maxZoom\":24},{\"id\":\"z52lq\",\"label\":\"logstash\",\"minZoom\":0,\"maxZoom\":24,\"sourceDescriptor\":{\"id\":\"e1a5e1a6-676c-4a89-8ea9-0d91d64b73c6\",\"type\":\"ES_SEARCH\",\"geoField\":\"geo.coordinates\",\"limit\":2048,\"filterByMapBounds\":true,\"showTooltip\":true,\"tooltipProperties\":[],\"scalingType\":\"TOP_HITS\",\"topHitsSplitField\":\"hour_of_day\",\"topHitsSize\":1,\"sortField\":\"@timestamp\",\"sortOrder\":\"desc\",\"applyGlobalQuery\":true,\"indexPatternRefName\":\"layer_1_source_index_pattern\"},\"visible\":true,\"temporary\":false,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#e6194b\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"airfield\"}}},\"previousStyle\":null},\"type\":\"VECTOR\"}]", "uiStateJSON" : "{\"isLayerTOCOpen\":true,\"openTOCDetails\":[]}", "bounds" : { "type" : "Polygon", @@ -861,6 +861,62 @@ } } +{ + "type": "doc", + "value": { + "id": "map:279e1f20-6883-11ea-952a-b102add99cf8", + "index": ".kibana", + "source": { + "map" : { + "title" : "blended document example", + "description" : "", + "mapStateJSON" : "{\"zoom\":10.27,\"center\":{\"lon\":-83.70716,\"lat\":32.73679},\"timeFilters\":{\"from\":\"2015-09-20T00:00:00.000Z\",\"to\":\"2015-09-23T00:00:00.000Z\"},\"refreshConfig\":{\"isPaused\":true,\"interval\":0},\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filters\":[]}", + "layerListJSON" : "[{\"sourceDescriptor\":{\"type\":\"EMS_TMS\",\"isAutoSelect\":true},\"id\":\"43a70a86-00fd-43af-9e84-4d9fe2d7513d\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":1,\"visible\":true,\"style\":{},\"type\":\"VECTOR_TILE\"},{\"id\":\"307c8495-89f7-431b-83d8-78724d9a8f72\",\"label\":\"logstash-*\",\"sourceDescriptor\":{\"geoField\":\"geo.coordinates\",\"id\":\"20fc58c3-3c0a-4c7b-9cdc-37552cafdc21\",\"tooltipProperties\":[],\"type\":\"ES_SEARCH\",\"scalingType\":\"CLUSTERS\",\"indexPatternRefName\":\"layer_1_source_index_pattern\"},\"type\":\"BLENDED_VECTOR\",\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"airfield\"}},\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#54B399\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#41937c\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":6}},\"iconOrientation\":{\"type\":\"STATIC\",\"options\":{\"orientation\":0}},\"labelText\":{\"type\":\"STATIC\",\"options\":{\"value\":\"\"}},\"labelColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#000000\"}},\"labelSize\":{\"type\":\"STATIC\",\"options\":{\"size\":14}},\"labelBorderColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"labelBorderSize\":{\"options\":{\"size\":\"SMALL\"}}},\"isTimeAware\":true}}]", + "uiStateJSON" : "{\"isLayerTOCOpen\":true,\"openTOCDetails\":[]}", + "bounds" : { + "type" : "Polygon", + "coordinates" : [ + [ + [ + -84.07816, + 32.95327 + ], + [ + -84.07816, + 32.51978 + ], + [ + -83.33616, + 32.51978 + ], + [ + -83.33616, + 32.95327 + ], + [ + -84.07816, + 32.95327 + ] + ] + ] + } + }, + "type" : "map", + "references" : [ + { + "name" : "layer_1_source_index_pattern", + "type" : "index-pattern", + "id" : "c698b940-e149-11e8-a35a-370a8516603a" + } + ], + "migrationVersion" : { + "map" : "7.7.0" + }, + "updated_at" : "2020-03-17T19:11:50.290Z" + } + } +} + { "type": "doc", "value": { diff --git a/x-pack/test/functional/page_objects/gis_page.js b/x-pack/test/functional/page_objects/gis_page.js index b78ec6a477e1f..8d0c649d75dd6 100644 --- a/x-pack/test/functional/page_objects/gis_page.js +++ b/x-pack/test/functional/page_objects/gis_page.js @@ -515,9 +515,7 @@ export function GisPageProvider({ getService, getPageObjects }) { } async uploadJsonFileForIndexing(path) { - log.debug(`Setting the path on the file input`); - const input = await find.byCssSelector('.euiFilePicker__input'); - await input.type(path); + await PageObjects.common.setFileInputPath(path); log.debug(`File selected`); await PageObjects.header.waitUntilLoadingHasFinished(); diff --git a/x-pack/test/functional/page_objects/infra_logs_page.ts b/x-pack/test/functional/page_objects/infra_logs_page.ts index 8f554729328bb..10d86140fd121 100644 --- a/x-pack/test/functional/page_objects/infra_logs_page.ts +++ b/x-pack/test/functional/page_objects/infra_logs_page.ts @@ -6,8 +6,21 @@ // import testSubjSelector from '@kbn/test-subj-selector'; // import moment from 'moment'; - +import querystring from 'querystring'; +import { encode, RisonValue } from 'rison-node'; import { FtrProviderContext } from '../ftr_provider_context'; +import { LogPositionUrlState } from '../../../../x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state'; +import { FlyoutOptionsUrlState } from '../../../../x-pack/plugins/infra/public/containers/logs/log_flyout'; + +export interface TabsParams { + stream: { + logPosition?: Partial; + flyoutOptions?: Partial; + }; + settings: never; + 'log-categories': any; + 'log-rate': any; +} export function InfraLogsPageProvider({ getPageObjects, getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); @@ -18,8 +31,26 @@ export function InfraLogsPageProvider({ getPageObjects, getService }: FtrProvide await pageObjects.common.navigateToApp('infraLogs'); }, - async navigateToTab(logsUiTab: LogsUiTab) { - await pageObjects.common.navigateToUrlWithBrowserHistory('infraLogs', `/${logsUiTab}`); + async navigateToTab(logsUiTab: T, params?: TabsParams[T]) { + let qs = ''; + if (params) { + const parsedParams: Record = {}; + + for (const key in params) { + if (params.hasOwnProperty(key)) { + const value = (params[key] as unknown) as RisonValue; + parsedParams[key] = encode(value); + } + } + qs = '?' + querystring.stringify(parsedParams); + } + + await pageObjects.common.navigateToUrlWithBrowserHistory( + 'infraLogs', + `/${logsUiTab}`, + qs, + { ensureCurrentUrl: false } // Test runner struggles with `rison-node` escaped values + ); }, async getLogStream() { diff --git a/x-pack/test/functional/page_objects/uptime_page.ts b/x-pack/test/functional/page_objects/uptime_page.ts index f6e93cd14e497..57842ffbb2c5d 100644 --- a/x-pack/test/functional/page_objects/uptime_page.ts +++ b/x-pack/test/functional/page_objects/uptime_page.ts @@ -24,11 +24,13 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo public async goToUptimeOverviewAndLoadData( datePickerStartValue: string, datePickerEndValue: string, - monitorIdToCheck: string + monitorIdToCheck?: string ) { await pageObjects.common.navigateToApp('uptime'); await pageObjects.timePicker.setAbsoluteRange(datePickerStartValue, datePickerEndValue); - await uptimeService.monitorIdExists(monitorIdToCheck); + if (monitorIdToCheck) { + await uptimeService.monitorIdExists(monitorIdToCheck); + } } public async loadDataAndGoToMonitorPage( @@ -96,5 +98,39 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo public locationMissingIsDisplayed() { return uptimeService.locationMissingExists(); } + + public async openAlertFlyoutAndCreateMonitorStatusAlert({ + alertInterval, + alertName, + alertNumTimes, + alertTags, + alertThrottleInterval, + alertTimerangeSelection, + filters, + }: { + alertName: string; + alertTags: string[]; + alertInterval: string; + alertThrottleInterval: string; + alertNumTimes: string; + alertTimerangeSelection: string; + filters?: string; + }) { + const { alerts, setKueryBarText } = uptimeService; + await alerts.openFlyout(); + await alerts.openMonitorStatusAlertType(); + await alerts.setAlertName(alertName); + await alerts.setAlertTags(alertTags); + await alerts.setAlertInterval(alertInterval); + await alerts.setAlertThrottleInterval(alertThrottleInterval); + if (filters) { + await setKueryBarText('xpack.uptime.alerts.monitorStatus.filterBar', filters); + } + await alerts.setAlertStatusNumTimes(alertNumTimes); + await alerts.setAlertTimerangeSelection(alertTimerangeSelection); + await alerts.setMonitorStatusSelectableToHours(); + await alerts.setLocationsSelectable(); + await alerts.clickSaveAlertButtion(); + } })(); } diff --git a/x-pack/test/functional/services/logs_ui/log_stream.ts b/x-pack/test/functional/services/logs_ui/log_stream.ts index ce37d2d5a60da..75486534cf5cc 100644 --- a/x-pack/test/functional/services/logs_ui/log_stream.ts +++ b/x-pack/test/functional/services/logs_ui/log_stream.ts @@ -6,6 +6,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; import { WebElementWrapper } from '../../../../../test/functional/services/lib/web_element_wrapper'; +import { TabsParams } from '../../page_objects/infra_logs_page'; export function LogStreamPageProvider({ getPageObjects, getService }: FtrProviderContext) { const pageObjects = getPageObjects(['infraLogs']); @@ -13,8 +14,8 @@ export function LogStreamPageProvider({ getPageObjects, getService }: FtrProvide const testSubjects = getService('testSubjects'); return { - async navigateTo() { - pageObjects.infraLogs.navigateToTab('stream'); + async navigateTo(params?: TabsParams['stream']) { + pageObjects.infraLogs.navigateToTab('stream', params); }, async getColumnHeaderLabels(): Promise { diff --git a/x-pack/test/functional/services/machine_learning/data_visualizer.ts b/x-pack/test/functional/services/machine_learning/data_visualizer.ts index dec854130624f..c60ae29b6b3f4 100644 --- a/x-pack/test/functional/services/machine_learning/data_visualizer.ts +++ b/x-pack/test/functional/services/machine_learning/data_visualizer.ts @@ -22,5 +22,10 @@ export function MachineLearningDataVisualizerProvider({ getService }: FtrProvide await testSubjects.click('mlDataVisualizerSelectIndexButton'); await testSubjects.existOrFail('mlPageSourceSelection'); }, + + async navigateToFileUpload() { + await testSubjects.click('mlDataVisualizerUploadFileButton'); + await testSubjects.existOrFail('mlPageFileDataVisualizerUpload'); + }, }; } diff --git a/x-pack/test/functional/services/machine_learning/data_visualizer_file_based.ts b/x-pack/test/functional/services/machine_learning/data_visualizer_file_based.ts new file mode 100644 index 0000000000000..eea0a83879ea7 --- /dev/null +++ b/x-pack/test/functional/services/machine_learning/data_visualizer_file_based.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; +import { MlCommon } from './common'; + +export function MachineLearningDataVisualizerFileBasedProvider( + { getService, getPageObjects }: FtrProviderContext, + mlCommon: MlCommon +) { + const log = getService('log'); + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); + + const PageObjects = getPageObjects(['common']); + + return { + async selectFile(path: string, expectError: boolean = false) { + log.debug(`Importing file '${path}' ...`); + await PageObjects.common.setFileInputPath(path); + + await testSubjects.waitForDeleted('mlPageFileDataVisLoading'); + + if (expectError) { + await testSubjects.existOrFail('~mlFileUploadErrorCallout'); + } else { + await testSubjects.missingOrFail('~mlFileUploadErrorCallout'); + await testSubjects.existOrFail('mlPageFileDataVisResults'); + } + }, + + async assertFileTitle(expectedTitle: string) { + const actualTitle = await testSubjects.getVisibleText('mlFileDataVisResultsTitle'); + expect(actualTitle).to.eql( + expectedTitle, + `Expected file title to be '${expectedTitle}' (got '${actualTitle}')` + ); + }, + + async assertFileContentPanelExists() { + await testSubjects.existOrFail('mlFileDataVisFileContentPanel'); + }, + + async assertSummaryPanelExists() { + await testSubjects.existOrFail('mlFileDataVisSummaryPanel'); + }, + + async assertFileStatsPanelExists() { + await testSubjects.existOrFail('mlFileDataVisFileStatsPanel'); + }, + + async navigateToFileImport() { + await testSubjects.click('mlFileDataVisOpenImportPageButton'); + await testSubjects.existOrFail('mlPageFileDataVisImport'); + }, + + async assertImportSettingsPanelExists() { + await testSubjects.existOrFail('mlFileDataVisImportSettingsPanel'); + }, + + async assertIndexNameValue(expectedValue: string) { + const actualIndexName = await testSubjects.getAttribute( + 'mlFileDataVisIndexNameInput', + 'value' + ); + expect(actualIndexName).to.eql( + expectedValue, + `Expected index name to be '${expectedValue}' (got '${actualIndexName}')` + ); + }, + + async setIndexName(indexName: string) { + await mlCommon.setValueWithChecks('mlFileDataVisIndexNameInput', indexName, { + clearWithKeyboard: true, + }); + await this.assertIndexNameValue(indexName); + }, + + async assertCreateIndexPatternCheckboxValue(expectedValue: boolean) { + const isChecked = await testSubjects.isChecked('mlFileDataVisCreateIndexPatternCheckbox'); + expect(isChecked).to.eql( + expectedValue, + `Expected create index pattern checkbox to be ${expectedValue ? 'checked' : 'unchecked'}` + ); + }, + + async setCreateIndexPatternCheckboxState(newState: boolean) { + const isChecked = await testSubjects.isChecked('mlFileDataVisCreateIndexPatternCheckbox'); + if (isChecked !== newState) { + // this checkbox can't be clicked directly, instead click the corresponding label + const panel = await testSubjects.find('mlFileDataVisImportSettingsPanel'); + const label = await panel.findByCssSelector('[for="createIndexPattern"]'); + await label.click(); + } + await this.assertCreateIndexPatternCheckboxValue(newState); + }, + + async startImportAndWaitForProcessing() { + await testSubjects.click('mlFileDataVisImportButton'); + await retry.tryForTime(60 * 1000, async () => { + await testSubjects.existOrFail('mlFileImportSuccessCallout'); + }); + }, + }; +} diff --git a/x-pack/test/functional/services/machine_learning/index.ts b/x-pack/test/functional/services/machine_learning/index.ts index b916c1e7909b1..f5adf63825163 100644 --- a/x-pack/test/functional/services/machine_learning/index.ts +++ b/x-pack/test/functional/services/machine_learning/index.ts @@ -13,6 +13,7 @@ export { MachineLearningDataFrameAnalyticsProvider } from './data_frame_analytic export { MachineLearningDataFrameAnalyticsCreationProvider } from './data_frame_analytics_creation'; export { MachineLearningDataFrameAnalyticsTableProvider } from './data_frame_analytics_table'; export { MachineLearningDataVisualizerProvider } from './data_visualizer'; +export { MachineLearningDataVisualizerFileBasedProvider } from './data_visualizer_file_based'; export { MachineLearningDataVisualizerIndexBasedProvider } from './data_visualizer_index_based'; export { MachineLearningJobManagementProvider } from './job_management'; export { MachineLearningJobSelectionProvider } from './job_selection'; diff --git a/x-pack/test/functional/services/machine_learning/job_wizard_categorization.ts b/x-pack/test/functional/services/machine_learning/job_wizard_categorization.ts index 2f4162c0cb60a..97d45701a2685 100644 --- a/x-pack/test/functional/services/machine_learning/job_wizard_categorization.ts +++ b/x-pack/test/functional/services/machine_learning/job_wizard_categorization.ts @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../plugins/ml/common/constants/new_job'; +import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../plugins/ml/common/constants/categorization_job'; export function MachineLearningJobWizardCategorizationProvider({ getService }: FtrProviderContext) { const comboBox = getService('comboBox'); diff --git a/x-pack/test/functional/services/ml.ts b/x-pack/test/functional/services/ml.ts index 354e0907375ca..f3981c9edf92f 100644 --- a/x-pack/test/functional/services/ml.ts +++ b/x-pack/test/functional/services/ml.ts @@ -16,6 +16,7 @@ import { MachineLearningDataFrameAnalyticsCreationProvider, MachineLearningDataFrameAnalyticsTableProvider, MachineLearningDataVisualizerProvider, + MachineLearningDataVisualizerFileBasedProvider, MachineLearningDataVisualizerIndexBasedProvider, MachineLearningJobManagementProvider, MachineLearningJobSelectionProvider, @@ -48,6 +49,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { ); const dataFrameAnalyticsTable = MachineLearningDataFrameAnalyticsTableProvider(context); const dataVisualizer = MachineLearningDataVisualizerProvider(context); + const dataVisualizerFileBased = MachineLearningDataVisualizerFileBasedProvider(context, common); const dataVisualizerIndexBased = MachineLearningDataVisualizerIndexBasedProvider(context); const jobManagement = MachineLearningJobManagementProvider(context, api); const jobSelection = MachineLearningJobSelectionProvider(context); @@ -75,6 +77,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { dataFrameAnalyticsCreation, dataFrameAnalyticsTable, dataVisualizer, + dataVisualizerFileBased, dataVisualizerIndexBased, jobManagement, jobSelection, diff --git a/x-pack/test/functional/services/uptime.ts b/x-pack/test/functional/services/uptime.ts index 938be2c71ae74..7994a7e934033 100644 --- a/x-pack/test/functional/services/uptime.ts +++ b/x-pack/test/functional/services/uptime.ts @@ -12,6 +12,91 @@ export function UptimeProvider({ getService }: FtrProviderContext) { const retry = getService('retry'); return { + alerts: { + async openFlyout() { + await testSubjects.click('xpack.uptime.alertsPopover.toggleButton', 5000); + await testSubjects.click('xpack.uptime.toggleAlertFlyout', 5000); + }, + async openMonitorStatusAlertType() { + return testSubjects.click('xpack.uptime.alerts.monitorStatus-SelectOption', 5000); + }, + async setAlertTags(tags: string[]) { + for (let i = 0; i < tags.length; i += 1) { + await testSubjects.click('comboBoxSearchInput', 5000); + await testSubjects.setValue('comboBoxInput', tags[i]); + await browser.pressKeys(browser.keys.ENTER); + } + }, + async setAlertName(name: string) { + return testSubjects.setValue('alertNameInput', name); + }, + async setAlertInterval(value: string) { + return testSubjects.setValue('intervalInput', value); + }, + async setAlertThrottleInterval(value: string) { + return testSubjects.setValue('throttleInput', value); + }, + async setAlertExpressionValue( + expressionAttribute: string, + fieldAttribute: string, + value: string + ) { + await testSubjects.click(expressionAttribute); + await testSubjects.setValue(fieldAttribute, value); + return browser.pressKeys(browser.keys.ESCAPE); + }, + async setAlertStatusNumTimes(value: string) { + return this.setAlertExpressionValue( + 'xpack.uptime.alerts.monitorStatus.numTimesExpression', + 'xpack.uptime.alerts.monitorStatus.numTimesField', + value + ); + }, + async setAlertTimerangeSelection(value: string) { + return this.setAlertExpressionValue( + 'xpack.uptime.alerts.monitorStatus.timerangeValueExpression', + 'xpack.uptime.alerts.monitorStatus.timerangeValueField', + value + ); + }, + async setAlertExpressionSelectable( + expressionAttribute: string, + selectableAttribute: string, + optionAttributes: string[] + ) { + await testSubjects.click(expressionAttribute, 5000); + await testSubjects.click(selectableAttribute, 5000); + for (let i = 0; i < optionAttributes.length; i += 1) { + await testSubjects.click(optionAttributes[i], 5000); + } + return browser.pressKeys(browser.keys.ESCAPE); + }, + async setMonitorStatusSelectableToHours() { + return this.setAlertExpressionSelectable( + 'xpack.uptime.alerts.monitorStatus.timerangeUnitExpression', + 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable', + ['xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.hoursOption'] + ); + }, + async setLocationsSelectable() { + await testSubjects.click( + 'xpack.uptime.alerts.monitorStatus.locationsSelectionExpression', + 5000 + ); + await testSubjects.click( + 'xpack.uptime.alerts.monitorStatus.locationsSelectionSwitch', + 5000 + ); + await testSubjects.click( + 'xpack.uptime.alerts.monitorStatus.locationsSelectionSelectable', + 5000 + ); + return browser.pressKeys(browser.keys.ESCAPE); + }, + async clickSaveAlertButtion() { + return testSubjects.click('saveAlertButton'); + }, + }, async assertExists(key: string) { if (!(await testSubjects.exists(key))) { throw new Error(`Couldn't find expected element with key "${key}".`); @@ -35,11 +120,14 @@ export function UptimeProvider({ getService }: FtrProviderContext) { async getMonitorNameDisplayedOnPageTitle() { return await testSubjects.getVisibleText('monitor-page-title'); }, - async setFilterText(filterQuery: string) { - await testSubjects.click('xpack.uptime.filterBar'); - await testSubjects.setValue('xpack.uptime.filterBar', filterQuery); + async setKueryBarText(attribute: string, value: string) { + await testSubjects.click(attribute); + await testSubjects.setValue(attribute, value); await browser.pressKeys(browser.keys.ENTER); }, + async setFilterText(filterQuery: string) { + await this.setKueryBarText('xpack.uptime.filterBar', filterQuery); + }, async goToNextPage() { await testSubjects.click('xpack.uptime.monitorList.nextButton', 5000); }, diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts index 791712fa24489..79448fa535370 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts @@ -18,20 +18,21 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const supertest = getService('supertest'); const find = getService('find'); - async function createAlert(alertTypeId?: string, name?: string, params?: any) { + async function createAlert(overwrites: Record = {}) { const { body: createdAlert } = await supertest .post(`/api/alert`) .set('kbn-xsrf', 'foo') .send({ enabled: true, - name: name ?? generateUniqueKey(), + name: generateUniqueKey(), tags: ['foo', 'bar'], - alertTypeId: alertTypeId ?? 'test.noop', + alertTypeId: 'test.noop', consumer: 'test', schedule: { interval: '1m' }, throttle: '1m', actions: [], - params: params ?? {}, + params: {}, + ...overwrites, }) .expect(200); return createdAlert; @@ -98,6 +99,22 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { ]); }); + it('should display alerts in alphabetical order', async () => { + const uniqueKey = generateUniqueKey(); + await createAlert({ name: 'b', tags: [uniqueKey] }); + await createAlert({ name: 'c', tags: [uniqueKey] }); + await createAlert({ name: 'a', tags: [uniqueKey] }); + + await pageObjects.common.navigateToApp('triggersActions'); + await pageObjects.triggersActionsUI.searchAlerts(uniqueKey); + + const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResults).to.have.length(3); + expect(searchResults[0].name).to.eql('a'); + expect(searchResults[1].name).to.eql('b'); + expect(searchResults[2].name).to.eql('c'); + }); + it('should search for alert', async () => { const createdAlert = await createAlert(); await pageObjects.common.navigateToApp('triggersActions'); @@ -115,16 +132,20 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('should edit an alert', async () => { - const createdAlert = await createAlert('.index-threshold', 'new alert', { - aggType: 'count', - termSize: 5, - thresholdComparator: '>', - timeWindowSize: 5, - timeWindowUnit: 'm', - groupBy: 'all', - threshold: [1000, 5000], - index: ['.kibana_1'], - timeField: 'alert', + const createdAlert = await createAlert({ + alertTypeId: '.index-threshold', + name: 'new alert', + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000, 5000], + index: ['.kibana_1'], + timeField: 'alert', + }, }); await pageObjects.common.navigateToApp('triggersActions'); await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); @@ -165,6 +186,45 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { ]); }); + it('should reset alert when canceling an edit', async () => { + const createdAlert = await createAlert({ + alertTypeId: '.index-threshold', + name: generateUniqueKey(), + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000, 5000], + index: ['.kibana_1'], + timeField: 'alert', + }, + }); + await pageObjects.common.navigateToApp('triggersActions'); + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const editLink = await testSubjects.findAll('alertsTableCell-editLink'); + await editLink[0].click(); + + const updatedAlertName = 'Changed Alert Name'; + const nameInputToUpdate = await testSubjects.find('alertNameInput'); + await nameInputToUpdate.click(); + await nameInputToUpdate.clearValue(); + await nameInputToUpdate.type(updatedAlertName); + + await testSubjects.click('cancelSaveEditedAlertButton'); + await find.waitForDeletedByCssSelector('[data-test-subj="cancelSaveEditedAlertButton"]'); + + const editLinkPostCancel = await testSubjects.findAll('alertsTableCell-editLink'); + await editLinkPostCancel[0].click(); + + const nameInputAfterCancel = await testSubjects.find('alertNameInput'); + const textAfterCancel = await nameInputAfterCancel.getAttribute('value'); + expect(textAfterCancel).to.eql(createdAlert.name); + }); + it('should search for tags', async () => { const createdAlert = await createAlert(); await pageObjects.common.navigateToApp('triggersActions'); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index 74a267c6e0a8e..64655e5b45a2b 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -148,6 +148,34 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); + describe.skip('View In App', function() { + const testRunUuid = uuid.v4(); + before(async () => { + await pageObjects.common.navigateToApp('triggersActions'); + }); + + it('renders the alert details view in app button', async () => { + const alert = await alerting.alerts.createNoOp(`test-alert-${testRunUuid}`); + + // refresh to see alert + await browser.refresh(); + + await pageObjects.header.waitUntilLoadingHasFinished(); + + // Verify content + await testSubjects.existOrFail('alertsList'); + + // click on first alert + await pageObjects.triggersActionsUI.clickOnAlertInAlertsList(alert.name); + + expect(await pageObjects.alertDetailsUI.isViewInAppEnabled()).to.be(true); + + await pageObjects.alertDetailsUI.clickViewInAppEnabled(); + + expect(await pageObjects.alertDetailsUI.getNoOpAppTitle()).to.be(`View Alert ${alert.id}`); + }); + }); + describe('Alert Instances', function() { const testRunUuid = uuid.v4(); let alert: any; diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts new file mode 100644 index 0000000000000..2a0358160da51 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + describe('overview page alert flyout controls', function() { + const DEFAULT_DATE_START = 'Sep 10, 2019 @ 12:40:08.078'; + const DEFAULT_DATE_END = 'Sep 11, 2019 @ 19:40:08.078'; + const pageObjects = getPageObjects(['common', 'uptime']); + const supertest = getService('supertest'); + const retry = getService('retry'); + + it('posts an alert, verfies its presence, and deletes the alert', async () => { + await pageObjects.uptime.goToUptimeOverviewAndLoadData(DEFAULT_DATE_START, DEFAULT_DATE_END); + + await pageObjects.uptime.openAlertFlyoutAndCreateMonitorStatusAlert({ + alertInterval: '11', + alertName: 'uptime-test', + alertNumTimes: '3', + alertTags: ['uptime', 'another'], + alertThrottleInterval: '30', + alertTimerangeSelection: '1', + filters: 'monitor.id: "0001-up"', + }); + + // The creation of the alert could take some time, so the first few times we query after + // the previous line resolves, the API may not be done creating the alert yet, so we + // put the fetch code in a retry block with a timeout. + let alert: any; + await retry.tryForTime(15000, async () => { + const apiResponse = await supertest.get('/api/alert/_find'); + const alertsFromThisTest = apiResponse.body.data.filter( + ({ name }: { name: string }) => name === 'uptime-test' + ); + expect(alertsFromThisTest).to.have.length(1); + alert = alertsFromThisTest[0]; + }); + + // Ensure the parameters and other stateful data + // on the alert match up with the values we provided + // for our test helper to input into the flyout. + const { + actions, + alertTypeId, + consumer, + id, + params: { numTimes, timerange, locations, filters }, + schedule: { interval }, + tags, + } = alert; + + // we're not testing the flyout's ability to associate alerts with action connectors + expect(actions).to.eql([]); + + expect(alertTypeId).to.eql('xpack.uptime.alerts.monitorStatus'); + expect(consumer).to.eql('uptime'); + expect(interval).to.eql('11m'); + expect(tags).to.eql(['uptime', 'another']); + expect(numTimes).to.be(3); + expect(timerange.from).to.be('now-1h'); + expect(timerange.to).to.be('now'); + expect(locations).to.eql(['mpls']); + expect(filters).to.eql( + '{"bool":{"should":[{"match_phrase":{"monitor.id":"0001-up"}}],"minimum_should_match":1}}' + ); + + await supertest + .delete(`/api/alert/${id}`) + .set('kbn-xsrf', 'true') + .expect(204); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts new file mode 100644 index 0000000000000..a433175acae01 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { FtrProviderContext } from '../../ftr_provider_context'; + +const ARCHIVE = 'uptime/full_heartbeat'; + +export default ({ getService, loadTestFile }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + + describe('Uptime app', function() { + this.tags('ciGroup6'); + + describe('with real-world data', () => { + before(async () => { + await esArchiver.load(ARCHIVE); + await kibanaServer.uiSettings.replace({ 'dateFormat:tz': 'UTC' }); + }); + after(async () => await esArchiver.unload(ARCHIVE)); + + loadTestFile(require.resolve('./alert_flyout')); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index 1a9736b0b4773..538817bd9d14c 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -28,7 +28,10 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) { services, pageObjects, // list paths to the files that contain your plugins tests - testFiles: [resolve(__dirname, './apps/triggers_actions_ui')], + testFiles: [ + resolve(__dirname, './apps/triggers_actions_ui'), + resolve(__dirname, './apps/uptime'), + ], apps: { ...xpackFunctionalConfig.get('apps'), triggersActions: { @@ -49,8 +52,6 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) { `--plugin-path=${join(__dirname, 'fixtures', 'plugins', 'alerts')}`, '--xpack.actions.enabled=true', '--xpack.alerting.enabled=true', - '--xpack.triggers_actions_ui.enabled=true', - '--xpack.triggers_actions_ui.createAlertUiEnabled=true', ], }, }; diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json new file mode 100644 index 0000000000000..f072937c4b128 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json @@ -0,0 +1,9 @@ +{ + "id": "alerting_fixture", + "version": "1.0.0", + "kibanaVersion": "kibana", + "configPath": ["xpack"], + "requiredPlugins": ["alerting"], + "server": true, + "ui": true +} diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/package.json b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/package.json index 836fa09855d8f..7f7463f4815e7 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/package.json +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/package.json @@ -3,5 +3,13 @@ "version": "0.0.0", "kibana": { "version": "kibana" + }, + "main": "target/test/functional_with_es_ssl/fixtures/plugins/alerts", + "scripts": { + "kbn": "node ../../../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.7.2" } } diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/public/application.tsx b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/public/application.tsx new file mode 100644 index 0000000000000..2301a39187801 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/public/application.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { BrowserRouter as Router, Route, RouteComponentProps } from 'react-router-dom'; +import { EuiPage, EuiText } from '@elastic/eui'; +import { AppMountParameters, CoreStart } from '../../../../../../../src/core/public'; + +export interface AlertingExampleComponentParams { + basename: string; +} + +const AlertingExampleApp = (deps: AlertingExampleComponentParams) => { + const { basename } = deps; + return ( + + + ) => { + return ( + +

      View Alert {props.match.params.id}

      +
      + ); + }} + /> +
      +
      + ); +}; + +export const renderApp = ( + core: CoreStart, + deps: any, + { appBasePath, element }: AppMountParameters +) => { + ReactDOM.render(, element); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/x-pack/legacy/plugins/uptime/public/lib/helper/get_api_path.ts b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/public/index.ts similarity index 69% rename from x-pack/legacy/plugins/uptime/public/lib/helper/get_api_path.ts rename to x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/public/index.ts index 398d58f8460ba..095769cccb8fb 100644 --- a/x-pack/legacy/plugins/uptime/public/lib/helper/get_api_path.ts +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/public/index.ts @@ -4,5 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export const getApiPath = (path: string, basePath?: string) => - basePath ? `${basePath}${path}` : path; +import { AlertingFixturePlugin } from './plugin'; + +export const plugin = () => new AlertingFixturePlugin(); diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/public/plugin.ts b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/public/plugin.ts new file mode 100644 index 0000000000000..2bf353f79985c --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/public/plugin.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Plugin, CoreSetup, AppMountParameters } from 'kibana/public'; +import { PluginSetupContract as AlertingSetup } from '../../../../../../plugins/alerting/public'; +import { AlertType, SanitizedAlert } from '../../../../../../plugins/alerting/common'; + +export type Setup = void; +export type Start = void; + +export interface AlertingExamplePublicSetupDeps { + alerting: AlertingSetup; +} + +export class AlertingFixturePlugin implements Plugin { + public setup(core: CoreSetup, { alerting }: AlertingExamplePublicSetupDeps) { + alerting.registerNavigation( + 'consumer-noop', + 'test.noop', + (alert: SanitizedAlert, alertType: AlertType) => `/alert/${alert.id}` + ); + + core.application.register({ + id: 'consumer-noop', + title: 'No Op App', + async mount(params: AppMountParameters) { + const [coreStart, depsStart] = await core.getStartServices(); + const { renderApp } = await import('./application'); + return renderApp(coreStart, depsStart, params); + }, + }); + } + + public start() {} + public stop() {} +} diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/index.ts b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/index.ts new file mode 100644 index 0000000000000..2b02d9ff0f681 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializer } from 'kibana/server'; +import { AlertingFixturePlugin } from './plugin'; + +export const plugin: PluginInitializer = () => new AlertingFixturePlugin(); diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts similarity index 61% rename from x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts rename to x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts index 9069044b83ed9..d4ae6d3557c3b 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts @@ -4,21 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AlertType } from '../../../../../plugins/alerting/server'; - -// eslint-disable-next-line import/no-default-export -export default function(kibana: any) { - return new kibana.Plugin({ - require: ['alerting'], - name: 'alerts', - init(server: any) { - createNoopAlertType(server.newPlatform.setup.plugins.alerting); - createAlwaysFiringAlertType(server.newPlatform.setup.plugins.alerting); - }, - }); +import { Plugin, CoreSetup } from 'kibana/server'; +import { + PluginSetupContract as AlertingSetup, + AlertType, +} from '../../../../../../plugins/alerting/server'; + +// this plugin's dependendencies +export interface AlertingExampleDeps { + alerting: AlertingSetup; +} + +export class AlertingFixturePlugin implements Plugin { + public setup(core: CoreSetup, { alerting }: AlertingExampleDeps) { + createNoopAlertType(alerting); + createAlwaysFiringAlertType(alerting); + } + + public start() {} + public stop() {} } -function createNoopAlertType(setupContract: any) { +function createNoopAlertType(alerting: AlertingSetup) { const noopAlertType: AlertType = { id: 'test.noop', name: 'Test: Noop', @@ -26,10 +33,10 @@ function createNoopAlertType(setupContract: any) { defaultActionGroupId: 'default', async executor() {}, }; - setupContract.registerType(noopAlertType); + alerting.registerType(noopAlertType); } -function createAlwaysFiringAlertType(setupContract: any) { +function createAlwaysFiringAlertType(alerting: AlertingSetup) { // Alert types const alwaysFiringAlertType: any = { id: 'test.always-firing', @@ -54,5 +61,5 @@ function createAlwaysFiringAlertType(setupContract: any) { }; }, }; - setupContract.registerType(alwaysFiringAlertType); + alerting.registerType(alwaysFiringAlertType); } diff --git a/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts b/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts index ddd88cb888534..03f0056670311 100644 --- a/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts +++ b/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts @@ -102,5 +102,16 @@ export function AlertDetailsPageProvider({ getService }: FtrProviderContext) { const nextButton = await testSubjects.find(`pagination-button-next`); nextButton.click(); }, + async isViewInAppEnabled() { + const viewInAppButton = await testSubjects.find(`alertDetails-viewInApp`); + return (await viewInAppButton.getAttribute('disabled')) !== 'disabled'; + }, + async clickViewInAppEnabled() { + const viewInAppButton = await testSubjects.find(`alertDetails-viewInApp`); + return viewInAppButton.click(); + }, + async getNoOpAppTitle() { + return await testSubjects.getVisibleText('noop-title'); + }, }; } diff --git a/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts b/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts index 695751cf5ac49..5b506c20e029c 100644 --- a/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts @@ -22,6 +22,31 @@ export class Alerts { }); } + public async createNoOp(name: string) { + this.log.debug(`creating alert ${name}`); + + const { data: alert, status, statusText } = await this.axios.post(`/api/alert`, { + enabled: true, + name, + tags: ['foo'], + alertTypeId: 'test.noop', + consumer: 'consumer-noop', + schedule: { interval: '1m' }, + throttle: '1m', + actions: [], + params: {}, + }); + if (status !== 200) { + throw new Error( + `Expected status code of 200, received ${status} ${statusText}: ${util.inspect(alert)}` + ); + } + + this.log.debug(`created alert ${alert.id}`); + + return alert; + } + public async createAlwaysFiringWithActions( name: string, actions: Array<{ diff --git a/yarn.lock b/yarn.lock index eaee706101a7b..b18bc67413cda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4894,10 +4894,10 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@10.12.27", "@types/node@8.10.54", "@types/node@>=8.9.0", "@types/node@^10.12.27", "@types/node@^12.0.2": - version "10.12.27" - resolved "https://registry.yarnpkg.com/@types/node/-/node-10.12.27.tgz#eb3843f15d0ba0986cc7e4d734d2ee8b50709ef8" - integrity sha512-e9wgeY6gaY21on3ve0xAjgBVjGDWq/xUteK0ujsE53bUoxycMkqfnkUgMt6ffZtykZ5X12Mg3T7Pw4TRCObDKg== +"@types/node@*", "@types/node@8.10.54", "@types/node@>=10.17.17 <10.20.0", "@types/node@>=8.9.0", "@types/node@^12.0.2": + version "10.17.17" + resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.17.tgz#7a183163a9e6ff720d86502db23ba4aade5999b8" + integrity sha512-gpNnRnZP3VWzzj5k3qrpRC6Rk3H/uclhAVo1aIvwzK5p5cOrs9yEyQ8H/HBsBY0u5rrWxXEiVPQ0dEB6pkjE8Q== "@types/nodemailer@^6.2.1": version "6.2.1" @@ -6968,7 +6968,7 @@ assert@1.4.1, assert@^1.1.1: dependencies: util "0.10.3" -assertion-error@^1.0.1, assertion-error@^1.1.0: +assertion-error@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== @@ -8872,18 +8872,6 @@ chai@3.5.0: deep-eql "^0.1.3" type-detect "^1.0.0" -chai@^4.1.2: - version "4.2.0" - resolved "https://registry.yarnpkg.com/chai/-/chai-4.2.0.tgz#760aa72cf20e3795e84b12877ce0e83737aa29e5" - integrity sha512-XQU3bhBukrOsQCuwZndwGcCVQHyZi53fQ6Ys1Fym7E4olpIqqZZhhoFJoaKVvV17lWQoXYwgWN2nF5crA8J2jw== - dependencies: - assertion-error "^1.1.0" - check-error "^1.0.2" - deep-eql "^3.0.1" - get-func-name "^2.0.0" - pathval "^1.1.0" - type-detect "^4.0.5" - chalk@2.4.2, chalk@^2.3.2, chalk@^2.4.2, chalk@~2.4.1: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -9031,11 +9019,6 @@ check-disk-space@^2.1.0: resolved "https://registry.yarnpkg.com/check-disk-space/-/check-disk-space-2.1.0.tgz#2e77fe62f30d9676dc37a524ea2008f40c780295" integrity sha512-f0nx9oJF/AVF8nhSYlF1EBvMNnO+CXyLwKhPvN1943iOMI9TWhQigLZm80jAf0wzQhwKkzA8XXjyvuVUeGGcVQ== -check-error@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" - integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII= - check-more-types@2.24.0: version "2.24.0" resolved "https://registry.yarnpkg.com/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600" @@ -11246,13 +11229,6 @@ deep-eql@^0.1.3: dependencies: type-detect "0.1.1" -deep-eql@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df" - integrity sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw== - dependencies: - type-detect "^4.0.0" - deep-equal@^1.0.0, deep-equal@^1.0.1, deep-equal@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" @@ -13162,19 +13138,6 @@ exec-sh@^0.3.2: resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.2.tgz#6738de2eb7c8e671d0366aea0b0db8c6f7d7391b" integrity sha512-9sLAvzhI5nc8TpuQUh4ahMdCrWT00wPWz7j47/emR5+2qEfoZP5zzUXvx+vdx+H6ohhnsYC31iX04QLYJK8zTg== -execa@1.0.0, execa@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" - integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== - dependencies: - cross-spawn "^6.0.0" - get-stream "^4.0.0" - is-stream "^1.1.0" - npm-run-path "^2.0.0" - p-finally "^1.0.0" - signal-exit "^3.0.0" - strip-eof "^1.0.0" - execa@3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/execa/-/execa-3.3.0.tgz#7e348eef129a1937f21ecbbd53390942653522c1" @@ -13251,10 +13214,23 @@ execa@^0.7.0: signal-exit "^3.0.0" strip-eof "^1.0.0" -execa@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-3.2.0.tgz#18326b79c7ab7fbd6610fd900c1b9e95fa48f90a" - integrity sha512-kJJfVbI/lZE1PZYDI5VPxp8zXPO9rtxOkhpZ0jMKha56AI9y2gGVC6bkukStQf0ka5Rh15BA5m7cCCH4jmHqkw== +execa@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" + integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== + dependencies: + cross-spawn "^6.0.0" + get-stream "^4.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + +execa@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-4.0.0.tgz#7f37d6ec17f09e6b8fc53288611695b6d12b9daf" + integrity sha512-JbDUxwV3BoT5ZVXQrSVbAiaXhXUkIwvbhPIwZ0N13kX+5yCzOhUNdocxB/UQRuYOHRYYwAxKYwJYc0T4D12pDA== dependencies: cross-spawn "^7.0.0" get-stream "^5.0.0" @@ -13263,7 +13239,6 @@ execa@^3.2.0: merge-stream "^2.0.0" npm-run-path "^4.0.0" onetime "^5.1.0" - p-finally "^2.0.0" signal-exit "^3.0.2" strip-final-newline "^2.0.0" @@ -14700,11 +14675,6 @@ get-caller-file@^2.0.1, get-caller-file@^2.0.5: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== -get-func-name@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41" - integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE= - get-own-enumerable-property-symbols@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-2.0.1.tgz#5c4ad87f2834c4b9b4e84549dc1e0650fb38c24b" @@ -21372,20 +21342,15 @@ no-case@^2.2.0, no-case@^2.3.2: dependencies: lower-case "^1.1.1" -nock@10.0.6: - version "10.0.6" - resolved "https://registry.yarnpkg.com/nock/-/nock-10.0.6.tgz#e6d90ee7a68b8cfc2ab7f6127e7d99aa7d13d111" - integrity sha512-b47OWj1qf/LqSQYnmokNWM8D88KvUl2y7jT0567NB3ZBAZFz2bWp2PC81Xn7u8F2/vJxzkzNZybnemeFa7AZ2w== +nock@12.0.3: + version "12.0.3" + resolved "https://registry.yarnpkg.com/nock/-/nock-12.0.3.tgz#83f25076dbc4c9aa82b5cdf54c9604c7a778d1c9" + integrity sha512-QNb/j8kbFnKCiyqi9C5DD0jH/FubFGj5rt9NQFONXwQm3IPB0CULECg/eS3AU1KgZb/6SwUa4/DTRKhVxkGABw== dependencies: - chai "^4.1.2" debug "^4.1.0" - deep-equal "^1.0.0" json-stringify-safe "^5.0.1" - lodash "^4.17.5" - mkdirp "^0.5.0" - propagate "^1.0.0" - qs "^6.5.1" - semver "^5.5.0" + lodash "^4.17.13" + propagate "^2.0.0" node-dir@^0.1.10: version "0.1.17" @@ -22990,11 +22955,6 @@ path2d-polyfill@^0.4.2: resolved "https://registry.yarnpkg.com/path2d-polyfill/-/path2d-polyfill-0.4.2.tgz#594d3103838ef6b9dd4a7fd498fe9a88f1f28531" integrity sha512-JSeAnUfkFjl+Ml/EZL898ivMSbGHrOH63Mirx5EQ1ycJiryHDmj1Q7Are+uEPvenVGCUN9YbolfGfyUewJfJEg== -pathval@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0" - integrity sha1-uULm1L3mUwBe9rcTYd74cn0GReA= - pbf@^3.0.5: version "3.1.0" resolved "https://registry.yarnpkg.com/pbf/-/pbf-3.1.0.tgz#f70004badcb281761eabb1e76c92f179f08189e9" @@ -23641,10 +23601,10 @@ prop-types@15.7.2, prop-types@15.x, prop-types@^15.5.10, prop-types@^15.5.4, pro object-assign "^4.1.1" react-is "^16.8.1" -propagate@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/propagate/-/propagate-1.0.0.tgz#00c2daeedda20e87e3782b344adba1cddd6ad709" - integrity sha1-AMLa7t2iDofjeCs0Stuhzd1q1wk= +propagate@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45" + integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag== proper-lockfile@^3.2.0: version "3.2.0" @@ -29903,7 +29863,7 @@ type-detect@0.1.1: resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-0.1.1.tgz#0ba5ec2a885640e470ea4e8505971900dac58822" integrity sha1-C6XsKohWQORw6k6FBZcZANrFiCI= -type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8: +type-detect@4.0.8, type-detect@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==