diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d1cf0300b9e17..d21d6ad81a0c6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -255,6 +255,8 @@ # Security /src/core/server/csp/ @elastic/kibana-security @elastic/kibana-platform +/src/plugins/security_oss/ @elastic/kibana-security +/test/security_functional/ @elastic/kibana-security /x-pack/plugins/spaces/ @elastic/kibana-security /x-pack/plugins/encrypted_saved_objects/ @elastic/kibana-security /x-pack/plugins/security/ @elastic/kibana-security @@ -263,10 +265,8 @@ /x-pack/test/encrypted_saved_objects_api_integration/ @elastic/kibana-security /x-pack/test/functional/apps/security/ @elastic/kibana-security /x-pack/test/kerberos_api_integration/ @elastic/kibana-security -/x-pack/test/login_selector_api_integration/ @elastic/kibana-security /x-pack/test/oidc_api_integration/ @elastic/kibana-security /x-pack/test/pki_api_integration/ @elastic/kibana-security -/x-pack/test/saml_api_integration/ @elastic/kibana-security /x-pack/test/security_api_integration/ @elastic/kibana-security /x-pack/test/security_functional/ @elastic/kibana-security /x-pack/test/spaces_api_integration/ @elastic/kibana-security diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 534b1cea6242f..c366819c49dde 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,8 +9,9 @@ Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios -- [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist) -- [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server) +- [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) +- [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) +- [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers diff --git a/.i18nrc.json b/.i18nrc.json index 153a5a6cafece..7ecaa89f66604 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -37,6 +37,7 @@ "regionMap": "src/plugins/region_map", "savedObjects": "src/plugins/saved_objects", "savedObjectsManagement": "src/plugins/saved_objects_management", + "security": "src/plugins/security_oss", "server": "src/legacy/server", "statusPage": "src/legacy/core_plugins/status_page", "telemetry": [ diff --git a/docs/developer/best-practices/index.asciidoc b/docs/developer/best-practices/index.asciidoc index 13ea010d0aa96..42b379e606898 100644 --- a/docs/developer/best-practices/index.asciidoc +++ b/docs/developer/best-practices/index.asciidoc @@ -138,9 +138,12 @@ Review: * <> * <> * <> +* <> include::navigation.asciidoc[leveloffset=+1] include::stability.asciidoc[leveloffset=+1] include::security.asciidoc[leveloffset=+1] + +include::typescript.asciidoc[leveloffset=+1] diff --git a/docs/developer/best-practices/typescript.asciidoc b/docs/developer/best-practices/typescript.asciidoc new file mode 100644 index 0000000000000..3321aae3c0994 --- /dev/null +++ b/docs/developer/best-practices/typescript.asciidoc @@ -0,0 +1,64 @@ +[[typescript]] +== Typescript + +Although this is not a requirement, we encourage if all new code is developed in https://www.typescriptlang.org/[Typescript]. + +[discrete] +=== Project references +Kibana has crossed the 2m LoC mark. The current situation creates some scaling problems when the default out-of-the-box setup stops working. As a result, developers suffer from slow project compilation and IDE unresponsiveness. As a part of https://github.com/elastic/kibana/projects/63[Developer Experience project], we are migrating our tooling to use built-in TypeScript features addressing the scaling problems - https://www.typescriptlang.org/docs/handbook/project-references.html[project references] & https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#faster-subsequent-builds-with-the---incremental-flag[incremental builds] + +In a nutshell - instead of compiling the whole Kibana codebase at once, this setup enforces splitting the code base into independent projects that form a directed acyclic graph (DAG). This allows the TypeScript compiler (`tsc`) to apply several advanced optimizations: + +- Every project emits `public` interfaces in the form of `d.ts` type declarations generated by the TypeScript compiler +- These generated `d.ts` type declarations are used whenever a referenced project is imported in a depending project +- This makes it possible to determine which project needs rebuilding when the source code has changed to use a more aggressive caching strategy. + +More details are available in the https://www.typescriptlang.org/docs/handbook/project-references.html[official docs] + +[discrete] +==== Caveats +This architecture imposes several limitations to which we must comply: + +- Projects cannot have circular dependencies. Even though the Kibana platform doesn't support circular dependencies between Kibana plugins, TypeScript (and ES6 modules) does allow circular imports between files. So in theory, you may face a problem when migrating to the TS project references and you will have to resolve this circular dependency. +- A project must emit its type declaration. It's not always possible to generate a type declaration if the compiler cannot infer a type. There are two basic cases: + +1. Your plugin exports a type inferring an internal type declared in Kibana codebase. In this case, you'll have to either export an internal type or to declare an exported type explicitly. +2. Your plugin exports something inferring a type from a 3rd party library that doesn't export this type. To fix the problem, you have to declare the exported type manually. + +[discrete] +==== Prerequisites +Since `tsc` doesn't support circular project references, the migration order does matter. You can migrate your plugin only when all the plugin dependencies already have migrated. It creates a situation where commonly used plugins (such as `data` or `kibana_react`) have to migrate first. + +[discrete] +==== Implementation +- Make sure all the plugins listed as dependencies in `kibana.json` file have migrated to TS project references. +- Add `tsconfig.json` in the root folder of your plugin. +[source,json] +---- +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + // add all the folders containg files to be compiled + ], + "references": [ + { "path": "../../core/tsconfig.json" }, + // add references to other TypeScript projects your plugin dependes on + ] +} +---- +If your plugin imports a file not listed in `include`, the build will fail with the next message `File ‘…’ is not listed within the file list of project …’. Projects must list all files or use an 'include' pattern.` + +- Build you plugin `./node_modules/.bin/tsc -b src/plugins/my_plugin`. Fix errors if `tsc` cannot generate type declarations for your project. +- Add your project reference to `references` property of `tsconfig.refs.json` +- Add your plugin to `references` property and plugin folder to `exclude` property of the `tsconfig.json` it used to belong to (for example, for `src/plugins/**` it's `tsconfig.json`; for `x-pack/plugins/**` it’s `x-pack/tsconfig.json`). +- List the reference to your newly created project in all the Kibana `tsconfig.json` files that could import your project: `tsconfig.json`, `test/tsconfig.json`, `x-pack/tsconfig.json`, `x-pack/test/tsconfig.json`. And in all the plugin-specific `tsconfig.refs.json` for dependent plugins. +- You can measure how your changes affect `tsc` compiler performance with `node --max-old-space-size=4096 ./node_modules/.bin/tsc -p tsconfig.json --extendedDiagnostics --noEmit`. Compare with `master` branch. + +You can use https://github.com/elastic/kibana/pull/79446 as an example. diff --git a/docs/developer/index.asciidoc b/docs/developer/index.asciidoc index 5f032a3952173..9e349a38557f2 100644 --- a/docs/developer/index.asciidoc +++ b/docs/developer/index.asciidoc @@ -1,7 +1,6 @@ [[development]] = Developer guide -[partintro] -- Contributing to {kib} can be daunting at first, but it doesn't have to be. The following sections should get you up and running in no time. If you have any problems, file an issue in the https://github.com/elastic/kibana/issues[Kibana repo]. diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 67b7aa8e6a011..d6d938bfc97de 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -155,6 +155,11 @@ It also provides a stateful version of it on the start contract. |WARNING: Missing README. +|{kib-repo}blob/{branch}/src/plugins/security_oss/README.md[securityOss] +|securityOss is responsible for educating users about Elastic's free security features, +so they can properly protect the data within their clusters. + + |{kib-repo}blob/{branch}/src/plugins/share/README.md[share] |Replaces the legacy ui/share module for registering share context menus. @@ -497,7 +502,7 @@ using the CURL scripts in the scripts folder. |WARNING: Missing README. -|{kib-repo}blob/{branch}/x-pack/plugins/triggers_actions_ui/README.md[triggers_actions_ui] +|{kib-repo}blob/{branch}/x-pack/plugins/triggers_actions_ui/README.md[triggersActionsUi] |The Kibana alerts and actions UI plugin provides a user interface for managing alerts and actions. As a developer you can reuse and extend built-in alerts and actions UI functionality: diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.action_visualize_lens_field.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.action_visualize_lens_field.md new file mode 100644 index 0000000000000..b00618f510510 --- /dev/null +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.action_visualize_lens_field.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-ui\_actions-public](./kibana-plugin-plugins-ui_actions-public.md) > [ACTION\_VISUALIZE\_LENS\_FIELD](./kibana-plugin-plugins-ui_actions-public.action_visualize_lens_field.md) + +## ACTION\_VISUALIZE\_LENS\_FIELD variable + +Signature: + +```typescript +ACTION_VISUALIZE_LENS_FIELD = "ACTION_VISUALIZE_LENS_FIELD" +``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.actioncontextmapping.action_visualize_lens_field.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.actioncontextmapping.action_visualize_lens_field.md new file mode 100644 index 0000000000000..96370a07806d3 --- /dev/null +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.actioncontextmapping.action_visualize_lens_field.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-ui\_actions-public](./kibana-plugin-plugins-ui_actions-public.md) > [ActionContextMapping](./kibana-plugin-plugins-ui_actions-public.actioncontextmapping.md) > [ACTION\_VISUALIZE\_LENS\_FIELD](./kibana-plugin-plugins-ui_actions-public.actioncontextmapping.action_visualize_lens_field.md) + +## ActionContextMapping.ACTION\_VISUALIZE\_LENS\_FIELD property + +Signature: + +```typescript +[ACTION_VISUALIZE_LENS_FIELD]: VisualizeFieldContext; +``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.actioncontextmapping.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.actioncontextmapping.md index 740e6ac63bfba..f83632dea0aa9 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.actioncontextmapping.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.actioncontextmapping.md @@ -17,4 +17,5 @@ export interface ActionContextMapping | [""](./kibana-plugin-plugins-ui_actions-public.actioncontextmapping.__.md) | BaseContext | | | [ACTION\_VISUALIZE\_FIELD](./kibana-plugin-plugins-ui_actions-public.actioncontextmapping.action_visualize_field.md) | VisualizeFieldContext | | | [ACTION\_VISUALIZE\_GEO\_FIELD](./kibana-plugin-plugins-ui_actions-public.actioncontextmapping.action_visualize_geo_field.md) | VisualizeFieldContext | | +| [ACTION\_VISUALIZE\_LENS\_FIELD](./kibana-plugin-plugins-ui_actions-public.actioncontextmapping.action_visualize_lens_field.md) | VisualizeFieldContext | | diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.md index ce4e8c17b9dff..5e10de4e0f2a5 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.md @@ -39,6 +39,7 @@ | --- | --- | | [ACTION\_VISUALIZE\_FIELD](./kibana-plugin-plugins-ui_actions-public.action_visualize_field.md) | | | [ACTION\_VISUALIZE\_GEO\_FIELD](./kibana-plugin-plugins-ui_actions-public.action_visualize_geo_field.md) | | +| [ACTION\_VISUALIZE\_LENS\_FIELD](./kibana-plugin-plugins-ui_actions-public.action_visualize_lens_field.md) | | | [APPLY\_FILTER\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.apply_filter_trigger.md) | | | [applyFilterTrigger](./kibana-plugin-plugins-ui_actions-public.applyfiltertrigger.md) | | | [SELECT\_RANGE\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.select_range_trigger.md) | | diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.addtriggeraction.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.addtriggeraction.md index 1782eef92442c..ba9060e01e57d 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.addtriggeraction.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.addtriggeraction.md @@ -11,5 +11,5 @@ Signature: ```typescript -readonly addTriggerAction: (triggerId: T, action: ActionDefinition | Action) => void; +readonly addTriggerAction: (triggerId: T, action: ActionDefinition | Action) => void; ``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.getaction.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.getaction.md index 0c4584a07b569..3e433809f9471 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.getaction.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.getaction.md @@ -7,5 +7,5 @@ Signature: ```typescript -readonly getAction: >(id: string) => Action, "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK">; +readonly getAction: >(id: string) => Action, "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK">; ``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggeractions.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggeractions.md index c65a9a992da2e..83afcab29689d 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggeractions.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggeractions.md @@ -7,5 +7,5 @@ Signature: ```typescript -readonly getTriggerActions: (triggerId: T) => Action[]; +readonly getTriggerActions: (triggerId: T) => Action[]; ``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggercompatibleactions.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggercompatibleactions.md index 751abe332b08e..879f5a3d8628a 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggercompatibleactions.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggercompatibleactions.md @@ -7,5 +7,5 @@ Signature: ```typescript -readonly getTriggerCompatibleActions: (triggerId: T, context: TriggerContextMapping[T]) => Promise[]>; +readonly getTriggerCompatibleActions: (triggerId: T, context: TriggerContextMapping[T]) => Promise[]>; ``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.md index c372eb113d682..7fade7c4c841b 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.md @@ -21,19 +21,19 @@ export declare class UiActionsService | Property | Modifiers | Type | Description | | --- | --- | --- | --- | | [actions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.actions.md) | | ActionRegistry | | -| [addTriggerAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.addtriggeraction.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, action: ActionDefinition<TriggerContextMapping[T]> | Action<TriggerContextMapping[T], "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK">) => void | addTriggerAction is similar to attachAction as it attaches action to a trigger, but it also registers the action, if it has not been registered, yet.addTriggerAction also infers better typing of the action argument. | +| [addTriggerAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.addtriggeraction.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, action: ActionDefinition<TriggerContextMapping[T]> | Action<TriggerContextMapping[T], "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK">) => void | addTriggerAction is similar to attachAction as it attaches action to a trigger, but it also registers the action, if it has not been registered, yet.addTriggerAction also infers better typing of the action argument. | | [attachAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.attachaction.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, actionId: string) => void | | | [clear](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.clear.md) | | () => void | Removes all registered triggers and actions. | | [detachAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.detachaction.md) | | (triggerId: TriggerId, actionId: string) => void | | | [executeTriggerActions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.executetriggeractions.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, context: TriggerContext<T>) => Promise<void> | | | [executionService](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.executionservice.md) | | UiActionsExecutionService | | | [fork](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.fork.md) | | () => UiActionsService | "Fork" a separate instance of UiActionsService that inherits all existing triggers and actions, but going forward all new triggers and actions added to this instance of UiActionsService are only available within this instance. | -| [getAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.getaction.md) | | <T extends ActionDefinition<{}>>(id: string) => Action<ActionContext<T>, "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK"> | | +| [getAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.getaction.md) | | <T extends ActionDefinition<{}>>(id: string) => Action<ActionContext<T>, "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK"> | | | [getTrigger](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettrigger.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T) => TriggerContract<T> | | -| [getTriggerActions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggeractions.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T) => Action<TriggerContextMapping[T], "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK">[] | | -| [getTriggerCompatibleActions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggercompatibleactions.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, context: TriggerContextMapping[T]) => Promise<Action<TriggerContextMapping[T], "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK">[]> | | +| [getTriggerActions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggeractions.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T) => Action<TriggerContextMapping[T], "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK">[] | | +| [getTriggerCompatibleActions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggercompatibleactions.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, context: TriggerContextMapping[T]) => Promise<Action<TriggerContextMapping[T], "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK">[]> | | | [hasAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.hasaction.md) | | (actionId: string) => boolean | | -| [registerAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.registeraction.md) | | <A extends ActionDefinition<{}>>(definition: A) => Action<ActionContext<A>, "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK"> | | +| [registerAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.registeraction.md) | | <A extends ActionDefinition<{}>>(definition: A) => Action<ActionContext<A>, "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK"> | | | [registerTrigger](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.registertrigger.md) | | (trigger: Trigger) => void | | | [triggers](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.triggers.md) | | TriggerRegistry | | | [triggerToActions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.triggertoactions.md) | | TriggerToActionsRegistry | | diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.registeraction.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.registeraction.md index c71e86fc09dc7..eeda7b503037d 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.registeraction.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.registeraction.md @@ -7,5 +7,5 @@ Signature: ```typescript -readonly registerAction: >(definition: A) => Action, "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK">; +readonly registerAction: >(definition: A) => Action, "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK">; ``` diff --git a/docs/user/dashboard/vega-reference.asciidoc b/docs/user/dashboard/vega-reference.asciidoc index 0bc77ab0a417e..6fd30690b988e 100644 --- a/docs/user/dashboard/vega-reference.asciidoc +++ b/docs/user/dashboard/vega-reference.asciidoc @@ -14,7 +14,7 @@ For additional *Vega* and *Vega-Lite* information, refer to the reference sectio * Automatic sizing * Default theme to match {kib} * Writing {es} queries using the time range and filters from dashboards -* Using the Elastic Map Service in Vega maps +* experimental[] Using the Elastic Map Service in Vega maps * Additional tooltip styling * Advanced setting to enable URL loading from any domain * Limited debugging support using the browser dev tools diff --git a/examples/embeddable_examples/kibana.json b/examples/embeddable_examples/kibana.json index 0ac40ae1889de..223b8c55a5fde 100644 --- a/examples/embeddable_examples/kibana.json +++ b/examples/embeddable_examples/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["embeddable", "uiActions", "dashboard"], + "requiredPlugins": ["embeddable", "uiActions", "dashboard", "savedObjects"], "optionalPlugins": [], "extraPublicDirs": ["public/todo", "public/hello_world", "public/todo/todo_ref_embeddable"], "requiredBundles": ["kibanaReact"] diff --git a/examples/embeddable_examples/public/book/book_embeddable_factory.tsx b/examples/embeddable_examples/public/book/book_embeddable_factory.tsx index 292261ee16c59..a535552282150 100644 --- a/examples/embeddable_examples/public/book/book_embeddable_factory.tsx +++ b/examples/embeddable_examples/public/book/book_embeddable_factory.tsx @@ -33,12 +33,19 @@ import { BookEmbeddableOutput, } from './book_embeddable'; import { CreateEditBookComponent } from './create_edit_book_component'; -import { OverlayStart } from '../../../../src/core/public'; +import { + OverlayStart, + SavedObjectsClientContract, + SimpleSavedObject, +} from '../../../../src/core/public'; import { DashboardStart, AttributeService } from '../../../../src/plugins/dashboard/public'; +import { checkForDuplicateTitle, OnSaveProps } from '../../../../src/plugins/saved_objects/public'; interface StartServices { getAttributeService: DashboardStart['getAttributeService']; openModal: OverlayStart['openModal']; + savedObjectsClient: SavedObjectsClientContract; + overlays: OverlayStart; } export type BookEmbeddableFactory = EmbeddableFactory< @@ -117,11 +124,55 @@ export class BookEmbeddableFactoryDefinition }); } + private async unwrapMethod(savedObjectId: string): Promise { + const { savedObjectsClient } = await this.getStartServices(); + const savedObject: SimpleSavedObject = await savedObjectsClient.get< + BookSavedObjectAttributes + >(this.type, savedObjectId); + return { ...savedObject.attributes }; + } + + private async saveMethod( + type: string, + attributes: BookSavedObjectAttributes, + savedObjectId?: string + ) { + const { savedObjectsClient } = await this.getStartServices(); + if (savedObjectId) { + return savedObjectsClient.update(type, savedObjectId, attributes); + } + return savedObjectsClient.create(type, attributes); + } + + private async checkForDuplicateTitleMethod(props: OnSaveProps): Promise { + const start = await this.getStartServices(); + const { savedObjectsClient, overlays } = start; + return checkForDuplicateTitle( + { + title: props.newTitle, + copyOnSave: false, + lastSavedTitle: '', + getEsType: () => this.type, + getDisplayName: this.getDisplayName || (() => this.type), + }, + props.isTitleDuplicateConfirmed, + props.onTitleDuplicate, + { + savedObjectsClient, + overlays, + } + ); + } + private async getAttributeService() { if (!this.attributeService) { - this.attributeService = await (await this.getStartServices()).getAttributeService< + this.attributeService = (await this.getStartServices()).getAttributeService< BookSavedObjectAttributes - >(this.type); + >(this.type, { + saveMethod: this.saveMethod.bind(this), + unwrapMethod: this.unwrapMethod.bind(this), + checkForDuplicateTitle: this.checkForDuplicateTitleMethod.bind(this), + }); } return this.attributeService!; } diff --git a/examples/embeddable_examples/public/book/edit_book_action.tsx b/examples/embeddable_examples/public/book/edit_book_action.tsx index 3541ace1e5e7e..77035b6887734 100644 --- a/examples/embeddable_examples/public/book/edit_book_action.tsx +++ b/examples/embeddable_examples/public/book/edit_book_action.tsx @@ -31,10 +31,13 @@ import { } from './book_embeddable'; import { CreateEditBookComponent } from './create_edit_book_component'; import { DashboardStart } from '../../../../src/plugins/dashboard/public'; +import { OnSaveProps } from '../../../../src/plugins/saved_objects/public'; +import { SavedObjectsClientContract } from '../../../../src/core/target/types/public/saved_objects'; interface StartServices { openModal: OverlayStart['openModal']; getAttributeService: DashboardStart['getAttributeService']; + savedObjectsClient: SavedObjectsClientContract; } interface ActionContext { @@ -56,8 +59,24 @@ export const createEditBookAction = (getStartServices: () => Promise { - const { openModal, getAttributeService } = await getStartServices(); - const attributeService = getAttributeService(BOOK_SAVED_OBJECT); + const { openModal, getAttributeService, savedObjectsClient } = await getStartServices(); + const attributeService = getAttributeService(BOOK_SAVED_OBJECT, { + saveMethod: async ( + type: string, + attributes: BookSavedObjectAttributes, + savedObjectId?: string + ) => { + if (savedObjectId) { + return savedObjectsClient.update(type, savedObjectId, attributes); + } + return savedObjectsClient.create(type, attributes); + }, + checkForDuplicateTitle: (props: OnSaveProps) => { + return new Promise(() => { + return true; + }); + }, + }); const onSave = async (attributes: BookSavedObjectAttributes, useRefType: boolean) => { const newInput = await attributeService.wrapAttributes( attributes, diff --git a/examples/embeddable_examples/public/plugin.ts b/examples/embeddable_examples/public/plugin.ts index 0c6ed1eb3be48..6d1b119e741bd 100644 --- a/examples/embeddable_examples/public/plugin.ts +++ b/examples/embeddable_examples/public/plugin.ts @@ -22,7 +22,7 @@ import { EmbeddableStart, CONTEXT_MENU_TRIGGER, } from '../../../src/plugins/embeddable/public'; -import { Plugin, CoreSetup, CoreStart } from '../../../src/core/public'; +import { Plugin, CoreSetup, CoreStart, SavedObjectsClient } from '../../../src/core/public'; import { HelloWorldEmbeddableFactory, HELLO_WORLD_EMBEDDABLE, @@ -76,6 +76,7 @@ export interface EmbeddableExamplesSetupDependencies { export interface EmbeddableExamplesStartDependencies { embeddable: EmbeddableStart; dashboard: DashboardStart; + savedObjectsClient: SavedObjectsClient; } interface ExampleEmbeddableFactories { @@ -158,12 +159,15 @@ export class EmbeddableExamplesPlugin new BookEmbeddableFactoryDefinition(async () => ({ getAttributeService: (await core.getStartServices())[1].dashboard.getAttributeService, openModal: (await core.getStartServices())[0].overlays.openModal, + savedObjectsClient: (await core.getStartServices())[0].savedObjects.client, + overlays: (await core.getStartServices())[0].overlays, })) ); const editBookAction = createEditBookAction(async () => ({ getAttributeService: (await core.getStartServices())[1].dashboard.getAttributeService, openModal: (await core.getStartServices())[0].overlays.openModal, + savedObjectsClient: (await core.getStartServices())[0].savedObjects.client, })); deps.uiActions.registerAction(editBookAction); deps.uiActions.attachAction(CONTEXT_MENU_TRIGGER, editBookAction.id); diff --git a/package.json b/package.json index f53edb7815106..a9721f05f997b 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "kbn:watch": "node scripts/kibana --dev --logging.json=false", "build:types": "rm -rf ./target/types && tsc --p tsconfig.types.json", "docs:acceptApiChanges": "node --max-old-space-size=6144 scripts/check_published_api_changes.js --accept", - "kbn:bootstrap": "node scripts/build_ts_refs --project tsconfig.refs.json && node scripts/register_git_hook", + "kbn:bootstrap": "node scripts/build_ts_refs && node scripts/register_git_hook", "spec_to_console": "node scripts/spec_to_console", "backport-skip-ci": "backport --prDescription \"[skip-ci]\"", "storybook": "node scripts/storybook", @@ -117,7 +117,7 @@ "dependencies": { "@elastic/datemath": "5.0.3", "@elastic/elasticsearch": "7.9.1", - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "@elastic/good": "8.1.1-kibana2", "@elastic/numeral": "^2.5.0", "@elastic/request-crypto": "1.1.4", diff --git a/packages/kbn-apm-config-loader/src/config.test.ts b/packages/kbn-apm-config-loader/src/config.test.ts index fe6247673e312..83438215716ac 100644 --- a/packages/kbn-apm-config-loader/src/config.test.ts +++ b/packages/kbn-apm-config-loader/src/config.test.ts @@ -42,12 +42,13 @@ describe('ApmConfiguration', () => { resetAllMocks(); }); - it('sets the correct service name', () => { + it('sets the correct service name and version', () => { packageMock.raw = { version: '9.2.1', }; const config = new ApmConfiguration(mockedRootDir, {}, false); - expect(config.getConfig('myservice').serviceName).toBe('myservice-9_2_1'); + expect(config.getConfig('myservice').serviceName).toBe('myservice'); + expect(config.getConfig('myservice').serviceVersion).toBe('9.2.1'); }); it('sets the git revision from `git rev-parse` command in non distribution mode', () => { diff --git a/packages/kbn-apm-config-loader/src/config.ts b/packages/kbn-apm-config-loader/src/config.ts index aab82c6c06a58..897e7fd7ca610 100644 --- a/packages/kbn-apm-config-loader/src/config.ts +++ b/packages/kbn-apm-config-loader/src/config.ts @@ -30,8 +30,15 @@ const getDefaultConfig = (isDistributable: boolean): ApmAgentConfig => { return { active: false, globalLabels: {}, + // Do not use a centralized controlled config + centralConfig: false, + // Capture all exceptions that are not caught + logUncaughtExceptions: true, + // Can be performance intensive, disabling by default + breakdownMetrics: false, }; } + return { active: false, serverUrl: 'https://f1542b814f674090afd914960583265f.apm.us-central1.gcp.cloud.es.io:443', @@ -60,14 +67,14 @@ export class ApmConfiguration { ) { // eslint-disable-next-line @typescript-eslint/no-var-requires const { version, build } = require(join(this.rootDir, 'package.json')); - this.kibanaVersion = version.replace(/\./g, '_'); + this.kibanaVersion = version; this.pkgBuild = build; } public getConfig(serviceName: string): ApmAgentConfig { return { ...this.getBaseConfig(), - serviceName: `${serviceName}-${this.kibanaVersion}`, + serviceName, }; } @@ -76,7 +83,8 @@ export class ApmConfiguration { const apmConfig = merge( getDefaultConfig(this.isDistributable), this.getConfigFromKibanaConfig(), - this.getDevConfig() + this.getDevConfig(), + this.getDistConfig() ); const rev = this.getGitRev(); @@ -88,6 +96,8 @@ export class ApmConfiguration { if (uuid) { apmConfig.globalLabels.kibana_uuid = uuid; } + + apmConfig.serviceVersion = this.kibanaVersion; this.baseConfig = apmConfig; } @@ -123,6 +133,19 @@ export class ApmConfiguration { } } + /** Config keys that cannot be overridden in production builds */ + private getDistConfig(): ApmAgentConfig { + if (!this.isDistributable) { + return {}; + } + + return { + // Headers & body may contain sensitive info + captureHeaders: false, + captureBody: 'off', + }; + } + private getGitRev() { if (this.isDistributable) { return this.pkgBuild.sha; diff --git a/packages/kbn-i18n/src/react/index.tsx b/packages/kbn-i18n/src/react/index.tsx index ff30934aad6d1..b438c44598b75 100644 --- a/packages/kbn-i18n/src/react/index.tsx +++ b/packages/kbn-i18n/src/react/index.tsx @@ -17,8 +17,10 @@ * under the License. */ -import { InjectedIntl as _InjectedIntl } from 'react-intl'; +import { InjectedIntl as _InjectedIntl, InjectedIntlProps as _InjectedIntlProps } from 'react-intl'; + export type InjectedIntl = _InjectedIntl; +export type InjectedIntlProps = _InjectedIntlProps; export { intlShape, @@ -29,6 +31,8 @@ export { FormattedPlural, FormattedMessage, FormattedHTMLMessage, + // Only used for testing. Use I18nProvider otherwise. + IntlProvider as __IntlProvider, } from 'react-intl'; export { I18nProvider } from './provider'; diff --git a/packages/kbn-ui-framework/package.json b/packages/kbn-ui-framework/package.json index 639d4e17d0e71..21d25311420ca 100644 --- a/packages/kbn-ui-framework/package.json +++ b/packages/kbn-ui-framework/package.json @@ -31,7 +31,7 @@ }, "devDependencies": { "@babel/core": "^7.11.6", - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "@kbn/babel-preset": "1.0.0", "@kbn/optimizer": "1.0.0", "babel-loader": "^8.0.6", diff --git a/packages/kbn-ui-shared-deps/entry.js b/packages/kbn-ui-shared-deps/entry.js index 69344174a2dc6..966fb65406ac6 100644 --- a/packages/kbn-ui-shared-deps/entry.js +++ b/packages/kbn-ui-shared-deps/entry.js @@ -35,7 +35,6 @@ export const MonacoBarePluginApi = require('@kbn/monaco').BarePluginApi; export const React = require('react'); export const ReactDom = require('react-dom'); export const ReactDomServer = require('react-dom/server'); -export const ReactIntl = require('react-intl'); export const ReactRouter = require('react-router'); // eslint-disable-line export const ReactRouterDom = require('react-router-dom'); export const StyledComponents = require('styled-components'); diff --git a/packages/kbn-ui-shared-deps/index.js b/packages/kbn-ui-shared-deps/index.js index a5d6954fd5cc0..a403ae63a8f70 100644 --- a/packages/kbn-ui-shared-deps/index.js +++ b/packages/kbn-ui-shared-deps/index.js @@ -39,7 +39,6 @@ exports.externals = { react: '__kbnSharedDeps__.React', 'react-dom': '__kbnSharedDeps__.ReactDom', 'react-dom/server': '__kbnSharedDeps__.ReactDomServer', - 'react-intl': '__kbnSharedDeps__.ReactIntl', 'react-router': '__kbnSharedDeps__.ReactRouter', 'react-router-dom': '__kbnSharedDeps__.ReactRouterDom', 'styled-components': '__kbnSharedDeps__.StyledComponents', diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index a9c817df9d107..059c3bf744ae0 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@elastic/charts": "23.2.0", - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "@elastic/numeral": "^2.5.0", "@kbn/i18n": "1.0.0", "@kbn/monaco": "1.0.0", @@ -26,7 +26,6 @@ "moment-timezone": "^0.5.27", "react": "^16.12.0", "react-dom": "^16.12.0", - "react-intl": "^2.8.0", "react-is": "^16.8.0", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", @@ -39,6 +38,7 @@ "devDependencies": { "@kbn/babel-preset": "1.0.0", "@kbn/dev-utils": "1.0.0", + "babel-plugin-transform-react-remove-prop-types": "^0.4.24", "css-loader": "^3.4.2", "del": "^5.1.0", "loader-utils": "^1.2.3", diff --git a/packages/kbn-ui-shared-deps/webpack.config.js b/packages/kbn-ui-shared-deps/webpack.config.js index b7d4e929ac93f..986ddba209270 100644 --- a/packages/kbn-ui-shared-deps/webpack.config.js +++ b/packages/kbn-ui-shared-deps/webpack.config.js @@ -77,6 +77,25 @@ exports.getWebpackConfig = ({ dev = false } = {}) => ({ }, ], }, + { + test: !dev ? /[\\\/]@elastic[\\\/]eui[\\\/].*\.js$/ : () => false, + use: [ + { + loader: 'babel-loader', + options: { + plugins: [ + [ + require.resolve('babel-plugin-transform-react-remove-prop-types'), + { + mode: 'remove', + removeImport: true, + }, + ], + ], + }, + }, + ], + }, ], }, diff --git a/rfcs/images/background_sessions_client.png b/rfcs/images/background_sessions_client.png new file mode 100644 index 0000000000000..46cb90c93aa32 Binary files /dev/null and b/rfcs/images/background_sessions_client.png differ diff --git a/rfcs/images/background_sessions_server.png b/rfcs/images/background_sessions_server.png new file mode 100644 index 0000000000000..593db3156f879 Binary files /dev/null and b/rfcs/images/background_sessions_server.png differ diff --git a/rfcs/text/0013_background_sessions.md b/rfcs/text/0013_background_sessions.md new file mode 100644 index 0000000000000..056149e770448 --- /dev/null +++ b/rfcs/text/0013_background_sessions.md @@ -0,0 +1,489 @@ +- Start Date: (fill me in with today's date, YYYY-MM-DD) +- RFC PR: (leave this empty) +- Kibana Issue: (leave this empty) + +- Architecture diagram: https://app.lucidchart.com/documents/edit/cf35b512-616a-4734-bc72-43dde70dbd44/0_0 +- Mockups: https://www.figma.com/proto/FD2M7MUpLScJKOyYjfbmev/ES-%2F-Query-Management-v4?node-id=440%3A1&viewport=984%2C-99%2C0.09413627535104752&scaling=scale-down +- Old issue: https://github.com/elastic/kibana/issues/53335 +- Background search roadmap: https://github.com/elastic/kibana/issues/61738 +- POC: https://github.com/elastic/kibana/pull/64641 + +# Summary + +Background Sessions will enable Kibana applications and solutions to start a group of related search requests (such as those coming from a single load of a dashboard or SIEM timeline), navigate away or close the browser, then retrieve the results when they have completed. + +# Basic example + +At its core, background sessions are enabled via several new APIs, that: +- Start a session, associating multiple search requests with a single entity +- Store the session (and continue search requests in the background) +- Restore the background session + +```ts +const searchService = dataPluginStart.search; + +if (appState.sessionId) { + // If we are restoring a session, set the session ID in the search service + searchService.session.restore(sessionId); +} else { + // Otherwise, start a new background session to associate our search requests + appState.sessionId = searchService.session.start(); +} + +// Search, passing in the generated session ID. +// If this is a new session, the `search_interceptor` will associate and keep track of the async search ID with the session ID. +// If this is a restored session, the server will immediately return saved results. +// In the case where there is no saved result for a given request, or if the results have expired, `search` will throw an error with a meaningful error code. +const request = buildKibanaRequest(...); +request.sessionId = searchService.session.get(); +const response$ = await searchService.search(request); + +// Calling `session.store()`, creates a saved object for this session, allowing the user to navigate away. +// The session object will be saved with all async search IDs that were executed so far. +// Any follow up searches executed with this sessionId will be saved into this object as well. +const backgroundSession = await searchService.session.store(); +``` + +# Motivation + +Kibana is great at providing fast results from large sets of "hot" data. However, there is an increasing number of use cases where users want to analyze large amounts of "colder" data (such as year-over-year reports, historical or audit data, batch queries, etc.). + +For these cases, users run into two limitations: + 1. Kibana has a default timeout of 30s per search. This is controlled by the `elasticsearch.requestTimeout` setting (originally intended to protect clusters from unintentional overload by a single query). + 2. Kibana cancels queries upon navigating away from an application, once again, as means of protecting clusters and reducing unnecessary load. + +In 7.7, with the introduction of the `_async_search` API in Elasticsearch, we provided Kibana users a way to bypass the timeout, but users still need to remain on-screen for the entire duration of the search requests. + +The primary motivation of this RFC is to enable users to do the following without needing to keep Kibana open, or while moving onto other work inside Kibana: + +- Run long search requests (beyond 30 seconds) +- View their status (complete/incomplete) +- Cancel incomplete search requests +- Retrieve completed search request results + +# Detailed design + +Because a single view (such as a dashboard with multiple visualizations) can initiate multiple search requests, we need a way to associate the search requests together in a single entity. + +We call this entity a `session`, and when a user decides that they want to continue running the search requests while moving onto other work, we will create a saved object corresponding with that specific `session`, persisting the *sessionId* along with a mapping of each *request's hash* to the *async ID* returned by Elasticsearch. + +## High Level Flow Charts + +### Client side search + +This diagram matches any case where `data.search` is called from the front end: + +![image](../images/background_sessions_client.png) + +### Server side search + +This case happens if the server is the one to invoke the `data.search` endpoint, for example with TSVB. + +![image](../images/background_sessions_server.png) + +## Data and Saved Objects + +### Background Session Status + +```ts +export enum BackgroundSessionStatus { + Running, // The session has at least one running search ID associated with it. + Done, // All search IDs associated with this session have completed. + Error, // At least one search ID associated with this session had an error. + Expired, // The session has expired. Associated search ID data was cleared from ES. +} +``` + +### Saved Object Structure + +The saved object created for a background session will be scoped to a single space, and will be a `hidden` saved object +(so that it doesn't show in the management listings). We will provide a separate interface for users to manage their own +background sessions (which will use the `list`, `expire`, and `extend` methods described below, which will be restricted +per-user). + +```ts +interface BackgroundSessionAttributes extends SavedObjectAttributes { + sessionId: string; + userId: string; // Something unique to the user who generated this session, like username/realm-name/realm-type + status: BackgroundSessionStatus; + name: string; + creation: Date; + expiration: Date; + idMapping: { [key: string]: string }; + url: string; // A URL relative to the Kibana root to retrieve the results of a completed background session (and/or to return to an incomplete view) + metadata: { [key: string]: any } // Any data the specific application requires to restore a background session view +} +``` + +The URL that is provided will need to be generated by the specific application implementing background sessions. We +recommend using the URL generator to ensure that URLs are backwards-compatible since background sessions may exist as +long as a user continues to extend the expiration. + +## Frontend Services + +Most sessions will probably not be saved. Therefore, to avoid creating unnecessary saved objects, the browser will keep track of requests and their respective search IDs, until the user chooses to store the session. Once a session is stored, any additional searches will be immediately saved on the server side. + +### New Session Service + +We will expose a new frontend `session` service on the `data` plugin `search` service. + +The service will expose the following APIs: + +```ts +interface ISessionService { + /** + * Returns the current session ID + */ + getActiveSessionId: () => string; + + /** + * Sets the current session + * @param sessionId: The ID of the session to set + * @param isRestored: Whether or not the session is being restored + */ + setActiveSessionId: (sessionId: string, isRestored: boolean) => void; + + /** + * Start a new session, by generating a new session ID (calls `setActiveSessionId` internally) + */ + start: () => string; + + /** + * Store a session, alongside with any tracked searchIds. + * @param sessionId Session ID to store. Probably retrieved from `sessionService.get()`. + * @param name A display name for the session. + * @param url TODO: is the URL provided here? How? + * @returns The stored `BackgroundSessionAttributes` object + * @throws Throws an error in OSS. + */ + store: (sessionId: string, name: string, url: string) => Promise + + /** + * @returns Is the current session stored (i.e. is there a saved object corresponding with this sessionId). + */ + isStored: () => boolean; + + /** + * @returns Is the current session a restored session + */ + isRestored: () => boolean; + + /** + * Mark a session and and all associated searchIds as expired. + * Cancels active requests, if there are any. + * @param sessionId Session ID to store. Probably retrieved from `sessionService.get()`. + * @returns success status + * @throws Throws an error in OSS. + */ + expire: (sessionId: string) => Promise + + /** + * Extend a session and all associated searchIds. + * @param sessionId Session ID to extend. Probably retrieved from `sessionService.get()`. + * @param extendBy Time to extend by, can be a relative or absolute string. + * @returns success status + * @throws Throws an error in OSS. + */ + extend: (sessionId: string, extendBy: string)=> Promise + + /** + * @param sessionId the ID of the session to retrieve the saved object. + * @returns a filtered list of BackgroundSessionAttributes objects. + * @throws Throws an error in OSS. + */ + get: (sessionId: string) => Promise + + /** + * @param options The options to query for specific background session saved objects. + * @returns a filtered list of BackgroundSessionAttributes objects. + * @throws Throws an error in OSS. + */ + list: (options: SavedObjectsFindOptions) => Promise + + /** + * Clears out any session info as well as the current session. Called internally whenever the user navigates + * between applications. + * @internal + */ + clear: () => void; + + /** + * Track a search ID of a sessionId, if it exists. Called internally by the search service. + * @param sessionId + * @param request + * @param searchId + * @internal + */ + trackSearchId: ( + sessionId: string, + request: IKibanaSearchRequest, + searchId: string, + ) => Promise +} +``` + +## Backend Services and Routes + +The server side's feature implementation builds on how Elasticsearch's `async_search` endpoint works. When making an +initial new request to Elasticsearch, it returns a search ID that can be later used to retrieve the results. + +The server will then store that `request`, `sessionId`, and `searchId` in a mapping in memory, and periodically query +for a saved object corresponding with that session. If the saved object is found, it will update the saved object to +include this `request`/`searchId` combination, and remove it from memory. If, after a period of time (5 minutes?) the +saved object has not been found, we will stop polling for that `sessionId` and remove the `request`/`searchId` from +memory. + +When the server receives a search request that has a `sessionId` and is marked as a `restore` request, the server will +attempt to find the correct id within the saved object, and use it to retrieve the results previously saved. + +### New Session Service + +```ts +interface ISessionService { + /** + * Adds a search ID to a Background Session, if it exists. + * Also extends the expiration of the search ID to match the session's expiration. + * @param request + * @param sessionId + * @param searchId + * @returns true if id was added, false if Background Session doesn't exist or if there was an error while updating. + * @throws an error if `searchId` already exists in the mapping for this `sessionId` + */ + trackSearchId: ( + request: KibanaRequest, + sessionId: string, + searchId: string, + ) => Promise + + /** + * Get a Background Session object. + * @param request + * @param sessionId + * @returns the Background Session object if exists, or undefined. + */ + get: async ( + request: KibanaRequest, + sessionId: string + ) => Promise + + /** + * Get a searchId from a Background Session object. + * @param request + * @param sessionId + * @returns the searchID if exists on the Background Session, or undefined. + */ + getSearchId: async ( + request: KibanaRequest, + sessionId: string + ) => Promise + + /** + * Store a session. + * @param request + * @param sessionId Session ID to store. Probably retrieved from `sessionService.get()`. + * @param searchIdMap A mapping of hashed requests mapped to the corresponding searchId. + * @param url TODO: is the URL provided here? How? + * @returns The stored `BackgroundSessionAttributes` object + * @throws Throws an error in OSS. + * @internal (Consumers should use searchInterceptor.sendToBackground()) + */ + store: ( + request: KibanaRequest, + sessionId: string, + name: string, + url: string, + searchIdMapping?: Record + ) => Promise + + /** + * Mark a session as and all associated searchIds as expired. + * @param request + * @param sessionId + * @returns success status + * @throws Throws an error in OSS. + */ + expire: async ( + request: KibanaRequest, + sessionId: string + ) => Promise + + /** + * Extend a session and all associated searchIds. + * @param request + * @param sessionId + * @param extendBy Time to extend by, can be a relative or absolute string. + * @returns success status + * @throws Throws an error in OSS. + */ + extend: async ( + request: KibanaRequest, + sessionId: string, + extendBy: string, + ) => Promise + + /** + * Get a list of background session objects. + * @param request + * @param sessionId + * @returns success status + * @throws Throws an error in OSS. + */ + list: async ( + request: KibanaRequest, + ) => Promise + + /** + * Update the status of a given session + * @param request + * @param sessionId + * @param status + * @returns success status + * @throws Throws an error in OSS. + */ + updateStatus: async ( + request: KibanaRequest, + sessionId: string, + status: BackgroundSessionStatus + ) => Promise +} + +``` + +### Search Service Changes + +There are cases where search requests are issued by the server (Like TSVB). +We can simplify this flow by introducing a mechanism, similar to the frontend one, tracking the information in memory and polling for a saved object with a corresponding sessionId to store the ids into it. + +```ts +interface SearchService { + /** + * The search API will accept the option `trackId`, which will track the search ID, if available, on the server, until a corresponding saved object is created. + **/ + search: async ( + context: RequestHandlerContext, + request: IEnhancedEsSearchRequest, + options?: ISearchOptions + ) => ISearchResponse +} +``` + +### Server Routes + +Each route exposes the corresponding method from the Session Service (used only by the client-side service, not meant to be used directly by any consumers): + +`POST /internal/session/store` + +`POST /internal/session/extend` + +`POST /internal/session/expire` + +`GET /internal/session/list` + +### Search Strategy Integration + +If the `EnhancedEsSearchStrategy` receives a `restore` option, it will attempt reloading data using the Background Session saved object matching the provided `sessionId`. If there are any errors during that process, the strategy will return an error response and *not attempt to re-run the request. + +The strategy will track the asyncId on the server side, if `trackId` option is provided. + +### Monitoring Service + +The `data` plugin will register a task with the task manager, periodically monitoring the status of incomplete background sessions. + +It will query the list of all incomplete sessions, and check the status of each search that is executing. If the search requests are all complete, it will update the corresponding saved object to have a `status` of `complete`. If any of the searches return an error, it will update the saved object to an `error` state. If the search requests have expired, it will update the saved object to an `expired` state. Expired sessions will be purged once they are older than the time definedby the `EXPIRED_SESSION_TTL` advanced setting. + +Once there's a notification area in Kibana, we may use that mechanism to push completion \ error notifications to the client. + +## Miscellaneous + +#### Relative dates and restore URLs + +Restoring a sessionId depends on each request's `sha-256` hash matching exactly to the ones saved, requiring special attention to relative date ranges, as having these might yield ambiguous results. + +There are two potential scenarios: + - A relative date (for example `now-1d`) is being used in query DSL - In this case any future hash will match, but the returned data *won't match the displayed timeframe*. For example, a report might state that it shows data from yesterday, but actually show data from a week ago. + - A relative date is being translated by the application before being set to the query DSL - In this case a different date will be sent and the hash will never match, resulting in an error restoring the dashboard. + +Both scenarios require careful attention during the UI design and implementation. + +The former can be resolved by clearly displaying the creation time of the restored Background Session. We could also attempt translating relative dates to absolute one's, but this might be challenging as relative dates may appear deeply nested within the DSL. + +The latter case happens at the moment for the timepicker only: The relative date is being translated each time into an absolute one, before being sent to Elasticsearch. In order to avoid issues, we'll have to make sure that restore URLs are generated with an absolute date, to make sure they are restored correctly. + +#### Changing a restored session + +If you have restored a Background Session, making any type of change to it (time range, filters, etc.) will trigger new (potentially long) searches. There should be a clear indication in the UI that the data is no longer stored. A user then may choose to send it to background, resulting in a new Background Session being saved. + +#### Loading an errored \ expired \ canceled session + +When trying to restore a Background Session, if any of the requests hashes don't match the ones saved, or if any of the saved async search IDs are expired, a meaningful error code will be returned by the server **by those requests**. It is each application's responsibility to handle these errors appropriately. + +In such a scenario, the session will be partially restored. + +#### Extending Expiration + +Sessions are given an expiration date defined in an advanced setting (5 days by default). This expiration date is measured from the time the Background Session is saved, and it includes the time it takes to generate the results. + +A session's expiration date may be extended indefinitely. However, if a session was canceled or has already expired, it needs to be re-run. + +# Limitations + +In the first iteration, cases which require multiple search requests to be made serially will not be supported. The +following are examples of such scenarios: + +- When a visualization is configured with a terms agg with an "other" bucket +- When using blended layers or term joins in Maps + +Eventually, when expressions can be run on the server, they will run in the context of a specific `sessionId`, hence enabling those edge cases too. + +# Drawbacks + +One drawback of this approach is that we will be regularly polling Elasticsearch for saved objects, which will increase +load on the Elasticsearch server, in addition to the Kibana server (since all server-side processes share the same event +loop). We've opened https://github.com/elastic/kibana/issues/77293 to track this, and hopefully come up with benchmarks +so we feel comfortable moving forward with this approach. + +Two potential drawbacks stem from storing things in server memory. If a Kibana server is restarted, in-memory results +will be lost. (This can be an issue if a search request has started, and the user has sent to background, but the +background session saved object has not yet been updated with the search request ID.) In such cases, the user interface +will need to indicate errors for requests that were not stored in the saved object. + +There is also the consideration of the memory footprint of the Kibana server; however, since +we are only storing a hash of the request and search request ID, and are periodically cleaning it up (see Backend +Services and Routes), we do not anticipate the footprint to increase significantly. + +The results of search requests that have been sent to the background will be stored in Elasticsearch for several days, +even if they will only be retrieved once. This will be mitigated by allowing the user manually delete a background +session object after it has been accessed. + +# Alternatives + +What other designs have been considered? What is the impact of not doing this? + +# Adoption strategy + +(See "Basic example" above.) + +Any application or solution that uses the `data` plugin `search` services will be able to facilitate background sessions +fairly simply. The public side will need to create/clear sessions when appropriate, and ensure the `sessionId` is sent +with all search requests. It will also need to ensure that any necessary application data, as well as a `restoreUrl` is +sent when creating the saved object. + +The server side will just need to ensure that the `sessionId` is sent to the `search` service. If bypassing the `search` +service, it will need to also call `trackSearchId` when the first response is received, and `getSearchId` when restoring +the view. + +# How we teach this + +What names and terminology work best for these concepts and why? How is this +idea best presented? As a continuation of existing Kibana patterns? + +Would the acceptance of this proposal mean the Kibana documentation must be +re-organized or altered? Does it change how Kibana is taught to new developers +at any level? + +How should this feature be taught to existing Kibana developers? + +# Unresolved questions + +Optional, but suggested for first drafts. What parts of the design are still +TBD? diff --git a/scripts/functional_tests.js b/scripts/functional_tests.js index 4facbe1ffbb07..2b338b1c054aa 100644 --- a/scripts/functional_tests.js +++ b/scripts/functional_tests.js @@ -23,6 +23,7 @@ const alwaysImportedTests = [ require.resolve('../test/plugin_functional/config.ts'), require.resolve('../test/ui_capabilities/newsfeed_err/config.ts'), require.resolve('../test/new_visualize_flow/config.js'), + require.resolve('../test/security_functional/config.ts'), ]; // eslint-disable-next-line no-restricted-syntax const onlyNotInCoverageTests = [ diff --git a/scripts/kibana_keystore.js b/scripts/kibana_keystore.js index 3efd3500e1f8e..3f970a6517225 100644 --- a/scripts/kibana_keystore.js +++ b/scripts/kibana_keystore.js @@ -17,5 +17,4 @@ * under the License. */ -require('../src/setup_node_env'); -require('../src/cli_keystore'); +require('../src/cli_keystore/dev'); diff --git a/scripts/kibana_plugin.js b/scripts/kibana_plugin.js index 2478e515297ef..5196bdbb853f5 100644 --- a/scripts/kibana_plugin.js +++ b/scripts/kibana_plugin.js @@ -17,5 +17,4 @@ * under the License. */ -require('../src/setup_node_env'); -require('../src/cli_plugin/cli'); +require('../src/cli_plugin/dev'); diff --git a/src/apm.js b/src/apm.js index 8a0c010d993f1..bde37fa006c61 100644 --- a/src/apm.js +++ b/src/apm.js @@ -36,7 +36,22 @@ module.exports = function (serviceName = name) { apmConfig = loadConfiguration(process.argv, ROOT_DIR, isKibanaDistributable); const conf = apmConfig.getConfig(serviceName); - require('elastic-apm-node').start(conf); + const apm = require('elastic-apm-node'); + + // Filter out all user PII + apm.addFilter((payload) => { + try { + if (payload.context && payload.context.user && typeof payload.context.user === 'object') { + Object.keys(payload.context.user).forEach((key) => { + payload.context.user[key] = '[REDACTED]'; + }); + } + } finally { + return payload; + } + }); + + apm.start(conf); }; module.exports.getConfig = (serviceName) => { @@ -50,4 +65,3 @@ module.exports.getConfig = (serviceName) => { } return {}; }; -module.exports.isKibanaDistributable = isKibanaDistributable; diff --git a/src/cli/cluster/cluster_manager.ts b/src/cli/cluster/cluster_manager.ts index 3d81185e8a313..931650a67687c 100644 --- a/src/cli/cluster/cluster_manager.ts +++ b/src/cli/cluster/cluster_manager.ts @@ -110,6 +110,7 @@ export class ClusterManager { type: 'server', log: this.log, argv: serverArgv, + apmServiceName: 'kibana', })), ]; diff --git a/src/cli/cluster/worker.ts b/src/cli/cluster/worker.ts index f6205b41ac5a5..d28065765070b 100644 --- a/src/cli/cluster/worker.ts +++ b/src/cli/cluster/worker.ts @@ -49,6 +49,7 @@ interface WorkerOptions { title?: string; watch?: boolean; baseArgv?: string[]; + apmServiceName?: string; } export class Worker extends EventEmitter { @@ -89,6 +90,7 @@ export class Worker extends EventEmitter { NODE_OPTIONS: process.env.NODE_OPTIONS || '', kbnWorkerType: this.type, kbnWorkerArgv: JSON.stringify([...(opts.baseArgv || baseArgv), ...(opts.argv || [])]), + ELASTIC_APM_SERVICE_NAME: opts.apmServiceName || '', }; } diff --git a/src/cli/dev.js b/src/cli/dev.js index 9d0cb35c3d730..a284c82dfeb6e 100644 --- a/src/cli/dev.js +++ b/src/cli/dev.js @@ -17,6 +17,6 @@ * under the License. */ -require('../apm')(process.env.ELASTIC_APM_PROXY_SERVICE_NAME || 'kibana-proxy'); +require('../apm')(process.env.ELASTIC_APM_SERVICE_NAME || 'kibana-proxy'); require('../setup_node_env'); require('./cli'); diff --git a/src/cli/dist.js b/src/cli/dist.js index 2e26eaf52e836..05f0a68aa495c 100644 --- a/src/cli/dist.js +++ b/src/cli/dist.js @@ -18,6 +18,5 @@ */ require('../apm')(); -require('../setup_node_env/no_transpilation'); -require('core-js/stable'); +require('../setup_node_env/dist'); require('./cli'); diff --git a/src/cli_keystore/index.js b/src/cli_keystore/dev.js similarity index 100% rename from src/cli_keystore/index.js rename to src/cli_keystore/dev.js diff --git a/src/cli_keystore/dist.js b/src/cli_keystore/dist.js new file mode 100644 index 0000000000000..60abe225372aa --- /dev/null +++ b/src/cli_keystore/dist.js @@ -0,0 +1,21 @@ +/* + * 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. + */ + +require('../setup_node_env/dist'); +require('./cli_keystore'); diff --git a/src/cli_plugin/index.js b/src/cli_plugin/dev.js similarity index 100% rename from src/cli_plugin/index.js rename to src/cli_plugin/dev.js diff --git a/src/cli_plugin/dist.js b/src/cli_plugin/dist.js new file mode 100644 index 0000000000000..cc931f60db1e4 --- /dev/null +++ b/src/cli_plugin/dist.js @@ -0,0 +1,21 @@ +/* + * 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. + */ + +require('../setup_node_env/dist'); +require('./cli'); diff --git a/src/core/public/apm_system.ts b/src/core/public/apm_system.ts index 0a18f02c97293..5e4953b96dc5b 100644 --- a/src/core/public/apm_system.ts +++ b/src/core/public/apm_system.ts @@ -28,6 +28,7 @@ import type { InternalApplicationStart } from './application'; interface ApmConfig { // AgentConfigOptions is not exported from @elastic/apm-rum + active?: boolean; globalLabels?: Record; } @@ -39,10 +40,10 @@ export class ApmSystem { private readonly enabled: boolean; /** * `apmConfig` would be populated with relevant APM RUM agent - * configuration if server is started with `ELASTIC_APM_ACTIVE=true` + * configuration if server is started with elastic.apm.* config. */ constructor(private readonly apmConfig?: ApmConfig) { - this.enabled = process.env.IS_KIBANA_DISTRIBUTABLE !== 'true' && apmConfig != null; + this.enabled = apmConfig != null && !!apmConfig.active; } async setup() { diff --git a/src/core/server/core_usage_data/core_usage_data_service.mock.ts b/src/core/server/core_usage_data/core_usage_data_service.mock.ts new file mode 100644 index 0000000000000..c443ce72f5ed7 --- /dev/null +++ b/src/core/server/core_usage_data/core_usage_data_service.mock.ts @@ -0,0 +1,153 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PublicMethodsOf } from '@kbn/utility-types'; +import { BehaviorSubject } from 'rxjs'; +import { CoreUsageDataService } from './core_usage_data_service'; +import { CoreUsageData, CoreUsageDataStart } from './types'; + +const createStartContractMock = () => { + const startContract: jest.Mocked = { + getCoreUsageData: jest.fn().mockResolvedValue( + new BehaviorSubject({ + config: { + elasticsearch: { + apiVersion: 'master', + customHeadersConfigured: false, + healthCheckDelayMs: 2500, + logQueries: false, + numberOfHostsConfigured: 1, + pingTimeoutMs: 30000, + requestHeadersWhitelistConfigured: false, + requestTimeoutMs: 30000, + shardTimeoutMs: 30000, + sniffIntervalMs: -1, + sniffOnConnectionFault: false, + sniffOnStart: false, + ssl: { + alwaysPresentCertificate: false, + certificateAuthoritiesConfigured: false, + certificateConfigured: false, + keyConfigured: false, + verificationMode: 'full', + keystoreConfigured: false, + truststoreConfigured: false, + }, + }, + http: { + basePathConfigured: false, + compression: { + enabled: true, + referrerWhitelistConfigured: false, + }, + keepaliveTimeout: 120000, + maxPayloadInBytes: 1048576, + requestId: { + allowFromAnyIp: false, + ipAllowlistConfigured: false, + }, + rewriteBasePath: false, + socketTimeout: 120000, + ssl: { + certificateAuthoritiesConfigured: false, + certificateConfigured: false, + cipherSuites: [ + 'ECDHE-RSA-AES128-GCM-SHA256', + 'ECDHE-ECDSA-AES128-GCM-SHA256', + 'ECDHE-RSA-AES256-GCM-SHA384', + 'ECDHE-ECDSA-AES256-GCM-SHA384', + 'DHE-RSA-AES128-GCM-SHA256', + 'ECDHE-RSA-AES128-SHA256', + 'DHE-RSA-AES128-SHA256', + 'ECDHE-RSA-AES256-SHA384', + 'DHE-RSA-AES256-SHA384', + 'ECDHE-RSA-AES256-SHA256', + 'DHE-RSA-AES256-SHA256', + 'HIGH', + '!aNULL', + '!eNULL', + '!EXPORT', + '!DES', + '!RC4', + '!MD5', + '!PSK', + '!SRP', + '!CAMELLIA', + ], + clientAuthentication: 'none', + keyConfigured: false, + keystoreConfigured: false, + redirectHttpFromPortConfigured: false, + supportedProtocols: ['TLSv1.1', 'TLSv1.2'], + truststoreConfigured: false, + }, + xsrf: { + disableProtection: false, + whitelistConfigured: false, + }, + }, + logging: { + appendersTypesUsed: [], + loggersConfiguredCount: 0, + }, + savedObjects: { + maxImportExportSizeBytes: 10000, + maxImportPayloadBytes: 10485760, + }, + }, + environment: { + memory: { + heapSizeLimit: 1, + heapTotalBytes: 1, + heapUsedBytes: 1, + }, + }, + services: { + savedObjects: { + indices: [ + { + docsCount: 1, + docsDeleted: 1, + alias: 'test_index', + primaryStoreSizeBytes: 1, + storeSizeBytes: 1, + }, + ], + }, + }, + }) + ), + }; + + return startContract; +}; + +const createMock = () => { + const mocked: jest.Mocked> = { + setup: jest.fn(), + start: jest.fn().mockReturnValue(createStartContractMock()), + stop: jest.fn(), + }; + return mocked; +}; + +export const coreUsageDataServiceMock = { + create: createMock, + createStartContract: createStartContractMock, +}; diff --git a/src/core/server/core_usage_data/core_usage_data_service.test.ts b/src/core/server/core_usage_data/core_usage_data_service.test.ts new file mode 100644 index 0000000000000..a664f6514e9c8 --- /dev/null +++ b/src/core/server/core_usage_data/core_usage_data_service.test.ts @@ -0,0 +1,259 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BehaviorSubject, Observable } from 'rxjs'; +import { HotObservable } from 'rxjs/internal/testing/HotObservable'; +import { TestScheduler } from 'rxjs/testing'; + +import { configServiceMock } from '../config/mocks'; + +import { mockCoreContext } from '../core_context.mock'; +import { config as RawElasticsearchConfig } from '../elasticsearch/elasticsearch_config'; +import { config as RawHttpConfig } from '../http/http_config'; +import { config as RawLoggingConfig } from '../logging/logging_config'; +import { config as RawKibanaConfig } from '../kibana_config'; +import { savedObjectsConfig as RawSavedObjectsConfig } from '../saved_objects/saved_objects_config'; +import { metricsServiceMock } from '../metrics/metrics_service.mock'; +import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service.mock'; + +import { CoreUsageDataService } from './core_usage_data_service'; +import { elasticsearchServiceMock } from '../elasticsearch/elasticsearch_service.mock'; + +describe('CoreUsageDataService', () => { + const getTestScheduler = () => + new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + + let service: CoreUsageDataService; + const configService = configServiceMock.create(); + configService.atPath.mockImplementation((path) => { + if (path === 'elasticsearch') { + return new BehaviorSubject(RawElasticsearchConfig.schema.validate({})); + } else if (path === 'server') { + return new BehaviorSubject(RawHttpConfig.schema.validate({})); + } else if (path === 'logging') { + return new BehaviorSubject(RawLoggingConfig.schema.validate({})); + } else if (path === 'savedObjects') { + return new BehaviorSubject(RawSavedObjectsConfig.schema.validate({})); + } else if (path === 'kibana') { + return new BehaviorSubject(RawKibanaConfig.schema.validate({})); + } + return new BehaviorSubject({}); + }); + const coreContext = mockCoreContext.create({ configService }); + + beforeEach(() => { + service = new CoreUsageDataService(coreContext); + }); + + describe('start', () => { + describe('getCoreUsageData', () => { + it('returns core metrics for default config', () => { + const metrics = metricsServiceMock.createInternalSetupContract(); + service.setup({ metrics }); + const elasticsearch = elasticsearchServiceMock.createStart(); + elasticsearch.client.asInternalUser.cat.indices.mockResolvedValueOnce({ + body: [ + { + name: '.kibana_task_manager_1', + 'docs.count': 10, + 'docs.deleted': 10, + 'store.size': 1000, + 'pri.store.size': 2000, + }, + ], + } as any); + elasticsearch.client.asInternalUser.cat.indices.mockResolvedValueOnce({ + body: [ + { + name: '.kibana_1', + 'docs.count': 20, + 'docs.deleted': 20, + 'store.size': 2000, + 'pri.store.size': 4000, + }, + ], + } as any); + const typeRegistry = savedObjectsServiceMock.createTypeRegistryMock(); + typeRegistry.getAllTypes.mockReturnValue([ + { name: 'type 1', indexPattern: '.kibana' }, + { name: 'type 2', indexPattern: '.kibana_task_manager' }, + ] as any); + + const { getCoreUsageData } = service.start({ + savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry), + elasticsearch, + }); + expect(getCoreUsageData()).resolves.toMatchInlineSnapshot(` + Object { + "config": Object { + "elasticsearch": Object { + "apiVersion": "master", + "customHeadersConfigured": false, + "healthCheckDelayMs": 2500, + "logQueries": false, + "numberOfHostsConfigured": 1, + "pingTimeoutMs": 30000, + "requestHeadersWhitelistConfigured": false, + "requestTimeoutMs": 30000, + "shardTimeoutMs": 30000, + "sniffIntervalMs": -1, + "sniffOnConnectionFault": false, + "sniffOnStart": false, + "ssl": Object { + "alwaysPresentCertificate": false, + "certificateAuthoritiesConfigured": false, + "certificateConfigured": false, + "keyConfigured": false, + "keystoreConfigured": false, + "truststoreConfigured": false, + "verificationMode": "full", + }, + }, + "http": Object { + "basePathConfigured": false, + "compression": Object { + "enabled": true, + "referrerWhitelistConfigured": false, + }, + "keepaliveTimeout": 120000, + "maxPayloadInBytes": 1048576, + "requestId": Object { + "allowFromAnyIp": false, + "ipAllowlistConfigured": false, + }, + "rewriteBasePath": false, + "socketTimeout": 120000, + "ssl": Object { + "certificateAuthoritiesConfigured": false, + "certificateConfigured": false, + "cipherSuites": Array [ + "ECDHE-RSA-AES128-GCM-SHA256", + "ECDHE-ECDSA-AES128-GCM-SHA256", + "ECDHE-RSA-AES256-GCM-SHA384", + "ECDHE-ECDSA-AES256-GCM-SHA384", + "DHE-RSA-AES128-GCM-SHA256", + "ECDHE-RSA-AES128-SHA256", + "DHE-RSA-AES128-SHA256", + "ECDHE-RSA-AES256-SHA384", + "DHE-RSA-AES256-SHA384", + "ECDHE-RSA-AES256-SHA256", + "DHE-RSA-AES256-SHA256", + "HIGH", + "!aNULL", + "!eNULL", + "!EXPORT", + "!DES", + "!RC4", + "!MD5", + "!PSK", + "!SRP", + "!CAMELLIA", + ], + "clientAuthentication": "none", + "keyConfigured": false, + "keystoreConfigured": false, + "redirectHttpFromPortConfigured": false, + "supportedProtocols": Array [ + "TLSv1.1", + "TLSv1.2", + ], + "truststoreConfigured": false, + }, + "xsrf": Object { + "disableProtection": false, + "whitelistConfigured": false, + }, + }, + "logging": Object { + "appendersTypesUsed": Array [], + "loggersConfiguredCount": 0, + }, + "savedObjects": Object { + "maxImportExportSizeBytes": 10000, + "maxImportPayloadBytes": 10485760, + }, + }, + "environment": Object { + "memory": Object { + "heapSizeLimit": 1, + "heapTotalBytes": 1, + "heapUsedBytes": 1, + }, + }, + "services": Object { + "savedObjects": Object { + "indices": Array [ + Object { + "alias": ".kibana", + "docsCount": 10, + "docsDeleted": 10, + "primaryStoreSizeBytes": 2000, + "storeSizeBytes": 1000, + }, + Object { + "alias": ".kibana_task_manager", + "docsCount": 20, + "docsDeleted": 20, + "primaryStoreSizeBytes": 4000, + "storeSizeBytes": 2000, + }, + ], + }, + }, + } + `); + }); + }); + }); + + describe('setup and stop', () => { + it('subscribes and unsubscribes from all config paths and metrics', () => { + getTestScheduler().run(({ cold, hot, expectSubscriptions }) => { + const observables: Array> = []; + configService.atPath.mockImplementation(() => { + const newObservable = hot('-a-------'); + observables.push(newObservable); + return newObservable; + }); + const metrics = metricsServiceMock.createInternalSetupContract(); + metrics.getOpsMetrics$.mockImplementation(() => { + const newObservable = hot('-a-------'); + observables.push(newObservable); + return newObservable as Observable; + }); + + service.setup({ metrics }); + + // Use the stopTimer$ to delay calling stop() until the third frame + const stopTimer$ = cold('---a|'); + stopTimer$.subscribe(() => { + service.stop(); + }); + + const subs = '^--!'; + + observables.forEach((o) => { + expectSubscriptions(o.subscriptions).toBe(subs); + }); + }); + }); + }); +}); diff --git a/src/core/server/core_usage_data/core_usage_data_service.ts b/src/core/server/core_usage_data/core_usage_data_service.ts new file mode 100644 index 0000000000000..f729e23cb68bc --- /dev/null +++ b/src/core/server/core_usage_data/core_usage_data_service.ts @@ -0,0 +1,285 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { CoreService } from 'src/core/types'; +import { SavedObjectsServiceStart } from 'src/core/server'; +import { CoreContext } from '../core_context'; +import { ElasticsearchConfigType } from '../elasticsearch/elasticsearch_config'; +import { HttpConfigType } from '../http'; +import { LoggingConfigType } from '../logging'; +import { SavedObjectsConfigType } from '../saved_objects/saved_objects_config'; +import { CoreServicesUsageData, CoreUsageData, CoreUsageDataStart } from './types'; +import { isConfigured } from './is_configured'; +import { ElasticsearchServiceStart } from '../elasticsearch'; +import { KibanaConfigType } from '../kibana_config'; +import { MetricsServiceSetup, OpsMetrics } from '..'; + +export interface SetupDeps { + metrics: MetricsServiceSetup; +} + +export interface StartDeps { + savedObjects: SavedObjectsServiceStart; + elasticsearch: ElasticsearchServiceStart; +} + +/** + * Because users can configure their Saved Object to any arbitrary index name, + * we need to map customized index names back to a "standard" index name. + * + * e.g. If a user configures `kibana.index: .my_saved_objects` we want to the + * collected data to be grouped under `.kibana` not ".my_saved_objects". + * + * This is rather brittle, but the option to configure index names might go + * away completely anyway (see #60053). + * + * @param index The index name configured for this SO type + * @param kibanaConfigIndex The default kibana index as configured by the user + * with `kibana.index` + */ +const kibanaOrTaskManagerIndex = (index: string, kibanaConfigIndex: string) => { + return index === kibanaConfigIndex ? '.kibana' : '.kibana_task_manager'; +}; + +export class CoreUsageDataService implements CoreService { + private elasticsearchConfig?: ElasticsearchConfigType; + private configService: CoreContext['configService']; + private httpConfig?: HttpConfigType; + private loggingConfig?: LoggingConfigType; + private soConfig?: SavedObjectsConfigType; + private stop$: Subject; + private opsMetrics?: OpsMetrics; + private kibanaConfig?: KibanaConfigType; + + constructor(core: CoreContext) { + this.configService = core.configService; + this.stop$ = new Subject(); + } + + private async getSavedObjectIndicesUsageData( + savedObjects: SavedObjectsServiceStart, + elasticsearch: ElasticsearchServiceStart + ): Promise { + const indices = await Promise.all( + Array.from( + savedObjects + .getTypeRegistry() + .getAllTypes() + .reduce((acc, type) => { + const index = type.indexPattern ?? this.kibanaConfig!.index; + return index != null ? acc.add(index) : acc; + }, new Set()) + .values() + ).map((index) => { + // The _cat/indices API returns the _index_ and doesn't return a way + // to map back from the index to the alias. So we have to make an API + // call for every alias + return elasticsearch.client.asInternalUser.cat + .indices({ + index, + format: 'JSON', + bytes: 'b', + }) + .then(({ body }) => { + const stats = body[0]; + return { + alias: kibanaOrTaskManagerIndex(index, this.kibanaConfig!.index), + docsCount: stats['docs.count'], + docsDeleted: stats['docs.deleted'], + storeSizeBytes: stats['store.size'], + primaryStoreSizeBytes: stats['pri.store.size'], + }; + }); + }) + ); + + return { + indices, + }; + } + + private async getCoreUsageData( + savedObjects: SavedObjectsServiceStart, + elasticsearch: ElasticsearchServiceStart + ): Promise { + if ( + this.elasticsearchConfig == null || + this.httpConfig == null || + this.soConfig == null || + this.opsMetrics == null + ) { + throw new Error('Unable to read config values. Ensure that setup() has completed.'); + } + + const es = this.elasticsearchConfig; + const soUsageData = await this.getSavedObjectIndicesUsageData(savedObjects, elasticsearch); + + const http = this.httpConfig; + return { + config: { + elasticsearch: { + apiVersion: es.apiVersion, + sniffOnStart: es.sniffOnStart, + sniffIntervalMs: es.sniffInterval !== false ? es.sniffInterval.asMilliseconds() : -1, + sniffOnConnectionFault: es.sniffOnConnectionFault, + numberOfHostsConfigured: Array.isArray(es.hosts) + ? es.hosts.length + : isConfigured.string(es.hosts) + ? 1 + : 0, + customHeadersConfigured: isConfigured.record(es.customHeaders), + healthCheckDelayMs: es.healthCheck.delay.asMilliseconds(), + logQueries: es.logQueries, + pingTimeoutMs: es.pingTimeout.asMilliseconds(), + requestHeadersWhitelistConfigured: isConfigured.stringOrArray( + es.requestHeadersWhitelist, + ['authorization'] + ), + requestTimeoutMs: es.requestTimeout.asMilliseconds(), + shardTimeoutMs: es.shardTimeout.asMilliseconds(), + ssl: { + alwaysPresentCertificate: es.ssl.alwaysPresentCertificate, + certificateAuthoritiesConfigured: isConfigured.stringOrArray( + es.ssl.certificateAuthorities + ), + certificateConfigured: isConfigured.string(es.ssl.certificate), + keyConfigured: isConfigured.string(es.ssl.key), + verificationMode: es.ssl.verificationMode, + truststoreConfigured: isConfigured.record(es.ssl.truststore), + keystoreConfigured: isConfigured.record(es.ssl.keystore), + }, + }, + http: { + basePathConfigured: isConfigured.string(http.basePath), + maxPayloadInBytes: http.maxPayload.getValueInBytes(), + rewriteBasePath: http.rewriteBasePath, + keepaliveTimeout: http.keepaliveTimeout, + socketTimeout: http.socketTimeout, + compression: { + enabled: http.compression.enabled, + referrerWhitelistConfigured: isConfigured.array(http.compression.referrerWhitelist), + }, + xsrf: { + disableProtection: http.xsrf.disableProtection, + whitelistConfigured: isConfigured.array(http.xsrf.whitelist), + }, + requestId: { + allowFromAnyIp: http.requestId.allowFromAnyIp, + ipAllowlistConfigured: isConfigured.array(http.requestId.ipAllowlist), + }, + ssl: { + certificateAuthoritiesConfigured: isConfigured.stringOrArray( + http.ssl.certificateAuthorities + ), + certificateConfigured: isConfigured.string(http.ssl.certificate), + cipherSuites: http.ssl.cipherSuites, + keyConfigured: isConfigured.string(http.ssl.key), + redirectHttpFromPortConfigured: isConfigured.number(http.ssl.redirectHttpFromPort), + supportedProtocols: http.ssl.supportedProtocols, + clientAuthentication: http.ssl.clientAuthentication, + keystoreConfigured: isConfigured.record(http.ssl.keystore), + truststoreConfigured: isConfigured.record(http.ssl.truststore), + }, + }, + + logging: { + appendersTypesUsed: Array.from( + Array.from(this.loggingConfig?.appenders.values() ?? []) + .reduce((acc, a) => acc.add(a.kind), new Set()) + .values() + ), + loggersConfiguredCount: this.loggingConfig?.loggers.length ?? 0, + }, + + savedObjects: { + maxImportPayloadBytes: this.soConfig.maxImportPayloadBytes.getValueInBytes(), + maxImportExportSizeBytes: this.soConfig.maxImportExportSize.getValueInBytes(), + }, + }, + environment: { + memory: { + heapSizeLimit: this.opsMetrics.process.memory.heap.size_limit, + heapTotalBytes: this.opsMetrics.process.memory.heap.total_in_bytes, + heapUsedBytes: this.opsMetrics.process.memory.heap.used_in_bytes, + }, + }, + services: { + savedObjects: soUsageData, + }, + }; + } + + setup({ metrics }: SetupDeps) { + metrics + .getOpsMetrics$() + .pipe(takeUntil(this.stop$)) + .subscribe((opsMetrics) => (this.opsMetrics = opsMetrics)); + + this.configService + .atPath('elasticsearch') + .pipe(takeUntil(this.stop$)) + .subscribe((config) => { + this.elasticsearchConfig = config; + }); + + this.configService + .atPath('server') + .pipe(takeUntil(this.stop$)) + .subscribe((config) => { + this.httpConfig = config; + }); + + this.configService + .atPath('logging') + .pipe(takeUntil(this.stop$)) + .subscribe((config) => { + this.loggingConfig = config; + }); + + this.configService + .atPath('savedObjects') + .pipe(takeUntil(this.stop$)) + .subscribe((config) => { + this.soConfig = config; + }); + + this.configService + .atPath('kibana') + .pipe(takeUntil(this.stop$)) + .subscribe((config) => { + this.kibanaConfig = config; + }); + } + + start({ savedObjects, elasticsearch }: StartDeps) { + return { + getCoreUsageData: () => { + return this.getCoreUsageData(savedObjects, elasticsearch); + }, + }; + } + + stop() { + this.stop$.next(); + this.stop$.complete(); + } +} diff --git a/src/core/server/core_usage_data/index.ts b/src/core/server/core_usage_data/index.ts new file mode 100644 index 0000000000000..b78c126657ef6 --- /dev/null +++ b/src/core/server/core_usage_data/index.ts @@ -0,0 +1,31 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export { CoreUsageDataStart } from './types'; +export { CoreUsageDataService } from './core_usage_data_service'; + +// Because of #79265 we need to explicity import, then export these types for +// scripts/telemetry_check.js to work as expected +import { + CoreUsageData, + CoreConfigUsageData, + CoreEnvironmentUsageData, + CoreServicesUsageData, +} from './types'; + +export { CoreUsageData, CoreConfigUsageData, CoreEnvironmentUsageData, CoreServicesUsageData }; diff --git a/src/core/server/core_usage_data/is_configured.test.ts b/src/core/server/core_usage_data/is_configured.test.ts new file mode 100644 index 0000000000000..e5d04946b8766 --- /dev/null +++ b/src/core/server/core_usage_data/is_configured.test.ts @@ -0,0 +1,137 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isConfigured } from './is_configured'; + +describe('isConfigured', () => { + describe('#string', () => { + it('returns true for a non-empty string', () => { + expect(isConfigured.string('I am configured')).toEqual(true); + }); + + it('returns false for an empty string', () => { + expect(isConfigured.string(' ')).toEqual(false); + expect(isConfigured.string(' ')).toEqual(false); + }); + + it('returns false for undefined', () => { + expect(isConfigured.string(undefined)).toEqual(false); + }); + + it('returns false for null', () => { + expect(isConfigured.string(null as any)).toEqual(false); + }); + + it('returns false for a record', () => { + expect(isConfigured.string({} as any)).toEqual(false); + expect(isConfigured.string({ key: 'hello' } as any)).toEqual(false); + }); + + it('returns false for an array', () => { + expect(isConfigured.string([] as any)).toEqual(false); + expect(isConfigured.string(['hello'] as any)).toEqual(false); + }); + }); + + describe('array', () => { + it('returns true for a non-empty array', () => { + expect(isConfigured.array(['a'])).toEqual(true); + expect(isConfigured.array([{}])).toEqual(true); + expect(isConfigured.array([{ key: 'hello' }])).toEqual(true); + }); + + it('returns false for an empty array', () => { + expect(isConfigured.array([])).toEqual(false); + }); + + it('returns false for undefined', () => { + expect(isConfigured.array(undefined)).toEqual(false); + }); + + it('returns false for null', () => { + expect(isConfigured.array(null as any)).toEqual(false); + }); + + it('returns false for a string', () => { + expect(isConfigured.array('string')).toEqual(false); + }); + + it('returns false for a record', () => { + expect(isConfigured.array({} as any)).toEqual(false); + }); + }); + + describe('stringOrArray', () => { + const arraySpy = jest.spyOn(isConfigured, 'array'); + const stringSpy = jest.spyOn(isConfigured, 'string'); + + it('calls #array for an array', () => { + isConfigured.stringOrArray([]); + expect(arraySpy).toHaveBeenCalledWith([]); + }); + + it('calls #string for non-array values', () => { + isConfigured.stringOrArray('string'); + expect(stringSpy).toHaveBeenCalledWith('string'); + }); + }); + + describe('record', () => { + it('returns true for a non-empty record', () => { + expect(isConfigured.record({ key: 'hello' })).toEqual(true); + expect(isConfigured.record({ key: undefined })).toEqual(true); + }); + + it('returns false for an empty record', () => { + expect(isConfigured.record({})).toEqual(false); + }); + it('returns false for undefined', () => { + expect(isConfigured.record(undefined)).toEqual(false); + }); + it('returns false for null', () => { + expect(isConfigured.record(null as any)).toEqual(false); + }); + }); + + describe('number', () => { + it('returns true for a valid number', () => { + expect(isConfigured.number(0)).toEqual(true); + expect(isConfigured.number(-0)).toEqual(true); + expect(isConfigured.number(1)).toEqual(true); + expect(isConfigured.number(-0)).toEqual(true); + }); + + it('returns false for NaN', () => { + expect(isConfigured.number(Number.NaN)).toEqual(false); + }); + + it('returns false for a string', () => { + expect(isConfigured.number('1' as any)).toEqual(false); + expect(isConfigured.number('' as any)).toEqual(false); + }); + + it('returns false for undefined', () => { + expect(isConfigured.number(undefined)).toEqual(false); + }); + + it('returns false for null', () => { + expect(isConfigured.number(null as any)).toEqual(false); + }); + }); +}); diff --git a/src/core/server/core_usage_data/is_configured.ts b/src/core/server/core_usage_data/is_configured.ts new file mode 100644 index 0000000000000..e66f990f1037c --- /dev/null +++ b/src/core/server/core_usage_data/is_configured.ts @@ -0,0 +1,65 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isEqual } from 'lodash'; + +/** + * Test whether a given config value is configured based on it's schema type. + * Our configuration schema and code often accept and ignore empty values like + * `elasticsearch.customHeaders: {}`. However, for telemetry purposes, we're + * only interested when these values have been set to something that will + * change the behaviour of Kibana. + */ +export const isConfigured = { + /** + * config is a string with non-zero length + */ + string: (config?: string): boolean => { + return (config?.trim?.()?.length ?? 0) > 0; + }, + /** + * config is an array with non-zero length + */ + array: (config?: unknown[] | string, defaultValue?: any): boolean => { + return Array.isArray(config) + ? (config?.length ?? 0) > 0 && !isEqual(config, defaultValue) + : false; + }, + /** + * config is a string or array of strings where each element has non-zero length + */ + stringOrArray: (config?: string[] | string, defaultValue?: any): boolean => { + return Array.isArray(config) + ? isConfigured.array(config, defaultValue) + : isConfigured.string(config); + }, + /** + * config is a record with at least one key + */ + record: (config?: Record): boolean => { + return config != null && typeof config === 'object' && Object.keys(config).length > 0; + }, + /** + * config is a number + */ + number: (config?: number): boolean => { + // kbn-config-schema already does NaN validation, but doesn't hurt to be sure + return typeof config === 'number' && !isNaN(config); + }, +}; diff --git a/src/core/server/core_usage_data/types.ts b/src/core/server/core_usage_data/types.ts new file mode 100644 index 0000000000000..52d2eadcf1377 --- /dev/null +++ b/src/core/server/core_usage_data/types.ts @@ -0,0 +1,162 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Type describing Core's usage data payload + * @internal + */ +export interface CoreUsageData { + config: CoreConfigUsageData; + services: CoreServicesUsageData; + environment: CoreEnvironmentUsageData; +} + +/** + * Usage data from Core services + * @internal + */ +export interface CoreServicesUsageData { + savedObjects: { + // scripts/telemetry_check.js does not support parsing Array<{...}> types + // so we have to disable eslint here and use {...}[] + // eslint-disable-next-line @typescript-eslint/array-type + indices: { + alias: string; + docsCount: number; + docsDeleted: number; + storeSizeBytes: number; + primaryStoreSizeBytes: number; + }[]; + }; +} + +/** + * Usage data on this Kibana node's runtime environment. + * @internal + */ +export interface CoreEnvironmentUsageData { + memory: { + heapTotalBytes: number; + heapUsedBytes: number; + /** V8 heap size limit */ + heapSizeLimit: number; + }; +} + +/** + * Usage data on this cluster's configuration of Core features + * @internal + */ +export interface CoreConfigUsageData { + elasticsearch: { + sniffOnStart: boolean; + sniffIntervalMs?: number; + sniffOnConnectionFault: boolean; + numberOfHostsConfigured: number; + requestHeadersWhitelistConfigured: boolean; + customHeadersConfigured: boolean; + shardTimeoutMs: number; + requestTimeoutMs: number; + pingTimeoutMs: number; + logQueries: boolean; + ssl: { + verificationMode: 'none' | 'certificate' | 'full'; + certificateAuthoritiesConfigured: boolean; + certificateConfigured: boolean; + keyConfigured: boolean; + keystoreConfigured: boolean; + truststoreConfigured: boolean; + alwaysPresentCertificate: boolean; + }; + apiVersion: string; + healthCheckDelayMs: number; + }; + + http: { + basePathConfigured: boolean; + maxPayloadInBytes: number; + rewriteBasePath: boolean; + keepaliveTimeout: number; + socketTimeout: number; + compression: { + enabled: boolean; + referrerWhitelistConfigured: boolean; + }; + xsrf: { + disableProtection: boolean; + whitelistConfigured: boolean; + }; + requestId: { + allowFromAnyIp: boolean; + ipAllowlistConfigured: boolean; + }; + ssl: { + certificateAuthoritiesConfigured: boolean; + certificateConfigured: boolean; + cipherSuites: string[]; + keyConfigured: boolean; + keystoreConfigured: boolean; + truststoreConfigured: boolean; + redirectHttpFromPortConfigured: boolean; + supportedProtocols: string[]; + clientAuthentication: 'none' | 'optional' | 'required'; + }; + }; + + logging: { + appendersTypesUsed: string[]; + loggersConfiguredCount: number; + }; + + // plugins: { + // /** list of built-in plugins that are disabled */ + // firstPartyDisabled: string[]; + // /** list of third-party plugins that are installed and enabled */ + // thirdParty: string[]; + // }; + + savedObjects: { + maxImportPayloadBytes: number; + maxImportExportSizeBytes: number; + }; + + // uiSettings: { + // overridesCount: number; + // }; +} + +/** + * Internal API for getting Core's usage data payload. + * + * @note This API should never be used to drive application logic and is only + * intended for telemetry purposes. + * + * @internal + */ +export interface CoreUsageDataStart { + /** + * Internal API for getting Core's usage data payload. + * + * @note This API should never be used to drive application logic and is only + * intended for telemetry purposes. + * + * @internal + * */ + getCoreUsageData(): Promise; +} diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 70ef93963c69f..887dc50d5f78b 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -64,6 +64,18 @@ import { MetricsServiceSetup, MetricsServiceStart } from './metrics'; import { StatusServiceSetup } from './status'; import { Auditor, AuditTrailSetup, AuditTrailStart } from './audit_trail'; import { AppenderConfigType, appendersSchema, LoggingServiceSetup } from './logging'; +import { CoreUsageDataStart } from './core_usage_data'; + +// Because of #79265 we need to explicity import, then export these types for +// scripts/telemetry_check.js to work as expected +import { + CoreUsageData, + CoreConfigUsageData, + CoreEnvironmentUsageData, + CoreServicesUsageData, +} from './core_usage_data'; + +export { CoreUsageData, CoreConfigUsageData, CoreEnvironmentUsageData, CoreServicesUsageData }; export { AuditableEvent, Auditor, AuditorFactory, AuditTrailSetup } from './audit_trail'; export { bootstrap } from './bootstrap'; @@ -349,6 +361,8 @@ export { StatusServiceSetup, } from './status'; +export { CoreUsageDataStart } from './core_usage_data'; + /** * Plugin specific context passed to a route handler. * @@ -456,6 +470,8 @@ export interface CoreStart { uiSettings: UiSettingsServiceStart; /** {@link AuditTrailSetup} */ auditTrail: AuditTrailStart; + /** @internal {@link CoreUsageDataStart} */ + coreUsageData: CoreUsageDataStart; } export { diff --git a/src/core/server/internal_types.ts b/src/core/server/internal_types.ts index f5a5edffb0a74..ce58348a14153 100644 --- a/src/core/server/internal_types.ts +++ b/src/core/server/internal_types.ts @@ -39,6 +39,7 @@ import { InternalHttpResourcesSetup } from './http_resources'; import { InternalStatusServiceSetup } from './status'; import { AuditTrailSetup, AuditTrailStart } from './audit_trail'; import { InternalLoggingServiceSetup } from './logging'; +import { CoreUsageDataStart } from './core_usage_data'; /** @internal */ export interface InternalCoreSetup { @@ -68,6 +69,7 @@ export interface InternalCoreStart { savedObjects: InternalSavedObjectsServiceStart; uiSettings: InternalUiSettingsServiceStart; auditTrail: AuditTrailStart; + coreUsageData: CoreUsageDataStart; } /** diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 086e20c98c1a3..75e8ae6524920 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -217,6 +217,11 @@ export class LegacyService implements CoreService { }, uiSettings: { asScopedToClient: startDeps.core.uiSettings.asScopedToClient }, auditTrail: startDeps.core.auditTrail, + coreUsageData: { + getCoreUsageData: () => { + throw new Error('core.start.coreUsageData.getCoreUsageData is unsupported in legacy'); + }, + }, }; const router = setupDeps.core.http.createRouter('', this.legacyId); diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 3030cd9f4e0cb..34e85920efb24 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -37,6 +37,7 @@ import { metricsServiceMock } from './metrics/metrics_service.mock'; import { environmentServiceMock } from './environment/environment_service.mock'; import { statusServiceMock } from './status/status_service.mock'; import { auditTrailServiceMock } from './audit_trail/audit_trail_service.mock'; +import { coreUsageDataServiceMock } from './core_usage_data/core_usage_data_service.mock'; export { configServiceMock } from './config/mocks'; export { httpServerMock } from './http/http_server.mocks'; @@ -55,6 +56,7 @@ export { renderingMock } from './rendering/rendering_service.mock'; export { statusServiceMock } from './status/status_service.mock'; export { contextServiceMock } from './context/context_service.mock'; export { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; +export { coreUsageDataServiceMock } from './core_usage_data/core_usage_data_service.mock'; export function pluginInitializerContextConfigMock(config: T) { const globalConfig: SharedGlobalConfig = { @@ -157,6 +159,7 @@ function createCoreStartMock() { metrics: metricsServiceMock.createStartContract(), savedObjects: savedObjectsServiceMock.createStartContract(), uiSettings: uiSettingsServiceMock.createStartContract(), + coreUsageData: coreUsageDataServiceMock.createStartContract(), }; return mock; @@ -190,6 +193,7 @@ function createInternalCoreStartMock() { savedObjects: savedObjectsServiceMock.createInternalStartContract(), uiSettings: uiSettingsServiceMock.createStartContract(), auditTrail: auditTrailServiceMock.createStartContract(), + coreUsageData: coreUsageDataServiceMock.createStartContract(), }; return startDeps; } diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index ab3f471fd7942..a8249ed7e3218 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -251,5 +251,6 @@ export function createPluginStartContext( asScopedToClient: deps.uiSettings.asScopedToClient, }, auditTrail: deps.auditTrail, + coreUsageData: deps.coreUsageData, }; } diff --git a/src/core/server/saved_objects/saved_objects_service.mock.ts b/src/core/server/saved_objects/saved_objects_service.mock.ts index bd76658c21731..c56cdabf6e4cd 100644 --- a/src/core/server/saved_objects/saved_objects_service.mock.ts +++ b/src/core/server/saved_objects/saved_objects_service.mock.ts @@ -33,10 +33,11 @@ import { savedObjectsClientMock } from './service/saved_objects_client.mock'; import { typeRegistryMock } from './saved_objects_type_registry.mock'; import { migrationMocks } from './migrations/mocks'; import { ServiceStatusLevels } from '../status'; +import { ISavedObjectTypeRegistry } from './saved_objects_type_registry'; type SavedObjectsServiceContract = PublicMethodsOf; -const createStartContractMock = () => { +const createStartContractMock = (typeRegistry?: jest.Mocked) => { const startContrat: jest.Mocked = { getScopedClient: jest.fn(), createInternalRepository: jest.fn(), @@ -48,13 +49,15 @@ const createStartContractMock = () => { startContrat.getScopedClient.mockReturnValue(savedObjectsClientMock.create()); startContrat.createInternalRepository.mockReturnValue(savedObjectsRepositoryMock.create()); startContrat.createScopedRepository.mockReturnValue(savedObjectsRepositoryMock.create()); - startContrat.getTypeRegistry.mockReturnValue(typeRegistryMock.create()); + startContrat.getTypeRegistry.mockReturnValue(typeRegistry ?? typeRegistryMock.create()); return startContrat; }; -const createInternalStartContractMock = () => { - const internalStartContract: jest.Mocked = createStartContractMock(); +const createInternalStartContractMock = (typeRegistry?: jest.Mocked) => { + const internalStartContract: jest.Mocked = createStartContractMock( + typeRegistry + ); return internalStartContract; }; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 7742dad150cfa..a718ae8a6ff17 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -401,9 +401,102 @@ export interface ContextSetup { createContextContainer>(): IContextContainer; } +// @internal +export interface CoreConfigUsageData { + // (undocumented) + elasticsearch: { + sniffOnStart: boolean; + sniffIntervalMs?: number; + sniffOnConnectionFault: boolean; + numberOfHostsConfigured: number; + requestHeadersWhitelistConfigured: boolean; + customHeadersConfigured: boolean; + shardTimeoutMs: number; + requestTimeoutMs: number; + pingTimeoutMs: number; + logQueries: boolean; + ssl: { + verificationMode: 'none' | 'certificate' | 'full'; + certificateAuthoritiesConfigured: boolean; + certificateConfigured: boolean; + keyConfigured: boolean; + keystoreConfigured: boolean; + truststoreConfigured: boolean; + alwaysPresentCertificate: boolean; + }; + apiVersion: string; + healthCheckDelayMs: number; + }; + // (undocumented) + http: { + basePathConfigured: boolean; + maxPayloadInBytes: number; + rewriteBasePath: boolean; + keepaliveTimeout: number; + socketTimeout: number; + compression: { + enabled: boolean; + referrerWhitelistConfigured: boolean; + }; + xsrf: { + disableProtection: boolean; + whitelistConfigured: boolean; + }; + requestId: { + allowFromAnyIp: boolean; + ipAllowlistConfigured: boolean; + }; + ssl: { + certificateAuthoritiesConfigured: boolean; + certificateConfigured: boolean; + cipherSuites: string[]; + keyConfigured: boolean; + keystoreConfigured: boolean; + truststoreConfigured: boolean; + redirectHttpFromPortConfigured: boolean; + supportedProtocols: string[]; + clientAuthentication: 'none' | 'optional' | 'required'; + }; + }; + // (undocumented) + logging: { + appendersTypesUsed: string[]; + loggersConfiguredCount: number; + }; + // (undocumented) + savedObjects: { + maxImportPayloadBytes: number; + maxImportExportSizeBytes: number; + }; +} + +// @internal +export interface CoreEnvironmentUsageData { + // (undocumented) + memory: { + heapTotalBytes: number; + heapUsedBytes: number; + heapSizeLimit: number; + }; +} + // @internal (undocumented) export type CoreId = symbol; +// @internal +export interface CoreServicesUsageData { + // (undocumented) + savedObjects: { + indices: { + alias: string; + docsCount: number; + docsDeleted: number; + storeSizeBytes: number; + primaryStoreSizeBytes: number; + }[]; + }; +} + // @public export interface CoreSetup { // (undocumented) @@ -438,6 +531,8 @@ export interface CoreStart { auditTrail: AuditTrailStart; // (undocumented) capabilities: CapabilitiesStart; + // @internal (undocumented) + coreUsageData: CoreUsageDataStart; // (undocumented) elasticsearch: ElasticsearchServiceStart; // (undocumented) @@ -458,6 +553,21 @@ export interface CoreStatus { savedObjects: ServiceStatus; } +// @internal +export interface CoreUsageData { + // (undocumented) + config: CoreConfigUsageData; + // (undocumented) + environment: CoreEnvironmentUsageData; + // (undocumented) + services: CoreServicesUsageData; +} + +// @internal +export interface CoreUsageDataStart { + getCoreUsageData(): Promise; +} + // @public (undocumented) export interface CountResponse { // (undocumented) diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 600f45e0b50da..f38cac4f43768 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -48,6 +48,7 @@ import { config as statusConfig } from './status'; import { ContextService } from './context'; import { RequestHandlerContext } from '.'; import { InternalCoreSetup, InternalCoreStart, ServiceConfigDescriptor } from './internal_types'; +import { CoreUsageDataService } from './core_usage_data'; import { CoreRouteHandlerContext } from './core_route_handler_context'; const coreId = Symbol('core'); @@ -72,6 +73,7 @@ export class Server { private readonly logging: LoggingService; private readonly coreApp: CoreApp; private readonly auditTrail: AuditTrailService; + private readonly coreUsageData: CoreUsageDataService; #pluginsInitialized?: boolean; private coreStart?: InternalCoreStart; @@ -103,6 +105,7 @@ export class Server { this.httpResources = new HttpResourcesService(core); this.auditTrail = new AuditTrailService(core); this.logging = new LoggingService(core); + this.coreUsageData = new CoreUsageDataService(core); } public async setup() { @@ -184,6 +187,8 @@ export class Server { loggingSystem: this.loggingSystem, }); + this.coreUsageData.setup({ metrics: metricsSetup }); + const coreSetup: InternalCoreSetup = { capabilities: capabilitiesSetup, context: contextServiceSetup, @@ -235,6 +240,10 @@ export class Server { const uiSettingsStart = await this.uiSettings.start(); const metricsStart = await this.metrics.start(); const httpStart = this.http.getStartContract(); + const coreUsageDataStart = this.coreUsageData.start({ + elasticsearch: elasticsearchStart, + savedObjects: savedObjectsStart, + }); this.coreStart = { capabilities: capabilitiesStart, @@ -244,6 +253,7 @@ export class Server { savedObjects: savedObjectsStart, uiSettings: uiSettingsStart, auditTrail: auditTrailStart, + coreUsageData: coreUsageDataStart, }; const pluginsStart = await this.plugins.start(this.coreStart); diff --git a/src/dev/build/tasks/bin/scripts/kibana-keystore b/src/dev/build/tasks/bin/scripts/kibana-keystore index d811e70095548..9d2fd64c1c4eb 100755 --- a/src/dev/build/tasks/bin/scripts/kibana-keystore +++ b/src/dev/build/tasks/bin/scripts/kibana-keystore @@ -26,4 +26,4 @@ if [ -f "${CONFIG_DIR}/node.options" ]; then KBN_NODE_OPTS="$(grep -v ^# < ${CONFIG_DIR}/node.options | xargs)" fi -NODE_OPTIONS="$KBN_NODE_OPTS $NODE_OPTIONS" "${NODE}" "${DIR}/src/cli_keystore" "$@" +NODE_OPTIONS="$KBN_NODE_OPTS $NODE_OPTIONS" "${NODE}" "${DIR}/src/cli_keystore/dist" "$@" diff --git a/src/dev/build/tasks/bin/scripts/kibana-keystore.bat b/src/dev/build/tasks/bin/scripts/kibana-keystore.bat index 7e227141c8ba3..2214769efc410 100755 --- a/src/dev/build/tasks/bin/scripts/kibana-keystore.bat +++ b/src/dev/build/tasks/bin/scripts/kibana-keystore.bat @@ -28,7 +28,7 @@ IF EXIST "%CONFIG_DIR%\node.options" ( ) TITLE Kibana Keystore -"%NODE%" "%DIR%\src\cli_keystore" %* +"%NODE%" "%DIR%\src\cli_keystore\dist" %* :finally diff --git a/src/dev/build/tasks/bin/scripts/kibana-plugin b/src/dev/build/tasks/bin/scripts/kibana-plugin index f4486e9cf85fb..78fdb7702643f 100755 --- a/src/dev/build/tasks/bin/scripts/kibana-plugin +++ b/src/dev/build/tasks/bin/scripts/kibana-plugin @@ -26,4 +26,4 @@ if [ -f "${CONFIG_DIR}/node.options" ]; then KBN_NODE_OPTS="$(grep -v ^# < ${CONFIG_DIR}/node.options | xargs)" fi -NODE_OPTIONS="--no-warnings $KBN_NODE_OPTS $NODE_OPTIONS" NODE_ENV=production exec "${NODE}" "${DIR}/src/cli_plugin" "$@" +NODE_OPTIONS="--no-warnings $KBN_NODE_OPTS $NODE_OPTIONS" NODE_ENV=production exec "${NODE}" "${DIR}/src/cli_plugin/dist" "$@" diff --git a/src/dev/build/tasks/bin/scripts/kibana-plugin.bat b/src/dev/build/tasks/bin/scripts/kibana-plugin.bat index 4fb30977fda06..0a6d135565e50 100755 --- a/src/dev/build/tasks/bin/scripts/kibana-plugin.bat +++ b/src/dev/build/tasks/bin/scripts/kibana-plugin.bat @@ -32,7 +32,7 @@ IF EXIST "%CONFIG_DIR%\node.options" ( set "NODE_OPTIONS=--no-warnings %NODE_OPTIONS%" TITLE Kibana Server -"%NODE%" "%DIR%\src\cli_plugin" %* +"%NODE%" "%DIR%\src\cli_plugin\dist" %* :finally diff --git a/src/dev/build/tasks/bin/scripts/kibana.bat b/src/dev/build/tasks/bin/scripts/kibana.bat index 98dd9ec05a48c..19bf8157ed7c8 100755 --- a/src/dev/build/tasks/bin/scripts/kibana.bat +++ b/src/dev/build/tasks/bin/scripts/kibana.bat @@ -34,7 +34,7 @@ set "NODE_OPTIONS=--no-warnings --max-http-header-size=65536 %NODE_OPTIONS%" :: This should run independently as the last instruction :: as we need NODE_OPTIONS previously set to expand -"%NODE%" "%DIR%\src\cli" %* +"%NODE%" "%DIR%\src\cli\dist" %* :finally diff --git a/src/dev/build/tasks/copy_source_task.ts b/src/dev/build/tasks/copy_source_task.ts index a5039717760ae..b0ace3c63d82e 100644 --- a/src/dev/build/tasks/copy_source_task.ts +++ b/src/dev/build/tasks/copy_source_task.ts @@ -38,9 +38,9 @@ export const CopySource: Task = { '!src/cli/dev.js', '!src/functional_test_runner/**', '!src/dev/**', - '!src/setup_node_env/babel_register/index.js', - '!src/setup_node_env/babel_register/register.js', - '!**/public/**', + // this is the dev-only entry + '!src/setup_node_env/index.js', + '!**/public/**/*.{js,ts,tsx,json}', 'typings/**', 'config/kibana.yml', 'config/node.options', diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker index 959e1f8dc3e72..0039debe383bd 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker @@ -93,6 +93,7 @@ kibana_vars=( path.data pid.file regionmap + security.showInsecureClusterWarning server.basePath server.customResponseHeaders server.compression.enabled diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile b/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile index 24649a52b729b..e7523c1bf6032 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile @@ -58,6 +58,11 @@ RUN curl -L -o /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releas RUN echo "37f2c1f0372a45554f1b89924fbb134fc24c3756efaedf11e07f599494e0eff9 /usr/local/bin/dumb-init" | sha256sum -c - RUN chmod +x /usr/local/bin/dumb-init +RUN mkdir /usr/share/fonts/local +RUN curl -L -o /usr/share/fonts/local/NotoSansCJK-Regular.ttc https://github.com/googlefonts/noto-cjk/raw/NotoSansV2.001/NotoSansCJK-Regular.ttc +RUN echo "5dcd1c336cc9344cb77c03a0cd8982ca8a7dc97d620fd6c9c434e02dcb1ceeb3 /usr/share/fonts/local/NotoSansCJK-Regular.ttc" | sha256sum -c - +RUN fc-cache -v + # Bring in Kibana from the initial stage. COPY --from=builder --chown=1000:0 /usr/share/kibana /usr/share/kibana WORKDIR /usr/share/kibana diff --git a/src/dev/typescript/build_refs.ts b/src/dev/typescript/build_refs.ts index de006bd674e87..2cc8283111959 100644 --- a/src/dev/typescript/build_refs.ts +++ b/src/dev/typescript/build_refs.ts @@ -38,17 +38,11 @@ async function buildRefs(log: ToolingLog, projectPath: string) { export async function runBuildRefs() { run( - async ({ log, flags }) => { - await buildRefs(log, flags.project as string); + async ({ log }) => { + await buildAllRefs(log); }, { description: 'Build TypeScript projects', - flags: { - string: ['project'], - help: ` ---project Required, path to the tsconfig.refs.file - `, - }, } ); } diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index feeb8e0bf6e4c..e1f03b8a08847 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -25,6 +25,10 @@ const HANDLED_IN_NEW_PLATFORM = Joi.any().description( ); export default () => Joi.object({ + elastic: Joi.object({ + apm: HANDLED_IN_NEW_PLATFORM, + }).default(), + pkg: Joi.object({ version: Joi.string().default(Joi.ref('$version')), branch: Joi.string().default(Joi.ref('$branch')), diff --git a/src/legacy/ui/apm/index.js b/src/legacy/ui/apm/index.js index c43b7b01d1159..e58f6fb73320d 100644 --- a/src/legacy/ui/apm/index.js +++ b/src/legacy/ui/apm/index.js @@ -17,18 +17,10 @@ * under the License. */ -import { getConfig, isKibanaDistributable } from '../../../apm'; +import { getConfig } from '../../../apm'; import agent from 'elastic-apm-node'; -const apmEnabled = !isKibanaDistributable && process.env.ELASTIC_APM_ACTIVE === 'true'; - -export function apmImport() { - return apmEnabled ? 'import { init } from "@elastic/apm-rum"' : ''; -} - -export function apmInit(config) { - return apmEnabled ? `init(${config})` : ''; -} +const apmEnabled = getConfig()?.active; export function getApmConfig(requestPath) { if (!apmEnabled) { @@ -36,11 +28,9 @@ export function getApmConfig(requestPath) { } const config = { ...getConfig('kibana-frontend'), - ...{ - active: true, - pageLoadTransactionName: requestPath, - }, + pageLoadTransactionName: requestPath, }; + /** * Get current active backend transaction to make distrubuted tracing * work for rendering the app diff --git a/src/plugins/dashboard/public/attribute_service/attribute_service.mock.tsx b/src/plugins/dashboard/public/attribute_service/attribute_service.mock.tsx index 321a53361fc7a..09d6f5b4f1e0d 100644 --- a/src/plugins/dashboard/public/attribute_service/attribute_service.mock.tsx +++ b/src/plugins/dashboard/public/attribute_service/attribute_service.mock.tsx @@ -31,19 +31,16 @@ export const mockAttributeService = < R extends SavedObjectEmbeddableInput = SavedObjectEmbeddableInput >( type: string, - options?: AttributeServiceOptions, + options: AttributeServiceOptions, customCore?: jest.Mocked ): AttributeService => { const core = customCore ? customCore : coreMock.createStart(); - const service = new AttributeService( + return new AttributeService( type, jest.fn(), - core.savedObjects.client, - core.overlays, core.i18n.Context, core.notifications.toasts, - jest.fn().mockReturnValue(() => ({ getDisplayName: () => type })), - options + options, + jest.fn().mockReturnValue(() => ({ getDisplayName: () => type })) ); - return service; }; diff --git a/src/plugins/dashboard/public/attribute_service/attribute_service.test.ts b/src/plugins/dashboard/public/attribute_service/attribute_service.test.ts index ae8f034aec687..d7368b299c411 100644 --- a/src/plugins/dashboard/public/attribute_service/attribute_service.test.ts +++ b/src/plugins/dashboard/public/attribute_service/attribute_service.test.ts @@ -20,6 +20,7 @@ import { ATTRIBUTE_SERVICE_KEY } from './attribute_service'; import { mockAttributeService } from './attribute_service.mock'; import { coreMock } from '../../../../core/public/mocks'; +import { OnSaveProps } from '../../../saved_objects/public/save_modal'; interface TestAttributes { title: string; @@ -37,6 +38,30 @@ describe('attributeService', () => { let attributes: TestAttributes; let byValueInput: TestByValueInput; let byReferenceInput: { id: string; savedObjectId: string }; + const defaultSaveMethod = ( + type: string, + testAttributes: TestAttributes, + savedObjectId?: string + ): Promise<{ id: string }> => { + return new Promise(() => { + return { id: '123' }; + }); + }; + const defaultUnwrapMethod = (savedObjectId: string): Promise => { + return new Promise(() => { + return { ...attributes }; + }); + }; + const defaultCheckForDuplicateTitle = (props: OnSaveProps): Promise => { + return new Promise(() => { + return true; + }); + }; + const options = { + saveMethod: defaultSaveMethod, + unwrapMethod: defaultUnwrapMethod, + checkForDuplicateTitle: defaultCheckForDuplicateTitle, + }; beforeEach(() => { attributes = { @@ -55,9 +80,10 @@ describe('attributeService', () => { }); describe('determining input type', () => { - const defaultAttributeService = mockAttributeService(defaultTestType); + const defaultAttributeService = mockAttributeService(defaultTestType, options); const customAttributeService = mockAttributeService( - defaultTestType + defaultTestType, + options ); it('can determine input type given default types', () => { @@ -85,39 +111,32 @@ describe('attributeService', () => { }); describe('unwrapping attributes', () => { - it('can unwrap all default attributes when given reference type input', async () => { - const core = coreMock.createStart(); - core.savedObjects.client.get = jest.fn().mockResolvedValueOnce({ - attributes, + it('does not throw error when given reference type input with no unwrap method', async () => { + const attributeService = mockAttributeService(defaultTestType, { + saveMethod: defaultSaveMethod, + checkForDuplicateTitle: jest.fn(), }); - const attributeService = mockAttributeService( - defaultTestType, - undefined, - core - ); - expect(await attributeService.unwrapAttributes(byReferenceInput)).toEqual(attributes); + expect(await attributeService.unwrapAttributes(byReferenceInput)).toEqual(byReferenceInput); }); it('returns attributes when when given value type input', async () => { - const attributeService = mockAttributeService(defaultTestType); + const attributeService = mockAttributeService(defaultTestType, options); expect(await attributeService.unwrapAttributes(byValueInput)).toEqual(attributes); }); it('runs attributes through a custom unwrap method', async () => { - const core = coreMock.createStart(); - core.savedObjects.client.get = jest.fn().mockResolvedValueOnce({ - attributes, - }); - const attributeService = mockAttributeService( - defaultTestType, - { - customUnwrapMethod: (savedObject) => ({ - ...savedObject.attributes, - testAttr2: { array: [1, 2, 3, 4, 5], testAttr3: 'kibanana' }, - }), + const attributeService = mockAttributeService(defaultTestType, { + saveMethod: defaultSaveMethod, + unwrapMethod: (savedObjectId) => { + return new Promise((resolve) => { + return resolve({ + ...attributes, + testAttr2: { array: [1, 2, 3, 4, 5], testAttr3: 'kibanana' }, + }); + }); }, - core - ); + checkForDuplicateTitle: jest.fn(), + }); expect(await attributeService.unwrapAttributes(byReferenceInput)).toEqual({ ...attributes, testAttr2: { array: [1, 2, 3, 4, 5], testAttr3: 'kibanana' }, @@ -127,52 +146,40 @@ describe('attributeService', () => { describe('wrapping attributes', () => { it('returns given attributes when use ref type is false', async () => { - const attributeService = mockAttributeService(defaultTestType); + const attributeService = mockAttributeService(defaultTestType, options); expect(await attributeService.wrapAttributes(attributes, false)).toEqual({ attributes }); }); - it('updates existing saved object with new attributes when given id', async () => { + it('calls saveMethod with appropriate parameters', async () => { const core = coreMock.createStart(); + const saveMethod = jest.fn(); + saveMethod.mockReturnValueOnce({}); const attributeService = mockAttributeService( defaultTestType, - undefined, + { + saveMethod, + unwrapMethod: defaultUnwrapMethod, + checkForDuplicateTitle: defaultCheckForDuplicateTitle, + }, core ); expect(await attributeService.wrapAttributes(attributes, true, byReferenceInput)).toEqual( byReferenceInput ); - expect(core.savedObjects.client.update).toHaveBeenCalledWith( - defaultTestType, - '123', - attributes - ); - }); - - it('creates new saved object with attributes when given no id', async () => { - const core = coreMock.createStart(); - core.savedObjects.client.create = jest.fn().mockResolvedValueOnce({ - id: '678', - }); - const attributeService = mockAttributeService( - defaultTestType, - undefined, - core - ); - expect(await attributeService.wrapAttributes(attributes, true)).toEqual({ - savedObjectId: '678', - }); - expect(core.savedObjects.client.create).toHaveBeenCalledWith(defaultTestType, attributes); + expect(saveMethod).toHaveBeenCalledWith(defaultTestType, attributes, '123'); }); it('uses custom save method when given an id', async () => { - const customSaveMethod = jest.fn().mockReturnValue({ id: '123' }); + const saveMethod = jest.fn().mockReturnValue({ id: '123' }); const attributeService = mockAttributeService(defaultTestType, { - customSaveMethod, + saveMethod, + unwrapMethod: defaultUnwrapMethod, + checkForDuplicateTitle: defaultCheckForDuplicateTitle, }); expect(await attributeService.wrapAttributes(attributes, true, byReferenceInput)).toEqual( byReferenceInput ); - expect(customSaveMethod).toHaveBeenCalledWith( + expect(saveMethod).toHaveBeenCalledWith( defaultTestType, attributes, byReferenceInput.savedObjectId @@ -180,14 +187,16 @@ describe('attributeService', () => { }); it('uses custom save method given no id', async () => { - const customSaveMethod = jest.fn().mockReturnValue({ id: '678' }); + const saveMethod = jest.fn().mockReturnValue({ id: '678' }); const attributeService = mockAttributeService(defaultTestType, { - customSaveMethod, + saveMethod, + unwrapMethod: defaultUnwrapMethod, + checkForDuplicateTitle: defaultCheckForDuplicateTitle, }); expect(await attributeService.wrapAttributes(attributes, true)).toEqual({ savedObjectId: '678', }); - expect(customSaveMethod).toHaveBeenCalledWith(defaultTestType, attributes, undefined); + expect(saveMethod).toHaveBeenCalledWith(defaultTestType, attributes, undefined); }); }); }); diff --git a/src/plugins/dashboard/public/attribute_service/attribute_service.tsx b/src/plugins/dashboard/public/attribute_service/attribute_service.tsx index 7499a6fced72a..b46226ec4ab02 100644 --- a/src/plugins/dashboard/public/attribute_service/attribute_service.tsx +++ b/src/plugins/dashboard/public/attribute_service/attribute_service.tsx @@ -27,22 +27,10 @@ import { IEmbeddable, Container, EmbeddableStart, - EmbeddableFactory, EmbeddableFactoryNotFoundError, } from '../embeddable_plugin'; -import { - SavedObjectsClientContract, - SimpleSavedObject, - I18nStart, - NotificationsStart, - OverlayStart, -} from '../../../../core/public'; -import { - SavedObjectSaveModal, - OnSaveProps, - SaveResult, - checkForDuplicateTitle, -} from '../../../saved_objects/public'; +import { I18nStart, NotificationsStart } from '../../../../core/public'; +import { SavedObjectSaveModal, OnSaveProps, SaveResult } from '../../../saved_objects/public'; /** * The attribute service is a shared, generic service that embeddables can use to provide the functionality @@ -53,12 +41,13 @@ import { export const ATTRIBUTE_SERVICE_KEY = 'attributes'; export interface AttributeServiceOptions { - customSaveMethod?: ( + saveMethod: ( type: string, attributes: A, savedObjectId?: string ) => Promise<{ id?: string } | { error: Error }>; - customUnwrapMethod?: (savedObject: SimpleSavedObject) => A; + checkForDuplicateTitle: (props: OnSaveProps) => Promise; + unwrapMethod?: (savedObjectId: string) => Promise; } export class AttributeService< @@ -68,38 +57,37 @@ export class AttributeService< } = EmbeddableInput & { [ATTRIBUTE_SERVICE_KEY]: SavedObjectAttributes }, RefType extends SavedObjectEmbeddableInput = SavedObjectEmbeddableInput > { - private embeddableFactory?: EmbeddableFactory; - constructor( private type: string, private showSaveModal: ( saveModal: React.ReactElement, I18nContext: I18nStart['Context'] ) => void, - private savedObjectsClient: SavedObjectsClientContract, - private overlays: OverlayStart, private i18nContext: I18nStart['Context'], private toasts: NotificationsStart['toasts'], - getEmbeddableFactory?: EmbeddableStart['getEmbeddableFactory'], - private options?: AttributeServiceOptions + private options: AttributeServiceOptions, + getEmbeddableFactory?: EmbeddableStart['getEmbeddableFactory'] ) { if (getEmbeddableFactory) { const factory = getEmbeddableFactory(this.type); if (!factory) { throw new EmbeddableFactoryNotFoundError(this.type); } - this.embeddableFactory = factory; } } + private async defaultUnwrapMethod(input: RefType): Promise { + return new Promise((resolve) => { + // @ts-ignore + return resolve({ ...input }); + }); + } + public async unwrapAttributes(input: RefType | ValType): Promise { if (this.inputIsRefType(input)) { - const savedObject: SimpleSavedObject = await this.savedObjectsClient.get< - SavedObjectAttributes - >(this.type, input.savedObjectId); - return this.options?.customUnwrapMethod - ? this.options?.customUnwrapMethod(savedObject) - : { ...savedObject.attributes }; + return this.options.unwrapMethod + ? await this.options.unwrapMethod(input.savedObjectId) + : await this.defaultUnwrapMethod(input); } return input[ATTRIBUTE_SERVICE_KEY]; } @@ -118,25 +106,11 @@ export class AttributeService< return { [ATTRIBUTE_SERVICE_KEY]: newAttributes } as ValType; } try { - if (this.options?.customSaveMethod) { - const savedItem = await this.options.customSaveMethod( - this.type, - newAttributes, - savedObjectId - ); - if ('id' in savedItem) { - return { ...originalInput, savedObjectId: savedItem.id } as RefType; - } - return { ...originalInput } as RefType; - } - - if (savedObjectId) { - await this.savedObjectsClient.update(this.type, savedObjectId, newAttributes); - return { ...originalInput, savedObjectId } as RefType; + const savedItem = await this.options.saveMethod(this.type, newAttributes, savedObjectId); + if ('id' in savedItem) { + return { ...originalInput, savedObjectId: savedItem.id } as RefType; } - - const savedItem = await this.savedObjectsClient.create(this.type, newAttributes); - return { ...originalInput, savedObjectId: savedItem.id } as RefType; + return { ...originalInput } as RefType; } catch (error) { this.toasts.addDanger({ title: i18n.translate('dashboard.attributeService.saveToLibraryError', { @@ -181,21 +155,7 @@ export class AttributeService< } return new Promise((resolve, reject) => { const onSave = async (props: OnSaveProps): Promise => { - await checkForDuplicateTitle( - { - title: props.newTitle, - copyOnSave: false, - lastSavedTitle: '', - getEsType: () => this.type, - getDisplayName: this.embeddableFactory?.getDisplayName || (() => this.type), - }, - props.isTitleDuplicateConfirmed, - props.onTitleDuplicate, - { - savedObjectsClient: this.savedObjectsClient, - overlays: this.overlays, - } - ); + await this.options.checkForDuplicateTitle(props); try { const newAttributes = { ...input[ATTRIBUTE_SERVICE_KEY] }; newAttributes.title = props.newTitle; diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index 315afd61c7c44..bf9a3b2b8a217 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -31,7 +31,12 @@ export { } from './application'; export { DashboardConstants, createDashboardEditUrl } from './dashboard_constants'; -export { DashboardStart, DashboardUrlGenerator, DashboardFeatureFlagConfig } from './plugin'; +export { + DashboardSetup, + DashboardStart, + DashboardUrlGenerator, + DashboardFeatureFlagConfig, +} from './plugin'; export { DASHBOARD_APP_URL_GENERATOR, createDashboardUrlGenerator, diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index eadb3cd207e4d..91f603dfc6c77 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -145,7 +145,7 @@ interface StartDependencies { savedObjects: SavedObjectsStart; } -export type Setup = void; +export type DashboardSetup = void; export interface DashboardStart { getSavedDashboardLoader: () => SavedObjectLoader; @@ -164,7 +164,7 @@ export interface DashboardStart { R extends SavedObjectEmbeddableInput = SavedObjectEmbeddableInput >( type: string, - options?: AttributeServiceOptions + options: AttributeServiceOptions ) => AttributeService; } @@ -180,7 +180,7 @@ declare module '../../../plugins/ui_actions/public' { } export class DashboardPlugin - implements Plugin { + implements Plugin { constructor(private initializerContext: PluginInitializerContext) {} private appStateUpdater = new BehaviorSubject(() => ({})); @@ -193,17 +193,8 @@ export class DashboardPlugin public setup( core: CoreSetup, - { - share, - uiActions, - embeddable, - home, - kibanaLegacy, - urlForwarding, - data, - usageCollection, - }: SetupDependencies - ): Setup { + { share, uiActions, embeddable, home, urlForwarding, data, usageCollection }: SetupDependencies + ): DashboardSetup { this.dashboardFeatureFlagConfig = this.initializerContext.config.get< DashboardFeatureFlagConfig >(); @@ -491,12 +482,10 @@ export class DashboardPlugin new AttributeService( type, showSaveModal, - core.savedObjects.client, - core.overlays, core.i18n.Context, core.notifications.toasts, - embeddable.getEmbeddableFactory, - options + options, + embeddable.getEmbeddableFactory ), }; } diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index aac1fe1fde212..11dcbb01bf4a6 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -214,11 +214,13 @@ export { ISearchSetup, ISearchStart, toSnakeCase, + getAsyncOptions, getDefaultSearchParams, getShardTimeout, getTotalLoaded, shimHitsTotal, usageProvider, + shimAbortSignal, SearchUsage, } from './search'; diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.ts b/src/plugins/data/server/search/es_search/es_search_strategy.ts index e2ed500689cfa..6e185d30ad56a 100644 --- a/src/plugins/data/server/search/es_search/es_search_strategy.ts +++ b/src/plugins/data/server/search/es_search/es_search_strategy.ts @@ -23,7 +23,13 @@ import { Observable } from 'rxjs'; import { ApiResponse } from '@elastic/elasticsearch'; import { SearchUsage } from '../collectors/usage'; import { toSnakeCase } from './to_snake_case'; -import { ISearchStrategy, getDefaultSearchParams, getTotalLoaded, getShardTimeout } from '..'; +import { + ISearchStrategy, + getDefaultSearchParams, + getTotalLoaded, + getShardTimeout, + shimAbortSignal, +} from '..'; export const esSearchStrategyProvider = ( config$: Observable, @@ -52,10 +58,10 @@ export const esSearchStrategyProvider = ( }); try { - // Temporary workaround until https://github.com/elastic/elasticsearch-js/issues/1297 - const promise = context.core.elasticsearch.client.asCurrentUser.search(params); - if (options?.abortSignal) - options.abortSignal.addEventListener('abort', () => promise.abort()); + const promise = shimAbortSignal( + context.core.elasticsearch.client.asCurrentUser.search(params), + options?.abortSignal + ); const { body: rawResponse } = (await promise) as ApiResponse>; if (usage) usage.trackSuccess(rawResponse.took); diff --git a/src/plugins/data/server/search/es_search/get_default_search_params.ts b/src/plugins/data/server/search/es_search/get_default_search_params.ts index 13607fce51670..b51293b88fcec 100644 --- a/src/plugins/data/server/search/es_search/get_default_search_params.ts +++ b/src/plugins/data/server/search/es_search/get_default_search_params.ts @@ -42,3 +42,11 @@ export async function getDefaultSearchParams(uiSettingsClient: IUiSettingsClient trackTotalHits: true, }; } + +/** + @internal + */ +export const getAsyncOptions = () => ({ + waitForCompletionTimeout: '100ms', // Wait up to 100ms for the response to return + keepAlive: '1m', // Extend the TTL for this search request by one minute +}); diff --git a/src/plugins/data/server/search/es_search/index.ts b/src/plugins/data/server/search/es_search/index.ts index 1bd17fc986168..63ab7a025ee51 100644 --- a/src/plugins/data/server/search/es_search/index.ts +++ b/src/plugins/data/server/search/es_search/index.ts @@ -21,5 +21,6 @@ export { esSearchStrategyProvider } from './es_search_strategy'; export * from './get_default_search_params'; export { getTotalLoaded } from './get_total_loaded'; export * from './to_snake_case'; +export { shimAbortSignal } from './shim_abort_signal'; export { ES_SEARCH_STRATEGY, IEsSearchRequest, IEsSearchResponse } from '../../../common'; diff --git a/src/plugins/data/server/search/es_search/shim_abort_signal.test.ts b/src/plugins/data/server/search/es_search/shim_abort_signal.test.ts new file mode 100644 index 0000000000000..794b6535cc184 --- /dev/null +++ b/src/plugins/data/server/search/es_search/shim_abort_signal.test.ts @@ -0,0 +1,55 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { elasticsearchServiceMock } from '../../../../../core/server/mocks'; +import { shimAbortSignal } from '.'; + +describe('shimAbortSignal', () => { + it('aborts the promise if the signal is aborted', () => { + const promise = elasticsearchServiceMock.createSuccessTransportRequestPromise({ + success: true, + }); + const controller = new AbortController(); + shimAbortSignal(promise, controller.signal); + controller.abort(); + + expect(promise.abort).toHaveBeenCalled(); + }); + + it('returns the original promise', async () => { + const promise = elasticsearchServiceMock.createSuccessTransportRequestPromise({ + success: true, + }); + const controller = new AbortController(); + const response = await shimAbortSignal(promise, controller.signal); + + expect(response).toEqual(expect.objectContaining({ body: { success: true } })); + }); + + it('allows the promise to be aborted manually', () => { + const promise = elasticsearchServiceMock.createSuccessTransportRequestPromise({ + success: true, + }); + const controller = new AbortController(); + const enhancedPromise = shimAbortSignal(promise, controller.signal); + + enhancedPromise.abort(); + expect(promise.abort).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/data/server/search/es_search/shim_abort_signal.ts b/src/plugins/data/server/search/es_search/shim_abort_signal.ts new file mode 100644 index 0000000000000..14a4a6919c5af --- /dev/null +++ b/src/plugins/data/server/search/es_search/shim_abort_signal.ts @@ -0,0 +1,41 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; + +/** + * + * @internal + * NOTE: Temporary workaround until https://github.com/elastic/elasticsearch-js/issues/1297 + * is resolved + * + * @param promise a TransportRequestPromise + * @param signal optional AbortSignal + * + * @returns a TransportRequestPromise that will be aborted if the signal is aborted + */ +export const shimAbortSignal = >( + promise: T, + signal: AbortSignal | undefined +): T => { + if (signal) { + signal.addEventListener('abort', () => promise.abort()); + } + return promise; +}; diff --git a/src/plugins/data/server/search/routes/call_msearch.ts b/src/plugins/data/server/search/routes/call_msearch.ts index 764dcd189f8db..8103b680c6bbb 100644 --- a/src/plugins/data/server/search/routes/call_msearch.ts +++ b/src/plugins/data/server/search/routes/call_msearch.ts @@ -25,7 +25,7 @@ import { IUiSettingsClient, IScopedClusterClient, SharedGlobalConfig } from 'src import { MsearchRequestBody, MsearchResponse } from '../../../common/search/search_source'; import { shimHitsTotal } from './shim_hits_total'; -import { getShardTimeout, getDefaultSearchParams, toSnakeCase } from '..'; +import { getShardTimeout, getDefaultSearchParams, toSnakeCase, shimAbortSignal } from '..'; /** @internal */ export function convertRequestBody( @@ -74,18 +74,17 @@ export function getCallMsearch(dependencies: CallMsearchDependencies) { const body = convertRequestBody(params.body, timeout); - // Temporary workaround until https://github.com/elastic/elasticsearch-js/issues/1297 - const promise = esClient.asCurrentUser.msearch( - { - body, - }, - { - querystring: toSnakeCase(defaultParams), - } + const promise = shimAbortSignal( + esClient.asCurrentUser.msearch( + { + body, + }, + { + querystring: toSnakeCase(defaultParams), + } + ), + params.signal ); - if (params.signal) { - params.signal.addEventListener('abort', () => promise.abort()); - } const response = (await promise) as ApiResponse<{ responses: Array> }>; return { diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index fed0c1a02297e..45dbdee0f846b 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -47,6 +47,7 @@ import { SearchResponse } from 'elasticsearch'; import { SerializedFieldFormat as SerializedFieldFormat_2 } from 'src/plugins/expressions/common'; import { ShardsResponse } from 'elasticsearch'; import { ToastInputFields } from 'src/core/public/notifications'; +import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; import { Type } from '@kbn/config-schema'; import { TypeOf } from '@kbn/config-schema'; import { Unit } from '@elastic/datemath'; @@ -354,6 +355,12 @@ export type Filter = { query?: any; }; +// @internal (undocumented) +export const getAsyncOptions: () => { + waitForCompletionTimeout: string; + keepAlive: string; +}; + // Warning: (ae-forgotten-export) The symbol "IUiSettingsClient" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "getDefaultSearchParams" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -980,6 +987,9 @@ export interface SearchUsage { trackSuccess(duration: number): Promise; } +// @internal +export const shimAbortSignal: >(promise: T, signal: AbortSignal | undefined) => T; + // @internal export function shimHitsTotal(response: SearchResponse): { hits: { @@ -1115,19 +1125,19 @@ export function usageProvider(core: CoreSetup_2): SearchUsage; // src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:127:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:127:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:226:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:226:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:226:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:226:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:228:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:229:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:238:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:239:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:240:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:244:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:245:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:249:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:252:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:228:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:228:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:228:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:228:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:230:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:231:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:240:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:241:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:242:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:246:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:247:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:251:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:254:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index_patterns/index_patterns_service.ts:50:14 - (ae-forgotten-export) The symbol "IndexPatternsService" needs to be exported by the entry point index.d.ts // src/plugins/data/server/plugin.ts:88:66 - (ae-forgotten-export) The symbol "DataEnhancements" needs to be exported by the entry point index.d.ts // src/plugins/data/server/search/types.ts:78:5 - (ae-forgotten-export) The symbol "ISearchStartSearchSource" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/discover/common/index.ts b/src/plugins/discover/common/index.ts index 72030d91220b5..4334af63539e3 100644 --- a/src/plugins/discover/common/index.ts +++ b/src/plugins/discover/common/index.ts @@ -27,3 +27,4 @@ export const FIELDS_LIMIT_SETTING = 'fields:popularLimit'; export const CONTEXT_DEFAULT_SIZE_SETTING = 'context:defaultSize'; export const CONTEXT_STEP_SETTING = 'context:step'; export const CONTEXT_TIE_BREAKER_FIELDS_SETTING = 'context:tieBreakerFields'; +export const MODIFY_COLUMNS_ON_SWITCH = 'discover:modifyColumnsOnSwitch'; diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 92b96d11723e0..078a047324113 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -71,7 +71,7 @@ const { import { getRootBreadcrumbs, getSavedSearchBreadcrumbs } from '../helpers/breadcrumbs'; import { validateTimeRange } from '../helpers/validate_time_range'; import { popularizeField } from '../helpers/popularize_field'; - +import { getSwitchIndexPatternAppState } from '../helpers/get_switch_index_pattern_app_state'; import { getIndexPatternId } from '../helpers/get_index_pattern_id'; import { addFatalError } from '../../../../kibana_legacy/public'; import { @@ -80,6 +80,7 @@ import { SORT_DEFAULT_ORDER_SETTING, SEARCH_ON_PAGE_LOAD_SETTING, DOC_HIDE_TIME_COLUMN_SETTING, + MODIFY_COLUMNS_ON_SWITCH, } from '../../../common'; const fetchStatuses = { @@ -253,6 +254,11 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise if (!_.isEqual(newStatePartial, oldStatePartial)) { $scope.$evalAsync(async () => { + if (oldStatePartial.index !== newStatePartial.index) { + //in case of index switch the route has currently to be reloaded, legacy + return; + } + $scope.state = { ...newState }; // detect changes that should trigger fetching of new data @@ -277,8 +283,18 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise }); $scope.setIndexPattern = async (id) => { - await replaceUrlAppState({ index: id }); - $route.reload(); + const nextIndexPattern = await indexPatterns.get(id); + if (nextIndexPattern) { + const nextAppState = getSwitchIndexPatternAppState( + $scope.indexPattern, + nextIndexPattern, + $scope.state.columns, + $scope.state.sort, + config.get(MODIFY_COLUMNS_ON_SWITCH) + ); + await replaceUrlAppState(nextAppState); + $route.reload(); + } }; // update data source when filters update diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts b/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts index e7fafde2e68d0..4911f207f0a5f 100644 --- a/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts +++ b/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts @@ -18,6 +18,7 @@ */ import { find, template } from 'lodash'; +import { stringify } from 'query-string'; import $ from 'jquery'; import rison from 'rison-node'; import '../../doc_viewer'; @@ -25,7 +26,7 @@ import '../../doc_viewer'; import openRowHtml from './table_row/open.html'; import detailsHtml from './table_row/details.html'; -import { dispatchRenderComplete } from '../../../../../../kibana_utils/public'; +import { dispatchRenderComplete, url } from '../../../../../../kibana_utils/public'; import { DOC_HIDE_TIME_COLUMN_SETTING } from '../../../../../common'; import cellTemplateHtml from '../components/table_row/cell.html'; import truncateByHeightTemplateHtml from '../components/table_row/truncate_by_height.html'; @@ -49,7 +50,7 @@ interface LazyScope extends ng.IScope { [key: string]: any; } -export function createTableRowDirective($compile: ng.ICompileService, $httpParamSerializer: any) { +export function createTableRowDirective($compile: ng.ICompileService) { const cellTemplate = template(noWhiteSpace(cellTemplateHtml)); const truncateByHeightTemplate = template(noWhiteSpace(truncateByHeightTemplateHtml)); @@ -114,26 +115,25 @@ export function createTableRowDirective($compile: ng.ICompileService, $httpParam }; $scope.getContextAppHref = () => { - const path = `#/context/${encodeURIComponent($scope.indexPattern.id)}/${encodeURIComponent( - $scope.row._id - )}`; const globalFilters: any = getServices().filterManager.getGlobalFilters(); const appFilters: any = getServices().filterManager.getAppFilters(); - const hash = $httpParamSerializer({ - _g: encodeURI( - rison.encode({ + + const hash = stringify( + url.encodeQuery({ + _g: rison.encode({ filters: globalFilters || [], - }) - ), - _a: encodeURI( - rison.encode({ + }), + _a: rison.encode({ columns: $scope.columns, filters: (appFilters || []).map(esFilters.disableFilter), - }) - ), - }); + }), + }), + { encode: false, sort: false } + ); - return `${path}?${hash}`; + return `#/context/${encodeURIComponent($scope.indexPattern.id)}/${encodeURIComponent( + $scope.row._id + )}?${hash}`; }; // create a tr element that lists the value for each *column* diff --git a/src/plugins/discover/public/application/helpers/get_switch_index_pattern_app_state.test.ts b/src/plugins/discover/public/application/helpers/get_switch_index_pattern_app_state.test.ts new file mode 100644 index 0000000000000..d35346ed24737 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/get_switch_index_pattern_app_state.test.ts @@ -0,0 +1,90 @@ +/* + * 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 { getSwitchIndexPatternAppState } from './get_switch_index_pattern_app_state'; +import { IIndexPatternFieldList, IndexPattern } from '../../../../data/common/index_patterns'; + +const currentIndexPattern: IndexPattern = { + id: 'prev', + getFieldByName(name) { + return this.fields.getByName(name); + }, + fields: { + getByName: (name: string) => { + const fields = [ + { name: 'category', sortable: true }, + { name: 'name', sortable: true }, + ] as IIndexPatternFieldList; + return fields.find((field) => field.name === name); + }, + }, +} as IndexPattern; + +const nextIndexPattern = { + id: 'next', + getFieldByName(name) { + return this.fields.getByName(name); + }, + fields: { + getByName: (name: string) => { + const fields = [{ name: 'category', sortable: true }] as IIndexPatternFieldList; + return fields.find((field) => field.name === name); + }, + }, +} as IndexPattern; + +describe('Discover getSwitchIndexPatternAppState', () => { + test('removing fields that are not part of the next index pattern, keeping unknown fields ', async () => { + const result = getSwitchIndexPatternAppState( + currentIndexPattern, + nextIndexPattern, + ['category', 'name', 'unknown'], + [['category', 'desc']] + ); + expect(result.columns).toEqual(['category', 'unknown']); + expect(result.sort).toEqual([['category', 'desc']]); + }); + test('removing sorted by fields that are not part of the next index pattern', async () => { + const result = getSwitchIndexPatternAppState( + currentIndexPattern, + nextIndexPattern, + ['name'], + [ + ['category', 'desc'], + ['name', 'asc'], + ] + ); + expect(result.columns).toEqual(['_source']); + expect(result.sort).toEqual([['category', 'desc']]); + }); + test('removing sorted by fields that without modifying columns', async () => { + const result = getSwitchIndexPatternAppState( + currentIndexPattern, + nextIndexPattern, + ['name'], + [ + ['category', 'desc'], + ['name', 'asc'], + ], + false + ); + expect(result.columns).toEqual(['name']); + expect(result.sort).toEqual([['category', 'desc']]); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/get_switch_index_pattern_app_state.ts b/src/plugins/discover/public/application/helpers/get_switch_index_pattern_app_state.ts new file mode 100644 index 0000000000000..458b9b7e066fd --- /dev/null +++ b/src/plugins/discover/public/application/helpers/get_switch_index_pattern_app_state.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 { getSortArray } from '../angular/doc_table'; +import { SortPairArr } from '../angular/doc_table/lib/get_sort'; +import { IndexPattern } from '../../kibana_services'; + +/** + * Helper function to remove or adapt the currently selected columns/sort to be valid with the next + * index pattern, returns a new state object + */ +export function getSwitchIndexPatternAppState( + currentIndexPattern: IndexPattern, + nextIndexPattern: IndexPattern, + currentColumns: string[], + currentSort: SortPairArr[], + modifyColumns: boolean = true +) { + const nextColumns = modifyColumns + ? currentColumns.filter( + (column) => + nextIndexPattern.fields.getByName(column) || !currentIndexPattern.fields.getByName(column) + ) + : currentColumns; + const nextSort = getSortArray(currentSort, nextIndexPattern); + return { + index: nextIndexPattern.id, + columns: nextColumns.length ? nextColumns : ['_source'], + sort: nextSort, + }; +} diff --git a/src/plugins/discover/server/ui_settings.ts b/src/plugins/discover/server/ui_settings.ts index 3eca11cc584a9..5447b982eef14 100644 --- a/src/plugins/discover/server/ui_settings.ts +++ b/src/plugins/discover/server/ui_settings.ts @@ -32,6 +32,7 @@ import { CONTEXT_DEFAULT_SIZE_SETTING, CONTEXT_STEP_SETTING, CONTEXT_TIE_BREAKER_FIELDS_SETTING, + MODIFY_COLUMNS_ON_SWITCH, } from '../common'; export const uiSettings: Record = { @@ -163,4 +164,15 @@ export const uiSettings: Record = { category: ['discover'], schema: schema.arrayOf(schema.string()), }, + [MODIFY_COLUMNS_ON_SWITCH]: { + name: i18n.translate('discover.advancedSettings.discover.modifyColumnsOnSwitchTitle', { + defaultMessage: 'Modify columns when changing index patterns', + }), + value: true, + description: i18n.translate('discover.advancedSettings.discover.modifyColumnsOnSwitchText', { + defaultMessage: 'Remove columns that not available in the new index pattern.', + }), + category: ['discover'], + schema: schema.boolean(), + }, }; diff --git a/src/plugins/es_ui_shared/static/forms/components/field.tsx b/src/plugins/es_ui_shared/static/forms/components/field.tsx index 78702e902ecf6..217a811168814 100644 --- a/src/plugins/es_ui_shared/static/forms/components/field.tsx +++ b/src/plugins/es_ui_shared/static/forms/components/field.tsx @@ -38,6 +38,7 @@ import { SelectField, SuperSelectField, ToggleField, + JsonEditorField, } from './fields'; const mapTypeToFieldComponent: { [key: string]: ComponentType } = { @@ -52,6 +53,7 @@ const mapTypeToFieldComponent: { [key: string]: ComponentType } = { [FIELD_TYPES.SELECT]: SelectField, [FIELD_TYPES.SUPER_SELECT]: SuperSelectField, [FIELD_TYPES.TOGGLE]: ToggleField, + [FIELD_TYPES.JSON]: JsonEditorField, }; export const Field = (props: Props) => { diff --git a/src/plugins/es_ui_shared/static/forms/components/fields/json_editor_field.tsx b/src/plugins/es_ui_shared/static/forms/components/fields/json_editor_field.tsx index fd57e098cf806..e2d80825f397e 100644 --- a/src/plugins/es_ui_shared/static/forms/components/fields/json_editor_field.tsx +++ b/src/plugins/es_ui_shared/static/forms/components/fields/json_editor_field.tsx @@ -23,7 +23,7 @@ import { JsonEditor, OnJsonEditorUpdateHandler } from '../../../../public'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../hook_form_lib'; interface Props { - field: FieldHook; + field: FieldHook; euiCodeEditorProps?: { [key: string]: any }; [key: string]: any; } @@ -44,7 +44,7 @@ export const JsonEditorField = ({ field, ...rest }: Props) => { ['validations']; - children: (args: { - items: ArrayItem[]; - error: string | null; - addItem: () => void; - removeItem: (id: number) => void; - moveItem: (sourceIdx: number, destinationIdx: number) => void; - form: FormHook; - }) => JSX.Element; + validations?: FieldConfig['validations']; + children: (formFieldArray: FormArrayField) => JSX.Element; } export interface ArrayItem { @@ -45,6 +38,15 @@ export interface ArrayItem { isNew: boolean; } +export interface FormArrayField { + items: ArrayItem[]; + error: string | null; + addItem: () => void; + removeItem: (id: number) => void; + moveItem: (sourceIdx: number, destinationIdx: number) => void; + form: FormHook; +} + /** * Use UseArray to dynamically add fields to your form. * @@ -71,7 +73,7 @@ export const UseArray = ({ const uniqueId = useRef(0); const form = useFormContext(); - const { getFieldDefaultValue } = form; + const { __getFieldDefaultValue } = form; const getNewItemAtIndex = useCallback( (index: number): ArrayItem => ({ @@ -84,7 +86,7 @@ export const UseArray = ({ const fieldDefaultValue = useMemo(() => { const defaultValues = readDefaultValueOnForm - ? (getFieldDefaultValue(path) as any[]) + ? (__getFieldDefaultValue(path) as any[]) : undefined; const getInitialItemsFromValues = (values: any[]): ArrayItem[] => @@ -97,17 +99,23 @@ export const UseArray = ({ return defaultValues ? getInitialItemsFromValues(defaultValues) : new Array(initialNumberOfItems).fill('').map((_, i) => getNewItemAtIndex(i)); - }, [path, initialNumberOfItems, readDefaultValueOnForm, getFieldDefaultValue, getNewItemAtIndex]); + }, [ + path, + initialNumberOfItems, + readDefaultValueOnForm, + __getFieldDefaultValue, + getNewItemAtIndex, + ]); // Create a new hook field with the "hasValue" set to false so we don't use its value to build the final form data. // Apart from that the field behaves like a normal field and is hooked into the form validation lifecycle. - const fieldConfigBase: FieldConfig & InternalFieldConfig = { + const fieldConfigBase: FieldConfig & InternalFieldConfig = { defaultValue: fieldDefaultValue, - errorDisplayDelay: 0, + valueChangeDebounceTime: 0, isIncludedInOutput: false, }; - const fieldConfig: FieldConfig & InternalFieldConfig = validations + const fieldConfig: FieldConfig & InternalFieldConfig = validations ? { validations, ...fieldConfigBase } : fieldConfigBase; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.tsx index 6b913f246abbb..a3a0984d4a736 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.tsx @@ -19,23 +19,23 @@ import React, { FunctionComponent } from 'react'; -import { FieldHook, FieldConfig } from '../types'; +import { FieldHook, FieldConfig, FormData } from '../types'; import { useField } from '../hooks'; import { useFormContext } from '../form_context'; -export interface Props { +export interface Props { path: string; - config?: FieldConfig; + config?: FieldConfig; defaultValue?: T; - component?: FunctionComponent | 'input'; + component?: FunctionComponent; componentProps?: Record; readDefaultValueOnForm?: boolean; - onChange?: (value: T) => void; - children?: (field: FieldHook) => JSX.Element; + onChange?: (value: I) => void; + children?: (field: FieldHook) => JSX.Element; [key: string]: any; } -function UseFieldComp(props: Props) { +function UseFieldComp(props: Props) { const { path, config, @@ -48,18 +48,16 @@ function UseFieldComp(props: Props) { ...rest } = props; - const form = useFormContext(); + const form = useFormContext(); const ComponentToRender = component ?? 'input'; - // For backward compatibility we merge the "componentProps" prop into the "rest" - const propsToForward = - componentProps !== undefined ? { ...componentProps, ...rest } : { ...rest }; + const propsToForward = { ...componentProps, ...rest }; - const fieldConfig: FieldConfig & { initialValue?: T } = + const fieldConfig: FieldConfig & { initialValue?: T } = config !== undefined ? { ...config } : ({ ...form.__readFieldConfigFromSchema(path), - } as Partial>); + } as Partial>); if (defaultValue !== undefined) { // update the form "defaultValue" ref object so when/if we reset the form we can go back to this value @@ -70,21 +68,12 @@ function UseFieldComp(props: Props) { } else { if (readDefaultValueOnForm) { // Read the field initial value from the "defaultValue" object passed to the form - fieldConfig.initialValue = (form.getFieldDefaultValue(path) as T) ?? fieldConfig.defaultValue; + fieldConfig.initialValue = + (form.__getFieldDefaultValue(path) as T) ?? fieldConfig.defaultValue; } } - if (!fieldConfig.path) { - (fieldConfig.path as any) = path; - } else { - if (fieldConfig.path !== path) { - throw new Error( - `Field path mismatch. Got "${path}" but field config has "${fieldConfig.path}".` - ); - } - } - - const field = useField(form, path, fieldConfig, onChange); + const field = useField(form, path, fieldConfig, onChange); // Children prevails over anything else provided. if (children) { @@ -111,9 +100,13 @@ export const UseField = React.memo(UseFieldComp) as typeof UseFieldComp; * Get a component providing some common props for all instances. * @param partialProps Partial props to apply to all instances */ -export function getUseField(partialProps: Partial>) { - return function (props: Partial>) { - const componentProps = { ...partialProps, ...props } as Props; - return {...componentProps} />; +export function getUseField( + partialProps: Partial> +) { + return function ( + props: Partial> + ) { + const componentProps = { ...partialProps, ...props } as Props; + return {...componentProps} />; }; } diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_multi_fields.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_multi_fields.tsx index d69527e36249b..20f4608352d94 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_multi_fields.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_multi_fields.tsx @@ -22,27 +22,27 @@ import React from 'react'; import { UseField, Props as UseFieldProps } from './use_field'; import { FieldHook } from '../types'; -type FieldsArray = Array<{ id: string } & Omit, 'children'>>; +type FieldsArray = Array<{ id: string } & Omit, 'children'>>; -interface Props { - fields: { [key: string]: Exclude, 'children'> }; - children: (fields: { [key: string]: FieldHook }) => JSX.Element; +interface Props { + fields: { [K in keyof T]: Exclude, 'children'> }; + children: (fields: { [K in keyof T]: FieldHook }) => JSX.Element; } -export const UseMultiFields = ({ fields, children }: Props) => { +export function UseMultiFields({ fields, children }: Props) { const fieldsArray = Object.entries(fields).reduce( - (acc, [fieldId, field]) => [...acc, { id: fieldId, ...field }], + (acc, [fieldId, field]) => [...acc, { id: fieldId, ...(field as FieldHook) }], [] as FieldsArray ); - const hookFields: { [key: string]: FieldHook } = {}; + const hookFields: { [K in keyof T]: FieldHook } = {} as any; const renderField = (index: number) => { const { id } = fieldsArray[index]; return ( - + {(field) => { - hookFields[id] = field; + hookFields[id as keyof T] = field; return index === fieldsArray.length - 1 ? children(hookFields) : renderField(index + 1); }} @@ -54,4 +54,4 @@ export const UseMultiFields = ({ fields, children }: Props) => { } return renderField(0); -}; +} diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts index 4056947483107..3a2ffdc3af146 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts @@ -30,11 +30,15 @@ export const FIELD_TYPES = { SELECT: 'select', SUPER_SELECT: 'superSelect', MULTI_SELECT: 'multiSelect', + JSON: 'json', }; // Validation types export const VALIDATION_TYPES = { - FIELD: 'field', // Default validation error (on the field value) - ASYNC: 'async', // Returned from asynchronous validations - ARRAY_ITEM: 'arrayItem', // If the field value is an Array, this error would be returned if an _item_ of the array is invalid + /** Default validation error (on the field value) */ + FIELD: 'field', + /** Returned from asynchronous validations */ + ASYNC: 'async', + /** If the field value is an Array, this error type would be returned if an _item_ of the array is invalid */ + ARRAY_ITEM: 'arrayItem', }; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_data_context.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_data_context.tsx index 0e6a75e9c5065..0670220ccd0c9 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_data_context.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_data_context.tsx @@ -22,8 +22,8 @@ import React, { createContext, useContext, useMemo } from 'react'; import { FormData, FormHook } from './types'; import { Subject } from './lib'; -export interface Context { - getFormData$: () => Subject; +export interface Context { + getFormData$: () => Subject; getFormData: FormHook['getFormData']; } diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts index bb4aae6eccae8..7b21b6638aeac 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts @@ -19,7 +19,14 @@ import { useMemo, useState, useEffect, useRef, useCallback } from 'react'; -import { FormHook, FieldHook, FieldConfig, FieldValidateResponse, ValidationError } from '../types'; +import { + FormHook, + FieldHook, + FieldConfig, + FieldValidateResponse, + ValidationError, + FormData, +} from '../types'; import { FIELD_TYPES, VALIDATION_TYPES } from '../constants'; export interface InternalFieldConfig { @@ -27,11 +34,11 @@ export interface InternalFieldConfig { isIncludedInOutput?: boolean; } -export const useField = ( - form: FormHook, +export const useField = ( + form: FormHook, path: string, - config: FieldConfig & InternalFieldConfig = {}, - valueChangeListener?: (value: T) => void + config: FieldConfig & InternalFieldConfig = {}, + valueChangeListener?: (value: I) => void ) => { const { type = FIELD_TYPES.TEXT, @@ -44,7 +51,7 @@ export const useField = ( validations, formatters, fieldsToValidateOnChange, - errorDisplayDelay = form.__options.errorDisplayDelay, + valueChangeDebounceTime = form.__options.valueChangeDebounceTime, serializer, deserializer, } = config; @@ -68,7 +75,7 @@ export const useField = ( [initialValue, deserializer] ); - const [value, setStateValue] = useState(deserializeValue); + const [value, setStateValue] = useState(deserializeValue); const [errors, setErrors] = useState([]); const [isPristine, setPristine] = useState(true); const [isValidating, setValidating] = useState(false); @@ -84,9 +91,9 @@ export const useField = ( // -- HELPERS // ---------------------------------- - const serializeValue: FieldHook['__serializeValue'] = useCallback( - (rawValue = value) => { - return serializer ? serializer(rawValue) : rawValue; + const serializeValue: FieldHook['__serializeValue'] = useCallback( + (internalValue: I = value) => { + return serializer ? serializer(internalValue) : ((internalValue as unknown) as T); }, [serializer, value] ); @@ -129,16 +136,8 @@ export const useField = ( const changeIteration = ++changeCounter.current; const startTime = Date.now(); - if (debounceTimeout.current) { - clearTimeout(debounceTimeout.current); - debounceTimeout.current = null; - } - setPristine(false); - - if (errorDisplayDelay > 0) { - setIsChangingValue(true); - } + setIsChangingValue(true); // Notify listener if (valueChangeListener) { @@ -161,22 +160,24 @@ export const useField = ( * and then, we verify how long we've already waited for as form.__validateFields() is asynchronous * and might already have taken more than the specified delay) */ - if (errorDisplayDelay > 0 && changeIteration === changeCounter.current) { - const delta = Date.now() - startTime; - if (delta < errorDisplayDelay) { - debounceTimeout.current = setTimeout(() => { - debounceTimeout.current = null; - setIsChangingValue(false); - }, errorDisplayDelay - delta); - } else { - setIsChangingValue(false); + if (changeIteration === changeCounter.current) { + if (valueChangeDebounceTime > 0) { + const delta = Date.now() - startTime; + if (delta < valueChangeDebounceTime) { + debounceTimeout.current = setTimeout(() => { + debounceTimeout.current = null; + setIsChangingValue(false); + }, valueChangeDebounceTime - delta); + return; + } } + setIsChangingValue(false); } }, [ path, value, valueChangeListener, - errorDisplayDelay, + valueChangeDebounceTime, fieldsToValidateOnChange, __updateFormDataAt, __validateFields, @@ -207,7 +208,7 @@ export const useField = ( validationTypeToValidate, }: { formData: any; - value: T; + value: I; validationTypeToValidate?: string; }): ValidationError[] | Promise => { if (!validations) { @@ -339,7 +340,7 @@ export const useField = ( * If a validationType is provided then only that validation will be executed, * skipping the other type of validation that might exist. */ - const validate: FieldHook['validate'] = useCallback( + const validate: FieldHook['validate'] = useCallback( (validationData = {}) => { const { formData = getFormData({ unflatten: false }), @@ -392,14 +393,14 @@ export const useField = ( * * @param newValue The new value to assign to the field */ - const setValue: FieldHook['setValue'] = useCallback( + const setValue: FieldHook['setValue'] = useCallback( (newValue) => { setStateValue((prev) => { - let formattedValue: T; + let formattedValue: I; if (typeof newValue === 'function') { - formattedValue = formatInputValue((newValue as Function)(prev)); + formattedValue = formatInputValue((newValue as Function)(prev)); } else { - formattedValue = formatInputValue(newValue); + formattedValue = formatInputValue(newValue); } return formattedValue; }); @@ -407,7 +408,7 @@ export const useField = ( [formatInputValue] ); - const _setErrors: FieldHook['setErrors'] = useCallback((_errors) => { + const _setErrors: FieldHook['setErrors'] = useCallback((_errors) => { setErrors( _errors.map((error) => ({ validationType: VALIDATION_TYPES.FIELD, @@ -422,13 +423,13 @@ export const useField = ( * * @param event Form input change event */ - const onChange: FieldHook['onChange'] = useCallback( + const onChange: FieldHook['onChange'] = useCallback( (event) => { const newValue = {}.hasOwnProperty.call(event!.target, 'checked') ? event.target.checked : event.target.value; - setValue((newValue as unknown) as T); + setValue((newValue as unknown) as I); }, [setValue] ); @@ -443,7 +444,7 @@ export const useField = ( * * @param validationType The validation type to return error messages from */ - const getErrorsMessages: FieldHook['getErrorsMessages'] = useCallback( + const getErrorsMessages: FieldHook['getErrorsMessages'] = useCallback( (args = {}) => { const { errorCode, validationType = VALIDATION_TYPES.FIELD } = args; const errorMessages = errors.reduce((messages, error) => { @@ -464,30 +465,64 @@ export const useField = ( [errors] ); - const reset: FieldHook['reset'] = useCallback( + /** + * Handler to update the state and make sure the component is still mounted. + * When resetting the form, some field might get unmounted (e.g. a toggle on "true" becomes "false" and now certain fields should not be in the DOM). + * In that scenario there is a race condition in the "reset" method below, because the useState() hook is not synchronous. + * + * A better approach would be to have the state in a reducer and being able to update all values in a single dispatch action. + */ + const updateStateIfMounted = useCallback( + ( + state: 'isPristine' | 'isValidating' | 'isChangingValue' | 'isValidated' | 'errors' | 'value', + nextValue: any + ) => { + if (isMounted.current === false) { + return; + } + + switch (state) { + case 'value': + return setValue(nextValue); + case 'errors': + return setErrors(nextValue); + case 'isChangingValue': + return setIsChangingValue(nextValue); + case 'isPristine': + return setPristine(nextValue); + case 'isValidated': + return setIsValidated(nextValue); + case 'isValidating': + return setValidating(nextValue); + } + }, + [setValue] + ); + + const reset: FieldHook['reset'] = useCallback( (resetOptions = { resetValue: true }) => { const { resetValue = true, defaultValue: updatedDefaultValue } = resetOptions; - setPristine(true); - setValidating(false); - setIsChangingValue(false); - setIsValidated(false); - setErrors([]); + updateStateIfMounted('isPristine', true); + updateStateIfMounted('isValidating', false); + updateStateIfMounted('isChangingValue', false); + updateStateIfMounted('isValidated', false); + updateStateIfMounted('errors', []); if (resetValue) { hasBeenReset.current = true; const newValue = deserializeValue(updatedDefaultValue ?? defaultValue); - setValue(newValue); + updateStateIfMounted('value', newValue); return newValue; } }, - [setValue, deserializeValue, defaultValue] + [updateStateIfMounted, deserializeValue, defaultValue] ); // Don't take into account non blocker validation. Some are just warning (like trying to add a wrong ComboBox item) const isValid = errors.filter((e) => e.__isBlocking__ !== false).length === 0; - const field = useMemo>(() => { + const field = useMemo>(() => { return { path, type, @@ -565,6 +600,7 @@ export const useField = ( return () => { if (debounceTimeout.current) { clearTimeout(debounceTimeout.current); + debounceTimeout.current = null; } }; }, [onValueChange]); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx index edcd84daf5d2f..b28c09d07fa98 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx @@ -196,7 +196,9 @@ describe('useForm() hook', () => { }); expect(isValid).toBe(false); - expect(data).toEqual({}); // Don't build the object (and call the serializers()) when invalid + // If the form is not valid, we don't build the final object to avoid + // calling the serializer(s) with invalid values. + expect(data).toEqual({}); }); }); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts index b390c17d3c2ff..be4535fec3669 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts @@ -24,19 +24,18 @@ import { set } from '@elastic/safer-lodash-set'; import { FormHook, FieldHook, FormData, FieldConfig, FieldsMap, FormConfig } from '../types'; import { mapFormFields, unflattenObject, Subject, Subscription } from '../lib'; -const DEFAULT_ERROR_DISPLAY_TIMEOUT = 500; const DEFAULT_OPTIONS = { - errorDisplayDelay: DEFAULT_ERROR_DISPLAY_TIMEOUT, + valueChangeDebounceTime: 500, stripEmptyFields: true, }; -interface UseFormReturn { - form: FormHook; +interface UseFormReturn { + form: FormHook; } -export function useForm( - formConfig?: FormConfig -): UseFormReturn { +export function useForm( + formConfig?: FormConfig +): UseFormReturn { const { onSubmit, schema, serializer, deserializer, options, id = 'default', defaultValue } = formConfig ?? {}; @@ -48,9 +47,9 @@ export function useForm( const filtered = Object.entries(_defaultValue as object) .filter(({ 1: value }) => value !== undefined) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {} as T); - return deserializer ? (deserializer(filtered) as any) : filtered; + return deserializer ? deserializer(filtered) : filtered; }, [deserializer] ); @@ -61,13 +60,13 @@ export function useForm( const defaultValueDeserialized = useRef(defaultValueMemoized); - const { errorDisplayDelay, stripEmptyFields: doStripEmptyFields } = options ?? {}; + const { valueChangeDebounceTime, stripEmptyFields: doStripEmptyFields } = options ?? {}; const formOptions = useMemo( () => ({ stripEmptyFields: doStripEmptyFields ?? DEFAULT_OPTIONS.stripEmptyFields, - errorDisplayDelay: errorDisplayDelay ?? DEFAULT_OPTIONS.errorDisplayDelay, + valueChangeDebounceTime: valueChangeDebounceTime ?? DEFAULT_OPTIONS.valueChangeDebounceTime, }), - [errorDisplayDelay, doStripEmptyFields] + [valueChangeDebounceTime, doStripEmptyFields] ); const [isSubmitted, setIsSubmitted] = useState(false); @@ -93,7 +92,7 @@ export function useForm( return formData$.current; }, []); - const fieldsToArray = useCallback(() => Object.values(fieldsRefs.current), []); + const fieldsToArray = useCallback<() => FieldHook[]>(() => Object.values(fieldsRefs.current), []); const getFieldsForOutput = useCallback( (fields: FieldsMap, opts: { stripEmptyFields: boolean }): FieldsMap => { @@ -144,7 +143,7 @@ export function useForm( }); const fieldsValue = mapFormFields(fieldsToOutput, (field) => field.__serializeValue()); return serializer - ? (serializer(unflattenObject(fieldsValue)) as T) + ? (serializer(unflattenObject(fieldsValue) as I) as T) : (unflattenObject(fieldsValue) as T); } @@ -175,6 +174,24 @@ export function useForm( const isFieldValid = (field: FieldHook) => field.isValid && !field.isValidating; + const waitForFieldsToFinishValidating = useCallback(async () => { + let areSomeFieldValidating = fieldsToArray().some((field) => field.isValidating); + if (!areSomeFieldValidating) { + return; + } + + return new Promise((resolve) => { + setTimeout(() => { + areSomeFieldValidating = fieldsToArray().some((field) => field.isValidating); + if (areSomeFieldValidating) { + // Recursively wait for all the fields to finish validating. + return waitForFieldsToFinishValidating().then(resolve); + } + resolve(); + }, 100); + }); + }, [fieldsToArray]); + const validateFields: FormHook['__validateFields'] = useCallback( async (fieldNames) => { const fieldsToValidate = fieldNames @@ -204,18 +221,25 @@ export function useForm( // To know the current form validity, we will then merge the "validationResult" _with_ the fieldsRefs object state, // the "validationResult" taking presedence over the fieldsRefs values. const formFieldsValidity = fieldsToArray().map((field) => { + const hasUpdatedValidity = validationResultByPath[field.path] !== undefined; const _isValid = validationResultByPath[field.path] ?? field.isValid; - const _isValidated = - validationResultByPath[field.path] !== undefined ? true : field.isValidated; - return [_isValid, _isValidated]; + const _isValidated = hasUpdatedValidity ? true : field.isValidated; + const _isValidating = hasUpdatedValidity ? false : field.isValidating; + return { + isValid: _isValid, + isValidated: _isValidated, + isValidating: _isValidating, + }; }); - const areAllFieldsValidated = formFieldsValidity.every(({ 1: isValidated }) => isValidated); + const areAllFieldsValidated = formFieldsValidity.every((field) => field.isValidated); + const areSomeFieldValidating = formFieldsValidity.some((field) => field.isValidating); // If *not* all the fiels have been validated, the validity of the form is unknown, thus still "undefined" - const isFormValid = areAllFieldsValidated - ? formFieldsValidity.every(([_isValid]) => _isValid) - : undefined; + const isFormValid = + areAllFieldsValidated && areSomeFieldValidating === false + ? formFieldsValidity.every((field) => field.isValid) + : undefined; setIsValid(isFormValid); @@ -225,6 +249,14 @@ export function useForm( ); const validateAllFields = useCallback(async (): Promise => { + // Maybe some field are being validated because of their async validation(s). + // We make sure those validations have finished executing before proceeding. + await waitForFieldsToFinishValidating(); + + if (!isMounted.current) { + return false; + } + const fieldsArray = fieldsToArray(); const fieldsToValidate = fieldsArray.filter((field) => !field.isValidated); @@ -238,7 +270,7 @@ export function useForm( setIsValid(isFormValid); return isFormValid!; - }, [fieldsToArray, validateFields]); + }, [fieldsToArray, validateFields, waitForFieldsToFinishValidating]); const addField: FormHook['__addField'] = useCallback( (field) => { @@ -303,7 +335,7 @@ export function useForm( const getFields: FormHook['getFields'] = useCallback(() => fieldsRefs.current, []); - const getFieldDefaultValue: FormHook['getFieldDefaultValue'] = useCallback( + const getFieldDefaultValue: FormHook['__getFieldDefaultValue'] = useCallback( (fieldName) => get(defaultValueDeserialized.current, fieldName), [] ); @@ -410,13 +442,13 @@ export function useForm( getFields, getFormData, getErrors, - getFieldDefaultValue, reset, __options: formOptions, __getFormData$: getFormData$, __updateFormDataAt: updateFormDataAt, __updateDefaultValueAt: updateDefaultValueAt, __readFieldConfigFromSchema: readFieldConfigFromSchema, + __getFieldDefaultValue: getFieldDefaultValue, __addField: addField, __removeField: removeField, __validateFields: validateFields, diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts index fb4a0984438ad..6c6dee3624979 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts @@ -63,7 +63,11 @@ export const useFormData = (options: Options = {}): ? (watch as string[]) : ([watch] as string[]); - if (valuesToWatchArray.some((value) => previousRawData.current[value] !== raw[value])) { + if ( + valuesToWatchArray.some( + (value) => previousRawData.current[value] !== raw[value as keyof T] + ) + ) { previousRawData.current = raw; // Only update the state if one of the field we watch has changed. setFormData(raw); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts index 18b8f478f7c0e..ae731caff2881 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts @@ -24,21 +24,37 @@ import { Subject, Subscription } from './lib'; // Comes from https://github.com/microsoft/TypeScript/issues/15012#issuecomment-365453623 type Required = T extends FormData ? { [P in keyof T]-?: NonNullable } : T; -export interface FormHook { +export interface FormHook { + /** Flag that indicates if the form has been submitted at least once. It is set to `true` when we call `submit()`. */ readonly isSubmitted: boolean; + /** Flag that indicates if the form is being submitted. */ readonly isSubmitting: boolean; + /** Flag that indicates if the form is valid. If `undefined` then the form validation has not been checked yet. */ readonly isValid: boolean | undefined; + /** The form id. If none was provided, "default" will be returned. */ readonly id: string; + /** + * This handler submits the form and returns its data and validity. If the form is not valid, the data will be `null` + * as only valid data is passed through the `serializer(s)` before being returned. + */ submit: (e?: FormEvent | MouseEvent) => Promise<{ data: T; isValid: boolean }>; + /** Use this handler to get the validity of the form. */ validate: () => Promise; subscribe: (handler: OnUpdateHandler) => Subscription; + /** Sets a field value imperatively. */ setFieldValue: (fieldName: string, value: FieldValue) => void; + /** Sets a field errors imperatively. */ setFieldErrors: (fieldName: string, errors: ValidationError[]) => void; + /** Access any field on the form. */ getFields: () => FieldsMap; + /** + * Return the form data. It accepts an optional options object with an `unflatten` parameter (defaults to `true`). + * If you are only interested in the raw form data, pass `unflatten: false` to the handler + */ getFormData: (options?: { unflatten?: boolean }) => T; - getFieldDefaultValue: (fieldName: string) => unknown; - /* Returns a list of all errors in the form */ + /* Returns an array with of all errors in the form. */ getErrors: () => string[]; + /** Resets the form to its initial state. */ reset: (options?: { resetValues?: boolean; defaultValue?: Partial }) => void; readonly __options: Required; __getFormData$: () => Subject; @@ -50,23 +66,19 @@ export interface FormHook { __updateFormDataAt: (field: string, value: unknown) => T; __updateDefaultValueAt: (field: string, value: unknown) => void; __readFieldConfigFromSchema: (fieldName: string) => FieldConfig; + __getFieldDefaultValue: (fieldName: string) => unknown; } -export interface FormSchema { - [key: string]: FormSchemaEntry; -} - -type FormSchemaEntry = - | FieldConfig - | Array> - | { [key: string]: FieldConfig | Array> | FormSchemaEntry }; +export type FormSchema = { + [K in keyof T]?: FieldConfig | FormSchema; +}; -export interface FormConfig { +export interface FormConfig { onSubmit?: FormSubmitHandler; - schema?: FormSchema; + schema?: FormSchema; defaultValue?: Partial; - serializer?: SerializerFunc; - deserializer?: SerializerFunc; + serializer?: SerializerFunc; + deserializer?: SerializerFunc; options?: FormOptions; id?: string; } @@ -83,20 +95,20 @@ export interface OnFormUpdateArg { export type OnUpdateHandler = (arg: OnFormUpdateArg) => void; export interface FormOptions { - errorDisplayDelay?: number; + valueChangeDebounceTime?: number; /** * Remove empty string field ("") from form data */ stripEmptyFields?: boolean; } -export interface FieldHook { +export interface FieldHook { readonly path: string; readonly label?: string; readonly labelAppend?: string | ReactNode; readonly helpText?: string | ReactNode; readonly type: string; - readonly value: T; + readonly value: I; readonly errors: ValidationError[]; readonly isValid: boolean; readonly isPristine: boolean; @@ -108,34 +120,33 @@ export interface FieldHook { errorCode?: string; }) => string | null; onChange: (event: ChangeEvent<{ name?: string; value: string; checked?: boolean }>) => void; - setValue: (value: T | ((prevValue: T) => T)) => void; + setValue: (value: I | ((prevValue: I) => I)) => void; setErrors: (errors: ValidationError[]) => void; clearErrors: (type?: string | string[]) => void; validate: (validateData?: { formData?: any; - value?: T; + value?: I; validationType?: string; }) => FieldValidateResponse | Promise; reset: (options?: { resetValue?: boolean; defaultValue?: T }) => unknown | undefined; // Flag to indicate if the field value will be included in the form data outputted // when calling form.getFormData(); __isIncludedInOutput: boolean; - __serializeValue: (rawValue?: unknown) => unknown; + __serializeValue: (internalValue?: I) => T; } -export interface FieldConfig { - readonly path?: string; +export interface FieldConfig { readonly label?: string; readonly labelAppend?: string | ReactNode; readonly helpText?: string | ReactNode; - readonly type?: HTMLInputElement['type']; - readonly defaultValue?: ValueType; - readonly validations?: Array>; + readonly type?: string; + readonly defaultValue?: T; + readonly validations?: Array>; readonly formatters?: FormatterFunc[]; - readonly deserializer?: SerializerFunc; - readonly serializer?: SerializerFunc; + readonly deserializer?: SerializerFunc; + readonly serializer?: SerializerFunc; readonly fieldsToValidateOnChange?: string[]; - readonly errorDisplayDelay?: number; + readonly valueChangeDebounceTime?: number; } export interface FieldsMap { @@ -166,7 +177,7 @@ export interface ValidationFuncArg { errors: readonly ValidationError[]; } -export type ValidationFunc = ( +export type ValidationFunc = ( data: ValidationFuncArg ) => ValidationError | void | undefined | Promise | void | undefined>; @@ -187,8 +198,12 @@ type FormatterFunc = (value: any, formData: FormData) => unknown; // string | number | boolean | string[] ... type FieldValue = unknown; -export interface ValidationConfig { - validator: ValidationFunc; +export interface ValidationConfig< + FormType extends FormData = any, + Error extends string = string, + ValueType = unknown +> { + validator: ValidationFunc; type?: string; /** * By default all validation are blockers, which means that if they fail, the field is invalid. diff --git a/src/plugins/input_control_vis/public/components/editor/control_editor.tsx b/src/plugins/input_control_vis/public/components/editor/control_editor.tsx index 90e875fd43432..aa473095aaf3f 100644 --- a/src/plugins/input_control_vis/public/components/editor/control_editor.tsx +++ b/src/plugins/input_control_vis/public/components/editor/control_editor.tsx @@ -18,9 +18,8 @@ */ import React, { PureComponent, ChangeEvent } from 'react'; -import { InjectedIntlProps } from 'react-intl'; +import { injectI18n, FormattedMessage, InjectedIntlProps } from '@kbn/i18n/react'; -import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import { EuiAccordion, EuiButtonIcon, diff --git a/src/plugins/input_control_vis/public/components/editor/controls_tab.tsx b/src/plugins/input_control_vis/public/components/editor/controls_tab.tsx index 19046f7f62fba..a9f04a86f8d03 100644 --- a/src/plugins/input_control_vis/public/components/editor/controls_tab.tsx +++ b/src/plugins/input_control_vis/public/components/editor/controls_tab.tsx @@ -18,9 +18,8 @@ */ import React, { PureComponent } from 'react'; -import { InjectedIntlProps } from 'react-intl'; +import { injectI18n, FormattedMessage, InjectedIntlProps } from '@kbn/i18n/react'; -import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiFlexGroup, diff --git a/src/plugins/input_control_vis/public/components/editor/field_select.tsx b/src/plugins/input_control_vis/public/components/editor/field_select.tsx index 2885cbf24553f..24ebfc46ae25f 100644 --- a/src/plugins/input_control_vis/public/components/editor/field_select.tsx +++ b/src/plugins/input_control_vis/public/components/editor/field_select.tsx @@ -19,9 +19,8 @@ import _ from 'lodash'; import React, { Component } from 'react'; -import { InjectedIntlProps } from 'react-intl'; -import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { injectI18n, FormattedMessage, InjectedIntlProps } from '@kbn/i18n/react'; import { EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { IIndexPattern, IFieldType } from '../../../../data/public'; diff --git a/src/plugins/input_control_vis/public/components/editor/index_pattern_select_form_row.tsx b/src/plugins/input_control_vis/public/components/editor/index_pattern_select_form_row.tsx index 66fdbca64f053..c2e726e913375 100644 --- a/src/plugins/input_control_vis/public/components/editor/index_pattern_select_form_row.tsx +++ b/src/plugins/input_control_vis/public/components/editor/index_pattern_select_form_row.tsx @@ -18,9 +18,9 @@ */ import React, { ComponentType } from 'react'; -import { injectI18n } from '@kbn/i18n/react'; +import { injectI18n, InjectedIntlProps } from '@kbn/i18n/react'; import { EuiFormRow } from '@elastic/eui'; -import { InjectedIntlProps } from 'react-intl'; + import { IndexPatternSelect } from 'src/plugins/data/public'; export type IndexPatternSelectFormRowUiProps = InjectedIntlProps & { diff --git a/src/plugins/input_control_vis/public/components/vis/list_control.tsx b/src/plugins/input_control_vis/public/components/vis/list_control.tsx index 8ca93a302be89..e34989427be21 100644 --- a/src/plugins/input_control_vis/public/components/vis/list_control.tsx +++ b/src/plugins/input_control_vis/public/components/vis/list_control.tsx @@ -19,9 +19,8 @@ import React, { PureComponent } from 'react'; import _ from 'lodash'; -import { injectI18n } from '@kbn/i18n/react'; -import { InjectedIntlProps } from 'react-intl'; +import { injectI18n, InjectedIntlProps } from '@kbn/i18n/react'; import { EuiFieldText, EuiComboBox } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormRow } from './form_row'; diff --git a/src/plugins/kibana_usage_collection/README.md b/src/plugins/kibana_usage_collection/README.md index 73a4d53f305f2..69711d30cdc74 100644 --- a/src/plugins/kibana_usage_collection/README.md +++ b/src/plugins/kibana_usage_collection/README.md @@ -8,3 +8,4 @@ This plugin registers the basic usage collectors from Kibana: - Number of Saved Objects per type - Non-default UI Settings - CSP configuration +- Core Metrics diff --git a/src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap b/src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap index 47a4c458a8398..c479562795512 100644 --- a/src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap +++ b/src/plugins/kibana_usage_collection/server/__snapshots__/index.test.ts.snap @@ -11,3 +11,5 @@ exports[`kibana_usage_collection Runs the setup method without issues 4`] = `fal exports[`kibana_usage_collection Runs the setup method without issues 5`] = `false`; exports[`kibana_usage_collection Runs the setup method without issues 6`] = `true`; + +exports[`kibana_usage_collection Runs the setup method without issues 7`] = `false`; diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts new file mode 100644 index 0000000000000..297baf016e9e6 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts @@ -0,0 +1,132 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { CoreUsageData, CoreUsageDataStart } from '../../../../../core/server'; + +export function getCoreUsageCollector( + usageCollection: UsageCollectionSetup, + getCoreUsageDataService: () => CoreUsageDataStart +) { + return usageCollection.makeUsageCollector({ + type: 'core', + isReady: () => typeof getCoreUsageDataService() !== 'undefined', + schema: { + config: { + elasticsearch: { + sniffOnStart: { type: 'boolean' }, + sniffIntervalMs: { type: 'long' }, + sniffOnConnectionFault: { type: 'boolean' }, + numberOfHostsConfigured: { type: 'long' }, + requestHeadersWhitelistConfigured: { type: 'boolean' }, + customHeadersConfigured: { type: 'boolean' }, + shardTimeoutMs: { type: 'long' }, + requestTimeoutMs: { type: 'long' }, + pingTimeoutMs: { type: 'long' }, + logQueries: { type: 'boolean' }, + ssl: { + verificationMode: { type: 'keyword' }, + certificateAuthoritiesConfigured: { type: 'boolean' }, + certificateConfigured: { type: 'boolean' }, + keyConfigured: { type: 'boolean' }, + keystoreConfigured: { type: 'boolean' }, + truststoreConfigured: { type: 'boolean' }, + alwaysPresentCertificate: { type: 'boolean' }, + }, + apiVersion: { type: 'keyword' }, + healthCheckDelayMs: { type: 'long' }, + }, + + http: { + basePathConfigured: { type: 'boolean' }, + maxPayloadInBytes: { type: 'long' }, + rewriteBasePath: { type: 'boolean' }, + keepaliveTimeout: { type: 'long' }, + socketTimeout: { type: 'long' }, + compression: { + enabled: { type: 'boolean' }, + referrerWhitelistConfigured: { type: 'boolean' }, + }, + xsrf: { + disableProtection: { type: 'boolean' }, + whitelistConfigured: { type: 'boolean' }, + }, + requestId: { + allowFromAnyIp: { type: 'boolean' }, + ipAllowlistConfigured: { type: 'boolean' }, + }, + ssl: { + certificateAuthoritiesConfigured: { type: 'boolean' }, + certificateConfigured: { type: 'boolean' }, + cipherSuites: { type: 'array', items: { type: 'keyword' } }, + keyConfigured: { type: 'boolean' }, + keystoreConfigured: { type: 'boolean' }, + truststoreConfigured: { type: 'boolean' }, + redirectHttpFromPortConfigured: { type: 'boolean' }, + supportedProtocols: { type: 'array', items: { type: 'keyword' } }, + clientAuthentication: { type: 'keyword' }, + }, + }, + + logging: { + appendersTypesUsed: { type: 'array', items: { type: 'keyword' } }, + loggersConfiguredCount: { type: 'long' }, + }, + + savedObjects: { + maxImportPayloadBytes: { type: 'long' }, + maxImportExportSizeBytes: { type: 'long' }, + }, + }, + environment: { + memory: { + heapSizeLimit: { type: 'long' }, + heapTotalBytes: { type: 'long' }, + heapUsedBytes: { type: 'long' }, + }, + }, + services: { + savedObjects: { + indices: { + type: 'array', + items: { + docsCount: { type: 'long' }, + docsDeleted: { type: 'long' }, + alias: { type: 'text' }, + primaryStoreSizeBytes: { type: 'long' }, + storeSizeBytes: { type: 'long' }, + }, + }, + }, + }, + }, + fetch() { + return getCoreUsageDataService().getCoreUsageData(); + }, + }); +} + +export function registerCoreUsageCollector( + usageCollection: UsageCollectionSetup, + getCoreUsageDataService: () => CoreUsageDataStart +) { + usageCollection.registerCollector( + getCoreUsageCollector(usageCollection, getCoreUsageDataService) + ); +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts new file mode 100644 index 0000000000000..b712e9ebbce48 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/core/index.test.ts @@ -0,0 +1,53 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + CollectorOptions, + createUsageCollectionSetupMock, +} from '../../../../usage_collection/server/usage_collection.mock'; + +import { registerCoreUsageCollector } from '.'; +import { coreUsageDataServiceMock } from '../../../../../core/server/mocks'; +import { CoreUsageData } from 'src/core/server/'; + +describe('telemetry_core', () => { + let collector: CollectorOptions; + + const usageCollectionMock = createUsageCollectionSetupMock(); + usageCollectionMock.makeUsageCollector.mockImplementation((config) => { + collector = config; + return createUsageCollectionSetupMock().makeUsageCollector(config); + }); + + const callCluster = jest.fn().mockImplementation(() => ({})); + const coreUsageDataStart = coreUsageDataServiceMock.createStartContract(); + const getCoreUsageDataReturnValue = (Symbol('core telemetry') as any) as CoreUsageData; + coreUsageDataStart.getCoreUsageData.mockResolvedValue(getCoreUsageDataReturnValue); + + beforeAll(() => registerCoreUsageCollector(usageCollectionMock, () => coreUsageDataStart)); + + test('registered collector is set', () => { + expect(collector).not.toBeUndefined(); + expect(collector.type).toBe('core'); + }); + + test('fetch', async () => { + expect(await collector.fetch(callCluster)).toEqual(getCoreUsageDataReturnValue); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/index.ts b/src/plugins/kibana_usage_collection/server/collectors/core/index.ts new file mode 100644 index 0000000000000..79a4b83b41355 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/core/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { registerCoreUsageCollector } from './core_usage_collector'; diff --git a/src/plugins/kibana_usage_collection/server/collectors/index.ts b/src/plugins/kibana_usage_collection/server/collectors/index.ts index 1f9fe130fa45d..2408dc84c2e56 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/index.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/index.ts @@ -23,3 +23,4 @@ export { registerApplicationUsageCollector } from './application_usage'; export { registerKibanaUsageCollector } from './kibana'; export { registerOpsStatsCollector } from './ops_stats'; export { registerCspCollector } from './csp'; +export { registerCoreUsageCollector } from './core'; diff --git a/src/plugins/kibana_usage_collection/server/plugin.ts b/src/plugins/kibana_usage_collection/server/plugin.ts index 260acd19ab516..198fdbb7a8703 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.ts @@ -31,6 +31,7 @@ import { SavedObjectsServiceSetup, OpsMetrics, Logger, + CoreUsageDataStart, } from '../../../core/server'; import { registerApplicationUsageCollector, @@ -39,6 +40,7 @@ import { registerOpsStatsCollector, registerUiMetricUsageCollector, registerCspCollector, + registerCoreUsageCollector, } from './collectors'; interface KibanaUsageCollectionPluginsDepsSetup { @@ -53,6 +55,7 @@ export class KibanaUsageCollectionPlugin implements Plugin { private savedObjectsClient?: ISavedObjectsRepository; private uiSettingsClient?: IUiSettingsClient; private metric$: Subject; + private coreUsageData?: CoreUsageDataStart; constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); @@ -72,6 +75,7 @@ export class KibanaUsageCollectionPlugin implements Plugin { const savedObjectsClient = new SavedObjectsClient(this.savedObjectsClient); this.uiSettingsClient = uiSettings.asScopedToClient(savedObjectsClient); core.metrics.getOpsMetrics$().subscribe(this.metric$); + this.coreUsageData = core.coreUsageData; } public stop() { @@ -86,6 +90,7 @@ export class KibanaUsageCollectionPlugin implements Plugin { ) { const getSavedObjectsClient = () => this.savedObjectsClient; const getUiSettingsClient = () => this.uiSettingsClient; + const getCoreUsageDataService = () => this.coreUsageData!; registerOpsStatsCollector(usageCollection, metric$); registerKibanaUsageCollector(usageCollection, this.legacyConfig$); @@ -98,5 +103,6 @@ export class KibanaUsageCollectionPlugin implements Plugin { getSavedObjectsClient ); registerCspCollector(usageCollection, coreSetup.http); + registerCoreUsageCollector(usageCollection, getCoreUsageDataService); } } diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index 976ddd789ad22..230be399febda 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -1,8 +1,3 @@ .kbnTopNavMenu { margin-right: $euiSizeXS; } - -.kbnTopNavMenu > * > * { - // TEMP fix to adjust spacing between EuiHeaderList__list items - margin: 0 $euiSizeXS; -} diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx index 212bc19208ca8..147feee3cd472 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx @@ -164,10 +164,6 @@ describe('TopNavMenu', () => { // menu is rendered outside of the component expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(0); - - const buttons = portalTarget.querySelectorAll('button'); - expect(buttons.length).toBe(menuItems.length + 1); // should be n+1 buttons in mobile for popover button - expect(buttons[buttons.length - 1].getAttribute('aria-label')).toBe('Open navigation menu'); // last button should be mobile button }); }); }); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index a27addeb14393..1739b7d915adb 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -88,7 +88,7 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { function renderMenu(className: string): ReactElement | null { if (!config || config.length === 0) return null; return ( - + {renderItems()} ); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx index e503ebb839f48..5c463902f77f5 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx @@ -52,7 +52,7 @@ export function TopNavMenuItem(props: TopNavMenuData) { {upperFirst(props.label || props.id!)} ) : ( - + {upperFirst(props.label || props.id!)} ); diff --git a/src/plugins/region_map/public/region_map_type.js b/src/plugins/region_map/public/region_map_type.js index 4cd30d32698ed..ec32d582ce15b 100644 --- a/src/plugins/region_map/public/region_map_type.js +++ b/src/plugins/region_map/public/region_map_type.js @@ -32,7 +32,7 @@ export function createRegionMapTypeDefinition(dependencies) { return { name: 'region_map', - getDeprecationMessage, + getInfoMessage: getDeprecationMessage, title: i18n.translate('regionMap.mapVis.regionMapTitle', { defaultMessage: 'Region Map' }), description: i18n.translate('regionMap.mapVis.regionMapDescription', { defaultMessage: diff --git a/src/plugins/security_oss/README.md b/src/plugins/security_oss/README.md new file mode 100644 index 0000000000000..6143149fec384 --- /dev/null +++ b/src/plugins/security_oss/README.md @@ -0,0 +1,4 @@ +# `securityOss` plugin + +`securityOss` is responsible for educating users about Elastic's free security features, +so they can properly protect the data within their clusters. diff --git a/src/plugins/security_oss/kibana.json b/src/plugins/security_oss/kibana.json new file mode 100644 index 0000000000000..70e37d586f1db --- /dev/null +++ b/src/plugins/security_oss/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "securityOss", + "version": "8.0.0", + "kibanaVersion": "kibana", + "configPath": ["security"], + "ui": true, + "server": true, + "requiredPlugins": [], + "requiredBundles": [] +} diff --git a/src/plugins/security_oss/public/config.ts b/src/plugins/security_oss/public/config.ts new file mode 100644 index 0000000000000..17f6b5a53eb6c --- /dev/null +++ b/src/plugins/security_oss/public/config.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 interface ConfigType { + showInsecureClusterWarning: boolean; +} diff --git a/src/plugins/security_oss/public/index.ts b/src/plugins/security_oss/public/index.ts new file mode 100644 index 0000000000000..2e63a9316c99b --- /dev/null +++ b/src/plugins/security_oss/public/index.ts @@ -0,0 +1,26 @@ +/* + * 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 { PluginInitializerContext } from 'kibana/public'; + +import { SecurityOssPlugin } from './plugin'; + +export { SecurityOssPluginSetup, SecurityOssPluginStart } from './plugin'; +export const plugin = (initializerContext: PluginInitializerContext) => + new SecurityOssPlugin(initializerContext); diff --git a/src/plugins/security_oss/public/insecure_cluster_service/components/default_alert.test.tsx b/src/plugins/security_oss/public/insecure_cluster_service/components/default_alert.test.tsx new file mode 100644 index 0000000000000..b414ab78cdfdb --- /dev/null +++ b/src/plugins/security_oss/public/insecure_cluster_service/components/default_alert.test.tsx @@ -0,0 +1,37 @@ +/* + * 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 { defaultAlertText } from './default_alert'; + +describe('defaultAlertText', () => { + it('creates a valid MountPoint that can cleanup correctly', () => { + const mountPoint = defaultAlertText(jest.fn()); + + const el = document.createElement('div'); + const unmount = mountPoint(el); + + expect(el.querySelectorAll('[data-test-subj="insecureClusterDefaultAlertText"]')).toHaveLength( + 1 + ); + + unmount(); + + expect(el).toMatchInlineSnapshot(` @@ -605,11 +659,11 @@ exports[`NewVisModal filter for visualization types should render as expected 1` id="visualizations.newVisWizard.resultsFound" values={ Object { - "resultCount": 2, + "resultCount": 3, } } > - 2 types found + 3 types found @@ -621,6 +675,75 @@ exports[`NewVisModal filter for visualization types should render as expected 1` className="euiKeyPadMenu visNewVisDialog__types" data-test-subj="visNewDialogTypes" > +
  • + + Vis alias with promotion + + } + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} + > + + +
  • @@ -867,7 +990,21 @@ exports[`NewVisModal filter for visualization types should render as expected 1`

    +

    + + promotion description + +

    + + + + +
    @@ -1129,6 +1326,37 @@ exports[`NewVisModal should render as expected 1`] = ` class="euiKeyPadMenu visNewVisDialog__types" data-test-subj="visNewDialogTypes" > +
  • + +
  • @@ -1454,6 +1705,75 @@ exports[`NewVisModal should render as expected 1`] = ` className="euiKeyPadMenu visNewVisDialog__types" data-test-subj="visNewDialogTypes" > +
  • + + Vis alias with promotion + + } + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} + > + + +
  • @@ -1700,7 +2020,21 @@ exports[`NewVisModal should render as expected 1`] = `

    +

    + + promotion description + +

    + + + + +
    diff --git a/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx b/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx index f48febfef5b43..51bcfed201687 100644 --- a/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx +++ b/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx @@ -51,13 +51,24 @@ describe('NewVisModal', () => { aliasApp: 'otherApp', aliasPath: '#/aliasUrl', }, + { + name: 'visAliasWithPromotion', + title: 'Vis alias with promotion', + stage: 'production', + aliasApp: 'anotherApp', + aliasPath: '#/anotherUrl', + promotion: { + description: 'promotion description', + buttonText: 'another app', + }, + }, ]; const visTypes: TypesStart = { - get: (id: string) => { - return _visTypes.find((vis) => vis.name === id) as VisType; + get(id: string): VisType { + return (_visTypes.find((vis) => vis.name === id) as unknown) as VisType; }, all: () => { - return _visTypes as VisType[]; + return (_visTypes as unknown) as VisType[]; }, getAliases: () => [], }; @@ -107,6 +118,30 @@ describe('NewVisModal', () => { expect(wrapper.find('[data-test-subj="visType-vis"]').exists()).toBe(true); }); + it('should sort promoted visualizations first', () => { + const wrapper = mountWithIntl( + null} + visTypesRegistry={visTypes} + addBasePath={addBasePath} + uiSettings={uiSettings} + application={{} as ApplicationStart} + savedObjects={{} as SavedObjectsStart} + /> + ); + expect( + wrapper + .find('button[data-test-subj^="visType-"]') + .map((button) => button.prop('data-test-subj')) + ).toEqual([ + 'visType-visAliasWithPromotion', + 'visType-vis', + 'visType-visWithAliasUrl', + 'visType-visWithSearch', + ]); + }); + describe('open editor', () => { it('should open the editor for visualizations without search', () => { const wrapper = mountWithIntl( diff --git a/src/plugins/visualizations/public/wizard/type_selection/new_vis_help.test.tsx b/src/plugins/visualizations/public/wizard/type_selection/new_vis_help.test.tsx index fa15a6c9ba02b..a5b6e8039ba6d 100644 --- a/src/plugins/visualizations/public/wizard/type_selection/new_vis_help.test.tsx +++ b/src/plugins/visualizations/public/wizard/type_selection/new_vis_help.test.tsx @@ -31,7 +31,6 @@ describe('NewVisHelp', () => { aliasApp: 'myApp', aliasPath: '/my/fancy/new/thing', description: 'Some desc', - highlighted: false, icon: 'whatever', name: 'whatever', promotion: { diff --git a/src/plugins/visualizations/public/wizard/type_selection/new_vis_help.tsx b/src/plugins/visualizations/public/wizard/type_selection/new_vis_help.tsx index fc48438904589..5b226a889408f 100644 --- a/src/plugins/visualizations/public/wizard/type_selection/new_vis_help.tsx +++ b/src/plugins/visualizations/public/wizard/type_selection/new_vis_help.tsx @@ -20,11 +20,10 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { Fragment } from 'react'; import { EuiText, EuiButton } from '@elastic/eui'; -import { VisTypeAliasListEntry } from './type_selection'; import { VisTypeAlias } from '../../vis_types'; interface Props { - promotedTypes: VisTypeAliasListEntry[]; + promotedTypes: VisTypeAlias[]; onPromotionClicked: (visType: VisTypeAlias) => void; } diff --git a/src/plugins/visualizations/public/wizard/type_selection/type_selection.tsx b/src/plugins/visualizations/public/wizard/type_selection/type_selection.tsx index f507635093f7f..8c086ed132ae4 100644 --- a/src/plugins/visualizations/public/wizard/type_selection/type_selection.tsx +++ b/src/plugins/visualizations/public/wizard/type_selection/type_selection.tsx @@ -42,11 +42,8 @@ import { VisHelpText } from './vis_help_text'; import { VisTypeIcon } from './vis_type_icon'; import { VisType, TypesStart } from '../../vis_types'; -export interface VisTypeListEntry extends VisType { - highlighted: boolean; -} - -export interface VisTypeAliasListEntry extends VisTypeAlias { +interface VisTypeListEntry { + type: VisType | VisTypeAlias; highlighted: boolean; } @@ -69,6 +66,10 @@ interface TypeSelectionState { query: string; } +function isVisTypeAlias(type: VisType | VisTypeAlias): type is VisTypeAlias { + return 'aliasPath' in type; +} + class TypeSelection extends React.Component { public state: TypeSelectionState = { highlightedType: null, @@ -155,7 +156,9 @@ class TypeSelection extends React.Component t.promotion)} + promotedTypes={visTypes + .map((t) => t.type) + .filter((t): t is VisTypeAlias => isVisTypeAlias(t) && Boolean(t.promotion))} onPromotionClicked={this.props.onVisTypeSelected} /> @@ -167,10 +170,7 @@ class TypeSelection extends React.Component { + private filteredVisTypes(visTypes: TypesStart, query: string): VisTypeListEntry[] { const types = visTypes.all().filter((type) => { // Filter out all lab visualizations if lab mode is not enabled if (!this.props.showExperimental && type.stage === 'experimental') { @@ -187,9 +187,9 @@ class TypeSelection extends React.Component; + let entries: VisTypeListEntry[]; if (!query) { - entries = allTypes.map((type) => ({ ...type, highlighted: false })); + entries = allTypes.map((type) => ({ type, highlighted: false })); } else { const q = query.toLowerCase(); entries = allTypes.map((type) => { @@ -197,17 +197,21 @@ class TypeSelection extends React.Component { + private renderVisType = (visType: VisTypeListEntry) => { let stage = {}; let highlightMsg; - if (!('aliasPath' in visType) && visType.stage === 'experimental') { + if (!isVisTypeAlias(visType.type) && visType.type.stage === 'experimental') { stage = { betaBadgeLabel: i18n.translate('visualizations.newVisWizard.experimentalTitle', { defaultMessage: 'Experimental', @@ -221,7 +225,7 @@ class TypeSelection extends React.Component this.props.onVisTypeSelected(visType); + const onClick = () => this.props.onVisTypeSelected(visType.type); const highlightedType: HighlightedType = { - title: visType.title, - name: visType.name, - description: visType.description, + title: visType.type.title, + name: visType.type.name, + description: visType.type.description, highlightMsg, }; return ( {visType.title}} + key={visType.type.name} + label={{visType.type.title}} onClick={onClick} onFocus={() => this.setHighlightType(highlightedType)} onMouseEnter={() => this.setHighlightType(highlightedType)} onMouseLeave={() => this.setHighlightType(null)} onBlur={() => this.setHighlightType(null)} className="visNewVisDialog__type" - data-test-subj={`visType-${visType.name}`} - data-vis-stage={!('aliasPath' in visType) ? visType.stage : 'alias'} + data-test-subj={`visType-${visType.type.name}`} + data-vis-stage={!isVisTypeAlias(visType.type) ? visType.type.stage : 'alias'} disabled={isDisabled} - aria-describedby={`visTypeDescription-${visType.name}`} + aria-describedby={`visTypeDescription-${visType.type.name}`} {...stage} > ); diff --git a/src/plugins/visualize/public/actions/visualize_field_action.ts b/src/plugins/visualize/public/actions/visualize_field_action.ts index 6671d2c981910..e570ed5e49e6a 100644 --- a/src/plugins/visualize/public/actions/visualize_field_action.ts +++ b/src/plugins/visualize/public/actions/visualize_field_action.ts @@ -34,6 +34,7 @@ import { AGGS_TERMS_SIZE_SETTING } from '../../common/constants'; export const visualizeFieldAction = createAction({ type: ACTION_VISUALIZE_FIELD, + id: ACTION_VISUALIZE_FIELD, getDisplayName: () => i18n.translate('visualize.discover.visualizeFieldLabel', { defaultMessage: 'Visualize field', diff --git a/src/plugins/visualize/public/application/components/visualize_editor_common.tsx b/src/plugins/visualize/public/application/components/visualize_editor_common.tsx index 4b7b4dae02d0a..545552b905553 100644 --- a/src/plugins/visualize/public/application/components/visualize_editor_common.tsx +++ b/src/plugins/visualize/public/application/components/visualize_editor_common.tsx @@ -78,8 +78,8 @@ export const VisualizeEditorCommon = ({ embeddableId={embeddableId} /> )} - {visInstance?.vis?.type?.isExperimental && } - {visInstance?.vis?.type?.getDeprecationMessage?.(visInstance.vis)} + {visInstance?.vis?.type?.stage === 'experimental' && } + {visInstance?.vis?.type?.getInfoMessage?.(visInstance.vis)} {visInstance && (

    diff --git a/src/setup_node_env/dist.js b/src/setup_node_env/dist.js new file mode 100644 index 0000000000000..dd3af8c48a30a --- /dev/null +++ b/src/setup_node_env/dist.js @@ -0,0 +1,21 @@ +/* + * 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. + */ + +require('./no_transpilation'); +require('./polyfill'); diff --git a/src/setup_node_env/polyfill.js b/src/setup_node_env/polyfill.js new file mode 100644 index 0000000000000..ad87354c83429 --- /dev/null +++ b/src/setup_node_env/polyfill.js @@ -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. + */ + +require('core-js/stable'); diff --git a/src/test_utils/public/enzyme_helpers.tsx b/src/test_utils/public/enzyme_helpers.tsx index a7bed2ad84956..ce4e7c7298734 100644 --- a/src/test_utils/public/enzyme_helpers.tsx +++ b/src/test_utils/public/enzyme_helpers.tsx @@ -18,13 +18,13 @@ */ /** - * Components using the react-intl module require access to the intl context. + * Components using the @kbn/i18n module require access to the intl context. * This is not available when mounting single components in Enzyme. * These helper functions aim to address that and wrap a valid, * intl context around them. */ -import { I18nProvider, InjectedIntl, intlShape } from '@kbn/i18n/react'; +import { I18nProvider, InjectedIntl, intlShape, __IntlProvider } from '@kbn/i18n/react'; import { mount, ReactWrapper, render, shallow } from 'enzyme'; import React, { ReactElement, ValidationMap } from 'react'; @@ -33,7 +33,7 @@ const { intl } = (mount(
    -).find('IntlProvider') as ReactWrapper<{}, {}, import('react-intl').IntlProvider>) +).find('IntlProvider') as ReactWrapper<{}, {}, __IntlProvider>) .instance() .getChildContext(); @@ -52,7 +52,7 @@ function getOptions(context = {}, childContextTypes: ValidationMap = {}, pr } /** - * When using React-Intl `injectIntl` on components, props.intl is required. + * When using @kbn/i18n `injectI18n` on components, props.intl is required. */ function nodeWithIntlProp(node: ReactElement): ReactElement { return React.cloneElement(node, { intl }); diff --git a/tasks/function_test_groups.js b/tasks/function_test_groups.js index d60f3ae53eecc..ac41b3f36be0f 100644 --- a/tasks/function_test_groups.js +++ b/tasks/function_test_groups.js @@ -43,6 +43,8 @@ const getDefaultArgs = (tag) => { '--debug', '--config', 'test/new_visualize_flow/config.js', + '--config', + 'test/security_functional/config.ts', ]; }; diff --git a/test/common/config.js b/test/common/config.js index 6a62151b12814..dbbd75d1f9577 100644 --- a/test/common/config.js +++ b/test/common/config.js @@ -48,6 +48,7 @@ export default function () { `--elasticsearch.username=${kibanaServerTestUser.username}`, `--elasticsearch.password=${kibanaServerTestUser.password}`, `--home.disableWelcomeScreen=true`, + `--security.showInsecureClusterWarning=false`, '--telemetry.banner=false', '--telemetry.optIn=false', // These are *very* important to have them pointing to staging diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json index 87a1bc20920a4..0d6d0286c5a8f 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json @@ -12,7 +12,7 @@ "build": "rm -rf './target' && tsc" }, "devDependencies": { - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "@kbn/plugin-helpers": "1.0.0", "react": "^16.12.0", "react-dom": "^16.12.0", diff --git a/test/plugin_functional/plugins/kbn_sample_panel_action/package.json b/test/plugin_functional/plugins/kbn_sample_panel_action/package.json index 8bbf6274bd15f..8efd2ee432415 100644 --- a/test/plugin_functional/plugins/kbn_sample_panel_action/package.json +++ b/test/plugin_functional/plugins/kbn_sample_panel_action/package.json @@ -12,7 +12,7 @@ "build": "rm -rf './target' && tsc" }, "devDependencies": { - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "react": "^16.12.0", "typescript": "4.0.2" } diff --git a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json index c0d9a03d02c32..4405063e54c06 100644 --- a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json +++ b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json @@ -12,7 +12,7 @@ "build": "rm -rf './target' && tsc" }, "devDependencies": { - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "@kbn/plugin-helpers": "1.0.0", "react": "^16.12.0", "typescript": "4.0.2" diff --git a/test/security_functional/config.ts b/test/security_functional/config.ts new file mode 100644 index 0000000000000..2a35d40678fd2 --- /dev/null +++ b/test/security_functional/config.ts @@ -0,0 +1,54 @@ +/* + * 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 path from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile(require.resolve('../functional/config')); + + return { + testFiles: [require.resolve('./index.ts')], + services: functionalConfig.get('services'), + pageObjects: functionalConfig.get('pageObjects'), + servers: functionalConfig.get('servers'), + esTestCluster: functionalConfig.get('esTestCluster'), + apps: {}, + esArchiver: { + directory: path.resolve(__dirname, '../functional/fixtures/es_archiver'), + }, + snapshots: { + directory: path.resolve(__dirname, 'snapshots'), + }, + junit: { + reportName: 'Security OSS Functional Tests', + }, + kbnTestServer: { + ...functionalConfig.get('kbnTestServer'), + serverArgs: [ + ...functionalConfig + .get('kbnTestServer.serverArgs') + .filter((arg: string) => !arg.startsWith('--security.showInsecureClusterWarning')), + '--security.showInsecureClusterWarning=true', + // Required to load new platform plugins via `--plugin-path` flag. + '--env.name=development', + ], + }, + }; +} diff --git a/test/security_functional/index.ts b/test/security_functional/index.ts new file mode 100644 index 0000000000000..8066a4eacf61a --- /dev/null +++ b/test/security_functional/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 '../functional/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Security OSS', function () { + this.tags(['skipCloud', 'ciGroup2']); + loadTestFile(require.resolve('./insecure_cluster_warning')); + }); +} diff --git a/test/security_functional/insecure_cluster_warning.ts b/test/security_functional/insecure_cluster_warning.ts new file mode 100644 index 0000000000000..03d9d248d6790 --- /dev/null +++ b/test/security_functional/insecure_cluster_warning.ts @@ -0,0 +1,87 @@ +/* + * 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 'test/functional/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const pageObjects = getPageObjects(['common']); + const testSubjects = getService('testSubjects'); + const browser = getService('browser'); + const esArchiver = getService('esArchiver'); + + describe('Insecure Cluster Warning', () => { + before(async () => { + await pageObjects.common.navigateToApp('home'); + await browser.setLocalStorageItem('insecureClusterWarningVisibility', ''); + // starting without user data + await esArchiver.unload('hamlet'); + }); + + after(async () => { + await esArchiver.unload('hamlet'); + }); + + describe('without user data', () => { + before(async () => { + await browser.setLocalStorageItem('insecureClusterWarningVisibility', ''); + await esArchiver.unload('hamlet'); + }); + + it('should not warn when the cluster contains no user data', async () => { + await browser.setLocalStorageItem( + 'insecureClusterWarningVisibility', + JSON.stringify({ show: false }) + ); + await pageObjects.common.navigateToApp('home'); + await testSubjects.missingOrFail('insecureClusterDefaultAlertText'); + }); + }); + + describe('with user data', () => { + before(async () => { + await pageObjects.common.navigateToApp('home'); + await browser.setLocalStorageItem('insecureClusterWarningVisibility', ''); + await esArchiver.load('hamlet'); + }); + + after(async () => { + await esArchiver.unload('hamlet'); + }); + + it('should warn about an insecure cluster, and hide when dismissed', async () => { + await pageObjects.common.navigateToApp('home'); + await testSubjects.existOrFail('insecureClusterDefaultAlertText'); + + await testSubjects.click('defaultDismissAlertButton'); + + await testSubjects.missingOrFail('insecureClusterDefaultAlertText'); + }); + + it('should not warn when local storage is configured to hide', async () => { + await browser.setLocalStorageItem( + 'insecureClusterWarningVisibility', + JSON.stringify({ show: false }) + ); + await pageObjects.common.navigateToApp('home'); + await testSubjects.missingOrFail('insecureClusterDefaultAlertText'); + }); + }); + }); +} diff --git a/tsconfig.json b/tsconfig.json index fb57936248cf6..cf112b26a2cbb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,8 +13,8 @@ "src/**/__fixtures__/**/*", "src/test_utils/**/*", "src/core/**/*", - "src/plugins/kibana_utils/**/*", - "src/plugins/kibana_react/**/*" + "src/plugins/kibana_utils/**/*", + "src/plugins/kibana_react/**/*" // In the build we actually exclude **/public/**/* from this config so that // we can run the TSC on both this and the .browser version of this config // file, but if we did it during development IDEs would not be able to find diff --git a/x-pack/README.md b/x-pack/README.md index 0449f1fc1bdab..73d8736124843 100644 --- a/x-pack/README.md +++ b/x-pack/README.md @@ -55,7 +55,7 @@ yarn test:mocha For more info, see [the Elastic functional test development guide](https://www.elastic.co/guide/en/kibana/current/development-functional-tests.html). -The functional UI tests, the API integration tests, and the SAML API integration tests are all run against a live browser, Kibana, and Elasticsearch install. Each set of tests is specified with a unique config that describes how to start the Elasticsearch server, the Kibana server, and what tests to run against them. The sets of tests that exist today are *functional UI tests* ([specified by this config](test/functional/config.js)), *API integration tests* ([specified by this config](test/api_integration/config.ts)), and *SAML API integration tests* ([specified by this config](test/saml_api_integration/config.ts)). +The functional UI tests, the API integration tests, and the SAML API integration tests are all run against a live browser, Kibana, and Elasticsearch install. Each set of tests is specified with a unique config that describes how to start the Elasticsearch server, the Kibana server, and what tests to run against them. The sets of tests that exist today are *functional UI tests* ([specified by this config](test/functional/config.js)), *API integration tests* ([specified by this config](test/api_integration/config.ts)), and *SAML API integration tests* ([specified by this config](test/security_api_integration/saml.config.ts)). The script runs all sets of tests sequentially like so: * builds Elasticsearch and X-Pack @@ -108,7 +108,7 @@ node scripts/functional_tests --config test/api_integration/config We also have SAML API integration tests which set up Elasticsearch and Kibana with SAML support. Run _only_ API integration tests with SAML enabled like so: ```sh -node scripts/functional_tests --config test/saml_api_integration/config +node scripts/functional_tests --config test/security_api_integration/saml.config ``` #### Running Jest integration tests diff --git a/x-pack/examples/alerting_example/kibana.json b/x-pack/examples/alerting_example/kibana.json index a2691c5fdcab7..0b2c2bdb3f6a7 100644 --- a/x-pack/examples/alerting_example/kibana.json +++ b/x-pack/examples/alerting_example/kibana.json @@ -4,6 +4,6 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["triggers_actions_ui", "charts", "data", "alerts", "actions", "features", "developerExamples"], + "requiredPlugins": ["triggersActionsUi", "charts", "data", "alerts", "actions", "features", "developerExamples"], "optionalPlugins": [] } diff --git a/x-pack/examples/alerting_example/public/application.tsx b/x-pack/examples/alerting_example/public/application.tsx index ebffc7d038aef..e229c1c1e6dad 100644 --- a/x-pack/examples/alerting_example/public/application.tsx +++ b/x-pack/examples/alerting_example/public/application.tsx @@ -30,7 +30,7 @@ export interface AlertingExampleComponentParams { application: CoreStart['application']; http: CoreStart['http']; basename: string; - triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; data: DataPublicPluginStart; charts: ChartsPluginStart; uiSettings: IUiSettingsClient; diff --git a/x-pack/examples/alerting_example/public/components/create_alert.tsx b/x-pack/examples/alerting_example/public/components/create_alert.tsx index c75c230e4f04e..6a85e21df450f 100644 --- a/x-pack/examples/alerting_example/public/components/create_alert.tsx +++ b/x-pack/examples/alerting_example/public/components/create_alert.tsx @@ -14,8 +14,7 @@ import { ALERTING_EXAMPLE_APP_ID } from '../../common/constants'; export const CreateAlert = ({ http, - // eslint-disable-next-line @typescript-eslint/naming-convention - triggers_actions_ui, + triggersActionsUi, charts, uiSettings, docLinks, @@ -39,8 +38,8 @@ export const CreateAlert = ({ { public setup( core: CoreSetup, - // eslint-disable-next-line @typescript-eslint/naming-convention - { alerts, triggers_actions_ui, developerExamples }: AlertingExamplePublicSetupDeps + { alerts, triggersActionsUi, developerExamples }: AlertingExamplePublicSetupDeps ) { core.application.register({ id: 'AlertingExample', @@ -52,8 +51,8 @@ export class AlertingExamplePlugin implements Plugin { + public readonly supportedTriggers = () => [MY_TRIGGER] as Trigger[]; + + protected async getURL(config: Config, context: Context): Promise { + return 'https://...'; + } +} +``` + +and then you register it in your plugin: + +```ts +export class MyPlugin implements Plugin { + public setup(core, { uiActionsEnhanced: uiActions }: SetupDependencies) { + const drilldown = new App2ToDashboardDrilldown(/* You can pass in dependencies here. */); + uiActions.registerDrilldown(drilldown); + } +} +``` diff --git a/x-pack/examples/ui_actions_enhanced_examples/kibana.json b/x-pack/examples/ui_actions_enhanced_examples/kibana.json index 1bae09b488a2e..4f5ac8519fe5b 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/kibana.json +++ b/x-pack/examples/ui_actions_enhanced_examples/kibana.json @@ -5,10 +5,21 @@ "configPath": ["ui_actions_enhanced_examples"], "server": false, "ui": true, - "requiredPlugins": ["uiActions","uiActionsEnhanced", "data", "discover"], + "requiredPlugins": [ + "uiActions", + "uiActionsEnhanced", + "data", + "discover", + "dashboard", + "dashboardEnhanced", + "developerExamples" + ], "optionalPlugins": [], "requiredBundles": [ + "dashboardEnhanced", + "embeddable", "kibanaUtils", - "kibanaReact" + "kibanaReact", + "share" ] } diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/components/page/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/components/page/index.tsx new file mode 100644 index 0000000000000..7b3e19ff94f0f --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/components/page/index.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, +} from '@elastic/eui'; + +export interface PageProps { + title?: React.ReactNode; +} + +export const Page: React.FC = ({ title = 'Untitled', children }) => { + return ( + + + + +

    {title}

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

    {title}

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

    With embeddable example

    +

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

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

    Without embeddable example (app 1)

    +

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

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

    Without embeddable example, single button (app 2)

    +

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

    +
    + + + + + + + plugins.uiActionsEnhanced.executeTriggerActions( + SAMPLE_APP2_CLICK_TRIGGER, + sampleApp2ClickContext + ) + } + > + Click this element + + + + + {showManager && ( + setShowManager(false)} aria-labelledby="Drilldown Manager"> + setShowManager(false)} + viewMode={viewRef.current} + dynamicActionManager={managerWithoutEmbeddableSingleButton} + triggers={[SAMPLE_APP2_CLICK_TRIGGER]} + /> + + )} + + ); +}; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_single_button_example/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_single_button_example/index.tsx new file mode 100644 index 0000000000000..74766309dc723 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/containers/drilldowns_without_embeddable_single_button_example/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './drilldowns_without_embeddable_single_button_example'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/context/context.ts b/x-pack/examples/ui_actions_enhanced_examples/public/context/context.ts new file mode 100644 index 0000000000000..2edb29eb5b28a --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/context/context.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createContext, useContext } from 'react'; +import { CoreStart } from 'src/core/public'; +import { UiActionsEnhancedDynamicActionManager } from '../../../../plugins/ui_actions_enhanced/public'; +import { StartDependencies } from '../plugin'; + +export interface UiActionsExampleAppContextValue { + appBasePath: string; + core: CoreStart; + plugins: StartDependencies; + managerWithoutEmbeddable: UiActionsEnhancedDynamicActionManager; + managerWithoutEmbeddableSingleButton: UiActionsEnhancedDynamicActionManager; + managerWithEmbeddable: UiActionsEnhancedDynamicActionManager; +} + +export const context = createContext(null); +export const useUiActions = () => useContext(context)!; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_title/index.ts b/x-pack/examples/ui_actions_enhanced_examples/public/context/index.ts similarity index 81% rename from x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_title/index.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/context/index.ts index b34e61b3b5e76..94b6977050535 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_title/index.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/context/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ExplorationTitle } from './exploration_title'; +export * from './context'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_hello_world_drilldown/app1_hello_world_drilldown.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_hello_world_drilldown/app1_hello_world_drilldown.tsx new file mode 100644 index 0000000000000..25de2f5953f31 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_hello_world_drilldown/app1_hello_world_drilldown.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFieldText, EuiFormRow } from '@elastic/eui'; +import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; +import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../../plugins/ui_actions_enhanced/public'; +import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../../src/plugins/kibana_utils/public'; +import { SAMPLE_APP1_CLICK_TRIGGER, SampleApp1ClickContext } from '../../triggers'; +import { SerializableState } from '../../../../../../src/plugins/kibana_utils/common'; + +export interface Config extends SerializableState { + name: string; +} + +type Trigger = typeof SAMPLE_APP1_CLICK_TRIGGER; +type Context = SampleApp1ClickContext; + +export type CollectConfigProps = CollectConfigPropsBase; + +export const APP1_HELLO_WORLD_DRILLDOWN = 'APP1_HELLO_WORLD_DRILLDOWN'; + +export class App1HelloWorldDrilldown implements Drilldown { + public readonly id = APP1_HELLO_WORLD_DRILLDOWN; + + public readonly order = 8; + + public readonly getDisplayName = () => 'Hello world (app 1)'; + + public readonly euiIcon = 'cheer'; + + supportedTriggers(): Trigger[] { + return [SAMPLE_APP1_CLICK_TRIGGER]; + } + + private readonly ReactCollectConfig: React.FC = ({ + config, + onConfig, + context, + }) => ( + + onConfig({ ...config, name: event.target.value })} + /> + + ); + + public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); + + public readonly createConfig = () => ({ + name: '', + }); + + public readonly isConfigValid = (config: Config): config is Config => { + return !!config.name; + }; + + public readonly execute = async (config: Config, context: Context) => { + alert(`Hello, ${config.name}`); + }; +} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_hello_world_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_hello_world_drilldown/index.tsx new file mode 100644 index 0000000000000..a92ba24d3f345 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_hello_world_drilldown/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './app1_hello_world_drilldown'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_to_dashboard_drilldown/app1_to_dashboard_drilldown.ts b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_to_dashboard_drilldown/app1_to_dashboard_drilldown.ts new file mode 100644 index 0000000000000..058b52c78b427 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_to_dashboard_drilldown/app1_to_dashboard_drilldown.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + DashboardEnhancedAbstractDashboardDrilldown as AbstractDashboardDrilldown, + DashboardEnhancedAbstractDashboardDrilldownConfig as Config, +} from '../../../../../plugins/dashboard_enhanced/public'; +import { SAMPLE_APP1_CLICK_TRIGGER, SampleApp1ClickContext } from '../../triggers'; +import { KibanaURL } from '../../../../../../src/plugins/share/public'; + +export const APP1_TO_DASHBOARD_DRILLDOWN = 'APP1_TO_DASHBOARD_DRILLDOWN'; + +type Trigger = typeof SAMPLE_APP1_CLICK_TRIGGER; +type Context = SampleApp1ClickContext; + +export class App1ToDashboardDrilldown extends AbstractDashboardDrilldown { + public readonly id = APP1_TO_DASHBOARD_DRILLDOWN; + + public readonly supportedTriggers = () => [SAMPLE_APP1_CLICK_TRIGGER] as Trigger[]; + + protected async getURL(config: Config, context: Context): Promise { + const path = await this.urlGenerator.createUrl({ + dashboardId: config.dashboardId, + }); + const url = new KibanaURL(path); + + return url; + } +} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_to_dashboard_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_to_dashboard_drilldown/index.tsx new file mode 100644 index 0000000000000..4c0c2c221496a --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app1_to_dashboard_drilldown/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './app1_to_dashboard_drilldown'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app2_to_dashboard_drilldown/app2_to_dashboard_drilldown.ts b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app2_to_dashboard_drilldown/app2_to_dashboard_drilldown.ts new file mode 100644 index 0000000000000..33bf54d4b4cc2 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app2_to_dashboard_drilldown/app2_to_dashboard_drilldown.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + DashboardEnhancedAbstractDashboardDrilldown as AbstractDashboardDrilldown, + DashboardEnhancedAbstractDashboardDrilldownConfig as Config, +} from '../../../../../plugins/dashboard_enhanced/public'; +import { SAMPLE_APP2_CLICK_TRIGGER, SampleApp2ClickContext } from '../../triggers'; +import { KibanaURL } from '../../../../../../src/plugins/share/public'; + +export const APP2_TO_DASHBOARD_DRILLDOWN = 'APP2_TO_DASHBOARD_DRILLDOWN'; + +type Trigger = typeof SAMPLE_APP2_CLICK_TRIGGER; +type Context = SampleApp2ClickContext; + +export class App2ToDashboardDrilldown extends AbstractDashboardDrilldown { + public readonly id = APP2_TO_DASHBOARD_DRILLDOWN; + + public readonly supportedTriggers = () => [SAMPLE_APP2_CLICK_TRIGGER] as Trigger[]; + + protected async getURL(config: Config, context: Context): Promise { + const path = await this.urlGenerator.createUrl({ + dashboardId: config.dashboardId, + }); + const url = new KibanaURL(path); + + return url; + } +} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app2_to_dashboard_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app2_to_dashboard_drilldown/index.tsx new file mode 100644 index 0000000000000..ef09061115f43 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/app2_to_dashboard_drilldown/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './app2_to_dashboard_drilldown'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/README.md b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_drilldown/README.md similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/README.md rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_drilldown/README.md diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_drilldown/index.tsx similarity index 83% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_drilldown/index.tsx index cac5f0b29dc6e..a7324f5530130 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_drilldown/index.tsx @@ -6,14 +6,14 @@ import React from 'react'; import { EuiFormRow, EuiFieldText } from '@elastic/eui'; -import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public'; -import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public'; -import { ChartActionContext } from '../../../../../src/plugins/embeddable/public'; -import { CollectConfigProps } from '../../../../../src/plugins/kibana_utils/public'; +import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; +import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../../plugins/ui_actions_enhanced/public'; +import { ChartActionContext } from '../../../../../../src/plugins/embeddable/public'; +import { CollectConfigProps } from '../../../../../../src/plugins/kibana_utils/public'; import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, -} from '../../../../../src/plugins/ui_actions/public'; +} from '../../../../../../src/plugins/ui_actions/public'; export type ActionContext = ChartActionContext; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_only_range_select_drilldown/README.md b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_only_range_select_drilldown/README.md similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_only_range_select_drilldown/README.md rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_only_range_select_drilldown/README.md diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_only_range_select_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_only_range_select_drilldown/index.tsx similarity index 81% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_only_range_select_drilldown/index.tsx rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_only_range_select_drilldown/index.tsx index fa2f0825f9335..24385bd6baa42 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_only_range_select_drilldown/index.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_hello_world_only_range_select_drilldown/index.tsx @@ -6,12 +6,12 @@ import React from 'react'; import { EuiFormRow, EuiFieldText } from '@elastic/eui'; -import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public'; -import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public'; -import { RangeSelectContext } from '../../../../../src/plugins/embeddable/public'; -import { CollectConfigProps } from '../../../../../src/plugins/kibana_utils/public'; -import { SELECT_RANGE_TRIGGER } from '../../../../../src/plugins/ui_actions/public'; -import { BaseActionFactoryContext } from '../../../../plugins/ui_actions_enhanced/public/dynamic_actions'; +import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; +import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../../plugins/ui_actions_enhanced/public'; +import { RangeSelectContext } from '../../../../../../src/plugins/embeddable/public'; +import { CollectConfigProps } from '../../../../../../src/plugins/kibana_utils/public'; +import { SELECT_RANGE_TRIGGER } from '../../../../../../src/plugins/ui_actions/public'; +import { BaseActionFactoryContext } from '../../../../../plugins/ui_actions_enhanced/public/dynamic_actions'; export type Config = { name: string; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/collect_config_container.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/collect_config_container.tsx similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/collect_config_container.tsx rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/collect_config_container.tsx diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/discover_drilldown_config.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/components/discover_drilldown_config/discover_drilldown_config.tsx similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/discover_drilldown_config.tsx rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/components/discover_drilldown_config/discover_drilldown_config.tsx diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/i18n.ts b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/components/discover_drilldown_config/i18n.ts similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/i18n.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/components/discover_drilldown_config/i18n.ts diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/index.ts b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/components/discover_drilldown_config/index.ts similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/index.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/components/discover_drilldown_config/index.ts diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/index.ts b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/components/index.ts similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/index.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/components/index.ts diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/constants.ts b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/constants.ts similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/constants.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/constants.ts diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/drilldown.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/drilldown.tsx similarity index 88% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/drilldown.tsx rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/drilldown.tsx index ba8d7f395e738..9cda534a340d6 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/drilldown.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/drilldown.tsx @@ -5,15 +5,15 @@ */ import React from 'react'; -import { StartDependencies as Start } from '../plugin'; -import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public'; -import { StartServicesGetter } from '../../../../../src/plugins/kibana_utils/public'; +import { StartDependencies as Start } from '../../plugin'; +import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; +import { StartServicesGetter } from '../../../../../../src/plugins/kibana_utils/public'; import { ActionContext, Config, CollectConfigProps } from './types'; import { CollectConfigContainer } from './collect_config_container'; import { SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN } from './constants'; -import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public'; +import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../../plugins/ui_actions_enhanced/public'; import { txtGoToDiscover } from './i18n'; -import { APPLY_FILTER_TRIGGER } from '../../../../../src/plugins/ui_actions/public'; +import { APPLY_FILTER_TRIGGER } from '../../../../../../src/plugins/ui_actions/public'; const isOutputWithIndexPatterns = ( output: unknown diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/i18n.ts b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/i18n.ts similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/i18n.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/i18n.ts diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/index.ts b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/index.ts similarity index 100% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/index.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/index.ts diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/types.ts similarity index 87% rename from x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/types.ts index 692de571e8a00..f0497780430d4 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/drilldowns/dashboard_to_discover_drilldown/types.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public'; -import { ApplyGlobalFilterActionContext } from '../../../../../src/plugins/data/public'; +import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../../src/plugins/kibana_utils/public'; +import { ApplyGlobalFilterActionContext } from '../../../../../../src/plugins/data/public'; export type ActionContext = ApplyGlobalFilterActionContext; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/embeddables/button_embeddable/button_embeddable.ts b/x-pack/examples/ui_actions_enhanced_examples/public/embeddables/button_embeddable/button_embeddable.ts new file mode 100644 index 0000000000000..fcd0c9b94c988 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/embeddables/button_embeddable/button_embeddable.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createElement } from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { AdvancedUiActionsStart } from '../../../../../plugins/ui_actions_enhanced/public'; +import { Embeddable, EmbeddableInput } from '../../../../../../src/plugins/embeddable/public'; +import { ButtonEmbeddableComponent } from './button_embeddable_component'; +import { VALUE_CLICK_TRIGGER } from '../../../../../../src/plugins/ui_actions/public'; + +export const BUTTON_EMBEDDABLE = 'BUTTON_EMBEDDABLE'; + +export interface ButtonEmbeddableParams { + uiActions: AdvancedUiActionsStart; +} + +export class ButtonEmbeddable extends Embeddable { + type = BUTTON_EMBEDDABLE; + + constructor(input: EmbeddableInput, private readonly params: ButtonEmbeddableParams) { + super(input, {}); + } + + reload() {} + + private el?: HTMLElement; + + public render(el: HTMLElement): void { + super.render(el); + this.el = el; + render( + createElement(ButtonEmbeddableComponent, { + onClick: () => { + this.params.uiActions.getTrigger(VALUE_CLICK_TRIGGER).exec({ + embeddable: this, + data: { + data: [], + }, + }); + }, + }), + el + ); + } + + public destroy() { + super.destroy(); + if (this.el) unmountComponentAtNode(this.el); + } +} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/embeddables/button_embeddable/button_embeddable_component.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/embeddables/button_embeddable/button_embeddable_component.tsx new file mode 100644 index 0000000000000..d810870de467b --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/embeddables/button_embeddable/button_embeddable_component.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { EuiCard, EuiFlexItem, EuiIcon } from '@elastic/eui'; + +export interface ButtonEmbeddableComponentProps { + onClick: () => void; +} + +export const ButtonEmbeddableComponent: React.FC = ({ + onClick, +}) => { + return ( + + } + title={`Click me!`} + description={'This embeddable fires "VALUE_CLICK" trigger on click'} + onClick={onClick} + /> + + ); +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/index.ts b/x-pack/examples/ui_actions_enhanced_examples/public/embeddables/button_embeddable/index.ts similarity index 77% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/index.ts rename to x-pack/examples/ui_actions_enhanced_examples/public/embeddables/button_embeddable/index.ts index c34290528d914..e3bfc9c7da425 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/index.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/embeddables/button_embeddable/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { CollectConfigContainer } from './collect_config_container'; +export * from './button_embeddable'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/mount.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/mount.tsx new file mode 100644 index 0000000000000..b2909c636b528 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/mount.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { CoreSetup, AppMountParameters } from 'kibana/public'; +import { StartDependencies, UiActionsEnhancedExamplesStart } from './plugin'; +import { UiActionsExampleAppContextValue, context } from './context'; + +export const mount = ( + coreSetup: CoreSetup +) => async ({ appBasePath, element }: AppMountParameters) => { + const [ + core, + plugins, + { managerWithoutEmbeddable, managerWithoutEmbeddableSingleButton, managerWithEmbeddable }, + ] = await coreSetup.getStartServices(); + const { App } = await import('./containers/app'); + + const deps: UiActionsExampleAppContextValue = { + appBasePath, + core, + plugins, + managerWithoutEmbeddable, + managerWithoutEmbeddableSingleButton, + managerWithEmbeddable, + }; + const reactElement = ( + + + + ); + render(reactElement, element); + return () => unmountComponentAtNode(element); +}; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts b/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts index 3f0b64a2ac9ed..c09f64f7b110b 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts @@ -4,44 +4,174 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Plugin, CoreSetup, CoreStart } from '../../../../src/core/public'; +import { createElement as h } from 'react'; +import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; +import { Plugin, CoreSetup, CoreStart, AppNavLinkStatus } from '../../../../src/core/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { AdvancedUiActionsSetup, AdvancedUiActionsStart, } from '../../../../x-pack/plugins/ui_actions_enhanced/public'; -import { DashboardHelloWorldDrilldown } from './dashboard_hello_world_drilldown'; -import { DashboardToDiscoverDrilldown } from './dashboard_to_discover_drilldown'; +import { DashboardHelloWorldDrilldown } from './drilldowns/dashboard_hello_world_drilldown'; +import { DashboardToDiscoverDrilldown } from './drilldowns/dashboard_to_discover_drilldown'; +import { App1ToDashboardDrilldown } from './drilldowns/app1_to_dashboard_drilldown'; +import { App1HelloWorldDrilldown } from './drilldowns/app1_hello_world_drilldown'; import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public'; import { DiscoverSetup, DiscoverStart } from '../../../../src/plugins/discover/public'; -import { DashboardHelloWorldOnlyRangeSelectDrilldown } from './dashboard_hello_world_only_range_select_drilldown'; +import { DashboardSetup, DashboardStart } from '../../../../src/plugins/dashboard/public'; +import { DashboardHelloWorldOnlyRangeSelectDrilldown } from './drilldowns/dashboard_hello_world_only_range_select_drilldown'; +import { DeveloperExamplesSetup } from '../../../../examples/developer_examples/public'; +import { + sampleApp1ClickTrigger, + sampleApp2ClickTrigger, + SAMPLE_APP2_CLICK_TRIGGER, + SampleApp2ClickContext, + sampleApp2ClickContext, +} from './triggers'; +import { mount } from './mount'; +import { + UiActionsEnhancedMemoryActionStorage, + UiActionsEnhancedDynamicActionManager, +} from '../../../plugins/ui_actions_enhanced/public'; +import { App2ToDashboardDrilldown } from './drilldowns/app2_to_dashboard_drilldown'; export interface SetupDependencies { + dashboard: DashboardSetup; data: DataPublicPluginSetup; + developerExamples: DeveloperExamplesSetup; discover: DiscoverSetup; uiActionsEnhanced: AdvancedUiActionsSetup; } export interface StartDependencies { + dashboard: DashboardStart; data: DataPublicPluginStart; discover: DiscoverStart; uiActionsEnhanced: AdvancedUiActionsStart; } +export interface UiActionsEnhancedExamplesStart { + managerWithoutEmbeddable: UiActionsEnhancedDynamicActionManager; + managerWithoutEmbeddableSingleButton: UiActionsEnhancedDynamicActionManager; + managerWithEmbeddable: UiActionsEnhancedDynamicActionManager; +} + export class UiActionsEnhancedExamplesPlugin - implements Plugin { + implements Plugin { public setup( - core: CoreSetup, - { uiActionsEnhanced: uiActions }: SetupDependencies + core: CoreSetup, + { uiActionsEnhanced: uiActions, developerExamples }: SetupDependencies ) { const start = createStartServicesGetter(core.getStartServices); uiActions.registerDrilldown(new DashboardHelloWorldDrilldown()); uiActions.registerDrilldown(new DashboardHelloWorldOnlyRangeSelectDrilldown()); uiActions.registerDrilldown(new DashboardToDiscoverDrilldown({ start })); + uiActions.registerDrilldown(new App1HelloWorldDrilldown()); + uiActions.registerDrilldown(new App1ToDashboardDrilldown({ start })); + uiActions.registerDrilldown(new App2ToDashboardDrilldown({ start })); + + uiActions.registerTrigger(sampleApp1ClickTrigger); + uiActions.registerTrigger(sampleApp2ClickTrigger); + + uiActions.addTriggerAction(SAMPLE_APP2_CLICK_TRIGGER, { + id: 'SINGLE_ELEMENT_EXAMPLE_OPEN_FLYOUT_AT_CREATE', + order: 2, + getDisplayName: () => 'Add drilldown', + getIconType: () => 'plusInCircle', + isCompatible: async ({ workpadId, elementId }: SampleApp2ClickContext) => + workpadId === '123' && elementId === '456', + execute: async () => { + const { core: coreStart, plugins: pluginsStart, self } = start(); + const handle = coreStart.overlays.openFlyout( + toMountPoint( + h(pluginsStart.uiActionsEnhanced.FlyoutManageDrilldowns, { + onClose: () => handle.close(), + viewMode: 'create', + dynamicActionManager: self.managerWithoutEmbeddableSingleButton, + triggers: [SAMPLE_APP2_CLICK_TRIGGER], + placeContext: {}, + }) + ), + { + ownFocus: true, + } + ); + }, + }); + uiActions.addTriggerAction(SAMPLE_APP2_CLICK_TRIGGER, { + id: 'SINGLE_ELEMENT_EXAMPLE_OPEN_FLYOUT_AT_MANAGE', + order: 1, + getDisplayName: () => 'Manage drilldowns', + getIconType: () => 'list', + isCompatible: async ({ workpadId, elementId }: SampleApp2ClickContext) => + workpadId === '123' && elementId === '456', + execute: async () => { + const { core: coreStart, plugins: pluginsStart, self } = start(); + const handle = coreStart.overlays.openFlyout( + toMountPoint( + h(pluginsStart.uiActionsEnhanced.FlyoutManageDrilldowns, { + onClose: () => handle.close(), + viewMode: 'manage', + dynamicActionManager: self.managerWithoutEmbeddableSingleButton, + triggers: [SAMPLE_APP2_CLICK_TRIGGER], + placeContext: { sampleApp2ClickContext }, + }) + ), + { + ownFocus: true, + } + ); + }, + }); + + core.application.register({ + id: 'ui_actions_enhanced-explorer', + title: 'UI Actions Enhanced Explorer', + navLinkStatus: AppNavLinkStatus.hidden, + mount: mount(core), + }); + + developerExamples.register({ + appId: 'ui_actions_enhanced-explorer', + title: 'UI Actions Enhanced', + description: 'Examples of how to use drilldowns.', + links: [ + { + label: 'README', + href: + 'https://github.com/elastic/kibana/tree/master/x-pack/examples/ui_actions_enhanced_examples#ui-actions-enhanced-examples', + iconType: 'logoGithub', + size: 's', + target: '_blank', + }, + ], + }); } - public start(core: CoreStart, plugins: StartDependencies) {} + public start(core: CoreStart, plugins: StartDependencies): UiActionsEnhancedExamplesStart { + const managerWithoutEmbeddable = new UiActionsEnhancedDynamicActionManager({ + storage: new UiActionsEnhancedMemoryActionStorage(), + isCompatible: async () => true, + uiActions: plugins.uiActionsEnhanced, + }); + const managerWithoutEmbeddableSingleButton = new UiActionsEnhancedDynamicActionManager({ + storage: new UiActionsEnhancedMemoryActionStorage(), + isCompatible: async () => true, + uiActions: plugins.uiActionsEnhanced, + }); + const managerWithEmbeddable = new UiActionsEnhancedDynamicActionManager({ + storage: new UiActionsEnhancedMemoryActionStorage(), + isCompatible: async () => true, + uiActions: plugins.uiActionsEnhanced, + }); + + return { + managerWithoutEmbeddable, + managerWithoutEmbeddableSingleButton, + managerWithEmbeddable, + }; + } public stop() {} } diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/triggers/index.ts b/x-pack/examples/ui_actions_enhanced_examples/public/triggers/index.ts new file mode 100644 index 0000000000000..554cb778934cb --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/triggers/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './sample_app1_trigger'; +export * from './sample_app2_trigger'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/triggers/sample_app1_trigger.ts b/x-pack/examples/ui_actions_enhanced_examples/public/triggers/sample_app1_trigger.ts new file mode 100644 index 0000000000000..93a985626c6cd --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/triggers/sample_app1_trigger.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Trigger } from '../../../../../src/plugins/ui_actions/public'; + +export const SAMPLE_APP1_CLICK_TRIGGER = 'SAMPLE_APP1_CLICK_TRIGGER'; + +export const sampleApp1ClickTrigger: Trigger<'SAMPLE_APP1_CLICK_TRIGGER'> = { + id: SAMPLE_APP1_CLICK_TRIGGER, + title: 'App 1 trigger fired on click', + description: 'Could be a click on a ML job in ML app.', +}; + +declare module '../../../../../src/plugins/ui_actions/public' { + export interface TriggerContextMapping { + [SAMPLE_APP1_CLICK_TRIGGER]: SampleApp1ClickContext; + } +} + +export interface SampleApp1ClickContext { + job: SampleMlJob; +} + +export interface SampleMlJob { + job_id: string; + job_type: 'anomaly_detector'; + description: string; +} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/triggers/sample_app2_trigger.ts b/x-pack/examples/ui_actions_enhanced_examples/public/triggers/sample_app2_trigger.ts new file mode 100644 index 0000000000000..664c99afc94a5 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/triggers/sample_app2_trigger.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Trigger } from '../../../../../src/plugins/ui_actions/public'; + +export const SAMPLE_APP2_CLICK_TRIGGER = 'SAMPLE_APP2_CLICK_TRIGGER'; + +export const sampleApp2ClickTrigger: Trigger<'SAMPLE_APP2_CLICK_TRIGGER'> = { + id: SAMPLE_APP2_CLICK_TRIGGER, + title: 'App 2 trigger fired on click', + description: 'Could be a click on an element in Canvas app.', +}; + +declare module '../../../../../src/plugins/ui_actions/public' { + export interface TriggerContextMapping { + [SAMPLE_APP2_CLICK_TRIGGER]: SampleApp2ClickContext; + } +} + +export interface SampleApp2ClickContext { + workpadId: string; + elementId: string; +} + +export const sampleApp2ClickContext: SampleApp2ClickContext = { + workpadId: '123', + elementId: '456', +}; diff --git a/x-pack/package.json b/x-pack/package.json index 36c6c2dee279a..941ebab2f3d65 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -6,7 +6,7 @@ "license": "Elastic-License", "scripts": { "kbn": "node ../scripts/kbn", - "kbn:bootstrap": "node ../scripts/build_ts_refs --project tsconfig.refs.json && node plugins/canvas/scripts/storybook --clean", + "kbn:bootstrap": "node plugins/canvas/scripts/storybook --clean", "start": "node ../scripts/kibana --dev", "build": "gulp build", "testonly": "echo 'Deprecated, use `yarn test`' && gulp test", @@ -273,7 +273,7 @@ "@babel/runtime": "^7.11.2", "@elastic/datemath": "5.0.3", "@elastic/ems-client": "7.10.0", - "@elastic/eui": "29.0.0", + "@elastic/eui": "29.3.0", "@elastic/filesaver": "1.1.2", "@elastic/node-crypto": "1.2.1", "@elastic/numeral": "^2.5.0", diff --git a/x-pack/plugins/actions/common/types.ts b/x-pack/plugins/actions/common/types.ts index 41ec4d2a88e9f..9ff1379894a29 100644 --- a/x-pack/plugins/actions/common/types.ts +++ b/x-pack/plugins/actions/common/types.ts @@ -26,11 +26,25 @@ export interface ActionResult { } // the result returned from an action type executor function +const ActionTypeExecutorResultStatusValues = ['ok', 'error'] as const; +type ActionTypeExecutorResultStatus = typeof ActionTypeExecutorResultStatusValues[number]; + export interface ActionTypeExecutorResult { actionId: string; - status: 'ok' | 'error'; + status: ActionTypeExecutorResultStatus; message?: string; serviceMessage?: string; data?: Data; retry?: null | boolean | Date; } + +export function isActionTypeExecutorResult( + result: unknown +): result is ActionTypeExecutorResult { + const unsafeResult = result as ActionTypeExecutorResult; + return ( + unsafeResult && + typeof unsafeResult?.actionId === 'string' && + ActionTypeExecutorResultStatusValues.includes(unsafeResult?.status) + ); +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts index 4c31691280c2c..513ca2cf18e6c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts @@ -37,7 +37,7 @@ export const ExecutorSubActionSchema = schema.oneOf([ ]); export const ExecutorSubActionPushParamsSchema = schema.object({ - savedObjectId: schema.string(), + savedObjectId: schema.nullable(schema.string()), title: schema.string(), description: schema.nullable(schema.string()), externalId: schema.nullable(schema.string()), diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts index 151f703dcc07e..b6e3a9525dfd4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts @@ -37,7 +37,7 @@ export const ExecutorSubActionSchema = schema.oneOf([ ]); export const ExecutorSubActionPushParamsSchema = schema.object({ - savedObjectId: schema.string(), + savedObjectId: schema.nullable(schema.string()), title: schema.string(), description: schema.nullable(schema.string()), externalId: schema.nullable(schema.string()), diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index 9896d4175954c..0dd70ea36636e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -34,7 +34,7 @@ export const ExecutorSubActionSchema = schema.oneOf([ ]); export const ExecutorSubActionPushParamsSchema = schema.object({ - savedObjectId: schema.string(), + savedObjectId: schema.nullable(schema.string()), title: schema.string(), description: schema.nullable(schema.string()), comment: schema.nullable(schema.string()), diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/alert_type.ts b/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/alert_type.ts new file mode 100644 index 0000000000000..ba3c9b2050e48 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/alert_type.ts @@ -0,0 +1,232 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 { schema } from '@kbn/config-schema'; +import { Service } from '../../types'; +import { BUILT_IN_ALERTS_FEATURE_ID } from '../../../common'; +import { getGeoThresholdExecutor } from './geo_threshold'; +import { + ActionGroup, + AlertServices, + ActionVariable, + AlertTypeState, +} from '../../../../alerts/server'; + +export const GEO_THRESHOLD_ID = '.geo-threshold'; +export type TrackingEvent = 'entered' | 'exited'; +export const ActionGroupId = 'tracking threshold met'; + +const actionVariableContextToEntityDateTimeLabel = i18n.translate( + 'xpack.alertingBuiltins.geoThreshold.actionVariableContextToEntityDateTimeLabel', + { + defaultMessage: `The time the entity was detected in the current boundary`, + } +); + +const actionVariableContextFromEntityDateTimeLabel = i18n.translate( + 'xpack.alertingBuiltins.geoThreshold.actionVariableContextFromEntityDateTimeLabel', + { + defaultMessage: `The last time the entity was recorded in the previous boundary`, + } +); + +const actionVariableContextToEntityLocationLabel = i18n.translate( + 'xpack.alertingBuiltins.geoThreshold.actionVariableContextToEntityLocationLabel', + { + defaultMessage: 'The most recently captured location of the entity', + } +); + +const actionVariableContextCrossingLineLabel = i18n.translate( + 'xpack.alertingBuiltins.geoThreshold.actionVariableContextCrossingLineLabel', + { + defaultMessage: + 'GeoJSON line connecting the two locations that were used to determine the crossing event', + } +); + +const actionVariableContextFromEntityLocationLabel = i18n.translate( + 'xpack.alertingBuiltins.geoThreshold.actionVariableContextFromEntityLocationLabel', + { + defaultMessage: 'The previously captured location of the entity', + } +); + +const actionVariableContextToBoundaryIdLabel = i18n.translate( + 'xpack.alertingBuiltins.geoThreshold.actionVariableContextCurrentBoundaryIdLabel', + { + defaultMessage: 'The current boundary id containing the entity (if any)', + } +); + +const actionVariableContextToBoundaryNameLabel = i18n.translate( + 'xpack.alertingBuiltins.geoThreshold.actionVariableContextToBoundaryNameLabel', + { + defaultMessage: 'The boundary (if any) the entity has crossed into and is currently located', + } +); + +const actionVariableContextFromBoundaryNameLabel = i18n.translate( + 'xpack.alertingBuiltins.geoThreshold.actionVariableContextFromBoundaryNameLabel', + { + defaultMessage: 'The boundary (if any) the entity has crossed from and was previously located', + } +); + +const actionVariableContextFromBoundaryIdLabel = i18n.translate( + 'xpack.alertingBuiltins.geoThreshold.actionVariableContextFromBoundaryIdLabel', + { + defaultMessage: 'The previous boundary id containing the entity (if any)', + } +); + +const actionVariableContextToEntityDocumentIdLabel = i18n.translate( + 'xpack.alertingBuiltins.geoThreshold.actionVariableContextCrossingDocumentIdLabel', + { + defaultMessage: 'The id of the crossing entity document', + } +); + +const actionVariableContextFromEntityDocumentIdLabel = i18n.translate( + 'xpack.alertingBuiltins.geoThreshold.actionVariableContextFromEntityDocumentIdLabel', + { + defaultMessage: 'The id of the crossing entity document', + } +); + +const actionVariableContextTimeOfDetectionLabel = i18n.translate( + 'xpack.alertingBuiltins.geoThreshold.actionVariableContextTimeOfDetectionLabel', + { + defaultMessage: 'The alert interval end time this change was recorded', + } +); + +const actionVariableContextEntityIdLabel = i18n.translate( + 'xpack.alertingBuiltins.geoThreshold.actionVariableContextEntityIdLabel', + { + defaultMessage: 'The entity ID of the document that triggered the alert', + } +); + +const actionVariables = { + context: [ + // Alert-specific data + { name: 'entityId', description: actionVariableContextEntityIdLabel }, + { name: 'timeOfDetection', description: actionVariableContextTimeOfDetectionLabel }, + { name: 'crossingLine', description: actionVariableContextCrossingLineLabel }, + + // Corresponds to a specific document in the entity-index + { name: 'toEntityLocation', description: actionVariableContextToEntityLocationLabel }, + { + name: 'toEntityDateTime', + description: actionVariableContextToEntityDateTimeLabel, + }, + { name: 'toEntityDocumentId', description: actionVariableContextToEntityDocumentIdLabel }, + + // Corresponds to a specific document in the boundary-index + { name: 'toBoundaryId', description: actionVariableContextToBoundaryIdLabel }, + { name: 'toBoundaryName', description: actionVariableContextToBoundaryNameLabel }, + + // Corresponds to a specific document in the entity-index (from) + { name: 'fromEntityLocation', description: actionVariableContextFromEntityLocationLabel }, + { name: 'fromEntityDateTime', description: actionVariableContextFromEntityDateTimeLabel }, + { name: 'fromEntityDocumentId', description: actionVariableContextFromEntityDocumentIdLabel }, + + // Corresponds to a specific document in the boundary-index (from) + { name: 'fromBoundaryId', description: actionVariableContextFromBoundaryIdLabel }, + { name: 'fromBoundaryName', description: actionVariableContextFromBoundaryNameLabel }, + ], +}; + +export const ParamsSchema = schema.object({ + index: schema.string({ minLength: 1 }), + indexId: schema.string({ minLength: 1 }), + geoField: schema.string({ minLength: 1 }), + entity: schema.string({ minLength: 1 }), + dateField: schema.string({ minLength: 1 }), + trackingEvent: schema.string({ minLength: 1 }), + boundaryType: schema.string({ minLength: 1 }), + boundaryIndexTitle: schema.string({ minLength: 1 }), + boundaryIndexId: schema.string({ minLength: 1 }), + boundaryGeoField: schema.string({ minLength: 1 }), + boundaryNameField: schema.maybe(schema.string({ minLength: 1 })), + delayOffsetWithUnits: schema.maybe(schema.string({ minLength: 1 })), +}); + +export interface GeoThresholdParams { + index: string; + indexId: string; + geoField: string; + entity: string; + dateField: string; + trackingEvent: string; + boundaryType: string; + boundaryIndexTitle: string; + boundaryIndexId: string; + boundaryGeoField: string; + boundaryNameField?: string; + delayOffsetWithUnits?: string; +} + +export function getAlertType( + service: Omit +): { + defaultActionGroupId: string; + actionGroups: ActionGroup[]; + executor: ({ + previousStartedAt: currIntervalStartTime, + startedAt: currIntervalEndTime, + services, + params, + alertId, + state, + }: { + previousStartedAt: Date | null; + startedAt: Date; + services: AlertServices; + params: GeoThresholdParams; + alertId: string; + state: AlertTypeState; + }) => Promise; + validate?: { + params?: { + validate: (object: unknown) => GeoThresholdParams; + }; + }; + name: string; + producer: string; + id: string; + actionVariables?: { + context?: ActionVariable[]; + state?: ActionVariable[]; + params?: ActionVariable[]; + }; +} { + const alertTypeName = i18n.translate('xpack.alertingBuiltins.geoThreshold.alertTypeTitle', { + defaultMessage: 'Geo tracking threshold', + }); + + const actionGroupName = i18n.translate( + 'xpack.alertingBuiltins.geoThreshold.actionGroupThresholdMetTitle', + { + defaultMessage: 'Tracking threshold met', + } + ); + + return { + id: GEO_THRESHOLD_ID, + name: alertTypeName, + actionGroups: [{ id: ActionGroupId, name: actionGroupName }], + defaultActionGroupId: ActionGroupId, + executor: getGeoThresholdExecutor(service), + producer: BUILT_IN_ALERTS_FEATURE_ID, + validate: { + params: ParamsSchema, + }, + actionVariables, + }; +} diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/es_query_builder.ts b/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/es_query_builder.ts new file mode 100644 index 0000000000000..c4238e62ff261 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/es_query_builder.ts @@ -0,0 +1,162 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ILegacyScopedClusterClient } from 'kibana/server'; +import { SearchResponse } from 'elasticsearch'; +import { Logger } from '../../types'; + +export const OTHER_CATEGORY = 'other'; +// Consider dynamically obtaining from config? +const MAX_TOP_LEVEL_QUERY_SIZE = 0; +const MAX_SHAPES_QUERY_SIZE = 10000; +const MAX_BUCKETS_LIMIT = 65535; + +export async function getShapesFilters( + boundaryIndexTitle: string, + boundaryGeoField: string, + geoField: string, + callCluster: ILegacyScopedClusterClient['callAsCurrentUser'], + log: Logger, + alertId: string, + boundaryNameField?: string +) { + const filters: Record = {}; + const shapesIdsNamesMap: Record = {}; + // Get all shapes in index + const boundaryData: SearchResponse> = await callCluster('search', { + index: boundaryIndexTitle, + body: { + size: MAX_SHAPES_QUERY_SIZE, + }, + }); + boundaryData.hits.hits.forEach(({ _index, _id }) => { + filters[_id] = { + geo_shape: { + [geoField]: { + indexed_shape: { + index: _index, + id: _id, + path: boundaryGeoField, + }, + }, + }, + }; + }); + if (boundaryNameField) { + boundaryData.hits.hits.forEach( + ({ _source, _id }: { _source: Record; _id: string }) => { + shapesIdsNamesMap[_id] = _source[boundaryNameField]; + } + ); + } + return { + shapesFilters: filters, + shapesIdsNamesMap, + }; +} + +export async function executeEsQueryFactory( + { + entity, + index, + dateField, + boundaryGeoField, + geoField, + boundaryIndexTitle, + }: { + entity: string; + index: string; + dateField: string; + boundaryGeoField: string; + geoField: string; + boundaryIndexTitle: string; + boundaryNameField?: string; + }, + { callCluster }: { callCluster: ILegacyScopedClusterClient['callAsCurrentUser'] }, + log: Logger, + shapesFilters: Record +) { + return async ( + gteDateTime: Date | null, + ltDateTime: Date | null + ): Promise | undefined> => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const esQuery: Record = { + index, + body: { + size: MAX_TOP_LEVEL_QUERY_SIZE, + aggs: { + shapes: { + filters: { + other_bucket_key: OTHER_CATEGORY, + filters: shapesFilters, + }, + aggs: { + entitySplit: { + terms: { + size: MAX_BUCKETS_LIMIT / ((Object.keys(shapesFilters).length || 1) * 2), + field: entity, + }, + aggs: { + entityHits: { + top_hits: { + size: 1, + sort: [ + { + [dateField]: { + order: 'desc', + }, + }, + ], + docvalue_fields: [entity, dateField, geoField], + _source: false, + }, + }, + }, + }, + }, + }, + }, + query: { + bool: { + must: [], + filter: [ + { + match_all: {}, + }, + { + range: { + [dateField]: { + ...(gteDateTime ? { gte: gteDateTime } : {}), + lt: ltDateTime, // 'less than' to prevent overlap between intervals + format: 'strict_date_optional_time', + }, + }, + }, + ], + should: [], + must_not: [], + }, + }, + stored_fields: ['*'], + docvalue_fields: [ + { + field: dateField, + format: 'date_time', + }, + ], + }, + }; + + let esResult: SearchResponse | undefined; + try { + esResult = await callCluster('search', esQuery); + } catch (err) { + log.warn(`${err.message}`); + } + return esResult; + }; +} diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/geo_threshold.ts b/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/geo_threshold.ts new file mode 100644 index 0000000000000..f30dea151ece8 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/geo_threshold.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 _ from 'lodash'; +import { SearchResponse } from 'elasticsearch'; +import { executeEsQueryFactory, getShapesFilters, OTHER_CATEGORY } from './es_query_builder'; +import { AlertServices, AlertTypeState } from '../../../../alerts/server'; +import { ActionGroupId, GEO_THRESHOLD_ID, GeoThresholdParams } from './alert_type'; +import { Logger } from '../../types'; + +interface LatestEntityLocation { + location: number[]; + shapeLocationId: string; + entityName: string; + dateInShape: string | null; + docId: string; +} + +// Flatten agg results and get latest locations for each entity +export function transformResults( + results: SearchResponse | undefined, + dateField: string, + geoField: string +): LatestEntityLocation[] { + if (!results) { + return []; + } + + return ( + _.chain(results) + .get('aggregations.shapes.buckets', {}) + // @ts-expect-error + .flatMap((bucket: unknown, bucketKey: string) => { + const subBuckets = _.get(bucket, 'entitySplit.buckets', []); + return _.map(subBuckets, (subBucket) => { + const locationFieldResult = _.get( + subBucket, + `entityHits.hits.hits[0].fields.${geoField}[0]`, + '' + ); + const location = locationFieldResult + ? _.chain(locationFieldResult) + .split(', ') + .map((coordString) => +coordString) + .reverse() + .value() + : null; + const dateInShape = _.get( + subBucket, + `entityHits.hits.hits[0].fields.${dateField}[0]`, + null + ); + const docId = _.get(subBucket, `entityHits.hits.hits[0]._id`); + + return { + location, + shapeLocationId: bucketKey, + entityName: subBucket.key, + dateInShape, + docId, + }; + }); + }) + .orderBy(['entityName', 'dateInShape'], ['asc', 'desc']) + .reduce((accu: LatestEntityLocation[], el: LatestEntityLocation) => { + if (!accu.length || el.entityName !== accu[accu.length - 1].entityName) { + accu.push(el); + } + return accu; + }, []) + .value() + ); +} + +interface EntityMovementDescriptor { + entityName: string; + currLocation: { + location: number[]; + shapeId: string; + date: string | null; + docId: string; + }; + prevLocation: { + location: number[]; + shapeId: string; + date: string | null; + docId: string; + }; +} + +export function getMovedEntities( + currLocationArr: LatestEntityLocation[], + prevLocationArr: LatestEntityLocation[], + trackingEvent: string +): EntityMovementDescriptor[] { + return ( + currLocationArr + // Check if shape has a previous location and has moved + .reduce( + ( + accu: EntityMovementDescriptor[], + { + entityName, + shapeLocationId, + dateInShape, + location, + docId, + }: { + entityName: string; + shapeLocationId: string; + dateInShape: string | null; + location: number[]; + docId: string; + } + ) => { + const prevLocationObj = prevLocationArr.find( + (locationObj: LatestEntityLocation) => locationObj.entityName === entityName + ); + if (!prevLocationObj) { + return accu; + } + if (shapeLocationId !== prevLocationObj.shapeLocationId) { + accu.push({ + entityName, + currLocation: { + location, + shapeId: shapeLocationId, + date: dateInShape, + docId, + }, + prevLocation: { + location: prevLocationObj.location, + shapeId: prevLocationObj.shapeLocationId, + date: prevLocationObj.dateInShape, + docId: prevLocationObj.docId, + }, + }); + } + return accu; + }, + [] + ) + // Do not track entries to or exits from 'other' + .filter((entityMovementDescriptor: EntityMovementDescriptor) => + trackingEvent === 'entered' + ? entityMovementDescriptor.currLocation.shapeId !== OTHER_CATEGORY + : entityMovementDescriptor.prevLocation.shapeId !== OTHER_CATEGORY + ) + ); +} + +function getOffsetTime(delayOffsetWithUnits: string, oldTime: Date): Date { + const timeUnit = delayOffsetWithUnits.slice(-1); + const time: number = +delayOffsetWithUnits.slice(0, -1); + + const adjustedDate = new Date(oldTime.getTime()); + if (timeUnit === 's') { + adjustedDate.setSeconds(adjustedDate.getSeconds() - time); + } else if (timeUnit === 'm') { + adjustedDate.setMinutes(adjustedDate.getMinutes() - time); + } else if (timeUnit === 'h') { + adjustedDate.setHours(adjustedDate.getHours() - time); + } else if (timeUnit === 'd') { + adjustedDate.setDate(adjustedDate.getDate() - time); + } + return adjustedDate; +} + +export const getGeoThresholdExecutor = ({ logger: log }: { logger: Logger }) => + async function ({ + previousStartedAt, + startedAt, + services, + params, + alertId, + state, + }: { + previousStartedAt: Date | null; + startedAt: Date; + services: AlertServices; + params: GeoThresholdParams; + alertId: string; + state: AlertTypeState; + }): Promise { + const { shapesFilters, shapesIdsNamesMap } = state.shapesFilters + ? state + : await getShapesFilters( + params.boundaryIndexTitle, + params.boundaryGeoField, + params.geoField, + services.callCluster, + log, + alertId, + params.boundaryNameField + ); + + const executeEsQuery = await executeEsQueryFactory(params, services, log, shapesFilters); + + let currIntervalStartTime = previousStartedAt; + let currIntervalEndTime = startedAt; + if (params.delayOffsetWithUnits) { + if (currIntervalStartTime) { + currIntervalStartTime = getOffsetTime(params.delayOffsetWithUnits, currIntervalStartTime); + } + currIntervalEndTime = getOffsetTime(params.delayOffsetWithUnits, currIntervalEndTime); + } + + // Start collecting data only on the first cycle + if (!currIntervalStartTime) { + log.debug(`alert ${GEO_THRESHOLD_ID}:${alertId} alert initialized. Collecting data`); + // Consider making first time window configurable? + const tempPreviousEndTime = new Date(currIntervalEndTime); + tempPreviousEndTime.setMinutes(tempPreviousEndTime.getMinutes() - 5); + const prevToCurrentIntervalResults: + | SearchResponse + | undefined = await executeEsQuery(tempPreviousEndTime, currIntervalEndTime); + return { + prevLocationArr: transformResults( + prevToCurrentIntervalResults, + params.dateField, + params.geoField + ), + shapesFilters, + shapesIdsNamesMap, + }; + } + + const currentIntervalResults: SearchResponse | undefined = await executeEsQuery( + currIntervalStartTime, + currIntervalEndTime + ); + // No need to compare if no changes in current interval + if (!_.get(currentIntervalResults, 'hits.total.value')) { + return state; + } + + const currLocationArr: LatestEntityLocation[] = transformResults( + currentIntervalResults, + params.dateField, + params.geoField + ); + + const movedEntities: EntityMovementDescriptor[] = getMovedEntities( + currLocationArr, + state.prevLocationArr, + params.trackingEvent + ); + + // Create alert instances + movedEntities.forEach(({ entityName, currLocation, prevLocation }) => { + const toBoundaryName = shapesIdsNamesMap[currLocation.shapeId] || currLocation.shapeId; + const fromBoundaryName = shapesIdsNamesMap[prevLocation.shapeId] || prevLocation.shapeId; + services + .alertInstanceFactory(`${entityName}-${toBoundaryName || currLocation.shapeId}`) + .scheduleActions(ActionGroupId, { + entityId: entityName, + timeOfDetection: new Date(currIntervalEndTime).getTime(), + crossingLine: `LINESTRING (${prevLocation.location[0]} ${prevLocation.location[1]}, ${currLocation.location[0]} ${currLocation.location[1]})`, + + toEntityLocation: `POINT (${currLocation.location[0]} ${currLocation.location[1]})`, + toEntityDateTime: currLocation.date, + toEntityDocumentId: currLocation.docId, + + toBoundaryId: currLocation.shapeId, + toBoundaryName, + + fromEntityLocation: `POINT (${prevLocation.location[0]} ${prevLocation.location[1]})`, + fromEntityDateTime: prevLocation.date, + fromEntityDocumentId: prevLocation.docId, + + fromBoundaryId: prevLocation.shapeId, + fromBoundaryName, + }); + }); + + // Combine previous results w/ current results for state of next run + const prevLocationArr = _.chain(currLocationArr) + .concat(state.prevLocationArr) + .orderBy(['entityName', 'dateInShape'], ['asc', 'desc']) + .reduce((accu: LatestEntityLocation[], el: LatestEntityLocation) => { + if (!accu.length || el.entityName !== accu[accu.length - 1].entityName) { + accu.push(el); + } + return accu; + }, []) + .value(); + + return { + prevLocationArr, + shapesFilters, + shapesIdsNamesMap, + }; + }; diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/index.ts b/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/index.ts new file mode 100644 index 0000000000000..d57f219bb8f9a --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/index.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. + */ + +import { Service, AlertingSetup } from '../../types'; +import { getAlertType } from './alert_type'; + +interface RegisterParams { + service: Omit; + alerts: AlertingSetup; +} + +export function register(params: RegisterParams) { + const { service, alerts } = params; + alerts.registerType(getAlertType(service)); +} diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/tests/__snapshots__/alert_type.test.ts.snap b/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/tests/__snapshots__/alert_type.test.ts.snap new file mode 100644 index 0000000000000..0cb04144fdb78 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/tests/__snapshots__/alert_type.test.ts.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`alertType alert type creation structure is the expected value 1`] = ` +Object { + "context": Array [ + Object { + "description": "The entity ID of the document that triggered the alert", + "name": "entityId", + }, + Object { + "description": "The alert interval end time this change was recorded", + "name": "timeOfDetection", + }, + Object { + "description": "GeoJSON line connecting the two locations that were used to determine the crossing event", + "name": "crossingLine", + }, + Object { + "description": "The most recently captured location of the entity", + "name": "toEntityLocation", + }, + Object { + "description": "The time the entity was detected in the current boundary", + "name": "toEntityDateTime", + }, + Object { + "description": "The id of the crossing entity document", + "name": "toEntityDocumentId", + }, + Object { + "description": "The current boundary id containing the entity (if any)", + "name": "toBoundaryId", + }, + Object { + "description": "The boundary (if any) the entity has crossed into and is currently located", + "name": "toBoundaryName", + }, + Object { + "description": "The previously captured location of the entity", + "name": "fromEntityLocation", + }, + Object { + "description": "The last time the entity was recorded in the previous boundary", + "name": "fromEntityDateTime", + }, + Object { + "description": "The id of the crossing entity document", + "name": "fromEntityDocumentId", + }, + Object { + "description": "The previous boundary id containing the entity (if any)", + "name": "fromBoundaryId", + }, + Object { + "description": "The boundary (if any) the entity has crossed from and was previously located", + "name": "fromBoundaryName", + }, + ], +} +`; diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/tests/alert_type.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/tests/alert_type.test.ts new file mode 100644 index 0000000000000..5cf113f519a5a --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/tests/alert_type.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { loggingSystemMock } from '../../../../../../../src/core/server/mocks'; +import { getAlertType, GeoThresholdParams } from '../alert_type'; + +describe('alertType', () => { + const service = { + logger: loggingSystemMock.create().get(), + }; + + const alertType = getAlertType(service); + + it('alert type creation structure is the expected value', async () => { + expect(alertType.id).toBe('.geo-threshold'); + expect(alertType.name).toBe('Geo tracking threshold'); + expect(alertType.actionGroups).toEqual([ + { id: 'tracking threshold met', name: 'Tracking threshold met' }, + ]); + + expect(alertType.actionVariables).toMatchSnapshot(); + }); + + it('validator succeeds with valid params', async () => { + const params: GeoThresholdParams = { + index: 'testIndex', + indexId: 'testIndexId', + geoField: 'testField', + entity: 'testField', + dateField: 'testField', + trackingEvent: 'testEvent', + boundaryType: 'testType', + boundaryIndexTitle: 'testIndex', + boundaryIndexId: 'testIndex', + boundaryGeoField: 'testField', + boundaryNameField: 'testField', + delayOffsetWithUnits: 'testOffset', + }; + + expect(alertType.validate?.params?.validate(params)).toBeTruthy(); + }); + + it('validator fails with invalid params', async () => { + const paramsSchema = alertType.validate?.params; + if (!paramsSchema) throw new Error('params validator not set'); + + const params: GeoThresholdParams = { + index: 'testIndex', + indexId: 'testIndexId', + geoField: 'testField', + entity: 'testField', + dateField: 'testField', + trackingEvent: '', + boundaryType: 'testType', + boundaryIndexTitle: '', + boundaryIndexId: 'testIndex', + boundaryGeoField: 'testField', + boundaryNameField: 'testField', + }; + + expect(() => paramsSchema.validate(params)).toThrowErrorMatchingInlineSnapshot( + `"[trackingEvent]: value has length [0] but it must have a minimum length of [1]."` + ); + }); +}); diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/tests/es_sample_response.json b/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/tests/es_sample_response.json new file mode 100644 index 0000000000000..1281777c03761 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/tests/es_sample_response.json @@ -0,0 +1,211 @@ +{ + "took" : 2760, + "timed_out" : false, + "_shards" : { + "total" : 1, + "successful" : 1, + "skipped" : 0, + "failed" : 0 + }, + "hits" : { + "total" : { + "value" : 10000, + "relation" : "gte" + }, + "max_score" : 0.0, + "hits" : [ + { + "_index" : "flight_tracks", + "_id" : "XOng1XQB6yyY-xQxbwWM", + "_score" : 0.0, + "fields" : { + "@timestamp" : [ + "2020-09-28T18:01:29.580Z" + ] + } + }, + { + "_index" : "flight_tracks", + "_id" : "Xeng1XQB6yyY-xQxbwWM", + "_score" : 0.0, + "fields" : { + "@timestamp" : [ + "2020-09-28T18:01:29.580Z" + ] + } + }, + { + "_index" : "flight_tracks", + "_id" : "Xung1XQB6yyY-xQxbwWM", + "_score" : 0.0, + "fields" : { + "@timestamp" : [ + "2020-09-28T18:01:29.580Z" + ] + } + }, + { + "_index" : "flight_tracks", + "_id" : "UOjg1XQB6yyY-xQxZvMz", + "_score" : 0.0, + "fields" : { + "@timestamp" : [ + "2020-09-28T18:01:27.266Z" + ] + } + } + ] + }, + "aggregations" : { + "shapes" : { + "meta" : { }, + "buckets" : { + "0DrJu3QB6yyY-xQxv6Ip" : { + "doc_count" : 1047, + "entitySplit" : { + "doc_count_error_upper_bound" : 0, + "sum_other_doc_count" : 957, + "buckets" : [ + { + "key" : "936", + "doc_count" : 9, + "entityHits" : { + "hits" : { + "total" : { + "value" : 9, + "relation" : "eq" + }, + "max_score" : null, + "hits" : [ + { + "_index" : "flight_tracks", + "_id" : "N-ng1XQB6yyY-xQxnGSM", + "_score" : null, + "fields" : { + "@timestamp" : [ + "2020-09-28T18:01:41.190Z" + ], + "location" : [ + "40.62806099653244, -82.8814151789993" + ], + "entity_id" : [ + "936" + ] + }, + "sort" : [ + 1601316101190 + ] + } + ] + } + } + }, + { + "key" : "AAL2019", + "doc_count" : 9, + "entityHits" : { + "hits" : { + "total" : { + "value" : 9, + "relation" : "eq" + }, + "max_score" : null, + "hits" : [ + { + "_index" : "flight_tracks", + "_id" : "iOng1XQB6yyY-xQxnGSM", + "_score" : null, + "fields" : { + "@timestamp" : [ + "2020-09-28T18:01:41.191Z" + ], + "location" : [ + "39.006176185794175, -82.22068064846098" + ], + "entity_id" : [ + "AAL2019" + ] + }, + "sort" : [ + 1601316101191 + ] + } + ] + } + } + }, + { + "key" : "AAL2323", + "doc_count" : 9, + "entityHits" : { + "hits" : { + "total" : { + "value" : 9, + "relation" : "eq" + }, + "max_score" : null, + "hits" : [ + { + "_index" : "flight_tracks", + "_id" : "n-ng1XQB6yyY-xQxnGSM", + "_score" : null, + "fields" : { + "@timestamp" : [ + "2020-09-28T18:01:41.191Z" + ], + "location" : [ + "41.6677269525826, -84.71324851736426" + ], + "entity_id" : [ + "AAL2323" + ] + }, + "sort" : [ + 1601316101191 + ] + } + ] + } + } + }, + { + "key" : "ABD5250", + "doc_count" : 9, + "entityHits" : { + "hits" : { + "total" : { + "value" : 9, + "relation" : "eq" + }, + "max_score" : null, + "hits" : [ + { + "_index" : "flight_tracks", + "_id" : "GOng1XQB6yyY-xQxnGWM", + "_score" : null, + "fields" : { + "@timestamp" : [ + "2020-09-28T18:01:41.192Z" + ], + "location" : [ + "39.07997465226799, 6.073727197945118" + ], + "entity_id" : [ + "ABD5250" + ] + }, + "sort" : [ + 1601316101192 + ] + } + ] + } + } + } + ] + } + } + } + } + } +} diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/tests/geo_threshold.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/tests/geo_threshold.test.ts new file mode 100644 index 0000000000000..0aaf30ab2f3fb --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/geo_threshold/tests/geo_threshold.test.ts @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import sampleJsonResponse from './es_sample_response.json'; +import { getMovedEntities, transformResults } from '../geo_threshold'; +import { OTHER_CATEGORY } from '../es_query_builder'; +import { SearchResponse } from 'elasticsearch'; + +describe('geo_threshold', () => { + describe('transformResults', () => { + const dateField = '@timestamp'; + const geoField = 'location'; + it('should correctly transform expected results', async () => { + const transformedResults = transformResults( + (sampleJsonResponse as unknown) as SearchResponse, + dateField, + geoField + ); + expect(transformedResults).toEqual([ + { + dateInShape: '2020-09-28T18:01:41.190Z', + docId: 'N-ng1XQB6yyY-xQxnGSM', + entityName: '936', + location: [-82.8814151789993, 40.62806099653244], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + { + dateInShape: '2020-09-28T18:01:41.191Z', + docId: 'iOng1XQB6yyY-xQxnGSM', + entityName: 'AAL2019', + location: [-82.22068064846098, 39.006176185794175], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + { + dateInShape: '2020-09-28T18:01:41.191Z', + docId: 'n-ng1XQB6yyY-xQxnGSM', + entityName: 'AAL2323', + location: [-84.71324851736426, 41.6677269525826], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + { + dateInShape: '2020-09-28T18:01:41.192Z', + docId: 'GOng1XQB6yyY-xQxnGWM', + entityName: 'ABD5250', + location: [6.073727197945118, 39.07997465226799], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ]); + }); + + it('should return an empty array if no results', async () => { + const transformedResults = transformResults(undefined, dateField, geoField); + expect(transformedResults).toEqual([]); + }); + }); + + describe('getMovedEntities', () => { + const trackingEvent = 'entered'; + it('should return empty array if only movements were within same shapes', async () => { + const currLocationArr = [ + { + dateInShape: '2020-08-28T18:01:41.190Z', + docId: 'N-ng1XQB6yyY-xQxnGSM', + entityName: '936', + location: [-82.8814151789993, 41.62806099653244], + shapeLocationId: 'sameShape1', + }, + { + dateInShape: '2020-08-28T18:01:41.191Z', + docId: 'iOng1XQB6yyY-xQxnGSM', + entityName: 'AAL2019', + location: [-82.22068064846098, 38.006176185794175], + shapeLocationId: 'sameShape2', + }, + ]; + const prevLocationArr = [ + { + dateInShape: '2020-09-28T18:01:41.190Z', + docId: 'N-ng1XQB6yyY-xQxnGSM', + entityName: '936', + location: [-82.8814151789993, 40.62806099653244], + shapeLocationId: 'sameShape1', + }, + { + dateInShape: '2020-09-28T18:01:41.191Z', + docId: 'iOng1XQB6yyY-xQxnGSM', + entityName: 'AAL2019', + location: [-82.22068064846098, 39.006176185794175], + shapeLocationId: 'sameShape2', + }, + ]; + const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, trackingEvent); + expect(movedEntities).toEqual([]); + }); + + it('should return result if entity has moved to different shape', async () => { + const currLocationArr = [ + { + dateInShape: '2020-09-28T18:01:41.190Z', + docId: 'currLocationDoc1', + entityName: '936', + location: [-82.8814151789993, 40.62806099653244], + shapeLocationId: 'newShapeLocation', + }, + { + dateInShape: '2020-09-28T18:01:41.191Z', + docId: 'currLocationDoc2', + entityName: 'AAL2019', + location: [-82.22068064846098, 39.006176185794175], + shapeLocationId: 'thisOneDidntMove', + }, + ]; + const prevLocationArr = [ + { + dateInShape: '2020-09-27T18:01:41.190Z', + docId: 'prevLocationDoc1', + entityName: '936', + location: [-82.8814151789993, 20.62806099653244], + shapeLocationId: 'oldShapeLocation', + }, + { + dateInShape: '2020-09-27T18:01:41.191Z', + docId: 'prevLocationDoc2', + entityName: 'AAL2019', + location: [-82.22068064846098, 39.006176185794175], + shapeLocationId: 'thisOneDidntMove', + }, + ]; + const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, trackingEvent); + expect(movedEntities.length).toEqual(1); + }); + + it('should ignore "entered" results to "other"', async () => { + const currLocationArr = [ + { + dateInShape: '2020-09-28T18:01:41.190Z', + docId: 'N-ng1XQB6yyY-xQxnGSM', + entityName: '936', + location: [-82.8814151789993, 41.62806099653244], + shapeLocationId: OTHER_CATEGORY, + }, + ]; + const prevLocationArr = [ + { + dateInShape: '2020-08-28T18:01:41.190Z', + docId: 'N-ng1XQB6yyY-xQxnGSM', + entityName: '936', + location: [-82.8814151789993, 40.62806099653244], + shapeLocationId: 'oldShapeLocation', + }, + ]; + const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, trackingEvent); + expect(movedEntities).toEqual([]); + }); + + it('should ignore "exited" results from "other"', async () => { + const currLocationArr = [ + { + dateInShape: '2020-09-28T18:01:41.190Z', + docId: 'N-ng1XQB6yyY-xQxnGSM', + entityName: '936', + location: [-82.8814151789993, 41.62806099653244], + shapeLocationId: 'newShapeLocation', + }, + ]; + const prevLocationArr = [ + { + dateInShape: '2020-08-28T18:01:41.190Z', + docId: 'N-ng1XQB6yyY-xQxnGSM', + entityName: '936', + location: [-82.8814151789993, 40.62806099653244], + shapeLocationId: OTHER_CATEGORY, + }, + ]; + const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'exited'); + expect(movedEntities).toEqual([]); + }); + }); +}); diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index.ts index d9232195b0f52..4dd1e8693aca0 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index.ts @@ -6,6 +6,7 @@ import { Service, IRouter, AlertingSetup } from '../types'; import { register as registerIndexThreshold } from './index_threshold'; +import { register as registerGeoThreshold } from './geo_threshold'; interface RegisterBuiltInAlertTypesParams { service: Service; @@ -16,4 +17,5 @@ interface RegisterBuiltInAlertTypesParams { export function registerBuiltInAlertTypes(params: RegisterBuiltInAlertTypesParams) { registerIndexThreshold(params); + registerGeoThreshold(params); } diff --git a/x-pack/plugins/alerting_builtins/server/feature.ts b/x-pack/plugins/alerting_builtins/server/feature.ts index a7c8b940fbf06..d1d4314db1cec 100644 --- a/x-pack/plugins/alerting_builtins/server/feature.ts +++ b/x-pack/plugins/alerting_builtins/server/feature.ts @@ -6,6 +6,7 @@ import { i18n } from '@kbn/i18n'; import { ID as IndexThreshold } from './alert_types/index_threshold/alert_type'; +import { GEO_THRESHOLD_ID as GeoThreshold } from './alert_types/geo_threshold/alert_type'; import { BUILT_IN_ALERTS_FEATURE_ID } from '../common'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; @@ -20,7 +21,7 @@ export const BUILT_IN_ALERTS_FEATURE = { management: { insightsAndAlerting: ['triggersActions'], }, - alerting: [IndexThreshold], + alerting: [IndexThreshold, GeoThreshold], privileges: { all: { app: [], @@ -29,7 +30,7 @@ export const BUILT_IN_ALERTS_FEATURE = { insightsAndAlerting: ['triggersActions'], }, alerting: { - all: [IndexThreshold], + all: [IndexThreshold, GeoThreshold], read: [], }, savedObject: { @@ -47,7 +48,7 @@ export const BUILT_IN_ALERTS_FEATURE = { }, alerting: { all: [], - read: [IndexThreshold], + read: [IndexThreshold, GeoThreshold], }, savedObject: { all: [], diff --git a/x-pack/plugins/alerting_builtins/server/plugin.test.ts b/x-pack/plugins/alerting_builtins/server/plugin.test.ts index 629c02d923071..3e2a919be0f13 100644 --- a/x-pack/plugins/alerting_builtins/server/plugin.test.ts +++ b/x-pack/plugins/alerting_builtins/server/plugin.test.ts @@ -27,11 +27,15 @@ describe('AlertingBuiltins Plugin', () => { const featuresSetup = featuresPluginMock.createSetup(); await plugin.setup(coreSetup, { alerts: alertingSetup, features: featuresSetup }); - expect(alertingSetup.registerType).toHaveBeenCalledTimes(1); + expect(alertingSetup.registerType).toHaveBeenCalledTimes(2); - const args = alertingSetup.registerType.mock.calls[0][0]; - const testedArgs = { id: args.id, name: args.name, actionGroups: args.actionGroups }; - expect(testedArgs).toMatchInlineSnapshot(` + const indexThresholdArgs = alertingSetup.registerType.mock.calls[0][0]; + const testedIndexThresholdArgs = { + id: indexThresholdArgs.id, + name: indexThresholdArgs.name, + actionGroups: indexThresholdArgs.actionGroups, + }; + expect(testedIndexThresholdArgs).toMatchInlineSnapshot(` Object { "actionGroups": Array [ Object { @@ -43,6 +47,26 @@ describe('AlertingBuiltins Plugin', () => { "name": "Index threshold", } `); + + const geoThresholdArgs = alertingSetup.registerType.mock.calls[1][0]; + const testedGeoThresholdArgs = { + id: geoThresholdArgs.id, + name: geoThresholdArgs.name, + actionGroups: geoThresholdArgs.actionGroups, + }; + expect(testedGeoThresholdArgs).toMatchInlineSnapshot(` + Object { + "actionGroups": Array [ + Object { + "id": "tracking threshold met", + "name": "Tracking threshold met", + }, + ], + "id": ".geo-threshold", + "name": "Geo tracking threshold", + } + `); + expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith(BUILT_IN_ALERTS_FEATURE); }); diff --git a/x-pack/plugins/alerts/server/index.ts b/x-pack/plugins/alerts/server/index.ts index 4c192a896c0c3..72d3a65220565 100644 --- a/x-pack/plugins/alerts/server/index.ts +++ b/x-pack/plugins/alerts/server/index.ts @@ -11,6 +11,7 @@ import { AlertingPlugin } from './plugin'; export type AlertsClient = PublicMethodsOf; export { + ActionVariable, AlertType, ActionGroup, AlertingPlugin, diff --git a/x-pack/plugins/apm/common/utils/formatters.test.ts b/x-pack/plugins/apm/common/utils/formatters.test.ts deleted file mode 100644 index ce317c5a6a827..0000000000000 --- a/x-pack/plugins/apm/common/utils/formatters.test.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 { asPercent } from './formatters'; - -describe('formatters', () => { - describe('asPercent', () => { - it('should format as integer when number is above 10', () => { - expect(asPercent(3725, 10000, 'n/a')).toEqual('37%'); - }); - - it('should add a decimal when value is below 10', () => { - expect(asPercent(0.092, 1)).toEqual('9.2%'); - }); - - it('should format when numerator is 0', () => { - expect(asPercent(0, 1, 'n/a')).toEqual('0%'); - }); - - it('should return fallback when denominator is undefined', () => { - expect(asPercent(3725, undefined, 'n/a')).toEqual('n/a'); - }); - - it('should return fallback when denominator is 0 ', () => { - expect(asPercent(3725, 0, 'n/a')).toEqual('n/a'); - }); - - it('should return fallback when numerator or denominator is NaN', () => { - expect(asPercent(3725, NaN, 'n/a')).toEqual('n/a'); - expect(asPercent(NaN, 10000, 'n/a')).toEqual('n/a'); - }); - }); -}); diff --git a/x-pack/plugins/apm/common/utils/formatters.ts b/x-pack/plugins/apm/common/utils/formatters.ts deleted file mode 100644 index f7c609d949adf..0000000000000 --- a/x-pack/plugins/apm/common/utils/formatters.ts +++ /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 numeral from '@elastic/numeral'; - -export function asPercent( - numerator: number, - denominator: number | undefined, - fallbackResult = '' -) { - if (!denominator || isNaN(numerator)) { - return fallbackResult; - } - - const decimal = numerator / denominator; - - // 33.2 => 33% - // 3.32 => 3.3% - // 0 => 0% - if (Math.abs(decimal) >= 0.1 || decimal === 0) { - return numeral(decimal).format('0%'); - } - - return numeral(decimal).format('0.0%'); -} diff --git a/x-pack/plugins/apm/public/utils/formatters/__test__/datetime.test.ts b/x-pack/plugins/apm/common/utils/formatters/datetime.test.ts similarity index 99% rename from x-pack/plugins/apm/public/utils/formatters/__test__/datetime.test.ts rename to x-pack/plugins/apm/common/utils/formatters/datetime.test.ts index 647a27c59aa4c..733fb7bb5eea1 100644 --- a/x-pack/plugins/apm/public/utils/formatters/__test__/datetime.test.ts +++ b/x-pack/plugins/apm/common/utils/formatters/datetime.test.ts @@ -8,7 +8,7 @@ import { asRelativeDateTimeRange, asAbsoluteDateTime, getDateDifference, -} from '../datetime'; +} from './datetime'; describe('date time formatters', () => { beforeAll(() => { diff --git a/x-pack/plugins/apm/public/utils/formatters/datetime.ts b/x-pack/plugins/apm/common/utils/formatters/datetime.ts similarity index 100% rename from x-pack/plugins/apm/public/utils/formatters/datetime.ts rename to x-pack/plugins/apm/common/utils/formatters/datetime.ts diff --git a/x-pack/plugins/apm/public/utils/formatters/__test__/duration.test.ts b/x-pack/plugins/apm/common/utils/formatters/duration.test.ts similarity index 99% rename from x-pack/plugins/apm/public/utils/formatters/__test__/duration.test.ts rename to x-pack/plugins/apm/common/utils/formatters/duration.test.ts index ca8a4919dd216..a39d9b47f41c2 100644 --- a/x-pack/plugins/apm/public/utils/formatters/__test__/duration.test.ts +++ b/x-pack/plugins/apm/common/utils/formatters/duration.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 { asDuration, toMicroseconds, asMillisecondDuration } from '../duration'; +import { asDuration, toMicroseconds, asMillisecondDuration } from './duration'; describe('duration formatters', () => { describe('asDuration', () => { diff --git a/x-pack/plugins/apm/public/utils/formatters/duration.ts b/x-pack/plugins/apm/common/utils/formatters/duration.ts similarity index 94% rename from x-pack/plugins/apm/public/utils/formatters/duration.ts rename to x-pack/plugins/apm/common/utils/formatters/duration.ts index 8381b0afb5f07..c0a99e0152fa7 100644 --- a/x-pack/plugins/apm/public/utils/formatters/duration.ts +++ b/x-pack/plugins/apm/common/utils/formatters/duration.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment'; import { memoize } from 'lodash'; import { NOT_AVAILABLE_LABEL } from '../../../common/i18n'; -import { asDecimal, asInteger } from './formatters'; +import { asDecimalOrInteger, asInteger } from './formatters'; import { TimeUnit } from './datetime'; import { Maybe } from '../../../typings/common'; @@ -31,14 +31,6 @@ export type TimeFormatter = ( type TimeFormatterBuilder = (max: number) => TimeFormatter; -function asDecimalOrInteger(value: number) { - // exact 0 or above 10 should not have decimal - if (value === 0 || value >= 10) { - return asInteger(value); - } - return asDecimal(value); -} - function getUnitLabelAndConvertedValue( unitKey: DurationTimeUnit, value: number diff --git a/x-pack/plugins/apm/common/utils/formatters/formatters.test.ts b/x-pack/plugins/apm/common/utils/formatters/formatters.test.ts new file mode 100644 index 0000000000000..4d6c348fcbee3 --- /dev/null +++ b/x-pack/plugins/apm/common/utils/formatters/formatters.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { asPercent, asDecimalOrInteger } from './formatters'; + +describe('formatters', () => { + describe('asPercent', () => { + it('formats as integer when number is above 10', () => { + expect(asPercent(3725, 10000, 'n/a')).toEqual('37%'); + }); + + it('adds a decimal when value is below 10', () => { + expect(asPercent(0.092, 1)).toEqual('9.2%'); + }); + + it('formats when numerator is 0', () => { + expect(asPercent(0, 1, 'n/a')).toEqual('0%'); + }); + + it('returns fallback when denominator is undefined', () => { + expect(asPercent(3725, undefined, 'n/a')).toEqual('n/a'); + }); + + it('returns fallback when denominator is 0 ', () => { + expect(asPercent(3725, 0, 'n/a')).toEqual('n/a'); + }); + + it('returns fallback when numerator or denominator is NaN', () => { + expect(asPercent(3725, NaN, 'n/a')).toEqual('n/a'); + expect(asPercent(NaN, 10000, 'n/a')).toEqual('n/a'); + }); + }); + + describe('asDecimalOrInteger', () => { + it('formats as integer when number equals to 0 ', () => { + expect(asDecimalOrInteger(0)).toEqual('0'); + }); + it('formats as integer when number is above or equals 10 ', () => { + expect(asDecimalOrInteger(10.123)).toEqual('10'); + expect(asDecimalOrInteger(15.123)).toEqual('15'); + }); + it('formats as decimal when number is below 10 ', () => { + expect(asDecimalOrInteger(0.25435632645)).toEqual('0.3'); + expect(asDecimalOrInteger(1)).toEqual('1.0'); + expect(asDecimalOrInteger(3.374329704990765)).toEqual('3.4'); + expect(asDecimalOrInteger(5)).toEqual('5.0'); + expect(asDecimalOrInteger(9)).toEqual('9.0'); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/utils/formatters/formatters.ts b/x-pack/plugins/apm/common/utils/formatters/formatters.ts similarity index 55% rename from x-pack/plugins/apm/public/utils/formatters/formatters.ts rename to x-pack/plugins/apm/common/utils/formatters/formatters.ts index 6249ce53b6779..d84bf86d0de2f 100644 --- a/x-pack/plugins/apm/public/utils/formatters/formatters.ts +++ b/x-pack/plugins/apm/common/utils/formatters/formatters.ts @@ -23,3 +23,32 @@ export function tpmUnit(type?: string) { defaultMessage: 'tpm', }); } + +export function asPercent( + numerator: number, + denominator: number | undefined, + fallbackResult = '' +) { + if (!denominator || isNaN(numerator)) { + return fallbackResult; + } + + const decimal = numerator / denominator; + + // 33.2 => 33% + // 3.32 => 3.3% + // 0 => 0% + if (Math.abs(decimal) >= 0.1 || decimal === 0) { + return numeral(decimal).format('0%'); + } + + return numeral(decimal).format('0.0%'); +} + +export function asDecimalOrInteger(value: number) { + // exact 0 or above 10 should not have decimal + if (value === 0 || value >= 10) { + return asInteger(value); + } + return asDecimal(value); +} diff --git a/x-pack/plugins/apm/public/utils/formatters/index.ts b/x-pack/plugins/apm/common/utils/formatters/index.ts similarity index 100% rename from x-pack/plugins/apm/public/utils/formatters/index.ts rename to x-pack/plugins/apm/common/utils/formatters/index.ts diff --git a/x-pack/plugins/apm/public/utils/formatters/__test__/size.test.ts b/x-pack/plugins/apm/common/utils/formatters/size.test.ts similarity index 97% rename from x-pack/plugins/apm/public/utils/formatters/__test__/size.test.ts rename to x-pack/plugins/apm/common/utils/formatters/size.test.ts index 07d3d0c1eb08f..a394874fe6d87 100644 --- a/x-pack/plugins/apm/public/utils/formatters/__test__/size.test.ts +++ b/x-pack/plugins/apm/common/utils/formatters/size.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 { getFixedByteFormatter, asDynamicBytes } from '../size'; +import { getFixedByteFormatter, asDynamicBytes } from './size'; describe('size formatters', () => { describe('byte formatting', () => { diff --git a/x-pack/plugins/apm/public/utils/formatters/size.ts b/x-pack/plugins/apm/common/utils/formatters/size.ts similarity index 100% rename from x-pack/plugins/apm/public/utils/formatters/size.ts rename to x-pack/plugins/apm/common/utils/formatters/size.ts diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index bdef0f9786a3f..f134b4eebddf8 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -7,7 +7,7 @@ "apmOss", "data", "licensing", - "triggers_actions_ui", + "triggersActionsUi", "embeddable" ], "optionalPlugins": [ diff --git a/x-pack/plugins/apm/public/application/application.test.tsx b/x-pack/plugins/apm/public/application/application.test.tsx index 0566ff19017f4..97700b9bc96b7 100644 --- a/x-pack/plugins/apm/public/application/application.test.tsx +++ b/x-pack/plugins/apm/public/application/application.test.tsx @@ -40,8 +40,15 @@ describe('renderApp', () => { const { core, config } = mockApmPluginContextValue; const plugins = { licensing: { license$: new Observable() }, - triggers_actions_ui: { actionTypeRegistry: {}, alertTypeRegistry: {} }, + triggersActionsUi: { actionTypeRegistry: {}, alertTypeRegistry: {} }, usageCollection: { reportUiStats: () => {} }, + data: { + query: { + timefilter: { + timefilter: { setTime: () => {}, getTime: () => ({}) }, + }, + }, + }, }; const params = { element: document.createElement('div'), diff --git a/x-pack/plugins/apm/public/application/index.tsx b/x-pack/plugins/apm/public/application/index.tsx index 24505951c9d71..4e3217ce17ed1 100644 --- a/x-pack/plugins/apm/public/application/index.tsx +++ b/x-pack/plugins/apm/public/application/index.tsx @@ -90,8 +90,8 @@ export function ApmAppRoot({ docLinks: core.docLinks, capabilities: core.application.capabilities, toastNotifications: core.notifications.toasts, - actionTypeRegistry: plugins.triggers_actions_ui.actionTypeRegistry, - alertTypeRegistry: plugins.triggers_actions_ui.alertTypeRegistry, + actionTypeRegistry: plugins.triggersActionsUi.actionTypeRegistry, + alertTypeRegistry: plugins.triggersActionsUi.alertTypeRegistry, }} > diff --git a/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.stories.tsx b/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.stories.tsx index c30cef7210a43..1a565ab8708bc 100644 --- a/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.stories.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { storiesOf } from '@storybook/react'; import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; import { ErrorCountAlertTrigger } from '.'; import { ApmPluginContextValue } from '../../../context/ApmPluginContext'; import { @@ -13,32 +13,35 @@ import { MockApmPluginContextWrapper, } from '../../../context/ApmPluginContext/MockApmPluginContext'; -storiesOf('app/ErrorCountAlertTrigger', module).add( - 'example', - () => { - const params = { - threshold: 2, - window: '5m', - }; - - return ( +export default { + title: 'app/ErrorCountAlertTrigger', + component: ErrorCountAlertTrigger, + decorators: [ + (Story: React.ComponentClass) => ( -
    - undefined} - setAlertProperty={() => undefined} - /> -
    + +
    + +
    +
    - ); - }, - { - info: { - propTablesExclude: [ErrorCountAlertTrigger, MockApmPluginContextWrapper], - source: false, - }, - } -); + ), + ], +}; + +export function Example() { + const params = { + threshold: 2, + window: '5m', + }; + + return ( + undefined} + setAlertProperty={() => undefined} + /> + ); +} diff --git a/x-pack/plugins/apm/public/components/alerting/fields.test.tsx b/x-pack/plugins/apm/public/components/alerting/fields.test.tsx index 7ffb46d3dda49..5af05cedf7fa3 100644 --- a/x-pack/plugins/apm/public/components/alerting/fields.test.tsx +++ b/x-pack/plugins/apm/public/components/alerting/fields.test.tsx @@ -9,7 +9,7 @@ import { act, fireEvent, render } from '@testing-library/react'; import { expectTextsInDocument } from '../../utils/testHelpers'; describe('alerting fields', () => { - describe('Service Fiels', () => { + describe('Service Field', () => { it('renders with value', () => { const component = render(); expectTextsInDocument(component, ['foo']); diff --git a/x-pack/plugins/apm/public/components/alerting/fields.tsx b/x-pack/plugins/apm/public/components/alerting/fields.tsx index aac64649546cc..858604d2baa2a 100644 --- a/x-pack/plugins/apm/public/components/alerting/fields.tsx +++ b/x-pack/plugins/apm/public/components/alerting/fields.tsx @@ -43,7 +43,7 @@ export function EnvironmentField({ })} > List', () => { - {/* @ts-expect-error invalid json props */} diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap index 5183432b4ae0f..f45b4913243ee 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap @@ -454,30 +454,38 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` Object { "culprit": "elasticapm.contrib.django.client.capture", "groupId": "a0ce2c8978ef92cdf2ff163ae28576ee", + "handled": true, "latestOccurrenceAt": "2018-01-10T10:06:37.561Z", "message": "About to blow up!", "occurrenceCount": 75, + "type": "AssertionError", }, Object { "culprit": "opbeans.views.oopsie", "groupId": "f3ac95493913cc7a3cfec30a19d2120a", + "handled": true, "latestOccurrenceAt": "2018-01-10T10:06:37.630Z", "message": "AssertionError: ", "occurrenceCount": 75, + "type": "AssertionError", }, Object { "culprit": "opbeans.tasks.update_stats", "groupId": "e90863d04b7a692435305f09bbe8c840", + "handled": true, "latestOccurrenceAt": "2018-01-10T10:06:36.859Z", "message": "AssertionError: Bad luck!", "occurrenceCount": 24, + "type": "AssertionError", }, Object { "culprit": "opbeans.views.customer", "groupId": "8673d8bf7a032e387c101bafbab0d2bc", + "handled": true, "latestOccurrenceAt": "2018-01-10T10:06:13.211Z", "message": "Customer with ID 8517 not found", "occurrenceCount": 15, + "type": "AssertionError", }, ] } @@ -818,7 +826,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` query={ Object { "end": "2018-01-10T10:06:41.050Z", - "kuery": "error.exception.type:undefined", + "kuery": "error.exception.type:AssertionError", "page": 0, "serviceName": "opbeans-python", "start": "2018-01-10T09:51:41.050Z", @@ -826,16 +834,21 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` } } serviceName="opbeans-python" + title="AssertionError" >
    + title="AssertionError" + > + AssertionError + @@ -1052,7 +1065,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` query={ Object { "end": "2018-01-10T10:06:41.050Z", - "kuery": "error.exception.type:undefined", + "kuery": "error.exception.type:AssertionError", "page": 0, "serviceName": "opbeans-python", "start": "2018-01-10T09:51:41.050Z", @@ -1060,16 +1073,21 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` } } serviceName="opbeans-python" + title="AssertionError" > + title="AssertionError" + > + AssertionError + @@ -1286,7 +1304,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` query={ Object { "end": "2018-01-10T10:06:41.050Z", - "kuery": "error.exception.type:undefined", + "kuery": "error.exception.type:AssertionError", "page": 0, "serviceName": "opbeans-python", "start": "2018-01-10T09:51:41.050Z", @@ -1294,16 +1312,21 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` } } serviceName="opbeans-python" + title="AssertionError" > + title="AssertionError" + > + AssertionError + @@ -1520,7 +1543,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` query={ Object { "end": "2018-01-10T10:06:41.050Z", - "kuery": "error.exception.type:undefined", + "kuery": "error.exception.type:AssertionError", "page": 0, "serviceName": "opbeans-python", "start": "2018-01-10T09:51:41.050Z", @@ -1528,16 +1551,21 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` } } serviceName="opbeans-python" + title="AssertionError" > + title="AssertionError" + > + AssertionError + diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json index 431a6c71b103b..ad49cd048aee3 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json @@ -2,31 +2,39 @@ "items": [ { "message": "About to blow up!", + "type": "AssertionError", "occurrenceCount": 75, "culprit": "elasticapm.contrib.django.client.capture", "groupId": "a0ce2c8978ef92cdf2ff163ae28576ee", - "latestOccurrenceAt": "2018-01-10T10:06:37.561Z" + "latestOccurrenceAt": "2018-01-10T10:06:37.561Z", + "handled": true }, { "message": "AssertionError: ", + "type": "AssertionError", "occurrenceCount": 75, "culprit": "opbeans.views.oopsie", "groupId": "f3ac95493913cc7a3cfec30a19d2120a", - "latestOccurrenceAt": "2018-01-10T10:06:37.630Z" + "latestOccurrenceAt": "2018-01-10T10:06:37.630Z", + "handled": true }, { "message": "AssertionError: Bad luck!", + "type": "AssertionError", "occurrenceCount": 24, "culprit": "opbeans.tasks.update_stats", "groupId": "e90863d04b7a692435305f09bbe8c840", - "latestOccurrenceAt": "2018-01-10T10:06:36.859Z" + "latestOccurrenceAt": "2018-01-10T10:06:36.859Z", + "handled": true }, { "message": "Customer with ID 8517 not found", + "type": "AssertionError", "occurrenceCount": 15, "culprit": "opbeans.views.customer", "groupId": "8673d8bf7a032e387c101bafbab0d2bc", - "latestOccurrenceAt": "2018-01-10T10:06:13.211Z" + "latestOccurrenceAt": "2018-01-10T10:06:13.211Z", + "handled": true } ], "location": { diff --git a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap index 00be0b37a0e82..0a22604837b97 100644 --- a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap @@ -53,6 +53,16 @@ exports[`Home component should render services 1`] = ` }, }, "plugins": Object { + "data": Object { + "query": Object { + "timefilter": Object { + "timefilter": Object { + "getTime": [Function], + "setTime": [Function], + }, + }, + }, + }, "ml": Object { "urlGenerator": MlUrlGenerator { "createUrl": [Function], @@ -126,6 +136,16 @@ exports[`Home component should render traces 1`] = ` }, }, "plugins": Object { + "data": Object { + "query": Object { + "timefilter": Object { + "timefilter": Object { + "getTime": [Function], + "setTime": [Function], + }, + }, + }, + }, "ml": Object { "urlGenerator": MlUrlGenerator { "createUrl": [Function], diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx index 4eb24f8c80b9a..242eb721639a2 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx @@ -93,10 +93,7 @@ export function PageLoadDistChart({ : EUI_CHARTS_THEME_LIGHT; return ( - + {(!loading || data) && ( + {(!loading || data) && ( + {storyFn()}) - .add( - 'Basic', - () => { - return ( - - ); - }, - { - info: { - propTables: false, - source: false, - }, - } - ) - .add( - '50% Good', - () => { - return ( - - ); - }, - { - info: { - propTables: false, - source: false, - }, - } - ) - .add( - '100% Bad', - () => { - return ( - - ); - }, - { - info: { - propTables: false, - source: false, - }, - } - ) - .add( - '100% Average', - () => { - return ( - - ); - }, - { - info: { - propTables: false, - source: false, - }, - } - ); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/index.tsx deleted file mode 100644 index fcc7b214943ff..0000000000000 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import * as React from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { CLS_LABEL, FID_LABEL, LCP_LABEL } from './translations'; -import { CoreVitalItem } from './CoreVitalItem'; -import { UXMetrics } from '../UXMetrics'; -import { formatToSec } from '../UXMetrics/KeyUXMetrics'; - -const CoreVitalsThresholds = { - LCP: { good: '2.5s', bad: '4.0s' }, - FID: { good: '100ms', bad: '300ms' }, - CLS: { good: '0.1', bad: '0.25' }, -}; - -interface Props { - data?: UXMetrics | null; - loading: boolean; -} - -export function CoreVitals({ data, loading }: Props) { - const { lcp, lcpRanks, fid, fidRanks, cls, clsRanks } = data || {}; - - return ( - - - - - - - - - - - - ); -} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/translations.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/translations.ts deleted file mode 100644 index cae1a43733713..0000000000000 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/translations.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; - -export const LCP_LABEL = i18n.translate('xpack.apm.rum.coreVitals.lcp', { - defaultMessage: 'Largest contentful paint', -}); - -export const FID_LABEL = i18n.translate('xpack.apm.rum.coreVitals.fip', { - defaultMessage: 'First input delay', -}); - -export const CLS_LABEL = i18n.translate('xpack.apm.rum.coreVitals.cls', { - defaultMessage: 'Cumulative layout shift', -}); - -export const FCP_LABEL = i18n.translate('xpack.apm.rum.coreVitals.fcp', { - defaultMessage: 'First contentful paint', -}); - -export const TBT_LABEL = i18n.translate('xpack.apm.rum.coreVitals.tbt', { - defaultMessage: 'Total blocking time', -}); - -export const NO_OF_LONG_TASK = i18n.translate( - 'xpack.apm.rum.uxMetrics.noOfLongTasks', - { - defaultMessage: 'No. of long tasks', - } -); - -export const LONGEST_LONG_TASK = i18n.translate( - 'xpack.apm.rum.uxMetrics.longestLongTasks', - { - defaultMessage: 'Longest long task duration', - } -); - -export const SUM_LONG_TASKS = i18n.translate( - 'xpack.apm.rum.uxMetrics.sumLongTasks', - { - defaultMessage: 'Total long tasks duration', - } -); - -export const CV_POOR_LABEL = i18n.translate('xpack.apm.rum.coreVitals.poor', { - defaultMessage: 'a poor', -}); - -export const CV_GOOD_LABEL = i18n.translate('xpack.apm.rum.coreVitals.good', { - defaultMessage: 'a good', -}); - -export const CV_AVERAGE_LABEL = i18n.translate( - 'xpack.apm.rum.coreVitals.average', - { - defaultMessage: 'an average', - } -); - -export const LEGEND_POOR_LABEL = i18n.translate( - 'xpack.apm.rum.coreVitals.legends.poor', - { - defaultMessage: 'Poor', - } -); - -export const LEGEND_GOOD_LABEL = i18n.translate( - 'xpack.apm.rum.coreVitals.legends.good', - { - defaultMessage: 'Good', - } -); - -export const LEGEND_NEEDS_IMPROVEMENT_LABEL = i18n.translate( - 'xpack.apm.rum.coreVitals.legends.needsImprovement', - { - defaultMessage: 'Needs improvement', - } -); - -export const MORE_LABEL = i18n.translate('xpack.apm.rum.coreVitals.more', { - defaultMessage: 'more', -}); - -export const LESS_LABEL = i18n.translate('xpack.apm.rum.coreVitals.less', { - defaultMessage: 'less', -}); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx index 45a40712f90fb..88d14a0213a96 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx @@ -13,7 +13,6 @@ import { BreakdownFilter } from '../Breakdowns/BreakdownFilter'; import { PageLoadDistChart } from '../Charts/PageLoadDistChart'; import { BreakdownItem } from '../../../../../typings/ui_filters'; import { ResetPercentileZoom } from './ResetPercentileZoom'; -import { FULL_HEIGHT } from '../RumDashboard'; export interface PercentileRange { min?: number | null; @@ -72,7 +71,7 @@ export function PageLoadDistribution() { }; return ( -
    +
    diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx index 7492096b93898..621098b6028cb 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx @@ -12,7 +12,6 @@ import { I18LABELS } from '../translations'; import { BreakdownFilter } from '../Breakdowns/BreakdownFilter'; import { PageViewsChart } from '../Charts/PageViewsChart'; import { BreakdownItem } from '../../../../../typings/ui_filters'; -import { FULL_HEIGHT } from '../RumDashboard'; export function PageViewsTrend() { const { urlParams, uiFilters } = useUrlParams(); @@ -49,7 +48,7 @@ export function PageViewsTrend() { ); return ( -
    +
    diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/PageLoadAndViews.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/PageLoadAndViews.tsx index cdc52c98de971..0475b9c62b42a 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/PageLoadAndViews.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/PageLoadAndViews.tsx @@ -5,35 +5,23 @@ */ import React from 'react'; -import { EuiPanel, EuiResizableContainer } from '@elastic/eui'; -import { FULL_HEIGHT } from '../RumDashboard'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; import { PageLoadDistribution } from '../PageLoadDistribution'; import { PageViewsTrend } from '../PageViewsTrend'; -import { useBreakPoints } from '../hooks/useBreakPoints'; export function PageLoadAndViews() { - const { isLarge } = useBreakPoints(); - return ( - - {(EuiResizablePanel, EuiResizableButton) => ( - <> - - - - - - - - - - - - - )} - + + + + + + + + + + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/VisitorBreakdowns.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/VisitorBreakdowns.tsx index 87ffacbf56f96..400fbd9991621 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/VisitorBreakdowns.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/VisitorBreakdowns.tsx @@ -5,35 +5,23 @@ */ import React from 'react'; -import { EuiPanel, EuiResizableContainer } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; import { VisitorBreakdown } from '../VisitorBreakdown'; import { VisitorBreakdownMap } from '../VisitorBreakdownMap'; -import { FULL_HEIGHT } from '../RumDashboard'; -import { useBreakPoints } from '../hooks/useBreakPoints'; export function VisitorBreakdownsPanel() { - const { isLarge } = useBreakPoints(); - return ( - - {(EuiResizablePanel, EuiResizableButton) => ( - <> - - - - - - - - - - - - - )} - + + + + + + + + + + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx index 0004599b1821b..9f086f41881c4 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx @@ -10,7 +10,6 @@ import { EuiTitle, EuiSpacer, EuiPanel, - EuiResizableContainer, } from '@elastic/eui'; import React from 'react'; import { ClientMetrics } from './ClientMetrics'; @@ -21,10 +20,8 @@ import { PageLoadAndViews } from './Panels/PageLoadAndViews'; import { VisitorBreakdownsPanel } from './Panels/VisitorBreakdowns'; import { useBreakPoints } from './hooks/useBreakPoints'; -export const FULL_HEIGHT = { height: '100%' }; - export function RumDashboard() { - const { isLarge, isSmall } = useBreakPoints(); + const { isSmall } = useBreakPoints(); return ( @@ -45,22 +42,10 @@ export function RumDashboard() { - - {(EuiResizablePanel, EuiResizableButton) => ( - <> - - - - - - - - - )} - + + + + diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx index 5b0e9709d4fa3..116266541e282 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx @@ -7,16 +7,16 @@ import React from 'react'; import { EuiFlexItem, EuiStat, EuiFlexGroup } from '@elastic/eui'; import numeral from '@elastic/numeral'; -import { UXMetrics } from './index'; import { FCP_LABEL, LONGEST_LONG_TASK, NO_OF_LONG_TASK, SUM_LONG_TASKS, TBT_LABEL, -} from '../CoreVitals/translations'; +} from './translations'; import { useFetcher } from '../../../../hooks/useFetcher'; import { useUxQuery } from '../hooks/useUxQuery'; +import { UXMetrics } from '../../../../../../observability/public'; export function formatToSec( value?: number | string, diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/index.tsx index f43be5beece88..da3e8af6ba048 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/index.tsx @@ -4,36 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React from 'react'; import { - EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, - EuiLink, EuiPanel, - EuiPopover, EuiSpacer, EuiTitle, - EuiText, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; import { I18LABELS } from '../translations'; -import { CoreVitals } from '../CoreVitals'; import { KeyUXMetrics } from './KeyUXMetrics'; import { useFetcher } from '../../../../hooks/useFetcher'; import { useUxQuery } from '../hooks/useUxQuery'; - -export interface UXMetrics { - cls: string; - fid: number; - lcp: number; - tbt: number; - fcp: number; - lcpRanks: number[]; - fidRanks: number[]; - clsRanks: number[]; -} +import { CoreVitals } from '../../../../../../observability/public'; export function UXMetrics() { const uxQuery = useUxQuery(); @@ -53,10 +37,6 @@ export function UXMetrics() { [uxQuery] ); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - - const closePopover = () => setIsPopoverOpen(false); - return ( @@ -72,39 +52,6 @@ export function UXMetrics() { - -

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

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

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

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

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

    +
    +
    + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/date_picker.test.tsx similarity index 52% rename from x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx rename to x-pack/plugins/apm/public/components/shared/DatePicker/date_picker.test.tsx index 9de70d50b25e1..520cc2f423ddd 100644 --- a/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/DatePicker/date_picker.test.tsx @@ -5,57 +5,78 @@ */ import { EuiSuperDatePicker } from '@elastic/eui'; -import { wait } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; import { mount } from 'enzyme'; import { createMemoryHistory } from 'history'; import React, { ReactNode } from 'react'; import { Router } from 'react-router-dom'; -import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; +import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; import { UrlParamsContext, useUiFilters, -} from '../../../../context/UrlParamsContext'; -import { IUrlParams } from '../../../../context/UrlParamsContext/types'; -import { DatePicker } from '../index'; +} from '../../../context/UrlParamsContext'; +import { IUrlParams } from '../../../context/UrlParamsContext/types'; +import { DatePicker } from './'; const history = createMemoryHistory(); -const mockHistoryPush = jest.spyOn(history, 'push'); -const mockHistoryReplace = jest.spyOn(history, 'replace'); + const mockRefreshTimeRange = jest.fn(); function MockUrlParamsProvider({ - params = {}, + urlParams = {}, children, }: { children: ReactNode; - params?: IUrlParams; + urlParams?: IUrlParams; }) { return ( ); } -function mountDatePicker(params?: IUrlParams) { - return mount( - +function mountDatePicker(urlParams?: IUrlParams) { + const setTimeSpy = jest.fn(); + const getTimeSpy = jest.fn().mockReturnValue({}); + const wrapper = mount( + - + ); + + return { wrapper, setTimeSpy, getTimeSpy }; } describe('DatePicker', () => { + let mockHistoryPush: jest.SpyInstance; + let mockHistoryReplace: jest.SpyInstance; beforeAll(() => { jest.spyOn(console, 'error').mockImplementation(() => null); + mockHistoryPush = jest.spyOn(history, 'push'); + mockHistoryReplace = jest.spyOn(history, 'replace'); }); afterAll(() => { @@ -76,16 +97,11 @@ describe('DatePicker', () => { ); }); - it('adds missing default value', () => { - mountDatePicker({ - rangeTo: 'now', - refreshInterval: 5000, - }); + it('adds missing `rangeFrom` to url', () => { + mountDatePicker({ rangeTo: 'now', refreshInterval: 5000 }); expect(mockHistoryReplace).toHaveBeenCalledTimes(1); expect(mockHistoryReplace).toHaveBeenCalledWith( - expect.objectContaining({ - search: 'rangeFrom=now-15m&rangeTo=now&refreshInterval=5000', - }) + expect.objectContaining({ search: 'rangeFrom=now-15m&rangeTo=now' }) ); }); @@ -100,9 +116,9 @@ describe('DatePicker', () => { }); it('updates the URL when the date range changes', () => { - const datePicker = mountDatePicker(); + const { wrapper } = mountDatePicker(); expect(mockHistoryReplace).toHaveBeenCalledTimes(1); - datePicker.find(EuiSuperDatePicker).props().onTimeChange({ + wrapper.find(EuiSuperDatePicker).props().onTimeChange({ start: 'updated-start', end: 'updated-end', isInvalid: false, @@ -118,13 +134,13 @@ describe('DatePicker', () => { it('enables auto-refresh when refreshPaused is false', async () => { jest.useFakeTimers(); - const wrapper = mountDatePicker({ + const { wrapper } = mountDatePicker({ refreshPaused: false, refreshInterval: 1000, }); expect(mockRefreshTimeRange).not.toHaveBeenCalled(); jest.advanceTimersByTime(1000); - await wait(); + await waitFor(() => {}); expect(mockRefreshTimeRange).toHaveBeenCalled(); wrapper.unmount(); }); @@ -134,7 +150,49 @@ describe('DatePicker', () => { mountDatePicker({ refreshPaused: true, refreshInterval: 1000 }); expect(mockRefreshTimeRange).not.toHaveBeenCalled(); jest.advanceTimersByTime(1000); - await wait(); + await waitFor(() => {}); expect(mockRefreshTimeRange).not.toHaveBeenCalled(); }); + + describe('if both `rangeTo` and `rangeFrom` is set', () => { + it('calls setTime ', async () => { + const { setTimeSpy } = mountDatePicker({ + rangeTo: 'now-20m', + rangeFrom: 'now-22m', + }); + expect(setTimeSpy).toHaveBeenCalledWith({ + to: 'now-20m', + from: 'now-22m', + }); + }); + + it('does not update the url', () => { + expect(mockHistoryReplace).toHaveBeenCalledTimes(0); + }); + }); + + describe('if `rangeFrom` is missing from the urlParams', () => { + let setTimeSpy: jest.Mock; + beforeEach(() => { + const res = mountDatePicker({ rangeTo: 'now-5m' }); + setTimeSpy = res.setTimeSpy; + }); + + it('does not call setTime', async () => { + expect(setTimeSpy).toHaveBeenCalledTimes(0); + }); + + it('updates the url with the default `rangeFrom` ', async () => { + expect(mockHistoryReplace).toHaveBeenCalledTimes(1); + expect(mockHistoryReplace.mock.calls[0][0].search).toContain( + 'rangeFrom=now-15m' + ); + }); + + it('preserves `rangeTo`', () => { + expect(mockHistoryReplace.mock.calls[0][0].search).toContain( + 'rangeTo=now-5m' + ); + }); + }); }); diff --git a/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx index b4d716f89169e..f35cc06748911 100644 --- a/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx @@ -5,8 +5,7 @@ */ import { EuiSuperDatePicker } from '@elastic/eui'; -import { isEmpty, isEqual, pickBy } from 'lodash'; -import React from 'react'; +import React, { useEffect } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { UI_SETTINGS } from '../../../../../../../src/plugins/data/common'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; @@ -15,14 +14,10 @@ import { clearCache } from '../../../services/rest/callApi'; import { fromQuery, toQuery } from '../Links/url_helpers'; import { TimePickerQuickRange, TimePickerTimeDefaults } from './typings'; -function removeUndefinedAndEmptyProps(obj: T): Partial { - return pickBy(obj, (value) => value !== undefined && !isEmpty(String(value))); -} - export function DatePicker() { const history = useHistory(); const location = useLocation(); - const { core } = useApmPluginContext(); + const { core, plugins } = useApmPluginContext(); const timePickerQuickRanges = core.uiSettings.get( UI_SETTINGS.TIMEPICKER_QUICK_RANGES @@ -32,11 +27,6 @@ export function DatePicker() { UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS ); - const DEFAULT_VALUES = { - rangeFrom: timePickerTimeDefaults.from, - rangeTo: timePickerTimeDefaults.to, - }; - const commonlyUsedRanges = timePickerQuickRanges.map( ({ from, to, display }) => ({ start: from, @@ -76,35 +66,48 @@ export function DatePicker() { updateUrl({ rangeFrom: start, rangeTo: end }); } - const { rangeFrom, rangeTo, refreshPaused, refreshInterval } = urlParams; - const timePickerURLParams = removeUndefinedAndEmptyProps({ - rangeFrom, - rangeTo, - refreshPaused, - refreshInterval, - }); + useEffect(() => { + // set time if both to and from are given in the url + if (urlParams.rangeFrom && urlParams.rangeTo) { + plugins.data.query.timefilter.timefilter.setTime({ + from: urlParams.rangeFrom, + to: urlParams.rangeTo, + }); + return; + } + + // read time from state and update the url + const timePickerSharedState = plugins.data.query.timefilter.timefilter.getTime(); - const nextParams = { - ...DEFAULT_VALUES, - ...timePickerURLParams, - }; - if (!isEqual(nextParams, timePickerURLParams)) { - // When the default parameters are not availbale in the url, replace it adding the necessary parameters. history.replace({ ...location, search: fromQuery({ ...toQuery(location.search), - ...nextParams, + rangeFrom: + urlParams.rangeFrom ?? + timePickerSharedState.from ?? + timePickerTimeDefaults.from, + rangeTo: + urlParams.rangeTo ?? + timePickerSharedState.to ?? + timePickerTimeDefaults.to, }), }); - } + }, [ + urlParams.rangeFrom, + urlParams.rangeTo, + plugins, + history, + location, + timePickerTimeDefaults, + ]); return ( { clearCache(); diff --git a/x-pack/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx b/x-pack/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx index 45fa3dd382266..1819e71a49753 100644 --- a/x-pack/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx @@ -4,31 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ -import { storiesOf } from '@storybook/react'; -import React from 'react'; +import React, { ComponentType } from 'react'; +import { LicensePrompt } from '.'; import { ApmPluginContext, ApmPluginContextValue, } from '../../../context/ApmPluginContext'; -import { LicensePrompt } from '.'; -storiesOf('app/LicensePrompt', module).add( - 'example', - () => { - const contextMock = ({ - core: { http: { basePath: { prepend: () => {} } } }, - } as unknown) as ApmPluginContextValue; +const contextMock = ({ + core: { http: { basePath: { prepend: () => {} } } }, +} as unknown) as ApmPluginContextValue; - return ( +export default { + title: 'app/LicensePrompt', + component: LicensePrompt, + decorators: [ + (Story: ComponentType) => ( - + {' '} - ); - }, - { - info: { - propTablesExclude: [ApmPluginContext.Provider, LicensePrompt], - source: false, - }, - } -); + ), + ], +}; + +export function Example() { + return ( + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverTransactionButton.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/discover_transaction_button.test.tsx.snap similarity index 100% rename from x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverTransactionButton.test.tsx.snap rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/discover_transaction_button.test.tsx.snap diff --git a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionButton.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/discover_transaction_button.test.tsx similarity index 78% rename from x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionButton.test.tsx rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/discover_transaction_button.test.tsx index 17dca4796ec74..4a68a5c0b4904 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionButton.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/discover_transaction_button.test.tsx @@ -11,12 +11,11 @@ import { DiscoverTransactionLink, getDiscoverQuery, } from '../DiscoverTransactionLink'; -import mockTransaction from './mockTransaction.json'; +import mockTransaction from './mock_transaction.json'; describe('DiscoverTransactionLink component', () => { it('should render with data', () => { - // @ts-expect-error invalid json mock - const transaction: Transaction = mockTransaction; + const transaction = mockTransaction as Transaction; expect( shallow() @@ -26,8 +25,7 @@ describe('DiscoverTransactionLink component', () => { describe('getDiscoverQuery', () => { it('should return the correct query params object', () => { - // @ts-expect-error invalid json mock - const transaction: Transaction = mockTransaction; + const transaction = mockTransaction as Transaction; const result = getDiscoverQuery(transaction); expect(result).toMatchSnapshot(); }); diff --git a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/mockTransaction.json b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/mock_transaction.json similarity index 98% rename from x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/mockTransaction.json rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/mock_transaction.json index 4d038cd7e8397..6c08eedf50b06 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/mockTransaction.json +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/mock_transaction.json @@ -1,6 +1,7 @@ { "agent": { "hostname": "227453131a17", + "name": "go", "type": "apm-server", "version": "7.0.0" }, @@ -91,9 +92,7 @@ }, "name": "GET /api/products/:id/customers", "span_count": { - "dropped": { - "total": 0 - }, + "dropped": 0, "started": 1 }, "id": "8b60bd32ecc6e150", diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/anomaly_detection_setup_link.test.tsx similarity index 96% rename from x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx rename to x-pack/plugins/apm/public/components/shared/Links/apm/anomaly_detection_setup_link.test.tsx index 585ab22b5fb27..3f675f494a661 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/anomaly_detection_setup_link.test.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { render, fireEvent, wait } from '@testing-library/react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; import { MissingJobsAlert } from './AnomalyDetectionSetupLink'; import * as hooks from '../../../../hooks/useFetcher'; @@ -33,7 +33,7 @@ async function renderTooltipAnchor({ fireEvent.mouseOver(toolTipAnchor); // wait for tooltip text to be in the DOM - await wait(() => { + await waitFor(() => { const toolTipText = baseElement.querySelector('.euiToolTipPopover') ?.textContent; expect(toolTipText).not.toBe(undefined); diff --git a/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap index a5f8c40876540..63093900cb543 100644 --- a/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap @@ -238,8 +238,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": -2021127760, - "componentId": "sc-fzoLsD", + "baseHash": 211589981, + "componentId": "sc-fznyAO", "isStatic": false, "rules": Array [ " @@ -254,7 +254,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-fzoLsD", + "styledComponentId": "sc-fznyAO", "target": "span", "toString": [Function], "warnTooManyClasses": [Function], @@ -444,8 +444,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": -1474970742, - "componentId": "sc-Axmtr", + "baseHash": -2021127760, + "componentId": "sc-fzoLsD", "isStatic": false, "rules": Array [ " @@ -462,7 +462,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-Axmtr", + "styledComponentId": "sc-fzoLsD", "target": "code", "toString": [Function], "warnTooManyClasses": [Function], @@ -474,8 +474,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": 1882630949, - "componentId": "sc-AxheI", + "baseHash": 1280172402, + "componentId": "sc-fzozJi", "isStatic": false, "rules": Array [ " @@ -500,7 +500,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-AxheI", + "styledComponentId": "sc-fzozJi", "target": "pre", "toString": [Function], "warnTooManyClasses": [Function], @@ -669,8 +669,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": -1474970742, - "componentId": "sc-Axmtr", + "baseHash": -2021127760, + "componentId": "sc-fzoLsD", "isStatic": false, "rules": Array [ " @@ -687,7 +687,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-Axmtr", + "styledComponentId": "sc-fzoLsD", "target": "code", "toString": [Function], "warnTooManyClasses": [Function], @@ -699,8 +699,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": 1882630949, - "componentId": "sc-AxheI", + "baseHash": 1280172402, + "componentId": "sc-fzozJi", "isStatic": false, "rules": Array [ " @@ -725,7 +725,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-AxheI", + "styledComponentId": "sc-fzozJi", "target": "pre", "toString": [Function], "warnTooManyClasses": [Function], @@ -895,8 +895,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": -1474970742, - "componentId": "sc-Axmtr", + "baseHash": -2021127760, + "componentId": "sc-fzoLsD", "isStatic": false, "rules": Array [ " @@ -913,7 +913,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-Axmtr", + "styledComponentId": "sc-fzoLsD", "target": "code", "toString": [Function], "warnTooManyClasses": [Function], @@ -925,8 +925,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": 1882630949, - "componentId": "sc-AxheI", + "baseHash": 1280172402, + "componentId": "sc-fzozJi", "isStatic": false, "rules": Array [ " @@ -951,7 +951,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-AxheI", + "styledComponentId": "sc-fzozJi", "target": "pre", "toString": [Function], "warnTooManyClasses": [Function], @@ -1131,8 +1131,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": -1474970742, - "componentId": "sc-Axmtr", + "baseHash": -2021127760, + "componentId": "sc-fzoLsD", "isStatic": false, "rules": Array [ " @@ -1149,7 +1149,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-Axmtr", + "styledComponentId": "sc-fzoLsD", "target": "code", "toString": [Function], "warnTooManyClasses": [Function], @@ -1161,8 +1161,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": 1882630949, - "componentId": "sc-AxheI", + "baseHash": 1280172402, + "componentId": "sc-fzozJi", "isStatic": false, "rules": Array [ " @@ -1187,7 +1187,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-AxheI", + "styledComponentId": "sc-fzozJi", "target": "pre", "toString": [Function], "warnTooManyClasses": [Function], @@ -1384,8 +1384,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": -1474970742, - "componentId": "sc-Axmtr", + "baseHash": -2021127760, + "componentId": "sc-fzoLsD", "isStatic": false, "rules": Array [ " @@ -1402,7 +1402,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-Axmtr", + "styledComponentId": "sc-fzoLsD", "target": "code", "toString": [Function], "warnTooManyClasses": [Function], @@ -1414,8 +1414,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "$$typeof": Symbol(react.forward_ref), "attrs": Array [], "componentStyle": ComponentStyle { - "baseHash": 1882630949, - "componentId": "sc-AxheI", + "baseHash": 1280172402, + "componentId": "sc-fzozJi", "isStatic": false, "rules": Array [ " @@ -1440,7 +1440,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] "foldedComponentIds": Array [], "render": [Function], "shouldForwardProp": undefined, - "styledComponentId": "sc-AxheI", + "styledComponentId": "sc-fzozJi", "target": "pre", "toString": [Function], "warnTooManyClasses": [Function], diff --git a/x-pack/plugins/apm/public/components/shared/Summary/DurationSummaryItem.tsx b/x-pack/plugins/apm/public/components/shared/Summary/DurationSummaryItem.tsx index 7858bebead408..7e88ebc7639e3 100644 --- a/x-pack/plugins/apm/public/components/shared/Summary/DurationSummaryItem.tsx +++ b/x-pack/plugins/apm/public/components/shared/Summary/DurationSummaryItem.tsx @@ -6,8 +6,8 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiToolTip, EuiText } from '@elastic/eui'; +import { asDuration } from '../../../../common/utils/formatters'; import { PercentOfParent } from '../../app/TransactionDetails/WaterfallWithSummmary/PercentOfParent'; -import { asDuration } from '../../../utils/formatters'; interface Props { duration: number; diff --git a/x-pack/plugins/apm/public/components/shared/TimestampTooltip/index.tsx b/x-pack/plugins/apm/public/components/shared/TimestampTooltip/index.tsx index 504ff36c078f0..eaa291c33cff6 100644 --- a/x-pack/plugins/apm/public/components/shared/TimestampTooltip/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/TimestampTooltip/index.tsx @@ -6,7 +6,10 @@ import React from 'react'; import { EuiToolTip } from '@elastic/eui'; import moment from 'moment-timezone'; -import { asAbsoluteDateTime, TimeUnit } from '../../../utils/formatters'; +import { + asAbsoluteDateTime, + TimeUnit, +} from '../../../../common/utils/formatters'; interface Props { /** diff --git a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx index d02c5a5d08927..9fc16ab0f9eab 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx @@ -13,11 +13,11 @@ import { EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { asAbsoluteDateTime } from '../../../../../common/utils/formatters'; import { useTheme } from '../../../../hooks/useTheme'; import { Maybe } from '../../../../../typings/common'; import { Annotation } from '../../../../../common/annotations'; import { PlotValues, SharedPlot } from './plotUtils'; -import { asAbsoluteDateTime } from '../../../../utils/formatters'; interface Props { annotations: Annotation[]; diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js index be0716ba748dd..03fd039a3401e 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js +++ b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js @@ -9,16 +9,16 @@ import React from 'react'; import d3 from 'd3'; import { HistogramInner } from '../index'; import response from './response.json'; -import { - getDurationFormatter, - asInteger, -} from '../../../../../utils/formatters'; import { disableConsoleWarning, toJson, mountWithTheme, } from '../../../../../utils/testHelpers'; import { getFormattedBuckets } from '../../../../app/TransactionDetails/Distribution/index'; +import { + asInteger, + getDurationFormatter, +} from '../../../../../../common/utils/formatters'; describe('Histogram', () => { let mockConsole; diff --git a/x-pack/plugins/apm/public/components/shared/charts/MetricsChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/MetricsChart/index.tsx index 3b77ef3b51403..270ebd1c0830d 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/MetricsChart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/MetricsChart/index.tsx @@ -5,18 +5,18 @@ */ import { EuiTitle } from '@elastic/eui'; import React from 'react'; -import { asPercent } from '../../../../../common/utils/formatters'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { GenericMetricsChart } from '../../../../../server/lib/metrics/transform_metrics_chart'; -// @ts-expect-error -import CustomPlot from '../CustomPlot'; import { + asPercent, asDecimal, asInteger, asDynamicBytes, getFixedByteFormatter, asDuration, -} from '../../../../utils/formatters'; +} from '../../../../../common/utils/formatters'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { GenericMetricsChart } from '../../../../../server/lib/metrics/transform_metrics_chart'; +// @ts-expect-error +import CustomPlot from '../CustomPlot'; import { Coordinate } from '../../../../../typings/timeseries'; import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; import { useChartsSync } from '../../../../hooks/useChartsSync'; diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.tsx index 64e0fe33c982f..37d3664e98acd 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.tsx @@ -7,9 +7,9 @@ import React from 'react'; import { EuiToolTip } from '@elastic/eui'; import styled from 'styled-components'; +import { asDuration } from '../../../../../../common/utils/formatters'; import { useTheme } from '../../../../../hooks/useTheme'; import { px, units } from '../../../../../style/variables'; -import { asDuration } from '../../../../../utils/formatters'; import { Legend } from '../../Legend'; import { AgentMark } from '../../../../app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks'; diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx index 4567bc3f0f0b7..de63e2323ddac 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx @@ -7,6 +7,7 @@ import React, { useState } from 'react'; import { EuiPopover, EuiText } from '@elastic/eui'; import styled from 'styled-components'; +import { asDuration } from '../../../../../../common/utils/formatters'; import { useTheme } from '../../../../../hooks/useTheme'; import { TRACE_ID, @@ -14,7 +15,6 @@ import { } from '../../../../../../common/elasticsearch_fieldnames'; import { useUrlParams } from '../../../../../hooks/useUrlParams'; import { px, unit, units } from '../../../../../style/variables'; -import { asDuration } from '../../../../../utils/formatters'; import { ErrorMark } from '../../../../app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks'; import { ErrorDetailLink } from '../../../Links/apm/ErrorDetailLink'; import { Legend, Shape } from '../../Legend'; diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/TimelineAxis.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/TimelineAxis.tsx index 5cbfcc695e012..cb5a44432dcbc 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/TimelineAxis.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/TimelineAxis.tsx @@ -8,9 +8,9 @@ import React, { ReactNode } from 'react'; import { inRange } from 'lodash'; import { Sticky } from 'react-sticky'; import { XAxis, XYPlot } from 'react-vis'; +import { getDurationFormatter } from '../../../../../common/utils/formatters'; import { useTheme } from '../../../../hooks/useTheme'; import { px } from '../../../../style/variables'; -import { getDurationFormatter } from '../../../../utils/formatters'; import { Mark } from './'; import { LastTickValue } from './LastTickValue'; import { Marker } from './Marker'; diff --git a/x-pack/plugins/apm/public/components/shared/charts/Tooltip/index.js b/x-pack/plugins/apm/public/components/shared/charts/Tooltip/index.js index 7ee0c6280526b..7183c4851e993 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Tooltip/index.js +++ b/x-pack/plugins/apm/public/components/shared/charts/Tooltip/index.js @@ -18,7 +18,7 @@ import { fontSizes, } from '../../../../style/variables'; import { Legend } from '../Legend'; -import { asAbsoluteDateTime } from '../../../../utils/formatters'; +import { asAbsoluteDateTime } from '../../../../../common/utils/formatters'; const TooltipElm = styled.div` margin: 0 ${px(unit)}; diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.test.ts b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.test.ts index a476892fa4a3f..850e5d9a16112 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.test.ts +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.test.ts @@ -9,11 +9,12 @@ import { getResponseTimeTooltipFormatter, getMaxY, } from './helper'; + +import { TimeSeries } from '../../../../../typings/timeseries'; import { getDurationFormatter, toMicroseconds, -} from '../../../../utils/formatters'; -import { TimeSeries } from '../../../../../typings/timeseries'; +} from '../../../../../common/utils/formatters'; describe('transaction chart helper', () => { describe('getResponseTimeTickFormatter', () => { diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.tsx index f11a33f932553..db245792982c3 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/helper.tsx @@ -5,10 +5,10 @@ */ import { flatten } from 'lodash'; +import { TimeFormatter } from '../../../../../common/utils/formatters'; import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; import { TimeSeries, Coordinate } from '../../../../../typings/timeseries'; -import { TimeFormatter } from '../../../../utils/formatters'; export function getResponseTimeTickFormatter(formatter: TimeFormatter) { return (t: number) => { diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx index 30ee0ba3eaa1f..0b741447f6fec 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx @@ -24,7 +24,7 @@ import { Coordinate } from '../../../../../typings/timeseries'; import { LicenseContext } from '../../../../context/LicenseContext'; import { IUrlParams } from '../../../../context/UrlParamsContext/types'; import { ITransactionChartData } from '../../../../selectors/chartSelectors'; -import { asDecimal, tpmUnit } from '../../../../utils/formatters'; +import { asDecimal, tpmUnit } from '../../../../../common/utils/formatters'; import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; import { ErroneousTransactionsRateChart } from '../ErroneousTransactionsRateChart'; import { TransactionBreakdown } from '../../TransactionBreakdown'; diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx index 78ff9a398b2e7..fc873cbda7bf2 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx @@ -5,9 +5,9 @@ */ import React from 'react'; import { TimeSeries } from '../../../../../typings/timeseries'; -import { toMicroseconds } from '../../../../utils/formatters'; import { useFormatter } from './use_formatter'; import { render, fireEvent, act } from '@testing-library/react'; +import { toMicroseconds } from '../../../../../common/utils/formatters'; function MockComponent({ timeSeries, diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts index 8cd8929c89960..d4694bc3caf1d 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts @@ -9,7 +9,7 @@ import { isEmpty } from 'lodash'; import { getDurationFormatter, TimeFormatter, -} from '../../../../utils/formatters'; +} from '../../../../../common/utils/formatters'; import { TimeSeries } from '../../../../../typings/timeseries'; import { getMaxY } from './helper'; diff --git a/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx b/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx index 65f6dca179e71..3b915045f54b6 100644 --- a/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx +++ b/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx @@ -87,6 +87,11 @@ const mockPlugin = { useHash: false, }), }, + data: { + query: { + timefilter: { timefilter: { setTime: () => {}, getTime: () => ({}) } }, + }, + }, }; export const mockApmPluginContextValue = { config: mockConfig, diff --git a/x-pack/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx b/x-pack/plugins/apm/public/context/UrlParamsContext/url_params_context.test.tsx similarity index 94% rename from x-pack/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx rename to x-pack/plugins/apm/public/context/UrlParamsContext/url_params_context.test.tsx index 9989e568953f5..3a6ccce178cd4 100644 --- a/x-pack/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx +++ b/x-pack/plugins/apm/public/context/UrlParamsContext/url_params_context.test.tsx @@ -5,14 +5,14 @@ */ import * as React from 'react'; -import { UrlParamsContext, UrlParamsProvider } from '..'; +import { UrlParamsContext, UrlParamsProvider } from './'; import { mount } from 'enzyme'; import { Location, History } from 'history'; import { MemoryRouter, Router } from 'react-router-dom'; import moment from 'moment-timezone'; -import { IUrlParams } from '../types'; -import { getParsedDate } from '../helpers'; -import { wait } from '@testing-library/react'; +import { IUrlParams } from './types'; +import { getParsedDate } from './helpers'; +import { waitFor } from '@testing-library/react'; function mountParams(location: Location) { return mount( @@ -119,13 +119,13 @@ describe('UrlParamsContext', () => { ); - await wait(); + await waitFor(() => {}); expect(calls.length).toBe(1); wrapper.find('button').simulate('click'); - await wait(); + await waitFor(() => {}); expect(calls.length).toBe(2); @@ -170,11 +170,11 @@ describe('UrlParamsContext', () => { ); - await wait(); + await waitFor(() => {}); wrapper.find('button').simulate('click'); - await wait(); + await waitFor(() => {}); const params = getDataFromOutput(wrapper); expect(params.start).toEqual('2000-06-14T00:00:00.000Z'); diff --git a/x-pack/plugins/apm/public/hooks/useFetcher.integration.test.tsx b/x-pack/plugins/apm/public/hooks/use_fetcher.integration.test.tsx similarity index 94% rename from x-pack/plugins/apm/public/hooks/useFetcher.integration.test.tsx rename to x-pack/plugins/apm/public/hooks/use_fetcher.integration.test.tsx index 0081662200b93..e837851828d94 100644 --- a/x-pack/plugins/apm/public/hooks/useFetcher.integration.test.tsx +++ b/x-pack/plugins/apm/public/hooks/use_fetcher.integration.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { render, wait } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import React from 'react'; import { delay } from '../utils/testHelpers'; import { useFetcher } from './useFetcher'; @@ -65,7 +65,7 @@ describe('when simulating race condition', () => { it('should render "Hello from Peter" after 200ms', async () => { jest.advanceTimersByTime(200); - await wait(); + await waitFor(() => {}); expect(renderSpy).lastCalledWith({ data: 'Hello from Peter', @@ -76,7 +76,7 @@ describe('when simulating race condition', () => { it('should render "Hello from Peter" after 600ms', async () => { jest.advanceTimersByTime(600); - await wait(); + await waitFor(() => {}); expect(renderSpy).lastCalledWith({ data: 'Hello from Peter', @@ -87,7 +87,7 @@ describe('when simulating race condition', () => { it('should should NOT have rendered "Hello from John" at any point', async () => { jest.advanceTimersByTime(600); - await wait(); + await waitFor(() => {}); expect(renderSpy).not.toHaveBeenCalledWith({ data: 'Hello from John', @@ -98,7 +98,7 @@ describe('when simulating race condition', () => { it('should send and receive calls in the right order', async () => { jest.advanceTimersByTime(600); - await wait(); + await waitFor(() => {}); expect(requestCallOrder).toEqual([ ['request', 'John', 500], diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index d9709bbe461b3..560a1a077931b 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -7,6 +7,7 @@ import { ConfigSchema } from '.'; import { FetchDataParams, + HasDataParams, ObservabilityPluginSetup, } from '../../observability/public'; import { @@ -48,7 +49,7 @@ export interface ApmPluginSetupDeps { features: FeaturesPluginSetup; home?: HomePublicPluginSetup; licensing: LicensingPluginSetup; - triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; + triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; observability?: ObservabilityPluginSetup; } @@ -58,7 +59,7 @@ export interface ApmPluginStartDeps { data: DataPublicPluginStart; home: void; licensing: void; - triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; embeddable: EmbeddableStart; } @@ -100,6 +101,30 @@ export class ApmPlugin implements Plugin { return await dataHelper.fetchOverviewPageData(params); }, }); + + const getUxDataHelper = async () => { + const { + fetchUxOverviewDate, + hasRumData, + createCallApmApi, + } = await import('./components/app/RumDashboard/ux_overview_fetchers'); + // have to do this here as well in case app isn't mounted yet + createCallApmApi(core.http); + + return { fetchUxOverviewDate, hasRumData }; + }; + + plugins.observability.dashboard.register({ + appName: 'ux', + hasData: async (params?: HasDataParams) => { + const dataHelper = await getUxDataHelper(); + return await dataHelper.hasRumData(params!); + }, + fetchData: async (params: FetchDataParams) => { + const dataHelper = await getUxDataHelper(); + return await dataHelper.fetchUxOverviewDate(params); + }, + }); } core.application.register({ @@ -148,6 +173,6 @@ export class ApmPlugin implements Plugin { } public start(core: CoreStart, plugins: ApmPluginStartDeps) { toggleAppLinkInNav(core, this.initializerContext.config.get()); - registerApmAlerts(plugins.triggers_actions_ui.alertTypeRegistry); + registerApmAlerts(plugins.triggersActionsUi.alertTypeRegistry); } } diff --git a/x-pack/plugins/apm/public/selectors/chartSelectors.ts b/x-pack/plugins/apm/public/selectors/chartSelectors.ts index 26c2365ed77e1..8c6093859f969 100644 --- a/x-pack/plugins/apm/public/selectors/chartSelectors.ts +++ b/x-pack/plugins/apm/public/selectors/chartSelectors.ts @@ -17,10 +17,10 @@ import { RectCoordinate, TimeSeries, } from '../../typings/timeseries'; -import { asDecimal, asDuration, tpmUnit } from '../utils/formatters'; import { IUrlParams } from '../context/UrlParamsContext/types'; import { getEmptySeries } from '../components/shared/charts/CustomPlot/getEmptySeries'; import { httpStatusCodeToColor } from '../utils/httpStatusCodeToColor'; +import { asDecimal, asDuration, tpmUnit } from '../../common/utils/formatters'; export interface ITpmBucket { title: string; diff --git a/x-pack/plugins/apm/public/utils/testHelpers.tsx b/x-pack/plugins/apm/public/utils/testHelpers.tsx index 7826e9672a3bb..f990c4387ddf1 100644 --- a/x-pack/plugins/apm/public/utils/testHelpers.tsx +++ b/x-pack/plugins/apm/public/utils/testHelpers.tsx @@ -12,7 +12,7 @@ import enzymeToJson from 'enzyme-to-json'; import { Location } from 'history'; import moment from 'moment'; import { Moment } from 'moment-timezone'; -import { render, waitForElement } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { APMConfig } from '../../server'; @@ -75,10 +75,10 @@ export async function getRenderedHref(Component: React.FC, location: Location) { ); + const a = el.container.querySelector('a'); - await waitForElement(() => el.container.querySelector('a')); + await waitFor(() => {}, { container: a! }); - const a = el.container.querySelector('a'); return a ? a.getAttribute('href') : ''; } diff --git a/x-pack/plugins/apm/server/lib/alerts/action_variables.ts b/x-pack/plugins/apm/server/lib/alerts/action_variables.ts index f2558da3a30e4..19af337e18811 100644 --- a/x-pack/plugins/apm/server/lib/alerts/action_variables.ts +++ b/x-pack/plugins/apm/server/lib/alerts/action_variables.ts @@ -45,4 +45,14 @@ export const apmActionVariables = { ), name: 'triggerValue', }, + interval: { + description: i18n.translate( + 'xpack.apm.alerts.action_variables.intervalSize', + { + defaultMessage: + 'The length and unit of the time period where the alert conditions were met', + } + ), + name: 'interval', + }, }; diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.test.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.test.ts index 6a6e2b7137055..8dbc8054d4371 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.test.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.test.ts @@ -100,7 +100,7 @@ describe('Error count alert', () => { })), alertInstanceFactory: jest.fn(() => ({ scheduleActions })), }; - const params = { threshold: 1 }; + const params = { threshold: 1, windowSize: 5, windowUnit: 'm' }; await alertExecutor!({ services, params }); [ @@ -117,24 +117,28 @@ describe('Error count alert', () => { environment: 'env-foo', threshold: 1, triggerValue: 2, + interval: '5m', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'foo', environment: 'env-foo-2', threshold: 1, triggerValue: 2, + interval: '5m', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'bar', environment: 'env-bar', threshold: 1, triggerValue: 2, + interval: '5m', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'bar', environment: 'env-bar-2', threshold: 1, triggerValue: 2, + interval: '5m', }); }); it('sends alerts with service name', async () => { @@ -174,7 +178,7 @@ describe('Error count alert', () => { })), alertInstanceFactory: jest.fn(() => ({ scheduleActions })), }; - const params = { threshold: 1 }; + const params = { threshold: 1, windowSize: 5, windowUnit: 'm' }; await alertExecutor!({ services, params }); ['apm.error_rate_foo', 'apm.error_rate_bar'].forEach((instanceName) => @@ -186,12 +190,14 @@ describe('Error count alert', () => { environment: undefined, threshold: 1, triggerValue: 2, + interval: '5m', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'bar', environment: undefined, threshold: 1, triggerValue: 2, + interval: '5m', }); }); }); diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts index 26e4a5e84b995..7b63f2c354916 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts @@ -55,6 +55,7 @@ export function registerErrorCountAlertType({ apmActionVariables.environment, apmActionVariables.threshold, apmActionVariables.triggerValue, + apmActionVariables.interval, ], }, producer: 'apm', @@ -138,6 +139,7 @@ export function registerErrorCountAlertType({ environment, threshold: alertParams.threshold, triggerValue: errorCount, + interval: `${alertParams.windowSize}${alertParams.windowUnit}`, }); } response.aggregations?.services.buckets.forEach((serviceBucket) => { diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts index 373d4bd4da832..1d8b664751ba2 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts @@ -7,6 +7,7 @@ import { schema } from '@kbn/config-schema'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; +import { getDurationFormatter } from '../../../common/utils/formatters'; import { ProcessorEvent } from '../../../common/processor_event'; import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; import { ESSearchResponse } from '../../../typings/elasticsearch'; @@ -15,6 +16,7 @@ import { SERVICE_NAME, TRANSACTION_TYPE, TRANSACTION_DURATION, + SERVICE_ENVIRONMENT, } from '../../../common/elasticsearch_fieldnames'; import { AlertingPlugin } from '../../../../alerts/server'; import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; @@ -62,6 +64,7 @@ export function registerTransactionDurationAlertType({ apmActionVariables.environment, apmActionVariables.threshold, apmActionVariables.triggerValue, + apmActionVariables.interval, ], }, producer: 'apm', @@ -106,6 +109,11 @@ export function registerTransactionDurationAlertType({ ], }, }, + environments: { + terms: { + field: SERVICE_ENVIRONMENT, + }, + }, }, }, }; @@ -119,7 +127,7 @@ export function registerTransactionDurationAlertType({ return; } - const { agg } = response.aggregations; + const { agg, environments } = response.aggregations; const transactionDuration = 'values' in agg ? Object.values(agg.values)[0] : agg?.value; @@ -127,15 +135,25 @@ export function registerTransactionDurationAlertType({ const threshold = alertParams.threshold * 1000; if (transactionDuration && transactionDuration > threshold) { - const alertInstance = services.alertInstanceFactory( - AlertType.TransactionDuration - ); - alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { - transactionType: alertParams.transactionType, - serviceName: alertParams.serviceName, - environment: alertParams.environment, - threshold, - triggerValue: transactionDuration, + const durationFormatter = getDurationFormatter(transactionDuration); + const transactionDurationFormatted = durationFormatter( + transactionDuration + ).formatted; + + environments.buckets.map((bucket) => { + const environment = bucket.key; + const alertInstance = services.alertInstanceFactory( + `${AlertType.TransactionDuration}_${environment}` + ); + + alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { + transactionType: alertParams.transactionType, + serviceName: alertParams.serviceName, + environment, + threshold, + triggerValue: transactionDurationFormatted, + interval: `${alertParams.windowSize}${alertParams.windowUnit}`, + }); }); } }, diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts index 6e97262dd77bb..17f6bb3ac19cf 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts @@ -180,12 +180,14 @@ describe('Transaction duration anomaly alert', () => { transaction_types: { buckets: [{ key: 'type-foo' }], }, + record_avg: { value: 80 }, }, { key: 'bar', transaction_types: { buckets: [{ key: 'type-bar' }], }, + record_avg: { value: 20 }, }, ], }, @@ -223,11 +225,15 @@ describe('Transaction duration anomaly alert', () => { serviceName: 'foo', transactionType: 'type-foo', environment: 'production', + threshold: 'minor', + thresholdValue: 'critical', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'bar', transactionType: 'type-bar', environment: 'production', + threshold: 'minor', + thresholdValue: 'warning', }); }); @@ -267,7 +273,10 @@ describe('Transaction duration anomaly alert', () => { hits: { total: { value: 2 } }, aggregations: { services: { - buckets: [{ key: 'foo' }, { key: 'bar' }], + buckets: [ + { key: 'foo', record_avg: { value: 80 } }, + { key: 'bar', record_avg: { value: 20 } }, + ], }, }, }), @@ -305,21 +314,29 @@ describe('Transaction duration anomaly alert', () => { serviceName: 'foo', transactionType: undefined, environment: 'production', + threshold: 'minor', + thresholdValue: 'critical', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'bar', transactionType: undefined, environment: 'production', + threshold: 'minor', + thresholdValue: 'warning', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'foo', transactionType: undefined, environment: 'testing', + threshold: 'minor', + thresholdValue: 'critical', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'bar', transactionType: undefined, environment: 'testing', + threshold: 'minor', + thresholdValue: 'warning', }); }); }); diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts index 36b7964e8128d..9b3da56b6f640 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts @@ -7,6 +7,7 @@ import { schema } from '@kbn/config-schema'; import { Observable } from 'rxjs'; import { isEmpty } from 'lodash'; +import { getSeverity } from '../../../common/anomaly_detection'; import { ANOMALY_SEVERITY } from '../../../../ml/common'; import { KibanaRequest } from '../../../../../../src/core/server'; import { @@ -61,6 +62,8 @@ export function registerTransactionDurationAnomalyAlertType({ apmActionVariables.serviceName, apmActionVariables.transactionType, apmActionVariables.environment, + apmActionVariables.threshold, + apmActionVariables.triggerValue, ], }, producer: 'apm', @@ -148,6 +151,11 @@ export function registerTransactionDurationAnomalyAlertType({ field: 'by_field_value', }, }, + record_avg: { + avg: { + field: 'record_score', + }, + }, }, }, }, @@ -162,6 +170,7 @@ export function registerTransactionDurationAnomalyAlertType({ services: { buckets: Array<{ key: string; + record_avg: { value: number }; transaction_types: { buckets: Array<{ key: string }> }; }>; }; @@ -173,10 +182,12 @@ export function registerTransactionDurationAnomalyAlertType({ if (hitCount > 0) { function scheduleAction({ serviceName, + severity, environment, transactionType, }: { serviceName: string; + severity: string; environment?: string; transactionType?: string; }) { @@ -192,23 +203,31 @@ export function registerTransactionDurationAnomalyAlertType({ const alertInstance = services.alertInstanceFactory( alertInstanceName ); + alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { serviceName, environment, transactionType, + threshold: selectedOption?.label, + thresholdValue: severity, }); } - mlJobs.map((job) => { const environment = job.custom_settings?.job_tags?.environment; response.aggregations?.services.buckets.forEach((serviceBucket) => { const serviceName = serviceBucket.key as string; + const severity = getSeverity(serviceBucket.record_avg.value); if (isEmpty(serviceBucket.transaction_types?.buckets)) { - scheduleAction({ serviceName, environment }); + scheduleAction({ serviceName, severity, environment }); } else { serviceBucket.transaction_types?.buckets.forEach((typeBucket) => { const transactionType = typeBucket.key as string; - scheduleAction({ serviceName, environment, transactionType }); + scheduleAction({ + serviceName, + severity, + environment, + transactionType, + }); }); } }); diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts index 90db48f84b5d9..7c13f2a17b255 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts @@ -115,7 +115,7 @@ describe('Transaction error rate alert', () => { })), alertInstanceFactory: jest.fn(() => ({ scheduleActions })), }; - const params = { threshold: 10 }; + const params = { threshold: 10, windowSize: 5, windowUnit: 'm' }; await alertExecutor!({ services, params }); [ @@ -132,28 +132,32 @@ describe('Transaction error rate alert', () => { transactionType: 'type-foo', environment: 'env-foo', threshold: 10, - triggerValue: 50, + triggerValue: '50', + interval: '5m', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'foo', transactionType: 'type-foo', environment: 'env-foo-2', threshold: 10, - triggerValue: 50, + triggerValue: '50', + interval: '5m', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'bar', transactionType: 'type-bar', environment: 'env-bar', threshold: 10, - triggerValue: 50, + triggerValue: '50', + interval: '5m', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'bar', transactionType: 'type-bar', environment: 'env-bar-2', threshold: 10, - triggerValue: 50, + triggerValue: '50', + interval: '5m', }); }); it('sends alerts with service name and transaction type', async () => { @@ -202,7 +206,7 @@ describe('Transaction error rate alert', () => { })), alertInstanceFactory: jest.fn(() => ({ scheduleActions })), }; - const params = { threshold: 10 }; + const params = { threshold: 10, windowSize: 5, windowUnit: 'm' }; await alertExecutor!({ services, params }); [ @@ -217,14 +221,16 @@ describe('Transaction error rate alert', () => { transactionType: 'type-foo', environment: undefined, threshold: 10, - triggerValue: 50, + triggerValue: '50', + interval: '5m', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'bar', transactionType: 'type-bar', environment: undefined, threshold: 10, - triggerValue: 50, + triggerValue: '50', + interval: '5m', }); }); @@ -261,7 +267,7 @@ describe('Transaction error rate alert', () => { })), alertInstanceFactory: jest.fn(() => ({ scheduleActions })), }; - const params = { threshold: 10 }; + const params = { threshold: 10, windowSize: 5, windowUnit: 'm' }; await alertExecutor!({ services, params }); [ @@ -276,14 +282,16 @@ describe('Transaction error rate alert', () => { transactionType: undefined, environment: undefined, threshold: 10, - triggerValue: 50, + triggerValue: '50', + interval: '5m', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'bar', transactionType: undefined, environment: undefined, threshold: 10, - triggerValue: 50, + triggerValue: '50', + interval: '5m', }); }); }); diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts index e14360029e5dd..969f4ceaca93a 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts @@ -8,6 +8,7 @@ import { schema } from '@kbn/config-schema'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; import { isEmpty } from 'lodash'; +import { asDecimalOrInteger } from '../../../common/utils/formatters'; import { ProcessorEvent } from '../../../common/processor_event'; import { EventOutcome } from '../../../common/event_outcome'; import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; @@ -60,6 +61,7 @@ export function registerTransactionErrorRateAlertType({ apmActionVariables.environment, apmActionVariables.threshold, apmActionVariables.triggerValue, + apmActionVariables.interval, ], }, producer: 'apm', @@ -170,7 +172,8 @@ export function registerTransactionErrorRateAlertType({ transactionType, environment, threshold: alertParams.threshold, - triggerValue: transactionErrorRate, + triggerValue: asDecimalOrInteger(transactionErrorRate), + interval: `${alertParams.windowSize}${alertParams.windowUnit}`, }); } diff --git a/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts b/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts new file mode 100644 index 0000000000000..14245ce1d6c83 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { + SERVICE_NAME, + TRANSACTION_TYPE, +} from '../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { rangeFilter } from '../../../common/utils/range_filter'; +import { TRANSACTION_PAGE_LOAD } from '../../../common/transaction_types'; + +export async function hasRumData({ setup }: { setup: Setup & SetupTimeRange }) { + try { + const { start, end } = setup; + + const params = { + apm: { + events: [ProcessorEvent.transaction], + }, + body: { + size: 0, + query: { + bool: { + filter: [{ term: { [TRANSACTION_TYPE]: TRANSACTION_PAGE_LOAD } }], + }, + }, + aggs: { + services: { + filter: { + range: rangeFilter(start, end), + }, + aggs: { + mostTraffic: { + terms: { + field: SERVICE_NAME, + size: 1, + }, + }, + }, + }, + }, + }, + }; + + const { apmEventClient } = setup; + + const response = await apmEventClient.search(params); + return { + hasData: response.hits.total.value > 0, + serviceName: + response.aggregations?.services?.mostTraffic?.buckets?.[0]?.key, + }; + } catch (e) { + return false; + } +} diff --git a/x-pack/plugins/apm/server/lib/service_map/group_resource_nodes.test.ts b/x-pack/plugins/apm/server/lib/service_map/group_resource_nodes.test.ts index 23ef3f92e21a2..c3238963eedee 100644 --- a/x-pack/plugins/apm/server/lib/service_map/group_resource_nodes.test.ts +++ b/x-pack/plugins/apm/server/lib/service_map/group_resource_nodes.test.ts @@ -4,14 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ConnectionElement } from '../../../common/service_map'; import { groupResourceNodes } from './group_resource_nodes'; -import preGroupedData from './mock_responses/group_resource_nodes_pregrouped.json'; import expectedGroupedData from './mock_responses/group_resource_nodes_grouped.json'; +import preGroupedData from './mock_responses/group_resource_nodes_pregrouped.json'; describe('groupResourceNodes', () => { it('should group external nodes', () => { - // @ts-expect-error invalid json mock - const responseWithGroups = groupResourceNodes(preGroupedData); + const responseWithGroups = groupResourceNodes( + preGroupedData as { elements: ConnectionElement[] } + ); expect(responseWithGroups.elements).toHaveLength( expectedGroupedData.elements.length ); diff --git a/x-pack/plugins/apm/server/lib/services/annotations/__fixtures__/no_versions.json b/x-pack/plugins/apm/server/lib/services/annotations/__fixtures__/no_versions.json index fa5c63f1b9a54..863d4bed998e9 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/__fixtures__/no_versions.json +++ b/x-pack/plugins/apm/server/lib/services/annotations/__fixtures__/no_versions.json @@ -12,7 +12,7 @@ "value": 10000, "relation": "gte" }, - "max_score": null, + "max_score": 0, "hits": [] }, "aggregations": { diff --git a/x-pack/plugins/apm/server/lib/services/annotations/__fixtures__/one_version.json b/x-pack/plugins/apm/server/lib/services/annotations/__fixtures__/one_version.json index 56303909bcd6f..d74f7bf82c2b9 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/__fixtures__/one_version.json +++ b/x-pack/plugins/apm/server/lib/services/annotations/__fixtures__/one_version.json @@ -12,7 +12,7 @@ "value": 10000, "relation": "gte" }, - "max_score": null, + "max_score": 0, "hits": [] }, "aggregations": { diff --git a/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts b/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts index 9bd9c7b7a1040..f30b77f147710 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts +++ b/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts @@ -3,14 +3,18 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { getDerivedServiceAnnotations } from './get_derived_service_annotations'; import { - SearchParamsMock, + ESSearchRequest, + ESSearchResponse, +} from '../../../../typings/elasticsearch'; +import { inspectSearchParams, + SearchParamsMock, } from '../../../utils/test_helpers'; +import { getDerivedServiceAnnotations } from './get_derived_service_annotations'; +import multipleVersions from './__fixtures__/multiple_versions.json'; import noVersions from './__fixtures__/no_versions.json'; import oneVersion from './__fixtures__/one_version.json'; -import multipleVersions from './__fixtures__/multiple_versions.json'; import versionsFirstSeen from './__fixtures__/versions_first_seen.json'; describe('getServiceAnnotations', () => { @@ -31,8 +35,14 @@ describe('getServiceAnnotations', () => { searchAggregatedTransactions: false, }), { - // @ts-expect-error invalid json mock - mockResponse: () => noVersions, + mockResponse: () => + noVersions as ESSearchResponse< + unknown, + ESSearchRequest, + { + restTotalHitsAsInt: false; + } + >, } ); @@ -51,8 +61,14 @@ describe('getServiceAnnotations', () => { searchAggregatedTransactions: false, }), { - // @ts-expect-error invalid json mock - mockResponse: () => oneVersion, + mockResponse: () => + oneVersion as ESSearchResponse< + unknown, + ESSearchRequest, + { + restTotalHitsAsInt: false; + } + >, } ); @@ -76,8 +92,14 @@ describe('getServiceAnnotations', () => { searchAggregatedTransactions: false, }), { - // @ts-expect-error invalid json mock - mockResponse: () => responses.shift(), + mockResponse: () => + (responses.shift() as unknown) as ESSearchResponse< + unknown, + ESSearchRequest, + { + restTotalHitsAsInt: false; + } + >, } ); diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 0560b977e708e..c1f13ee646e49 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -79,6 +79,7 @@ import { anomalyDetectionEnvironmentsRoute, } from './settings/anomaly_detection'; import { + rumHasDataRoute, rumClientMetricsRoute, rumJSErrors, rumLongTaskMetrics, @@ -186,7 +187,8 @@ const createApmApi = () => { .add(rumWebCoreVitals) .add(rumJSErrors) .add(rumUrlSearch) - .add(rumLongTaskMetrics); + .add(rumLongTaskMetrics) + .add(rumHasDataRoute); return api; }; diff --git a/x-pack/plugins/apm/server/routes/rum_client.ts b/x-pack/plugins/apm/server/routes/rum_client.ts index 8dee8b759df26..93e62e68fc228 100644 --- a/x-pack/plugins/apm/server/routes/rum_client.ts +++ b/x-pack/plugins/apm/server/routes/rum_client.ts @@ -18,6 +18,7 @@ import { getWebCoreVitals } from '../lib/rum_client/get_web_core_vitals'; import { getJSErrors } from '../lib/rum_client/get_js_errors'; import { getLongTaskMetrics } from '../lib/rum_client/get_long_task_metrics'; import { getUrlSearch } from '../lib/rum_client/get_url_search'; +import { hasRumData } from '../lib/rum_client/has_rum_data'; export const percentileRangeRt = t.partial({ minPercentile: t.string, @@ -227,3 +228,14 @@ export const rumJSErrors = createRoute(() => ({ }); }, })); + +export const rumHasDataRoute = createRoute(() => ({ + path: '/api/apm/observability_overview/has_rum_data', + params: { + query: t.intersection([uiFiltersRt, rangeRt]), + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + return await hasRumData({ setup }); + }, +})); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js index faadfd4bb26d7..43999e9bc7fac 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/demodata.js @@ -5,16 +5,18 @@ */ import React from 'react'; -import { EuiText } from '@elastic/eui'; +import { EuiCallOut, EuiText } from '@elastic/eui'; import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; import { DataSourceStrings } from '../../../i18n'; const { DemoData: strings } = DataSourceStrings; const DemodataDatasource = () => ( - -

    {strings.getDescription()}

    -
    + + +

    {strings.getDescription()}

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

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

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

    {strings.getExpressionArgDescription()}

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

    {title}

    -
    - {description &&

    {description}

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

    {title}

    -
    - {description && ( - -

    {description}

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

    {title}

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

    {title}

    ; + break; + default: + titleElement =

    {title}

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

    {description}

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

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

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

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

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

    + + )} +
    +
    + ); + + return ( + <> + + + + + + + + {numGroups > 0 && !groupListLoading ? : noResults} + {newGroupModalOpen && } + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.ts new file mode 100644 index 0000000000000..35d4387b4cf3d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.ts @@ -0,0 +1,351 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { kea, MakeLogicType } from 'kea'; +import { i18n } from '@kbn/i18n'; + +import { HttpLogic } from '../../../shared/http'; + +import { + FlashMessagesLogic, + flashAPIErrors, + setSuccessMessage, +} from '../../../shared/flash_messages'; + +import { IContentSource, IGroup, IUser } from '../../types'; + +import { JSON_HEADER as headers } from '../../../../../common/constants'; +import { DEFAULT_META } from '../../../shared/constants'; +import { IMeta } from '../../../../../common/types'; + +export const MAX_NAME_LENGTH = 40; + +interface IGroupsServerData { + contentSources: IContentSource[]; + users: IUser[]; +} + +interface IGroupsSearchResponse { + results: IGroup[]; + meta: IMeta; +} + +export interface IGroupsActions { + onInitializeGroups(data: IGroupsServerData): IGroupsServerData; + setSearchResults(data: IGroupsSearchResponse): IGroupsSearchResponse; + addFilteredSource(sourceId: string): string; + removeFilteredSource(sourceId: string): string; + addFilteredUser(userId: string): string; + removeFilteredUser(userId: string): string; + setGroupUsers(allGroupUsers: IUser[]): IUser[]; + setAllGroupLoading(allGroupUsersLoading: boolean): boolean; + setFilterValue(filterValue: string): string; + setActivePage(activePage: number): number; + setNewGroupName(newGroupName: string): string; + setNewGroup(newGroup: IGroup): IGroup; + setNewGroupFormErrors(errors: string[]): string[]; + openNewGroupModal(): void; + closeNewGroupModal(): void; + closeFilterSourcesDropdown(): void; + closeFilterUsersDropdown(): void; + toggleFilterSourcesDropdown(): void; + toggleFilterUsersDropdown(): void; + setGroupsLoading(): void; + resetGroupsFilters(): void; + resetGroups(): void; + initializeGroups(): void; + getSearchResults(resetPagination?: boolean): { resetPagination: boolean | undefined }; + fetchGroupUsers(groupId: string): { groupId: string }; + saveNewGroup(): void; +} + +export interface IGroupsValues { + groups: IGroup[]; + contentSources: IContentSource[]; + users: IUser[]; + groupsDataLoading: boolean; + groupListLoading: boolean; + newGroupModalOpen: boolean; + newGroupName: string; + newGroup: IGroup | null; + newGroupNameErrors: string[]; + filterSourcesDropdownOpen: boolean; + filteredSources: string[]; + filterUsersDropdownOpen: boolean; + filteredUsers: string[]; + allGroupUsersLoading: boolean; + allGroupUsers: IUser[]; + filterValue: string; + groupsMeta: IMeta; + hasFiltersSet: boolean; +} + +export const GroupsLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'groups'], + actions: { + onInitializeGroups: (data: IGroupsServerData) => data, + setSearchResults: (data: IGroupsSearchResponse) => data, + addFilteredSource: (sourceId: string) => sourceId, + removeFilteredSource: (sourceId: string) => sourceId, + addFilteredUser: (userId: string) => userId, + removeFilteredUser: (userId: string) => userId, + setGroupUsers: (allGroupUsers: IUser[]) => allGroupUsers, + setAllGroupLoading: (allGroupUsersLoading: boolean) => allGroupUsersLoading, + setFilterValue: (filterValue: string) => filterValue, + setActivePage: (activePage: number) => activePage, + setNewGroupName: (newGroupName: string) => newGroupName, + setNewGroup: (newGroup: IGroup) => newGroup, + setNewGroupFormErrors: (errors: string[]) => errors, + openNewGroupModal: () => true, + closeNewGroupModal: () => true, + closeFilterSourcesDropdown: () => true, + closeFilterUsersDropdown: () => true, + toggleFilterSourcesDropdown: () => true, + toggleFilterUsersDropdown: () => true, + setGroupsLoading: () => true, + resetGroupsFilters: () => true, + resetGroups: () => true, + initializeGroups: () => true, + getSearchResults: (resetPagination?: boolean) => ({ resetPagination }), + fetchGroupUsers: (groupId: string) => ({ groupId }), + saveNewGroup: () => true, + }, + reducers: { + groups: [ + [] as IGroup[], + { + setSearchResults: (_, { results }) => results, + }, + ], + contentSources: [ + [], + { + onInitializeGroups: (_, { contentSources }) => contentSources, + }, + ], + users: [ + [], + { + onInitializeGroups: (_, { users }) => users, + }, + ], + groupsDataLoading: [ + true, + { + onInitializeGroups: () => false, + }, + ], + groupListLoading: [ + true, + { + setSearchResults: () => false, + setGroupsLoading: () => true, + }, + ], + newGroupModalOpen: [ + false, + { + openNewGroupModal: () => true, + closeNewGroupModal: () => false, + setNewGroup: () => false, + }, + ], + newGroupName: [ + '', + { + setNewGroupName: (_, newGroupName) => newGroupName, + setSearchResults: () => '', + closeNewGroupModal: () => '', + }, + ], + newGroup: [ + null, + { + setNewGroup: (_, newGroup) => newGroup, + resetGroups: () => null, + openNewGroupModal: () => null, + }, + ], + newGroupNameErrors: [ + [], + { + setNewGroupFormErrors: (_, newGroupNameErrors) => newGroupNameErrors, + setNewGroup: () => [], + setNewGroupName: () => [], + closeNewGroupModal: () => [], + }, + ], + filterSourcesDropdownOpen: [ + false, + { + toggleFilterSourcesDropdown: (state) => !state, + closeFilterSourcesDropdown: () => false, + }, + ], + filteredSources: [ + [], + { + resetGroupsFilters: () => [], + setNewGroup: () => [], + addFilteredSource: (state, sourceId) => [...state, sourceId].sort(), + removeFilteredSource: (state, sourceId) => state.filter((id) => id !== sourceId), + }, + ], + filterUsersDropdownOpen: [ + false, + { + toggleFilterUsersDropdown: (state) => !state, + closeFilterUsersDropdown: () => false, + }, + ], + filteredUsers: [ + [], + { + resetGroupsFilters: () => [], + setNewGroup: () => [], + addFilteredUser: (state, userId) => [...state, userId].sort(), + removeFilteredUser: (state, userId) => state.filter((id) => id !== userId), + }, + ], + allGroupUsersLoading: [ + false, + { + setAllGroupLoading: (_, allGroupUsersLoading) => allGroupUsersLoading, + setGroupUsers: () => false, + }, + ], + allGroupUsers: [ + [], + { + setGroupUsers: (_, allGroupUsers) => allGroupUsers, + setAllGroupLoading: () => [], + }, + ], + filterValue: [ + '', + { + setFilterValue: (_, filterValue) => filterValue, + resetGroupsFilters: () => '', + }, + ], + groupsMeta: [ + DEFAULT_META, + { + resetGroupsFilters: () => DEFAULT_META, + setNewGroup: () => DEFAULT_META, + setSearchResults: (_, { meta }) => meta, + setActivePage: (state, activePage) => ({ + ...state, + page: { + ...state.page, + current: activePage, + }, + }), + }, + ], + }, + selectors: ({ selectors }) => ({ + hasFiltersSet: [ + () => [selectors.filteredUsers, selectors.filteredSources], + (filteredUsers, filteredSources) => filteredUsers.length > 0 || filteredSources.length > 0, + ], + }), + listeners: ({ actions, values }) => ({ + initializeGroups: async () => { + try { + const response = await HttpLogic.values.http.get('/api/workplace_search/groups'); + actions.onInitializeGroups(response); + } catch (e) { + flashAPIErrors(e); + } + }, + getSearchResults: async ({ resetPagination }, breakpoint) => { + // Debounce search results when typing + await breakpoint(300); + + actions.setGroupsLoading(); + + const { + groupsMeta: { + page: { current, size }, + }, + filterValue, + filteredSources, + filteredUsers, + } = values; + + // Is the user changes the query while on a different page, we want to start back over at 1. + const page = { + current: resetPagination ? 1 : current, + size, + }; + const search = { + query: filterValue, + content_source_ids: filteredSources, + user_ids: filteredUsers, + }; + + try { + const response = await HttpLogic.values.http.post('/api/workplace_search/groups/search', { + body: JSON.stringify({ + page, + search, + }), + headers, + }); + + actions.setSearchResults(response); + } catch (e) { + flashAPIErrors(e); + } + }, + fetchGroupUsers: async ({ groupId }) => { + actions.setAllGroupLoading(true); + try { + const response = await HttpLogic.values.http.get( + `/api/workplace_search/groups/${groupId}/group_users` + ); + actions.setGroupUsers(response); + } catch (e) { + flashAPIErrors(e); + } + }, + saveNewGroup: async () => { + try { + const response = await HttpLogic.values.http.post('/api/workplace_search/groups', { + body: JSON.stringify({ group_name: values.newGroupName }), + headers, + }); + actions.getSearchResults(true); + + const SUCCESS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.groups.newGroupSavedSuccess', + { + defaultMessage: 'Successfully created {groupName}', + values: { groupName: response.name }, + } + ); + + setSuccessMessage(SUCCESS_MESSAGE); + actions.setNewGroup(response); + } catch (e) { + flashAPIErrors(e); + } + }, + openNewGroupModal: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + resetGroupsFilters: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + toggleFilterSourcesDropdown: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + toggleFilterUsersDropdown: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_router.tsx new file mode 100644 index 0000000000000..caa71d0d622f3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_router.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; + +import { useActions } from 'kea'; + +import { Route, Switch } from 'react-router-dom'; + +import { GROUP_PATH, GROUPS_PATH } from '../../routes'; + +import { GroupsLogic } from './groups_logic'; + +import { GroupRouter } from './group_router'; +import { Groups } from './groups'; + +import './groups.scss'; + +export const GroupsRouter: React.FC = () => { + const { initializeGroups } = useActions(GroupsLogic); + + useEffect(() => { + initializeGroups(); + }, []); + + return ( + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/index.ts new file mode 100644 index 0000000000000..79b5e39d2b27d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { GroupsRouter } from './groups_router'; +export { GroupsLogic } from './groups_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx index f3736c0a21551..a712fbdd0dea6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview.tsx @@ -65,7 +65,7 @@ export const Overview: React.FC = () => { return ( - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx index d632792f2a666..3d6d65fce2528 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/setup_guide/setup_guide.tsx @@ -27,9 +27,11 @@ export const SetupGuide: React.FC = () => { elasticsearchNativeAuthLink="https://www.elastic.co/guide/en/workplace-search/current/workplace-search-security.html#elasticsearch-native-realm" > diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts index c63e3ff8ffb2b..dcc696f6d01e2 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts @@ -12,7 +12,7 @@ import { ConfigType } from '../'; import { IAccess } from './check_access'; import { IInitialAppData } from '../../common/types'; -import { stripTrailingSlash } from '../../common/strip_trailing_slash'; +import { stripTrailingSlash } from '../../common/strip_slashes'; interface IParams { request: KibanaRequest; diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index a9bd03e8f97d4..43b0be8a5b438 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -45,6 +45,7 @@ import { registerCredentialsRoutes } from './routes/app_search/credentials'; import { workplaceSearchTelemetryType } from './saved_objects/workplace_search/telemetry'; import { registerTelemetryUsageCollector as registerWSTelemetryUsageCollector } from './collectors/workplace_search/telemetry'; import { registerWSOverviewRoute } from './routes/workplace_search/overview'; +import { registerWSGroupRoutes } from './routes/workplace_search/groups'; export interface PluginsSetup { usageCollection?: UsageCollectionSetup; @@ -129,6 +130,7 @@ export class EnterpriseSearchPlugin implements Plugin { registerEnginesRoute(dependencies); registerCredentialsRoutes(dependencies); registerWSOverviewRoute(dependencies); + registerWSGroupRoutes(dependencies); /** * Bootstrap the routes, saved objects, and collector for telemetry diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.ts new file mode 100644 index 0000000000000..21d08e5c8756b --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.ts @@ -0,0 +1,256 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +import { IRouteDependencies } from '../../plugin'; + +import { IMeta } from '../../../common/types'; +import { IUser, IContentSource, IGroup } from '../../../common/types/workplace_search'; + +export function registerGroupsRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { + router.get( + { + path: '/api/workplace_search/groups', + validate: false, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/groups', + hasValidData: (body: { users: IUser[]; contentSources: IContentSource[] }) => + typeof Array.isArray(body?.users) && typeof Array.isArray(body?.contentSources), + }) + ); + + router.post( + { + path: '/api/workplace_search/groups', + validate: { + body: schema.object({ + group_name: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/groups', + body: request.body, + hasValidData: (body: { created_at: string }) => typeof body?.created_at === 'string', + })(context, request, response); + } + ); +} + +export function registerSearchGroupsRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { + router.post( + { + path: '/api/workplace_search/groups/search', + validate: { + body: schema.object({ + page: schema.object({ + current: schema.number(), + size: schema.number(), + }), + search: schema.object({ + query: schema.string(), + content_source_ids: schema.arrayOf(schema.string()), + user_ids: schema.arrayOf(schema.string()), + }), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/groups/search', + body: request.body, + hasValidData: (body: { results: IGroup[]; meta: IMeta }) => + typeof Array.isArray(body?.results) && + typeof body?.meta?.page?.total_results === 'number', + })(context, request, response); + } + ); +} + +export function registerGroupRoute({ router, enterpriseSearchRequestHandler }: IRouteDependencies) { + router.get( + { + path: '/api/workplace_search/groups/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}`, + hasValidData: (body: IGroup) => + typeof body?.createdAt === 'string' && typeof body?.usersCount === 'number', + })(context, request, response); + } + ); + + router.put( + { + path: '/api/workplace_search/groups/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + group: schema.object({ + name: schema.string(), + }), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}`, + body: request.body, + hasValidData: (body: IGroup) => + typeof body?.createdAt === 'string' && typeof body?.usersCount === 'number', + })(context, request, response); + } + ); + + router.delete( + { + path: '/api/workplace_search/groups/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}`, + hasValidData: (body: { deleted: boolean }) => body?.deleted === true, + })(context, request, response); + } + ); +} + +export function registerGroupUsersRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { + router.get( + { + path: '/api/workplace_search/groups/{id}/group_users', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}/group_users`, + hasValidData: (body: IUser[]) => typeof Array.isArray(body), + })(context, request, response); + } + ); +} + +export function registerShareGroupRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { + router.post( + { + path: '/api/workplace_search/groups/{id}/share', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + content_source_ids: schema.arrayOf(schema.string()), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}/share`, + body: request.body, + hasValidData: (body: IGroup) => + typeof body?.createdAt === 'string' && typeof body?.usersCount === 'number', + })(context, request, response); + } + ); +} + +export function registerAssignGroupRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { + router.post( + { + path: '/api/workplace_search/groups/{id}/assign', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + user_ids: schema.arrayOf(schema.string()), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}/assign`, + body: request.body, + hasValidData: (body: IGroup) => + typeof body?.createdAt === 'string' && typeof body?.usersCount === 'number', + })(context, request, response); + } + ); +} + +export function registerBoostsGroupRoute({ + router, + enterpriseSearchRequestHandler, +}: IRouteDependencies) { + router.put( + { + path: '/api/workplace_search/groups/{id}/boosts', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + content_source_boosts: schema.arrayOf( + schema.arrayOf(schema.oneOf([schema.string(), schema.number()])) + ), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/groups/${request.params.id}/update_source_boosts`, + body: request.body, + hasValidData: (body: IGroup) => + typeof body?.createdAt === 'string' && typeof body?.usersCount === 'number', + })(context, request, response); + } + ); +} + +export function registerWSGroupRoutes(dependencies: IRouteDependencies) { + registerGroupsRoute(dependencies); + registerSearchGroupsRoute(dependencies); + registerGroupRoute(dependencies); + registerGroupUsersRoute(dependencies); + registerShareGroupRoute(dependencies); + registerAssignGroupRoute(dependencies); + registerBoostsGroupRoute(dependencies); +} diff --git a/x-pack/plugins/global_search/README.md b/x-pack/plugins/global_search/README.md index d47e0bd696fd8..db72f4901c778 100644 --- a/x-pack/plugins/global_search/README.md +++ b/x-pack/plugins/global_search/README.md @@ -39,7 +39,7 @@ Results from providers registered from the client-side `registerResultProvider` not be available when performing a search from the server-side. For this reason, prefer registering providers using the server-side API when possible. -Refer to the [RFC](rfcs/text/0011_global_search.md#result_provider_registration) for more details +Refer to the [RFC](../../../rfcs/text/0011_global_search.md#result_provider_registration) for more details ### Search completion cause diff --git a/x-pack/plugins/global_search/common/constants.ts b/x-pack/plugins/global_search/common/constants.ts new file mode 100644 index 0000000000000..423cf5f8be5a8 --- /dev/null +++ b/x-pack/plugins/global_search/common/constants.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const defaultMaxProviderResults = 40; diff --git a/x-pack/plugins/global_search/public/services/search_service.ts b/x-pack/plugins/global_search/public/services/search_service.ts index 68970b75ad975..62b347d925868 100644 --- a/x-pack/plugins/global_search/public/services/search_service.ts +++ b/x-pack/plugins/global_search/public/services/search_service.ts @@ -12,6 +12,7 @@ import { HttpStart } from 'src/core/public'; import { GlobalSearchProviderResult, GlobalSearchBatchedResults } from '../../common/types'; import { GlobalSearchFindError } from '../../common/errors'; import { takeInArray } from '../../common/operators'; +import { defaultMaxProviderResults } from '../../common/constants'; import { processProviderResult } from '../../common/process_result'; import { ILicenseChecker } from '../../common/license_checker'; import { GlobalSearchResultProvider } from '../types'; @@ -79,7 +80,6 @@ interface StartDeps { licenseChecker: ILicenseChecker; } -const defaultMaxProviderResults = 20; const mapToUndefined = () => undefined; /** @internal */ diff --git a/x-pack/plugins/global_search/server/services/search_service.ts b/x-pack/plugins/global_search/server/services/search_service.ts index d79f3781c6bec..1897a24196cf1 100644 --- a/x-pack/plugins/global_search/server/services/search_service.ts +++ b/x-pack/plugins/global_search/server/services/search_service.ts @@ -11,6 +11,7 @@ import { KibanaRequest, CoreStart, IBasePath } from 'src/core/server'; import { GlobalSearchProviderResult, GlobalSearchBatchedResults } from '../../common/types'; import { GlobalSearchFindError } from '../../common/errors'; import { takeInArray } from '../../common/operators'; +import { defaultMaxProviderResults } from '../../common/constants'; import { ILicenseChecker } from '../../common/license_checker'; import { processProviderResult } from '../../common/process_result'; @@ -80,7 +81,6 @@ interface StartDeps { licenseChecker: ILicenseChecker; } -const defaultMaxProviderResults = 20; const mapToUndefined = () => undefined; /** @internal */ diff --git a/x-pack/plugins/global_search/tsconfig.json b/x-pack/plugins/global_search/tsconfig.json new file mode 100644 index 0000000000000..2d05328f445df --- /dev/null +++ b/x-pack/plugins/global_search/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "public/**/*", + "server/**/*", + "common/**/*", + "../../../typings/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" } + ] +} + diff --git a/x-pack/plugins/global_search_bar/kibana.json b/x-pack/plugins/global_search_bar/kibana.json index 2d4ffa34d346f..bf0ae83a0d863 100644 --- a/x-pack/plugins/global_search_bar/kibana.json +++ b/x-pack/plugins/global_search_bar/kibana.json @@ -5,6 +5,6 @@ "server": false, "ui": true, "requiredPlugins": ["globalSearch"], - "optionalPlugins": [], + "optionalPlugins": ["usageCollection"], "configPath": ["xpack", "global_search_bar"] } diff --git a/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap b/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap index b93e27efccaef..ff9d603bc6375 100644 --- a/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap +++ b/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap @@ -2,81 +2,30 @@ exports[`SearchBar correctly filters and sorts results 1`] = ` Array [ - Object { - "append": undefined, - "className": "euiSelectableTemplateSitewide__listItem", - "key": "Canvas", - "label": "Canvas", - "meta": Array [ - Object { - "text": "Kibana", - }, - ], - "prepend": undefined, - "title": "Canvas • Kibana", - "url": "/app/test/Canvas", - }, - Object { - "append": undefined, - "className": "euiSelectableTemplateSitewide__listItem", - "key": "Discover", - "label": "Discover", - "meta": Array [ - Object { - "text": "Kibana", - }, - ], - "prepend": undefined, - "title": "Discover • Kibana", - "url": "/app/test/Discover", - }, - Object { - "append": undefined, - "className": "euiSelectableTemplateSitewide__listItem", - "key": "Graph", - "label": "Graph", - "meta": Array [ - Object { - "text": "Kibana", - }, - ], - "prepend": undefined, - "title": "Graph • Kibana", - "url": "/app/test/Graph", - }, + "Canvas • Kibana", + "Discover • Kibana", + "Graph • Kibana", ] `; exports[`SearchBar correctly filters and sorts results 2`] = ` Array [ - Object { - "append": undefined, - "className": "euiSelectableTemplateSitewide__listItem", - "key": "Discover", - "label": "Discover", - "meta": Array [ - Object { - "text": "Kibana", - }, - ], - "prepend": undefined, - "title": "Discover • Kibana", - "url": "/app/test/Discover", - }, - Object { - "append": undefined, - "className": "euiSelectableTemplateSitewide__listItem", - "key": "My Dashboard", - "label": "My Dashboard", - "meta": Array [ - Object { - "text": "Test", - }, - ], - "prepend": undefined, - "title": "My Dashboard • Test", - "url": "/app/test/My Dashboard", - }, + "Discover • Kibana", + "My Dashboard • Test", +] +`; + +exports[`SearchBar only display results from the last search 1`] = ` +Array [ + "Visualize • Kibana", + "Map • Kibana", +] +`; + +exports[`SearchBar only display results from the last search 2`] = ` +Array [ + "Visualize • Kibana", + "Map • Kibana", ] `; diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx index 6fad3335c5efc..b669499c63f05 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx @@ -5,17 +5,15 @@ */ import React from 'react'; -import { wait } from '@testing-library/react'; -import { of } from 'rxjs'; +import { waitFor, act } from '@testing-library/react'; +import { ReactWrapper } from 'enzyme'; +import { of, BehaviorSubject } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { httpServiceMock, uiSettingsServiceMock } from '../../../../../src/core/public/mocks'; -import { - GlobalSearchBatchedResults, - GlobalSearchPluginStart, - GlobalSearchResult, -} from '../../../global_search/public'; +import { applicationServiceMock } from '../../../../../src/core/public/mocks'; +import { GlobalSearchBatchedResults, GlobalSearchResult } from '../../../global_search/public'; import { globalSearchPluginMock } from '../../../global_search/public/mocks'; -import { SearchBar } from '../components/search_bar'; +import { SearchBar } from './search_bar'; type Result = { id: string; type: string } | string; @@ -38,30 +36,46 @@ const createBatch = (...results: Result[]): GlobalSearchBatchedResults => ({ results: results.map(createResult), }); -jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ - htmlIdGenerator: () => () => 'mockId', -})); - const getSelectableProps: any = (component: any) => component.find('EuiSelectable').props(); const getSearchProps: any = (component: any) => component.find('EuiFieldSearch').props(); describe('SearchBar', () => { - let searchService: GlobalSearchPluginStart; - let findSpy: jest.SpyInstance; - const http = httpServiceMock.createSetupContract({ basePath: '/test' }); - const basePathUrl = http.basePath.prepend('/plugins/globalSearchBar/assets/'); - const uiSettings = uiSettingsServiceMock.createStartContract(); - const darkMode = uiSettings.get('theme:darkMode'); + let searchService: ReturnType; + let applications: ReturnType; + const basePathUrl = '/plugins/globalSearchBar/assets/'; + const darkMode = false; + + let component: ReactWrapper; beforeEach(() => { + applications = applicationServiceMock.createStartContract(); searchService = globalSearchPluginMock.createStartContract(); - findSpy = jest.spyOn(searchService, 'find'); jest.useFakeTimers(); }); + const triggerFocus = () => { + component.find('input[data-test-subj="header-search"]').simulate('focus'); + }; + + const update = () => { + act(() => { + jest.runAllTimers(); + }); + component.update(); + }; + + const simulateTypeChar = async (text: string) => { + await waitFor(() => + getSearchProps(component).onKeyUpCapture({ currentTarget: { value: text } }) + ); + }; + + const getDisplayedOptionsTitle = () => { + return getSelectableProps(component).options.map((option: any) => option.title); + }; + it('correctly filters and sorts results', async () => { - const navigate = jest.fn(); - findSpy + searchService.find .mockReturnValueOnce( of( createBatch('Discover', 'Canvas'), @@ -70,37 +84,41 @@ describe('SearchBar', () => { ) .mockReturnValueOnce(of(createBatch('Discover', { id: 'My Dashboard', type: 'test' }))); - const component = mountWithIntl( + component = mountWithIntl( ); - expect(findSpy).toHaveBeenCalledTimes(0); - component.find('input[data-test-subj="header-search"]').simulate('focus'); - jest.runAllTimers(); - component.update(); - expect(findSpy).toHaveBeenCalledTimes(1); - expect(findSpy).toHaveBeenCalledWith('', {}); - expect(getSelectableProps(component).options).toMatchSnapshot(); - await wait(() => getSearchProps(component).onKeyUpCapture({ currentTarget: { value: 'd' } })); - jest.runAllTimers(); - component.update(); - expect(getSelectableProps(component).options).toMatchSnapshot(); - expect(findSpy).toHaveBeenCalledTimes(2); - expect(findSpy).toHaveBeenCalledWith('d', {}); + expect(searchService.find).toHaveBeenCalledTimes(0); + + triggerFocus(); + update(); + + expect(searchService.find).toHaveBeenCalledTimes(1); + expect(searchService.find).toHaveBeenCalledWith('', {}); + expect(getDisplayedOptionsTitle()).toMatchSnapshot(); + + await simulateTypeChar('d'); + update(); + + expect(getDisplayedOptionsTitle()).toMatchSnapshot(); + expect(searchService.find).toHaveBeenCalledTimes(2); + expect(searchService.find).toHaveBeenCalledWith('d', {}); }); it('supports keyboard shortcuts', () => { mountWithIntl( ); @@ -113,4 +131,43 @@ describe('SearchBar', () => { expect(document.activeElement).toMatchSnapshot(); }); + + it('only display results from the last search', async () => { + const firstSearchTrigger = new BehaviorSubject(false); + const firstSearch = firstSearchTrigger.pipe( + filter((event) => event), + map(() => { + return createBatch('Discover', 'Canvas'); + }) + ); + const secondSearch = of(createBatch('Visualize', 'Map')); + + searchService.find.mockReturnValueOnce(firstSearch).mockReturnValueOnce(secondSearch); + + component = mountWithIntl( + + ); + + triggerFocus(); + update(); + + expect(searchService.find).toHaveBeenCalledTimes(1); + + await simulateTypeChar('d'); + update(); + + expect(getDisplayedOptionsTitle()).toMatchSnapshot(); + + firstSearchTrigger.next(true); + + update(); + + expect(getDisplayedOptionsTitle()).toMatchSnapshot(); + }); }); diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx index 4ca0f8cf81b7b..e73f9d954d5ad 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx @@ -8,26 +8,29 @@ import { EuiBadge, EuiFlexGroup, EuiFlexItem, - EuiSelectableTemplateSitewide, - EuiSelectableTemplateSitewideOption, - EuiText, + EuiHeaderSectionItemButton, EuiIcon, EuiImage, - EuiHeaderSectionItemButton, EuiSelectableMessage, + EuiSelectableTemplateSitewide, + EuiSelectableTemplateSitewideOption, + EuiText, } from '@elastic/eui'; +import { METRIC_TYPE, UiStatsMetricType } from '@kbn/analytics'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ApplicationStart } from 'kibana/public'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; import useEvent from 'react-use/lib/useEvent'; import useMountedState from 'react-use/lib/useMountedState'; +import { Subscription } from 'rxjs'; import { GlobalSearchPluginStart, GlobalSearchResult } from '../../../global_search/public'; interface Props { globalSearch: GlobalSearchPluginStart['find']; navigateToUrl: ApplicationStart['navigateToUrl']; + trackUiMetric: (metricType: UiStatsMetricType, eventName: string | string[]) => void; basePathUrl: string; darkMode: boolean; } @@ -45,48 +48,82 @@ const clearField = (field: HTMLInputElement) => { const cleanMeta = (str: string) => (str.charAt(0).toUpperCase() + str.slice(1)).replace(/-/g, ' '); const blurEvent = new FocusEvent('blur'); -export function SearchBar({ globalSearch, navigateToUrl, basePathUrl, darkMode }: Props) { +const sortByScore = (a: GlobalSearchResult, b: GlobalSearchResult): number => { + if (a.score < b.score) return 1; + if (a.score > b.score) return -1; + return 0; +}; + +const sortByTitle = (a: GlobalSearchResult, b: GlobalSearchResult): number => { + const titleA = a.title.toUpperCase(); // ignore upper and lowercase + const titleB = b.title.toUpperCase(); // ignore upper and lowercase + if (titleA < titleB) return -1; + if (titleA > titleB) return 1; + return 0; +}; + +const resultToOption = (result: GlobalSearchResult): EuiSelectableTemplateSitewideOption => { + const { id, title, url, icon, type, meta } = result; + const option: EuiSelectableTemplateSitewideOption = { + key: id, + label: title, + url, + type, + }; + + if (icon) { + option.icon = { type: icon }; + } + + if (type === 'application') { + option.meta = [{ text: meta?.categoryLabel as string }]; + } else { + option.meta = [{ text: cleanMeta(type) }]; + } + + return option; +}; + +export function SearchBar({ + globalSearch, + navigateToUrl, + trackUiMetric, + basePathUrl, + darkMode, +}: Props) { const isMounted = useMountedState(); const [searchValue, setSearchValue] = useState(''); const [searchRef, setSearchRef] = useState(null); + const [buttonRef, setButtonRef] = useState(null); + const searchSubscription = useRef(null); const [options, _setOptions] = useState([] as EuiSelectableTemplateSitewideOption[]); const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; const setOptions = useCallback( (_options: GlobalSearchResult[]) => { - if (!isMounted()) return; - - _setOptions([ - ..._options.map(({ id, title, url, icon, type, meta }) => { - const option: EuiSelectableTemplateSitewideOption = { - key: id, - label: title, - url, - }; - - if (icon) option.icon = { type: icon }; - - if (type === 'application') option.meta = [{ text: meta?.categoryLabel as string }]; - else option.meta = [{ text: cleanMeta(type) }]; + if (!isMounted()) { + return; + } - return option; - }), - ]); + _setOptions(_options.map(resultToOption)); }, [isMounted, _setOptions] ); useDebounce( () => { + // cancel pending search if not completed yet + if (searchSubscription.current) { + searchSubscription.current.unsubscribe(); + searchSubscription.current = null; + } + let arr: GlobalSearchResult[] = []; - globalSearch(searchValue, {}).subscribe({ + if (searchValue.length !== 0) trackUiMetric(METRIC_TYPE.COUNT, 'search_request'); + searchSubscription.current = globalSearch(searchValue, {}).subscribe({ next: ({ results }) => { if (searchValue.length > 0) { - arr = [...results, ...arr].sort((a, b) => { - if (a.score < b.score) return 1; - if (a.score > b.score) return -1; - return 0; - }); + arr = [...results, ...arr].sort(sortByScore); setOptions(arr); return; } @@ -94,20 +131,14 @@ export function SearchBar({ globalSearch, navigateToUrl, basePathUrl, darkMode } // if searchbar is empty, filter to only applications and sort alphabetically results = results.filter(({ type }: GlobalSearchResult) => type === 'application'); - arr = [...results, ...arr].sort((a, b) => { - const titleA = a.title.toUpperCase(); // ignore upper and lowercase - const titleB = b.title.toUpperCase(); // ignore upper and lowercase - if (titleA < titleB) return -1; - if (titleA > titleB) return 1; - return 0; - }); + arr = [...results, ...arr].sort(sortByTitle); setOptions(arr); }, error: () => { - // TODO #74430 - add telemetry to see if errors are happening // Not doing anything on error right now because it'll either just show the previous // results or empty results which is basically what we want anyways + trackUiMetric(METRIC_TYPE.COUNT, 'unhandled_error'); }, complete: () => {}, }); @@ -118,16 +149,31 @@ export function SearchBar({ globalSearch, navigateToUrl, basePathUrl, darkMode } const onKeyDown = (event: KeyboardEvent) => { if (event.key === '/' && (isMac ? event.metaKey : event.ctrlKey)) { + event.preventDefault(); + trackUiMetric(METRIC_TYPE.COUNT, 'shortcut_used'); if (searchRef) { - event.preventDefault(); searchRef.focus(); + } else if (buttonRef) { + (buttonRef.children[0] as HTMLButtonElement).click(); } } }; const onChange = (selected: EuiSelectableTemplateSitewideOption[]) => { // @ts-ignore - ts error is "union type is too complex to express" - const { url } = selected.find(({ checked }) => checked === 'on'); + const { url, type, key } = selected.find(({ checked }) => checked === 'on'); + + if (type === 'application') { + trackUiMetric(METRIC_TYPE.CLICK, [ + 'user_navigated_to_application', + `user_navigated_to_application_${key.toLowerCase().replaceAll(' ', '_')}`, // which application + ]); + } else { + trackUiMetric(METRIC_TYPE.CLICK, [ + 'user_navigated_to_saved_object', + `user_navigated_to_saved_object_${type}`, // which type of saved object + ]); + } navigateToUrl(url); (document.activeElement as HTMLElement).blur(); @@ -191,9 +237,13 @@ export function SearchBar({ globalSearch, navigateToUrl, basePathUrl, darkMode } placeholder: i18n.translate('xpack.globalSearchBar.searchBar.placeholder', { defaultMessage: 'Search Elastic', }), + onFocus: () => { + trackUiMetric(METRIC_TYPE.COUNT, 'search_focus'); + }, }} popoverProps={{ repositionOnScroll: true, + buttonRef: setButtonRef, }} emptyMessage={emptyMessage} noMatchesMessage={emptyMessage} diff --git a/x-pack/plugins/global_search_bar/public/plugin.tsx b/x-pack/plugins/global_search_bar/public/plugin.tsx index 9bc6b824b8716..14ac0935467d7 100644 --- a/x-pack/plugins/global_search_bar/public/plugin.tsx +++ b/x-pack/plugins/global_search_bar/public/plugin.tsx @@ -4,16 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreStart, Plugin } from 'src/core/public'; -import React from 'react'; +import { UiStatsMetricType } from '@kbn/analytics'; import { I18nProvider } from '@kbn/i18n/react'; -import ReactDOM from 'react-dom'; import { ApplicationStart } from 'kibana/public'; -import { SearchBar } from '../public/components/search_bar'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { CoreStart, Plugin } from 'src/core/public'; +import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; import { GlobalSearchPluginStart } from '../../global_search/public'; +import { SearchBar } from '../public/components/search_bar'; export interface GlobalSearchBarPluginStartDeps { globalSearch: GlobalSearchPluginStart; + usageCollection: UsageCollectionSetup; } export class GlobalSearchBarPlugin implements Plugin<{}, {}> { @@ -21,7 +24,13 @@ export class GlobalSearchBarPlugin implements Plugin<{}, {}> { return {}; } - public start(core: CoreStart, { globalSearch }: GlobalSearchBarPluginStartDeps) { + public start(core: CoreStart, { globalSearch, usageCollection }: GlobalSearchBarPluginStartDeps) { + let trackUiMetric = (metricType: UiStatsMetricType, eventName: string | string[]) => {}; + + if (usageCollection) { + trackUiMetric = usageCollection.reportUiStats.bind(usageCollection, 'global_search_bar'); + } + core.chrome.navControls.registerCenter({ order: 1000, mount: (target) => @@ -30,7 +39,8 @@ export class GlobalSearchBarPlugin implements Plugin<{}, {}> { globalSearch, core.application.navigateToUrl, core.http.basePath.prepend('/plugins/globalSearchBar/assets/'), - core.uiSettings.get('theme:darkMode') + core.uiSettings.get('theme:darkMode'), + trackUiMetric ), }); return {}; @@ -41,7 +51,8 @@ export class GlobalSearchBarPlugin implements Plugin<{}, {}> { globalSearch: GlobalSearchPluginStart, navigateToUrl: ApplicationStart['navigateToUrl'], basePathUrl: string, - darkMode: boolean + darkMode: boolean, + trackUiMetric: (metricType: UiStatsMetricType, eventName: string | string[]) => void ) { ReactDOM.render( @@ -50,6 +61,7 @@ export class GlobalSearchBarPlugin implements Plugin<{}, {}> { navigateToUrl={navigateToUrl} basePathUrl={basePathUrl} darkMode={darkMode} + trackUiMetric={trackUiMetric} /> , targetDomElement diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts index 352191658ed0d..b556e2785b4b4 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts @@ -5,6 +5,7 @@ */ import { EMPTY } from 'rxjs'; +import { toArray } from 'rxjs/operators'; import { TestScheduler } from 'rxjs/testing'; import { SavedObjectsFindResponse, @@ -114,8 +115,8 @@ describe('savedObjectsResultProvider', () => { expect(provider.id).toBe('savedObjects'); }); - it('calls `savedObjectClient.find` with the correct parameters', () => { - provider.find('term', defaultOption, context); + it('calls `savedObjectClient.find` with the correct parameters', async () => { + await provider.find('term', defaultOption, context).toPromise(); expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ @@ -128,6 +129,13 @@ describe('savedObjectsResultProvider', () => { }); }); + it('does not call `savedObjectClient.find` if `term` is empty', async () => { + const results = await provider.find('', defaultOption, context).pipe(toArray()).toPromise(); + + expect(context.core.savedObjects.client.find).not.toHaveBeenCalled(); + expect(results).toEqual([[]]); + }); + it('converts the saved objects to results', async () => { context.core.savedObjects.client.find.mockResolvedValue( createFindResponse([ diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts index 1c79380fe17fd..3861858a53626 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { from, combineLatest } from 'rxjs'; +import { from, combineLatest, of } from 'rxjs'; import { map, takeUntil, first } from 'rxjs/operators'; import { GlobalSearchResultProvider } from '../../../../global_search/server'; import { mapToResults } from './map_object_to_result'; @@ -13,6 +13,10 @@ export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider = return { id: 'savedObjects', find: (term, { aborted$, maxResults, preference }, { core }) => { + if (!term) { + return of([]); + } + const { capabilities, savedObjects: { client, typeRegistry }, diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts index 06f57896d4900..37df55f784252 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts @@ -127,7 +127,7 @@ describe('Index Templates tab', () => { const indexTemplate = templates[i]; const { name, indexPatterns, ilmPolicy, composedOf, template } = indexTemplate; - const hasContent = !!template.settings || !!template.mappings || !!template.aliases; + const hasContent = !!template?.settings || !!template?.mappings || !!template?.aliases; const ilmPolicyName = ilmPolicy && ilmPolicy.name ? ilmPolicy.name : ''; const composedOfString = composedOf ? composedOf.join(',') : ''; @@ -152,7 +152,7 @@ describe('Index Templates tab', () => { const legacyIndexTemplate = legacyTemplates[i]; const { name, indexPatterns, ilmPolicy, template } = legacyIndexTemplate; - const hasContent = !!template.settings || !!template.mappings || !!template.aliases; + const hasContent = !!template?.settings || !!template?.mappings || !!template?.aliases; const ilmPolicyName = ilmPolicy && ilmPolicy.name ? ilmPolicy.name : ''; try { diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx index 8b74e9fb0cdf8..f0c0128fd6463 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx @@ -112,7 +112,7 @@ describe('', () => { name: `${templateToClone.name}-copy`, indexPatterns: DEFAULT_INDEX_PATTERNS, }; - // @ts-expect-error + delete expected.template; // As no settings, mappings or aliases have been defined, no "template" param is sent expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); diff --git a/x-pack/plugins/index_management/common/lib/template_serialization.ts b/x-pack/plugins/index_management/common/lib/template_serialization.ts index 1803d89a40016..bb7604f06c302 100644 --- a/x-pack/plugins/index_management/common/lib/template_serialization.ts +++ b/x-pack/plugins/index_management/common/lib/template_serialization.ts @@ -85,7 +85,7 @@ export function deserializeTemplateList( ): TemplateListItem[] { return indexTemplates.map(({ name, index_template: templateSerialized }) => { const { - template: { mappings, settings, aliases }, + template: { mappings, settings, aliases } = {}, ...deserializedTemplate } = deserializeTemplate({ name, ...templateSerialized }, cloudManagedTemplatePrefix); @@ -149,7 +149,7 @@ export function deserializeLegacyTemplateList( ): TemplateListItem[] { return Object.entries(indexTemplatesByName).map(([name, templateSerialized]) => { const { - template: { mappings, settings, aliases }, + template: { mappings, settings, aliases } = {}, ...deserializedTemplate } = deserializeLegacyTemplate({ name, ...templateSerialized }, cloudManagedTemplatePrefix); diff --git a/x-pack/plugins/index_management/common/types/templates.ts b/x-pack/plugins/index_management/common/types/templates.ts index eda00ec819159..d1b51fe5b89bf 100644 --- a/x-pack/plugins/index_management/common/types/templates.ts +++ b/x-pack/plugins/index_management/common/types/templates.ts @@ -13,7 +13,7 @@ import { Mappings } from './mappings'; */ export interface TemplateSerialized { index_patterns: string[]; - template: { + template?: { settings?: IndexSettings; aliases?: Aliases; mappings?: Mappings; @@ -33,7 +33,7 @@ export interface TemplateSerialized { export interface TemplateDeserialized { name: string; indexPatterns: string[]; - template: { + template?: { settings?: IndexSettings; aliases?: Aliases; mappings?: Mappings; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/other_datatype.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/other_datatype.test.tsx new file mode 100644 index 0000000000000..c1474b0ec6781 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/other_datatype.test.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { act } from 'react-dom/test-utils'; + +import { componentHelpers, MappingsEditorTestBed } from '../helpers'; + +const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor; + +describe('Mappings editor: other datatype', () => { + /** + * Variable to store the mappings data forwarded to the consumer component + */ + let data: any; + let onChangeHandler: jest.Mock = jest.fn(); + let getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + let testBed: MappingsEditorTestBed; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + onChangeHandler = jest.fn(); + getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + }); + + test('allow to add custom field type', async () => { + await act(async () => { + testBed = setup({ onChange: onChangeHandler }); + }); + testBed.component.update(); + + const { + component, + actions: { addField }, + } = testBed; + + await addField('myField', 'other', 'customType'); + + const mappings = { + properties: { + myField: { + type: 'customType', + }, + }, + }; + + ({ data } = await getMappingsEditorData(component)); + expect(data).toEqual(mappings); + }); + + test('allow to change a field type to a custom type', async () => { + const defaultMappings = { + properties: { + myField: { + type: 'text', + }, + }, + }; + + let updatedMappings = { ...defaultMappings }; + + await act(async () => { + testBed = setup({ value: defaultMappings, onChange: onChangeHandler }); + }); + testBed.component.update(); + + const { + component, + find, + form, + actions: { startEditField, updateFieldAndCloseFlyout }, + } = testBed; + + // Open the flyout to edit the field + await startEditField('myField'); + + // Change the field type + await act(async () => { + find('mappingsEditorFieldEdit.fieldType').simulate('change', [ + { + label: 'other', + value: 'other', + }, + ]); + }); + component.update(); + + form.setInputValue('mappingsEditorFieldEdit.fieldSubType', 'customType'); + + // Save the field and close the flyout + await updateFieldAndCloseFlyout(); + + updatedMappings = { + properties: { + myField: { + type: 'customType', + }, + }, + }; + + ({ data } = await getMappingsEditorData(component)); + expect(data).toEqual(updatedMappings); + }); +}); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx index e123dea6ff2ff..2eb56a97dc3a0 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx @@ -149,7 +149,7 @@ const createActions = (testBed: TestBed) => { return { field: find(testSubject as TestSubjects), testSubject }; }; - const addField = async (name: string, type: string) => { + const addField = async (name: string, type: string, subType?: string) => { await act(async () => { form.setInputValue('nameParameterInput', name); find('createFieldForm.fieldType').simulate('change', [ @@ -160,6 +160,17 @@ const createActions = (testBed: TestBed) => { ]); }); + component.update(); + + if (subType !== undefined) { + await act(async () => { + if (type === 'other') { + // subType is a text input + form.setInputValue('createFieldForm.fieldSubType', subType); + } + }); + } + await act(async () => { find('createFieldForm.addButton').simulate('click'); }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx index 7ec78646a654e..2b56a0e68c46b 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useRef } from 'react'; import { EuiSpacer } from '@elastic/eui'; -import { useForm, Form, SerializerFunc } from '../../shared_imports'; +import { useForm, Form } from '../../shared_imports'; import { GenericObject, MappingsConfiguration } from '../../types'; import { useDispatch } from '../../mappings_state_context'; import { DynamicMappingSection } from './dynamic_mapping_section'; @@ -19,7 +19,7 @@ interface Props { value?: MappingsConfiguration; } -const formSerializer: SerializerFunc = (formData) => { +const formSerializer = (formData: GenericObject) => { const { dynamicMapping: { enabled: dynamicMappingsEnabled, @@ -88,7 +88,7 @@ const formDeserializer = (formData: GenericObject) => { export const ConfigurationForm = React.memo(({ value }: Props) => { const isMounted = useRef(false); - const { form } = useForm({ + const { form } = useForm({ schema: configurationFormSchema, serializer: formSerializer, deserializer: formDeserializer, @@ -108,7 +108,7 @@ export const ConfigurationForm = React.memo(({ value }: Props) => { validate, submitForm: submit, }, - }); + } as any); }); return subscription.unsubscribe; @@ -130,7 +130,7 @@ export const ConfigurationForm = React.memo(({ value }: Props) => { // Save a snapshot of the form state so we can get back to it when navigating back to the tab const configurationData = getFormData(); - dispatch({ type: 'configuration.save', value: configurationData }); + dispatch({ type: 'configuration.save', value: configurationData as any }); }; }, [getFormData, dispatch]); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx index 8742dfc916924..2ab594d9d57fb 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx @@ -11,7 +11,7 @@ import { EuiLink, EuiCode } from '@elastic/eui'; import { documentationService } from '../../../../services/documentation'; import { FormSchema, FIELD_TYPES, VALIDATION_TYPES, fieldValidators } from '../../shared_imports'; -import { ComboBoxOption, MappingsConfiguration } from '../../types'; +import { ComboBoxOption } from '../../types'; const { containsCharsField, isJsonField } = fieldValidators; @@ -28,7 +28,7 @@ const fieldPathComboBoxConfig = { deserializer: (values: string[]): ComboBoxOption[] => values.map((value) => ({ label: value })), }; -export const configurationFormSchema: FormSchema = { +export const configurationFormSchema: FormSchema = { metaField: { label: i18n.translate('xpack.idxMgmt.mappingsEditor.configuration.metaFieldEditorLabel', { defaultMessage: '_meta field data', diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_frequency_filter_absolute.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_frequency_filter_absolute.tsx index b446f9dae46bf..8a3bf5810459e 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_frequency_filter_absolute.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_frequency_filter_absolute.tsx @@ -16,8 +16,8 @@ import { import { FieldHook } from '../../../shared_imports'; interface Props { - min: FieldHook; - max: FieldHook; + min: FieldHook; + max: FieldHook; } export const FielddataFrequencyFilterAbsolute = ({ min, max }: Props) => { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_frequency_filter_percentage.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_frequency_filter_percentage.tsx index 97edba8179180..d1f810b1e8e13 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_frequency_filter_percentage.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_frequency_filter_percentage.tsx @@ -10,8 +10,8 @@ import { EuiDualRange, EuiFormRow } from '@elastic/eui'; import { FieldHook } from '../../../shared_imports'; interface Props { - min: FieldHook; - max: FieldHook; + min: FieldHook; + max: FieldHook; } export const FielddataFrequencyFilterPercentage = ({ min, max }: Props) => { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_parameter.tsx index df49282b51e74..358f01833e9fe 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/fielddata_parameter.tsx @@ -34,6 +34,11 @@ interface Props { type ValueType = 'percentage' | 'absolute'; +interface FieldsType { + min: number; + max: number; +} + export const FieldDataParameter = ({ field, defaultToggleValue }: Props) => { const [valueType, setValueType] = useState( field.source.fielddata_frequency_filter !== undefined @@ -43,21 +48,22 @@ export const FieldDataParameter = ({ field, defaultToggleValue }: Props) => { : 'percentage' ); - const getConfig = (fieldProp: 'min' | 'max', type = valueType) => - type === 'percentage' - ? getFieldConfig('fielddata_frequency_filter_percentage', fieldProp) - : getFieldConfig('fielddata_frequency_filter_absolute', fieldProp); + function getConfig(fieldProp: 'min' | 'max', type = valueType) { + return type === 'percentage' + ? getFieldConfig('fielddata_frequency_filter_percentage', fieldProp) + : getFieldConfig('fielddata_frequency_filter_absolute', fieldProp); + } - const switchType = (min: FieldHook, max: FieldHook) => () => { + const switchType = (min: FieldHook, max: FieldHook) => () => { const nextValueType = valueType === 'percentage' ? 'absolute' : 'percentage'; - const nextMinConfig = getConfig('min', nextValueType); - const nextMaxConfig = getConfig('max', nextValueType); + const nextMinConfig = getConfig('min', nextValueType); + const nextMaxConfig = getConfig('max', nextValueType); min.setValue( - nextMinConfig.deserializer?.(nextMinConfig.defaultValue) ?? nextMinConfig.defaultValue + nextMinConfig.deserializer?.(nextMinConfig.defaultValue!) ?? nextMinConfig.defaultValue! ); max.setValue( - nextMaxConfig.deserializer?.(nextMaxConfig.defaultValue) ?? nextMaxConfig.defaultValue + nextMaxConfig.deserializer?.(nextMaxConfig.defaultValue!) ?? nextMaxConfig.defaultValue! ); setValueType(nextValueType); @@ -85,15 +91,15 @@ export const FieldDataParameter = ({ field, defaultToggleValue }: Props) => { defaultToggleValue={defaultToggleValue} > {/* fielddata_frequency_filter */} - fields={{ min: { path: 'fielddata_frequency_filter.min', - config: getConfig('min'), + config: getConfig('min'), }, max: { path: 'fielddata_frequency_filter.max', - config: getConfig('max'), + config: getConfig('max'), }, }} > diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/other_type_json_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/other_type_json_parameter.tsx index 64e50f711a249..32a9926b2d925 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/other_type_json_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/other_type_json_parameter.tsx @@ -23,7 +23,7 @@ const { isJsonField } = fieldValidators; * We use it to store custom defined parameters in a field called "otherTypeJson". */ -const fieldConfig: FieldConfig = { +const fieldConfig: FieldConfig = { label: i18n.translate('xpack.idxMgmt.mappingsEditor.otherTypeJsonFieldLabel', { defaultMessage: 'Type Parameters JSON', }), diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/other_type_name_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/other_type_name_parameter.tsx index 6004e484323a1..8043a0deaf8da 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/other_type_name_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/other_type_name_parameter.tsx @@ -4,13 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; -import { UseField, TextField, FieldConfig } from '../../../shared_imports'; -import { fieldValidators } from '../../../shared_imports'; - -const { emptyField } = fieldValidators; +import { UseField, TextField, FieldConfig, FieldHook } from '../../../shared_imports'; +import { getFieldConfig } from '../../../lib'; /** * This is a special component that does not have an explicit entry in {@link PARAMETERS_DEFINITION}. @@ -18,25 +16,69 @@ const { emptyField } = fieldValidators; * We use it to store the name of types unknown to the mappings editor in the "subType" path. */ +type FieldType = [{ value: string }]; + +const typeParameterConfig = getFieldConfig('type'); + const fieldConfig: FieldConfig = { label: i18n.translate('xpack.idxMgmt.mappingsEditor.otherTypeNameFieldLabel', { defaultMessage: 'Type Name', }), defaultValue: '', + deserializer: typeParameterConfig.deserializer, + serializer: typeParameterConfig.serializer, validations: [ { - validator: emptyField( - i18n.translate( - 'xpack.idxMgmt.mappingsEditor.parameters.validations.otherTypeNameIsRequiredErrorMessage', - { - defaultMessage: 'The type name is required.', - } - ) - ), + validator: ({ value: fieldValue }) => { + if ((fieldValue as FieldType)[0].value.trim() === '') { + return { + message: i18n.translate( + 'xpack.idxMgmt.mappingsEditor.parameters.validations.otherTypeNameIsRequiredErrorMessage', + { + defaultMessage: 'The type name is required.', + } + ), + }; + } + }, }, ], }; +interface Props { + field: FieldHook; +} + +/** + * The "subType" parameter can be configured either with a ComboBox (when the type is known) + * or with a TextField (when the type is unknown). This causes its value to have different type + * (either an array of object either a string). In order to align both value and let the consumer of + * the value worry about a single type, we will create a custom TextField component that works with the + * array of object that the ComboBox works with. + */ +const CustomTextField = ({ field }: Props) => { + const { setValue } = field; + + const transformedField: FieldHook = { + ...field, + value: field.value[0]?.value ?? '', + }; + + const onChange = useCallback( + (e: React.ChangeEvent) => { + setValue([{ value: e.target.value }]); + }, + [setValue] + ); + + return ( + + ); +}; + export const OtherTypeNameParameter = () => ( - + ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx index 95575124b6abd..faa0c8c9dc792 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx @@ -91,8 +91,8 @@ export const EditField = React.memo(({ form, field, allFields, exitEdit, updateF {({ type, subType }) => { const linkDocumentation = - documentationService.getTypeDocLink(subType?.[0].value) || - documentationService.getTypeDocLink(type?.[0].value); + documentationService.getTypeDocLink(subType?.[0]?.value) || + documentationService.getTypeDocLink(type?.[0]?.value); if (!linkDocumentation) { return null; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx index c0e68b082c310..ce349b2c6104f 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx @@ -93,7 +93,7 @@ export const EditFieldFormRow = React.memo( showLabel={false} /> ) : ( - path={formFieldPath} config={{ ...getFieldConfig(configPath ? configPath : formFieldPath), diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form_schema.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form_schema.ts index daca85f95b0b9..08ccd27d5bca3 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form_schema.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form_schema.ts @@ -7,11 +7,10 @@ import { i18n } from '@kbn/i18n'; import { FormSchema, fieldValidators } from '../../shared_imports'; -import { MappingsTemplates } from '../../types'; const { isJsonField } = fieldValidators; -export const templatesFormSchema: FormSchema = { +export const templatesFormSchema: FormSchema<{ dynamicTemplates: any[] }> = { dynamicTemplates: { label: i18n.translate('xpack.idxMgmt.mappingsEditor.templates.dynamicTemplatesEditorLabel', { defaultMessage: 'Dynamic templates data', diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx index 4c9786d88bfa2..1434b7d4b4429 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx @@ -168,7 +168,7 @@ export const PARAMETERS_DEFINITION: { [key in ParameterName]: ParameterDefinitio }, ]; } - return []; + return [{ value: '' }]; }, serializer: (fieldType: ComboBoxOption[] | undefined) => fieldType && fieldType.length ? fieldType[0].value : fieldType, @@ -273,15 +273,15 @@ export const PARAMETERS_DEFINITION: { [key in ParameterName]: ParameterDefinitio min: { fieldConfig: { defaultValue: 0.01, - serializer: (value) => (value === '' ? '' : toInt(value) / 100), - deserializer: (value) => Math.round(value * 100), + serializer: (value: string) => (value === '' ? '' : toInt(value) / 100), + deserializer: (value: number) => Math.round(value * 100), } as FieldConfig, }, max: { fieldConfig: { defaultValue: 1, - serializer: (value) => (value === '' ? '' : toInt(value) / 100), - deserializer: (value) => Math.round(value * 100), + serializer: (value: string) => (value === '' ? '' : toInt(value) / 100), + deserializer: (value: number) => Math.round(value * 100), } as FieldConfig, }, }, @@ -949,8 +949,8 @@ export const PARAMETERS_DEFINITION: { [key in ParameterName]: ParameterDefinitio ), }, ], - serializer: (value: AliasOption[]) => (value.length === 0 ? '' : value[0].id), - } as FieldConfig, + serializer: (value) => (value.length === 0 ? '' : value[0].id), + } as FieldConfig, targetTypesNotAllowed: ['object', 'nested', 'alias'] as DataType[], schema: t.string, }, @@ -991,14 +991,14 @@ export const PARAMETERS_DEFINITION: { [key in ParameterName]: ParameterDefinitio fieldConfig: { type: FIELD_TYPES.NUMBER, defaultValue: 2, - serializer: (value) => (value === '' ? '' : toInt(value)), + serializer: (value: string) => (value === '' ? '' : toInt(value)), } as FieldConfig, }, max_chars: { fieldConfig: { type: FIELD_TYPES.NUMBER, defaultValue: 5, - serializer: (value) => (value === '' ? '' : toInt(value)), + serializer: (value: string) => (value === '' ? '' : toInt(value)), } as FieldConfig, }, }, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts index d96f20683216a..fd7aa41638505 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts @@ -92,7 +92,7 @@ export const getTypeLabelFromField = (field: Field) => { export const getFieldConfig = ( param: ParameterName, prop?: string -): FieldConfig => { +): FieldConfig => { if (prop !== undefined) { if ( !(PARAMETERS_DEFINITION[param] as any).props || diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts index 926b4c9d12bee..ee4dd55a5801f 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts @@ -25,7 +25,7 @@ export interface DataTypeDefinition { export interface ParameterDefinition { title?: string; description?: JSX.Element | string; - fieldConfig: FieldConfig; + fieldConfig: FieldConfig; schema?: any; props?: { [key: string]: ParameterDefinition }; documentation?: { diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx index 3a03835e85970..8e84abb5ce495 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx @@ -117,7 +117,7 @@ export const TemplateForm = ({ }; const { - template: { settings, mappings, aliases }, + template: { settings, mappings, aliases } = {}, composedOf, _kbnMeta, ...logistics @@ -170,18 +170,19 @@ export const TemplateForm = ({ const cleanupTemplateObject = (template: TemplateDeserialized) => { const outputTemplate = { ...template }; - if (outputTemplate.template.settings === undefined) { - delete outputTemplate.template.settings; - } - if (outputTemplate.template.mappings === undefined) { - delete outputTemplate.template.mappings; - } - if (outputTemplate.template.aliases === undefined) { - delete outputTemplate.template.aliases; - } - if (Object.keys(outputTemplate.template).length === 0) { - // @ts-expect-error - delete outputTemplate.template; + if (outputTemplate.template) { + if (outputTemplate.template.settings === undefined) { + delete outputTemplate.template.settings; + } + if (outputTemplate.template.mappings === undefined) { + delete outputTemplate.template.mappings; + } + if (outputTemplate.template.aliases === undefined) { + delete outputTemplate.template.aliases; + } + if (Object.keys(outputTemplate.template).length === 0) { + delete outputTemplate.template; + } } return outputTemplate; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx index 94891297c857e..48083f324de3d 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx @@ -161,9 +161,7 @@ export const TemplateDetailsContent = ({ } if (templateDetails) { - const { - template: { settings, mappings, aliases }, - } = templateDetails; + const { template: { settings, mappings, aliases } = {} } = templateDetails; const tabToComponentMap: Record = { [SUMMARY_TAB_ID]: , diff --git a/x-pack/plugins/infra/kibana.json b/x-pack/plugins/infra/kibana.json index 08ffe520e53d5..e84767f4931ca 100644 --- a/x-pack/plugins/infra/kibana.json +++ b/x-pack/plugins/infra/kibana.json @@ -11,7 +11,7 @@ "dataEnhanced", "visTypeTimeseries", "alerts", - "triggers_actions_ui" + "triggersActionsUi" ], "optionalPlugins": ["ml", "observability", "home"], "server": true, diff --git a/x-pack/plugins/infra/public/apps/logs_app.tsx b/x-pack/plugins/infra/public/apps/logs_app.tsx index 528d90b2a3a23..b6b171fcb4727 100644 --- a/x-pack/plugins/infra/public/apps/logs_app.tsx +++ b/x-pack/plugins/infra/public/apps/logs_app.tsx @@ -51,7 +51,7 @@ const LogsApp: React.FC<{ diff --git a/x-pack/plugins/infra/public/apps/metrics_app.tsx b/x-pack/plugins/infra/public/apps/metrics_app.tsx index 3069490466938..d91c64de933e6 100644 --- a/x-pack/plugins/infra/public/apps/metrics_app.tsx +++ b/x-pack/plugins/infra/public/apps/metrics_app.tsx @@ -53,7 +53,7 @@ const MetricsApp: React.FC<{ diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index 0e49ca93010fd..2bbd0067642c0 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -28,9 +28,9 @@ export class Plugin implements InfraClientPluginClass { registerFeatures(pluginsSetup.home); } - pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(createInventoryMetricAlertType()); - pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getLogsAlertType()); - pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(createMetricThresholdAlertType()); + pluginsSetup.triggersActionsUi.alertTypeRegistry.register(createInventoryMetricAlertType()); + pluginsSetup.triggersActionsUi.alertTypeRegistry.register(getLogsAlertType()); + pluginsSetup.triggersActionsUi.alertTypeRegistry.register(createMetricThresholdAlertType()); if (pluginsSetup.observability) { pluginsSetup.observability.dashboard.register({ diff --git a/x-pack/plugins/infra/public/types.ts b/x-pack/plugins/infra/public/types.ts index 20a276dbb4f41..6ff699066eb15 100644 --- a/x-pack/plugins/infra/public/types.ts +++ b/x-pack/plugins/infra/public/types.ts @@ -27,7 +27,7 @@ export interface InfraClientSetupDeps { dataEnhanced: DataEnhancedSetup; home?: HomePublicPluginSetup; observability: ObservabilityPluginSetup; - triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; + triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; usageCollection: UsageCollectionSetup; } @@ -36,7 +36,7 @@ export interface InfraClientStartDeps { dataEnhanced: DataEnhancedStart; observability: ObservabilityPluginStart; spaces: SpacesPluginStart; - triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; + triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; usageCollection: UsageCollectionStart; } diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts index 0ea65f94c9400..d3d34cd2aad58 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts @@ -340,9 +340,12 @@ type AlertInstanceUpdater = ( export const updateAlertInstance: AlertInstanceUpdater = (alertInstance, state, actions) => { if (actions && actions.length > 0) { + const sharedContext = { + timestamp: new Date().toISOString(), + }; actions.forEach((actionSet) => { const { actionGroup, context } = actionSet; - alertInstance.scheduleActions(actionGroup, context); + alertInstance.scheduleActions(actionGroup, { ...sharedContext, ...context }); }); } diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts index 2c1d7e0976607..34afddc2a4d48 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts @@ -13,6 +13,13 @@ import { import { InfraBackendLibs } from '../../infra_types'; import { decodeOrThrow } from '../../../../common/runtime_types'; +const timestampActionVariableDescription = i18n.translate( + 'xpack.infra.logs.alerting.threshold.timestampActionVariableDescription', + { + defaultMessage: 'UTC timestamp of when the alert was triggered', + } +); + const documentCountActionVariableDescription = i18n.translate( 'xpack.infra.logs.alerting.threshold.documentCountActionVariableDescription', { @@ -85,6 +92,7 @@ export async function registerLogThresholdAlertType( executor: createLogThresholdExecutor(libs), actionVariables: { context: [ + { name: 'timestamp', description: timestampActionVariableDescription }, { name: 'matchingDocuments', description: documentCountActionVariableDescription }, { name: 'conditions', description: conditionsActionVariableDescription }, { name: 'group', description: groupByActionVariableDescription }, diff --git a/x-pack/plugins/ingest_manager/README.md b/x-pack/plugins/ingest_manager/README.md index a95a2582f1f60..65df682c23659 100644 --- a/x-pack/plugins/ingest_manager/README.md +++ b/x-pack/plugins/ingest_manager/README.md @@ -8,7 +8,8 @@ - [code for adding the routes](https://github.com/elastic/kibana/blob/1f27d349533b1c2865c10c45b2cf705d7416fb36/x-pack/plugins/ingest_manager/server/plugin.ts#L115-L133) - [Integration tests](server/integration_tests/router.test.ts) - Both EPM and Fleet require `ingestManager` be enabled. They are not standalone features. -- For Gold+ license, a custom package registry URL can be used by setting `xpack.ingestManager.registryUrl=http://localhost:8080` +- For Enterprise license, a custom package registry URL can be used by setting `xpack.ingestManager.registryUrl=http://localhost:8080` + - This property is currently only for internal Elastic development and is unsupported ## Fleet Requirements diff --git a/x-pack/plugins/ingest_manager/common/constants/routes.ts b/x-pack/plugins/ingest_manager/common/constants/routes.ts index 69672dfb9ec6c..2b1d24f14874f 100644 --- a/x-pack/plugins/ingest_manager/common/constants/routes.ts +++ b/x-pack/plugins/ingest_manager/common/constants/routes.ts @@ -91,6 +91,7 @@ export const AGENT_API_ROUTES = { BULK_REASSIGN_PATTERN: `${FLEET_API_ROOT}/agents/bulk_reassign`, STATUS_PATTERN: `${FLEET_API_ROOT}/agent-status`, UPGRADE_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/upgrade`, + BULK_UPGRADE_PATTERN: `${FLEET_API_ROOT}/agents/bulk_upgrade`, }; export const ENROLLMENT_API_KEY_ROUTES = { diff --git a/x-pack/plugins/ingest_manager/common/services/agent_status.ts b/x-pack/plugins/ingest_manager/common/services/agent_status.ts index 70f4d7f9344f9..cd990d70c3612 100644 --- a/x-pack/plugins/ingest_manager/common/services/agent_status.ts +++ b/x-pack/plugins/ingest_manager/common/services/agent_status.ts @@ -19,9 +19,6 @@ export function getAgentStatus(agent: Agent, now: number = Date.now()): AgentSta if (!agent.last_checkin) { return 'enrolling'; } - if (agent.upgrade_started_at && !agent.upgraded_at) { - return 'upgrading'; - } const msLastCheckIn = new Date(lastCheckIn || 0).getTime(); const msSinceLastCheckIn = new Date().getTime() - msLastCheckIn; @@ -33,6 +30,9 @@ export function getAgentStatus(agent: Agent, now: number = Date.now()): AgentSta if (agent.last_checkin_status === 'degraded') { return 'degraded'; } + if (agent.upgrade_started_at && !agent.upgraded_at) { + return 'updating'; + } if (intervalsSinceLastCheckIn >= 4) { return 'offline'; } @@ -61,3 +61,7 @@ export function buildKueryForOfflineAgents() { (4 * AGENT_POLLING_THRESHOLD_MS) / 1000 }s AND not (${buildKueryForErrorAgents()})`; } + +export function buildKueryForUpdatingAgents() { + return `${AGENT_SAVED_OBJECT_TYPE}.upgrade_started_at:*`; +} diff --git a/x-pack/plugins/ingest_manager/common/services/index.ts b/x-pack/plugins/ingest_manager/common/services/index.ts index 4bffa01ad5ee2..19285e921e931 100644 --- a/x-pack/plugins/ingest_manager/common/services/index.ts +++ b/x-pack/plugins/ingest_manager/common/services/index.ts @@ -13,3 +13,4 @@ export { decodeCloudId } from './decode_cloud_id'; export { isValidNamespace } from './is_valid_namespace'; export { isDiffPathProtocol } from './is_diff_path_protocol'; export { LicenseService } from './license'; +export { isAgentUpgradeable } from './is_agent_upgradeable'; diff --git a/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.test.ts b/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.test.ts new file mode 100644 index 0000000000000..cb087a3b8f805 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.test.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { isAgentUpgradeable } from './is_agent_upgradeable'; +import { Agent } from '../types/models/agent'; + +const getAgent = (version: string, upgradeable: boolean): Agent => { + const agent: Agent = { + id: 'de9006e1-54a7-4320-b24e-927e6fe518a8', + active: true, + policy_id: '63a284b0-0334-11eb-a4e0-09883c57114b', + type: 'PERMANENT', + enrolled_at: '2020-09-30T20:24:08.347Z', + user_provided_metadata: {}, + local_metadata: { + elastic: { + agent: { + id: 'de9006e1-54a7-4320-b24e-927e6fe518a8', + version, + snapshot: false, + 'build.original': + '8.0.0 (build: e2ef4fc375a5ece83d5d38f57b2977d7866b5819 at 2020-09-30 20:21:35 +0000 UTC)', + }, + }, + host: { + architecture: 'x86_64', + hostname: 'Sandras-MBP.fios-router.home', + name: 'Sandras-MBP.fios-router.home', + id: '1112D0AD-526D-5268-8E86-765D35A0F484', + ip: [ + '127.0.0.1/8', + '::1/128', + 'fe80::1/64', + 'fe80::aede:48ff:fe00:1122/64', + 'fe80::4fc:2526:7d51:19cc/64', + '192.168.1.161/24', + 'fe80::3083:5ff:fe30:4b00/64', + 'fe80::3083:5ff:fe30:4b00/64', + 'fe80::f7fb:518e:2c3c:7815/64', + 'fe80::2abd:20e3:9bc3:c054/64', + 'fe80::531a:20ab:1f38:7f9/64', + ], + mac: [ + 'a6:83:e7:b0:1a:d2', + 'ac:de:48:00:11:22', + 'a4:83:e7:b0:1a:d2', + '82:c5:c2:25:b0:01', + '82:c5:c2:25:b0:00', + '82:c5:c2:25:b0:05', + '82:c5:c2:25:b0:04', + '82:c5:c2:25:b0:01', + '06:83:e7:b0:1a:d2', + '32:83:05:30:4b:00', + '32:83:05:30:4b:00', + ], + }, + os: { + family: 'darwin', + kernel: '19.4.0', + platform: 'darwin', + version: '10.15.4', + name: 'Mac OS X', + full: 'Mac OS X(10.15.4)', + }, + }, + access_api_key_id: 'A_6v4HQBEEDXi-A9vxPE', + default_api_key_id: 'BP6v4HQBEEDXi-A95xMk', + policy_revision: 1, + packages: ['system'], + last_checkin: '2020-10-01T14:43:27.255Z', + current_error_events: [], + status: 'online', + }; + if (upgradeable) { + agent.local_metadata.elastic.agent.upgradeable = true; + } + return agent; +}; +describe('Ingest Manager - isAgentUpgradeable', () => { + it('returns false if agent reports not upgradeable with agent version < kibana version', () => { + expect(isAgentUpgradeable(getAgent('7.9.0', false), '8.0.0')).toBe(false); + }); + it('returns false if agent reports not upgradeable with agent version > kibana version', () => { + expect(isAgentUpgradeable(getAgent('8.0.0', false), '7.9.0')).toBe(false); + }); + it('returns false if agent reports not upgradeable with agent version === kibana version', () => { + expect(isAgentUpgradeable(getAgent('8.0.0', false), '8.0.0')).toBe(false); + }); + it('returns false if agent reports upgradeable, with agent version === kibana version', () => { + expect(isAgentUpgradeable(getAgent('8.0.0', true), '8.0.0')).toBe(false); + }); + it('returns false if agent reports upgradeable, with agent version > kibana version', () => { + expect(isAgentUpgradeable(getAgent('8.0.0', true), '7.9.0')).toBe(false); + }); + it('returns true if agent reports upgradeable, with agent version < kibana version', () => { + expect(isAgentUpgradeable(getAgent('7.9.0', true), '8.0.0')).toBe(true); + }); +}); diff --git a/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.ts b/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.ts new file mode 100644 index 0000000000000..5f96e108e6184 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import semver from 'semver'; +import { Agent } from '../types'; + +export function isAgentUpgradeable(agent: Agent, kibanaVersion: string) { + let agentVersion: string; + if (typeof agent?.local_metadata?.elastic?.agent?.version === 'string') { + agentVersion = agent.local_metadata.elastic.agent.version; + } else { + return false; + } + const kibanaVersionParsed = semver.parse(kibanaVersion); + const agentVersionParsed = semver.parse(agentVersion); + if (!agentVersionParsed || !kibanaVersionParsed) return false; + if (!agent.local_metadata.elastic.agent.upgradeable) return false; + return semver.lt(agentVersionParsed, kibanaVersionParsed); +} diff --git a/x-pack/plugins/ingest_manager/common/services/license.ts b/x-pack/plugins/ingest_manager/common/services/license.ts index 6d9b20a8456c0..381db149f7d6d 100644 --- a/x-pack/plugins/ingest_manager/common/services/license.ts +++ b/x-pack/plugins/ingest_manager/common/services/license.ts @@ -43,4 +43,11 @@ export class LicenseService { this.licenseInformation?.hasAtLeast('gold') ); } + public isEnterprise() { + return ( + this.licenseInformation?.isAvailable && + this.licenseInformation?.isActive && + this.licenseInformation?.hasAtLeast('enterprise') + ); + } } diff --git a/x-pack/plugins/ingest_manager/common/services/routes.ts b/x-pack/plugins/ingest_manager/common/services/routes.ts index 3c3534926908a..c709794f2ce55 100644 --- a/x-pack/plugins/ingest_manager/common/services/routes.ts +++ b/x-pack/plugins/ingest_manager/common/services/routes.ts @@ -135,6 +135,9 @@ export const agentRouteService = { getReassignPath: (agentId: string) => AGENT_API_ROUTES.REASSIGN_PATTERN.replace('{agentId}', agentId), getBulkReassignPath: () => AGENT_API_ROUTES.BULK_REASSIGN_PATTERN, + getUpgradePath: (agentId: string) => + AGENT_API_ROUTES.UPGRADE_PATTERN.replace('{agentId}', agentId), + getBulkUpgradePath: () => AGENT_API_ROUTES.BULK_UPGRADE_PATTERN, getListPath: () => AGENT_API_ROUTES.LIST_PATTERN, getStatusPath: () => AGENT_API_ROUTES.STATUS_PATTERN, }; diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent.ts b/x-pack/plugins/ingest_manager/common/types/models/agent.ts index 6ac783820ce82..215764939d3d1 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent.ts @@ -19,7 +19,7 @@ export type AgentStatus = | 'warning' | 'enrolling' | 'unenrolling' - | 'upgrading' + | 'updating' | 'degraded'; export type AgentActionType = 'POLICY_CHANGE' | 'UNENROLL' | 'UPGRADE'; @@ -89,6 +89,7 @@ export interface NewAgentEvent { | 'STOPPING' | 'STOPPED' | 'DEGRADED' + | 'UPDATING' // Action results | 'DATA_DUMP' // Actions @@ -109,10 +110,8 @@ export interface AgentEvent extends NewAgentEvent { export type AgentEventSOAttributes = NewAgentEvent; -type MetadataValue = string | AgentMetadata; - export interface AgentMetadata { - [x: string]: MetadataValue; + [x: string]: any; } interface AgentBase { type: AgentType; @@ -129,7 +128,7 @@ interface AgentBase { policy_id?: string; policy_revision?: number | null; last_checkin?: string; - last_checkin_status?: 'error' | 'online' | 'degraded'; + last_checkin_status?: 'error' | 'online' | 'degraded' | 'updating'; user_provided_metadata: AgentMetadata; local_metadata: AgentMetadata; } diff --git a/x-pack/plugins/ingest_manager/common/types/models/epm.ts b/x-pack/plugins/ingest_manager/common/types/models/epm.ts index d2d1f22dda3a0..ea7fd60d1fa3f 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/epm.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/epm.ts @@ -20,6 +20,7 @@ export enum InstallStatus { } export type InstallType = 'reinstall' | 'reupdate' | 'rollback' | 'update' | 'install'; +export type InstallSource = 'registry' | 'upload'; export type EpmPackageInstallStatus = 'installed' | 'installing'; @@ -49,10 +50,8 @@ export enum AgentAssetType { export type RegistryRelease = 'ga' | 'beta' | 'experimental'; -// from /package/{name} -// type Package struct at https://github.com/elastic/package-registry/blob/master/util/package.go -// https://github.com/elastic/package-registry/blob/master/docs/api/package.json -export interface RegistryPackage { +// Fields common to packages that come from direct upload and the registry +export interface InstallablePackage { name: string; title?: string; version: string; @@ -61,7 +60,6 @@ export interface RegistryPackage { description: string; type: string; categories: string[]; - requirement: RequirementsByServiceName; screenshots?: RegistryImage[]; icons?: RegistryImage[]; assets?: string[]; @@ -69,6 +67,17 @@ export interface RegistryPackage { format_version: string; data_streams?: RegistryDataStream[]; policy_templates?: RegistryPolicyTemplate[]; +} + +// Uploaded package archives don't have extra fields +// Linter complaint disabled because this extra type is meant for better code readability +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ArchivePackage extends InstallablePackage {} + +// Registry packages do have extra fields. +// cf. type Package struct at https://github.com/elastic/package-registry/blob/master/util/package.go +export interface RegistryPackage extends InstallablePackage { + requirement: RequirementsByServiceName; download: string; path: string; } @@ -240,6 +249,7 @@ export interface Installation extends SavedObjectAttributes { install_status: EpmPackageInstallStatus; install_version: string; install_started_at: string; + install_source: InstallSource; } export type Installable = Installed | NotInstalled; diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts index ab4c372c4e1d6..da7d126c4ecd3 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts @@ -20,6 +20,7 @@ export interface GetAgentsRequest { perPage: number; kuery?: string; showInactive: boolean; + showUpgradeable?: boolean; }; } @@ -113,22 +114,38 @@ export interface PostAgentUnenrollRequest { // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PostAgentUnenrollResponse {} +export interface PostBulkAgentUnenrollRequest { + body: { + agents: string[] | string; + force?: boolean; + }; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PostBulkAgentUnenrollResponse {} + export interface PostAgentUpgradeRequest { params: { agentId: string; }; + body: { + source_uri?: string; + version: string; + }; } -export interface PostBulkAgentUnenrollRequest { + +export interface PostBulkAgentUpgradeRequest { body: { agents: string[] | string; - force?: boolean; + source_uri?: string; + version: string; }; } +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PostBulkAgentUpgradeResponse {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PostAgentUpgradeResponse {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PostBulkAgentUnenrollResponse {} export interface PutAgentReassignRequest { params: { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx index 550c282460531..bd26daeb4e879 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx @@ -6,7 +6,7 @@ import React from 'react'; import styled from 'styled-components'; -import { EuiText, EuiSpacer, EuiCode, EuiTitle, EuiCodeBlock } from '@elastic/eui'; +import { EuiText, EuiSpacer, EuiLink, EuiTitle, EuiCodeBlock } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { EnrollmentAPIKey } from '../../../types'; @@ -29,70 +29,63 @@ export const ManualInstructions: React.FunctionComponent = ({ const enrollArgs = `${kibanaUrl} ${apiKey.api_key}${ kibanaCASha256 ? ` --ca_sha256=${kibanaCASha256}` : '' }`; - const macOsLinuxTarCommand = `./elastic-agent enroll ${enrollArgs} -./elastic-agent run`; - const linuxDebRpmCommand = `elastic-agent enroll ${enrollArgs} -systemctl enable elastic-agent -systemctl start elastic-agent`; + const linuxMacCommand = `./elastic-agent install -f ${enrollArgs}`; - const windowsCommand = `.\\elastic-agent enroll ${enrollArgs} -.\\install-service-elastic-agent.ps1`; + const windowsCommand = `.\\elastic-agent.exe install -f ${enrollArgs}`; return ( <>

    - {windowsCommand} + {linuxMacCommand}

    - {linuxDebRpmCommand} + {windowsCommand} - -

    - -

    -
    - - - {macOsLinuxTarCommand} - - - + ./elastic-agent run, + link: ( + + + + ), }} /> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/constants/page_paths.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/constants/page_paths.ts index c370f46b9f5a0..1273fb9b86ca9 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/constants/page_paths.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/constants/page_paths.ts @@ -5,6 +5,7 @@ */ export type StaticPage = + | 'base' | 'overview' | 'integrations' | 'integrations_all' @@ -62,6 +63,7 @@ export const pagePathGetters: { { [key in DynamicPage]: (values: DynamicPagePathValues) => string; } = { + base: () => '/', overview: () => '/', integrations: () => '/integrations', integrations_all: () => '/integrations', diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts index 64434e163f043..29843f6a3e5b1 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts @@ -7,6 +7,7 @@ export { useCapabilities } from './use_capabilities'; export { useCore } from './use_core'; export { useConfig, ConfigContext } from './use_config'; +export { useKibanaVersion, KibanaVersionContext } from './use_kibana_version'; export { useSetupDeps, useStartDeps, DepsContext } from './use_deps'; export { licenseService, useLicense } from './use_license'; export { useBreadcrumbs } from './use_breadcrumbs'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_breadcrumbs.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_breadcrumbs.tsx index b263f46b90a25..ccd889dec5b9f 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_breadcrumbs.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_breadcrumbs.tsx @@ -18,6 +18,7 @@ const BASE_BREADCRUMB: ChromeBreadcrumb = { const breadcrumbGetters: { [key in Page]: (values: DynamicPagePathValues) => ChromeBreadcrumb[]; } = { + base: () => [BASE_BREADCRUMB], overview: () => [ BASE_BREADCRUMB, { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_kibana_version.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_kibana_version.ts new file mode 100644 index 0000000000000..a5113ca10d439 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_kibana_version.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; + +export const KibanaVersionContext = React.createContext(null); + +export function useKibanaVersion() { + const version = useContext(KibanaVersionContext); + if (version === null) { + throw new Error('KibanaVersionContext is not initialized'); + } + return version; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts index 41967fd068e0b..564e7b225cf45 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts @@ -22,6 +22,10 @@ import { GetAgentsResponse, GetAgentStatusRequest, GetAgentStatusResponse, + PostAgentUpgradeRequest, + PostBulkAgentUpgradeRequest, + PostAgentUpgradeResponse, + PostBulkAgentUpgradeResponse, } from '../../types'; type RequestOptions = Pick, 'pollIntervalMs'>; @@ -126,3 +130,28 @@ export function sendPostBulkAgentUnenroll( ...options, }); } + +export function sendPostAgentUpgrade( + agentId: string, + body: PostAgentUpgradeRequest['body'], + options?: RequestOptions +) { + return sendRequest({ + path: agentRouteService.getUpgradePath(agentId), + method: 'post', + body, + ...options, + }); +} + +export function sendPostBulkAgentUpgrade( + body: PostBulkAgentUpgradeRequest['body'], + options?: RequestOptions +) { + return sendRequest({ + path: agentRouteService.getBulkUpgradePath(), + method: 'post', + body, + ...options, + }); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx index 0bef3c20ddd1a..1262644382f37 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx @@ -12,6 +12,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import styled from 'styled-components'; import { EuiErrorBoundary, EuiPanel, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; import { CoreStart, AppMountParameters } from 'src/core/public'; +import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { EuiThemeProvider } from '../../../../xpack_legacy/common'; import { IngestManagerSetupDeps, @@ -30,12 +31,12 @@ import { sendSetup, sendGetPermissionsCheck, licenseService, + KibanaVersionContext, } from './hooks'; import { PackageInstallProvider } from './sections/epm/hooks'; -import { FleetStatusProvider } from './hooks/use_fleet_status'; -import './index.scss'; -import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { FleetStatusProvider, useBreadcrumbs } from './hooks'; import { IntraAppStateProvider } from './hooks/use_intra_app_state'; +import './index.scss'; export interface ProtectedRouteProps extends RouteProps { isAllowed?: boolean; @@ -66,6 +67,7 @@ const ErrorLayout = ({ children }: { children: JSX.Element }) => ( const IngestManagerRoutes = memo<{ history: AppMountParameters['history']; basepath: string }>( ({ history, ...rest }) => { + useBreadcrumbs('base'); const { fleet } = useConfig(); const { notifications } = useCore(); @@ -235,6 +237,7 @@ const IngestManagerApp = ({ startDeps, config, history, + kibanaVersion, }: { basepath: string; coreStart: CoreStart; @@ -242,6 +245,7 @@ const IngestManagerApp = ({ startDeps: IngestManagerStartDeps; config: IngestManagerConfigType; history: AppMountParameters['history']; + kibanaVersion: string; }) => { const isDarkMode = useObservable(coreStart.uiSettings.get$('theme:darkMode')); return ( @@ -249,9 +253,11 @@ const IngestManagerApp = ({ - - - + + + + + @@ -264,7 +270,8 @@ export function renderApp( { element, appBasePath, history }: AppMountParameters, setupDeps: IngestManagerSetupDeps, startDeps: IngestManagerStartDeps, - config: IngestManagerConfigType + config: IngestManagerConfigType, + kibanaVersion: string ) { ReactDOM.render( , element ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/details_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/details_page/index.tsx index 4c317c54c68c4..d708b447caa58 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/details_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/details_page/index.tsx @@ -75,14 +75,18 @@ export const AgentPolicyDetailsPage: React.FunctionComponent = () => {

    - {(agentPolicy && agentPolicy.name) || ( - + {isLoading ? ( + + ) : ( + (agentPolicy && agentPolicy.name) || ( + + ) )}

    @@ -98,7 +102,7 @@ export const AgentPolicyDetailsPage: React.FunctionComponent = () => { ) : null} ), - [getHref, agentPolicy, policyId] + [getHref, isLoading, agentPolicy, policyId] ); const enrollmentCancelClickHandler = useCallback(() => { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/actions_menu.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/actions_menu.tsx index ea5dcce8c05bb..9ed464401fdc6 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/actions_menu.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/actions_menu.tsx @@ -7,10 +7,15 @@ import React, { memo, useState, useMemo } from 'react'; import { EuiPortal, EuiContextMenuItem } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { Agent } from '../../../../types'; -import { useCapabilities } from '../../../../hooks'; +import { useCapabilities, useKibanaVersion } from '../../../../hooks'; import { ContextMenuActions } from '../../../../components'; -import { AgentUnenrollAgentModal, AgentReassignAgentPolicyFlyout } from '../../components'; +import { + AgentUnenrollAgentModal, + AgentReassignAgentPolicyFlyout, + AgentUpgradeAgentModal, +} from '../../components'; import { useAgentRefresh } from '../hooks'; +import { isAgentUpgradeable } from '../../../../services'; export const AgentDetailsActionMenu: React.FunctionComponent<{ agent: Agent; @@ -18,9 +23,11 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ onCancelReassign?: () => void; }> = memo(({ agent, assignFlyoutOpenByDefault = false, onCancelReassign }) => { const hasWriteCapabilites = useCapabilities().write; + const kibanaVersion = useKibanaVersion(); const refreshAgent = useAgentRefresh(); const [isReassignFlyoutOpen, setIsReassignFlyoutOpen] = useState(assignFlyoutOpenByDefault); const [isUnenrollModalOpen, setIsUnenrollModalOpen] = useState(false); + const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false); const isUnenrolling = agent.status === 'unenrolling'; const onClose = useMemo(() => { @@ -51,6 +58,19 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ /> )} + {isUpgradeModalOpen && ( + + { + setIsUpgradeModalOpen(false); + refreshAgent(); + }} + /> + + )} )} , + { + setIsUpgradeModalOpen(true); + }} + > + + , ]} /> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx index 68abb43abac18..2493fda3317d2 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx @@ -13,15 +13,20 @@ import { EuiLink, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { EuiText } from '@elastic/eui'; +import { EuiIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { Agent, AgentPolicy } from '../../../../types'; -import { useLink } from '../../../../hooks'; +import { useKibanaVersion, useLink } from '../../../../hooks'; import { AgentHealth } from '../../components'; +import { isAgentUpgradeable } from '../../../../services'; export const AgentDetailsContent: React.FunctionComponent<{ agent: Agent; agentPolicy?: AgentPolicy; }> = memo(({ agent, agentPolicy }) => { const { getHref } = useLink(); + const kibanaVersion = useKibanaVersion(); return ( {[ @@ -69,8 +74,39 @@ export const AgentDetailsContent: React.FunctionComponent<{ description: typeof agent.local_metadata.elastic === 'object' && typeof agent.local_metadata.elastic.agent === 'object' && - typeof agent.local_metadata.elastic.agent.version === 'string' - ? agent.local_metadata.elastic.agent.version + typeof agent.local_metadata.elastic.agent.version === 'string' ? ( + + + {agent.local_metadata.elastic.agent.version} + + {isAgentUpgradeable(agent, kibanaVersion) ? ( + + + +   + + + + ) : null} + + ) : ( + '-' + ), + }, + { + title: i18n.translate('xpack.ingestManager.agentDetails.releaseLabel', { + defaultMessage: 'Agent release', + }), + description: + typeof agent.local_metadata.elastic === 'object' && + typeof agent.local_metadata.elastic.agent === 'object' && + typeof agent.local_metadata.elastic.agent.snapshot === 'boolean' + ? agent.local_metadata.elastic.agent.snapshot === true + ? 'snapshot' + : 'stable' : '-', }, { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx index 56af9519bc1da..f597b9c72ab02 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx @@ -119,6 +119,14 @@ export const SUBTYPE_LABEL: { [key in AgentEvent['subtype']]: JSX.Element } = { /> ), + UPDATING: ( + + + + ), UNKNOWN: ( { params: { agentId, tabId = '' }, } = useRouteMatch<{ agentId: string; tabId?: string }>(); const { getHref } = useLink(); + const kibanaVersion = useKibanaVersion(); const { isLoading, isInitialRequest, @@ -93,8 +97,10 @@ export const AgentDetailsPage: React.FunctionComponent = () => {

    - {typeof agentData?.item?.local_metadata?.host === 'object' && - typeof agentData?.item?.local_metadata?.host?.hostname === 'string' ? ( + {isLoading && isInitialRequest ? ( + + ) : typeof agentData?.item?.local_metadata?.host === 'object' && + typeof agentData?.item?.local_metadata?.host?.hostname === 'string' ? ( agentData.item.local_metadata.host.hostname ) : ( { ), - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - [agentData, agentId, getHref] + [agentData?.item?.local_metadata?.host, agentId, getHref, isInitialRequest, isLoading] ); const headerRightContent = useMemo( @@ -144,6 +149,45 @@ export const AgentDetailsPage: React.FunctionComponent = () => { ), }, { isDivider: true }, + { + label: i18n.translate('xpack.ingestManager.agentDetails.agentVersionLabel', { + defaultMessage: 'Agent version', + }), + content: + typeof agentData.item.local_metadata.elastic === 'object' && + typeof agentData.item.local_metadata.elastic.agent === 'object' && + typeof agentData.item.local_metadata.elastic.agent.version === 'string' ? ( + + + {agentData.item.local_metadata.elastic.agent.version} + + {isAgentUpgradeable(agentData.item, kibanaVersion) ? ( + + + + ) : null} + + ) : ( + '-' + ), + }, + { isDivider: true }, { content: ( { + const kibanaVersion = useKibanaVersion(); // Bulk actions menu states const [isMenuOpen, setIsMenuOpen] = useState(false); const closeMenu = () => setIsMenuOpen(false); @@ -67,6 +73,7 @@ export const AgentBulkActions: React.FunctionComponent<{ // Actions states const [isReassignFlyoutOpen, setIsReassignFlyoutOpen] = useState(false); const [isUnenrollModalOpen, setIsUnenrollModalOpen] = useState(false); + const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false); // Check if user is working with only inactive agents const atLeastOneActiveAgentSelected = @@ -106,6 +113,20 @@ export const AgentBulkActions: React.FunctionComponent<{ setIsUnenrollModalOpen(true); }, }, + { + name: ( + + ), + icon: , + disabled: !atLeastOneActiveAgentSelected, + onClick: () => { + closeMenu(); + setIsUpgradeModalOpen(true); + }, + }, { name: ( )} + {isUpgradeModalOpen && ( + + { + setIsUpgradeModalOpen(false); + refreshAgents(); + }} + /> + + )} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx index 0bc463ce98590..83cbb9ccb728c 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx @@ -35,14 +35,16 @@ import { useLink, useBreadcrumbs, useLicense, + useKibanaVersion, } from '../../../hooks'; import { SearchBar, ContextMenuActions } from '../../../components'; -import { AgentStatusKueryHelper } from '../../../services'; +import { AgentStatusKueryHelper, isAgentUpgradeable } from '../../../services'; import { AGENT_SAVED_OBJECT_TYPE } from '../../../constants'; import { AgentReassignAgentPolicyFlyout, AgentHealth, AgentUnenrollAgentModal, + AgentUpgradeAgentModal, } from '../components'; import { AgentBulkActions, SelectionMode } from './components/bulk_actions'; @@ -68,6 +70,12 @@ const statusFilters = [ defaultMessage: 'Error', }), }, + { + status: 'updating', + label: i18n.translate('xpack.ingestManager.agentList.statusUpdatingFilterText', { + defaultMessage: 'Updating', + }), + }, ] as Array<{ label: string; status: string }>; const RowActions = React.memo<{ @@ -75,11 +83,13 @@ const RowActions = React.memo<{ refresh: () => void; onReassignClick: () => void; onUnenrollClick: () => void; -}>(({ agent, refresh, onReassignClick, onUnenrollClick }) => { + onUpgradeClick: () => void; +}>(({ agent, refresh, onReassignClick, onUnenrollClick, onUpgradeClick }) => { const { getHref } = useLink(); const hasWriteCapabilites = useCapabilities().write; const isUnenrolling = agent.status === 'unenrolling'; + const kibanaVersion = useKibanaVersion(); const [isMenuOpen, setIsMenuOpen] = useState(false); return ( )} , + { + onUpgradeClick(); + }} + > + + , ]} /> ); @@ -146,10 +168,11 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { const defaultKuery: string = (useUrlParams().urlParams.kuery as string) || ''; const hasWriteCapabilites = useCapabilities().write; const isGoldPlus = useLicense().isGoldPlus(); + const kibanaVersion = useKibanaVersion(); // Agent data states const [showInactive, setShowInactive] = useState(false); - + const [showUpgradeable, setShowUpgradeable] = useState(false); // Table and search states const [search, setSearch] = useState(defaultKuery); const [selectionMode, setSelectionMode] = useState('manual'); @@ -189,6 +212,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { // Agent actions states const [agentToReassign, setAgentToReassign] = useState(undefined); const [agentToUnenroll, setAgentToUnenroll] = useState(undefined); + const [agentToUpgrade, setAgentToUpgrade] = useState(undefined); let kuery = search.trim(); if (selectedAgentPolicies.length) { @@ -199,7 +223,6 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { .map((agentPolicy) => `"${agentPolicy}"`) .join(' or ')})`; } - if (selectedStatus.length) { const kueryStatus = selectedStatus .map((status) => { @@ -208,6 +231,8 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { return AgentStatusKueryHelper.buildKueryForOnlineAgents(); case 'offline': return AgentStatusKueryHelper.buildKueryForOfflineAgents(); + case 'updating': + return AgentStatusKueryHelper.buildKueryForUpdatingAgents(); case 'error': return AgentStatusKueryHelper.buildKueryForErrorAgents(); } @@ -229,6 +254,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { perPage: pagination.pageSize, kuery: kuery && kuery !== '' ? kuery : undefined, showInactive, + showUpgradeable, }, { pollIntervalMs: REFRESH_INTERVAL_MS, @@ -329,11 +355,29 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }, { field: 'local_metadata.elastic.agent.version', - width: '100px', + width: '200px', name: i18n.translate('xpack.ingestManager.agentList.versionTitle', { defaultMessage: 'Version', }), - render: (version: string, agent: Agent) => safeMetadata(version), + render: (version: string, agent: Agent) => ( + + + {safeMetadata(version)} + + {isAgentUpgradeable(agent, kibanaVersion) ? ( + + + +   + + + + ) : null} + + ), }, { field: 'last_checkin', @@ -356,6 +400,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { refresh={() => agentsRequest.resendRequest()} onReassignClick={() => setAgentToReassign(agent)} onUnenrollClick={() => setAgentToUnenroll(agent)} + onUpgradeClick={() => setAgentToUpgrade(agent)} /> ); }, @@ -421,6 +466,20 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { )} + {agentToUpgrade && ( + + { + setAgentToUpgrade(undefined); + agentsRequest.resendRequest(); + }} + version={kibanaVersion} + /> + + )} + {/* Search and filter bar */} @@ -519,13 +578,24 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { ))} + { + setShowUpgradeable(!showUpgradeable); + }} + > + + setShowInactive(!showInactive)} > diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx index 387ccfc66cbc1..3e5cf236e63dc 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx @@ -29,7 +29,7 @@ interface Props { agentPolicies?: AgentPolicy[]; } -const RUN_INSTRUCTIONS = './elastic-agent run'; +const RUN_INSTRUCTIONS = './elastic-agent install'; export const StandaloneInstructions = React.memo(({ agentPolicies }) => { const { getHref } = useLink(); @@ -131,7 +131,7 @@ export const StandaloneInstructions = React.memo(({ agentPolicies }) => { {RUN_INSTRUCTIONS} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx index 7c6c95cab420f..a16d4e7347ad1 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx @@ -77,6 +77,14 @@ const Status = { /> ), + Upgrading: ( + + + + ), }; function getStatusComponent(agent: Agent): React.ReactElement { @@ -95,6 +103,8 @@ function getStatusComponent(agent: Agent): React.ReactElement { return Status.Unenrolling; case 'enrolling': return Status.Enrolling; + case 'updating': + return Status.Upgrading; default: return Status.Online; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_upgrade_modal/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_upgrade_modal/index.tsx new file mode 100644 index 0000000000000..a59f503d2994b --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_upgrade_modal/index.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Agent } from '../../../../types'; +import { sendPostAgentUpgrade, sendPostBulkAgentUpgrade, useCore } from '../../../../hooks'; + +interface Props { + onClose: () => void; + agents: Agent[] | string; + agentCount: number; + version: string; +} + +export const AgentUpgradeAgentModal: React.FunctionComponent = ({ + onClose, + agents, + agentCount, + version, +}) => { + const { notifications } = useCore(); + const [isSubmitting, setIsSubmitting] = useState(false); + const isSingleAgent = Array.isArray(agents) && agents.length === 1; + async function onSubmit() { + try { + setIsSubmitting(true); + const { error } = isSingleAgent + ? await sendPostAgentUpgrade((agents[0] as Agent).id, { + version, + }) + : await sendPostBulkAgentUpgrade({ + agents: Array.isArray(agents) ? agents.map((agent) => agent.id) : agents, + version, + }); + if (error) { + throw error; + } + setIsSubmitting(false); + const successMessage = isSingleAgent + ? i18n.translate('xpack.ingestManager.upgradeAgents.successSingleNotificationTitle', { + defaultMessage: 'Upgrading agent', + }) + : i18n.translate('xpack.ingestManager.upgradeAgents.successMultiNotificationTitle', { + defaultMessage: 'Upgrading agents', + }); + notifications.toasts.addSuccess(successMessage); + onClose(); + } catch (error) { + setIsSubmitting(false); + notifications.toasts.addError(error, { + title: i18n.translate('xpack.ingestManager.upgradeAgents.fatalErrorNotificationTitle', { + defaultMessage: 'Error upgrading {count, plural, one {agent} other {agents}}', + values: { count: agentCount }, + }), + }); + } + } + + return ( + + + ) : ( + + ) + } + onCancel={onClose} + onConfirm={onSubmit} + cancelButtonText={ + + } + confirmButtonDisabled={isSubmitting} + confirmButtonText={ + isSingleAgent ? ( + + ) : ( + + ) + } + > +

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

    +
    +
    + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/index.tsx index eea4ed3b712b1..3dd04b4f5b0b7 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/index.tsx @@ -9,3 +9,4 @@ export * from './agent_reassign_policy_flyout'; export * from './agent_enrollment_flyout'; export * from './agent_health'; export * from './agent_unenroll_modal'; +export * from './agent_upgrade_modal'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts index ed6ba5c891a0b..ee976d40402cc 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts @@ -26,4 +26,5 @@ export { doesAgentPolicyAlreadyIncludePackage, isValidNamespace, LicenseService, + isAgentUpgradeable, } from '../../../../common'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts index e825448f359d6..386ffa5649cc2 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts @@ -53,6 +53,10 @@ export { PostAgentUnenrollResponse, PostBulkAgentUnenrollRequest, PostBulkAgentUnenrollResponse, + PostAgentUpgradeRequest, + PostBulkAgentUpgradeRequest, + PostAgentUpgradeResponse, + PostBulkAgentUpgradeResponse, GetOneAgentEventsRequest, GetOneAgentEventsResponse, GetAgentStatusRequest, diff --git a/x-pack/plugins/ingest_manager/public/plugin.ts b/x-pack/plugins/ingest_manager/public/plugin.ts index 59741ce79dd8b..cb1d59b698f0a 100644 --- a/x-pack/plugins/ingest_manager/public/plugin.ts +++ b/x-pack/plugins/ingest_manager/public/plugin.ts @@ -60,13 +60,16 @@ export class IngestManagerPlugin implements Plugin { private config: IngestManagerConfigType; + private kibanaVersion: string; constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get(); + this.kibanaVersion = initializerContext.env.packageInfo.version; } public setup(core: CoreSetup, deps: IngestManagerSetupDeps) { const config = this.config; + const kibanaVersion = this.kibanaVersion; // Set up http client setHttpClient(core.http); @@ -88,7 +91,7 @@ export class IngestManagerPlugin IngestManagerStart ]; const { renderApp, teardownIngestManager } = await import('./applications/ingest_manager'); - const unmount = renderApp(coreStart, params, deps, startDeps, config); + const unmount = renderApp(coreStart, params, deps, startDeps, config, kibanaVersion); return () => { unmount(); diff --git a/x-pack/plugins/ingest_manager/server/errors/handlers.test.ts b/x-pack/plugins/ingest_manager/server/errors/handlers.test.ts index 361386a86d547..272d95c0b3688 100644 --- a/x-pack/plugins/ingest_manager/server/errors/handlers.test.ts +++ b/x-pack/plugins/ingest_manager/server/errors/handlers.test.ts @@ -13,6 +13,7 @@ import { IngestManagerError, RegistryError, PackageNotFoundError, + PackageUnsupportedMediaTypeError, defaultIngestErrorHandler, } from './index'; @@ -101,6 +102,25 @@ describe('defaultIngestErrorHandler', () => { expect(mockContract.logger?.error).toHaveBeenCalledWith(error.message); }); + it('415: PackageUnsupportedMediaType', async () => { + const error = new PackageUnsupportedMediaTypeError('123'); + const response = httpServerMock.createResponseFactory(); + + await defaultIngestErrorHandler({ error, response }); + + // response + expect(response.ok).toHaveBeenCalledTimes(0); + expect(response.customError).toHaveBeenCalledTimes(1); + expect(response.customError).toHaveBeenCalledWith({ + statusCode: 415, + body: { message: error.message }, + }); + + // logging + expect(mockContract.logger?.error).toHaveBeenCalledTimes(1); + expect(mockContract.logger?.error).toHaveBeenCalledWith(error.message); + }); + it('404: PackageNotFoundError', async () => { const error = new PackageNotFoundError('123'); const response = httpServerMock.createResponseFactory(); diff --git a/x-pack/plugins/ingest_manager/server/errors/handlers.ts b/x-pack/plugins/ingest_manager/server/errors/handlers.ts index b621f2dd29331..bcad3f9c022da 100644 --- a/x-pack/plugins/ingest_manager/server/errors/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/errors/handlers.ts @@ -13,7 +13,12 @@ import { } from 'src/core/server'; import { errors as LegacyESErrors } from 'elasticsearch'; import { appContextService } from '../services'; -import { IngestManagerError, RegistryError, PackageNotFoundError } from './index'; +import { + IngestManagerError, + RegistryError, + PackageNotFoundError, + PackageUnsupportedMediaTypeError, +} from './index'; type IngestErrorHandler = ( params: IngestErrorHandlerParams @@ -52,6 +57,9 @@ const getHTTPResponseCode = (error: IngestManagerError): number => { if (error instanceof PackageNotFoundError) { return 404; // Not Found } + if (error instanceof PackageUnsupportedMediaTypeError) { + return 415; // Unsupported Media Type + } return 400; // Bad Request }; diff --git a/x-pack/plugins/ingest_manager/server/errors/index.ts b/x-pack/plugins/ingest_manager/server/errors/index.ts index f495bf551dcff..15ac97f21a17a 100644 --- a/x-pack/plugins/ingest_manager/server/errors/index.ts +++ b/x-pack/plugins/ingest_manager/server/errors/index.ts @@ -18,3 +18,7 @@ export class RegistryConnectionError extends RegistryError {} export class RegistryResponseError extends RegistryError {} export class PackageNotFoundError extends IngestManagerError {} export class PackageOutdatedError extends IngestManagerError {} +export class PackageUnsupportedMediaTypeError extends IngestManagerError {} +export class PackageInvalidArchiveError extends IngestManagerError {} +export class PackageCacheError extends IngestManagerError {} +export class PackageOperationNotSupportedError extends IngestManagerError {} diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts index fb867af513fdc..5e075cbbcdf5e 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts @@ -239,6 +239,7 @@ export const getAgentsHandler: RequestHandler< page: request.query.page, perPage: request.query.perPage, showInactive: request.query.showInactive, + showUpgradeable: request.query.showUpgradeable, kuery: request.query.kuery, }); const totalInactive = request.query.showInactive diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts index 73ed276ba02e7..ea8981bbfbad5 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts @@ -11,7 +11,12 @@ import { IRouter, RouteValidationResultFactory } from 'src/core/server'; import Ajv from 'ajv'; -import { PLUGIN_ID, AGENT_API_ROUTES, LIMITED_CONCURRENCY_ROUTE_TAG } from '../../constants'; +import { + PLUGIN_ID, + AGENT_API_ROUTES, + LIMITED_CONCURRENCY_ROUTE_TAG, + AGENT_POLLING_REQUEST_TIMEOUT_MARGIN_MS, +} from '../../constants'; import { GetAgentsRequestSchema, GetOneAgentRequestSchema, @@ -30,6 +35,7 @@ import { PostBulkAgentReassignRequestSchema, PostAgentEnrollRequestBodyJSONSchema, PostAgentUpgradeRequestSchema, + PostBulkAgentUpgradeRequestSchema, } from '../../types'; import { getAgentsHandler, @@ -49,7 +55,7 @@ import { postNewAgentActionHandlerBuilder } from './actions_handlers'; import { appContextService } from '../../services'; import { postAgentUnenrollHandler, postBulkAgentsUnenrollHandler } from './unenroll_handler'; import { IngestManagerConfigType } from '../..'; -import { postAgentUpgradeHandler } from './upgrade_handler'; +import { postAgentUpgradeHandler, postBulkAgentsUpgradeHandler } from './upgrade_handler'; const ajv = new Ajv({ coerceTypes: true, @@ -123,7 +129,8 @@ export const registerRoutes = (router: IRouter, config: IngestManagerConfigType) }, options: { tags: [], - ...(pollingRequestTimeout + // If the timeout is too short, do not set socket idle timeout and rely on Kibana global socket timeout + ...(pollingRequestTimeout && pollingRequestTimeout > AGENT_POLLING_REQUEST_TIMEOUT_MARGIN_MS ? { timeout: { idleSocket: pollingRequestTimeout, @@ -226,6 +233,15 @@ export const registerRoutes = (router: IRouter, config: IngestManagerConfigType) }, postAgentUpgradeHandler ); + // bulk upgrade + router.post( + { + path: AGENT_API_ROUTES.BULK_UPGRADE_PATTERN, + validate: PostBulkAgentUpgradeRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + postBulkAgentsUpgradeHandler + ); // Bulk reassign router.post( { diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/upgrade_handler.ts b/x-pack/plugins/ingest_manager/server/routes/agent/upgrade_handler.ts index e5d7a44c00768..c4aa33999cf22 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/upgrade_handler.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/upgrade_handler.ts @@ -6,11 +6,16 @@ import { RequestHandler } from 'src/core/server'; import { TypeOf } from '@kbn/config-schema'; -import { PostAgentUpgradeResponse } from '../../../common/types'; -import { PostAgentUpgradeRequestSchema } from '../../types'; +import { + AgentSOAttributes, + PostAgentUpgradeResponse, + PostBulkAgentUpgradeResponse, +} from '../../../common/types'; +import { PostAgentUpgradeRequestSchema, PostBulkAgentUpgradeRequestSchema } from '../../types'; import * as AgentService from '../../services/agents'; import { appContextService } from '../../services'; import { defaultIngestErrorHandler } from '../../errors'; +import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; export const postAgentUpgradeHandler: RequestHandler< TypeOf, @@ -30,6 +35,18 @@ export const postAgentUpgradeHandler: RequestHandler< }, }); } + const agent = await soClient.get( + AGENT_SAVED_OBJECT_TYPE, + request.params.agentId + ); + if (agent.attributes.unenrollment_started_at || agent.attributes.unenrolled_at) { + return response.customError({ + statusCode: 400, + body: { + message: `cannot upgrade an unenrolling or unenrolled agent`, + }, + }); + } try { await AgentService.sendUpgradeAgentAction({ @@ -45,3 +62,44 @@ export const postAgentUpgradeHandler: RequestHandler< return defaultIngestErrorHandler({ error, response }); } }; + +export const postBulkAgentsUpgradeHandler: RequestHandler< + undefined, + undefined, + TypeOf +> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + const { version, source_uri: sourceUri, agents } = request.body; + + // temporarily only allow upgrading to the same version as the installed kibana version + const kibanaVersion = appContextService.getKibanaVersion(); + if (kibanaVersion !== version) { + return response.customError({ + statusCode: 400, + body: { + message: `cannot upgrade agent to ${version} because it is different than the installed kibana version ${kibanaVersion}`, + }, + }); + } + + try { + if (Array.isArray(agents)) { + await AgentService.sendUpgradeAgentsActions(soClient, { + agentIds: agents, + sourceUri, + version, + }); + } else { + await AgentService.sendUpgradeAgentsActions(soClient, { + kuery: agents, + sourceUri, + version, + }); + } + + const body: PostBulkAgentUpgradeResponse = {}; + return response.ok({ body }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); + } +}; diff --git a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts index c55979d187f9d..0aa8641fd2a3e 100644 --- a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts @@ -8,7 +8,6 @@ import { RequestHandler, CustomHttpResponseOptions } from 'src/core/server'; import { GetInfoResponse, InstallPackageResponse, - MessageResponse, DeletePackageResponse, GetCategoriesResponse, GetPackagesResponse, @@ -35,8 +34,9 @@ import { getFile, getPackageInfo, handleInstallPackageFailure, - installPackage, isBulkInstallError, + installPackageFromRegistry, + installPackageByUpload, removeInstallation, getLimitedPackages, getInstallationObject, @@ -148,7 +148,7 @@ export const installPackageFromRegistryHandler: RequestHandler< const { pkgName, pkgVersion } = splitPkgKey(pkgkey); const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName }); try { - const res = await installPackage({ + const res = await installPackageFromRegistry({ savedObjectsClient, pkgkey, callCluster, @@ -212,10 +212,24 @@ export const installPackageByUploadHandler: RequestHandler< undefined, TypeOf > = async (context, request, response) => { - const body: MessageResponse = { - response: 'package upload was received ok, but not installed (not implemented yet)', - }; - return response.ok({ body }); + const savedObjectsClient = context.core.savedObjects.client; + const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser; + const contentType = request.headers['content-type'] as string; // from types it could also be string[] or undefined but this is checked later + const archiveBuffer = Buffer.from(request.body); + try { + const res = await installPackageByUpload({ + savedObjectsClient, + callCluster, + archiveBuffer, + contentType, + }); + const body: InstallPackageResponse = { + response: res, + }; + return response.ok({ body }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); + } }; export const deletePackageHandler: RequestHandler, + Installation +> = (installationDoc) => { + installationDoc.attributes.install_source = 'registry'; + + return installationDoc; +}; diff --git a/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts b/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts index 8ae151577fefa..2481655ccdc6f 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts @@ -167,6 +167,12 @@ export async function createAgentActionFromPolicyAction( function getPollingTimeoutMs() { const pollingTimeoutMs = appContextService.getConfig()?.fleet.pollingRequestTimeout ?? 0; + + // If polling timeout is too short do not use margin + if (pollingTimeoutMs <= AGENT_POLLING_REQUEST_TIMEOUT_MARGIN_MS) { + return pollingTimeoutMs; + } + // Set a timeout 20s before the real timeout to have a chance to respond an empty response before socket timeout return Math.max( pollingTimeoutMs - AGENT_POLLING_REQUEST_TIMEOUT_MARGIN_MS, diff --git a/x-pack/plugins/ingest_manager/server/services/agents/crud.ts b/x-pack/plugins/ingest_manager/server/services/agents/crud.ts index c941b0512e597..90db6c4b17713 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/crud.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/crud.ts @@ -5,10 +5,12 @@ */ import Boom from 'boom'; import { SavedObjectsClientContract } from 'src/core/server'; +import { isAgentUpgradeable } from '../../../common'; import { AGENT_SAVED_OBJECT_TYPE, AGENT_EVENT_SAVED_OBJECT_TYPE } from '../../constants'; import { AgentSOAttributes, Agent, AgentEventSOAttributes, ListWithKuery } from '../../types'; import { escapeSearchQueryPhrase, normalizeKuery, findAllSOs } from '../saved_object'; import { savedObjectToAgent } from './saved_objects'; +import { appContextService } from '../../services'; const ACTIVE_AGENT_CONDITION = `${AGENT_SAVED_OBJECT_TYPE}.attributes.active:true`; const INACTIVE_AGENT_CONDITION = `NOT (${ACTIVE_AGENT_CONDITION})`; @@ -41,6 +43,7 @@ export async function listAgents( sortOrder = 'desc', kuery, showInactive = false, + showUpgradeable, } = options; const filters = []; @@ -52,7 +55,7 @@ export async function listAgents( filters.push(ACTIVE_AGENT_CONDITION); } - const { saved_objects: agentSOs, total } = await soClient.find({ + let { saved_objects: agentSOs, total } = await soClient.find({ type: AGENT_SAVED_OBJECT_TYPE, filter: _joinFilters(filters), sortField, @@ -60,6 +63,14 @@ export async function listAgents( page, perPage, }); + // filtering for a range on the version string will not work, + // nor does filtering on a flattened field (local_metadata), so filter here + if (showUpgradeable) { + agentSOs = agentSOs.filter((agent) => + isAgentUpgradeable(savedObjectToAgent(agent), appContextService.getKibanaVersion()) + ); + total = agentSOs.length; + } return { agents: agentSOs.map(savedObjectToAgent), diff --git a/x-pack/plugins/ingest_manager/server/services/agents/upgrade.ts b/x-pack/plugins/ingest_manager/server/services/agents/upgrade.ts index cee3bc69f25db..612ebf9c11ab3 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/upgrade.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/upgrade.ts @@ -7,7 +7,8 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { AgentSOAttributes, AgentAction, AgentActionSOAttributes } from '../../types'; import { AGENT_ACTION_SAVED_OBJECT_TYPE, AGENT_SAVED_OBJECT_TYPE } from '../../constants'; -import { createAgentAction } from './actions'; +import { bulkCreateAgentActions, createAgentAction } from './actions'; +import { getAgents, listAllAgents } from './crud'; export async function sendUpgradeAgentAction({ soClient, @@ -18,7 +19,7 @@ export async function sendUpgradeAgentAction({ soClient: SavedObjectsClientContract; agentId: string; version: string; - sourceUri: string; + sourceUri: string | undefined; }) { const now = new Date().toISOString(); const data = { @@ -50,12 +51,62 @@ export async function ackAgentUpgraded( if (!version) throw new Error('version missing from UPGRADE action'); await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentAction.agent_id, { upgraded_at: new Date().toISOString(), - local_metadata: { - elastic: { - agent: { - version, - }, - }, - }, + upgrade_started_at: undefined, }); } + +export async function sendUpgradeAgentsActions( + soClient: SavedObjectsClientContract, + options: + | { + agentIds: string[]; + sourceUri: string | undefined; + version: string; + } + | { + kuery: string; + sourceUri: string | undefined; + version: string; + } +) { + // Filter out agents currently unenrolling, agents unenrolled + const agents = + 'agentIds' in options + ? await getAgents(soClient, options.agentIds) + : ( + await listAllAgents(soClient, { + kuery: options.kuery, + showInactive: false, + }) + ).agents; + const agentsToUpdate = agents.filter( + (agent) => !agent.unenrollment_started_at && !agent.unenrolled_at + ); + const now = new Date().toISOString(); + const data = { + version: options.version, + source_uri: options.sourceUri, + }; + // Create upgrade action for each agent + await bulkCreateAgentActions( + soClient, + agentsToUpdate.map((agent) => ({ + agent_id: agent.id, + created_at: now, + data, + ack_data: data, + type: 'UPGRADE', + })) + ); + + return await soClient.bulkUpdate( + agentsToUpdate.map((agent) => ({ + type: AGENT_SAVED_OBJECT_TYPE, + id: agent.id, + attributes: { + upgraded_at: undefined, + upgrade_started_at: now, + }, + })) + ); +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/archive/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/archive/index.ts new file mode 100644 index 0000000000000..91ed489b3a5bb --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/archive/index.ts @@ -0,0 +1,332 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import yaml from 'js-yaml'; +import { uniq } from 'lodash'; + +import { + ArchivePackage, + RegistryPolicyTemplate, + RegistryDataStream, + RegistryInput, + RegistryStream, + RegistryVarsEntry, +} from '../../../../common/types'; +import { PackageInvalidArchiveError, PackageUnsupportedMediaTypeError } from '../../../errors'; +import { pkgToPkgKey } from '../registry'; +import { cacheGet, cacheSet, setArchiveFilelist } from '../registry/cache'; +import { unzipBuffer, untarBuffer, ArchiveEntry } from '../registry/extract'; + +export async function loadArchivePackage({ + archiveBuffer, + contentType, +}: { + archiveBuffer: Buffer; + contentType: string; +}): Promise<{ paths: string[]; archivePackageInfo: ArchivePackage }> { + const paths = await unpackArchiveToCache(archiveBuffer, contentType); + const archivePackageInfo = parseAndVerifyArchive(paths); + setArchiveFilelist(archivePackageInfo.name, archivePackageInfo.version, paths); + + return { + paths, + archivePackageInfo, + }; +} + +function getBufferExtractorForContentType(contentType: string) { + if (contentType === 'application/gzip') { + return untarBuffer; + } else if (contentType === 'application/zip') { + return unzipBuffer; + } else { + throw new PackageUnsupportedMediaTypeError( + `Unsupported media type ${contentType}. Please use 'application/gzip' or 'application/zip'` + ); + } +} + +export async function unpackArchiveToCache( + archiveBuffer: Buffer, + contentType: string, + filter = (entry: ArchiveEntry): boolean => true +): Promise { + const bufferExtractor = getBufferExtractorForContentType(contentType); + const paths: string[] = []; + try { + await bufferExtractor(archiveBuffer, filter, (entry: ArchiveEntry) => { + const { path, buffer } = entry; + // skip directories + if (path.slice(-1) === '/') return; + if (buffer) { + cacheSet(path, buffer); + paths.push(path); + } + }); + } catch (error) { + throw new PackageInvalidArchiveError( + `Error during extraction of uploaded package: ${error}. Assumed content type was ${contentType}, check if this matches the archive type.` + ); + } + + // While unpacking a tar.gz file with unzipBuffer() will result in a thrown error in the try-catch above, + // unpacking a zip file with untarBuffer() just results in nothing. + if (paths.length === 0) { + throw new PackageInvalidArchiveError( + `Uploaded archive seems empty. Assumed content type was ${contentType}, check if this matches the archive type.` + ); + } + return paths; +} + +// TODO: everything below performs verification of manifest.yml files, and hence duplicates functionality already implemented in the +// package registry. At some point this should probably be replaced (or enhanced) with verification based on +// https://github.com/elastic/package-spec/ + +function parseAndVerifyArchive(paths: string[]): ArchivePackage { + // The top-level directory must match pkgName-pkgVersion, and no other top-level files or directories may be present + const toplevelDir = paths[0].split('/')[0]; + paths.forEach((path) => { + if (path.split('/')[0] !== toplevelDir) { + throw new PackageInvalidArchiveError('Package contains more than one top-level directory.'); + } + }); + + // The package must contain a manifest file ... + const manifestFile = `${toplevelDir}/manifest.yml`; + const manifestBuffer = cacheGet(manifestFile); + if (!paths.includes(manifestFile) || !manifestBuffer) { + throw new PackageInvalidArchiveError('Package must contain a top-level manifest.yml file.'); + } + + // ... which must be valid YAML + let manifest; + try { + manifest = yaml.load(manifestBuffer.toString()); + } catch (error) { + throw new PackageInvalidArchiveError(`Could not parse top-level package manifest: ${error}.`); + } + + // Package name and version from the manifest must match those from the toplevel directory + const pkgKey = pkgToPkgKey({ name: manifest.name, version: manifest.version }); + if (toplevelDir !== pkgKey) { + throw new PackageInvalidArchiveError( + `Name ${manifest.name} and version ${manifest.version} do not match top-level directory ${toplevelDir}` + ); + } + + const { name, version, description, type, categories, format_version: formatVersion } = manifest; + // check for mandatory fields + if (!(name && version && description && type && categories && formatVersion)) { + throw new PackageInvalidArchiveError( + 'Invalid top-level package manifest: one or more fields missing of name, version, description, type, categories, format_version' + ); + } + + const dataStreams = parseAndVerifyDataStreams(paths, name, version); + const policyTemplates = parseAndVerifyPolicyTemplates(manifest); + + return { + name, + version, + description, + type, + categories, + format_version: formatVersion, + data_streams: dataStreams, + policy_templates: policyTemplates, + }; +} + +function parseAndVerifyDataStreams( + paths: string[], + pkgName: string, + pkgVersion: string +): RegistryDataStream[] { + // A data stream is made up of a subdirectory of name-version/data_stream/, containing a manifest.yml + let dataStreamPaths: string[] = []; + const dataStreams: RegistryDataStream[] = []; + const pkgKey = pkgToPkgKey({ name: pkgName, version: pkgVersion }); + + // pick all paths matching name-version/data_stream/DATASTREAM_PATH/... + // from those, pick all unique data stream paths + paths + .filter((path) => path.startsWith(`${pkgKey}/data_stream/`)) + .forEach((path) => { + const parts = path.split('/'); + if (parts.length > 2 && parts[2]) dataStreamPaths.push(parts[2]); + }); + + dataStreamPaths = uniq(dataStreamPaths); + + dataStreamPaths.forEach((dataStreamPath) => { + const manifestFile = `${pkgKey}/data_stream/${dataStreamPath}/manifest.yml`; + const manifestBuffer = cacheGet(manifestFile); + if (!paths.includes(manifestFile) || !manifestBuffer) { + throw new PackageInvalidArchiveError( + `No manifest.yml file found for data stream '${dataStreamPath}'` + ); + } + + let manifest; + try { + manifest = yaml.load(manifestBuffer.toString()); + } catch (error) { + throw new PackageInvalidArchiveError( + `Could not parse package manifest for data stream '${dataStreamPath}': ${error}.` + ); + } + + const { + title: dataStreamTitle, + release, + ingest_pipeline: ingestPipeline, + type, + dataset, + } = manifest; + if (!(dataStreamTitle && release && type)) { + throw new PackageInvalidArchiveError( + `Invalid manifest for data stream '${dataStreamPath}': one or more fields missing of 'title', 'release', 'type'` + ); + } + const streams = parseAndVerifyStreams(manifest, dataStreamPath); + + // default ingest pipeline name see https://github.com/elastic/package-registry/blob/master/util/dataset.go#L26 + return dataStreams.push({ + dataset: dataset || `${pkgName}.${dataStreamPath}`, + title: dataStreamTitle, + release, + package: pkgName, + ingest_pipeline: ingestPipeline || 'default', + path: dataStreamPath, + type, + streams, + }); + }); + + return dataStreams; +} + +function parseAndVerifyStreams(manifest: any, dataStreamPath: string): RegistryStream[] { + const streams: RegistryStream[] = []; + const manifestStreams = manifest.streams; + if (manifestStreams && manifestStreams.length > 0) { + manifestStreams.forEach((manifestStream: any) => { + const { + input, + title: streamTitle, + description, + enabled, + vars: manifestVars, + template_path: templatePath, + } = manifestStream; + if (!(input && streamTitle)) { + throw new PackageInvalidArchiveError( + `Invalid manifest for data stream ${dataStreamPath}: stream is missing one or more fields of: input, title` + ); + } + const vars = parseAndVerifyVars(manifestVars, `data stream ${dataStreamPath}`); + // default template path name see https://github.com/elastic/package-registry/blob/master/util/dataset.go#L143 + streams.push({ + input, + title: streamTitle, + description, + enabled, + vars, + template_path: templatePath || 'stream.yml.hbs', + }); + }); + } + return streams; +} + +function parseAndVerifyVars(manifestVars: any[], location: string): RegistryVarsEntry[] { + const vars: RegistryVarsEntry[] = []; + if (manifestVars && manifestVars.length > 0) { + manifestVars.forEach((manifestVar) => { + const { + name, + title: varTitle, + description, + type, + required, + show_user: showUser, + multi, + def, + os, + } = manifestVar; + if (!(name && type)) { + throw new PackageInvalidArchiveError( + `Invalid var definition for ${location}: one of mandatory fields 'name' and 'type' missing in var: ${manifestVar}` + ); + } + vars.push({ + name, + title: varTitle, + description, + type, + required, + show_user: showUser, + multi, + default: def, + os, + }); + }); + } + return vars; +} + +function parseAndVerifyPolicyTemplates(manifest: any): RegistryPolicyTemplate[] { + const policyTemplates: RegistryPolicyTemplate[] = []; + const manifestPolicyTemplates = manifest.policy_templates; + if (manifestPolicyTemplates && manifestPolicyTemplates > 0) { + manifestPolicyTemplates.forEach((policyTemplate: any) => { + const { name, title: policyTemplateTitle, description, inputs, multiple } = policyTemplate; + if (!(name && policyTemplateTitle && description && inputs)) { + throw new PackageInvalidArchiveError( + `Invalid top-level manifest: one of mandatory fields 'name', 'title', 'description', 'input' missing in policy template: ${policyTemplate}` + ); + } + + const parsedInputs = parseAndVerifyInputs(inputs, `config template ${name}`); + + // defaults to true if undefined, but may be explicitly set to false. + let parsedMultiple = true; + if (typeof multiple === 'boolean' && multiple === false) parsedMultiple = false; + + policyTemplates.push({ + name, + title: policyTemplateTitle, + description, + inputs: parsedInputs, + multiple: parsedMultiple, + }); + }); + } + return policyTemplates; +} + +function parseAndVerifyInputs(manifestInputs: any, location: string): RegistryInput[] { + const inputs: RegistryInput[] = []; + if (manifestInputs && manifestInputs.length > 0) { + manifestInputs.forEach((input: any) => { + const { type, title: inputTitle, description, vars } = input; + if (!(type && inputTitle)) { + throw new PackageInvalidArchiveError( + `Invalid top-level manifest: one of mandatory fields 'type', 'title' missing in input: ${input}` + ); + } + const parsedVars = parseAndVerifyVars(vars, location); + inputs.push({ + type, + title: inputTitle, + description, + vars: parsedVars, + }); + }); + } + return inputs; +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/install.ts index 6088bcb71f878..43c0179c0aa8a 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/install.ts @@ -9,7 +9,7 @@ import { EsAssetReference, RegistryDataStream, ElasticsearchAssetType, - RegistryPackage, + InstallablePackage, } from '../../../../types'; import * as Registry from '../../registry'; import { CallESAsCurrentUser } from '../../../../types'; @@ -22,7 +22,7 @@ interface RewriteSubstitution { } export const installPipelines = async ( - registryPackage: RegistryPackage, + installablePackage: InstallablePackage, paths: string[], callCluster: CallESAsCurrentUser, savedObjectsClient: SavedObjectsClientContract @@ -30,7 +30,7 @@ export const installPipelines = async ( // unlike other ES assets, pipeline names are versioned so after a template is updated // it can be created pointing to the new template, without removing the old one and effecting data // so do not remove the currently installed pipelines here - const dataStreams = registryPackage.data_streams; + const dataStreams = installablePackage.data_streams; if (!dataStreams?.length) return []; const pipelinePaths = paths.filter((path) => isPipeline(path)); // get and save pipeline refs before installing pipelines @@ -43,14 +43,14 @@ export const installPipelines = async ( const nameForInstallation = getPipelineNameForInstallation({ pipelineName: name, dataStream, - packageVersion: registryPackage.version, + packageVersion: installablePackage.version, }); return { id: nameForInstallation, type: ElasticsearchAssetType.ingestPipeline }; }); acc.push(...pipelineObjectRefs); return acc; }, []); - await saveInstalledEsRefs(savedObjectsClient, registryPackage.name, pipelineRefs); + await saveInstalledEsRefs(savedObjectsClient, installablePackage.name, pipelineRefs); const pipelines = dataStreams.reduce>>((acc, dataStream) => { if (dataStream.ingest_pipeline) { acc.push( @@ -58,7 +58,7 @@ export const installPipelines = async ( dataStream, callCluster, paths: pipelinePaths, - pkgVersion: registryPackage.version, + pkgVersion: installablePackage.version, }) ); } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts index 8f80feb268910..d32d5b8093c52 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts @@ -8,10 +8,10 @@ import Boom from 'boom'; import { SavedObjectsClientContract } from 'src/core/server'; import { RegistryDataStream, - RegistryPackage, ElasticsearchAssetType, TemplateRef, RegistryElasticsearch, + InstallablePackage, } from '../../../../types'; import { CallESAsCurrentUser } from '../../../../types'; import { Field, loadFieldsFromYaml, processFields } from '../../fields/field'; @@ -21,7 +21,7 @@ import * as Registry from '../../registry'; import { removeAssetsFromInstalledEsByType, saveInstalledEsRefs } from '../../packages/install'; export const installTemplates = async ( - registryPackage: RegistryPackage, + installablePackage: InstallablePackage, callCluster: CallESAsCurrentUser, paths: string[], savedObjectsClient: SavedObjectsClientContract @@ -35,11 +35,11 @@ export const installTemplates = async ( // remove package installation's references to index templates await removeAssetsFromInstalledEsByType( savedObjectsClient, - registryPackage.name, + installablePackage.name, ElasticsearchAssetType.indexTemplate ); // build templates per data stream from yml files - const dataStreams = registryPackage.data_streams; + const dataStreams = installablePackage.data_streams; if (!dataStreams) return []; // get template refs to save const installedTemplateRefs = dataStreams.map((dataStream) => ({ @@ -48,14 +48,14 @@ export const installTemplates = async ( })); // add package installation's references to index templates - await saveInstalledEsRefs(savedObjectsClient, registryPackage.name, installedTemplateRefs); + await saveInstalledEsRefs(savedObjectsClient, installablePackage.name, installedTemplateRefs); if (dataStreams) { const installTemplatePromises = dataStreams.reduce>>( (acc, dataStream) => { acc.push( installTemplateForDataStream({ - pkg: registryPackage, + pkg: installablePackage, callCluster, dataStream, }) @@ -171,7 +171,7 @@ export async function installTemplateForDataStream({ callCluster, dataStream, }: { - pkg: RegistryPackage; + pkg: InstallablePackage; callCluster: CallESAsCurrentUser; dataStream: RegistryDataStream; }): Promise { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/install.ts index d8aff10492595..89811783a7f79 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/install.ts @@ -11,7 +11,7 @@ import * as Registry from '../../registry'; import { ElasticsearchAssetType, EsAssetReference, - RegistryPackage, + InstallablePackage, } from '../../../../../common/types/models'; import { CallESAsCurrentUser } from '../../../../types'; import { getInstallation } from '../../packages'; @@ -24,14 +24,14 @@ interface TransformInstallation { } export const installTransform = async ( - registryPackage: RegistryPackage, + installablePackage: InstallablePackage, paths: string[], callCluster: CallESAsCurrentUser, savedObjectsClient: SavedObjectsClientContract ) => { const installation = await getInstallation({ savedObjectsClient, - pkgName: registryPackage.name, + pkgName: installablePackage.name, }); let previousInstalledTransformEsAssets: EsAssetReference[] = []; if (installation) { @@ -46,13 +46,13 @@ export const installTransform = async ( previousInstalledTransformEsAssets.map((asset) => asset.id) ); - const installNameSuffix = `${registryPackage.version}`; + const installNameSuffix = `${installablePackage.version}`; const transformPaths = paths.filter((path) => isTransform(path)); let installedTransforms: EsAssetReference[] = []; if (transformPaths.length > 0) { const transformRefs = transformPaths.reduce((acc, path) => { acc.push({ - id: getTransformNameForInstallation(registryPackage, path, installNameSuffix), + id: getTransformNameForInstallation(installablePackage, path, installNameSuffix), type: ElasticsearchAssetType.transform, }); @@ -60,11 +60,15 @@ export const installTransform = async ( }, []); // get and save transform refs before installing transforms - await saveInstalledEsRefs(savedObjectsClient, registryPackage.name, transformRefs); + await saveInstalledEsRefs(savedObjectsClient, installablePackage.name, transformRefs); const transforms: TransformInstallation[] = transformPaths.map((path: string) => { return { - installationName: getTransformNameForInstallation(registryPackage, path, installNameSuffix), + installationName: getTransformNameForInstallation( + installablePackage, + path, + installNameSuffix + ), content: getAsset(path).toString('utf-8'), }; }); @@ -79,14 +83,14 @@ export const installTransform = async ( if (previousInstalledTransformEsAssets.length > 0) { const currentInstallation = await getInstallation({ savedObjectsClient, - pkgName: registryPackage.name, + pkgName: installablePackage.name, }); // remove the saved object reference await deleteTransformRefs( savedObjectsClient, currentInstallation?.installed_es || [], - registryPackage.name, + installablePackage.name, previousInstalledTransformEsAssets.map((asset) => asset.id), installedTransforms.map((installed) => installed.id) ); @@ -123,12 +127,12 @@ async function handleTransformInstall({ } const getTransformNameForInstallation = ( - registryPackage: RegistryPackage, + installablePackage: InstallablePackage, path: string, suffix: string ) => { const pathPaths = path.split('/'); const filename = pathPaths?.pop()?.split('.')[0]; const folderName = pathPaths?.pop(); - return `${registryPackage.name}.${folderName}-${filename}-${suffix}`; + return `${installablePackage.name}.${folderName}-${filename}-${suffix}`; }; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts index 5913302e77ba6..06091ab3cedb0 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts @@ -5,7 +5,7 @@ */ import { safeLoad } from 'js-yaml'; -import { RegistryPackage } from '../../../types'; +import { InstallablePackage } from '../../../types'; import { getAssetsData } from '../packages/assets'; // This should become a copy of https://github.com/elastic/beats/blob/d9a4c9c240a9820fab15002592e5bb6db318543b/libbeat/mapping/field.go#L39 @@ -253,7 +253,7 @@ const isFields = (path: string) => { */ export const loadFieldsFromYaml = async ( - pkg: RegistryPackage, + pkg: InstallablePackage, datasetName?: string ): Promise => { // Fetch all field definition files diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts index bde542412f123..2aa28d23cf857 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts @@ -89,7 +89,9 @@ export async function installIndexPatterns( // TODO: move to install package // cache all installed packages if they don't exist const packagePromises = installedPackages.map((pkg) => - Registry.ensureCachedArchiveInfo(pkg.pkgName, pkg.pkgVersion) + // TODO: this hard-codes 'registry' as installSource, so uploaded packages are ignored + // and their fields will be removed from the generated index patterns after this runs. + Registry.ensureCachedArchiveInfo(pkg.pkgName, pkg.pkgVersion, 'registry') ); await Promise.all(packagePromises); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.test.ts index 78b42b03be831..eb43bef72db70 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.test.ts @@ -4,34 +4,41 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RegistryPackage } from '../../../types'; +import { InstallablePackage } from '../../../types'; import { getAssets } from './assets'; +import { getArchiveFilelist } from '../registry/cache'; + +jest.mock('../registry/cache', () => { + return { + getArchiveFilelist: jest.fn(), + }; +}); + +const mockedGetArchiveFilelist = getArchiveFilelist as jest.Mock; +mockedGetArchiveFilelist.mockImplementation(() => [ + 'coredns-1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-plaintext.json', + 'coredns-1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-json.json', +]); const tests = [ { package: { - assets: [ - '/package/coredns/1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-plaintext.json', - '/package/coredns/1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-json.json', - ], - path: '/package/coredns/1.0.1', + name: 'coredns', + version: '1.0.1', }, dataset: 'log', filter: (path: string) => { return true; }, expected: [ - '/package/coredns/1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-plaintext.json', - '/package/coredns/1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-json.json', + 'coredns-1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-plaintext.json', + 'coredns-1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-json.json', ], }, { package: { - assets: [ - '/package/coredns-1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-plaintext.json', - '/package/coredns-1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-json.json', - ], - path: '/package/coredns/1.0.1', + name: 'coredns', + version: '1.0.1', }, // Non existant dataset dataset: 'foo', @@ -42,10 +49,8 @@ const tests = [ }, { package: { - assets: [ - '/package/coredns-1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-plaintext.json', - '/package/coredns-1.0.1/data_stream/log/elasticsearch/ingest-pipeline/pipeline-json.json', - ], + name: 'coredns', + version: '1.0.1', }, // Filter which does not exist filter: (path: string) => { @@ -57,8 +62,8 @@ const tests = [ test('testGetAssets', () => { for (const value of tests) { - // as needed to pretent it is a RegistryPackage - const assets = getAssets(value.package as RegistryPackage, value.filter, value.dataset); + // as needed to pretend it is an InstallablePackage + const assets = getAssets(value.package as InstallablePackage, value.filter, value.dataset); expect(assets).toStrictEqual(value.expected); } }); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts index a8abc12917781..856f04c0c9b67 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RegistryPackage } from '../../../types'; +import { InstallablePackage } from '../../../types'; import * as Registry from '../registry'; -import { ensureCachedArchiveInfo } from '../registry'; +import { getArchiveFilelist } from '../registry/cache'; // paths from RegistryPackage are routes to the assets on EPR // e.g. `/package/nginx/1.2.0/data_stream/access/fields/fields.yml` @@ -14,30 +14,26 @@ import { ensureCachedArchiveInfo } from '../registry'; // e.g. `nginx-1.2.0/data_stream/access/fields/fields.yml` // RegistryPackage paths have a `/package/` prefix compared to ArchiveEntry paths // and different package and version structure -const EPR_PATH_PREFIX = '/package'; -function registryPathToArchivePath(registryPath: RegistryPackage['path']): string { - const path = registryPath.replace(`${EPR_PATH_PREFIX}/`, ''); - const [pkgName, pkgVersion] = path.split('/'); - return path.replace(`${pkgName}/${pkgVersion}`, `${pkgName}-${pkgVersion}`); -} export function getAssets( - packageInfo: RegistryPackage, + packageInfo: InstallablePackage, filter = (path: string): boolean => true, datasetName?: string ): string[] { const assets: string[] = []; - if (!packageInfo?.assets) return assets; + const paths = getArchiveFilelist(packageInfo.name, packageInfo.version); + // TODO: might be better to throw a PackageCacheError here + if (!paths || paths.length === 0) return assets; // Skip directories - for (const path of packageInfo.assets) { + for (const path of paths) { if (path.endsWith('/')) { continue; } // if dataset, filter for them if (datasetName) { - const comparePath = `${packageInfo.path}/data_stream/${datasetName}/`; + const comparePath = `${packageInfo.name}-${packageInfo.version}/data_stream/${datasetName}/`; if (!path.includes(comparePath)) { continue; } @@ -52,21 +48,20 @@ export function getAssets( } export async function getAssetsData( - packageInfo: RegistryPackage, + packageInfo: InstallablePackage, filter = (path: string): boolean => true, datasetName?: string ): Promise { // TODO: Needs to be called to fill the cache but should not be required - await ensureCachedArchiveInfo(packageInfo.name, packageInfo.version); + await Registry.ensureCachedArchiveInfo(packageInfo.name, packageInfo.version, 'registry'); // Gather all asset data const assets = getAssets(packageInfo, filter, datasetName); - const entries: Registry.ArchiveEntry[] = assets.map((registryPath) => { - const archivePath = registryPathToArchivePath(registryPath); - const buffer = Registry.getAsset(archivePath); + const entries: Registry.ArchiveEntry[] = assets.map((path) => { + const buffer = Registry.getAsset(path); - return { path: registryPath, buffer }; + return { path, buffer }; }); return entries; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/ensure_installed_default_packages.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/ensure_installed_default_packages.test.ts index f0b487ad59774..aaff5df39bac3 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/ensure_installed_default_packages.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/ensure_installed_default_packages.test.ts @@ -49,6 +49,7 @@ const mockInstallation: SavedObject = { install_status: 'installed', install_version: '1.0.0', install_started_at: new Date().toISOString(), + install_source: 'registry', }, }; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts index 2d11b6157804f..74ee25eace736 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts @@ -101,11 +101,14 @@ export async function getPackageInfo(options: { pkgVersion: string; }): Promise { const { savedObjectsClient, pkgName, pkgVersion } = options; - const [item, savedObject, latestPackage, assets] = await Promise.all([ - Registry.fetchInfo(pkgName, pkgVersion), + const [ + savedObject, + latestPackage, + { paths: assets, registryPackageInfo: item }, + ] = await Promise.all([ getInstallationObject({ savedObjectsClient, pkgName }), Registry.fetchFindLatestPackage(pkgName), - Registry.getArchiveInfo(pkgName, pkgVersion), + Registry.loadRegistryPackage(pkgName, pkgVersion), ]); // add properties that aren't (or aren't yet) on Registry response diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/get_install_type.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/get_install_type.test.ts index cce4b7fee8fd7..a04bfaafe7570 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/get_install_type.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/get_install_type.test.ts @@ -21,6 +21,7 @@ const mockInstallation: SavedObject = { install_status: 'installed', install_version: '1.0.0', install_started_at: new Date().toISOString(), + install_source: 'registry', }, }; const mockInstallationUpdateFail: SavedObject = { @@ -37,6 +38,7 @@ const mockInstallationUpdateFail: SavedObject = { install_status: 'installing', install_version: '1.0.1', install_started_at: new Date().toISOString(), + install_source: 'registry', }, }; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts index 94aa969c2d2b8..92070f3c2fafc 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts @@ -27,9 +27,10 @@ export { export { BulkInstallResponse, - handleInstallPackageFailure, - installPackage, IBulkInstallPackageError, + handleInstallPackageFailure, + installPackageFromRegistry, + installPackageByUpload, ensureInstalledPackage, } from './install'; export { removeInstallation } from './remove'; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts index d7262ebb66b2e..a7514d1075d78 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts @@ -8,7 +8,7 @@ import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; import semver from 'semver'; import Boom from 'boom'; import { UnwrapPromise } from '@kbn/utility-types'; -import { BulkInstallPackageInfo } from '../../../../common'; +import { BulkInstallPackageInfo, InstallablePackage, InstallSource } from '../../../../common'; import { PACKAGES_SAVED_OBJECT_TYPE, MAX_TIME_COMPLETE_INSTALL } from '../../../constants'; import { AssetReference, @@ -42,10 +42,15 @@ import { } from '../kibana/assets/install'; import { updateCurrentWriteIndices } from '../elasticsearch/template/template'; import { deleteKibanaSavedObjectsAssets, removeInstallation } from './remove'; -import { IngestManagerError, PackageOutdatedError } from '../../../errors'; +import { + IngestManagerError, + PackageOperationNotSupportedError, + PackageOutdatedError, +} from '../../../errors'; import { getPackageSavedObjects } from './get'; import { installTransform } from '../elasticsearch/transform/install'; import { appContextService } from '../../app_context'; +import { loadArchivePackage } from '../archive'; export async function installLatestPackage(options: { savedObjectsClient: SavedObjectsClientContract; @@ -59,7 +64,7 @@ export async function installLatestPackage(options: { name: latestPackage.name, version: latestPackage.version, }); - return installPackage({ savedObjectsClient, pkgkey, callCluster }); + return installPackageFromRegistry({ savedObjectsClient, pkgkey, callCluster }); } catch (err) { throw err; } @@ -155,7 +160,7 @@ export async function handleInstallPackageFailure({ } const prevVersion = `${pkgName}-${installedPkg.attributes.version}`; logger.error(`rolling back to ${prevVersion} after error installing ${pkgkey}`); - await installPackage({ + await installPackageFromRegistry({ savedObjectsClient, pkgkey: prevVersion, callCluster, @@ -193,7 +198,7 @@ export async function upgradePackage({ }); try { - const assets = await installPackage({ savedObjectsClient, pkgkey, callCluster }); + const assets = await installPackageFromRegistry({ savedObjectsClient, pkgkey, callCluster }); return { name: pkgToUpgrade, newVersion: latestPkg.version, @@ -232,7 +237,7 @@ interface InstallPackageParams { force?: boolean; } -export async function installPackage({ +export async function installPackageFromRegistry({ savedObjectsClient, pkgkey, callCluster, @@ -254,12 +259,96 @@ export async function installPackage({ if (semver.lt(pkgVersion, latestPackage.version) && !force && !installOutOfDateVersionOk) { throw new PackageOutdatedError(`${pkgkey} is out-of-date and cannot be installed or updated`); } - const paths = await Registry.getArchiveInfo(pkgName, pkgVersion); - const registryPackageInfo = await Registry.fetchInfo(pkgName, pkgVersion); + + const { paths, registryPackageInfo } = await Registry.loadRegistryPackage(pkgName, pkgVersion); const removable = !isRequiredPackage(pkgName); const { internal = false } = registryPackageInfo; - const toSaveESIndexPatterns = generateESIndexPatterns(registryPackageInfo.data_streams); + const installSource = 'registry'; + + return installPackage({ + savedObjectsClient, + callCluster, + pkgName, + pkgVersion, + installedPkg, + paths, + removable, + internal, + packageInfo: registryPackageInfo, + installType, + installSource, + }); +} + +export async function installPackageByUpload({ + savedObjectsClient, + callCluster, + archiveBuffer, + contentType, +}: { + savedObjectsClient: SavedObjectsClientContract; + callCluster: CallESAsCurrentUser; + archiveBuffer: Buffer; + contentType: string; +}): Promise { + const { paths, archivePackageInfo } = await loadArchivePackage({ archiveBuffer, contentType }); + const installedPkg = await getInstallationObject({ + savedObjectsClient, + pkgName: archivePackageInfo.name, + }); + const installType = getInstallType({ pkgVersion: archivePackageInfo.version, installedPkg }); + if (installType !== 'install') { + throw new PackageOperationNotSupportedError( + `Package upload only supports fresh installations. Package ${archivePackageInfo.name} is already installed, please uninstall first.` + ); + } + + const removable = !isRequiredPackage(archivePackageInfo.name); + const { internal = false } = archivePackageInfo; + const installSource = 'upload'; + + return installPackage({ + savedObjectsClient, + callCluster, + pkgName: archivePackageInfo.name, + pkgVersion: archivePackageInfo.version, + installedPkg, + paths, + removable, + internal, + packageInfo: archivePackageInfo, + installType, + installSource, + }); +} + +async function installPackage({ + savedObjectsClient, + callCluster, + pkgName, + pkgVersion, + installedPkg, + paths, + removable, + internal, + packageInfo, + installType, + installSource, +}: { + savedObjectsClient: SavedObjectsClientContract; + callCluster: CallESAsCurrentUser; + pkgName: string; + pkgVersion: string; + installedPkg?: SavedObject; + paths: string[]; + removable: boolean; + internal: boolean; + packageInfo: InstallablePackage; + installType: InstallType; + installSource: InstallSource; +}): Promise { + const toSaveESIndexPatterns = generateESIndexPatterns(packageInfo.data_streams); // add the package installation to the saved object. // if some installation already exists, just update install info @@ -273,12 +362,14 @@ export async function installPackage({ installed_kibana: [], installed_es: [], toSaveESIndexPatterns, + installSource, }); } else { await savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { install_version: pkgVersion, install_status: 'installing', install_started_at: new Date().toISOString(), + install_source: installSource, }); } const installIndexPatternPromise = installIndexPatterns(savedObjectsClient, pkgName, pkgVersion); @@ -309,14 +400,14 @@ export async function installPackage({ // installs versionized pipelines without removing currently installed ones const installedPipelines = await installPipelines( - registryPackageInfo, + packageInfo, paths, callCluster, savedObjectsClient ); // install or update the templates referencing the newly installed pipelines const installedTemplates = await installTemplates( - registryPackageInfo, + packageInfo, callCluster, paths, savedObjectsClient @@ -326,7 +417,7 @@ export async function installPackage({ await updateCurrentWriteIndices(callCluster, installedTemplates); const installedTransforms = await installTransform( - registryPackageInfo, + packageInfo, paths, callCluster, savedObjectsClient @@ -388,6 +479,7 @@ export async function createInstallation(options: { installed_kibana: KibanaAssetReference[]; installed_es: EsAssetReference[]; toSaveESIndexPatterns: Record; + installSource: InstallSource; }) { const { savedObjectsClient, @@ -398,6 +490,7 @@ export async function createInstallation(options: { installed_kibana: installedKibana, installed_es: installedEs, toSaveESIndexPatterns, + installSource, } = options; await savedObjectsClient.create( PACKAGES_SAVED_OBJECT_TYPE, @@ -412,6 +505,7 @@ export async function createInstallation(options: { install_version: pkgVersion, install_status: 'installing', install_started_at: new Date().toISOString(), + install_source: installSource, }, { id: pkgName, overwrite: true } ); @@ -477,7 +571,7 @@ export async function ensurePackagesCompletedInstall( const pkgkey = `${pkg.attributes.name}-${pkg.attributes.install_version}`; // reinstall package if (elapsedTime > MAX_TIME_COMPLETE_INSTALL) { - acc.push(installPackage({ savedObjectsClient, pkgkey, callCluster })); + acc.push(installPackageFromRegistry({ savedObjectsClient, pkgkey, callCluster })); } return acc; }, []); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts index 2434ebf27aa5d..417f2871a6cbf 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts @@ -18,7 +18,7 @@ import { deletePipeline } from '../elasticsearch/ingest_pipeline/'; import { installIndexPatterns } from '../kibana/index_pattern/install'; import { deleteTransforms } from '../elasticsearch/transform/remove'; import { packagePolicyService, appContextService } from '../..'; -import { splitPkgKey, deletePackageCache, getArchiveInfo } from '../registry'; +import { splitPkgKey, deletePackageCache } from '../registry'; export async function removeInstallation(options: { savedObjectsClient: SavedObjectsClientContract; @@ -57,8 +57,7 @@ export async function removeInstallation(options: { // remove the package archive and its contents from the cache so that a reinstall fetches // a fresh copy from the registry - const paths = await getArchiveInfo(pkgName, pkgVersion); - deletePackageCache(pkgName, pkgVersion, paths); + deletePackageCache(pkgName, pkgVersion); // successful delete's in SO client return {}. return something more useful return installedAssets; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts index b7c1e8c2069d6..695db9db73fa2 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts @@ -12,12 +12,12 @@ export const cacheHas = (key: string) => cache.has(key); export const cacheClear = () => cache.clear(); export const cacheDelete = (key: string) => cache.delete(key); -const archiveLocationCache: Map = new Map(); -export const getArchiveLocation = (name: string, version: string) => - archiveLocationCache.get(pkgToPkgKey({ name, version })); +const archiveFilelistCache: Map = new Map(); +export const getArchiveFilelist = (name: string, version: string) => + archiveFilelistCache.get(pkgToPkgKey({ name, version })); -export const setArchiveLocation = (name: string, version: string, location: string) => - archiveLocationCache.set(pkgToPkgKey({ name, version }), location); +export const setArchiveFilelist = (name: string, version: string, paths: string[]) => + archiveFilelistCache.set(pkgToPkgKey({ name, version }), paths); -export const deleteArchiveLocation = (name: string, version: string) => - archiveLocationCache.delete(pkgToPkgKey({ name, version })); +export const deleteArchiveFilelist = (name: string, version: string) => + archiveFilelistCache.delete(pkgToPkgKey({ name, version })); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts index 2fd9175549026..ba51636c13f36 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts @@ -6,17 +6,8 @@ import { AssetParts } from '../../../types'; import { getBufferExtractor, pathParts, splitPkgKey } from './index'; -import { getArchiveLocation } from './cache'; import { untarBuffer, unzipBuffer } from './extract'; -jest.mock('./cache', () => { - return { - getArchiveLocation: jest.fn(), - }; -}); - -const mockedGetArchiveLocation = getArchiveLocation as jest.Mock; - const testPaths = [ { path: 'foo-1.1.0/service/type/file.yml', @@ -92,19 +83,13 @@ describe('splitPkgKey tests', () => { }); describe('getBufferExtractor', () => { - it('throws if the archive has not been downloaded/cached yet', () => { - expect(() => getBufferExtractor('missing', '1.2.3')).toThrow('no archive location'); - }); - it('returns unzipBuffer if the archive key ends in .zip', () => { - mockedGetArchiveLocation.mockImplementation(() => '.zip'); - const extractor = getBufferExtractor('will-use-mocked-key', 'a.b.c'); + const extractor = getBufferExtractor('.zip'); expect(extractor).toBe(unzipBuffer); }); it('returns untarBuffer if the key ends in anything else', () => { - mockedGetArchiveLocation.mockImplementation(() => 'xyz'); - const extractor = getBufferExtractor('will-use-mocked-key', 'a.b.c'); + const extractor = getBufferExtractor('.xyz'); expect(extractor).toBe(untarBuffer); }); }); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts index 22f1b670b2cc4..66f28fe58599a 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts @@ -12,6 +12,7 @@ import { AssetsGroupedByServiceByType, CategoryId, CategorySummaryList, + InstallSource, KibanaAssetType, RegistryPackage, RegistrySearchResults, @@ -21,17 +22,16 @@ import { cacheGet, cacheSet, cacheDelete, - cacheHas, - getArchiveLocation, - setArchiveLocation, - deleteArchiveLocation, + getArchiveFilelist, + setArchiveFilelist, + deleteArchiveFilelist, } from './cache'; import { ArchiveEntry, untarBuffer, unzipBuffer } from './extract'; import { fetchUrl, getResponse, getResponseStream } from './requests'; import { streamToBuffer } from './streams'; import { getRegistryUrl } from './registry_url'; import { appContextService } from '../..'; -import { PackageNotFoundError } from '../../../errors'; +import { PackageNotFoundError, PackageCacheError } from '../../../errors'; export { ArchiveEntry } from './extract'; @@ -132,14 +132,14 @@ export async function fetchCategories(params?: CategoriesParams): Promise true ): Promise { const paths: string[] = []; - const archiveBuffer = await getOrFetchArchiveBuffer(pkgName, pkgVersion); - const bufferExtractor = getBufferExtractor(pkgName, pkgVersion); + const { archiveBuffer, archivePath } = await fetchArchiveBuffer(pkgName, pkgVersion); + const bufferExtractor = getBufferExtractor(archivePath); await bufferExtractor(archiveBuffer, filter, (entry: ArchiveEntry) => { const { path, buffer } = entry; const { file } = pathParts(path); @@ -153,6 +153,22 @@ export async function getArchiveInfo( return paths; } +export async function loadRegistryPackage( + pkgName: string, + pkgVersion: string +): Promise<{ paths: string[]; registryPackageInfo: RegistryPackage }> { + let paths = getArchiveFilelist(pkgName, pkgVersion); + if (!paths || paths.length === 0) { + paths = await unpackRegistryPackageToCache(pkgName, pkgVersion); + setArchiveFilelist(pkgName, pkgVersion, paths); + } + + // TODO: cache this as well? + const registryPackageInfo = await fetchInfo(pkgName, pkgVersion); + + return { paths, registryPackageInfo }; +} + export function pathParts(path: string): AssetParts { let dataset; @@ -183,45 +199,39 @@ export function pathParts(path: string): AssetParts { } as AssetParts; } -export function getBufferExtractor(pkgName: string, pkgVersion: string) { - const archiveLocation = getArchiveLocation(pkgName, pkgVersion); - if (!archiveLocation) throw new Error(`no archive location for ${pkgName} ${pkgVersion}`); - const isZip = archiveLocation.endsWith('.zip'); +export function getBufferExtractor(archivePath: string) { + const isZip = archivePath.endsWith('.zip'); const bufferExtractor = isZip ? unzipBuffer : untarBuffer; return bufferExtractor; } -async function getOrFetchArchiveBuffer(pkgName: string, pkgVersion: string): Promise { - const key = getArchiveLocation(pkgName, pkgVersion); - let buffer = key && cacheGet(key); - if (!buffer) { - buffer = await fetchArchiveBuffer(pkgName, pkgVersion); - } - - if (buffer) { - return buffer; - } else { - throw new Error(`no archive buffer for ${key}`); - } -} - -export async function ensureCachedArchiveInfo(name: string, version: string) { - const pkgkey = getArchiveLocation(name, version); - if (!pkgkey || !cacheHas(pkgkey)) { - await getArchiveInfo(name, version); +export async function ensureCachedArchiveInfo( + name: string, + version: string, + installSource: InstallSource = 'registry' +) { + const paths = getArchiveFilelist(name, version); + if (!paths || paths.length === 0) { + if (installSource === 'registry') { + await loadRegistryPackage(name, version); + } else { + throw new PackageCacheError( + `Package ${name}-${version} not cached. If it was uploaded, try uninstalling and reinstalling manually.` + ); + } } } -async function fetchArchiveBuffer(pkgName: string, pkgVersion: string): Promise { +async function fetchArchiveBuffer( + pkgName: string, + pkgVersion: string +): Promise<{ archiveBuffer: Buffer; archivePath: string }> { const { download: archivePath } = await fetchInfo(pkgName, pkgVersion); const archiveUrl = `${getRegistryUrl()}${archivePath}`; - const buffer = await getResponseStream(archiveUrl).then(streamToBuffer); + const archiveBuffer = await getResponseStream(archiveUrl).then(streamToBuffer); - setArchiveLocation(pkgName, pkgVersion, archivePath); - cacheSet(archivePath, buffer); - - return buffer; + return { archiveBuffer, archivePath }; } export function getAsset(key: string) { @@ -250,16 +260,14 @@ export function groupPathsByService(paths: string[]): AssetsGroupedByServiceByTy }; } -export const deletePackageCache = (name: string, version: string, paths: string[]) => { - const archiveLocation = getArchiveLocation(name, version); - if (archiveLocation) { - // delete cached archive - cacheDelete(archiveLocation); +export const deletePackageCache = (name: string, version: string) => { + // get cached archive filelist + const paths = getArchiveFilelist(name, version); - // delete cached archive location - deleteArchiveLocation(name, version); - } - // delete cached archive contents - // this has been populated in Registry.getArchiveInfo() - paths.forEach((path) => cacheDelete(path)); + // delete cached archive filelist + deleteArchiveFilelist(name, version); + + // delete cached archive files + // this has been populated in unpackRegistryPackageToCache() + paths?.forEach((path) => cacheDelete(path)); }; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts index 6618220a27085..ff9a7871a7db8 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts @@ -28,16 +28,19 @@ const getDefaultRegistryUrl = (): string => { } }; +// Custom registry URL is currently only for internal Elastic development and is unsupported export const getRegistryUrl = (): string => { const customUrl = appContextService.getConfig()?.registryUrl; - const isGoldPlus = licenseService.isGoldPlus(); + const isEnterprise = licenseService.isEnterprise(); - if (customUrl && isGoldPlus) { + if (customUrl && isEnterprise) { return customUrl; } if (customUrl) { - appContextService.getLogger().warn('Gold license is required to use a custom registry url.'); + appContextService + .getLogger() + .warn('Enterprise license is required to use a custom registry url.'); } return getDefaultRegistryUrl(); diff --git a/x-pack/plugins/ingest_manager/server/types/index.tsx b/x-pack/plugins/ingest_manager/server/types/index.tsx index fc5ba1af196ad..0c070959e3b93 100644 --- a/x-pack/plugins/ingest_manager/server/types/index.tsx +++ b/x-pack/plugins/ingest_manager/server/types/index.tsx @@ -52,6 +52,7 @@ export { KibanaAssetReference, ElasticsearchAssetType, RegistryPackage, + InstallablePackage, AssetType, Installable, KibanaAssetType, @@ -68,6 +69,7 @@ export { Settings, SettingsSOAttributes, InstallType, + InstallSource, // Agent Request types PostAgentEnrollRequest, PostAgentCheckinRequest, diff --git a/x-pack/plugins/ingest_manager/server/types/models/agent.ts b/x-pack/plugins/ingest_manager/server/types/models/agent.ts index 87e9257b7189c..24ac1970cb225 100644 --- a/x-pack/plugins/ingest_manager/server/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/models/agent.ts @@ -31,6 +31,7 @@ const AgentEventBase = { schema.literal('STOPPING'), schema.literal('STOPPED'), schema.literal('DEGRADED'), + schema.literal('UPDATING'), ]), // Action results schema.literal('DATA_DUMP'), diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts index 3866ef095563e..4fd1f3f3e1573 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts @@ -13,6 +13,7 @@ export const GetAgentsRequestSchema = { perPage: schema.number({ defaultValue: 20 }), kuery: schema.maybe(schema.string()), showInactive: schema.boolean({ defaultValue: false }), + showUpgradeable: schema.boolean({ defaultValue: false }), }), }; @@ -58,6 +59,7 @@ export const PostAgentCheckinRequestBodyJSONSchema = { 'DEGRADED', 'DATA_DUMP', 'ACKNOWLEDGED', + 'UPDATING', 'UNKNOWN', ], }, @@ -172,20 +174,28 @@ export const PostAgentUnenrollRequestSchema = { ), }; +export const PostBulkAgentUnenrollRequestSchema = { + body: schema.object({ + agents: schema.oneOf([schema.arrayOf(schema.string()), schema.string()]), + force: schema.maybe(schema.boolean()), + }), +}; + export const PostAgentUpgradeRequestSchema = { params: schema.object({ agentId: schema.string(), }), body: schema.object({ - source_uri: schema.string(), + source_uri: schema.maybe(schema.string()), version: schema.string(), }), }; -export const PostBulkAgentUnenrollRequestSchema = { +export const PostBulkAgentUpgradeRequestSchema = { body: schema.object({ agents: schema.oneOf([schema.arrayOf(schema.string()), schema.string()]), - force: schema.maybe(schema.boolean()), + source_uri: schema.maybe(schema.string()), + version: schema.string(), }), }; diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/common.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/common.ts index dc0f111680490..cdb23da5b6b11 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/common.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/common.ts @@ -10,6 +10,7 @@ export const ListWithKuerySchema = schema.object({ perPage: schema.maybe(schema.number({ defaultValue: 20 })), sortField: schema.maybe(schema.string()), sortOrder: schema.maybe(schema.oneOf([schema.literal('desc'), schema.literal('asc')])), + showUpgradeable: schema.maybe(schema.boolean()), kuery: schema.maybe(schema.string()), }); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipeline_form.helpers.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipeline_form.helpers.ts index 752ffef51b43b..dd354b4927836 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipeline_form.helpers.ts +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipeline_form.helpers.ts @@ -3,38 +3,42 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { act } from 'react-dom/test-utils'; + import { TestBed } from '../../../../../test_utils'; export const getFormActions = (testBed: TestBed) => { - const { find, form } = testBed; + const { find, form, component } = testBed; // User actions - const clickSubmitButton = () => { - find('submitButton').simulate('click'); - }; + const clickSubmitButton = async () => { + await act(async () => { + find('submitButton').simulate('click'); + }); - const clickAddDocumentsButton = () => { - find('addDocumentsButton').simulate('click'); + component.update(); }; - const clickShowRequestLink = () => { - find('showRequestLink').simulate('click'); + const clickShowRequestLink = async () => { + await act(async () => { + find('showRequestLink').simulate('click'); + }); + + component.update(); }; const toggleVersionSwitch = () => { - form.toggleEuiSwitch('versionToggle'); - }; + act(() => { + form.toggleEuiSwitch('versionToggle'); + }); - const toggleOnFailureSwitch = () => { - form.toggleEuiSwitch('onFailureToggle'); + component.update(); }; return { clickSubmitButton, clickShowRequestLink, toggleVersionSwitch, - toggleOnFailureSwitch, - clickAddDocumentsButton, }; }; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_list.helpers.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_list.helpers.ts index 43ca849e61aee..6c446e8254f6b 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_list.helpers.ts +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_list.helpers.ts @@ -11,7 +11,6 @@ import { TestBed, TestBedConfig, findTestSubject, - nextTick, } from '../../../../../test_utils'; import { PipelinesList } from '../../../public/application/sections/pipelines_list'; import { WithAppDependencies } from './setup_environment'; @@ -32,13 +31,17 @@ export type PipelineListTestBed = TestBed & { }; const createActions = (testBed: TestBed) => { - const { find } = testBed; - /** * User Actions */ - const clickReloadButton = () => { - find('reloadButton').simulate('click'); + const clickReloadButton = async () => { + const { component, find } = testBed; + + await act(async () => { + find('reloadButton').simulate('click'); + }); + + component.update(); }; const clickPipelineAt = async (index: number) => { @@ -49,16 +52,19 @@ const createActions = (testBed: TestBed) => { await act(async () => { const { href } = pipelineLink.props(); router.navigateTo(href!); - await nextTick(); - component.update(); }); + component.update(); }; const clickActionMenu = (pipelineName: string) => { const { component } = testBed; - // When a table has > 2 actions, EUI displays an overflow menu with an id "-actions" - component.find(`div[id="${pipelineName}-actions"] button`).simulate('click'); + act(() => { + // When a table has > 2 actions, EUI displays an overflow menu with an id "-actions" + component.find(`div[id="${pipelineName}-actions"] button`).simulate('click'); + }); + + component.update(); }; const clickPipelineAction = (pipelineName: string, action: 'edit' | 'clone' | 'delete') => { @@ -67,7 +73,11 @@ const createActions = (testBed: TestBed) => { clickActionMenu(pipelineName); - component.find('.euiContextMenuItem').at(actions.indexOf(action)).simulate('click'); + act(() => { + component.find('.euiContextMenuItem').at(actions.indexOf(action)).simulate('click'); + }); + + component.update(); }; return { diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx index d9a0ac4115389..eff2572aea38d 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx @@ -4,20 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; +import axios from 'axios'; +import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { LocationDescriptorObject } from 'history'; +import { HttpSetup } from 'kibana/public'; + import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { notificationServiceMock, - fatalErrorsServiceMock, docLinksServiceMock, - injectedMetadataServiceMock, scopedHistoryMock, } from '../../../../../../src/core/public/mocks'; import { usageCollectionPluginMock } from '../../../../../../src/plugins/usage_collection/public/mocks'; -import { HttpService } from '../../../../../../src/core/public/http'; - import { breadcrumbService, documentationService, @@ -27,10 +27,7 @@ import { import { init as initHttpRequests } from './http_requests'; -const httpServiceSetupMock = new HttpService().setup({ - injectedMetadata: injectedMetadataServiceMock.createSetupContract(), - fatalErrors: fatalErrorsServiceMock.createSetupContract(), -}); +const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); const history = scopedHistoryMock.create(); history.createHref.mockImplementation((location: LocationDescriptorObject) => { @@ -53,7 +50,7 @@ const appServices = { export const setupEnvironment = () => { uiMetricService.setup(usageCollectionPluginMock.createSetupContract()); - apiService.setup(httpServiceSetupMock, uiMetricService); + apiService.setup((mockHttpClient as unknown) as HttpSetup, uiMetricService); documentationService.setup(docLinksServiceMock.createStartContract()); breadcrumbService.setup(() => {}); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_clone.test.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_clone.test.tsx index f8e0030441ba0..6e0889ac55d4d 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_clone.test.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_clone.test.tsx @@ -28,8 +28,7 @@ jest.mock('@elastic/eui', () => { }; }); -// FLAKY: https://github.com/elastic/kibana/issues/66856 -describe.skip('', () => { +describe('', () => { let testBed: PipelinesCloneTestBed; const { server, httpRequestsMockHelpers } = setupEnvironment(); @@ -38,13 +37,14 @@ describe.skip('', () => { server.restore(); }); - beforeEach(async () => { - httpRequestsMockHelpers.setLoadPipelineResponse(PIPELINE_TO_CLONE); + httpRequestsMockHelpers.setLoadPipelineResponse(PIPELINE_TO_CLONE); + beforeEach(async () => { await act(async () => { testBed = await setup(); - await testBed.waitFor('pipelineForm'); }); + + testBed.component.update(); }); test('should render the correct page header', () => { @@ -61,12 +61,9 @@ describe.skip('', () => { describe('form submission', () => { it('should send the correct payload', async () => { - const { actions, waitFor } = testBed; + const { actions } = testBed; - await act(async () => { - actions.clickSubmitButton(); - await waitFor('pipelineForm', 0); - }); + await actions.clickSubmitButton(); const latestRequest = server.requests[server.requests.length - 1]; @@ -75,7 +72,7 @@ describe.skip('', () => { name: `${PIPELINE_TO_CLONE.name}-copy`, }; - expect(JSON.parse(latestRequest.requestBody)).toEqual(expected); + expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); }); }); }); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx index 18ca71f2bb73a..976627b1fa8a8 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { setupEnvironment, pageHelpers, nextTick } from './helpers'; +import { setupEnvironment, pageHelpers } from './helpers'; import { PipelinesCreateTestBed } from './helpers/pipelines_create.helpers'; import { nestedProcessorsErrorFixture } from './fixtures'; @@ -43,8 +43,9 @@ describe('', () => { beforeEach(async () => { await act(async () => { testBed = await setup(); - await testBed.waitFor('pipelineForm'); }); + + testBed.component.update(); }); test('should render the correct page header', () => { @@ -60,28 +61,20 @@ describe('', () => { }); test('should toggle the version field', async () => { - const { actions, component, exists } = testBed; + const { actions, exists } = testBed; // Version field should be hidden by default expect(exists('versionField')).toBe(false); - await act(async () => { - actions.toggleVersionSwitch(); - await nextTick(); - component.update(); - }); + actions.toggleVersionSwitch(); expect(exists('versionField')).toBe(true); }); test('should show the request flyout', async () => { - const { actions, component, find, exists } = testBed; + const { actions, find, exists } = testBed; - await act(async () => { - actions.clickShowRequestLink(); - await nextTick(); - component.update(); - }); + await actions.clickShowRequestLink(); // Verify request flyout opens expect(exists('requestFlyout')).toBe(true); @@ -92,23 +85,18 @@ describe('', () => { test('should prevent form submission if required fields are missing', async () => { const { form, actions, component, find } = testBed; - await act(async () => { - actions.clickSubmitButton(); - await nextTick(); - component.update(); - }); + await actions.clickSubmitButton(); expect(form.getErrorsMessages()).toEqual(['Name is required.']); expect(find('submitButton').props().disabled).toEqual(true); - // Add required fields and verify button is enabled again - form.setInputValue('nameField.input', 'my_pipeline'); - await act(async () => { - await nextTick(); - component.update(); + // Add required fields and verify button is enabled again + form.setInputValue('nameField.input', 'my_pipeline'); }); + component.update(); + expect(find('submitButton').props().disabled).toEqual(false); }); }); @@ -117,23 +105,27 @@ describe('', () => { beforeEach(async () => { await act(async () => { testBed = await setup(); + }); - const { waitFor, form } = testBed; + testBed.component.update(); + + await act(async () => { + testBed.form.setInputValue('nameField.input', 'my_pipeline'); + }); - await waitFor('pipelineForm'); + testBed.component.update(); - form.setInputValue('nameField.input', 'my_pipeline'); - form.setInputValue('descriptionField.input', 'pipeline description'); + await act(async () => { + testBed.form.setInputValue('descriptionField.input', 'pipeline description'); }); + + testBed.component.update(); }); test('should send the correct payload', async () => { - const { actions, waitFor } = testBed; + const { actions } = testBed; - await act(async () => { - actions.clickSubmitButton(); - await waitFor('pipelineForm', 0); - }); + await actions.clickSubmitButton(); const latestRequest = server.requests[server.requests.length - 1]; @@ -143,11 +135,11 @@ describe('', () => { processors: [], }; - expect(JSON.parse(latestRequest.requestBody)).toEqual(expected); + expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); }); test('should surface API errors from the request', async () => { - const { actions, find, exists, waitFor } = testBed; + const { actions, find, exists } = testBed; const error = { status: 409, @@ -157,29 +149,29 @@ describe('', () => { httpRequestsMockHelpers.setCreatePipelineResponse(undefined, { body: error }); - await act(async () => { - actions.clickSubmitButton(); - await waitFor('savePipelineError'); - }); + await actions.clickSubmitButton(); expect(exists('savePipelineError')).toBe(true); expect(find('savePipelineError').text()).toContain(error.message); }); test('displays nested pipeline errors as a flat list', async () => { - const { actions, find, exists, waitFor } = testBed; + const { actions, find, exists, component } = testBed; httpRequestsMockHelpers.setCreatePipelineResponse(undefined, { body: nestedProcessorsErrorFixture, }); - await act(async () => { - actions.clickSubmitButton(); - await waitFor('savePipelineError'); - }); + await actions.clickSubmitButton(); expect(exists('savePipelineError')).toBe(true); expect(exists('savePipelineError.showErrorsButton')).toBe(true); - find('savePipelineError.showErrorsButton').simulate('click'); + + await act(async () => { + find('savePipelineError.showErrorsButton').simulate('click'); + }); + + component.update(); + expect(exists('savePipelineError.hideErrorsButton')).toBe(true); expect(exists('savePipelineError.showErrorsButton')).toBe(false); expect(find('savePipelineError').find('li').length).toBe(8); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_edit.test.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_edit.test.tsx index 6c89216e34733..3fe7f5ec42710 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_edit.test.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_edit.test.tsx @@ -37,13 +37,14 @@ describe('', () => { server.restore(); }); - beforeEach(async () => { - httpRequestsMockHelpers.setLoadPipelineResponse(PIPELINE_TO_EDIT); + httpRequestsMockHelpers.setLoadPipelineResponse(PIPELINE_TO_EDIT); + beforeEach(async () => { await act(async () => { testBed = await setup(); - await testBed.waitFor('pipelineForm'); }); + + testBed.component.update(); }); test('should render the correct page header', () => { @@ -68,15 +69,12 @@ describe('', () => { describe('form submission', () => { it('should send the correct payload with changed values', async () => { const UPDATED_DESCRIPTION = 'updated pipeline description'; - const { actions, form, waitFor } = testBed; + const { actions, form } = testBed; // Make change to description field form.setInputValue('descriptionField.input', UPDATED_DESCRIPTION); - await act(async () => { - actions.clickSubmitButton(); - await waitFor('pipelineForm', 0); - }); + await actions.clickSubmitButton(); const latestRequest = server.requests[server.requests.length - 1]; @@ -87,7 +85,7 @@ describe('', () => { description: UPDATED_DESCRIPTION, }; - expect(JSON.parse(latestRequest.requestBody)).toEqual(expected); + expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); }); }); }); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts index c0acc39ca35a1..d29d38da80e47 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts @@ -8,7 +8,7 @@ import { act } from 'react-dom/test-utils'; import { API_BASE_PATH } from '../../common/constants'; -import { setupEnvironment, pageHelpers, nextTick } from './helpers'; +import { setupEnvironment, pageHelpers } from './helpers'; import { PipelineListTestBed } from './helpers/pipelines_list.helpers'; const { setup } = pageHelpers.pipelinesList; @@ -22,6 +22,14 @@ describe('', () => { }); describe('With pipelines', () => { + beforeEach(async () => { + await act(async () => { + testBed = await setup(); + }); + + testBed.component.update(); + }); + const pipeline1 = { name: 'test_pipeline1', description: 'test_pipeline1 description', @@ -38,16 +46,6 @@ describe('', () => { httpRequestsMockHelpers.setLoadPipelinesResponse(pipelines); - beforeEach(async () => { - testBed = await setup(); - - await act(async () => { - const { waitFor } = testBed; - - await waitFor('pipelinesTable'); - }); - }); - test('should render the list view', async () => { const { exists, find, table } = testBed; @@ -72,14 +70,10 @@ describe('', () => { }); test('should reload the pipeline data', async () => { - const { component, actions } = testBed; + const { actions } = testBed; const totalRequests = server.requests.length; - await act(async () => { - actions.clickReloadButton(); - await nextTick(100); - component.update(); - }); + await actions.clickReloadButton(); expect(server.requests.length).toBe(totalRequests + 1); expect(server.requests[server.requests.length - 1].url).toBe(API_BASE_PATH); @@ -118,33 +112,27 @@ describe('', () => { await act(async () => { confirmButton!.click(); - await nextTick(); - component.update(); }); - const latestRequest = server.requests[server.requests.length - 1]; + component.update(); + + const deleteRequest = server.requests[server.requests.length - 2]; - expect(latestRequest.method).toBe('DELETE'); - expect(latestRequest.url).toBe(`${API_BASE_PATH}/${pipelineName}`); - expect(latestRequest.status).toEqual(200); + expect(deleteRequest.method).toBe('DELETE'); + expect(deleteRequest.url).toBe(`${API_BASE_PATH}/${pipelineName}`); + expect(deleteRequest.status).toEqual(200); }); }); describe('No pipelines', () => { - beforeEach(async () => { + test('should display an empty prompt', async () => { httpRequestsMockHelpers.setLoadPipelinesResponse([]); - testBed = await setup(); - await act(async () => { - const { waitFor } = testBed; - - await waitFor('emptyList'); + testBed = await setup(); }); - }); - - test('should display an empty prompt', async () => { - const { exists, find } = testBed; + const { exists, component, find } = testBed; + component.update(); expect(exists('sectionLoading')).toBe(false); expect(exists('emptyList')).toBe(true); @@ -162,13 +150,11 @@ describe('', () => { httpRequestsMockHelpers.setLoadPipelinesResponse(undefined, { body: error }); - testBed = await setup(); - await act(async () => { - const { waitFor } = testBed; - - await waitFor('pipelineLoadError'); + testBed = await setup(); }); + + testBed.component.update(); }); test('should render an error message if error fetching pipelines', async () => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx index 10fb73df1ce1c..72c25d6dff72d 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx @@ -6,19 +6,9 @@ import { act } from 'react-dom/test-utils'; import React from 'react'; -import { notificationServiceMock, scopedHistoryMock } from 'src/core/public/mocks'; - -import { LocationDescriptorObject } from 'history'; -import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; import { registerTestBed, TestBed } from '../../../../../../../test_utils'; -import { ProcessorsEditorContextProvider, Props, PipelineProcessorsEditor } from '../'; - -import { - breadcrumbService, - uiMetricService, - documentationService, - apiService, -} from '../../../services'; +import { Props } from '../'; +import { ProcessorsEditorWithDeps } from './processors_editor'; jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); @@ -67,28 +57,8 @@ jest.mock('react-virtualized', () => { }; }); -const history = scopedHistoryMock.create(); -history.createHref.mockImplementation((location: LocationDescriptorObject) => { - return `${location.pathname}?${location.search}`; -}); - -const appServices = { - breadcrumbs: breadcrumbService, - metric: uiMetricService, - documentation: documentationService, - api: apiService, - notifications: notificationServiceMock.createSetupContract(), - history, -}; - const testBedSetup = registerTestBed( - (props: Props) => ( - - - - - - ), + (props: Props) => , { doMountAsync: false, } diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors_editor.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors_editor.tsx new file mode 100644 index 0000000000000..8fb51ade921a9 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors_editor.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +import { notificationServiceMock, scopedHistoryMock } from 'src/core/public/mocks'; + +import { LocationDescriptorObject } from 'history'; +import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; +import { ProcessorsEditorContextProvider, Props, PipelineProcessorsEditor } from '../'; + +import { + breadcrumbService, + uiMetricService, + documentationService, + apiService, +} from '../../../services'; + +const history = scopedHistoryMock.create(); +history.createHref.mockImplementation((location: LocationDescriptorObject) => { + return `${location.pathname}?${location.search}`; +}); + +const appServices = { + breadcrumbs: breadcrumbService, + metric: uiMetricService, + documentation: documentationService, + api: apiService, + notifications: notificationServiceMock.createSetupContract(), + history, +}; + +export const ProcessorsEditorWithDeps: React.FunctionComponent = (props) => { + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.helpers.tsx index 222e0a491e0d2..570d9878f7634 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.helpers.tsx @@ -8,26 +8,15 @@ import React from 'react'; import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; -import { notificationServiceMock, scopedHistoryMock } from 'src/core/public/mocks'; - -import { LocationDescriptorObject } from 'history'; -import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; /* eslint-disable @kbn/eslint/no-restricted-paths */ import { usageCollectionPluginMock } from 'src/plugins/usage_collection/public/mocks'; import { registerTestBed, TestBed } from '../../../../../../../test_utils'; import { stubWebWorker } from '../../../../../../../test_utils/stub_web_worker'; - -import { - breadcrumbService, - uiMetricService, - documentationService, - apiService, -} from '../../../services'; - -import { ProcessorsEditorContextProvider, Props, PipelineProcessorsEditor } from '../'; - +import { uiMetricService, apiService } from '../../../services'; +import { Props } from '../'; import { initHttpRequests } from './http_requests.helpers'; +import { ProcessorsEditorWithDeps } from './processors_editor'; stubWebWorker(); @@ -75,34 +64,8 @@ jest.mock('react-virtualized', () => { }; }); -const history = scopedHistoryMock.create(); -history.createHref.mockImplementation((location: LocationDescriptorObject) => { - return `${location.pathname}?${location.search}`; -}); - -const appServices = { - breadcrumbs: breadcrumbService, - metric: uiMetricService, - documentation: documentationService, - api: apiService, - notifications: notificationServiceMock.createSetupContract(), - history, - uiSettings: {}, - urlGenerators: { - getUrlGenerator: jest.fn().mockReturnValue({ - createUrl: jest.fn(), - }), - }, -}; - const testBedSetup = registerTestBed( - (props: Props) => ( - - - - - - ), + (props: Props) => , { doMountAsync: false, } diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx index e91974adca20a..a9e8028fd02ee 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx @@ -4,26 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ import classNames from 'classnames'; -import React, { FunctionComponent, useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, memo } from 'react'; import { EuiFieldText, EuiText, keys } from '@elastic/eui'; export interface Props { placeholder: string; ariaLabel: string; onChange: (value: string) => void; - disabled: boolean; + /** + * Whether the containing element of the text input can be focused. + * + * If it cannot be focused, this component cannot switch to showing + * the text input field. + * + * Defaults to false. + */ + disabled?: boolean; text?: string; } -export const InlineTextInput: FunctionComponent = ({ - disabled, +function _InlineTextInput({ placeholder, text, ariaLabel, + disabled = false, onChange, -}) => { +}: Props): React.ReactElement | null { const [isShowingTextInput, setIsShowingTextInput] = useState(false); - const [textValue, setTextValue] = useState(text ?? ''); + const [textValue, setTextValue] = useState(() => text ?? ''); const containerClasses = classNames('pipelineProcessorsEditor__item__textContainer', { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -39,6 +47,10 @@ export const InlineTextInput: FunctionComponent = ({ }); }, [setIsShowingTextInput, onChange, textValue]); + useEffect(() => { + setTextValue(text ?? ''); + }, [text]); + useEffect(() => { const keyboardListener = (event: KeyboardEvent) => { if (event.key === keys.ESCAPE || event.code === 'Escape') { @@ -71,7 +83,11 @@ export const InlineTextInput: FunctionComponent = ({ /> ) : ( -
    setIsShowingTextInput(true)}> +
    setIsShowingTextInput(true)} + >
    {text || {placeholder}} @@ -79,4 +95,6 @@ export const InlineTextInput: FunctionComponent = ({
    ); -}; +} + +export const InlineTextInput = memo(_InlineTextInput); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx index bf69f817183ab..dd7798a37dd4e 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx @@ -5,7 +5,7 @@ */ import classNames from 'classnames'; -import React, { FunctionComponent, memo } from 'react'; +import React, { FunctionComponent, memo, useCallback } from 'react'; import { EuiButtonToggle, EuiFlexGroup, @@ -89,6 +89,32 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( 'pipelineProcessorsEditor__item--displayNone': isInMoveMode && !processor.options.description, }); + const onDescriptionChange = useCallback( + (nextDescription) => { + let nextOptions: Record; + if (!nextDescription) { + const { description: _description, ...restOptions } = processor.options; + nextOptions = restOptions; + } else { + nextOptions = { + ...processor.options, + description: nextDescription, + }; + } + processorsDispatch({ + type: 'updateProcessor', + payload: { + processor: { + ...processor, + options: nextOptions, + }, + selector, + }, + }); + }, + [processor, processorsDispatch, selector] + ); + const renderMoveButton = () => { const label = !isMovingThisProcessor ? i18nTexts.moveButtonLabel @@ -159,6 +185,7 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( color={isDimmed ? 'subdued' : undefined} > { editor.setMode({ @@ -175,28 +202,7 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( { - let nextOptions: Record; - if (!nextDescription) { - const { description: _description, ...restOptions } = processor.options; - nextOptions = restOptions; - } else { - nextOptions = { - ...processor.options, - description: nextDescription, - }; - } - processorsDispatch({ - type: 'updateProcessor', - payload: { - processor: { - ...processor, - options: nextOptions, - }, - selector, - }, - }); - }} + onChange={onDescriptionChange} ariaLabel={i18nTexts.processorTypeLabel({ type: processor.type })} text={description} placeholder={i18nTexts.descriptionPlaceholder} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx index b663daedd9b9c..f663832702b1c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx @@ -24,10 +24,8 @@ import { getProcessorDescriptor } from '../shared'; import { DocumentationButton } from './documentation_button'; import { ProcessorSettingsFields } from './processor_settings_fields'; +import { Fields } from './processor_form.container'; -interface Fields { - fields: { [key: string]: any }; -} export interface Props { isOnFailure: boolean; form: FormHook; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_form.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_form.container.tsx index 25c9579e3c48e..61a6f985340ea 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_form.container.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_form.container.tsx @@ -19,6 +19,7 @@ export type OnSubmitHandler = (processor: ProcessorFormOnSubmitArg) => void; export type OnFormUpdateHandler = (form: OnFormUpdateArg) => void; export interface Fields { + type: string; fields: { [key: string]: any }; } @@ -57,8 +58,28 @@ export const ProcessorFormContainer: FunctionComponent = ({ return { ...processor, options } as ProcessorInternal; }, [processor, unsavedFormState]); + const formSerializer = useCallback( + (formState) => { + return { + type: formState.type, + fields: formState.customOptions + ? { + ...formState.customOptions, + } + : { + ...formState.fields, + // The description field is not editable in processor forms currently. We re-add it here or it will be + // stripped. + description: processor ? processor.options.description : undefined, + }, + }; + }, + [processor] + ); + const { form } = useForm({ defaultValue: { fields: getProcessor().options }, + serializer: formSerializer, }); const { subscribe } = form; @@ -67,8 +88,7 @@ export const ProcessorFormContainer: FunctionComponent = ({ const { isValid, data } = await form.submit(); if (isValid) { - const { type, customOptions, fields } = data as FormData; - const options = customOptions ? customOptions : fields; + const { type, fields: options } = data as FormData; unsavedFormState.current = options; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/common_processor_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/common_processor_fields.tsx index 1777cac2a5615..e66534ae1b250 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/common_processor_fields.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/common_processor_fields.tsx @@ -18,7 +18,7 @@ import { import { TextEditor } from '../../field_components'; import { to, from, EDITOR_PX_HEIGHT } from '../shared'; -const ignoreFailureConfig: FieldConfig = { +const ignoreFailureConfig: FieldConfig = { defaultValue: false, deserializer: to.booleanOrUndef, serializer: from.undefinedIfValue(false), diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/processor_type_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/processor_type_field.tsx index 5b3df63a11294..eb792f5a85213 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/processor_type_field.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/processor_type_field.tsx @@ -47,7 +47,7 @@ interface Props { const { emptyField } = fieldValidators; -const typeConfig: FieldConfig = { +const typeConfig: FieldConfig = { type: FIELD_TYPES.COMBO_BOX, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.typeField.typeFieldLabel', { defaultMessage: 'Processor', diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/custom.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/custom.tsx index f49e77501f931..4a8cfc8be2d8c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/custom.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/custom.tsx @@ -20,7 +20,7 @@ import { XJsonEditor } from '../field_components'; import { Fields } from '../processor_form.container'; import { EDITOR_PX_HEIGHT } from './shared'; -const customConfig: FieldConfig = { +const customConfig: FieldConfig = { type: FIELD_TYPES.TEXT, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.customForm.optionsFieldLabel', { defaultMessage: 'Configuration', diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/shared.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/shared.ts index e45469e23e8a0..c33cce323b727 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/shared.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/shared.ts @@ -74,6 +74,6 @@ export const EDITOR_PX_HEIGHT = { large: 300, }; -export type FieldsConfig = Record; +export type FieldsConfig = Record>; export type FormFieldsComponent = FunctionComponent<{ initialFieldValues?: Record }>; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx index 57ecb6f7f1187..cd32e2ec54726 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx @@ -7,11 +7,15 @@ import { i18n } from '@kbn/i18n'; import React, { FunctionComponent } from 'react'; import classNames from 'classnames'; -import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { EuiButtonIcon } from '@elastic/eui'; export interface Props { isVisible: boolean; isDisabled: boolean; + /** + * Useful for buttons at the very top or bottom of lists to avoid any overflow. + */ + compressed?: boolean; onClick: (event: React.MouseEvent) => void; 'data-test-subj'?: string; } @@ -29,7 +33,7 @@ const cannotMoveHereLabel = i18n.translate( ); export const DropZoneButton: FunctionComponent = (props) => { - const { onClick, isDisabled, isVisible } = props; + const { onClick, isDisabled, isVisible, compressed } = props; const isUnavailable = isVisible && isDisabled; const containerClasses = classNames({ // eslint-disable-next-line @typescript-eslint/naming-convention @@ -40,14 +44,16 @@ export const DropZoneButton: FunctionComponent = (props) => { const buttonClasses = classNames({ // eslint-disable-next-line @typescript-eslint/naming-convention 'pipelineProcessorsEditor__tree__dropZoneButton--visible': isVisible, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'pipelineProcessorsEditor__tree__dropZoneButton--compressed': compressed, }); - const content = ( + return (
    {} : onClick} @@ -55,15 +61,4 @@ export const DropZoneButton: FunctionComponent = (props) => { />
    ); - - return isUnavailable ? ( - - {content} - - ) : ( - content - ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx index 89407fd4366d8..cbff02070483a 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FunctionComponent, MutableRefObject, useEffect } from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { FunctionComponent, MutableRefObject, useEffect, useMemo } from 'react'; +import { EuiFlexGroup } from '@elastic/eui'; import { AutoSizer, List, WindowScroller } from 'react-virtualized'; import { DropSpecialLocations } from '../../../constants'; @@ -65,6 +65,10 @@ export const PrivateTree: FunctionComponent = ({ windowScrollerRef, listRef, }) => { + const selectors: string[][] = useMemo(() => { + return processors.map((_, idx) => selector.concat(String(idx))); + }, [processors, selector]); + const renderRow = ({ idx, info, @@ -78,50 +82,45 @@ export const PrivateTree: FunctionComponent = ({ return ( <> {idx === 0 ? ( - - { - event.preventDefault(); - onAction({ - type: 'move', - payload: { - destination: selector.concat(DropSpecialLocations.top), - source: movingProcessor!.selector, - }, - }); - }} - isVisible={Boolean(movingProcessor)} - isDisabled={!movingProcessor || isDropZoneAboveDisabled(info, movingProcessor)} - /> - - ) : undefined} - - - - { event.preventDefault(); onAction({ type: 'move', payload: { - destination: selector.concat(String(idx + 1)), + destination: selector.concat(DropSpecialLocations.top), source: movingProcessor!.selector, }, }); }} + isVisible={Boolean(movingProcessor)} + isDisabled={!movingProcessor || isDropZoneAboveDisabled(info, movingProcessor)} /> - + ) : undefined} + + { + event.preventDefault(); + onAction({ + type: 'move', + payload: { + destination: selector.concat(String(idx + 1)), + source: movingProcessor!.selector, + }, + }); + }} + /> ); }; @@ -141,52 +140,50 @@ export const PrivateTree: FunctionComponent = ({ {({ height, registerChild, isScrolling, onChildScroll, scrollTop }: any) => { return ( - - - {({ width }) => { - return ( -
    - { - const processor = processors[index]; - return calculateItemHeight({ - processor, - isFirstInArray: index === 0, - }); - }} - rowRenderer={({ index: idx, style }) => { - const processor = processors[idx]; - const above = processors[idx - 1]; - const below = processors[idx + 1]; - const info: ProcessorInfo = { - id: processor.id, - selector: selector.concat(String(idx)), - aboveId: above?.id, - belowId: below?.id, - }; + + {({ width }) => { + return ( +
    + { + const processor = processors[index]; + return calculateItemHeight({ + processor, + isFirstInArray: index === 0, + }); + }} + rowRenderer={({ index: idx, style }) => { + const processor = processors[idx]; + const above = processors[idx - 1]; + const below = processors[idx + 1]; + const info: ProcessorInfo = { + id: processor.id, + selector: selectors[idx], + aboveId: above?.id, + belowId: below?.id, + }; - return ( -
    - {renderRow({ processor, info, idx })} -
    - ); - }} - processors={processors} - /> -
    - ); - }} -
    - + return ( +
    + {renderRow({ processor, info, idx })} +
    + ); + }} + processors={processors} + /> +
    + ); + }} +
    ); }}
    diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss index 25e4eb7320bf4..f1e399428cdf2 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss @@ -31,15 +31,14 @@ } } $dropZoneButtonHeight: 60px; - $dropZoneButtonOffsetY: $dropZoneButtonHeight * -0.5; + $dropZoneButtonOffsetY: $dropZoneButtonHeight * 0.5; &__dropZoneButton { position: absolute; padding: 0; height: $dropZoneButtonHeight; - margin-top: $dropZoneButtonOffsetY; + margin-top: -$dropZoneButtonOffsetY; width: 100%; - opacity: 0; text-decoration: none !important; z-index: $dropZoneZIndex; @@ -49,6 +48,10 @@ transform: none !important; } } + + &--compressed { + height: $dropZoneButtonOffsetY; + } } &__addProcessorButton { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx index ffc0a1459b791..46d237e1467e7 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx @@ -98,9 +98,16 @@ export const ProcessorsTree: FunctionComponent = memo((props) => { />
    - - - {!processors.length && ( + + {!processors.length && ( + // We want to make this dropzone the max length of its container + = memo((props) => { }); }} /> - )} + + )} + { onAction({ type: 'addProcessor', payload: { target: baseSelector } }); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.container.tsx index e7ccb9d17f2b1..7a32e6328bc73 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.container.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.container.tsx @@ -28,6 +28,10 @@ export interface TestPipelineConfig { verbose?: boolean; } +export interface TestPipelineFlyoutForm { + documents: string | Document[]; +} + export const TestPipelineFlyout: React.FunctionComponent = ({ onClose, activeTab, @@ -46,7 +50,7 @@ export const TestPipelineFlyout: React.FunctionComponent = ({ config: { documents: cachedDocuments, verbose: cachedVerbose }, } = testPipelineData; - const { form } = useForm({ + const { form } = useForm({ defaultValue: { documents: cachedDocuments || '', }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.tsx index 51b75dab170a3..aa9e2879aaddf 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.tsx @@ -20,6 +20,7 @@ import { FormHook } from '../../../../../shared_imports'; import { Document } from '../../types'; import { Tabs, TestPipelineFlyoutTab, OutputTab, DocumentsTab } from './test_pipeline_tabs'; +import { TestPipelineFlyoutForm } from './test_pipeline_flyout.container'; export interface Props { onClose: () => void; handleTestPipeline: ( @@ -30,9 +31,7 @@ export interface Props { cachedVerbose?: boolean; cachedDocuments?: Document[]; testOutput?: any; - form: FormHook<{ - documents: string | Document[]; - }>; + form: FormHook; validateAndTestPipeline: () => Promise; selectedTab: TestPipelineFlyoutTab; setSelectedTab: (selectedTa: TestPipelineFlyoutTab) => void; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx index ae784472ebbd9..cd82e0f4ff5ca 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx @@ -75,7 +75,7 @@ const i18nTexts = { ), }; -const documentFieldConfig: FieldConfig = { +const documentFieldConfig: FieldConfig = { label: i18n.translate( 'xpack.ingestPipelines.testPipelineFlyout.documentsForm.documentsFieldLabel', { diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index f5fba766e60ee..46a0b56a03ec5 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -11,9 +11,10 @@ "urlForwarding", "visualizations", "dashboard", - "charts" + "charts", + "uiActions" ], - "optionalPlugins": ["embeddable", "usageCollection", "taskManager", "uiActions", "globalSearch"], + "optionalPlugins": ["embeddable", "usageCollection", "taskManager", "globalSearch"], "configPath": ["xpack", "lens"], "extraPublicDirs": ["common/constants"], "requiredBundles": ["savedObjects", "kibanaUtils", "kibanaReact", "embeddable"] diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 24114e2b31518..70f3f767930ec 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -145,8 +145,9 @@ describe('Lens App', () => { >( DOC_TYPE, { - customSaveMethod: jest.fn(), - customUnwrapMethod: jest.fn(), + saveMethod: jest.fn(), + unwrapMethod: jest.fn(), + checkForDuplicateTitle: jest.fn(), }, core ); @@ -280,6 +281,7 @@ describe('Lens App', () => { }, "doc": undefined, "filters": Array [], + "initialContext": undefined, "onChange": [Function], "onError": [Function], "query": Object { diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index e4af2a33ec68b..3407ea5de49c4 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -47,6 +47,7 @@ export function App({ incomingState, redirectToOrigin, setHeaderActionMenu, + initialContext, }: LensAppProps) { const { data, @@ -67,7 +68,7 @@ export function App({ const [state, setState] = useState(() => { const currentRange = data.query.timefilter.timefilter.getTime(); return { - query: data.query.queryString.getDefaultQuery(), + query: data.query.queryString.getQuery(), filters: data.query.filterManager.getFilters(), isLoading: Boolean(initialInput), indexPatternsForTopNav: [], @@ -142,8 +143,11 @@ export function App({ useEffect(() => { // Clear app-specific filters when navigating to Lens. Necessary because Lens - // can be loaded without a full page refresh - data.query.filterManager.setAppFilters([]); + // can be loaded without a full page refresh. If the user navigates to Lens from Discover + // we keep the filters + if (!initialContext) { + data.query.filterManager.setAppFilters([]); + } const filterSubscription = data.query.filterManager.getUpdates$().subscribe({ next: () => { @@ -187,6 +191,7 @@ export function App({ uiSettings, data.query, history, + initialContext, ]); useEffect(() => { @@ -576,6 +581,7 @@ export function App({ doc: state.persistedDoc, onError, showNoDataPopover, + initialContext, onChange: ({ filterableIndexPatterns, doc, isSaveable }) => { if (isSaveable !== state.isSaveable) { setState((s) => ({ ...s, isSaveable })); diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index 0d50e541d3e48..90ba74decfd80 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -26,8 +26,9 @@ import { LensByReferenceInput, LensByValueInput, } from '../editor_frame_service/embeddable/embeddable'; +import { ACTION_VISUALIZE_LENS_FIELD } from '../../../../../src/plugins/ui_actions/public'; import { LensAttributeService } from '../lens_attribute_service'; -import { LensAppServices, RedirectToOriginProps } from './types'; +import { LensAppServices, RedirectToOriginProps, HistoryLocationState } from './types'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; export async function mountApp( @@ -46,6 +47,7 @@ export async function mountApp( const instance = await createEditorFrame(); const storage = new Storage(localStorage); const stateTransfer = embeddable?.getStateTransfer(params.history); + const historyLocationState = params.history.location.state as HistoryLocationState; const embeddableEditorIncomingState = stateTransfer?.getIncomingEditorState(); const lensServices: LensAppServices = { @@ -132,6 +134,11 @@ export async function mountApp( onAppLeave={params.onAppLeave} setHeaderActionMenu={params.setHeaderActionMenu} history={routeProps.history} + initialContext={ + historyLocationState && historyLocationState.type === ACTION_VISUALIZE_LENS_FIELD + ? historyLocationState.payload + : undefined + } /> ); }; diff --git a/x-pack/plugins/lens/public/app_plugin/types.ts b/x-pack/plugins/lens/public/app_plugin/types.ts index fcdd0b20f8d27..bd5a9b5a8ed0a 100644 --- a/x-pack/plugins/lens/public/app_plugin/types.ts +++ b/x-pack/plugins/lens/public/app_plugin/types.ts @@ -28,6 +28,10 @@ import { NavigationPublicPluginStart } from '../../../../../src/plugins/navigati import { LensAttributeService } from '../lens_attribute_service'; import { IStorageWrapper } from '../../../../../src/plugins/kibana_utils/public'; import { DashboardFeatureFlagConfig } from '../../../../../src/plugins/dashboard/public'; +import { + VisualizeFieldContext, + ACTION_VISUALIZE_LENS_FIELD, +} from '../../../../../src/plugins/ui_actions/public'; import { EmbeddableEditorState } from '../../../../../src/plugins/embeddable/public'; import { EditorFrameInstance } from '..'; @@ -75,6 +79,12 @@ export interface LensAppProps { // State passed in by the container which is used to determine the id of the Originating App. incomingState?: EmbeddableEditorState; + initialContext?: VisualizeFieldContext; +} + +export interface HistoryLocationState { + type: typeof ACTION_VISUALIZE_LENS_FIELD; + payload: VisualizeFieldContext; } export interface LensAppServices { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss index f200e25453a2a..bd2789cf645c7 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss @@ -13,17 +13,7 @@ animation: euiFlyout $euiAnimSpeedNormal $euiAnimSlightResistance; } -.lnsDimensionContainer--noAnimation { - animation: none; -} - .lnsDimensionContainer__footer, .lnsDimensionContainer__header { padding: $euiSizeS; } - -.lnsDimensionContainer__trigger { - width: 100%; - display: block; - word-break: break-word; -} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx index 19f4c0428260e..8f1b441d1d285 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx @@ -16,89 +16,42 @@ import { EuiOutsideClickDetector, } from '@elastic/eui'; -import classNames from 'classnames'; import { i18n } from '@kbn/i18n'; -import { VisualizationDimensionGroupConfig } from '../../../types'; -import { DimensionContainerState } from './types'; export function DimensionContainer({ - dimensionContainerState, - setDimensionContainerState, - groups, - accessor, - groupId, - trigger, + isOpen, + groupLabel, + handleClose, panel, - panelTitle, }: { - dimensionContainerState: DimensionContainerState; - setDimensionContainerState: (newState: DimensionContainerState) => void; - groups: VisualizationDimensionGroupConfig[]; - accessor: string; - groupId: string; - trigger: React.ReactElement; + isOpen: boolean; + handleClose: () => void; panel: React.ReactElement; - panelTitle: React.ReactNode; + groupLabel: string; }) { - const [openByCreation, setIsOpenByCreation] = useState( - dimensionContainerState.openId === accessor - ); const [focusTrapIsEnabled, setFocusTrapIsEnabled] = useState(false); - const [flyoutIsVisible, setFlyoutIsVisible] = useState(false); - - const noMatch = dimensionContainerState.isOpen - ? !groups.some((d) => d.accessors.includes(accessor)) - : false; const closeFlyout = () => { - setDimensionContainerState({ - isOpen: false, - openId: null, - addingToGroupId: null, - }); - setIsOpenByCreation(false); + handleClose(); setFocusTrapIsEnabled(false); - setFlyoutIsVisible(false); - }; - - const openFlyout = () => { - setFlyoutIsVisible(true); - setTimeout(() => { - setFocusTrapIsEnabled(true); - }, 255); }; - const flyoutShouldBeOpen = - dimensionContainerState.isOpen && - (dimensionContainerState.openId === accessor || - (noMatch && dimensionContainerState.addingToGroupId === groupId)); - useEffect(() => { - if (flyoutShouldBeOpen) { - openFlyout(); + if (isOpen) { + // without setTimeout here the flyout pushes content when animating + setTimeout(() => { + setFocusTrapIsEnabled(true); + }, 255); } - }); + }, [isOpen]); - useEffect(() => { - if (!flyoutShouldBeOpen) { - if (flyoutIsVisible) { - setFlyoutIsVisible(false); - } - if (focusTrapIsEnabled) { - setFocusTrapIsEnabled(false); - } - } - }, [flyoutShouldBeOpen, flyoutIsVisible, focusTrapIsEnabled]); - - const flyout = flyoutIsVisible && ( + return isOpen ? ( - +
    @@ -109,7 +62,14 @@ export function DimensionContainer({ iconType="sortLeft" flush="left" > - {panelTitle} + + {i18n.translate('xpack.lens.configure.configurePanelTitle', { + defaultMessage: '{groupLabel} configuration', + values: { + groupLabel, + }, + })} + @@ -126,12 +86,5 @@ export function DimensionContainer({
    - ); - - return ( - <> - {trigger} - {flyout} - - ); + ) : null; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index a9e2d6dc696ab..44dc22d20a4fe 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -14,7 +14,6 @@ import { } from '../../mocks'; import { ChildDragDropProvider } from '../../../drag_drop'; import { EuiFormRow } from '@elastic/eui'; -import { mount } from 'enzyme'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { Visualization } from '../../../types'; import { LayerPanel } from './layer_panel'; @@ -211,7 +210,7 @@ describe('LayerPanel', () => { groupId: 'a', accessors: ['newid'], filterOperations: () => true, - supportsMoreColumns: false, + supportsMoreColumns: true, dataTestSubj: 'lnsGroup', enableDimensionEditor: true, }, @@ -220,11 +219,14 @@ describe('LayerPanel', () => { mockVisualization.renderDimensionEditor = jest.fn(); const component = mountWithIntl(); + act(() => { + component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); + }); + component.update(); - const group = component.find('DimensionContainer'); - const panel = mount(group.prop('panel')); - - expect(panel.children()).toHaveLength(2); + const group = component.find('DimensionContainer').first(); + const panel: React.ReactElement = group.prop('panel'); + expect(panel.props.children).toHaveLength(2); }); it('should keep the DimensionContainer open when configuring a new dimension', () => { @@ -263,11 +265,8 @@ describe('LayerPanel', () => { }); const component = mountWithIntl(); - - const group = component.find('DimensionContainer'); - const triggerButton = mountWithIntl(group.prop('trigger')); act(() => { - triggerButton.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); + component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); }); component.update(); @@ -312,10 +311,8 @@ describe('LayerPanel', () => { const component = mountWithIntl(); - const group = component.find('DimensionContainer'); - const triggerButton = mountWithIntl(group.prop('trigger')); act(() => { - triggerButton.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); + component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); }); component.update(); expect(component.find('EuiFlyoutHeader').exists()).toBe(true); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index ce2955da890d7..e72bf75b010c3 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -23,13 +23,11 @@ import { DragContext, DragDrop, ChildDragDropProvider } from '../../../drag_drop import { LayerSettings } from './layer_settings'; import { trackUiEvent } from '../../../lens_ui_telemetry'; import { generateId } from '../../../id_generator'; -import { ConfigPanelWrapperProps, DimensionContainerState } from './types'; +import { ConfigPanelWrapperProps, ActiveDimensionState } from './types'; import { DimensionContainer } from './dimension_container'; -const initialDimensionContainerState = { - isOpen: false, - openId: null, - addingToGroupId: null, +const initialActiveDimensionState = { + isNew: false, }; function isConfiguration( @@ -70,15 +68,15 @@ export function LayerPanel( } ) { const dragDropContext = useContext(DragContext); - const [dimensionContainerState, setDimensionContainerState] = useState( - initialDimensionContainerState + const [activeDimension, setActiveDimension] = useState( + initialActiveDimensionState ); const { framePublicAPI, layerId, isOnlyLayer, onRemoveLayer, dataTestSubj } = props; const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId]; useEffect(() => { - setDimensionContainerState(initialDimensionContainerState); + setActiveDimension(initialActiveDimensionState); }, [props.activeVisualizationId]); if ( @@ -117,7 +115,7 @@ export function LayerPanel( const { groups } = activeVisualization.getConfiguration(layerVisualizationConfigProps); const isEmptyLayer = !groups.some((d) => d.accessors.length > 0); - + const { activeId, activeGroup } = activeDimension; return ( @@ -196,31 +194,6 @@ export function LayerPanel( > <> {group.accessors.map((accessor) => { - const datasourceDimensionEditor = ( - - ); - const visDimensionEditor = - activeVisualization.renderDimensionEditor && group.enableDimensionEditor ? ( -
    - -
    - ) : null; return (
    - { - if (dimensionContainerState.isOpen) { - setDimensionContainerState(initialDimensionContainerState); - } else { - setDimensionContainerState({ - isOpen: true, - openId: accessor, - addingToGroupId: null, // not set for existing dimension - }); - } - }, - }} - /> - } - panel={ - <> - {datasourceDimensionEditor} - {visDimensionEditor} - - } - panelTitle={i18n.translate('xpack.lens.configure.configurePanelTitle', { - defaultMessage: '{groupLabel} configuration', - values: { - groupLabel: group.groupLabel, + { + if (activeId) { + setActiveDimension(initialActiveDimensionState); + } else { + setActiveDimension({ + isNew: false, + activeGroup: group, + activeId: accessor, + }); + } }, - })} + }} /> -
    - { - if (dimensionContainerState.isOpen) { - setDimensionContainerState(initialDimensionContainerState); - } else { - setDimensionContainerState({ - isOpen: true, - openId: newId, - addingToGroupId: group.groupId, - }); - } - }} - > - - - } - panelTitle={i18n.translate('xpack.lens.configure.configurePanelTitle', { - defaultMessage: '{groupLabel} configuration', - values: { - groupLabel: group.groupLabel, - }, - })} - panel={ - { - props.updateAll( - datasourceId, - newState, - activeVisualization.setDimension({ - layerId, - groupId: group.groupId, - columnId: newId, - prevState: props.visualizationState, - }) - ); - setDimensionContainerState({ - isOpen: true, - openId: newId, - addingToGroupId: null, // clear now that dimension exists - }); - }, - }} - /> - } - /> + { + if (activeId) { + setActiveDimension(initialActiveDimensionState); + } else { + setActiveDimension({ + isNew: true, + activeGroup: group, + activeId: newId, + }); + } + }} + > + +
    ) : null} @@ -472,6 +378,60 @@ export function LayerPanel( ); })} + setActiveDimension(initialActiveDimensionState)} + panel={ + <> + {activeGroup && activeId && ( + { + props.updateAll( + datasourceId, + newState, + activeVisualization.setDimension({ + layerId, + groupId: activeGroup.groupId, + columnId: activeId, + prevState: props.visualizationState, + }) + ); + setActiveDimension({ + ...activeDimension, + isNew: false, + }); + }, + }} + /> + )} + {activeGroup && + activeId && + !activeDimension.isNew && + activeVisualization.renderDimensionEditor && + activeGroup?.enableDimensionEditor && ( +
    + +
    + )} + + } + /> diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts index d42c5c3b99e53..c172c6da6848c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts @@ -10,6 +10,7 @@ import { FramePublicAPI, Datasource, DatasourceDimensionEditorProps, + VisualizationDimensionGroupConfig, } from '../../../types'; export interface ConfigPanelWrapperProps { @@ -30,8 +31,8 @@ export interface ConfigPanelWrapperProps { core: DatasourceDimensionEditorProps['core']; } -export interface DimensionContainerState { - isOpen: boolean; - openId: string | null; - addingToGroupId: string | null; +export interface ActiveDimensionState { + isNew: boolean; + activeId?: string; + activeGroup?: VisualizationDimensionGroupConfig; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index e628ea0675a8d..7328cdaf6fc9b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -184,8 +184,8 @@ describe('editor_frame', () => { /> ); }); - expect(mockDatasource.initialize).toHaveBeenCalledWith(datasource1State, []); - expect(mockDatasource2.initialize).toHaveBeenCalledWith(datasource2State, []); + expect(mockDatasource.initialize).toHaveBeenCalledWith(datasource1State, [], undefined); + expect(mockDatasource2.initialize).toHaveBeenCalledWith(datasource2State, [], undefined); expect(mockDatasource3.initialize).not.toHaveBeenCalled(); }); @@ -972,6 +972,32 @@ describe('editor_frame', () => { }); describe('suggestions', () => { + it('should fetch suggestions of currently active datasource when initializes from visualization trigger', async () => { + await act(async () => { + mount( + + ); + }); + + expect(mockDatasource.getDatasourceSuggestionsForVisualizeField).toHaveBeenCalled(); + }); + it('should fetch suggestions of currently active datasource', async () => { await act(async () => { mount( @@ -1208,6 +1234,7 @@ describe('editor_frame', () => { ...mockDatasource, getDatasourceSuggestionsForField: () => [generateSuggestion()], getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()], + getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()], }, }} initialDatasourceId="testDatasource" @@ -1274,6 +1301,7 @@ describe('editor_frame', () => { ...mockDatasource, getDatasourceSuggestionsForField: () => [generateSuggestion()], getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()], + getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()], renderDataPanel: (_element, { dragDropContext: { setDragging, dragging } }) => { if (dragging !== 'draggedField') { setDragging('draggedField'); @@ -1370,6 +1398,7 @@ describe('editor_frame', () => { ...mockDatasource, getDatasourceSuggestionsForField: () => [generateSuggestion()], getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()], + getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()], renderDataPanel: (_element, { dragDropContext: { setDragging, dragging } }) => { if (dragging !== 'draggedField') { setDragging('draggedField'); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index 72ad8e074226c..32fd4461dfc8b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useReducer } from 'react'; +import React, { useEffect, useReducer, useState } from 'react'; import { CoreSetup, CoreStart } from 'kibana/public'; import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public'; import { Datasource, FramePublicAPI, Visualization } from '../../types'; @@ -19,8 +19,10 @@ import { RootDragDropProvider } from '../../drag_drop'; import { getSavedObjectFormat } from './save'; import { generateId } from '../../id_generator'; import { Filter, Query, SavedQuery } from '../../../../../../src/plugins/data/public'; +import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public'; import { EditorFrameStartPlugins } from '../service'; import { initializeDatasources, createDatasourceLayers } from './state_helpers'; +import { applyVisualizeFieldSuggestions } from './suggestion_helpers'; export interface EditorFrameProps { doc?: Document; @@ -45,10 +47,14 @@ export interface EditorFrameProps { isSaveable: boolean; }) => void; showNoDataPopover: () => void; + initialContext?: VisualizeFieldContext; } export function EditorFrame(props: EditorFrameProps) { const [state, dispatch] = useReducer(reducer, props, getInitialState); + const [visualizeTriggerFieldContext, setVisualizeTriggerFieldContext] = useState( + props.initialContext + ); const { onError } = props; const activeVisualization = state.visualization.activeId && props.visualizationMap[state.visualization.activeId]; @@ -63,7 +69,12 @@ export function EditorFrame(props: EditorFrameProps) { // prevents executing dispatch on unmounted component let isUnmounted = false; if (!allLoaded) { - initializeDatasources(props.datasourceMap, state.datasourceStates, props.doc?.references) + initializeDatasources( + props.datasourceMap, + state.datasourceStates, + props.doc?.references, + visualizeTriggerFieldContext + ) .then((result) => { if (!isUnmounted) { Object.entries(result).forEach(([datasourceId, { state: datasourceState }]) => { @@ -84,7 +95,6 @@ export function EditorFrame(props: EditorFrameProps) { // eslint-disable-next-line react-hooks/exhaustive-deps [allLoaded, onError] ); - const datasourceLayers = createDatasourceLayers(props.datasourceMap, state.datasourceStates); const framePublicAPI: FramePublicAPI = { @@ -180,6 +190,23 @@ export function EditorFrame(props: EditorFrameProps) { [allLoaded, activeVisualization, state.visualization.state] ); + // Get suggestions for visualize field when all datasources are ready + useEffect(() => { + if (allLoaded && visualizeTriggerFieldContext && !props.doc) { + applyVisualizeFieldSuggestions({ + datasourceMap: props.datasourceMap, + datasourceStates: state.datasourceStates, + visualizationMap: props.visualizationMap, + activeVisualizationId: state.visualization.activeId, + visualizationState: state.visualization.state, + visualizeTriggerFieldContext, + dispatch, + }); + setVisualizeTriggerFieldContext(undefined); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allLoaded]); + // The frame needs to call onChange every time its internal state changes useEffect( () => { @@ -275,6 +302,7 @@ export function EditorFrame(props: EditorFrameProps) { ExpressionRenderer={props.ExpressionRenderer} core={props.core} plugins={props.plugins} + visualizeTriggerFieldContext={visualizeTriggerFieldContext} /> ) } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts index 1fe5224d0b1b4..8b0334ab98c14 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts @@ -9,18 +9,20 @@ import { Ast } from '@kbn/interpreter/common'; import { Datasource, DatasourcePublicAPI, Visualization } from '../../types'; import { buildExpression } from './expression_helpers'; import { Document } from '../../persistence/saved_object_store'; +import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public'; export async function initializeDatasources( datasourceMap: Record, datasourceStates: Record, - references?: SavedObjectReference[] + references?: SavedObjectReference[], + initialContext?: VisualizeFieldContext ) { const states: Record = {}; await Promise.all( Object.entries(datasourceMap).map(([datasourceId, datasource]) => { if (datasourceStates[datasourceId]) { return datasource - .initialize(datasourceStates[datasourceId].state || undefined, references) + .initialize(datasourceStates[datasourceId].state || undefined, references, initialContext) .then((datasourceState) => { states[datasourceId] = { isLoading: false, state: datasourceState }; }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts index 63b8b1f048296..c5c66c1c820e8 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts @@ -173,6 +173,75 @@ describe('suggestion helpers', () => { expect(multiDatasourceMap.mock3.getDatasourceSuggestionsForField).not.toHaveBeenCalled(); }); + it('should call getDatasourceSuggestionsForVisualizeField when a visualizeTriggerField is passed', () => { + datasourceMap.mock.getDatasourceSuggestionsForVisualizeField.mockReturnValue([ + generateSuggestion(), + ]); + getSuggestions({ + visualizationMap: { + vis1: createMockVisualization(), + }, + activeVisualizationId: 'vis1', + visualizationState: {}, + datasourceMap, + datasourceStates, + visualizeTriggerFieldContext: { + indexPatternId: '1', + fieldName: 'test', + }, + }); + expect(datasourceMap.mock.getDatasourceSuggestionsForVisualizeField).toHaveBeenCalledWith( + datasourceStates.mock.state, + '1', + 'test' + ); + }); + + it('should call getDatasourceSuggestionsForVisualizeField from all datasources with a state', () => { + const multiDatasourceStates = { + mock: { + isLoading: false, + state: {}, + }, + mock2: { + isLoading: false, + state: {}, + }, + }; + const multiDatasourceMap = { + mock: createMockDatasource('a'), + mock2: createMockDatasource('a'), + mock3: createMockDatasource('a'), + }; + const visualizeTriggerField = { + indexPatternId: '1', + fieldName: 'test', + }; + getSuggestions({ + visualizationMap: { + vis1: createMockVisualization(), + }, + activeVisualizationId: 'vis1', + visualizationState: {}, + datasourceMap: multiDatasourceMap, + datasourceStates: multiDatasourceStates, + visualizeTriggerFieldContext: visualizeTriggerField, + }); + expect(multiDatasourceMap.mock.getDatasourceSuggestionsForVisualizeField).toHaveBeenCalledWith( + multiDatasourceStates.mock.state, + '1', + 'test' + ); + expect(multiDatasourceMap.mock2.getDatasourceSuggestionsForVisualizeField).toHaveBeenCalledWith( + multiDatasourceStates.mock2.state, + '1', + 'test' + ); + expect( + multiDatasourceMap.mock3.getDatasourceSuggestionsForVisualizeField + ).not.toHaveBeenCalled(); + }); + it('should rank the visualizations by score', () => { const mockVisualization1 = createMockVisualization(); const mockVisualization2 = createMockVisualization(); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts index 2bb1baf9d54f2..c4a92dde6187c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts @@ -7,6 +7,7 @@ import _ from 'lodash'; import { Ast } from '@kbn/interpreter/common'; import { IconType } from '@elastic/eui/src/components/icon/icon'; +import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public'; import { Visualization, Datasource, @@ -47,6 +48,7 @@ export function getSuggestions({ subVisualizationId, visualizationState, field, + visualizeTriggerFieldContext, }: { datasourceMap: Record; datasourceStates: Record< @@ -61,6 +63,7 @@ export function getSuggestions({ subVisualizationId?: string; visualizationState: unknown; field?: unknown; + visualizeTriggerFieldContext?: VisualizeFieldContext; }): Suggestion[] { const datasources = Object.entries(datasourceMap).filter( ([datasourceId]) => datasourceStates[datasourceId] && !datasourceStates[datasourceId].isLoading @@ -70,10 +73,21 @@ export function getSuggestions({ const datasourceTableSuggestions = _.flatten( datasources.map(([datasourceId, datasource]) => { const datasourceState = datasourceStates[datasourceId].state; - return (field - ? datasource.getDatasourceSuggestionsForField(datasourceState, field) - : datasource.getDatasourceSuggestionsFromCurrentState(datasourceState) - ).map((suggestion) => ({ ...suggestion, datasourceId })); + let dataSourceSuggestions; + if (visualizeTriggerFieldContext) { + dataSourceSuggestions = datasource.getDatasourceSuggestionsForVisualizeField( + datasourceState, + visualizeTriggerFieldContext.indexPatternId, + visualizeTriggerFieldContext.fieldName + ); + } else if (field) { + dataSourceSuggestions = datasource.getDatasourceSuggestionsForField(datasourceState, field); + } else { + dataSourceSuggestions = datasource.getDatasourceSuggestionsFromCurrentState( + datasourceState + ); + } + return dataSourceSuggestions.map((suggestion) => ({ ...suggestion, datasourceId })); }) ); @@ -100,6 +114,45 @@ export function getSuggestions({ ).sort((a, b) => b.score - a.score); } +export function applyVisualizeFieldSuggestions({ + datasourceMap, + datasourceStates, + visualizationMap, + activeVisualizationId, + visualizationState, + visualizeTriggerFieldContext, + dispatch, +}: { + datasourceMap: Record; + datasourceStates: Record< + string, + { + isLoading: boolean; + state: unknown; + } + >; + visualizationMap: Record; + activeVisualizationId: string | null; + subVisualizationId?: string; + visualizationState: unknown; + visualizeTriggerFieldContext?: VisualizeFieldContext; + dispatch: (action: Action) => void; +}): void { + const suggestions = getSuggestions({ + datasourceMap, + datasourceStates, + visualizationMap, + activeVisualizationId, + visualizationState, + visualizeTriggerFieldContext, + }); + if (suggestions.length) { + const selectedSuggestion = + suggestions.find((s) => s.visualizationId === activeVisualizationId) || suggestions[0]; + switchToSuggestion(dispatch, selectedSuggestion, 'SWITCH_VISUALIZATION'); + } +} + /** * Queries a single visualization extensions for a single datasource suggestion and * creates an array of complete suggestions containing both the target datasource diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index 3993b4ffc02b0..34979083645c3 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -28,7 +28,10 @@ import { getSuggestions, switchToSuggestion } from '../suggestion_helpers'; import { buildExpression } from '../expression_helpers'; import { debouncedComponent } from '../../../debounced_component'; import { trackUiEvent } from '../../../lens_ui_telemetry'; -import { UiActionsStart } from '../../../../../../../src/plugins/ui_actions/public'; +import { + UiActionsStart, + VisualizeFieldContext, +} from '../../../../../../../src/plugins/ui_actions/public'; import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/plugins/visualizations/public'; import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public'; import { WorkspacePanelWrapper } from './workspace_panel_wrapper'; @@ -53,6 +56,7 @@ export interface WorkspacePanelProps { core: CoreStart | CoreSetup; plugins: { uiActions?: UiActionsStart; data: DataPublicPluginStart }; title?: string; + visualizeTriggerFieldContext?: VisualizeFieldContext; } export const WorkspacePanel = debouncedComponent(InnerWorkspacePanel); @@ -71,6 +75,7 @@ export function InnerWorkspacePanel({ plugins, ExpressionRenderer: ExpressionRendererComponent, title, + visualizeTriggerFieldContext, }: WorkspacePanelProps) { const dragDropContext = useContext(DragContext); @@ -245,7 +250,9 @@ export function InnerWorkspacePanel({ } function renderVisualization() { - if (expression === null) { + // we don't want to render the emptyWorkspace on visualizing field from Discover + // as it is specific for the drag and drop functionality and can confuse the users + if (expression === null && !visualizeTriggerFieldContext) { return renderEmptyWorkspace(); } diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx index 151f85e817c70..4966fce590542 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx @@ -27,6 +27,7 @@ import { coreMock, httpServiceMock } from '../../../../../../src/core/public/moc import { IBasePath } from '../../../../../../src/core/public'; import { AttributeService } from '../../../../../../src/plugins/dashboard/public'; import { LensAttributeService } from '../../lens_attribute_service'; +import { OnSaveProps } from '../../../../../../src/plugins/saved_objects/public/save_modal'; jest.mock('../../../../../../src/plugins/inspector/public/', () => ({ isAvailable: false, @@ -44,6 +45,30 @@ const savedVis: Document = { title: 'My title', visualizationType: '', }; +const defaultSaveMethod = ( + type: string, + testAttributes: LensSavedObjectAttributes, + savedObjectId?: string +): Promise<{ id: string }> => { + return new Promise(() => { + return { id: '123' }; + }); +}; +const defaultUnwrapMethod = (savedObjectId: string): Promise => { + return new Promise(() => { + return { ...savedVis }; + }); +}; +const defaultCheckForDuplicateTitle = (props: OnSaveProps): Promise => { + return new Promise(() => { + return true; + }); +}; +const options = { + saveMethod: defaultSaveMethod, + unwrapMethod: defaultUnwrapMethod, + checkForDuplicateTitle: defaultCheckForDuplicateTitle, +}; const attributeServiceMockFromSavedVis = (document: Document): LensAttributeService => { const core = coreMock.createStart(); @@ -51,14 +76,7 @@ const attributeServiceMockFromSavedVis = (document: Document): LensAttributeServ LensSavedObjectAttributes, LensByValueInput, LensByReferenceInput - >( - 'lens', - jest.fn(), - core.savedObjects.client, - core.overlays, - core.i18n.Context, - core.notifications.toasts - ); + >('lens', jest.fn(), core.i18n.Context, core.notifications.toasts, options); service.unwrapAttributes = jest.fn((input: LensByValueInput | LensByReferenceInput) => { return Promise.resolve({ ...document } as LensSavedObjectAttributes); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx index 86b137851d9bd..93898ef1d43a8 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/mocks.tsx @@ -69,6 +69,7 @@ export function createMockDatasource(id: string): DatasourceMock { id: 'mockindexpattern', clearLayer: jest.fn((state, _layerId) => state), getDatasourceSuggestionsForField: jest.fn((_state, _item) => []), + getDatasourceSuggestionsForVisualizeField: jest.fn((_state, _indexpatternId, _fieldName) => []), getDatasourceSuggestionsFromCurrentState: jest.fn((_state) => []), getPersistableState: jest.fn((x) => ({ state: x, savedObjectReferences: [] })), getPublicAPI: jest.fn().mockReturnValue(publicAPIMock), diff --git a/x-pack/plugins/lens/public/editor_frame_service/service.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.test.tsx index c1b6d74bb49c0..e9f8013ef7e2d 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/service.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.test.tsx @@ -53,6 +53,10 @@ describe('editor_frame service', () => { query: { query: '', language: 'lucene' }, filters: [], showNoDataPopover: jest.fn(), + initialContext: { + indexPatternId: '1', + fieldName: 'test', + }, }); instance.unmount(); })() diff --git a/x-pack/plugins/lens/public/editor_frame_service/service.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.tsx index e6d7f78f5ad07..54250c3bd9300 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/service.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.tsx @@ -127,7 +127,17 @@ export class EditorFrameService { return { mount: async ( element, - { doc, onError, dateRange, query, filters, savedQuery, onChange, showNoDataPopover } + { + doc, + onError, + dateRange, + query, + filters, + savedQuery, + onChange, + showNoDataPopover, + initialContext, + } ) => { domElement = element; const firstDatasourceId = Object.keys(resolvedDatasources)[0]; @@ -156,6 +166,7 @@ export class EditorFrameService { savedQuery={savedQuery} onChange={onChange} showNoDataPopover={showNoDataPopover} + initialContext={initialContext} /> , domElement diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/__snapshots__/no_fields_callout.test.tsx.snap b/x-pack/plugins/lens/public/indexpattern_datasource/__snapshots__/no_fields_callout.test.tsx.snap deleted file mode 100644 index 607f968d86faa..0000000000000 --- a/x-pack/plugins/lens/public/indexpattern_datasource/__snapshots__/no_fields_callout.test.tsx.snap +++ /dev/null @@ -1,49 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`NoFieldCallout renders properly for index with no fields 1`] = ` - -`; - -exports[`NoFieldCallout renders properly when affected by field filter 1`] = ` - - - Try: - -
      -
    • - Using different field filters -
    • -
    -
    -`; - -exports[`NoFieldCallout renders properly when affected by field filters, global filter and timerange 1`] = ` - - - Try: - -
      -
    • - Extending the time range -
    • -
    • - Using different field filters -
    • -
    • - Changing the global filters -
    • -
    -
    -`; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index c78a7a6629c65..28c5605f3bfc5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -335,6 +335,9 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ isAffectedByGlobalFilter: !!filters.length, isAffectedByTimeFilter: true, hideDetails: fieldInfoUnavailable, + defaultNoFieldsMessage: i18n.translate('xpack.lens.indexPatterns.noAvailableDataLabel', { + defaultMessage: `There are no available fields that contain data.`, + }), }, EmptyFields: { fields: groupedFields.emptyFields, @@ -347,6 +350,9 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ title: i18n.translate('xpack.lens.indexPattern.emptyFieldsLabel', { defaultMessage: 'Empty fields', }), + defaultNoFieldsMessage: i18n.translate('xpack.lens.indexPatterns.noEmptyDataLabel', { + defaultMessage: `There are no empty fields.`, + }), }, MetaFields: { fields: groupedFields.metaFields, @@ -359,6 +365,9 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ title: i18n.translate('xpack.lens.indexPattern.metaFieldsLabel', { defaultMessage: 'Meta fields', }), + defaultNoFieldsMessage: i18n.translate('xpack.lens.indexPatterns.noMetaDataLabel', { + defaultMessage: `There are no meta fields.`, + }), }, }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx index 63809218a1dd0..eb7730677d52a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx @@ -32,6 +32,7 @@ export type FieldGroups = Record< isAffectedByGlobalFilter: boolean; isAffectedByTimeFilter: boolean; hideDetails?: boolean; + defaultNoFieldsMessage?: string; } >; @@ -76,8 +77,6 @@ export function FieldList({ ) ); - const isAffectedByFieldFilter = !!(filter.typeFilter.length || filter.nameFilter.length); - useEffect(() => { // Reset the scroll if we have made material changes to the field list if (scrollContainer) { @@ -180,9 +179,10 @@ export function FieldList({ renderCallout={ } /> diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 7f7eb0bc0fdac..28aeac223e4a6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -36,6 +36,7 @@ import { IndexPatternDataPanel } from './datapanel'; import { getDatasourceSuggestionsForField, getDatasourceSuggestionsFromCurrentState, + getDatasourceSuggestionsForVisualizeField, } from './indexpattern_suggestions'; import { isDraggedField, normalizeOperationDataType } from './utils'; @@ -49,6 +50,7 @@ import { } from './types'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; +import { VisualizeFieldContext } from '../../../../../src/plugins/ui_actions/public'; import { deleteColumn } from './state_helpers'; import { Datasource, StateSetter } from '../index'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; @@ -134,7 +136,8 @@ export function getIndexPatternDatasource({ async initialize( persistedState?: IndexPatternPersistedState, - references?: SavedObjectReference[] + references?: SavedObjectReference[], + initialContext?: VisualizeFieldContext ) { return loadInitialState({ persistedState, @@ -143,6 +146,7 @@ export function getIndexPatternDatasource({ defaultIndexPatternId: core.uiSettings.get('defaultIndex'), storage, indexPatternsService, + initialContext, }); }, @@ -335,6 +339,7 @@ export function getIndexPatternDatasource({ : []; }, getDatasourceSuggestionsFromCurrentState, + getDatasourceSuggestionsForVisualizeField, }; return indexPatternDatasource; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index 80765627c1fc2..a480cfe408982 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -10,6 +10,7 @@ import { IndexPatternPrivateState } from './types'; import { getDatasourceSuggestionsForField, getDatasourceSuggestionsFromCurrentState, + getDatasourceSuggestionsForVisualizeField, } from './indexpattern_suggestions'; jest.mock('./loader'); @@ -1077,6 +1078,70 @@ describe('IndexPattern Data Source suggestions', () => { }); }); }); + describe('#getDatasourceSuggestionsForVisualizeField', () => { + describe('with no layer', () => { + function stateWithoutLayer() { + return { + ...testInitialState(), + layers: {}, + }; + } + + it('should return an empty array if the field does not exist', () => { + const suggestions = getDatasourceSuggestionsForVisualizeField( + stateWithoutLayer(), + '1', + 'field_not_exist' + ); + + expect(suggestions).toEqual([]); + }); + + it('should apply a bucketed aggregation for a string field', () => { + const suggestions = getDatasourceSuggestionsForVisualizeField( + stateWithoutLayer(), + '1', + 'source' + ); + + expect(suggestions).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ + layers: { + id1: expect.objectContaining({ + columnOrder: ['id2', 'id3'], + columns: { + id2: expect.objectContaining({ + operationType: 'terms', + sourceField: 'source', + params: expect.objectContaining({ size: 5 }), + }), + id3: expect.objectContaining({ + operationType: 'count', + }), + }, + }), + }, + }), + table: { + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'id2', + }), + expect.objectContaining({ + columnId: 'id3', + }), + ], + layerId: 'id1', + }, + }) + ); + }); + }); + }); describe('#getDatasourceSuggestionsFromCurrentState', () => { it('returns no suggestions if there are no columns', () => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts index 75945529ffb34..c7eeef178c251 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts @@ -118,6 +118,25 @@ export function getDatasourceSuggestionsForField( } } +// Called when the user navigates from Discover to Lens (Visualize button) +export function getDatasourceSuggestionsForVisualizeField( + state: IndexPatternPrivateState, + indexPatternId: string, + fieldName: string +): IndexPatternSugestion[] { + const layers = Object.keys(state.layers); + const layerIds = layers.filter((id) => state.layers[id].indexPatternId === indexPatternId); + // Identify the field by the indexPatternId and the fieldName + const indexPattern = state.indexPatterns[indexPatternId]; + const field = indexPattern.fields.find((fld) => fld.name === fieldName); + + if (layerIds.length !== 0 || !field) return []; + const newId = generateId(); + return getEmptyLayerSuggestionsForField(state, newId, indexPatternId, field).concat( + getEmptyLayerSuggestionsForField({ ...state, layers: {} }, newId, indexPatternId, field) + ); +} + function getBucketOperation(field: IndexPatternField) { // We allow numeric bucket types in some cases, but it's generally not the right suggestion, // so we eliminate it here. @@ -473,7 +492,6 @@ export function getDatasourceSuggestionsFromCurrentState( suggestions.push(createChangedNestingSuggestion(state, layerId)); } } - return suggestions; }) ); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index ef6abbec9a34d..06cfdf7e03481 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -441,6 +441,34 @@ describe('loader', () => { }); }); + it('should use the indexPatternId of the visualize trigger field, if provided', async () => { + const storage = createMockStorage(); + const state = await loadInitialState({ + savedObjectsClient: mockClient(), + indexPatternsService: mockIndexPatternsService(), + storage, + initialContext: { + indexPatternId: '1', + fieldName: '', + }, + }); + + expect(state).toMatchObject({ + currentIndexPatternId: '1', + indexPatternRefs: [ + { id: '1', title: sampleIndexPatterns['1'].title }, + { id: '2', title: sampleIndexPatterns['2'].title }, + ], + indexPatterns: { + '1': sampleIndexPatterns['1'], + }, + layers: {}, + }); + expect(storage.set).toHaveBeenCalledWith('lens-settings', { + indexPatternId: '1', + }); + }); + it('should initialize from saved state', async () => { const savedState: IndexPatternPersistedState = { layers: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index c4b1eb9e0c4c4..fd8e071d524ee 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -23,6 +23,7 @@ import { IndexPatternsContract, indexPatterns as indexPatternsUtils, } from '../../../../../src/plugins/data/public'; +import { VisualizeFieldContext } from '../../../../../src/plugins/ui_actions/public'; import { documentField } from './document_field'; import { readFromStorage, writeToStorage } from '../settings_storage'; @@ -179,6 +180,7 @@ export async function loadInitialState({ defaultIndexPatternId, storage, indexPatternsService, + initialContext, }: { persistedState?: IndexPatternPersistedState; references?: SavedObjectReference[]; @@ -186,6 +188,7 @@ export async function loadInitialState({ defaultIndexPatternId?: string; storage: IStorageWrapper; indexPatternsService: IndexPatternsService; + initialContext?: VisualizeFieldContext; }): Promise { const indexPatternRefs = await loadIndexPatternRefs(savedObjectsClient); const lastUsedIndexPatternId = getLastUsedIndexPatternId(storage, indexPatternRefs); @@ -201,13 +204,13 @@ export async function loadInitialState({ : [lastUsedIndexPatternId || defaultIndexPatternId || indexPatternRefs[0].id] ); - const currentIndexPatternId = requiredPatterns[0]; + const currentIndexPatternId = initialContext?.indexPatternId ?? requiredPatterns[0]; setLastUsedIndexPatternId(storage, currentIndexPatternId); const indexPatterns = await loadIndexPatterns({ indexPatternsService, cache: {}, - patterns: requiredPatterns, + patterns: initialContext ? [initialContext.indexPatternId] : requiredPatterns, }); if (state) { return { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/no_fields_callout.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/no_fields_callout.test.tsx index f32bf52339e1c..02958c6a913b6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/no_fields_callout.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/no_fields_callout.test.tsx @@ -8,29 +8,152 @@ import { shallow } from 'enzyme'; import { NoFieldsCallout } from './no_fields_callout'; describe('NoFieldCallout', () => { - it('renders properly for index with no fields', () => { + it('renders correctly for index with no fields', () => { + const component = shallow(); + expect(component).toMatchInlineSnapshot(` + + `); + }); + it('renders correctly when empty with no filters/timerange reasons', () => { + const component = shallow(); + expect(component).toMatchInlineSnapshot(` + + `); + }); + it('renders correctly with passed defaultNoFieldsMessage', () => { const component = shallow( - + ); - expect(component).toMatchSnapshot(); + expect(component).toMatchInlineSnapshot(` + + `); }); - it('renders properly when affected by field filters, global filter and timerange', () => { + it('renders properly when affected by field filter', () => { + const component = shallow( + + ); + expect(component).toMatchInlineSnapshot(` + + + Try: + +
      +
    • + Using different field filters +
    • +
    +
    + `); + }); + + it('renders correctly when affected by global filters and timerange', () => { const component = shallow( ); - expect(component).toMatchSnapshot(); + expect(component).toMatchInlineSnapshot(` + + + Try: + +
      +
    • + Extending the time range +
    • +
    • + Changing the global filters +
    • +
    +
    + `); }); - it('renders properly when affected by field filter', () => { + it('renders correctly when affected by global filters and field filters', () => { const component = shallow( - + + ); + expect(component).toMatchInlineSnapshot(` + + + Try: + +
      +
    • + Extending the time range +
    • +
    • + Using different field filters +
    • +
    +
    + `); + }); + + it('renders correctly when affected by field filters, global filter and timerange', () => { + const component = shallow( + ); - expect(component).toMatchSnapshot(); + expect(component).toMatchInlineSnapshot(` + + + Try: + +
      +
    • + Extending the time range +
    • +
    • + Using different field filters +
    • +
    • + Changing the global filters +
    • +
    +
    + `); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/no_fields_callout.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/no_fields_callout.tsx index 066d60f006207..158b5f086f5eb 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/no_fields_callout.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/no_fields_callout.tsx @@ -7,17 +7,35 @@ import React from 'react'; import { EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +const defaultNoFieldsMessageCopy = i18n.translate('xpack.lens.indexPatterns.noDataLabel', { + defaultMessage: 'There are no fields.', +}); + export const NoFieldsCallout = ({ - isAffectedByFieldFilter, existFieldsInIndex, + defaultNoFieldsMessage = defaultNoFieldsMessageCopy, + isAffectedByFieldFilter = false, isAffectedByTimerange = false, isAffectedByGlobalFilter = false, }: { - isAffectedByFieldFilter: boolean; existFieldsInIndex: boolean; + isAffectedByFieldFilter?: boolean; + defaultNoFieldsMessage?: string; isAffectedByTimerange?: boolean; isAffectedByGlobalFilter?: boolean; }) => { + if (!existFieldsInIndex) { + return ( + + ); + } + return ( - {existFieldsInIndex && ( + {(isAffectedByTimerange || isAffectedByFieldFilter || isAffectedByGlobalFilter) && ( <> {i18n.translate('xpack.lens.indexPatterns.noFields.tryText', { @@ -45,28 +57,26 @@ export const NoFieldsCallout = ({
      {isAffectedByTimerange && ( - <> -
    • - {i18n.translate('xpack.lens.indexPatterns.noFields.extendTimeBullet', { - defaultMessage: 'Extending the time range', - })} -
    • - +
    • + {i18n.translate('xpack.lens.indexPatterns.noFields.extendTimeBullet', { + defaultMessage: 'Extending the time range', + })} +
    • )} - {isAffectedByFieldFilter ? ( + {isAffectedByFieldFilter && (
    • {i18n.translate('xpack.lens.indexPatterns.noFields.fieldTypeFilterBullet', { defaultMessage: 'Using different field filters', })}
    • - ) : null} - {isAffectedByGlobalFilter ? ( + )} + {isAffectedByGlobalFilter && (
    • {i18n.translate('xpack.lens.indexPatterns.noFields.globalFiltersBullet', { defaultMessage: 'Changing the global filters', })}
    • - ) : null} + )}
    )} diff --git a/x-pack/plugins/lens/public/lens_attribute_service.ts b/x-pack/plugins/lens/public/lens_attribute_service.ts index 3c43fd98cceb4..9e1ce535d6675 100644 --- a/x-pack/plugins/lens/public/lens_attribute_service.ts +++ b/x-pack/plugins/lens/public/lens_attribute_service.ts @@ -13,6 +13,7 @@ import { LensByReferenceInput, } from './editor_frame_service/embeddable/embeddable'; import { SavedObjectIndexStore, DOC_TYPE } from './persistence'; +import { checkForDuplicateTitle, OnSaveProps } from '../../../../src/plugins/saved_objects/public'; export type LensAttributeService = AttributeService< LensSavedObjectAttributes, @@ -30,7 +31,7 @@ export function getLensAttributeService( LensByValueInput, LensByReferenceInput >(DOC_TYPE, { - customSaveMethod: async ( + saveMethod: async ( type: string, attributes: LensSavedObjectAttributes, savedObjectId?: string @@ -42,11 +43,34 @@ export function getLensAttributeService( }); return { id: savedDoc.savedObjectId }; }, - customUnwrapMethod: (savedObject) => { + unwrapMethod: async (savedObjectId: string): Promise => { + const savedObject = await core.savedObjects.client.get( + DOC_TYPE, + savedObjectId + ); return { ...savedObject.attributes, references: savedObject.references, }; }, + checkForDuplicateTitle: (props: OnSaveProps) => { + const savedObjectsClient = core.savedObjects.client; + const overlays = core.overlays; + return checkForDuplicateTitle( + { + title: props.newTitle, + copyOnSave: false, + lastSavedTitle: '', + getEsType: () => DOC_TYPE, + getDisplayName: () => DOC_TYPE, + }, + props.isTitleDuplicateConfirmed, + props.onTitleDuplicate, + { + savedObjectsClient, + overlays, + } + ); + }, }); } diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 38d256d2b3afd..90b0f0a2bde84 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -29,10 +29,15 @@ import { PieVisualization, PieVisualizationPluginSetupPlugins } from './pie_visu import { stopReportManager } from './lens_ui_telemetry'; import { AppNavLinkStatus } from '../../../../src/core/public'; -import { UiActionsStart } from '../../../../src/plugins/ui_actions/public'; +import { + UiActionsStart, + ACTION_VISUALIZE_FIELD, + VISUALIZE_FIELD_TRIGGER, +} from '../../../../src/plugins/ui_actions/public'; import { NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../common'; import { EditorFrameStart } from './types'; import { getLensAliasConfig } from './vis_type_alias'; +import { visualizeFieldAction } from './trigger_actions/visualize_field_actions'; import { getSearchProvider } from './search_provider'; import { getLensAttributeService, LensAttributeService } from './lens_attribute_service'; @@ -155,6 +160,14 @@ export class LensPlugin { start(core: CoreStart, startDependencies: LensPluginStartDependencies) { this.attributeService = getLensAttributeService(core, startDependencies); this.createEditorFrame = this.editorFrameService.start(core, startDependencies).createInstance; + // unregisters the Visualize action and registers the lens one + if (startDependencies.uiActions.hasAction(ACTION_VISUALIZE_FIELD)) { + startDependencies.uiActions.unregisterAction(ACTION_VISUALIZE_FIELD); + } + startDependencies.uiActions.addTriggerAction( + VISUALIZE_FIELD_TRIGGER, + visualizeFieldAction(core.application) + ); } stop() { diff --git a/x-pack/plugins/lens/public/trigger_actions/visualize_field_actions.ts b/x-pack/plugins/lens/public/trigger_actions/visualize_field_actions.ts new file mode 100644 index 0000000000000..a473d433ac89d --- /dev/null +++ b/x-pack/plugins/lens/public/trigger_actions/visualize_field_actions.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 { i18n } from '@kbn/i18n'; +import { + createAction, + ACTION_VISUALIZE_LENS_FIELD, + VisualizeFieldContext, +} from '../../../../../src/plugins/ui_actions/public'; +import { ApplicationStart } from '../../../../../src/core/public'; + +export const visualizeFieldAction = (application: ApplicationStart) => + createAction({ + type: ACTION_VISUALIZE_LENS_FIELD, + id: ACTION_VISUALIZE_LENS_FIELD, + getDisplayName: () => + i18n.translate('xpack.lens.discover.visualizeFieldLegend', { + defaultMessage: 'Visualize field', + }), + isCompatible: async () => !!application.capabilities.visualize.show, + execute: async (context: VisualizeFieldContext) => { + application.navigateToApp('lens', { + state: { type: ACTION_VISUALIZE_LENS_FIELD, payload: context }, + }); + }, + }); diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index e5e8a645dd0e8..6061f928bce41 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -22,6 +22,7 @@ import { SELECT_RANGE_TRIGGER, TriggerContext, VALUE_CLICK_TRIGGER, + VisualizeFieldContext, } from '../../../../src/plugins/ui_actions/public'; export type ErrorCallback = (e: { message: string }) => void; @@ -40,6 +41,7 @@ export interface EditorFrameProps { query: Query; filters: Filter[]; savedQuery?: SavedQuery; + initialContext?: VisualizeFieldContext; // Frame loader (app or embeddable) is expected to call this when it loads and updates // This should be replaced with a top-down state @@ -145,7 +147,11 @@ export interface Datasource { // For initializing, either from an empty state or from persisted state // Because this will be called at runtime, state might have a type of `any` and // datasources should validate their arguments - initialize: (state?: P, savedObjectReferences?: SavedObjectReference[]) => Promise; + initialize: ( + state?: P, + savedObjectReferences?: SavedObjectReference[], + initialContext?: VisualizeFieldContext + ) => Promise; // Given the current state, which parts should be saved? getPersistableState: (state: T) => { state: P; savedObjectReferences: SavedObjectReference[] }; @@ -166,6 +172,11 @@ export interface Datasource { toExpression: (state: T, layerId: string) => Ast | string | null; getDatasourceSuggestionsForField: (state: T, field: unknown) => Array>; + getDatasourceSuggestionsForVisualizeField: ( + state: T, + indexPatternId: string, + fieldName: string + ) => Array>; getDatasourceSuggestionsFromCurrentState: (state: T) => Array>; getPublicAPI: (props: PublicAPIProps) => DatasourcePublicAPI; diff --git a/x-pack/plugins/lists/common/constants.ts b/x-pack/plugins/lists/common/constants.ts index 1851487b824a2..d1b8e685c2c8a 100644 --- a/x-pack/plugins/lists/common/constants.ts +++ b/x-pack/plugins/lists/common/constants.ts @@ -44,10 +44,10 @@ export const ENDPOINT_LIST_ITEM_URL = '/api/endpoint_list/items'; export const ENDPOINT_LIST_ID = 'endpoint_list'; /** The name of the single global space agnostic endpoint list */ -export const ENDPOINT_LIST_NAME = 'Elastic Endpoint Security Exception List'; +export const ENDPOINT_LIST_NAME = 'Endpoint Security Exception List'; /** The description of the single global space agnostic endpoint list */ -export const ENDPOINT_LIST_DESCRIPTION = 'Elastic Endpoint Security Exception List'; +export const ENDPOINT_LIST_DESCRIPTION = 'Endpoint Security Exception List'; export const MAX_EXCEPTION_LIST_SIZE = 10000; @@ -55,7 +55,7 @@ export const MAX_EXCEPTION_LIST_SIZE = 10000; export const ENDPOINT_TRUSTED_APPS_LIST_ID = 'endpoint_trusted_apps'; /** Name of trusted apps agnostic list */ -export const ENDPOINT_TRUSTED_APPS_LIST_NAME = 'Elastic Endpoint Security Trusted Apps List'; +export const ENDPOINT_TRUSTED_APPS_LIST_NAME = 'Endpoint Security Trusted Apps List'; /** Description of trusted apps agnostic list */ -export const ENDPOINT_TRUSTED_APPS_LIST_DESCRIPTION = 'Elastic Endpoint Security Trusted Apps List'; +export const ENDPOINT_TRUSTED_APPS_LIST_DESCRIPTION = 'Endpoint Security Trusted Apps List'; diff --git a/x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.test.tsx b/x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.test.tsx new file mode 100644 index 0000000000000..cbdd253b6e2dd --- /dev/null +++ b/x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setDefaultAutoFitToBounds } from './set_default_auto_fit_to_bounds'; + +describe('setDefaultAutoFitToBounds', () => { + test('Should handle missing mapStateJSON attribute', () => { + const attributes = { + title: 'my map', + }; + expect(setDefaultAutoFitToBounds({ attributes })).toEqual({ + title: 'my map', + }); + }); + + test('Should set default auto fit to bounds when map settings exist in map state', () => { + const attributes = { + title: 'my map', + mapStateJSON: JSON.stringify({ + settings: { showSpatialFilters: false }, + }), + }; + expect(JSON.parse(setDefaultAutoFitToBounds({ attributes }).mapStateJSON!)).toEqual({ + settings: { autoFitToDataBounds: false, showSpatialFilters: false }, + }); + }); + + test('Should set default auto fit to bounds when map settings does not exist in map state', () => { + const attributes = { + title: 'my map', + mapStateJSON: JSON.stringify({}), + }; + expect(JSON.parse(setDefaultAutoFitToBounds({ attributes }).mapStateJSON!)).toEqual({ + settings: { autoFitToDataBounds: false }, + }); + }); +}); diff --git a/x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.ts b/x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.ts new file mode 100644 index 0000000000000..09e23b5213d6c --- /dev/null +++ b/x-pack/plugins/maps/common/migrations/set_default_auto_fit_to_bounds.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MapSavedObjectAttributes } from '../map_saved_object_type'; + +export function setDefaultAutoFitToBounds({ + attributes, +}: { + attributes: MapSavedObjectAttributes; +}): MapSavedObjectAttributes { + if (!attributes || !attributes.mapStateJSON) { + return attributes; + } + + // MapState type is defined in public, no need to bring all of that to common for this migration + const mapState: { settings?: { autoFitToDataBounds: boolean } } = JSON.parse( + attributes.mapStateJSON + ); + if ('settings' in mapState) { + mapState.settings!.autoFitToDataBounds = false; + } else { + mapState.settings = { + autoFitToDataBounds: false, + }; + } + + return { + ...attributes, + mapStateJSON: JSON.stringify(mapState), + }; +} diff --git a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx index bf75c86ac249d..352aed4a8cc93 100644 --- a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx @@ -50,6 +50,8 @@ interface Props { refreshConfig: MapRefreshConfig; renderTooltipContent?: RenderToolTipContent; triggerRefreshTimer: () => void; + title?: string; + description?: string; } interface State { @@ -197,7 +199,12 @@ export class MapContainer extends Component { if (mapInitError) { return ( -
    +
    { data-dom-id={this.state.domId} data-render-complete={this.state.isInitialLoadRenderTimeoutComplete} data-shared-item + data-title={this.props.title} + data-description={this.props.description} > { type = MAP_SAVED_OBJECT_TYPE; + private _description: string; private _renderTooltipContent?: RenderToolTipContent; private _eventHandlers?: EventHandlers; private _layerList: LayerDescriptor[]; @@ -95,6 +96,7 @@ export class MapEmbeddable extends Embeddable this.onContainerStateChanged(input)); } + public getDescription() { + return this._description; + } + supportedTriggers(): Array { return [APPLY_FILTER_TRIGGER]; } @@ -238,6 +244,8 @@ export class MapEmbeddable extends Embeddable , diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts index 489a73a90cf70..b49419487b6fa 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts @@ -109,6 +109,7 @@ export class MapEmbeddableFactory implements EmbeddableFactoryDefinition { { layerList, title: savedMap.title, + description: savedMap.description, editUrl: getHttp().basePath.prepend(getExistingMapPath(savedObjectId)), editApp: APP_ID, editPath: `/${MAP_PATH}/${savedObjectId}`, diff --git a/x-pack/plugins/maps/public/embeddable/types.ts b/x-pack/plugins/maps/public/embeddable/types.ts index ce06b7da9094a..8ba906111ad1e 100644 --- a/x-pack/plugins/maps/public/embeddable/types.ts +++ b/x-pack/plugins/maps/public/embeddable/types.ts @@ -6,15 +6,12 @@ import { IIndexPattern } from '../../../../../src/plugins/data/common/index_patterns'; import { MapSettings } from '../reducers/map'; -import { - EmbeddableInput, - EmbeddableOutput, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../src/plugins/embeddable/public/lib/embeddables'; +import { EmbeddableInput, EmbeddableOutput } from '../../../../../src/plugins/embeddable/public'; import { Filter, Query, RefreshInterval, TimeRange } from '../../../../../src/plugins/data/common'; import { LayerDescriptor, MapCenterAndZoom } from '../../common/descriptor_types'; export interface MapEmbeddableConfig { + description?: string; editUrl?: string; editApp?: string; editPath?: string; diff --git a/x-pack/plugins/maps/public/reducers/default_map_settings.ts b/x-pack/plugins/maps/public/reducers/default_map_settings.ts index 896ac11e36782..5375b5ca5c59b 100644 --- a/x-pack/plugins/maps/public/reducers/default_map_settings.ts +++ b/x-pack/plugins/maps/public/reducers/default_map_settings.ts @@ -9,7 +9,7 @@ import { MapSettings } from './map'; export function getDefaultMapSettings(): MapSettings { return { - autoFitToDataBounds: false, + autoFitToDataBounds: true, initialLocation: INITIAL_LOCATION.LAST_SAVED_LOCATION, fixedLocation: { lat: 0, lon: 0, zoom: 2 }, browserLocation: { zoom: 2 }, diff --git a/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.tsx b/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.tsx index abc3462caf6b4..bd08b2f11fadc 100644 --- a/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.tsx +++ b/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.tsx @@ -459,7 +459,11 @@ export class MapsAppView extends React.Component { {this._renderTopNav()}

    {`screenTitle placeholder`}

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

    {name}

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

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

    +
    + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/data_picker/index.tsx b/x-pack/plugins/observability/public/components/shared/data_picker/index.tsx index 1c4f465a1d301..747ec8a441c42 100644 --- a/x-pack/plugins/observability/public/components/shared/data_picker/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/data_picker/index.tsx @@ -5,9 +5,10 @@ */ import { EuiSuperDatePicker } from '@elastic/eui'; -import React from 'react'; +import React, { useEffect } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { UI_SETTINGS, useKibanaUISettings } from '../../../hooks/use_kibana_ui_settings'; +import { usePluginContext } from '../../../hooks/use_plugin_context'; import { fromQuery, toQuery } from '../../../utils/url'; export interface TimePickerTime { @@ -34,6 +35,14 @@ interface Props { export function DatePicker({ rangeFrom, rangeTo, refreshPaused, refreshInterval }: Props) { const location = useLocation(); const history = useHistory(); + const { plugins } = usePluginContext(); + + useEffect(() => { + plugins.data.query.timefilter.timefilter.setTime({ + from: rangeFrom, + to: rangeTo, + }); + }, [plugins, rangeFrom, rangeTo]); const timePickerQuickRanges = useKibanaUISettings( UI_SETTINGS.TIMEPICKER_QUICK_RANGES diff --git a/x-pack/plugins/observability/public/context/plugin_context.tsx b/x-pack/plugins/observability/public/context/plugin_context.tsx index 7d705e7a6cc05..9c14adb7c584e 100644 --- a/x-pack/plugins/observability/public/context/plugin_context.tsx +++ b/x-pack/plugins/observability/public/context/plugin_context.tsx @@ -5,10 +5,12 @@ */ import { createContext } from 'react'; -import { AppMountContext } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; +import { ObservabilityPluginSetupDeps } from '../plugin'; export interface PluginContextValue { - core: AppMountContext['core']; + core: CoreStart; + plugins: ObservabilityPluginSetupDeps; } export const PluginContext = createContext({} as PluginContextValue); diff --git a/x-pack/plugins/observability/public/data_handler.test.ts b/x-pack/plugins/observability/public/data_handler.test.ts index 935fc0682c414..dae2f62777d30 100644 --- a/x-pack/plugins/observability/public/data_handler.test.ts +++ b/x-pack/plugins/observability/public/data_handler.test.ts @@ -15,6 +15,7 @@ import { LogsFetchDataResponse, MetricsFetchDataResponse, UptimeFetchDataResponse, + UxFetchDataResponse, } from './typings'; const params = { @@ -273,6 +274,60 @@ describe('registerDataHandler', () => { expect(hasData).toBeTruthy(); }); }); + describe('Ux', () => { + registerDataHandler({ + appName: 'ux', + fetchData: async () => { + return { + title: 'User Experience', + appLink: '/ux', + coreWebVitals: { + cls: '0.01', + fid: 5, + lcp: 1464.3333333333333, + tbt: 232.92166666666665, + fcp: 1154.8, + lcpRanks: [73, 16, 11], + fidRanks: [85, 4, 11], + clsRanks: [88, 7, 5], + }, + }; + }, + hasData: async () => ({ hasData: true, serviceName: 'elastic-co-frontend' }), + }); + + it('registered data handler', () => { + const dataHandler = getDataHandler('ux'); + expect(dataHandler?.fetchData).toBeDefined(); + expect(dataHandler?.hasData).toBeDefined(); + }); + + it('returns data when fetchData is called', async () => { + const dataHandler = getDataHandler('ux'); + const response = await dataHandler?.fetchData(params); + expect(response).toEqual({ + title: 'User Experience', + appLink: '/ux', + coreWebVitals: { + cls: '0.01', + fid: 5, + lcp: 1464.3333333333333, + tbt: 232.92166666666665, + fcp: 1154.8, + lcpRanks: [73, 16, 11], + fidRanks: [85, 4, 11], + clsRanks: [88, 7, 5], + }, + }); + }); + + it('returns true when hasData is called', async () => { + const dataHandler = getDataHandler('ux'); + const hasData = await dataHandler?.hasData(); + expect(hasData).toBeTruthy(); + }); + }); + describe('Metrics', () => { registerDataHandler({ appName: 'infra_metrics', @@ -396,6 +451,7 @@ describe('registerDataHandler', () => { unregisterDataHandler({ appName: 'infra_logs' }); unregisterDataHandler({ appName: 'infra_metrics' }); unregisterDataHandler({ appName: 'uptime' }); + unregisterDataHandler({ appName: 'ux' }); registerDataHandler({ appName: 'apm', @@ -425,11 +481,19 @@ describe('registerDataHandler', () => { throw new Error('BOOM'); }, }); - expect(await fetchHasData()).toEqual({ + registerDataHandler({ + appName: 'ux', + fetchData: async () => (({} as unknown) as UxFetchDataResponse), + hasData: async () => { + throw new Error('BOOM'); + }, + }); + expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ apm: false, uptime: false, infra_logs: false, infra_metrics: false, + ux: false, }); }); it('returns true when has data and false when an exception happens', async () => { @@ -437,6 +501,7 @@ describe('registerDataHandler', () => { unregisterDataHandler({ appName: 'infra_logs' }); unregisterDataHandler({ appName: 'infra_metrics' }); unregisterDataHandler({ appName: 'uptime' }); + unregisterDataHandler({ appName: 'ux' }); registerDataHandler({ appName: 'apm', @@ -462,11 +527,19 @@ describe('registerDataHandler', () => { throw new Error('BOOM'); }, }); - expect(await fetchHasData()).toEqual({ + registerDataHandler({ + appName: 'ux', + fetchData: async () => (({} as unknown) as UxFetchDataResponse), + hasData: async () => { + throw new Error('BOOM'); + }, + }); + expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ apm: true, uptime: false, infra_logs: true, infra_metrics: false, + ux: false, }); }); it('returns true when has data', async () => { @@ -474,6 +547,7 @@ describe('registerDataHandler', () => { unregisterDataHandler({ appName: 'infra_logs' }); unregisterDataHandler({ appName: 'infra_metrics' }); unregisterDataHandler({ appName: 'uptime' }); + unregisterDataHandler({ appName: 'ux' }); registerDataHandler({ appName: 'apm', @@ -495,11 +569,23 @@ describe('registerDataHandler', () => { fetchData: async () => (({} as unknown) as UptimeFetchDataResponse), hasData: async () => true, }); - expect(await fetchHasData()).toEqual({ + registerDataHandler({ + appName: 'ux', + fetchData: async () => (({} as unknown) as UxFetchDataResponse), + hasData: async () => ({ + hasData: true, + serviceName: 'elastic-co', + }), + }); + expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ apm: true, uptime: true, infra_logs: true, infra_metrics: true, + ux: { + hasData: true, + serviceName: 'elastic-co', + }, }); }); it('returns false when has no data', async () => { @@ -507,6 +593,7 @@ describe('registerDataHandler', () => { unregisterDataHandler({ appName: 'infra_logs' }); unregisterDataHandler({ appName: 'infra_metrics' }); unregisterDataHandler({ appName: 'uptime' }); + unregisterDataHandler({ appName: 'ux' }); registerDataHandler({ appName: 'apm', @@ -528,11 +615,17 @@ describe('registerDataHandler', () => { fetchData: async () => (({} as unknown) as UptimeFetchDataResponse), hasData: async () => false, }); - expect(await fetchHasData()).toEqual({ + registerDataHandler({ + appName: 'ux', + fetchData: async () => (({} as unknown) as UxFetchDataResponse), + hasData: async () => false, + }); + expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ apm: false, uptime: false, infra_logs: false, infra_metrics: false, + ux: false, }); }); it('returns false when has data was not registered', async () => { @@ -540,12 +633,14 @@ describe('registerDataHandler', () => { unregisterDataHandler({ appName: 'infra_logs' }); unregisterDataHandler({ appName: 'infra_metrics' }); unregisterDataHandler({ appName: 'uptime' }); + unregisterDataHandler({ appName: 'ux' }); - expect(await fetchHasData()).toEqual({ + expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ apm: false, uptime: false, infra_logs: false, infra_metrics: false, + ux: false, }); }); }); diff --git a/x-pack/plugins/observability/public/data_handler.ts b/x-pack/plugins/observability/public/data_handler.ts index cae21fd9fed52..91043a3da0dab 100644 --- a/x-pack/plugins/observability/public/data_handler.ts +++ b/x-pack/plugins/observability/public/data_handler.ts @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DataHandler, ObservabilityFetchDataPlugins } from './typings/fetch_overview_data'; +import { + DataHandler, + HasDataResponse, + ObservabilityFetchDataPlugins, +} from './typings/fetch_overview_data'; const dataHandlers: Partial> = {}; @@ -31,14 +35,26 @@ export function getDataHandler(appName: } } -export async function fetchHasData(): Promise> { - const apps: ObservabilityFetchDataPlugins[] = ['apm', 'uptime', 'infra_logs', 'infra_metrics']; +export async function fetchHasData(absoluteTime: { + start: number; + end: number; +}): Promise> { + const apps: ObservabilityFetchDataPlugins[] = [ + 'apm', + 'uptime', + 'infra_logs', + 'infra_metrics', + 'ux', + ]; - const promises = apps.map(async (app) => getDataHandler(app)?.hasData() || false); + const promises = apps.map( + async (app) => + getDataHandler(app)?.hasData(app === 'ux' ? { absoluteTime } : undefined) || false + ); const results = await Promise.allSettled(promises); - const [apm, uptime, logs, metrics] = results.map((result) => { + const [apm, uptime, logs, metrics, ux] = results.map((result) => { if (result.status === 'fulfilled') { return result.value; } @@ -50,6 +66,7 @@ export async function fetchHasData(): Promise { + return search ? parse(search[0] === '?' ? search.slice(1) : search, { sort: false }) : {}; +}; + +export function useQueryParams() { + const { from, to } = useKibanaUISettings(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS); + + const { rangeFrom, rangeTo } = getParsedParams(useLocation().search); + + return useMemo(() => { + return { + start: (rangeFrom as string) ?? from, + end: (rangeTo as string) ?? to, + absStart: getAbsoluteTime((rangeFrom as string) ?? from)!, + absEnd: getAbsoluteTime((rangeTo as string) ?? to, { roundUp: true })!, + }; + }, [rangeFrom, rangeTo, from, to]); +} diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index 0aecea59ad013..9c16e3034400b 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -17,6 +17,8 @@ export const plugin: PluginInitializer fetchHasData(), []); + + const { absStart, absEnd } = useQueryParams(); + + const { data = {} } = useFetcher( + () => fetchHasData({ start: absStart, end: absEnd }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); const values = Object.values(data); const hasSomeData = values.length ? values.some((hasData) => hasData) : null; @@ -24,5 +33,5 @@ export function HomePage() { } }, [hasSomeData, history]); - return <>; + return ; } diff --git a/x-pack/plugins/observability/public/pages/overview/data_sections.tsx b/x-pack/plugins/observability/public/pages/overview/data_sections.tsx new file mode 100644 index 0000000000000..dfb335902b7b8 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/overview/data_sections.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { LogsSection } from '../../components/app/section/logs'; +import { MetricsSection } from '../../components/app/section/metrics'; +import { APMSection } from '../../components/app/section/apm'; +import { UptimeSection } from '../../components/app/section/uptime'; +import { UXSection } from '../../components/app/section/ux'; +import { + HasDataResponse, + ObservabilityFetchDataPlugins, + UXHasDataResponse, +} from '../../typings/fetch_overview_data'; + +interface Props { + bucketSize: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; + hasData: Record; +} + +export function DataSections({ bucketSize, hasData, absoluteTime, relativeTime }: Props) { + return ( + + + {hasData?.infra_logs && ( + + + + )} + {hasData?.infra_metrics && ( + + + + )} + {hasData?.apm && ( + + + + )} + {hasData?.uptime && ( + + + + )} + {hasData?.ux && ( + + + + )} + + + ); +} diff --git a/x-pack/plugins/observability/public/pages/overview/empty_section.ts b/x-pack/plugins/observability/public/pages/overview/empty_section.ts index 0330ba5cc04b4..5b13f2bcbbbd7 100644 --- a/x-pack/plugins/observability/public/pages/overview/empty_section.ts +++ b/x-pack/plugins/observability/public/pages/overview/empty_section.ts @@ -69,6 +69,21 @@ export const getEmptySections = ({ core }: { core: AppMountContext['core'] }): I }), href: core.http.basePath.prepend('/app/home#/tutorial/uptimeMonitors'), }, + { + id: 'ux', + title: i18n.translate('xpack.observability.emptySection.apps.ux.title', { + defaultMessage: 'User Experience', + }), + icon: 'logoAPM', + description: i18n.translate('xpack.observability.emptySection.apps.ux.description', { + defaultMessage: + 'Performance is a distribution. Measure the experiences of all visitors to your web application and understand how to improve the experience for everyone.', + }), + linkTitle: i18n.translate('xpack.observability.emptySection.apps.ux.link', { + defaultMessage: 'Install Rum Agent', + }), + href: core.http.basePath.prepend('/app/home#/tutorial/apm'), + }, { id: 'alert', title: i18n.translate('xpack.observability.emptySection.apps.alert.title', { diff --git a/x-pack/plugins/observability/public/pages/overview/index.tsx b/x-pack/plugins/observability/public/pages/overview/index.tsx index 3d10e4abcbb42..a234837e13e43 100644 --- a/x-pack/plugins/observability/public/pages/overview/index.tsx +++ b/x-pack/plugins/observability/public/pages/overview/index.tsx @@ -8,39 +8,53 @@ import React, { useContext } from 'react'; import { ThemeContext } from 'styled-components'; import { EmptySection } from '../../components/app/empty_section'; import { WithHeaderLayout } from '../../components/app/layout/with_header'; -import { NewsFeed } from '../../components/app/news_feed'; import { Resources } from '../../components/app/resources'; import { AlertsSection } from '../../components/app/section/alerts'; -import { APMSection } from '../../components/app/section/apm'; -import { LogsSection } from '../../components/app/section/logs'; -import { MetricsSection } from '../../components/app/section/metrics'; -import { UptimeSection } from '../../components/app/section/uptime'; import { DatePicker, TimePickerTime } from '../../components/shared/data_picker'; +import { NewsFeed } from '../../components/app/news_feed'; import { fetchHasData } from '../../data_handler'; import { FETCH_STATUS, useFetcher } from '../../hooks/use_fetcher'; import { UI_SETTINGS, useKibanaUISettings } from '../../hooks/use_kibana_ui_settings'; import { usePluginContext } from '../../hooks/use_plugin_context'; -import { useTrackPageview } from '../../hooks/use_track_metric'; import { RouteParams } from '../../routes'; -import { getNewsFeed } from '../../services/get_news_feed'; import { getObservabilityAlerts } from '../../services/get_observability_alerts'; import { getAbsoluteTime } from '../../utils/date'; import { getBucketSize } from '../../utils/get_bucket_size'; import { getEmptySections } from './empty_section'; import { LoadingObservability } from './loading_observability'; +import { getNewsFeed } from '../../services/get_news_feed'; +import { DataSections } from './data_sections'; +import { useTrackPageview } from '../..'; interface Props { routeParams: RouteParams<'/overview'>; } -function calculatetBucketSize({ start, end }: { start?: number; end?: number }) { +function calculateBucketSize({ start, end }: { start?: number; end?: number }) { if (start && end) { return getBucketSize({ start, end, minInterval: '60s' }); } } export function OverviewPage({ routeParams }: Props) { - const { core } = usePluginContext(); + const { core, plugins } = usePluginContext(); + + // read time from state and update the url + const timePickerSharedState = plugins.data.query.timefilter.timefilter.getTime(); + + const timePickerDefaults = useKibanaUISettings( + UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS + ); + + const relativeTime = { + start: routeParams.query.rangeFrom || timePickerSharedState.from || timePickerDefaults.from, + end: routeParams.query.rangeTo || timePickerSharedState.to || timePickerDefaults.to, + }; + + const absoluteTime = { + start: getAbsoluteTime(relativeTime.start) as number, + end: getAbsoluteTime(relativeTime.end, { roundUp: true }) as number, + }; useTrackPageview({ app: 'observability', path: 'overview' }); useTrackPageview({ app: 'observability', path: 'overview', delay: 15000 }); @@ -52,9 +66,12 @@ export function OverviewPage({ routeParams }: Props) { const { data: newsFeed } = useFetcher(() => getNewsFeed({ core }), [core]); const theme = useContext(ThemeContext); - const timePickerTime = useKibanaUISettings(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS); - const result = useFetcher(() => fetchHasData(), []); + const result = useFetcher( + () => fetchHasData(absoluteTime), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); const hasData = result.data; if (!hasData) { @@ -63,17 +80,7 @@ export function OverviewPage({ routeParams }: Props) { const { refreshInterval = 10000, refreshPaused = true } = routeParams.query; - const relativeTime = { - start: routeParams.query.rangeFrom ?? timePickerTime.from, - end: routeParams.query.rangeTo ?? timePickerTime.to, - }; - - const absoluteTime = { - start: getAbsoluteTime(relativeTime.start), - end: getAbsoluteTime(relativeTime.end, { roundUp: true }), - }; - - const bucketSize = calculatetBucketSize({ + const bucketSize = calculateBucketSize({ start: absoluteTime.start, end: absoluteTime.end, }); @@ -117,46 +124,12 @@ export function OverviewPage({ routeParams }: Props) { {/* Data sections */} {showDataSections && ( - - - {hasData.infra_logs && ( - - - - )} - {hasData.infra_metrics && ( - - - - )} - {hasData.apm && ( - - - - )} - {hasData.uptime && ( - - - - )} - - + )} {/* Empty sections */} diff --git a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx index ff34116f59104..608a5e3100276 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx @@ -6,12 +6,13 @@ import { makeDecorator } from '@storybook/addons'; import { storiesOf } from '@storybook/react'; -import { AppMountContext } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { UI_SETTINGS } from '../../../../../../src/plugins/data/public'; import { PluginContext } from '../../context/plugin_context'; import { registerDataHandler, unregisterDataHandler } from '../../data_handler'; +import { ObservabilityPluginSetupDeps } from '../../plugin'; import { EuiThemeProvider } from '../../typings'; import { OverviewPage } from './'; import { alertsFetchData } from './mock/alerts.mock'; @@ -36,7 +37,18 @@ const withCore = makeDecorator({ return ( - + {}, getTime: () => ({}) } }, + }, + }, + } as unknown) as ObservabilityPluginSetupDeps, + }} + > {storyFn(context)} @@ -119,7 +131,7 @@ const core = ({ return euiSettings[key]; }, }, -} as unknown) as AppMountContext['core']; +} as unknown) as CoreStart; const coreWithAlerts = ({ ...core, @@ -127,7 +139,7 @@ const coreWithAlerts = ({ ...core.http, get: alertsFetchData, }, -} as unknown) as AppMountContext['core']; +} as unknown) as CoreStart; const coreWithNewsFeed = ({ ...core, @@ -135,7 +147,7 @@ const coreWithNewsFeed = ({ ...core.http, get: newsFeedFetchData, }, -} as unknown) as AppMountContext['core']; +} as unknown) as CoreStart; const coreAlertsThrowsError = ({ ...core, @@ -145,7 +157,7 @@ const coreAlertsThrowsError = ({ throw new Error('Error fetching Alerts data'); }, }, -} as unknown) as AppMountContext['core']; +} as unknown) as CoreStart; storiesOf('app/Overview', module) .addDecorator(withCore(core)) diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index be8abb4dcac78..ab51e16164251 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -6,6 +6,7 @@ import { BehaviorSubject } from 'rxjs'; import { i18n } from '@kbn/i18n'; +import { DataPublicPluginSetup } from '../../../../src/plugins/data/public'; import { AppMountParameters, AppUpdater, @@ -25,6 +26,7 @@ export interface ObservabilityPluginSetup { export interface ObservabilityPluginSetupDeps { home?: HomePublicPluginSetup; + data: DataPublicPluginSetup; } export type ObservabilityPluginStart = void; diff --git a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts index 41330e878035c..a64e6fc55b85a 100644 --- a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts +++ b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts @@ -5,6 +5,7 @@ */ import { ObservabilityApp } from '../../../typings/common'; +import { UXMetrics } from '../../components/shared/core_web_vitals'; export interface Stat { type: 'number' | 'percent' | 'bytesPerSecond'; @@ -24,17 +25,29 @@ export interface FetchDataParams { absoluteTime: { start: number; end: number }; relativeTime: { start: string; end: string }; bucketSize: string; + serviceName?: string; } +export interface HasDataParams { + absoluteTime: { start: number; end: number }; +} + +export interface UXHasDataResponse { + hasData: boolean; + serviceName: string | number | undefined; +} + +export type HasDataResponse = UXHasDataResponse | boolean; + export type FetchData = ( fetchDataParams: FetchDataParams ) => Promise; -export type HasData = () => Promise; +export type HasData = (params?: HasDataParams) => Promise; export type ObservabilityFetchDataPlugins = Exclude< ObservabilityApp, - 'observability' | 'stack_monitoring' | 'ux' + 'observability' | 'stack_monitoring' >; export interface DataHandler< @@ -89,9 +102,14 @@ export interface ApmFetchDataResponse extends FetchDataResponse { }; } +export interface UxFetchDataResponse extends FetchDataResponse { + coreWebVitals: UXMetrics; +} + export interface ObservabilityFetchDataResponse { apm: ApmFetchDataResponse; infra_metrics: MetricsFetchDataResponse; infra_logs: LogsFetchDataResponse; uptime: UptimeFetchDataResponse; + ux: UxFetchDataResponse; } diff --git a/x-pack/plugins/observability/public/utils/test_helper.tsx b/x-pack/plugins/observability/public/utils/test_helper.tsx index 2a290f2b24d6b..d857165f29528 100644 --- a/x-pack/plugins/observability/public/utils/test_helper.tsx +++ b/x-pack/plugins/observability/public/utils/test_helper.tsx @@ -5,9 +5,12 @@ */ import React from 'react'; import { render as testLibRender } from '@testing-library/react'; -import { AppMountContext } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; +import { of } from 'rxjs'; import { PluginContext } from '../context/plugin_context'; import { EuiThemeProvider } from '../typings'; +import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; +import { ObservabilityPluginSetupDeps } from '../plugin'; export const core = ({ http: { @@ -15,12 +18,22 @@ export const core = ({ prepend: jest.fn(), }, }, -} as unknown) as AppMountContext['core']; + uiSettings: { + get: (key: string) => true, + get$: (key: string) => of(true), + }, +} as unknown) as CoreStart; + +const plugins = ({ + data: { query: { timefilter: { timefilter: { setTime: jest.fn() } } } }, +} as unknown) as ObservabilityPluginSetupDeps; export const render = (component: React.ReactNode) => { return testLibRender( - - {component} - + + + {component} + + ); }; diff --git a/x-pack/plugins/security/kibana.json b/x-pack/plugins/security/kibana.json index 40d7e293eaf66..40629dbe4f3b3 100644 --- a/x-pack/plugins/security/kibana.json +++ b/x-pack/plugins/security/kibana.json @@ -3,7 +3,7 @@ "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "security"], - "requiredPlugins": ["data", "features", "licensing", "taskManager"], + "requiredPlugins": ["data", "features", "licensing", "taskManager", "securityOss"], "optionalPlugins": ["home", "management", "usageCollection"], "server": true, "ui": true, diff --git a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx index 9eb2616cebb18..9a62187cffd33 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx @@ -536,7 +536,7 @@ export class EditUserPage extends Component { {isNewUser || showChangePasswordForm ? null : ( - + { toolsRight: this.renderToolsRight(), box: { incremental: true, + 'data-test-subj': 'searchUsers', }, onChange: (query: any) => { this.setState({ @@ -275,12 +276,18 @@ export class UsersGridPage extends Component { private handleDelete = (usernames: string[], errors: string[]) => { const { users } = this.state; + const filteredUsers = users.filter(({ username }) => { + return !usernames.includes(username) || errors.includes(username); + }); this.setState({ selection: [], showDeleteConfirmation: false, - users: users.filter(({ username }) => { - return !usernames.includes(username) || errors.includes(username); - }), + users: filteredUsers, + visibleUsers: this.getVisibleUsers( + filteredUsers, + this.state.filter, + this.state.includeReservedUsers + ), }); }; diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index fb8034da11731..d86d4812af5e3 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -8,6 +8,7 @@ import { Observable } from 'rxjs'; import BroadcastChannel from 'broadcast-channel'; import { CoreSetup } from 'src/core/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { mockSecurityOssPlugin } from '../../../../src/plugins/security_oss/public/mocks'; import { SessionTimeout } from './session'; import { PluginStartDependencies, SecurityPlugin } from './plugin'; @@ -35,6 +36,7 @@ describe('Security Plugin', () => { >, { licensing: licensingMock.createSetup(), + securityOss: mockSecurityOssPlugin.createSetup(), } ) ).toEqual({ @@ -61,6 +63,7 @@ describe('Security Plugin', () => { plugin.setup(coreSetupMock as CoreSetup, { licensing: licensingMock.createSetup(), + securityOss: mockSecurityOssPlugin.createSetup(), management: managementSetupMock, }); @@ -85,11 +88,12 @@ describe('Security Plugin', () => { const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext()); plugin.setup( coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup, - { licensing: licensingMock.createSetup() } + { licensing: licensingMock.createSetup(), securityOss: mockSecurityOssPlugin.createSetup() } ); expect( plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), { + securityOss: mockSecurityOssPlugin.createStart(), data: {} as DataPublicPluginStart, features: {} as FeaturesPluginStart, }) @@ -110,12 +114,14 @@ describe('Security Plugin', () => { coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup, { licensing: licensingMock.createSetup(), + securityOss: mockSecurityOssPlugin.createSetup(), management: managementSetupMock, } ); const coreStart = coreMock.createStart({ basePath: '/some-base-path' }); plugin.start(coreStart, { + securityOss: mockSecurityOssPlugin.createStart(), data: {} as DataPublicPluginStart, features: {} as FeaturesPluginStart, management: managementStartMock, @@ -130,7 +136,7 @@ describe('Security Plugin', () => { const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext()); plugin.setup( coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup, - { licensing: licensingMock.createSetup() } + { licensing: licensingMock.createSetup(), securityOss: mockSecurityOssPlugin.createSetup() } ); expect(() => plugin.stop()).not.toThrow(); @@ -141,10 +147,11 @@ describe('Security Plugin', () => { plugin.setup( coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup, - { licensing: licensingMock.createSetup() } + { licensing: licensingMock.createSetup(), securityOss: mockSecurityOssPlugin.createSetup() } ); plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), { + securityOss: mockSecurityOssPlugin.createStart(), data: {} as DataPublicPluginStart, features: {} as FeaturesPluginStart, }); diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index f5770ae2bc35c..87bcc96d1f9d4 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -5,6 +5,7 @@ */ import { i18n } from '@kbn/i18n'; +import { SecurityOssPluginSetup, SecurityOssPluginStart } from 'src/plugins/security_oss/public'; import { CoreSetup, CoreStart, @@ -32,9 +33,11 @@ import { AuthenticationService, AuthenticationServiceSetup } from './authenticat import { ConfigType } from './config'; import { ManagementService } from './management'; import { accountManagementApp } from './account_management'; +import { SecurityCheckupService } from './security_checkup'; export interface PluginSetupDependencies { licensing: LicensingPluginSetup; + securityOss: SecurityOssPluginSetup; home?: HomePublicPluginSetup; management?: ManagementSetup; } @@ -42,6 +45,7 @@ export interface PluginSetupDependencies { export interface PluginStartDependencies { data: DataPublicPluginStart; features: FeaturesPluginStart; + securityOss: SecurityOssPluginStart; management?: ManagementStart; } @@ -58,6 +62,7 @@ export class SecurityPlugin private readonly navControlService = new SecurityNavControlService(); private readonly securityLicenseService = new SecurityLicenseService(); private readonly managementService = new ManagementService(); + private readonly securityCheckupService = new SecurityCheckupService(); private authc!: AuthenticationServiceSetup; private readonly config: ConfigType; @@ -67,7 +72,7 @@ export class SecurityPlugin public setup( core: CoreSetup, - { home, licensing, management }: PluginSetupDependencies + { home, licensing, management, securityOss }: PluginSetupDependencies ) { const { http, notifications } = core; const { anonymousPaths } = http; @@ -82,6 +87,8 @@ export class SecurityPlugin const { license } = this.securityLicenseService.setup({ license$: licensing.license$ }); + this.securityCheckupService.setup({ securityOssSetup: securityOss }); + this.authc = this.authenticationService.setup({ application: core.application, fatalErrors: core.fatalErrors, @@ -137,9 +144,10 @@ export class SecurityPlugin }; } - public start(core: CoreStart, { management }: PluginStartDependencies) { + public start(core: CoreStart, { management, securityOss }: PluginStartDependencies) { this.sessionTimeout.start(); this.navControlService.start({ core }); + this.securityCheckupService.start({ securityOssStart: securityOss }); if (management) { this.managementService.start({ capabilities: core.application.capabilities }); } @@ -150,6 +158,7 @@ export class SecurityPlugin this.navControlService.stop(); this.securityLicenseService.stop(); this.managementService.stop(); + this.securityCheckupService.stop(); } } diff --git a/x-pack/plugins/security/public/security_checkup/components/index.ts b/x-pack/plugins/security/public/security_checkup/components/index.ts new file mode 100644 index 0000000000000..685d0fe67db74 --- /dev/null +++ b/x-pack/plugins/security/public/security_checkup/components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { insecureClusterAlertTitle, insecureClusterAlertText } from './insecure_cluster_alert'; diff --git a/x-pack/plugins/security/public/security_checkup/components/insecure_cluster_alert.tsx b/x-pack/plugins/security/public/security_checkup/components/insecure_cluster_alert.tsx new file mode 100644 index 0000000000000..6ba06e0cc4770 --- /dev/null +++ b/x-pack/plugins/security/public/security_checkup/components/insecure_cluster_alert.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { I18nProvider, FormattedMessage } from '@kbn/i18n/react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { MountPoint } from 'kibana/public'; +import { + EuiCheckbox, + EuiText, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiButton, +} from '@elastic/eui'; + +export const insecureClusterAlertTitle = i18n.translate( + 'xpack.security.checkup.insecureClusterTitle', + { defaultMessage: 'Please secure your installation' } +); + +export const insecureClusterAlertText = (onDismiss: (persist: boolean) => void) => + ((e) => { + const AlertText = () => { + const [persist, setPersist] = useState(false); + + return ( + +
    + + + + + setPersist(changeEvent.target.checked)} + label={i18n.translate('xpack.security.checkup.dontShowAgain', { + defaultMessage: `Don't show again`, + })} + /> + + + + + {i18n.translate('xpack.security.checkup.enableButtonText', { + defaultMessage: `Enable security`, + })} + + + + onDismiss(persist)} + data-test-subj="dismissAlertButton" + > + {i18n.translate('xpack.security.checkup.dismissButtonText', { + defaultMessage: `Dismiss`, + })} + + + +
    +
    + ); + }; + + render(, e); + + return () => unmountComponentAtNode(e); + }) as MountPoint; diff --git a/x-pack/plugins/security/public/security_checkup/index.ts b/x-pack/plugins/security/public/security_checkup/index.ts new file mode 100644 index 0000000000000..691a99a5c6fc0 --- /dev/null +++ b/x-pack/plugins/security/public/security_checkup/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SecurityCheckupService } from './security_checkup_service'; diff --git a/x-pack/plugins/security/public/security_checkup/security_checkup_service.test.ts b/x-pack/plugins/security/public/security_checkup/security_checkup_service.test.ts new file mode 100644 index 0000000000000..3709f52d29ffb --- /dev/null +++ b/x-pack/plugins/security/public/security_checkup/security_checkup_service.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mockSecurityOssPlugin } from '../../../../../src/plugins/security_oss/public/mocks'; +import { insecureClusterAlertTitle } from './components'; +import { SecurityCheckupService } from './security_checkup_service'; + +let mockOnDismiss = jest.fn(); + +jest.mock('./components', () => { + return { + insecureClusterAlertTitle: 'mock insecure cluster title', + insecureClusterAlertText: (onDismiss: any) => { + mockOnDismiss = onDismiss; + return 'mock insecure cluster text'; + }, + }; +}); + +describe('SecurityCheckupService', () => { + describe('#setup', () => { + it('configures the alert title and text for the default distribution', async () => { + const securityOssSetup = mockSecurityOssPlugin.createSetup(); + const service = new SecurityCheckupService(); + service.setup({ securityOssSetup }); + + expect(securityOssSetup.insecureCluster.setAlertTitle).toHaveBeenCalledWith( + insecureClusterAlertTitle + ); + + expect(securityOssSetup.insecureCluster.setAlertText).toHaveBeenCalledWith( + 'mock insecure cluster text' + ); + }); + }); + describe('#start', () => { + it('onDismiss triggers hiding of the alert', async () => { + const securityOssSetup = mockSecurityOssPlugin.createSetup(); + const securityOssStart = mockSecurityOssPlugin.createStart(); + const service = new SecurityCheckupService(); + service.setup({ securityOssSetup }); + service.start({ securityOssStart }); + + expect(securityOssStart.insecureCluster.hideAlert).toHaveBeenCalledTimes(0); + + mockOnDismiss(); + + expect(securityOssStart.insecureCluster.hideAlert).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/security/public/security_checkup/security_checkup_service.tsx b/x-pack/plugins/security/public/security_checkup/security_checkup_service.tsx new file mode 100644 index 0000000000000..899a74083656b --- /dev/null +++ b/x-pack/plugins/security/public/security_checkup/security_checkup_service.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + SecurityOssPluginSetup, + SecurityOssPluginStart, +} from '../../../../../src/plugins/security_oss/public'; +import { insecureClusterAlertTitle, insecureClusterAlertText } from './components'; + +interface SetupDeps { + securityOssSetup: SecurityOssPluginSetup; +} + +interface StartDeps { + securityOssStart: SecurityOssPluginStart; +} + +export class SecurityCheckupService { + private securityOssStart?: SecurityOssPluginStart; + + public setup({ securityOssSetup }: SetupDeps) { + securityOssSetup.insecureCluster.setAlertTitle(insecureClusterAlertTitle); + securityOssSetup.insecureCluster.setAlertText( + insecureClusterAlertText((persist: boolean) => this.onDismiss(persist)) + ); + } + + public start({ securityOssStart }: StartDeps) { + this.securityOssStart = securityOssStart; + } + + private onDismiss(persist: boolean) { + if (this.securityOssStart) { + this.securityOssStart.insecureCluster.hideAlert(persist); + } + } + + public stop() {} +} diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index fcc652505ba3a..4f52ebe3065a3 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -1374,6 +1374,14 @@ describe('Authenticator', () => { '/mock-server-basepath/login?next=%2Fmock-server-basepath%2Fpath' ) ); + + // Unauthenticated session should be treated as non-existent one. + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: undefined }); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/login?next=%2Fmock-server-basepath%2Fpath' + ) + ); expect(mockBasicAuthenticationProvider.authenticate).not.toHaveBeenCalled(); }); }); @@ -1591,26 +1599,6 @@ describe('Authenticator', () => { ); }); - it('does not redirect to Overwritten Session if session was unauthenticated before this authentication attempt', async () => { - const request = httpServerMock.createKibanaRequest(); - mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: undefined }); - - const newMockUser = mockAuthenticatedUser({ username: 'new-username' }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.succeeded(newMockUser, { - state: 'some-state', - authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, - }) - ); - - await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.succeeded(newMockUser, { - state: 'some-state', - authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, - }) - ); - }); - it('redirects to Overwritten Session when username changes', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 1fb9d9221f041..b8ec6258eb0d5 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -131,6 +131,10 @@ function isLoginAttemptWithProviderType( ); } +function isSessionAuthenticated(sessionValue?: Readonly | null) { + return !!sessionValue?.username; +} + /** * Instantiates authentication provider based on the provider key from config. * @param providerType Provider type key. @@ -558,7 +562,7 @@ export class Authenticator { return ownsSession ? { value: existingSessionValue, overwritten: false } : null; } - const isExistingSessionAuthenticated = !!existingSessionValue?.username; + const isExistingSessionAuthenticated = isSessionAuthenticated(existingSessionValue); const isNewSessionAuthenticated = !!authenticationResult.user; const providerHasChanged = !!existingSessionValue && !ownsSession; @@ -637,7 +641,7 @@ export class Authenticator { // 4. Request isn't attributed with HTTP Authorization header return ( canRedirectRequest(request) && - !sessionValue && + !isSessionAuthenticated(sessionValue) && this.options.config.authc.selector.enabled && HTTPAuthorizationHeader.parseFromRequest(request) == null ); @@ -688,14 +692,14 @@ export class Authenticator { return authenticationResult; } - const isSessionAuthenticated = !!sessionUpdateResult?.value?.username; + const isUpdatedSessionAuthenticated = isSessionAuthenticated(sessionUpdateResult?.value); let preAccessRedirectURL; - if (isSessionAuthenticated && sessionUpdateResult?.overwritten) { + if (isUpdatedSessionAuthenticated && sessionUpdateResult?.overwritten) { this.logger.debug('Redirecting user to the overwritten session UI.'); preAccessRedirectURL = `${this.options.basePath.serverBasePath}${OVERWRITTEN_SESSION_ROUTE}`; } else if ( - isSessionAuthenticated && + isUpdatedSessionAuthenticated && this.shouldRedirectToAccessAgreement(sessionUpdateResult?.value ?? null) ) { this.logger.debug('Redirecting user to the access agreement UI.'); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 69b55fcb3d0a4..7d44160c52e53 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -9,6 +9,7 @@ import { first, map } from 'rxjs/operators'; import { TypeOf } from '@kbn/config-schema'; import { deepFreeze } from '@kbn/std'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { SecurityOssPluginSetup } from 'src/plugins/security_oss/server'; import { CoreSetup, CoreStart, @@ -84,6 +85,7 @@ export interface PluginSetupDependencies { licensing: LicensingPluginSetup; taskManager: TaskManagerSetupContract; usageCollection?: UsageCollectionSetup; + securityOss?: SecurityOssPluginSetup; } export interface PluginStartDependencies { @@ -133,7 +135,7 @@ export class Plugin { public async setup( core: CoreSetup, - { features, licensing, taskManager, usageCollection }: PluginSetupDependencies + { features, licensing, taskManager, usageCollection, securityOss }: PluginSetupDependencies ) { const [config, legacyConfig] = await combineLatest([ this.initializerContext.config.create>().pipe( @@ -153,6 +155,13 @@ export class Plugin { license$: licensing.license$, }); + if (securityOss) { + license.features$.subscribe(({ allowRbac }) => { + const showInsecureClusterWarning = !allowRbac; + securityOss.showInsecureClusterWarning$.next(showInsecureClusterWarning); + }); + } + securityFeatures.forEach((securityFeature) => features.registerElasticsearchFeature(securityFeature) ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts b/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts index ea50acc9b46be..202733574b69f 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { hasLargeValueList, hasNestedEntry, isThreatMatchRule } from './utils'; +import { hasEqlSequenceQuery, hasLargeValueList, hasNestedEntry, isThreatMatchRule } from './utils'; import { EntriesArray } from '../shared_imports'; describe('#hasLargeValueList', () => { @@ -113,3 +113,40 @@ describe('#hasNestedEntry', () => { }); }); }); + +describe('#hasEqlSequenceQuery', () => { + describe('when a non-sequence query is passed', () => { + const query = 'process where process.name == "regsvr32.exe"'; + it('should return false', () => { + expect(hasEqlSequenceQuery(query)).toEqual(false); + }); + }); + + describe('when a sequence query is passed', () => { + const query = 'sequence [process where process.name = "test.exe"]'; + it('should return true', () => { + expect(hasEqlSequenceQuery(query)).toEqual(true); + }); + }); + + describe('when a sequence query is passed with extra white space and escape characters', () => { + const query = '\tsequence \n [process where process.name = "test.exe"]'; + it('should return true', () => { + expect(hasEqlSequenceQuery(query)).toEqual(true); + }); + }); + + describe('when a non-sequence query is passed using the word sequence', () => { + const query = 'sequence where true'; + it('should return false', () => { + expect(hasEqlSequenceQuery(query)).toEqual(false); + }); + }); + + describe('when a non-sequence query is passed using the word sequence with extra white space and escape characters', () => { + const query = ' sequence\nwhere\ttrue'; + it('should return false', () => { + expect(hasEqlSequenceQuery(query)).toEqual(false); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/utils.ts b/x-pack/plugins/security_solution/common/detection_engine/utils.ts index d7b23755699f5..d35c5980d96a2 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/utils.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/utils.ts @@ -17,6 +17,14 @@ export const hasNestedEntry = (entries: EntriesArray): boolean => { return found.length > 0; }; +export const hasEqlSequenceQuery = (ruleQuery: string | undefined): boolean => { + if (ruleQuery != null) { + const parsedQuery = ruleQuery.trim().split(/[ \t\r\n]+/); + return parsedQuery[0] === 'sequence' && parsedQuery[1] !== 'where'; + } + return false; +}; + export const isEqlRule = (ruleType: Type | undefined): boolean => ruleType === 'eql'; export const isThresholdRule = (ruleType: Type | undefined): boolean => ruleType === 'threshold'; export const isQueryRule = (ruleType: Type | undefined): boolean => diff --git a/x-pack/plugins/security_solution/common/ecs/rule/index.ts b/x-pack/plugins/security_solution/common/ecs/rule/index.ts index fa200b46b37b4..47d1323371941 100644 --- a/x-pack/plugins/security_solution/common/ecs/rule/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/rule/index.ts @@ -41,4 +41,5 @@ export interface RuleEcs { updated_by?: string[]; version?: string[]; note?: string[]; + building_block_type?: string[]; } diff --git a/x-pack/plugins/security_solution/common/ecs/signal/index.ts b/x-pack/plugins/security_solution/common/ecs/signal/index.ts index 55a889f3a5dd1..6482b892bc18d 100644 --- a/x-pack/plugins/security_solution/common/ecs/signal/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/signal/index.ts @@ -10,4 +10,7 @@ export interface SignalEcs { rule?: RuleEcs; original_time?: string[]; status?: string[]; + group?: { + id?: string[]; + }; } diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index ec7a49da469fe..f0254616e6c9d 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -1215,6 +1215,7 @@ export class EndpointDocGenerator { install_version: '0.5.0', install_status: 'installed', install_started_at: '2020-06-24T14:41:23.098Z', + install_source: 'registry', }, references: [], updated_at: '2020-06-24T14:41:23.098Z', diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts index 352c628f9fa23..ab3549c11bef4 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts @@ -348,6 +348,21 @@ describe('When invoking Trusted Apps Schema', () => { }); }).toThrow(); }); + + it('should trim hash value before validation', () => { + expect(() => { + body.validate({ + ...getCreateTrustedAppItem(), + entries: [ + { + ...getTrustedAppItemEntryItem(), + field: 'process.hash.*', + value: ` ${VALID_HASH_MD5} \r\n`, + }, + ], + }); + }).not.toThrow(); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index b4e837c472915..29957682f72fc 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -29,7 +29,7 @@ export const GetTrustedAppsRequestSchema = { export const PostTrustedAppCreateRequestSchema = { body: schema.object({ name: schema.string({ minLength: 1 }), - description: schema.maybe(schema.string({ minLength: 0, defaultValue: '' })), + description: schema.maybe(schema.string({ minLength: 0, maxLength: 256, defaultValue: '' })), os: schema.oneOf([schema.literal('linux'), schema.literal('macos'), schema.literal('windows')]), entries: schema.arrayOf( schema.object({ @@ -52,11 +52,15 @@ export const PostTrustedAppCreateRequestSchema = { usedFields.push(field); - if ( - field === 'process.hash.*' && - (!hashLengths.includes(value.length) || hasInvalidCharacters.test(value)) - ) { - return `Invalid hash value [${value}]`; + if (field === 'process.hash.*') { + const trimmedValue = value.trim(); + + if ( + !hashLengths.includes(trimmedValue.length) || + hasInvalidCharacters.test(trimmedValue) + ) { + return `Invalid hash value [${value}]`; + } } } }, diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index abb0ccee8d909..0054c1f1abdd5 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -718,7 +718,10 @@ export type SafeEndpointEvent = Partial<{ forwarded_ip: ECSField; }>; dns: Partial<{ - question: Partial<{ name: ECSField }>; + question: Partial<{ + name: ECSField; + type: ECSField; + }>; }>; process: Partial<{ entity_id: ECSField; diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/all/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/all/index.ts index f673fca290a29..764d8aaf6ea39 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/all/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/all/index.ts @@ -6,7 +6,7 @@ import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; import { Ecs } from '../../../../ecs'; -import { CursorType, Inspect, Maybe, PageInfoPaginated } from '../../../common'; +import { CursorType, Inspect, Maybe, PaginationInputPaginated } from '../../../common'; import { TimelineRequestOptionsPaginated } from '../..'; export interface TimelineEdges { @@ -29,7 +29,7 @@ export interface TimelineNonEcsData { export interface TimelineEventsAllStrategyResponse extends IEsSearchResponse { edges: TimelineEdges[]; totalCount: number; - pageInfo: PageInfoPaginated; + pageInfo: Pick; inspect?: Maybe; } diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts index 6b96783adc25a..578f905617746 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts @@ -14,13 +14,7 @@ import { TimelineEventsLastEventTimeRequestOptions, TimelineEventsLastEventTimeStrategyResponse, } from './events'; -import { - DocValueFields, - PaginationInput, - PaginationInputPaginated, - TimerangeInput, - SortField, -} from '../common'; +import { DocValueFields, PaginationInputPaginated, TimerangeInput, SortField } from '../common'; export * from './events'; @@ -34,14 +28,9 @@ export interface TimelineRequestBasicOptions extends IEsSearchRequest { factoryQueryType?: TimelineFactoryQueryTypes; } -export interface TimelineRequestOptions extends TimelineRequestBasicOptions { - pagination: PaginationInput; - sort: SortField; -} - export interface TimelineRequestOptionsPaginated extends TimelineRequestBasicOptions { - pagination: PaginationInputPaginated; + pagination: Pick; sort: SortField; } diff --git a/x-pack/plugins/security_solution/cypress/fixtures/overview.json b/x-pack/plugins/security_solution/cypress/fixtures/overview.json deleted file mode 100644 index c4aeda0c446e4..0000000000000 --- a/x-pack/plugins/security_solution/cypress/fixtures/overview.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "data": { - "source": { - "id": "default", - "status": { - "indicesExist": true, - "indexFields": [], - "__typename": "SourceStatus" - }, - "__typename": "Source" - } - } -} diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts index ca7832603f13d..13e5edd1cfe23 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { eqlRule, indexPatterns } from '../objects/rule'; +import { eqlRule, eqlSequenceRule, indexPatterns } from '../objects/rule'; import { ALERT_RULE_METHOD, @@ -85,8 +85,10 @@ const expectedMitre = eqlRule.mitre .join(''); const expectedNumberOfRules = 1; const expectedNumberOfAlerts = 7; +const expectedNumberOfSequenceAlerts = 1; -describe('Detection rules, EQL', () => { +// Failing: See https://github.com/elastic/kibana/issues/79522 +describe.skip('Detection rules, EQL', () => { before(() => { esArchiverLoad('timeline'); }); @@ -172,4 +174,43 @@ describe('Detection rules, EQL', () => { cy.get(ALERT_RULE_SEVERITY).first().should('have.text', eqlRule.severity.toLowerCase()); cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', eqlRule.riskScore); }); + + it('Creates and activates a new EQL rule with a sequence', () => { + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); + waitForAlertsPanelToBeLoaded(); + waitForAlertsIndexToBeCreated(); + goToManageAlertsDetectionRules(); + waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); + goToCreateNewRule(); + selectEqlRuleType(); + fillDefineEqlRuleAndContinue(eqlSequenceRule); + fillAboutRuleAndContinue(eqlSequenceRule); + fillScheduleRuleAndContinue(eqlSequenceRule); + createAndActivateRule(); + + cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)'); + + changeToThreeHundredRowsPerPage(); + waitForRulesToBeLoaded(); + + cy.get(RULES_TABLE).then(($table) => { + cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); + }); + + filterByCustomRules(); + goToRuleDetails(); + refreshPage(); + waitForTheRuleToBeExecuted(); + + cy.get(NUMBER_OF_ALERTS) + .invoke('text') + .then((numberOfAlertsText) => { + cy.wrap(parseInt(numberOfAlertsText, 10)).should('eql', expectedNumberOfSequenceAlerts); + }); + cy.get(ALERT_RULE_NAME).first().should('have.text', eqlSequenceRule.name); + cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); + cy.get(ALERT_RULE_METHOD).first().should('have.text', 'eql'); + cy.get(ALERT_RULE_SEVERITY).first().should('have.text', eqlSequenceRule.severity.toLowerCase()); + cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', eqlSequenceRule.riskScore); + }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_prebuilt.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_prebuilt.spec.ts index 6088a9dedbd06..b76e4b108a16a 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_prebuilt.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_prebuilt.spec.ts @@ -10,6 +10,7 @@ import { RELOAD_PREBUILT_RULES_BTN, RULES_ROW, RULES_TABLE, + SHOWING_RULES_TEXT, } from '../screens/alerts_detection_rules'; import { @@ -22,6 +23,7 @@ import { deleteFirstRule, deleteSelectedRules, loadPrebuiltDetectionRules, + paginate, reloadDeletedRules, selectNumberOfRules, waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded, @@ -61,8 +63,17 @@ describe('Alerts rules, prebuilt rules', () => { changeToThreeHundredRowsPerPage(); waitForRulesToBeLoaded(); - cy.get(RULES_TABLE).then(($table) => { - cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); + cy.get(SHOWING_RULES_TEXT).should('have.text', `Showing ${expectedNumberOfRules} rules`); + cy.get(RULES_TABLE).then(($table1) => { + const firstScreenRules = $table1.find(RULES_ROW).length; + paginate(); + waitForRulesToBeLoaded(); + cy.get(RULES_TABLE).then(($table2) => { + const secondScreenRules = $table2.find(RULES_ROW).length; + const totalNumberOfRules = firstScreenRules + secondScreenRules; + + expect(totalNumberOfRules).to.eql(expectedNumberOfRules); + }); }); }); }); @@ -85,10 +96,6 @@ describe('Deleting prebuilt rules', () => { changeToThreeHundredRowsPerPage(); waitForRulesToBeLoaded(); - - cy.get(RULES_TABLE).then(($table) => { - cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); - }); }); afterEach(() => { @@ -117,9 +124,6 @@ describe('Deleting prebuilt rules', () => { 'have.text', `Elastic rules (${expectedNumberOfRulesAfterDeletion})` ); - cy.get(RULES_TABLE).then(($table) => { - cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRulesAfterDeletion); - }); cy.get(RELOAD_PREBUILT_RULES_BTN).should('exist'); cy.get(RELOAD_PREBUILT_RULES_BTN).should('have.text', 'Install 1 Elastic prebuilt rule '); @@ -131,9 +135,6 @@ describe('Deleting prebuilt rules', () => { changeToThreeHundredRowsPerPage(); waitForRulesToBeLoaded(); - cy.get(RULES_TABLE).then(($table) => { - cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRulesAfterRecovering); - }); cy.get(ELASTIC_RULES_BTN).should( 'have.text', `Elastic rules (${expectedNumberOfRulesAfterRecovering})` @@ -160,9 +161,6 @@ describe('Deleting prebuilt rules', () => { 'have.text', `Elastic rules (${expectedNumberOfRulesAfterDeletion})` ); - cy.get(RULES_TABLE).then(($table) => { - cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRulesAfterDeletion); - }); reloadDeletedRules(); @@ -172,9 +170,6 @@ describe('Deleting prebuilt rules', () => { changeToThreeHundredRowsPerPage(); waitForRulesToBeLoaded(); - cy.get(RULES_TABLE).then(($table) => { - cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRulesAfterRecovering); - }); cy.get(ELASTIC_RULES_BTN).should( 'have.text', `Elastic rules (${expectedNumberOfRulesAfterRecovering})` diff --git a/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts b/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts index 14464333fcafe..e2f5ca9025bd9 100644 --- a/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts @@ -13,8 +13,8 @@ import { OVERVIEW_URL } from '../urls/navigation'; describe('Overview Page', () => { before(() => { - cy.stubSecurityApi('overview'); - cy.stubSearchStrategyApi('overview_search_strategy'); + cy.stubSearchStrategyApi('overviewHostQuery', 'overview_search_strategy'); + cy.stubSearchStrategyApi('overviewNetworkQuery', 'overview_search_strategy'); loginAndWaitForPage(OVERVIEW_URL); }); diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index f375eccd902c4..0bb4c8e356091 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -230,6 +230,25 @@ export const eqlRule: CustomRule = { lookBack, }; +export const eqlSequenceRule: CustomRule = { + customQuery: + 'sequence with maxspan=30s\ + [any where process.name == "which"]\ + [any where process.name == "xargs"]', + name: 'New EQL Sequence Rule', + description: 'New EQL rule description.', + severity: 'High', + riskScore: '17', + tags: ['test', 'newRule'], + referenceUrls: ['https://www.google.com/', 'https://elastic.co/'], + falsePositivesExamples: ['False1', 'False2'], + mitre: [mitre1, mitre2], + note: '# test markdown', + timelineId: '0162c130-78be-11ea-9718-118a926974a4', + runsEvery, + lookBack, +}; + export const indexPatterns = [ 'apm-*-transaction*', 'auditbeat-*', diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts index 14f5383939a94..f68ad88f578c7 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts @@ -33,6 +33,8 @@ export const LOADING_INITIAL_PREBUILT_RULES_TABLE = export const LOADING_SPINNER = '[data-test-subj="loading-spinner"]'; +export const NEXT_BTN = '[data-test-subj="pagination-button-next"]'; + export const PAGINATION_POPOVER_BTN = '[data-test-subj="tablePaginationPopoverButton"]'; export const RISK_SCORE = '[data-test-subj="riskScore"]'; diff --git a/x-pack/plugins/security_solution/cypress/support/commands.js b/x-pack/plugins/security_solution/cypress/support/commands.js index 0e3c9562aedf0..0dbcb1af4642f 100644 --- a/x-pack/plugins/security_solution/cypress/support/commands.js +++ b/x-pack/plugins/security_solution/cypress/support/commands.js @@ -39,13 +39,17 @@ Cypress.Commands.add('stubSecurityApi', function (dataFileName) { cy.route('POST', 'api/solutions/security/graphql', `@${dataFileName}JSON`); }); -Cypress.Commands.add('stubSearchStrategyApi', function (dataFileName) { +Cypress.Commands.add('stubSearchStrategyApi', function ( + queryId, + dataFileName, + searchStrategyName = 'securitySolutionSearchStrategy' +) { cy.on('window:before:load', (win) => { win.fetch = null; }); cy.server(); cy.fixture(dataFileName).as(`${dataFileName}JSON`); - cy.route('POST', 'internal/search/securitySolutionSearchStrategy', `@${dataFileName}JSON`); + cy.route('POST', `internal/search/${searchStrategyName}/${queryId}`, `@${dataFileName}JSON`); }); Cypress.Commands.add( diff --git a/x-pack/plugins/security_solution/cypress/support/index.d.ts b/x-pack/plugins/security_solution/cypress/support/index.d.ts index f0b0b8c92c616..59180507cbade 100644 --- a/x-pack/plugins/security_solution/cypress/support/index.d.ts +++ b/x-pack/plugins/security_solution/cypress/support/index.d.ts @@ -8,7 +8,11 @@ declare namespace Cypress { interface Chainable { promisify(): Promise; stubSecurityApi(dataFileName: string): Chainable; - stubSearchStrategyApi(dataFileName: string): Chainable; + stubSearchStrategyApi( + queryId: string, + dataFileName: string, + searchStrategyName?: string + ): Chainable; attachFile(fileName: string, fileType?: string): Chainable; } } diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index c530594508f95..8b494edaade3a 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -25,6 +25,7 @@ import { THREE_HUNDRED_ROWS, EXPORT_ACTION_BTN, EDIT_RULE_ACTION_BTN, + NEXT_BTN, } from '../screens/alerts_detection_rules'; export const activateRule = (rulePosition: number) => { @@ -75,6 +76,10 @@ export const loadPrebuiltDetectionRules = () => { cy.get(LOAD_PREBUILT_RULES_BTN).should('exist').click({ force: true }); }; +export const paginate = () => { + cy.get(NEXT_BTN).click(); +}; + export const reloadDeletedRules = () => { cy.get(RELOAD_PREBUILT_RULES_BTN).click({ force: true }); }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index 914566a13a9a9..079c18b6abe6e 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -223,7 +223,6 @@ export const fillDefineThresholdRuleAndContinue = (rule: ThresholdRule) => { export const fillDefineEqlRuleAndContinue = (rule: CustomRule) => { cy.get(EQL_QUERY_INPUT).type(rule.customQuery); - cy.get(EQL_QUERY_INPUT).invoke('text').should('eq', rule.customQuery); cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true }); cy.get(EQL_QUERY_INPUT).should('not.exist'); diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index 3b566559abfcd..1fea4bb1aba54 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -15,7 +15,7 @@ "inspector", "licensing", "maps", - "triggers_actions_ui", + "triggersActionsUi", "uiActions" ], "optionalPlugins": [ diff --git a/x-pack/plugins/security_solution/public/app/home/index.tsx b/x-pack/plugins/security_solution/public/app/home/index.tsx index 68eb93f7e2fe8..079c34114dfd6 100644 --- a/x-pack/plugins/security_solution/public/app/home/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/index.tsx @@ -58,7 +58,11 @@ const HomePageComponent: React.FC = ({ children }) => { ); const [showTimeline] = useShowTimeline(); - const { browserFields, indexPattern, indicesExist } = useSourcererScope(); + const { browserFields, indexPattern, indicesExist } = useSourcererScope( + subPluginId.current === DETECTIONS_SUB_PLUGIN_ID + ? SourcererScopeName.detections + : SourcererScopeName.default + ); // side effect: this will attempt to upgrade the endpoint package if it is not up to date // this will run when a user navigates to the Security Solution app and when they navigate between // tabs in the app. This is useful for keeping the endpoint package as up to date as possible until diff --git a/x-pack/plugins/security_solution/public/cases/components/__mock__/form.ts b/x-pack/plugins/security_solution/public/cases/components/__mock__/form.ts index 87f8f46affb52..1029bd35f35f6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/__mock__/form.ts +++ b/x-pack/plugins/security_solution/public/cases/components/__mock__/form.ts @@ -23,7 +23,6 @@ export const mockFormHook = { setFieldErrors: jest.fn(), getFields: jest.fn(), getFormData: jest.fn(), - getFieldDefaultValue: jest.fn(), /* Returns a list of all errors in the form */ getErrors: jest.fn(), reset: jest.fn(), @@ -34,6 +33,7 @@ export const mockFormHook = { __validateFields: jest.fn(), __updateFormDataAt: jest.fn(), __readFieldConfigFromSchema: jest.fn(), + __getFieldDefaultValue: jest.fn(), }; // eslint-disable-next-line @typescript-eslint/no-explicit-any export const getFormMock = (sampleData: any) => ({ diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx index 3c17a9191d20c..a012b4171fa23 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx @@ -38,7 +38,7 @@ const useGetUrlSearchMock = useGetUrlSearch as jest.Mock; describe('ConfigureCases', () => { beforeEach(() => { - useKibanaMock().services.triggers_actions_ui = ({ + useKibanaMock().services.triggersActionsUi = ({ actionTypeRegistry: actionTypeRegistryMock.create(), } as unknown) as TriggersAndActionsUIPublicPluginStart; }); @@ -62,17 +62,17 @@ describe('ConfigureCases', () => { }); test('it renders the ActionsConnectorsContextProvider', () => { - // Components from triggers_actions_ui do not have a data-test-subj + // Components from triggersActionsUi do not have a data-test-subj expect(wrapper.find(ActionsConnectorsContextProvider).exists()).toBeTruthy(); }); test('it renders the ConnectorAddFlyout', () => { - // Components from triggers_actions_ui do not have a data-test-subj + // Components from triggersActionsUi do not have a data-test-subj expect(wrapper.find(ConnectorAddFlyout).exists()).toBeTruthy(); }); test('it does NOT render the ConnectorEditFlyout', () => { - // Components from triggers_actions_ui do not have a data-test-subj + // Components from triggersActionsUi do not have a data-test-subj expect(wrapper.find(ConnectorEditFlyout).exists()).toBeFalsy(); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx index 63b271b8cce78..7b57f9ac60990 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx @@ -55,8 +55,7 @@ interface ConfigureCasesComponentProps { } const ConfigureCasesComponent: React.FC = ({ userCanCrud }) => { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { http, triggers_actions_ui, notifications, application, docLinks } = useKibana().services; + const { http, triggersActionsUi, notifications, application, docLinks } = useKibana().services; const [connectorIsValid, setConnectorIsValid] = useState(true); const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); @@ -197,7 +196,7 @@ const ConfigureCasesComponent: React.FC = ({ userC void, onError: () => void) => void; + onSubmit: (a: string, onSuccess: () => void, onError: () => void) => void; selectedConnector: string; } @@ -48,7 +48,13 @@ export const EditConnector = React.memo( onSubmit, selectedConnector, }: EditConnectorProps) => { - const initialState = { connectors }; + const initialState: { + connectors: Connector[]; + connector: string | undefined; + } = { + connectors, + connector: undefined, + }; const { form } = useForm({ defaultValue: initialState, options: { stripEmptyFields: false }, diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx index 4efb662a4aab6..175682aa43e76 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx @@ -126,8 +126,7 @@ export const DragDropContextWrapperComponent = React.memo diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts index 132ab054c3afd..a300f253de08d 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts @@ -9,6 +9,7 @@ import { DropResult } from 'react-beautiful-dnd'; import { Dispatch } from 'redux'; import { ActionCreator } from 'typescript-fsa'; +import { alertsHeaders } from '../../../detections/components/alerts_table/default_config'; import { BrowserField, BrowserFields, getAllFieldsByName } from '../../containers/source'; import { dragAndDropActions } from '../../store/actions'; import { IdToDataProvider } from '../../store/drag_and_drop/model'; @@ -17,6 +18,7 @@ import { timelineActions } from '../../../timelines/store/timeline'; import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; import { addContentToTimeline } from '../../../timelines/components/timeline/data_providers/helpers'; import { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider'; +import { TimelineId } from '../../../../common/types/timeline'; export const draggableIdPrefix = 'draggableId'; @@ -197,6 +199,10 @@ export const addFieldToTimelineColumns = ({ const fieldId = getFieldIdFromDraggable(result); const allColumns = getAllFieldsByName(browserFields); const column = allColumns[fieldId]; + const initColumnHeader = + timelineId === TimelineId.detectionsPage || timelineId === TimelineId.detectionsRulesDetailsPage + ? alertsHeaders.find((c) => c.id === fieldId) ?? {} + : {}; if (column != null) { dispatch( @@ -211,6 +217,7 @@ export const addFieldToTimelineColumns = ({ type: column.type, aggregatable: column.aggregatable, width: DEFAULT_COLUMN_MIN_WIDTH, + ...initColumnHeader, }, id: timelineId, index: result.destination != null ? result.destination.index : 0, diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/provider_container.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/provider_container.tsx index 8db6d073f9687..e39633dbf3288 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/provider_container.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/provider_container.tsx @@ -66,8 +66,6 @@ const ProviderContainerComponent = styled.div` .${STATEFUL_EVENT_CSS_CLASS_NAME}:hover &, tr:hover & { - background-color: ${({ theme }) => theme.eui.euiColorLightShade}; - &::before { background-image: linear-gradient( 135deg, diff --git a/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx b/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx index d37de2cd3ec3d..c3592360672ad 100644 --- a/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx @@ -53,7 +53,7 @@ export const getDefaultWhenTooltipIsUnspecified = ({ /** * Renders the content of the draggable, wrapped in a tooltip */ -const Content = React.memo<{ +export const Content = React.memo<{ children?: React.ReactNode; field: string; tooltipContent?: React.ReactNode; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 7859f5584b0e5..152161e2ce3a4 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -5,7 +5,7 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; -import { isEmpty, union } from 'lodash/fp'; +import { isEmpty } from 'lodash/fp'; import React, { useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; @@ -190,14 +190,10 @@ const EventsViewerComponent: React.FC = ({ [isLoadingIndexPattern, combinedQueries, start, end] ); - const fields = useMemo( - () => - union( - columnsHeader.map((c) => c.id), - queryFields ?? [] - ), - [columnsHeader, queryFields] - ); + const fields = useMemo(() => [...columnsHeader.map((c) => c.id), ...(queryFields ?? [])], [ + columnsHeader, + queryFields, + ]); const sortField = useMemo( () => ({ @@ -311,8 +307,7 @@ const EventsViewerComponent: React.FC = ({ itemsPerPageOptions={itemsPerPageOptions} onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadPage} - serverSideEventCount={totalCountMinusDeleted} - totalCount={pageInfo.fakeTotalCount} + totalCount={totalCountMinusDeleted} /> ) } diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx index 037462839c72d..35bd5ee572160 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx @@ -25,6 +25,11 @@ import * as helpers from '../helpers'; import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { EntriesArray } from '../../../../../../lists/common/schemas/types'; import { ExceptionListItemSchema } from '../../../../../../lists/common'; +import { + getRulesEqlSchemaMock, + getRulesSchemaMock, +} from '../../../../../common/detection_engine/schemas/response/rules_schema.mocks'; +import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async'; jest.mock('../../../../detections/containers/detection_engine/alerts/use_signal_index'); jest.mock('../../../../common/lib/kibana'); @@ -34,6 +39,7 @@ jest.mock('../use_add_exception'); jest.mock('../use_fetch_or_create_rule_exception_list'); jest.mock('../builder'); jest.mock('../../../../shared_imports'); +jest.mock('../../../../detections/containers/detection_engine/rules/use_rule_async'); describe('When the add exception modal is opened', () => { const ruleName = 'test rule'; @@ -73,6 +79,9 @@ describe('When the add exception modal is opened', () => { }, ]); (useCurrentUser as jest.Mock).mockReturnValue({ username: 'test-username' }); + (useRuleAsync as jest.Mock).mockImplementation(() => ({ + rule: getRulesSchemaMock(), + })); }); afterEach(() => { @@ -193,6 +202,9 @@ describe('When the add exception modal is opened', () => { it('should contain the endpoint specific documentation text', () => { expect(wrapper.find('[data-test-subj="add-exception-endpoint-text"]').exists()).toBeTruthy(); }); + it('should not display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).not.toBeTruthy(); + }); }); describe('when there is alert data passed to a detection list exception', () => { @@ -241,6 +253,66 @@ describe('When the add exception modal is opened', () => { .getDOMNode() ).toBeDisabled(); }); + it('should not display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).not.toBeTruthy(); + }); + }); + + describe('when there is an exception being created on a sequence eql rule type', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + const alertDataMock: Ecs = { _id: 'test-id', file: { path: ['test/path'] } }; + (useRuleAsync as jest.Mock).mockImplementation(() => ({ + rule: { + ...getRulesEqlSchemaMock(), + query: + 'sequence [process where process.name = "test.exe"] [process where process.name = "explorer.exe"]', + }, + })); + wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + const callProps = ExceptionBuilderComponent.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [getExceptionListItemSchemaMock()] }) + ); + }); + it('has the add exception button enabled', () => { + expect( + wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() + ).not.toBeDisabled(); + }); + it('should render the exception builder', () => { + expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); + }); + it('should not prepopulate endpoint items', () => { + expect(defaultEndpointItems).not.toHaveBeenCalled(); + }); + it('should render the close on add exception checkbox', () => { + expect( + wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() + ).toBeTruthy(); + }); + it('should have the bulk close checkbox disabled', () => { + expect( + wrapper + .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') + .getDOMNode() + ).toBeDisabled(); + }); + it('should display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).toBeTruthy(); + }); }); describe('when there is bulk-closeable alert data passed to an endpoint list exception', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index ad5bc98243467..bf483387580ce 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -19,7 +19,9 @@ import { EuiSpacer, EuiFormRow, EuiText, + EuiCallOut, } from '@elastic/eui'; +import { hasEqlSequenceQuery, isEqlRule } from '../../../../../common/detection_engine/utils'; import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; import { ExceptionListItemSchema, @@ -315,6 +317,13 @@ export const AddExceptionModal = memo(function AddExceptionModal({ const addExceptionMessage = exceptionListType === 'endpoint' ? i18n.ADD_ENDPOINT_EXCEPTION : i18n.ADD_EXCEPTION; + const isRuleEQLSequenceStatement = useMemo((): boolean => { + if (maybeRule != null) { + return isEqlRule(maybeRule.type) && hasEqlSequenceQuery(maybeRule.query); + } + return false; + }, [maybeRule]); + return ( @@ -353,6 +362,15 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ruleExceptionList && ( <> + {isRuleEQLSequenceStatement && ( + <> + + + + )} {i18n.EXCEPTION_BUILDER_INFO} { const ruleName = 'test rule'; @@ -58,6 +64,9 @@ describe('When the edit exception modal is opened', () => { }, ]); (useCurrentUser as jest.Mock).mockReturnValue({ username: 'test-username' }); + (useRuleAsync as jest.Mock).mockImplementation(() => ({ + rule: getRulesSchemaMock(), + })); }); afterEach(() => { @@ -190,7 +199,58 @@ describe('When the edit exception modal is opened', () => { }); }); - describe('when an detection exception with entries is passed', () => { + describe('when an exception assigned to a sequence eql rule type is passed', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + (useRuleAsync as jest.Mock).mockImplementation(() => ({ + rule: { + ...getRulesEqlSchemaMock(), + query: + 'sequence [process where process.name = "test.exe"] [process where process.name = "explorer.exe"]', + }, + })); + wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + const callProps = ExceptionBuilderComponent.mock.calls[0][0]; + await waitFor(() => { + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); + }); + }); + it('has the edit exception button enabled', () => { + expect( + wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() + ).not.toBeDisabled(); + }); + it('renders the exceptions builder', () => { + expect(wrapper.find('[data-test-subj="edit-exception-modal-builder"]').exists()).toBeTruthy(); + }); + it('should not contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="edit-exception-endpoint-text"]').exists()).toBeFalsy(); + }); + it('should have the bulk close checkbox disabled', () => { + expect( + wrapper + .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') + .getDOMNode() + ).toBeDisabled(); + }); + it('should display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).toBeTruthy(); + }); + }); + + describe('when a detection exception with entries is passed', () => { let wrapper: ReactWrapper; beforeEach(async () => { wrapper = mount( @@ -229,6 +289,9 @@ describe('When the edit exception modal is opened', () => { .getDOMNode() ).toBeDisabled(); }); + it('should not display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).not.toBeTruthy(); + }); }); describe('when an exception with no entries is passed', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index 08f7e3af90d0c..257c8e8c4d873 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -22,6 +22,7 @@ import { EuiCallOut, } from '@elastic/eui'; +import { hasEqlSequenceQuery, isEqlRule } from '../../../../../common/detection_engine/utils'; import { useFetchIndex } from '../../../containers/source'; import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async'; @@ -246,6 +247,13 @@ export const EditExceptionModal = memo(function EditExceptionModal({ signalIndexName, ]); + const isRuleEQLSequenceStatement = useMemo((): boolean => { + if (maybeRule != null) { + return isEqlRule(maybeRule.type) && hasEqlSequenceQuery(maybeRule.query); + } + return false; + }, [maybeRule]); + return ( @@ -265,6 +273,15 @@ export const EditExceptionModal = memo(function EditExceptionModal({ {!isSignalIndexLoading && !addExceptionIsLoading && !isIndexPatternLoading && ( <> + {isRuleEQLSequenceStatement && ( + <> + + + + )} {i18n.EXCEPTION_BUILDER_INFO} - value 1 + + value 1 + `; diff --git a/x-pack/plugins/security_solution/public/common/components/item_details_card/index.tsx b/x-pack/plugins/security_solution/public/common/components/item_details_card/index.tsx index ee1c3e1bead1a..9105514b75807 100644 --- a/x-pack/plugins/security_solution/public/common/components/item_details_card/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/item_details_card/index.tsx @@ -52,23 +52,30 @@ const DetailsSection = styled(EuiFlexItem)` `; const DescriptionListTitle = styled(EuiDescriptionListTitle)` - width: 40%; + &&& { + width: 40%; + } `; const DescriptionListDescription = styled(EuiDescriptionListDescription)` - width: 60%; + &&& { + width: 60%; + } `; interface ItemDetailsPropertySummaryProps { name: ReactNode | ReactNode[]; value: ReactNode | ReactNode[]; + title?: string; } export const ItemDetailsPropertySummary: FC = memo( - ({ name, value }) => ( + ({ name, value, title = '' }) => ( <> {name} - {value} + + {value} + ) ); diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap index af022fc3d525d..3df8663324fdd 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap @@ -66,7 +66,8 @@ exports[`Paginated Table Component rendering it renders the default load more ta "euiButtonHeight": "40px", "euiButtonHeightSmall": "32px", "euiButtonIconTypes": Object { - "danger": "#ff6666", + "accent": "#f990c0", + "danger": "#ff7575", "disabled": "#4c4e51", "ghost": "#ffffff", "primary": "#1ba9f5", @@ -217,7 +218,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta "euiDataGridColumnResizerWidth": "3px", "euiDataGridPopoverMaxHeight": "400px", "euiDataGridPrefix": ".euiDataGrid--", - "euiDataGridStyles": "'bordersAll', 'bordersNone', 'bordersHorizontal', 'paddingSmall', 'paddingMedium', 'paddingLarge', 'stripes', 'rowHoverNone', 'rowHoverHighlight', 'headerShade', 'headerUnderline', 'fontSizeSmall', 'fontSizeLarge', 'noControls'", + "euiDataGridStyles": "'bordersAll', 'bordersNone', 'bordersHorizontal', 'paddingSmall', 'paddingMedium', 'paddingLarge', 'stripes', 'rowHoverNone', 'rowHoverHighlight', 'headerShade', 'headerUnderline', 'footerShade', 'footerOverline', 'fontSizeSmall', 'fontSizeLarge', 'noControls', 'stickyFooter'", "euiDataGridVerticalBorder": "solid 1px #24272e", "euiDatePickerCalendarWidth": "284px", "euiDragAndDropSpacing": Object { @@ -292,9 +293,15 @@ exports[`Paginated Table Component rendering it renders the default load more ta "euiHeaderChildSize": "48px", "euiHeaderHeight": "48px", "euiHeaderHeightCompensation": "49px", + "euiHeaderLinksGutterSizes": Object { + "gutterL": "24px", + "gutterM": "12px", + "gutterS": "8px", + "gutterXS": "4px", + }, "euiIconColors": Object { "accent": "#f990c0", - "danger": "#ff7575", + "danger": "#ff6666", "ghost": "#ffffff", "primary": "#1ba9f5", "secondary": "#7de2d1", diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx index f7b69c4fc8ed3..c0d540d01ee97 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx @@ -325,7 +325,7 @@ const FooterAction = styled(EuiFlexGroup).attrs(() => ({ FooterAction.displayName = 'FooterAction'; -const PaginationEuiFlexItem = styled(EuiFlexItem)` +export const PaginationEuiFlexItem = styled(EuiFlexItem)` @media only screen and (min-width: ${({ theme }) => theme.eui.euiBreakpoints.m}) { .euiButtonIcon:last-child { margin-left: 28px; diff --git a/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.ts b/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.ts index dc2d6605bc292..ab44cbd65516e 100644 --- a/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.ts +++ b/x-pack/plugins/security_solution/public/common/containers/events/last_event_time/index.ts @@ -61,7 +61,7 @@ export const useTimelineLastEventTime = ({ details, }); - const [TimelineLastEventTimeResponse, setTimelineLastEventTimeResponse] = useState< + const [timelineLastEventTimeResponse, setTimelineLastEventTimeResponse] = useState< UseTimelineLastEventTimeArgs >({ lastSeen: null, @@ -151,5 +151,5 @@ export const useTimelineLastEventTime = ({ timelineLastEventTimeSearch(TimelineLastEventTimeRequest); }, [TimelineLastEventTimeRequest, timelineLastEventTimeSearch]); - return [loading, TimelineLastEventTimeResponse]; + return [loading, timelineLastEventTimeResponse]; }; diff --git a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts index 33cc86f5e9798..54e349fe3e926 100644 --- a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts +++ b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts @@ -34,6 +34,8 @@ export interface UseMatrixHistogramArgs { totalCount: number; } +const ID = 'matrixHistogramQuery'; + export const useMatrixHistogram = ({ endDate, errorMessage, @@ -54,6 +56,7 @@ export const useMatrixHistogram = ({ factoryQueryType: MatrixHistogramQuery, filterQuery: createFilter(filterQuery), histogramType, + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/common/translations.ts b/x-pack/plugins/security_solution/public/common/translations.ts index c4a9540f62914..6fed7db62aa3c 100644 --- a/x-pack/plugins/security_solution/public/common/translations.ts +++ b/x-pack/plugins/security_solution/public/common/translations.ts @@ -50,7 +50,7 @@ export const EMPTY_ACTION_SECONDARY = i18n.translate( export const EMPTY_ACTION_ENDPOINT = i18n.translate( 'xpack.securitySolution.pages.common.emptyActionEndpoint', { - defaultMessage: 'Add Elastic Endpoint Security', + defaultMessage: 'Add Endpoint Security', } ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx index 0e2aee5abd42e..043a5afc4480d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx @@ -150,6 +150,12 @@ export const getThresholdAggregationDataProvider = ( ]; }; +export const isEqlRule = (ecsData: Ecs) => + ecsData.signal?.rule?.type?.length && ecsData.signal?.rule?.type[0] === 'eql'; + +export const isThresholdRule = (ecsData: Ecs) => + ecsData.signal?.rule?.type?.length && ecsData.signal?.rule?.type[0] === 'threshold'; + export const sendAlertToTimelineAction = async ({ apolloClient, createTimeline, @@ -158,13 +164,12 @@ export const sendAlertToTimelineAction = async ({ updateTimelineIsLoading, searchStrategyClient, }: SendAlertToTimelineActionProps) => { - let openAlertInBasicTimeline = true; const noteContent = ecsData.signal?.rule?.note != null ? ecsData.signal?.rule?.note[0] : ''; const timelineId = ecsData.signal?.rule?.timeline_id != null ? ecsData.signal?.rule?.timeline_id[0] : ''; const { to, from } = determineToAndFrom({ ecsData }); - if (timelineId !== '' && apolloClient != null) { + if (!isEmpty(timelineId) && apolloClient != null) { try { updateTimelineIsLoading({ id: TimelineId.active, isLoading: true }); const [responseTimeline, eventDataResp] = await Promise.all([ @@ -173,6 +178,7 @@ export const sendAlertToTimelineAction = async ({ fetchPolicy: 'no-cache', variables: { id: timelineId, + timelineType: TimelineType.template, }, }), searchStrategyClient.search< @@ -195,7 +201,6 @@ export const sendAlertToTimelineAction = async ({ const eventData: TimelineEventsDetailsItem[] = getOr([], 'data', eventDataResp); if (!isEmpty(resultingTimeline)) { const timelineTemplate: TimelineResult = omitTypenameInTimeline(resultingTimeline); - openAlertInBasicTimeline = false; const { timeline, notes } = formatTimelineResultToModel( timelineTemplate, true, @@ -250,16 +255,11 @@ export const sendAlertToTimelineAction = async ({ }); } } catch { - openAlertInBasicTimeline = true; updateTimelineIsLoading({ id: TimelineId.active, isLoading: false }); } } - if ( - ecsData.signal?.rule?.type?.length && - ecsData.signal?.rule?.type[0] === 'threshold' && - openAlertInBasicTimeline - ) { + if (isThresholdRule(ecsData)) { return createTimeline({ from, notes: null, @@ -312,26 +312,44 @@ export const sendAlertToTimelineAction = async ({ ruleNote: noteContent, }); } else { + let dataProviders = [ + { + and: [], + id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${ecsData._id}`, + name: ecsData._id, + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: '_id', + value: ecsData._id, + operator: ':' as const, + }, + }, + ]; + if (isEqlRule(ecsData)) { + const signalGroupId = ecsData.signal?.group?.id?.length + ? ecsData.signal?.group?.id[0] + : 'unknown-signal-group-id'; + dataProviders = [ + { + ...dataProviders[0], + id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${signalGroupId}`, + queryMatch: { + field: 'signal.group.id', + value: signalGroupId, + operator: ':' as const, + }, + }, + ]; + } + return createTimeline({ from, notes: null, timeline: { ...timelineDefaults, - dataProviders: [ - { - and: [], - id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${ecsData._id}`, - name: ecsData._id, - enabled: true, - excluded: false, - kqlQuery: '', - queryMatch: { - field: '_id', - value: ecsData._id, - operator: ':', - }, - }, - ], + dataProviders, id: TimelineId.active, indexNames: [], dateRange: { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.tsx index 30fcdc21d61e8..a7e7126eae111 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.tsx @@ -47,6 +47,17 @@ const UtilityBarFlexGroup = styled(EuiFlexGroup)` min-width: 175px; `; +const BuildingBlockContainer = styled(EuiFlexItem)` + background: repeating-linear-gradient( + 127deg, + rgba(245, 167, 0, 0.2), + rgba(245, 167, 0, 0.2) 1px, + rgba(245, 167, 0, 0.05) 2px, + rgba(245, 167, 0, 0.05) 10px + ); + padding: ${({ theme }) => `${theme.eui.paddingSizes.xs}`}; +`; + const AlertsUtilityBarComponent: React.FC = ({ canUserCRUD, hasIndexWrite, @@ -133,7 +144,7 @@ const AlertsUtilityBarComponent: React.FC = ({ const UtilityBarAdditionalFiltersContent = (closePopover: () => void) => ( - + = ({ data-test-subj="showBuildingBlockAlertsCheckbox" label={i18n.ADDITIONAL_FILTERS_ACTIONS_SHOW_BUILDING_BLOCK} /> - + ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index eebabc59d9324..a5d3a3f90767f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -165,7 +165,9 @@ export const alertsDefaultModel: SubsetTimelineModel = { export const requiredFieldsForActions = [ '@timestamp', 'signal.status', + 'signal.group.id', 'signal.original_time', + 'signal.rule.building_block_type', 'signal.rule.filters', 'signal.rule.from', 'signal.rule.language', @@ -177,7 +179,6 @@ export const requiredFieldsForActions = [ 'signal.rule.type', 'signal.original_event.kind', 'signal.original_event.module', - // Endpoint exception fields 'file.path', 'file.Ext.code_signature.subject_name', diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx index 83d087e60bc7d..7d509270fff95 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx @@ -125,9 +125,7 @@ export const StepRuleDescriptionComponent = ({ ); }; -export const StepRuleDescription = memo( - StepRuleDescriptionComponent -) as typeof StepRuleDescriptionComponent; +export const StepRuleDescription = memo(StepRuleDescriptionComponent); export const buildListItems = ( data: unknown, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts index 9b0cec99b1b38..66fe138c30ad5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts @@ -17,7 +17,7 @@ export const PRE_BUILT_MSG = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.prePackagedRules.emptyPromptMessage', { defaultMessage: - 'Elastic Security comes with prebuilt detection rules that run in the background and create alerts when their conditions are met. By default, all prebuilt rules except the Elastic Endpoint Security rule are disabled. You can select additional rules you want to activate.', + 'Elastic Security comes with prebuilt detection rules that run in the background and create alerts when their conditions are met. By default, all prebuilt rules except the Endpoint Security rule are disabled. You can select additional rules you want to activate.', } ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx index 20c3073789b2a..cce6c72ca4cc5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx @@ -17,7 +17,7 @@ describe('RuleActionsField', () => { it('should not render ActionForm if no actions are supported', () => { (useKibana as jest.Mock).mockReturnValue({ services: { - triggers_actions_ui: { + triggersActionsUi: { actionTypeRegistry: {}, }, application: { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx index b9097949bd20a..4ff1b4e4f20f3 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx @@ -41,7 +41,7 @@ export const RuleActionsField: ThrottleSelectField = ({ field, messageVariables const { isSubmitted, isSubmitting, isValid } = form; const { http, - triggers_actions_ui: { actionTypeRegistry }, + triggersActionsUi: { actionTypeRegistry }, notifications, docLinks, application: { capabilities }, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx index d13635bfd1b50..07a67ae8705c3 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx @@ -19,10 +19,7 @@ import { hasMlAdminPermissions } from '../../../../../common/machine_learning/ha import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license'; import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities'; import { useUiSetting$ } from '../../../../common/lib/kibana'; -import { - filterRuleFieldsForType, - RuleFields, -} from '../../../pages/detection_engine/rules/create/helpers'; +import { filterRuleFieldsForType } from '../../../pages/detection_engine/rules/create/helpers'; import { DefineStepRule, RuleStep, @@ -223,7 +220,7 @@ const StepDefineRuleComponent: FC = ({ diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.test.tsx index aea75b5b3b796..a1d7e69b7a60f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.test.tsx @@ -23,7 +23,7 @@ jest.mock('../../../../common/lib/kibana', () => ({ }, }, }, - triggers_actions_ui: { + triggersActionsUi: { actionTypeRegistry: jest.fn(), }, }, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx index 77168e62492c6..de473b6fc6aec 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx @@ -72,7 +72,7 @@ const StepRuleActionsComponent: FC = ({ const { services: { application, - triggers_actions_ui: { actionTypeRegistry }, + triggersActionsUi: { actionTypeRegistry }, }, } = useKibana(); const kibanaAbsoluteUrl = useMemo( diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/threatmatch_input/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/threatmatch_input/index.tsx index 2a4609a2f5e9e..9a3dcc7a4d713 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/threatmatch_input/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/threatmatch_input/index.tsx @@ -17,6 +17,7 @@ import { UseField, getFieldValidityAndErrorMessage, } from '../../../../shared_imports'; +import { DefineStepRule } from '../../../pages/detection_engine/rules/types'; import { schema } from '../step_define_rule/schema'; import { QueryBarDefineRule } from '../query_bar'; import { IndexPattern } from '../../../../../../../../src/plugins/data/public'; @@ -51,7 +52,7 @@ const ThreatMatchInputComponent: React.FC = ({ - path="threatIndex" config={{ ...schema.threatIndex, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts index 160809a2ba3cd..540fdc6bc75f5 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts @@ -110,7 +110,7 @@ const isThreatMatchFields = ( fields: QueryRuleFields | MlRuleFields | ThresholdRuleFields | ThreatMatchRuleFields ): fields is ThreatMatchRuleFields => has('threatIndex', fields); -export const filterRuleFieldsForType = ( +export const filterRuleFieldsForType = >( fields: T, type: Type ): QueryRuleFields | MlRuleFields | ThresholdRuleFields | ThreatMatchRuleFields => { diff --git a/x-pack/plugins/security_solution/public/graphql/introspection.json b/x-pack/plugins/security_solution/public/graphql/introspection.json index 8d780137b847c..cc91d23905814 100644 --- a/x-pack/plugins/security_solution/public/graphql/introspection.json +++ b/x-pack/plugins/security_solution/public/graphql/introspection.json @@ -212,6 +212,12 @@ "ofType": { "kind": "SCALAR", "name": "ID", "ofType": null } }, "defaultValue": null + }, + { + "name": "timelineType", + "description": "", + "type": { "kind": "ENUM", "name": "TimelineType", "ofType": null }, + "defaultValue": null } ], "type": { @@ -1914,6 +1920,29 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "ENUM", + "name": "TimelineType", + "description": "", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "default", + "description": "", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "template", + "description": "", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, { "kind": "OBJECT", "name": "TimelineResult", @@ -2953,29 +2982,6 @@ "enumValues": null, "possibleTypes": null }, - { - "kind": "ENUM", - "name": "TimelineType", - "description": "", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "default", - "description": "", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "template", - "description": "", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, { "kind": "INPUT_OBJECT", "name": "PageInfoTimeline", diff --git a/x-pack/plugins/security_solution/public/graphql/types.ts b/x-pack/plugins/security_solution/public/graphql/types.ts index 5cc8fd1f37d2e..52598b5f44943 100644 --- a/x-pack/plugins/security_solution/public/graphql/types.ts +++ b/x-pack/plugins/security_solution/public/graphql/types.ts @@ -274,6 +274,11 @@ export enum HostPolicyResponseActionStatus { warning = 'warning', } +export enum TimelineType { + default = 'default', + template = 'template', +} + export enum DataProviderType { default = 'default', template = 'template', @@ -301,11 +306,6 @@ export enum TimelineStatus { immutable = 'immutable', } -export enum TimelineType { - default = 'default', - template = 'template', -} - export enum SortFieldTimeline { title = 'title', description = 'description', @@ -1599,6 +1599,8 @@ export interface SourceQueryArgs { } export interface GetOneTimelineQueryArgs { id: string; + + timelineType?: Maybe; } export interface GetAllTimelineQueryArgs { pageInfo: PageInfoTimeline; @@ -2166,6 +2168,7 @@ export namespace PersistTimelineNoteMutation { export namespace GetOneTimeline { export type Variables = { id: string; + timelineType?: Maybe; }; export type Query = { diff --git a/x-pack/plugins/security_solution/public/helpers.ts b/x-pack/plugins/security_solution/public/helpers.ts index 92f3d23907559..def49fd205130 100644 --- a/x-pack/plugins/security_solution/public/helpers.ts +++ b/x-pack/plugins/security_solution/public/helpers.ts @@ -110,5 +110,6 @@ export const getInspectResponse = ( prevResponse: InspectResponse ): InspectResponse => ({ dsl: response?.inspect?.dsl ?? prevResponse?.dsl ?? [], - response: response != null ? [JSON.stringify(response, null, 2)] : prevResponse?.response, + response: + response != null ? [JSON.stringify(response.rawResponse, null, 2)] : prevResponse?.response, }); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx index bc7137097c646..6418ea83d97f9 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx @@ -37,7 +37,7 @@ import { hostsModel, hostsSelectors } from '../../store'; import * as i18n from './translations'; -const ID = 'authenticationQuery'; +const ID = 'hostsAuthenticationsQuery'; export interface AuthenticationArgs { authentications: AuthenticationsEdges[]; @@ -78,25 +78,35 @@ export const useAuthentications = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [authenticationsRequest, setAuthenticationsRequest] = useState< - HostAuthenticationsRequestOptions - >({ - defaultIndex: indexNames, - docValueFields: docValueFields ?? [], - factoryQueryType: HostsQueries.authentications, - filterQuery: createFilter(filterQuery), - pagination: generateTablePaginationOptions(activePage, limit), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - sort: {} as SortField, - }); + const [ + authenticationsRequest, + setAuthenticationsRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + docValueFields: docValueFields ?? [], + factoryQueryType: HostsQueries.authentications, + filterQuery: createFilter(filterQuery), + id: ID, + pagination: generateTablePaginationOptions(activePage, limit), + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + sort: {} as SortField, + } + : null + ); const wrappedLoadMore = useCallback( (newActivePage: number) => { setAuthenticationsRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + return { ...prevRequest, pagination: generateTablePaginationOptions(newActivePage, limit), @@ -126,7 +136,11 @@ export const useAuthentications = ({ }); const authenticationsSearch = useCallback( - (request: HostAuthenticationsRequestOptions) => { + (request: HostAuthenticationsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -184,16 +198,19 @@ export const useAuthentications = ({ useEffect(() => { setAuthenticationsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, docValueFields: docValueFields ?? [], + factoryQueryType: HostsQueries.authentications, filterQuery: createFilter(filterQuery), + id: ID, pagination: generateTablePaginationOptions(activePage, limit), timerange: { interval: '12h', from: startDate, to: endDate, }, + sort: {} as SortField, }; if (!skip && !deepEqual(prevRequest, myRequest)) { return myRequest; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/details/_index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/hosts/details/_index.tsx index 5b69e20398a35..2dec01dc4d9e3 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/hosts/details/_index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/details/_index.tsx @@ -28,7 +28,7 @@ import { import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'hostDetailsQuery'; +const ID = 'hostsDetailsQuery'; export interface HostDetailsArgs { id: string; @@ -60,16 +60,21 @@ export const useHostDetails = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [hostDetailsRequest, setHostDetailsRequest] = useState({ - defaultIndex: indexNames, - hostName, - factoryQueryType: HostsQueries.details, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [hostDetailsRequest, setHostDetailsRequest] = useState( + !skip + ? { + defaultIndex: indexNames, + hostName, + id, + factoryQueryType: HostsQueries.details, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [hostDetailsResponse, setHostDetailsResponse] = useState({ endDate, @@ -84,7 +89,11 @@ export const useHostDetails = ({ }); const hostDetailsSearch = useCallback( - (request: HostDetailsRequestOptions) => { + (request: HostDetailsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -141,9 +150,11 @@ export const useHostDetails = ({ useEffect(() => { setHostDetailsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: HostsQueries.details, hostName, + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx index 77f4567fc6a5f..9dd4881b3c9ff 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx @@ -33,7 +33,7 @@ import { import { getInspectResponse } from '../../../helpers'; import { InspectResponse } from '../../../types'; -const ID = 'hostsQuery'; +const ID = 'hostsAllQuery'; type LoadPage = (newActivePage: number) => void; export interface HostsArgs { @@ -76,29 +76,40 @@ export const useAllHost = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [hostsRequest, setHostRequest] = useState({ - defaultIndex: indexNames, - docValueFields: docValueFields ?? [], - factoryQueryType: HostsQueries.hosts, - filterQuery: createFilter(filterQuery), - pagination: generateTablePaginationOptions(activePage, limit), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - sort: { - direction, - field: sortField, - }, - }); + const [hostsRequest, setHostRequest] = useState( + !skip + ? { + defaultIndex: indexNames, + docValueFields: docValueFields ?? [], + factoryQueryType: HostsQueries.hosts, + filterQuery: createFilter(filterQuery), + id: ID, + pagination: generateTablePaginationOptions(activePage, limit), + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + sort: { + direction, + field: sortField, + }, + } + : null + ); const wrappedLoadMore = useCallback( (newActivePage: number) => { - setHostRequest((prevRequest) => ({ - ...prevRequest, - pagination: generateTablePaginationOptions(newActivePage, limit), - })); + setHostRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); }, [limit] ); @@ -124,7 +135,11 @@ export const useAllHost = ({ }); const hostsSearch = useCallback( - (request: HostsRequestOptions) => { + (request: HostsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -180,10 +195,12 @@ export const useAllHost = ({ useEffect(() => { setHostRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, docValueFields: docValueFields ?? [], + factoryQueryType: HostsQueries.hosts, filterQuery: createFilter(filterQuery), + id: ID, pagination: generateTablePaginationOptions(activePage, limit), timerange: { interval: '12h', diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx index 404231be1e6cd..90be23b48786c 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx @@ -52,19 +52,24 @@ export const useHostsKpiAuthentications = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [hostsKpiAuthenticationsRequest, setHostsKpiAuthenticationsRequest] = useState< - HostsKpiAuthenticationsRequestOptions - >({ - defaultIndex: indexNames, - factoryQueryType: HostsKpiQueries.kpiAuthentications, - filterQuery: createFilter(filterQuery), - id: ID, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [ + hostsKpiAuthenticationsRequest, + setHostsKpiAuthenticationsRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: HostsKpiQueries.kpiAuthentications, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [hostsKpiAuthenticationsResponse, setHostsKpiAuthenticationsResponse] = useState< HostsKpiAuthenticationsArgs @@ -83,7 +88,11 @@ export const useHostsKpiAuthentications = ({ }); const hostsKpiAuthenticationsSearch = useCallback( - (request: HostsKpiAuthenticationsRequestOptions) => { + (request: HostsKpiAuthenticationsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -146,9 +155,11 @@ export const useHostsKpiAuthentications = ({ useEffect(() => { setHostsKpiAuthenticationsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: HostsKpiQueries.kpiAuthentications, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx index bb918a9214f40..2bb08dec78e8f 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx @@ -51,17 +51,24 @@ export const useHostsKpiHosts = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [hostsKpiHostsRequest, setHostsKpiHostsRequest] = useState({ - defaultIndex: indexNames, - factoryQueryType: HostsKpiQueries.kpiHosts, - filterQuery: createFilter(filterQuery), - id: ID, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [ + hostsKpiHostsRequest, + setHostsKpiHostsRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: HostsKpiQueries.kpiHosts, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [hostsKpiHostsResponse, setHostsKpiHostsResponse] = useState({ hosts: 0, @@ -76,7 +83,11 @@ export const useHostsKpiHosts = ({ }); const hostsKpiHostsSearch = useCallback( - (request: HostsKpiHostsRequestOptions) => { + (request: HostsKpiHostsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -134,9 +145,11 @@ export const useHostsKpiHosts = ({ useEffect(() => { setHostsKpiHostsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: HostsKpiQueries.kpiHosts, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx index b8e93eef8dc91..e5ef53643ff53 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx @@ -52,19 +52,24 @@ export const useHostsKpiUniqueIps = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [hostsKpiUniqueIpsRequest, setHostsKpiUniqueIpsRequest] = useState< - HostsKpiUniqueIpsRequestOptions - >({ - defaultIndex: indexNames, - factoryQueryType: HostsKpiQueries.kpiUniqueIps, - filterQuery: createFilter(filterQuery), - id: ID, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [ + hostsKpiUniqueIpsRequest, + setHostsKpiUniqueIpsRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: HostsKpiQueries.kpiUniqueIps, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [hostsKpiUniqueIpsResponse, setHostsKpiUniqueIpsResponse] = useState( { @@ -83,7 +88,11 @@ export const useHostsKpiUniqueIps = ({ ); const hostsKpiUniqueIpsSearch = useCallback( - (request: HostsKpiUniqueIpsRequestOptions) => { + (request: HostsKpiUniqueIpsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -143,9 +152,11 @@ export const useHostsKpiUniqueIps = ({ useEffect(() => { setHostsKpiUniqueIpsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: HostsKpiQueries.kpiUniqueIps, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx index 4036837024025..2bf97c896f5e5 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx @@ -35,7 +35,7 @@ import { ESTermQuery } from '../../../../common/typed_json'; import { getInspectResponse } from '../../../helpers'; import { InspectResponse } from '../../../types'; -const ID = 'uncommonProcessesQuery'; +const ID = 'hostsUncommonProcessesQuery'; export interface UncommonProcessesArgs { id: string; @@ -75,25 +75,35 @@ export const useUncommonProcesses = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [uncommonProcessesRequest, setUncommonProcessesRequest] = useState< - HostsUncommonProcessesRequestOptions - >({ - defaultIndex: indexNames, - docValueFields: docValueFields ?? [], - factoryQueryType: HostsQueries.uncommonProcesses, - filterQuery: createFilter(filterQuery), - pagination: generateTablePaginationOptions(activePage, limit), - timerange: { - interval: '12h', - from: startDate!, - to: endDate!, - }, - sort: {} as SortField, - }); + const [ + uncommonProcessesRequest, + setUncommonProcessesRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + docValueFields: docValueFields ?? [], + factoryQueryType: HostsQueries.uncommonProcesses, + filterQuery: createFilter(filterQuery), + id: ID, + pagination: generateTablePaginationOptions(activePage, limit), + timerange: { + interval: '12h', + from: startDate!, + to: endDate!, + }, + sort: {} as SortField, + } + : null + ); const wrappedLoadMore = useCallback( (newActivePage: number) => { setUncommonProcessesRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + return { ...prevRequest, pagination: generateTablePaginationOptions(newActivePage, limit), @@ -124,7 +134,11 @@ export const useUncommonProcesses = ({ ); const uncommonProcessesSearch = useCallback( - (request: HostsUncommonProcessesRequestOptions) => { + (request: HostsUncommonProcessesRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -185,10 +199,12 @@ export const useUncommonProcesses = ({ useEffect(() => { setUncommonProcessesRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, docValueFields: docValueFields ?? [], + factoryQueryType: HostsQueries.uncommonProcesses, filterQuery: createFilter(filterQuery), + id: ID, pagination: generateTablePaginationOptions(activePage, limit), timerange: { interval: '12h', diff --git a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx index 4617865d6aa6d..d51a23639f5cb 100644 --- a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx +++ b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx @@ -58,7 +58,7 @@ const PolicyEmptyState = React.memo<{

    @@ -66,21 +66,21 @@ const PolicyEmptyState = React.memo<{ } bodyComponent={ diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 4bb9335496ef4..7639b878b9c5c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -570,7 +570,7 @@ export const EndpointList = () => { subtitle={ } > diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_list.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_list.test.tsx.snap index d33c74a021f86..bf5f5149b2ef2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_list.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_list.test.tsx.snap @@ -494,11 +494,11 @@ exports[`TrustedAppsList renders correctly when item details expanded 1`] = ` padding: 16px; } -.c1 { +.c1.c1.c1 { width: 40%; } -.c2 { +.c2.c2.c2 { width: 60%; } @@ -791,7 +791,11 @@ exports[`TrustedAppsList renders correctly when item details expanded 1`] = `
    - trusted app 0 + + trusted app 0 +
    - Windows + + Windows +
    - 1 minute ago + + 1 minute ago +
    - someone + + someone + + +
    + Description +
    +
    + + Trusted App 0 +
    diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx index cced85083348c..681e60b91cefa 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx @@ -13,6 +13,8 @@ import { EuiFlyoutBody, EuiFlyoutFooter, EuiFlyoutHeader, + EuiSpacer, + EuiText, EuiTitle, } from '@elastic/eui'; import React, { memo, useCallback, useEffect, useState } from 'react'; @@ -29,6 +31,7 @@ import { useTrustedAppsSelector } from '../hooks'; import { getApiCreateErrors, isCreatePending, wasCreateSuccessful } from '../../store/selectors'; import { AppAction } from '../../../../../common/store/actions'; import { useToasts } from '../../../../../common/lib/kibana'; +import { ABOUT_TRUSTED_APPS } from '../translations'; type CreateTrustedAppFlyoutProps = Omit; export const CreateTrustedAppFlyout = memo( @@ -105,6 +108,10 @@ export const CreateTrustedAppFlyout = memo( + +

    {ABOUT_TRUSTED_APPS}

    + +
    ( value={formValues.description} onChange={handleDomChangeEvents} fullWidth + maxLength={256} data-test-subj={getTestId('descriptionField')} /> diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/__snapshots__/index.test.tsx.snap index 3928f4ddec837..1d33a06c507a3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/__snapshots__/index.test.tsx.snap @@ -24,6 +24,81 @@ exports[`trusted_app_card TrustedAppCard should render correctly 1`] = ` name="Created By" value="someone" /> + + + + Remove + + +`; + +exports[`trusted_app_card TrustedAppCard should trim long descriptions 1`] = ` + + + + + } + /> + + ( )); +const PATH_CONDITION: WindowsConditionEntry = { + field: 'process.executable.caseless', + operator: 'included', + type: 'match', + value: '/some/path/on/file/system', +}; + +const SIGNER_CONDITION: WindowsConditionEntry = { + field: 'process.code_signature', + operator: 'included', + type: 'match', + value: 'Elastic', +}; + storiesOf('TrustedApps|TrustedAppCard', module) .add('default', () => { const trustedApp: TrustedApp = createSampleTrustedApp(5); trustedApp.created_at = '2020-09-17T14:52:33.899Z'; - trustedApp.entries = [ - { - field: 'process.executable.caseless', - operator: 'included', - type: 'match', - value: '/some/path/on/file/system', - }, - ]; + trustedApp.entries = [PATH_CONDITION]; return ; }) .add('multiple entries', () => { const trustedApp: TrustedApp = createSampleTrustedApp(5); trustedApp.created_at = '2020-09-17T14:52:33.899Z'; - trustedApp.entries = [ - { - field: 'process.executable.caseless', - operator: 'included', - type: 'match', - value: '/some/path/on/file/system', - }, - { - field: 'process.code_signature', - operator: 'included', - type: 'match', - value: 'Elastic', - }, - ]; + trustedApp.entries = [PATH_CONDITION, SIGNER_CONDITION]; + + return ; + }) + .add('trim description', () => { + const trustedApp: TrustedApp = createSampleTrustedApp(5); + trustedApp.created_at = '2020-09-17T14:52:33.899Z'; + trustedApp.entries = [PATH_CONDITION, SIGNER_CONDITION]; + trustedApp.description = [...new Array(40).keys()].map((index) => `item${index}`).join(' '); return ; }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.test.tsx index 163883b3dc3b8..1e2d18aea20fd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.test.tsx @@ -18,5 +18,15 @@ describe('trusted_app_card', () => { expect(element).toMatchSnapshot(); }); + + it('should trim long descriptions', () => { + const trustedApp = { + ...createSampleTrustedApp(4), + description: [...new Array(40).keys()].map((index) => `item${index}`).join(' '), + }; + const element = shallow( {}} />); + + expect(element).toMatchSnapshot(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx index 73dbe5482573a..95a9fd8a6b84d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.tsx @@ -27,6 +27,14 @@ import { OS_TITLES, PROPERTY_TITLES, ENTRY_PROPERTY_TITLES } from '../../transla type Entry = MacosLinuxConditionEntry | WindowsConditionEntry; +const trimTextOverflow = (text: string, maxSize: number) => { + if (text.length > maxSize) { + return `${text.substr(0, maxSize)}...`; + } else { + return text; + } +}; + const getEntriesColumnDefinitions = (): Array> => [ { field: 'field', @@ -75,6 +83,13 @@ export const TrustedAppCard = memo(({ trustedApp, onDelete }: TrustedAppCardProp } /> + trimTextOverflow(trustedApp.description || '', 100), [ + trustedApp.description, + ])} + title={trustedApp.description} + /> getEntriesColumnDefinitions(), [])} diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts index e16155df6d2db..6188d427e71a2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts @@ -11,6 +11,11 @@ import { WindowsConditionEntry, } from '../../../../../common/endpoint/types'; +export const ABOUT_TRUSTED_APPS = i18n.translate('xpack.securitySolution.trustedapps.aboutInfo', { + defaultMessage: + 'Add a trusted application to improve performance or alleviate conflicts with other applications running on your hosts. Trusted applications will be applied to hosts running Endpoint Security.', +}); + export const OS_TITLES: Readonly<{ [K in TrustedApp['os']]: string }> = { windows: i18n.translate('xpack.securitySolution.trustedapps.os.windows', { defaultMessage: 'Windows', @@ -24,7 +29,7 @@ export const OS_TITLES: Readonly<{ [K in TrustedApp['os']]: string }> = { }; export const PROPERTY_TITLES: Readonly< - { [K in keyof Omit]: string } + { [K in keyof Omit]: string } > = { name: i18n.translate('xpack.securitySolution.trustedapps.trustedapp.name', { defaultMessage: 'Name', @@ -38,6 +43,9 @@ export const PROPERTY_TITLES: Readonly< created_by: i18n.translate('xpack.securitySolution.trustedapps.trustedapp.createdBy', { defaultMessage: 'Created By', }), + description: i18n.translate('xpack.securitySolution.trustedapps.trustedapp.description', { + defaultMessage: 'Description', + }), }; export const ENTRY_PROPERTY_TITLES: Readonly< diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index 53fb455e2a070..e95f834684f6e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -17,6 +17,9 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ })); describe('When on the Trusted Apps Page', () => { + const expectedAboutInfo = + 'Add a trusted application to improve performance or alleviate conflicts with other applications running on your hosts. Trusted applications will be applied to hosts running Endpoint Security.'; + let history: AppContextTestRender['history']; let coreStart: AppContextTestRender['coreStart']; let waitForAction: MiddlewareActionSpyHelper['waitForAction']; @@ -52,6 +55,11 @@ describe('When on the Trusted Apps Page', () => { expect(tableColumns).toEqual(['Name', 'OS', 'Date Created', 'Created By', 'Actions']); }); + it('should display subtitle info about trusted apps', async () => { + const { getByTestId } = render(); + expect(getByTestId('header-panel-subtitle').textContent).toEqual(expectedAboutInfo); + }); + it('should display a Add Trusted App button', async () => { const { getByTestId } = render(); const addButton = await getByTestId('trustedAppsListAddButton'); @@ -188,6 +196,12 @@ describe('When on the Trusted Apps Page', () => { afterEach(() => resolveHttpPost()); + it('should display info about Trusted Apps', async () => { + expect(renderResult.getByTestId('addTrustedAppFlyout-about').textContent).toEqual( + expectedAboutInfo + ); + }); + it('should disable the Cancel button', async () => { expect( (renderResult.getByTestId('addTrustedAppFlyout-cancelButton') as HTMLButtonElement) diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx index c3add8cd5a9df..3b5b419d0f8ad 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx @@ -18,6 +18,7 @@ import { useTrustedAppsSelector } from './hooks'; import { getCurrentLocation } from '../store/selectors'; import { TrustedAppsListPageRouteState } from '../../../../../common/endpoint/types'; import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; +import { ABOUT_TRUSTED_APPS } from './translations'; export const TrustedAppsPage = memo(() => { const history = useHistory(); @@ -71,12 +72,7 @@ export const TrustedAppsPage = memo(() => { /> } headerBackComponent={backButton} - subtitle={ - - } + subtitle={ABOUT_TRUSTED_APPS} actions={addButton} > diff --git a/x-pack/plugins/security_solution/public/network/components/ip/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/ip/__snapshots__/index.test.tsx.snap index a2c71b914b989..2e1465da73a4c 100644 --- a/x-pack/plugins/security_solution/public/network/components/ip/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/ip/__snapshots__/index.test.tsx.snap @@ -7,6 +7,7 @@ exports[`Port renders correctly against snapshot 1`] = ` eventId="abcd" fieldName="destination.ip" fieldType="ip" + truncate={true} value="10.1.2.3" /> `; diff --git a/x-pack/plugins/security_solution/public/network/components/ip/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/ip/index.test.tsx index 39ecb27606181..8afc22d799a7d 100644 --- a/x-pack/plugins/security_solution/public/network/components/ip/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/ip/index.test.tsx @@ -43,7 +43,7 @@ describe('Port', () => { ); expect( - wrapper.find('[data-test-subj="draggable-content-destination.ip"]').find('a').first().props() + wrapper.find('[data-test-subj="draggable-truncatable-content"]').find('a').first().props() .href ).toEqual('/ip/10.1.2.3/source'); }); diff --git a/x-pack/plugins/security_solution/public/network/components/ip/index.tsx b/x-pack/plugins/security_solution/public/network/components/ip/index.tsx index 21e2dd3ebc04d..701094cee88a2 100644 --- a/x-pack/plugins/security_solution/public/network/components/ip/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/ip/index.tsx @@ -30,6 +30,7 @@ export const Ip = React.memo<{ fieldName={fieldName} fieldType={IP_FIELD_TYPE} value={value} + truncate /> )); diff --git a/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.test.tsx b/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.test.tsx index 847b6a3ced554..890add4222503 100644 --- a/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.test.tsx @@ -772,7 +772,7 @@ describe('SourceDestinationIp', () => { ); - expect(wrapper.find('[data-test-subj="draggable-content-source.ip"]').first().text()).toEqual( + expect(wrapper.find('[data-test-subj="draggable-truncatable-content"]').first().text()).toEqual( '192.168.1.2' ); }); @@ -823,7 +823,7 @@ describe('SourceDestinationIp', () => { ); - expect(wrapper.find('[data-test-subj="draggable-content-source.ip"]').first().text()).toEqual( + expect(wrapper.find('[data-test-subj="draggable-truncatable-content"]').first().text()).toEqual( '192.168.1.2' ); }); @@ -874,9 +874,9 @@ describe('SourceDestinationIp', () => { ); - expect( - wrapper.find('[data-test-subj="draggable-content-destination.ip"]').first().text() - ).toEqual('10.1.2.3'); + expect(wrapper.find('[data-test-subj="draggable-truncatable-content"]').first().text()).toEqual( + '10.1.2.3' + ); }); test('it renders the expected destination IP when type is `destination`, but the length of the destinationIp and destinationPort port arrays is different', () => { @@ -925,9 +925,9 @@ describe('SourceDestinationIp', () => { ); - expect( - wrapper.find('[data-test-subj="draggable-content-destination.ip"]').first().text() - ).toEqual('10.1.2.3'); + expect(wrapper.find('[data-test-subj="draggable-truncatable-content"]').first().text()).toEqual( + '10.1.2.3' + ); }); test('it renders the expected source port when type is `source`, and both sourceIp and sourcePort are populated', () => { diff --git a/x-pack/plugins/security_solution/public/network/containers/details/index.tsx b/x-pack/plugins/security_solution/public/network/containers/details/index.tsx index 217241bdadcbb..238270107b071 100644 --- a/x-pack/plugins/security_solution/public/network/containers/details/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/details/index.tsx @@ -59,13 +59,21 @@ export const useNetworkDetails = ({ const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkDetailsRequest, setNetworkDetailsRequest] = useState({ - defaultIndex: indexNames, - docValueFields: docValueFields ?? [], - factoryQueryType: NetworkQueries.details, - filterQuery: createFilter(filterQuery), - ip, - }); + const [ + networkDetailsRequest, + setNetworkDetailsRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + docValueFields: docValueFields ?? [], + factoryQueryType: NetworkQueries.details, + filterQuery: createFilter(filterQuery), + id, + ip, + } + : null + ); const [networkDetailsResponse, setNetworkDetailsResponse] = useState({ networkDetails: {}, @@ -79,7 +87,11 @@ export const useNetworkDetails = ({ }); const networkDetailsSearch = useCallback( - (request: NetworkDetailsRequestOptions) => { + (request: NetworkDetailsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -136,18 +148,20 @@ export const useNetworkDetails = ({ useEffect(() => { setNetworkDetailsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, - ip, docValueFields: docValueFields ?? [], + factoryQueryType: NetworkQueries.details, filterQuery: createFilter(filterQuery), + id, + ip, }; if (!skip && !deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [indexNames, filterQuery, skip, ip, docValueFields]); + }, [indexNames, filterQuery, skip, ip, docValueFields, id]); useEffect(() => { networkDetailsSearch(networkDetailsRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx index dc60bb0a82ba8..aa0e607fc3c05 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx @@ -56,17 +56,24 @@ export const useNetworkKpiDns = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkKpiDnsRequest, setNetworkKpiDnsRequest] = useState({ - defaultIndex: indexNames, - factoryQueryType: NetworkKpiQueries.dns, - filterQuery: createFilter(filterQuery), - id: ID, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [ + networkKpiDnsRequest, + setNetworkKpiDnsRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.dns, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [networkKpiDnsResponse, setNetworkKpiDnsResponse] = useState({ dnsQueries: 0, @@ -80,7 +87,11 @@ export const useNetworkKpiDns = ({ }); const networkKpiDnsSearch = useCallback( - (request: NetworkKpiDnsRequestOptions) => { + (request: NetworkKpiDnsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -137,9 +148,11 @@ export const useNetworkKpiDns = ({ useEffect(() => { setNetworkKpiDnsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.dns, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx index a1727d5bb4331..9ab14602140f7 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx @@ -56,19 +56,24 @@ export const useNetworkKpiNetworkEvents = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkKpiNetworkEventsRequest, setNetworkKpiNetworkEventsRequest] = useState< - NetworkKpiNetworkEventsRequestOptions - >({ - defaultIndex: indexNames, - factoryQueryType: NetworkKpiQueries.networkEvents, - filterQuery: createFilter(filterQuery), - id: ID, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [ + networkKpiNetworkEventsRequest, + setNetworkKpiNetworkEventsRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.networkEvents, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [networkKpiNetworkEventsResponse, setNetworkKpiNetworkEventsResponse] = useState< NetworkKpiNetworkEventsArgs @@ -84,7 +89,11 @@ export const useNetworkKpiNetworkEvents = ({ }); const networkKpiNetworkEventsSearch = useCallback( - (request: NetworkKpiNetworkEventsRequestOptions) => { + (request: NetworkKpiNetworkEventsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -144,9 +153,11 @@ export const useNetworkKpiNetworkEvents = ({ useEffect(() => { setNetworkKpiNetworkEventsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.networkEvents, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx index bcbe485e82163..bc32395c100f2 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx @@ -56,19 +56,24 @@ export const useNetworkKpiTlsHandshakes = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkKpiTlsHandshakesRequest, setNetworkKpiTlsHandshakesRequest] = useState< - NetworkKpiTlsHandshakesRequestOptions - >({ - defaultIndex: indexNames, - factoryQueryType: NetworkKpiQueries.tlsHandshakes, - filterQuery: createFilter(filterQuery), - id: ID, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [ + networkKpiTlsHandshakesRequest, + setNetworkKpiTlsHandshakesRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.tlsHandshakes, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [networkKpiTlsHandshakesResponse, setNetworkKpiTlsHandshakesResponse] = useState< NetworkKpiTlsHandshakesArgs @@ -84,7 +89,10 @@ export const useNetworkKpiTlsHandshakes = ({ }); const networkKpiTlsHandshakesSearch = useCallback( - (request: NetworkKpiTlsHandshakesRequestOptions) => { + (request: NetworkKpiTlsHandshakesRequestOptions | null) => { + if (request == null) { + return; + } let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -144,9 +152,11 @@ export const useNetworkKpiTlsHandshakes = ({ useEffect(() => { setNetworkKpiTlsHandshakesRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.tlsHandshakes, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx index a4fdefc93fe75..256953efac146 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx @@ -56,19 +56,24 @@ export const useNetworkKpiUniqueFlows = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkKpiUniqueFlowsRequest, setNetworkKpiUniqueFlowsRequest] = useState< - NetworkKpiUniqueFlowsRequestOptions - >({ - defaultIndex: indexNames, - factoryQueryType: NetworkKpiQueries.uniqueFlows, - filterQuery: createFilter(filterQuery), - id: ID, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [ + networkKpiUniqueFlowsRequest, + setNetworkKpiUniqueFlowsRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.uniqueFlows, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [networkKpiUniqueFlowsResponse, setNetworkKpiUniqueFlowsResponse] = useState< NetworkKpiUniqueFlowsArgs @@ -84,7 +89,11 @@ export const useNetworkKpiUniqueFlows = ({ }); const networkKpiUniqueFlowsSearch = useCallback( - (request: NetworkKpiUniqueFlowsRequestOptions) => { + (request: NetworkKpiUniqueFlowsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -144,9 +153,11 @@ export const useNetworkKpiUniqueFlows = ({ useEffect(() => { setNetworkKpiUniqueFlowsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.uniqueFlows, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx index 5e9d829077f23..54307eb7c4c1d 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx @@ -60,19 +60,24 @@ export const useNetworkKpiUniquePrivateIps = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkKpiUniquePrivateIpsRequest, setNetworkKpiUniquePrivateIpsRequest] = useState< - NetworkKpiUniquePrivateIpsRequestOptions - >({ - defaultIndex: indexNames, - factoryQueryType: NetworkKpiQueries.uniquePrivateIps, - filterQuery: createFilter(filterQuery), - id: ID, - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [ + networkKpiUniquePrivateIpsRequest, + setNetworkKpiUniquePrivateIpsRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.uniquePrivateIps, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [networkKpiUniquePrivateIpsResponse, setNetworkKpiUniquePrivateIpsResponse] = useState< NetworkKpiUniquePrivateIpsArgs @@ -91,7 +96,11 @@ export const useNetworkKpiUniquePrivateIps = ({ }); const networkKpiUniquePrivateIpsSearch = useCallback( - (request: NetworkKpiUniquePrivateIpsRequestOptions) => { + (request: NetworkKpiUniquePrivateIpsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -155,9 +164,11 @@ export const useNetworkKpiUniquePrivateIps = ({ useEffect(() => { setNetworkKpiUniquePrivateIpsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkKpiQueries.uniquePrivateIps, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx index c49aa6a415904..576fc810e9c5f 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx @@ -61,7 +61,6 @@ interface UseNetworkDns { export const useNetworkDns = ({ endDate, filterQuery, - id = ID, indexNames, skip, startDate, @@ -74,26 +73,37 @@ export const useNetworkDns = ({ const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkDnsRequest, setNetworkDnsRequest] = useState({ - defaultIndex: indexNames, - factoryQueryType: NetworkQueries.dns, - filterQuery: createFilter(filterQuery), - isPtrIncluded, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - }); + const [networkDnsRequest, setNetworkDnsRequest] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkQueries.dns, + filterQuery: createFilter(filterQuery), + id: ID, + isPtrIncluded, + pagination: generateTablePaginationOptions(activePage, limit), + sort, + timerange: { + interval: '12h', + from: startDate ? startDate : '', + to: endDate ? endDate : new Date(Date.now()).toISOString(), + }, + } + : null + ); const wrappedLoadMore = useCallback( (newActivePage: number) => { - setNetworkDnsRequest((prevRequest) => ({ - ...prevRequest, - pagination: generateTablePaginationOptions(newActivePage, limit), - })); + setNetworkDnsRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); }, [limit] ); @@ -118,7 +128,11 @@ export const useNetworkDns = ({ }); const networkDnsSearch = useCallback( - (request: NetworkDnsRequestOptions) => { + (request: NetworkDnsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -178,10 +192,12 @@ export const useNetworkDns = ({ useEffect(() => { setNetworkDnsRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, isPtrIncluded, + factoryQueryType: NetworkQueries.dns, filterQuery: createFilter(filterQuery), + id: ID, pagination: generateTablePaginationOptions(activePage, limit), sort, timerange: { diff --git a/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx index ec4ac39599351..12c3cc481cfc1 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx @@ -76,23 +76,32 @@ export const useNetworkHttp = ({ const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkHttpRequest, setHostRequest] = useState({ - defaultIndex: indexNames, - factoryQueryType: NetworkQueries.http, - filterQuery: createFilter(filterQuery), - ip, - pagination: generateTablePaginationOptions(activePage, limit), - sort: sort as SortField, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - }); + const [networkHttpRequest, setHostRequest] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkQueries.http, + filterQuery: createFilter(filterQuery), + id: ID, + ip, + pagination: generateTablePaginationOptions(activePage, limit), + sort: sort as SortField, + timerange: { + interval: '12h', + from: startDate ? startDate : '', + to: endDate ? endDate : new Date(Date.now()).toISOString(), + }, + } + : null + ); const wrappedLoadMore = useCallback( (newActivePage: number) => { setHostRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + return { ...prevRequest, pagination: generateTablePaginationOptions(newActivePage, limit), @@ -121,7 +130,11 @@ export const useNetworkHttp = ({ }); const networkHttpSearch = useCallback( - (request: NetworkHttpRequestOptions) => { + (request: NetworkHttpRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -180,9 +193,11 @@ export const useNetworkHttp = ({ useEffect(() => { setHostRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkQueries.http, filterQuery: createFilter(filterQuery), + id: ID, pagination: generateTablePaginationOptions(activePage, limit), sort: sort as SortField, timerange: { diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx index 2d75de138a88c..0b864d66842d1 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx @@ -5,7 +5,7 @@ */ import { noop } from 'lodash/fp'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import deepEqual from 'fast-deep-equal'; import { ESTermQuery } from '../../../../common/typed_json'; @@ -73,27 +73,42 @@ export const useNetworkTopCountries = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); + const queryId = useMemo(() => `${ID}-${flowTarget}`, [flowTarget]); - const [networkTopCountriesRequest, setHostRequest] = useState({ - defaultIndex: indexNames, - factoryQueryType: NetworkQueries.topCountries, - filterQuery: createFilter(filterQuery), - flowTarget, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - }); + const [ + networkTopCountriesRequest, + setHostRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkQueries.topCountries, + filterQuery: createFilter(filterQuery), + flowTarget, + id: queryId, + pagination: generateTablePaginationOptions(activePage, limit), + sort, + timerange: { + interval: '12h', + from: startDate ? startDate : '', + to: endDate ? endDate : new Date(Date.now()).toISOString(), + }, + } + : null + ); const wrappedLoadMore = useCallback( (newActivePage: number) => { - setHostRequest((prevRequest) => ({ - ...prevRequest, - pagination: generateTablePaginationOptions(newActivePage, limit), - })); + setHostRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); }, [limit] ); @@ -102,7 +117,7 @@ export const useNetworkTopCountries = ({ NetworkTopCountriesArgs >({ networkTopCountries: [], - id: `${ID}-${flowTarget}`, + id: queryId, inspect: { dsl: [], response: [], @@ -119,7 +134,11 @@ export const useNetworkTopCountries = ({ }); const networkTopCountriesSearch = useCallback( - (request: NetworkTopCountriesRequestOptions) => { + (request: NetworkTopCountriesRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -178,9 +197,12 @@ export const useNetworkTopCountries = ({ useEffect(() => { setHostRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkQueries.topCountries, filterQuery: createFilter(filterQuery), + flowTarget, + id: queryId, pagination: generateTablePaginationOptions(activePage, limit), sort, timerange: { @@ -194,7 +216,18 @@ export const useNetworkTopCountries = ({ } return prevRequest; }); - }, [activePage, indexNames, endDate, filterQuery, limit, startDate, sort, skip]); + }, [ + activePage, + indexNames, + endDate, + filterQuery, + limit, + startDate, + sort, + skip, + flowTarget, + queryId, + ]); useEffect(() => { networkTopCountriesSearch(networkTopCountriesRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx index 328bb5aabcbb8..c68ad2422c514 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx @@ -74,26 +74,40 @@ export const useNetworkTopNFlow = ({ const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkTopNFlowRequest, setTopNFlowRequest] = useState({ - defaultIndex: indexNames, - factoryQueryType: NetworkQueries.topNFlow, - filterQuery: createFilter(filterQuery), - flowTarget, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - }); + const [ + networkTopNFlowRequest, + setTopNFlowRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkQueries.topNFlow, + filterQuery: createFilter(filterQuery), + flowTarget, + id: ID, + pagination: generateTablePaginationOptions(activePage, limit), + sort, + timerange: { + interval: '12h', + from: startDate ? startDate : '', + to: endDate ? endDate : new Date(Date.now()).toISOString(), + }, + } + : null + ); const wrappedLoadMore = useCallback( (newActivePage: number) => { - setTopNFlowRequest((prevRequest) => ({ - ...prevRequest, - pagination: generateTablePaginationOptions(newActivePage, limit), - })); + setTopNFlowRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); }, [limit] ); @@ -117,7 +131,11 @@ export const useNetworkTopNFlow = ({ }); const networkTopNFlowSearch = useCallback( - (request: NetworkTopNFlowRequestOptions) => { + (request: NetworkTopNFlowRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -176,9 +194,12 @@ export const useNetworkTopNFlow = ({ useEffect(() => { setTopNFlowRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkQueries.topNFlow, filterQuery: createFilter(filterQuery), + flowTarget, + id: ID, pagination: generateTablePaginationOptions(activePage, limit), timerange: { interval: '12h', @@ -192,7 +213,7 @@ export const useNetworkTopNFlow = ({ } return prevRequest; }); - }, [activePage, indexNames, endDate, filterQuery, limit, startDate, sort, skip]); + }, [activePage, indexNames, endDate, filterQuery, limit, startDate, sort, skip, flowTarget]); useEffect(() => { networkTopNFlowSearch(networkTopNFlowRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx b/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx index ddea2914a1bbb..09ade9c1bd885 100644 --- a/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx @@ -75,28 +75,38 @@ export const useNetworkTls = ({ const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [networkTlsRequest, setHostRequest] = useState({ - defaultIndex: indexNames, - factoryQueryType: NetworkQueries.tls, - filterQuery: createFilter(filterQuery), - flowTarget, - id, - ip, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - }); + const [networkTlsRequest, setHostRequest] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkQueries.tls, + filterQuery: createFilter(filterQuery), + flowTarget, + id, + ip, + pagination: generateTablePaginationOptions(activePage, limit), + sort, + timerange: { + interval: '12h', + from: startDate ? startDate : '', + to: endDate ? endDate : new Date(Date.now()).toISOString(), + }, + } + : null + ); const wrappedLoadMore = useCallback( (newActivePage: number) => { - setHostRequest((prevRequest) => ({ - ...prevRequest, - pagination: generateTablePaginationOptions(newActivePage, limit), - })); + setHostRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); }, [limit] ); @@ -120,7 +130,11 @@ export const useNetworkTls = ({ }); const networkTlsSearch = useCallback( - (request: NetworkTlsRequestOptions) => { + (request: NetworkTlsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -176,9 +190,13 @@ export const useNetworkTls = ({ useEffect(() => { setHostRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkQueries.tls, filterQuery: createFilter(filterQuery), + flowTarget, + id, + ip, pagination: generateTablePaginationOptions(activePage, limit), timerange: { interval: '12h', @@ -192,7 +210,19 @@ export const useNetworkTls = ({ } return prevRequest; }); - }, [activePage, indexNames, endDate, filterQuery, limit, startDate, sort, skip]); + }, [ + activePage, + indexNames, + endDate, + filterQuery, + limit, + startDate, + sort, + skip, + flowTarget, + ip, + id, + ]); useEffect(() => { networkTlsSearch(networkTlsRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/users/index.tsx b/x-pack/plugins/security_solution/public/network/containers/users/index.tsx index 5bca8d773c2f6..2e83c9866c59a 100644 --- a/x-pack/plugins/security_solution/public/network/containers/users/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/users/index.tsx @@ -73,27 +73,38 @@ export const useNetworkUsers = ({ const defaultIndex = uiSettings.get(DEFAULT_INDEX_KEY); const [loading, setLoading] = useState(false); - const [networkUsersRequest, setNetworkUsersRequest] = useState({ - defaultIndex, - factoryQueryType: NetworkQueries.users, - filterQuery: createFilter(filterQuery), - flowTarget, - ip, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - }); + const [networkUsersRequest, setNetworkUsersRequest] = useState( + !skip + ? { + defaultIndex, + factoryQueryType: NetworkQueries.users, + filterQuery: createFilter(filterQuery), + flowTarget, + id, + ip, + pagination: generateTablePaginationOptions(activePage, limit), + sort, + timerange: { + interval: '12h', + from: startDate ? startDate : '', + to: endDate ? endDate : new Date(Date.now()).toISOString(), + }, + } + : null + ); const wrappedLoadMore = useCallback( (newActivePage: number) => { - setNetworkUsersRequest((prevRequest) => ({ - ...prevRequest, - pagination: generateTablePaginationOptions(newActivePage, limit), - })); + setNetworkUsersRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); }, [limit] ); @@ -117,7 +128,11 @@ export const useNetworkUsers = ({ }); const networkUsersSearch = useCallback( - (request: NetworkUsersRequestOptions) => { + (request: NetworkUsersRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -176,9 +191,13 @@ export const useNetworkUsers = ({ useEffect(() => { setNetworkUsersRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), + id, + ip, defaultIndex, + factoryQueryType: NetworkQueries.users, filterQuery: createFilter(filterQuery), + flowTarget, pagination: generateTablePaginationOptions(activePage, limit), sort, timerange: { @@ -192,7 +211,19 @@ export const useNetworkUsers = ({ } return prevRequest; }); - }, [activePage, defaultIndex, endDate, filterQuery, limit, startDate, sort, skip]); + }, [ + activePage, + defaultIndex, + endDate, + filterQuery, + limit, + startDate, + sort, + skip, + ip, + flowTarget, + id, + ]); useEffect(() => { networkUsersSearch(networkUsersRequest); diff --git a/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx b/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx index 8b73253157edc..98c70908ca5d7 100644 --- a/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx @@ -33,7 +33,7 @@ export const EndpointNotice = memo<{ onDismiss: () => void }>(({ onDismiss }) => } @@ -49,7 +49,7 @@ export const EndpointNotice = memo<{ onDismiss: () => void }>(({ onDismiss }) => diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx index 2112350278e8e..2d4b8538a5d53 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx @@ -53,7 +53,7 @@ describe('OverviewEmpty', () => { description: 'Protect your hosts with threat prevention, detection, and deep security data visibility.', fill: false, - label: 'Add Elastic Endpoint Security', + label: 'Add Endpoint Security', onClick: undefined, url: '/app/home#/tutorial_directory/security', }, diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx index 1d2c6889213f1..9b07d6a53537d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx @@ -63,7 +63,7 @@ const OverviewEmptyComponent: React.FC = () => { <> {i18nCommon.EMPTY_ACTION_SECONDARY} @@ -80,7 +80,7 @@ const OverviewEmptyComponent: React.FC = () => { <> {i18nCommon.EMPTY_ACTION_SECONDARY} diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_host_stats/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/overview/components/overview_host_stats/__snapshots__/index.test.tsx.snap index 23732e88ba1f9..2e70d1a7f8a5b 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_host_stats/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/overview/components/overview_host_stats/__snapshots__/index.test.tsx.snap @@ -254,7 +254,7 @@ exports[`Overview Host Stat Data rendering it renders the default OverviewHostSt > diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_host_stats/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_host_stats/index.tsx index ef595476d8a94..d2865c4385152 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_host_stats/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_host_stats/index.tsx @@ -209,7 +209,7 @@ const hostStatGroups: StatGroup[] = [ name: ( ), statIds: [ diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx index 946cd33088a45..e53915bc05fdf 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx @@ -55,16 +55,21 @@ export const useHostOverview = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [overviewHostRequest, setHostRequest] = useState({ - defaultIndex: indexNames, - factoryQueryType: HostsQueries.overview, - filterQuery: createFilter(filterQuery), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [overviewHostRequest, setHostRequest] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: HostsQueries.overview, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [overviewHostResponse, setHostOverviewResponse] = useState({ overviewHost: {}, @@ -78,7 +83,11 @@ export const useHostOverview = ({ }); const overviewHostSearch = useCallback( - (request: HostOverviewRequestOptions) => { + (request: HostOverviewRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -135,9 +144,11 @@ export const useHostOverview = ({ useEffect(() => { setHostRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: HostsQueries.overview, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx index 588fb1f08ef6f..96711917ca393 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx @@ -55,16 +55,24 @@ export const useNetworkOverview = ({ const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [overviewNetworkRequest, setNetworkRequest] = useState({ - defaultIndex: indexNames, - factoryQueryType: NetworkQueries.overview, - filterQuery: createFilter(filterQuery), - timerange: { - interval: '12h', - from: startDate, - to: endDate, - }, - }); + const [ + overviewNetworkRequest, + setNetworkRequest, + ] = useState( + !skip + ? { + defaultIndex: indexNames, + factoryQueryType: NetworkQueries.overview, + filterQuery: createFilter(filterQuery), + id: ID, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + } + : null + ); const [overviewNetworkResponse, setNetworkOverviewResponse] = useState({ overviewNetwork: {}, @@ -78,7 +86,11 @@ export const useNetworkOverview = ({ }); const overviewNetworkSearch = useCallback( - (request: NetworkOverviewRequestOptions) => { + (request: NetworkOverviewRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -135,9 +147,11 @@ export const useNetworkOverview = ({ useEffect(() => { setNetworkRequest((prevRequest) => { const myRequest = { - ...prevRequest, + ...(prevRequest ?? {}), defaultIndex: indexNames, + factoryQueryType: NetworkQueries.overview, filterQuery: createFilter(filterQuery), + id: ID, timerange: { interval: '12h', from: startDate, diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts index ae07104fa0e22..66dc7b98168ea 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts @@ -50,7 +50,7 @@ export function dataAccessLayerFactory( after?: string ): Promise { return context.services.http.post('/api/endpoint/resolver/events', { - query: { afterEvent: after }, + query: { afterEvent: after, limit: 25 }, body: JSON.stringify({ filter: `process.entity_id:"${entityID}" and event.category:"${category}"`, }), diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_node_with_paginated_related_events.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_node_with_paginated_related_events.ts new file mode 100644 index 0000000000000..01477ff16868e --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_node_with_paginated_related_events.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 { DataAccessLayer } from '../../types'; +import { mockTreeWithOneNodeAndTwoPagesOfRelatedEvents } from '../../mocks/resolver_tree'; +import { + ResolverRelatedEvents, + ResolverTree, + ResolverEntityIndex, + SafeResolverEvent, +} from '../../../../common/endpoint/types'; +import * as eventModel from '../../../../common/endpoint/models/event'; + +interface Metadata { + /** + * The `_id` of the document being analyzed. + */ + databaseDocumentID: string; + /** + * A record of entityIDs to be used in tests assertions. + */ + entityIDs: { + /** + * The entityID of the node related to the document being analyzed. + */ + origin: 'origin'; + }; +} +export function oneNodeWithPaginatedEvents(): { + dataAccessLayer: DataAccessLayer; + metadata: Metadata; +} { + const metadata: Metadata = { + databaseDocumentID: '_id', + entityIDs: { origin: 'origin' }, + }; + const tree = mockTreeWithOneNodeAndTwoPagesOfRelatedEvents({ + originID: metadata.entityIDs.origin, + }); + + return { + metadata, + dataAccessLayer: { + /** + * Fetch related events for an entity ID + */ + async relatedEvents(entityID: string): Promise { + /** + * Respond with the mocked related events when the origin's related events are fetched. + **/ + const events = entityID === metadata.entityIDs.origin ? tree.relatedEvents.events : []; + + return { + entityID, + events, + nextEvent: null, + }; + }, + + /** + * If called with an "after" cursor, return the 2nd page, else return the first. + */ + async eventsWithEntityIDAndCategory( + entityID: string, + category: string, + after?: string + ): Promise<{ events: SafeResolverEvent[]; nextEvent: string | null }> { + let events: SafeResolverEvent[] = []; + const eventsOfCategory = tree.relatedEvents.events.filter( + (event) => event.event?.category === category + ); + if (after === undefined) { + events = eventsOfCategory.slice(0, 25); + } else { + events = eventsOfCategory.slice(25); + } + return { + events, + nextEvent: typeof after === 'undefined' ? 'firstEventPage2' : null, + }; + }, + + /** + * Any of the origin's related events by event.id + */ + async event(eventID: string): Promise { + return ( + tree.relatedEvents.events.find((event) => eventModel.eventID(event) === eventID) ?? null + ); + }, + + /** + * Fetch a ResolverTree for a entityID + */ + async resolverTree(): Promise { + return tree; + }, + + /** + * Get entities matching a document. + */ + async entities(): Promise { + return [{ entity_id: metadata.entityIDs.origin }]; + }, + }, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts b/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts index 5b851d588543d..50cc7eaa378ea 100644 --- a/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts +++ b/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts @@ -8,6 +8,47 @@ import { mockEndpointEvent } from './endpoint_event'; import { ResolverTree, SafeResolverEvent } from '../../../common/endpoint/types'; import * as eventModel from '../../../common/endpoint/models/event'; +export function mockTreeWithOneNodeAndTwoPagesOfRelatedEvents({ + originID, +}: { + originID: string; +}): ResolverTree { + const originEvent: SafeResolverEvent = mockEndpointEvent({ + entityID: originID, + processName: 'c', + parentEntityID: undefined, + timestamp: 1600863932318, + }); + const events = []; + // page size is currently 25 + const eventsToGenerate = 30; + for (let i = 0; i < eventsToGenerate; i++) { + const newEvent = mockEndpointEvent({ + entityID: originID, + eventID: `test-${i}`, + eventType: 'access', + eventCategory: 'registry', + timestamp: 1600863932318, + }); + events.push(newEvent); + } + return { + entityID: originID, + children: { + childNodes: [], + nextChild: null, + }, + ancestry: { + nextAncestor: null, + ancestors: [], + }, + lifecycle: [originEvent], + relatedEvents: { events, nextEvent: null }, + relatedAlerts: { alerts: [], nextAlert: null }, + stats: { events: { total: eventsToGenerate, byCategory: {} }, totalAlerts: 0 }, + }; +} + export function mockTreeWith2AncestorsAndNoChildren({ originID, firstAncestorID, diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts index 40a103ac6add7..35a1e14a66625 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts @@ -34,6 +34,28 @@ interface AppRequestedResolverData { readonly payload: TreeFetcherParameters; } +interface UserRequestedAdditionalRelatedEvents { + readonly type: 'userRequestedAdditionalRelatedEvents'; +} + +interface ServerFailedToReturnNodeEventsInCategory { + readonly type: 'serverFailedToReturnNodeEventsInCategory'; + readonly payload: { + /** + * The cursor, if any, that can be used to retrieve more events. + */ + cursor: string | null; + /** + * The nodeID that `events` are related to. + */ + nodeID: string; + /** + * The category that `events` have in common. + */ + eventCategory: string; + }; +} + interface ServerFailedToReturnResolverData { readonly type: 'serverFailedToReturnResolverData'; /** @@ -101,4 +123,6 @@ export type DataAction = | ServerReturnedRelatedEventData | ServerReturnedNodeEventsInCategory | AppRequestedResolverData + | UserRequestedAdditionalRelatedEvents + | ServerFailedToReturnNodeEventsInCategory | AppAbortedResolverDataRequest; diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/node_events_in_category_model.ts b/x-pack/plugins/security_solution/public/resolver/store/data/node_events_in_category_model.ts index b834671458d6b..d10edf64dcd35 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/node_events_in_category_model.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/node_events_in_category_model.ts @@ -39,6 +39,7 @@ export function updatedWith( eventCategory: first.eventCategory, events: [...first.events, ...second.events], cursor: second.cursor, + lastCursorRequested: null, }; } else { return undefined; diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts index 7760bda19ff07..b91cf5b59ce21 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts @@ -19,7 +19,7 @@ const initialState: DataState = { relatedEvents: new Map(), resolverComponentInstanceID: undefined, }; - +/* eslint-disable complexity */ export const dataReducer: Reducer = (state = initialState, action) => { if (action.type === 'appReceivedNewExternalProperties') { const nextState: DataState = { @@ -157,6 +157,32 @@ export const dataReducer: Reducer = (state = initialS // the action is stale, ignore it return state; } + } else if (action.type === 'userRequestedAdditionalRelatedEvents') { + if (state.nodeEventsInCategory) { + const nextState: DataState = { + ...state, + nodeEventsInCategory: { + ...state.nodeEventsInCategory, + lastCursorRequested: state.nodeEventsInCategory?.cursor, + }, + }; + return nextState; + } else { + return state; + } + } else if (action.type === 'serverFailedToReturnNodeEventsInCategory') { + if (state.nodeEventsInCategory) { + const nextState: DataState = { + ...state, + nodeEventsInCategory: { + ...state.nodeEventsInCategory, + error: true, + }, + }; + return nextState; + } else { + return state; + } } else if (action.type === 'appRequestedCurrentRelatedEventData') { const nextState: DataState = { ...state, diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 8e06b26b5c316..5eb920ca835f4 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -20,7 +20,7 @@ import { import { isGraphableProcess, isTerminatedProcess } from '../../models/process_event'; import * as indexedProcessTreeModel from '../../models/indexed_process_tree'; import * as eventModel from '../../../../common/endpoint/models/event'; - +import * as nodeEventsInCategoryModel from './node_events_in_category_model'; import { ResolverTree, ResolverNodeStats, @@ -665,3 +665,74 @@ export const panelViewAndParameters = createSelector( export const nodeEventsInCategory = (state: DataState) => { return state.nodeEventsInCategory?.events ?? []; }; + +export const lastRelatedEventResponseContainsCursor = createSelector( + (state: DataState) => state.nodeEventsInCategory, + panelViewAndParameters, + /* eslint-disable-next-line no-shadow */ + function (nodeEventsInCategory, panelViewAndParameters) { + if ( + nodeEventsInCategory !== undefined && + nodeEventsInCategoryModel.isRelevantToPanelViewAndParameters( + nodeEventsInCategory, + panelViewAndParameters + ) + ) { + return nodeEventsInCategory.cursor !== null; + } else { + return false; + } + } +); + +export const hadErrorLoadingNodeEventsInCategory = createSelector( + (state: DataState) => state.nodeEventsInCategory, + panelViewAndParameters, + /* eslint-disable-next-line no-shadow */ + function (nodeEventsInCategory, panelViewAndParameters) { + if ( + nodeEventsInCategory !== undefined && + nodeEventsInCategoryModel.isRelevantToPanelViewAndParameters( + nodeEventsInCategory, + panelViewAndParameters + ) + ) { + return nodeEventsInCategory && nodeEventsInCategory.error === true; + } else { + return false; + } + } +); + +export const isLoadingNodeEventsInCategory = createSelector( + (state: DataState) => state.nodeEventsInCategory, + panelViewAndParameters, + /* eslint-disable-next-line no-shadow */ + function (nodeEventsInCategory, panelViewAndParameters) { + const { panelView } = panelViewAndParameters; + return panelView === 'nodeEventsInCategory' && nodeEventsInCategory === undefined; + } +); + +export const isLoadingMoreNodeEventsInCategory = createSelector( + (state: DataState) => state.nodeEventsInCategory, + panelViewAndParameters, + /* eslint-disable-next-line no-shadow */ + function (nodeEventsInCategory, panelViewAndParameters) { + if ( + nodeEventsInCategory !== undefined && + nodeEventsInCategoryModel.isRelevantToPanelViewAndParameters( + nodeEventsInCategory, + panelViewAndParameters + ) + ) { + return ( + nodeEventsInCategory && + nodeEventsInCategory.lastCursorRequested !== null && + nodeEventsInCategory.cursor === nodeEventsInCategory.lastCursorRequested + ); + } else { + return false; + } + } +); diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts index 0b0a469a047c3..6d054a20b856d 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts @@ -6,7 +6,7 @@ import { Dispatch, MiddlewareAPI } from 'redux'; import { isEqual } from 'lodash'; -import { ResolverPaginatedEvents, ResolverRelatedEvents } from '../../../../common/endpoint/types'; +import { ResolverPaginatedEvents } from '../../../../common/endpoint/types'; import { ResolverState, DataAccessLayer, PanelViewAndParameters } from '../../types'; import * as selectors from '../selectors'; @@ -25,46 +25,69 @@ export function RelatedEventsFetcher( const state = api.getState(); const newParams = selectors.panelViewAndParameters(state); + const isLoadingMoreEvents = selectors.isLoadingMoreNodeEventsInCategory(state); const oldParams = last; // Update this each time before fetching data (or even if we don't fetch data) so that subsequent actions that call this (concurrently) will have up to date info. last = newParams; + async function fetchEvents({ + nodeID, + eventCategory, + cursor, + }: { + nodeID: string; + eventCategory: string; + cursor: string | null; + }) { + let result: ResolverPaginatedEvents | null = null; + try { + if (cursor) { + result = await dataAccessLayer.eventsWithEntityIDAndCategory( + nodeID, + eventCategory, + cursor + ); + } else { + result = await dataAccessLayer.eventsWithEntityIDAndCategory(nodeID, eventCategory); + } + } catch (error) { + api.dispatch({ + type: 'serverFailedToReturnNodeEventsInCategory', + payload: { + nodeID, + eventCategory, + cursor, + }, + }); + } + + if (result) { + api.dispatch({ + type: 'serverReturnedNodeEventsInCategory', + payload: { + events: result.events, + eventCategory, + cursor: result.nextEvent, + nodeID, + }, + }); + } + } + // If the panel view params have changed and the current panel view is either `nodeEventsInCategory` or `eventDetail`, then fetch the related events for that nodeID. if (!isEqual(newParams, oldParams)) { if (newParams.panelView === 'nodeEventsInCategory') { const nodeID = newParams.panelParameters.nodeID; - - const result: - | ResolverPaginatedEvents - | undefined = await dataAccessLayer.eventsWithEntityIDAndCategory( + fetchEvents({ nodeID, - newParams.panelParameters.eventCategory - ); - - if (result) { - api.dispatch({ - type: 'serverReturnedNodeEventsInCategory', - payload: { - events: result.events, - eventCategory: newParams.panelParameters.eventCategory, - cursor: result.nextEvent, - nodeID, - }, - }); - } - } else if (newParams.panelView === 'eventDetail') { - const nodeID = newParams.panelParameters.nodeID; - - const result: ResolverRelatedEvents | undefined = await dataAccessLayer.relatedEvents( - nodeID - ); - - if (result) { - api.dispatch({ - type: 'serverReturnedRelatedEventData', - payload: result, - }); - } + eventCategory: newParams.panelParameters.eventCategory, + cursor: null, + }); + } + } else if (isLoadingMoreEvents) { + const nodeEventsInCategory = state.data.nodeEventsInCategory; + if (nodeEventsInCategory !== undefined) { + fetchEvents(nodeEventsInCategory); } } }; diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 8809b4b15a3fb..e805c16ed9c28 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -364,6 +364,37 @@ export const nodeEventsInCategory = composeSelectors( dataSelectors.nodeEventsInCategory ); +/** + * Flag used to show a Load More Data button in the nodeEventsOfType panel view. + */ +export const lastRelatedEventResponseContainsCursor = composeSelectors( + dataStateSelector, + dataSelectors.lastRelatedEventResponseContainsCursor +); + +/** + * Flag to show an error message when loading more related events. + */ +export const hadErrorLoadingNodeEventsInCategory = composeSelectors( + dataStateSelector, + dataSelectors.hadErrorLoadingNodeEventsInCategory +); +/** + * Flag used to show a loading view for the initial loading of related events. + */ +export const isLoadingNodeEventsInCategory = composeSelectors( + dataStateSelector, + dataSelectors.isLoadingNodeEventsInCategory +); + +/** + * Flag used to show a loading state for any additional related events. + */ +export const isLoadingMoreNodeEventsInCategory = composeSelectors( + dataStateSelector, + dataSelectors.isLoadingMoreNodeEventsInCategory +); + /** * Calls the `secondSelector` with the result of the `selector`. Use this when re-exporting a * concern-specific selector. `selector` should return the concern-specific state. diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 9f440d7094987..5007b7cffa5c6 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -227,6 +227,17 @@ export interface NodeEventsInCategoryState { * The cursor, if any, that can be used to retrieve more events. */ cursor: null | string; + + /** + * The cursor, if any, that was last used to fetch additional related events. + */ + + lastCursorRequested?: null | string; + + /** + * Flag for showing an error message when fetching additional related events. + */ + error?: boolean; } /** diff --git a/x-pack/plugins/security_solution/public/resolver/view/limit_warnings.tsx b/x-pack/plugins/security_solution/public/resolver/view/limit_warnings.tsx index 1686fc9de2d7a..e9c666824be63 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/limit_warnings.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/limit_warnings.tsx @@ -7,7 +7,7 @@ /* eslint-disable react/display-name */ import React from 'react'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage } from '@kbn/i18n/react'; import { LimitWarningsEuiCallOut } from './styles'; const lineageLimitMessage = ( diff --git a/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx index d5c0242535010..0664608d73c27 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history'; - +import copy from 'copy-to-clipboard'; import { noAncestorsTwoChildrenWithRelatedEventsOnOrigin } from '../data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin'; import { Simulator } from '../test_utilities/simulator'; // Extend jest with a custom matcher @@ -14,6 +14,10 @@ import { urlSearch } from '../test_utilities/url_search'; // the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances const resolverComponentInstanceID = 'resolverComponentInstanceID'; +jest.mock('copy-to-clipboard', () => { + return jest.fn(); +}); + describe(`Resolver: when analyzing a tree with no ancestors and two children and two related registry event on the origin, and when the component instance ID is ${resolverComponentInstanceID}`, () => { /** * Get (or lazily create and get) the simulator. @@ -112,6 +116,16 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and wordBreaks: 2, }); }); + it('should allow all node details to be copied', async () => { + const copyableFields = await simulator().resolve('resolver:panel:copyable-field'); + + copyableFields?.map((copyableField) => { + copyableField.simulate('mouseenter'); + simulator().testSubject('clipboard').last().simulate('click'); + expect(copy).toHaveBeenLastCalledWith(copyableField.text(), expect.any(Object)); + copyableField.simulate('mouseleave'); + }); + }); }); const queryStringWithFirstChildSelected = urlSearch(resolverComponentInstanceID, { @@ -158,6 +172,19 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and ).toYieldEqualTo(3); }); + it('should be able to copy the timestamps for all 3 nodes', async () => { + const copyableFields = await simulator().resolve('resolver:panel:copyable-field'); + + expect(copyableFields?.length).toBe(3); + + copyableFields?.map((copyableField) => { + copyableField.simulate('mouseenter'); + simulator().testSubject('clipboard').last().simulate('click'); + expect(copy).toHaveBeenLastCalledWith(copyableField.text(), expect.any(Object)); + copyableField.simulate('mouseleave'); + }); + }); + describe('when there is an item in the node list and its text has been clicked', () => { beforeEach(async () => { const nodeLinks = await simulator().resolve('resolver:node-list:node-link:title'); @@ -239,6 +266,34 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and ) ).toYieldEqualTo(2); }); + describe('and when the first event link is clicked', () => { + beforeEach(async () => { + const link = await simulator().resolve( + 'resolver:panel:node-events-in-category:event-link' + ); + const first = link?.first(); + expect(first).toBeTruthy(); + + if (first) { + first.simulate('click', { button: 0 }); + } + }); + it('should show the event detail view', async () => { + await expect( + simulator().map(() => simulator().testSubject('resolver:panel:event-detail').length) + ).toYieldEqualTo(1); + }); + it('should allow all fields to be copied', async () => { + const copyableFields = await simulator().resolve('resolver:panel:copyable-field'); + + copyableFields?.map((copyableField) => { + copyableField.simulate('mouseenter'); + simulator().testSubject('clipboard').last().simulate('click'); + expect(copy).toHaveBeenLastCalledWith(copyableField.text(), expect.any(Object)); + copyableField.simulate('mouseleave'); + }); + }); + }); }); }); describe('and when the node list link has been clicked', () => { diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/copyable_panel_field.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/copyable_panel_field.tsx new file mode 100644 index 0000000000000..356dd1c73678e --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/copyable_panel_field.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. + */ + +/* eslint-disable react/display-name */ + +import { EuiToolTip, EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import React, { memo, useState, useContext } from 'react'; +import { WithCopyToClipboard } from '../../../common/lib/clipboard/with_copy_to_clipboard'; +import { useColors } from '../use_colors'; +import { ResolverPanelContext } from './panel_context'; + +interface StyledCopyableField { + readonly backgroundColor: string; + readonly activeBackgroundColor: string; +} + +const StyledCopyableField = styled.div` + background-color: ${(props) => props.backgroundColor}; + border-radius: 3px; + padding: 4px; + transition: background 0.2s ease; + + &:hover { + background-color: ${(props) => props.activeBackgroundColor}; + color: #fff; + } +`; + +export const CopyablePanelField = memo( + ({ textToCopy, content }: { textToCopy: string; content: JSX.Element | string }) => { + const { linkColor, copyableBackground } = useColors(); + const [isOpen, setIsOpen] = useState(false); + const panelContext = useContext(ResolverPanelContext); + + const onMouseEnter = () => setIsOpen(true); + + const ButtonContent = memo(() => ( + + {content} + + )); + + const onMouseLeave = () => setIsOpen(false); + + return ( +
    + } + closePopover={onMouseLeave} + hasArrow={false} + isOpen={isOpen} + panelPaddingSize="s" + > + + + + +
    + ); + } +); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/descriptive_name.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/descriptive_name.tsx index 195ebceee0610..c15b9e01baf44 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/descriptive_name.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/descriptive_name.tsx @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FormattedMessage } from 'react-intl'; - import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { isLegacyEventSafeVersion, diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx index 039287d04e921..e5569b30abb9d 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx @@ -10,10 +10,10 @@ import React, { memo, useMemo, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { EuiSpacer, EuiText, EuiDescriptionList, EuiTextColor, EuiTitle } from '@elastic/eui'; import styled from 'styled-components'; import { useSelector } from 'react-redux'; -import { FormattedMessage } from 'react-intl'; import { StyledPanel } from '../styles'; import { BoldCode, @@ -21,6 +21,7 @@ import { GeneratedText, noTimestampRetrievedText, } from './panel_content_utilities'; +import { CopyablePanelField } from './copyable_panel_field'; import { Breadcrumbs } from './breadcrumbs'; import * as eventModel from '../../../../common/endpoint/models/event'; import * as selectors from '../../store/selectors'; @@ -156,7 +157,12 @@ function EventDetailFields({ event }: { event: SafeResolverEvent }) { namespace: {key}, descriptions: deepObjectEntries(value).map(([path, fieldValue]) => ({ title: {path.join('.')}, - description: {String(fieldValue)}, + description: ( + {String(fieldValue)}} + /> + ), })), }; returnValue.push(section); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx index 396050420f54e..043229dbeda66 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx @@ -6,7 +6,7 @@ /* eslint-disable react/display-name */ -import React, { memo } from 'react'; +import React, { memo, useState } from 'react'; import { useSelector } from 'react-redux'; import * as selectors from '../../store/selectors'; import { NodeEventsInCategory } from './node_events_of_type'; @@ -15,33 +15,48 @@ import { NodeDetail } from './node_detail'; import { NodeList } from './node_list'; import { EventDetail } from './event_detail'; import { PanelViewAndParameters } from '../../types'; +import { ResolverPanelContext } from './panel_context'; /** * Show the panel that matches the `panelViewAndParameters` (derived from the browser's location.search) */ + export const PanelRouter = memo(function () { const params: PanelViewAndParameters = useSelector(selectors.panelViewAndParameters); + const [isHoveringInPanel, updateIsHoveringInPanel] = useState(false); + + const triggerPanelHover = () => updateIsHoveringInPanel(true); + const stopPanelHover = () => updateIsHoveringInPanel(false); + + /* The default 'Event List' / 'List of all processes' view */ + let panelViewToRender = ; + if (params.panelView === 'nodeDetail') { - return ; + panelViewToRender = ; } else if (params.panelView === 'nodeEvents') { - return ; + panelViewToRender = ; } else if (params.panelView === 'nodeEventsInCategory') { - return ( + panelViewToRender = ( ); } else if (params.panelView === 'eventDetail') { - return ( + panelViewToRender = ( ); - } else { - /* The default 'Event List' / 'List of all processes' view */ - return ; } + + return ( + +
    + {panelViewToRender} +
    +
    + ); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx index 011614e8eb9b5..c7d4f8632659b 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx @@ -10,13 +10,14 @@ import React, { memo, useMemo, HTMLAttributes } from 'react'; import { useSelector } from 'react-redux'; import { i18n } from '@kbn/i18n'; import { htmlIdGenerator, EuiSpacer, EuiTitle, EuiText, EuiTextColor, EuiLink } from '@elastic/eui'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage } from '@kbn/i18n/react'; import styled from 'styled-components'; import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; import { StyledDescriptionList, StyledTitle } from './styles'; import * as selectors from '../../store/selectors'; import * as eventModel from '../../../../common/endpoint/models/event'; import { GeneratedText } from './panel_content_utilities'; +import { CopyablePanelField } from './copyable_panel_field'; import { Breadcrumbs } from './breadcrumbs'; import { processPath, processPID } from '../../models/process_event'; import { CubeForProcess } from './cube_for_process'; @@ -131,7 +132,12 @@ const NodeDetailView = memo(function ({ .map((entry) => { return { ...entry, - description: {String(entry.description)}, + description: ( + {String(entry.description)}} + /> + ), }; }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events.tsx index 34728d793bf90..d0601fad43f57 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events.tsx @@ -9,7 +9,7 @@ import React, { memo, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiBasicTableColumn, EuiButtonEmpty, EuiSpacer, EuiInMemoryTable } from '@elastic/eui'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage } from '@kbn/i18n/react'; import { useSelector } from 'react-redux'; import { Breadcrumbs } from './breadcrumbs'; import * as event from '../../../../common/endpoint/models/event'; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.test.tsx new file mode 100644 index 0000000000000..5f6b4e81e740e --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.test.tsx @@ -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 { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history'; + +import { oneNodeWithPaginatedEvents } from '../../data_access_layer/mocks/one_node_with_paginated_related_events'; +import { Simulator } from '../../test_utilities/simulator'; +// Extend jest with a custom matcher +import '../../test_utilities/extend_jest'; +import { urlSearch } from '../../test_utilities/url_search'; + +// the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances +const resolverComponentInstanceID = 'resolverComponentInstanceID'; + +describe(`Resolver: when analyzing a tree with only the origin and paginated related events, and when the component instance ID is ${resolverComponentInstanceID}`, () => { + /** + * Get (or lazily create and get) the simulator. + */ + let simulator: () => Simulator; + /** lazily populated by `simulator`. */ + let simulatorInstance: Simulator | undefined; + let memoryHistory: HistoryPackageHistoryInterface; + + beforeEach(() => { + // create a mock data access layer + const { metadata: dataAccessLayerMetadata, dataAccessLayer } = oneNodeWithPaginatedEvents(); + + memoryHistory = createMemoryHistory(); + + // create a resolver simulator, using the data access layer and an arbitrary component instance ID + simulator = () => { + if (simulatorInstance) { + return simulatorInstance; + } else { + simulatorInstance = new Simulator({ + databaseDocumentID: dataAccessLayerMetadata.databaseDocumentID, + dataAccessLayer, + resolverComponentInstanceID, + history: memoryHistory, + indices: [], + }); + return simulatorInstance; + } + }; + }); + + afterEach(() => { + simulatorInstance = undefined; + }); + + describe(`when the URL query string is showing a resolver with nodeID origin, panel view nodeEventsInCategory, and eventCategory registry`, () => { + beforeEach(() => { + memoryHistory.push({ + search: urlSearch(resolverComponentInstanceID, { + panelParameters: { nodeID: 'origin', eventCategory: 'registry' }, + panelView: 'nodeEventsInCategory', + }), + }); + }); + it('should show the load more data button', async () => { + await expect( + simulator().map(() => ({ + loadMoreButton: simulator().testSubject('resolver:nodeEventsInCategory:loadMore').length, + visibleEvents: simulator().testSubject( + 'resolver:panel:node-events-in-category:event-link' + ).length, + })) + ).toYieldEqualTo({ + loadMoreButton: 1, + visibleEvents: 25, + }); + }); + describe('when the user clicks the load more button', () => { + beforeEach(async () => { + const loadMore = await simulator().resolve('resolver:nodeEventsInCategory:loadMore'); + if (loadMore) { + loadMore.simulate('click', { button: 0 }); + } + }); + it('should hide the load more button and show all 30 events', async () => { + await expect( + simulator().map(() => ({ + loadMoreButton: simulator().testSubject('resolver:nodeEventsInCategory:loadMore') + .length, + visibleEvents: simulator().testSubject( + 'resolver:panel:node-events-in-category:event-link' + ).length, + })) + ).toYieldEqualTo({ + loadMoreButton: 0, + visibleEvents: 30, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx index 7f635097d8ac9..17e91902d0c96 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx @@ -4,13 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -/* eslint-disable react/display-name */ - -import React, { memo, Fragment } from 'react'; +import React, { memo, useCallback, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiSpacer, EuiText, EuiButtonEmpty, EuiHorizontalRule } from '@elastic/eui'; +import { + EuiSpacer, + EuiText, + EuiButtonEmpty, + EuiHorizontalRule, + EuiFlexItem, + EuiButton, + EuiCallOut, +} from '@elastic/eui'; import { useSelector } from 'react-redux'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage } from '@kbn/i18n/react'; import { StyledPanel } from '../styles'; import { BoldCode, noTimestampRetrievedText, StyledTime } from './panel_content_utilities'; import { Breadcrumbs } from './breadcrumbs'; @@ -21,6 +27,7 @@ import { ResolverState } from '../../types'; import { PanelLoading } from './panel_loading'; import { DescriptiveName } from './descriptive_name'; import { useLinkProps } from '../use_link_props'; +import { useResolverDispatch } from '../use_resolver_dispatch'; import { useFormattedDate } from './use_formatted_date'; /** @@ -44,29 +51,56 @@ export const NodeEventsInCategory = memo(function ({ ); const events = useSelector((state: ResolverState) => selectors.nodeEventsInCategory(state)); + const isLoading = useSelector(selectors.isLoadingNodeEventsInCategory); + const hasError = useSelector(selectors.hadErrorLoadingNodeEventsInCategory); return ( <> - {eventCount === undefined || processEvent === null ? ( + {isLoading || processEvent === null ? ( ) : ( - - - + {hasError ? ( + +

    + +

    +
    + ) : ( + <> + + + + + )}
    )} ); }); +NodeEventsInCategory.displayName = 'NodeEventsInCategory'; + /** * Rendered for each event in the list. */ @@ -136,6 +170,14 @@ const NodeEventList = memo(function NodeEventList({ events: SafeResolverEvent[]; nodeID: string; }) { + const dispatch = useResolverDispatch(); + const handleLoadMore = useCallback(() => { + dispatch({ + type: 'userRequestedAdditionalRelatedEvents', + }); + }, [dispatch]); + const isLoading = useSelector(selectors.isLoadingMoreNodeEventsInCategory); + const hasMore = useSelector(selectors.lastRelatedEventResponseContainsCursor); return ( <> {events.map((event, index) => ( @@ -144,6 +186,23 @@ const NodeEventList = memo(function NodeEventList({ {index === events.length - 1 ? null : }
    ))} + {hasMore && ( + + + + + + )} ); }); @@ -166,7 +225,7 @@ const NodeEventsInCategoryBreadcrumbs = memo(function ({ /** * The events to list. */ - eventCount: number; + eventCount: number | undefined; nodeID: string; /** * The count of events in the category that this list is showing. diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx index 78d3477301539..06e3acfb3dc6d 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx @@ -42,6 +42,7 @@ import { SafeResolverEvent } from '../../../../common/endpoint/types'; import { ResolverAction } from '../../store/actions'; import { useFormattedDate } from './use_formatted_date'; import { getEmptyTagValue } from '../../../common/components/empty_value'; +import { CopyablePanelField } from './copyable_panel_field'; interface ProcessTableView { name?: string; @@ -214,5 +215,9 @@ function NodeDetailLink({ const NodeDetailTimestamp = memo(({ eventDate }: { eventDate: Date | undefined }) => { const formattedDate = useFormattedDate(eventDate); - return formattedDate ? <>{formattedDate} : getEmptyTagValue(); + return formattedDate ? ( + + ) : ( + getEmptyTagValue() + ); }); diff --git a/x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_context.tsx similarity index 56% rename from x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts rename to x-pack/plugins/security_solution/public/resolver/view/panels/panel_context.tsx index e3add3748f56d..60109c732c46d 100644 --- a/x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_context.tsx @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; +import React from 'react'; -import { services } from './services'; - -export type FtrProviderContext = GenericFtrProviderContext; +export const ResolverPanelContext = React.createContext({ isHoveringInPanel: false }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts b/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts index 8072266f1e8c8..6f0cbcb3fd876 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts @@ -10,10 +10,12 @@ import { useMemo } from 'react'; import { useUiSetting } from '../../../../../../src/plugins/kibana_react/public'; type ResolverColorNames = + | 'copyableBackground' | 'descriptionText' | 'full' | 'graphControls' | 'graphControlsBackground' + | 'linkColor' | 'resolverBackground' | 'resolverEdge' | 'resolverEdgeText' @@ -31,6 +33,7 @@ export function useColors(): ColorMap { const theme = isDarkMode ? euiThemeAmsterdamDark : euiThemeAmsterdamLight; return useMemo(() => { return { + copyableBackground: theme.euiColorLightShade, descriptionText: theme.euiTextColor, full: theme.euiColorFullShade, graphControls: theme.euiColorDarkestShade, @@ -42,6 +45,7 @@ export function useColors(): ColorMap { resolverEdgeText: isDarkMode ? theme.euiColorFullShade : theme.euiColorDarkShade, triggerBackingFill: `${theme.euiColorDanger}${isDarkMode ? '1F' : '0F'}`, pillStroke: theme.euiColorLightShade, + linkColor: theme.euiLinkColor, }; }, [isDarkMode, theme]); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx index 8922434746234..a75089b58b86b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx @@ -35,6 +35,7 @@ import { OnUpdateColumns } from '../timeline/events'; import { TruncatableText } from '../../../common/components/truncatable_text'; import { FieldName } from './field_name'; import * as i18n from './translations'; +import { getAlertColumnHeader } from './helpers'; const TypeIcon = styled(EuiIcon)` margin-left: 5px; @@ -127,6 +128,7 @@ export const getFieldItems = ({ columnHeaderType: defaultColumnHeaderType, id: field.name || '', width: DEFAULT_COLUMN_MIN_WIDTH, + ...getAlertColumnHeader(timelineId, field.name || ''), }) } /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx index 62e41d967cb9a..b6829bcce28fa 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx @@ -21,6 +21,7 @@ import { */ export const FieldNameContainer = styled.span` border-radius: 4px; + display: flex; padding: 0 4px 0 8px; position: relative; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/helpers.tsx index df4b106ab6bd4..ba9ade096c4d2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/helpers.tsx @@ -7,8 +7,10 @@ import { EuiLoadingSpinner } from '@elastic/eui'; import { filter, get, pickBy } from 'lodash/fp'; import styled from 'styled-components'; +import { TimelineId } from '../../../../common/types/timeline'; import { BrowserField, BrowserFields } from '../../../common/containers/source'; +import { alertsHeaders } from '../../../detections/components/alerts_table/default_config'; import { DEFAULT_CATEGORY_NAME, defaultHeaders, @@ -141,3 +143,8 @@ export const mergeBrowserFieldsWithDefaultCategory = ( fieldIds: defaultHeaders.map((header) => header.id), }), }); + +export const getAlertColumnHeader = (timelineId: string, fieldId: string) => + timelineId === TimelineId.detectionsPage || timelineId === TimelineId.detectionsRulesDetailsPage + ? alertsHeaders.find((c) => c.id === fieldId) ?? {} + : {}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx index 091bb41bc2080..65210ab2fd60a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx @@ -12,6 +12,7 @@ import { DraggableWrapper, } from '../../../common/components/drag_and_drop/draggable_wrapper'; import { escapeDataProviderId } from '../../../common/components/drag_and_drop/helpers'; +import { Content } from '../../../common/components/draggables'; import { getOrEmptyTagFromValue } from '../../../common/components/empty_value'; import { NetworkDetailsLink } from '../../../common/components/links'; import { parseQueryValue } from '../../../timelines/components/timeline/body/renderers/parse_query_value'; @@ -148,9 +149,11 @@ const AddressLinksItemComponent: React.FC = ({ ) : ( - + + + ), - [address, dataProviderProp] + [address, dataProviderProp, fieldName] ); return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap index 4eb320571a75b..10ad0123f7fc6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap @@ -66,7 +66,8 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "euiButtonHeight": "40px", "euiButtonHeightSmall": "32px", "euiButtonIconTypes": Object { - "danger": "#ff6666", + "accent": "#f990c0", + "danger": "#ff7575", "disabled": "#4c4e51", "ghost": "#ffffff", "primary": "#1ba9f5", @@ -217,7 +218,7 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "euiDataGridColumnResizerWidth": "3px", "euiDataGridPopoverMaxHeight": "400px", "euiDataGridPrefix": ".euiDataGrid--", - "euiDataGridStyles": "'bordersAll', 'bordersNone', 'bordersHorizontal', 'paddingSmall', 'paddingMedium', 'paddingLarge', 'stripes', 'rowHoverNone', 'rowHoverHighlight', 'headerShade', 'headerUnderline', 'fontSizeSmall', 'fontSizeLarge', 'noControls'", + "euiDataGridStyles": "'bordersAll', 'bordersNone', 'bordersHorizontal', 'paddingSmall', 'paddingMedium', 'paddingLarge', 'stripes', 'rowHoverNone', 'rowHoverHighlight', 'headerShade', 'headerUnderline', 'footerShade', 'footerOverline', 'fontSizeSmall', 'fontSizeLarge', 'noControls', 'stickyFooter'", "euiDataGridVerticalBorder": "solid 1px #24272e", "euiDatePickerCalendarWidth": "284px", "euiDragAndDropSpacing": Object { @@ -292,9 +293,15 @@ exports[`NoteCardBody renders correctly against snapshot 1`] = ` "euiHeaderChildSize": "48px", "euiHeaderHeight": "48px", "euiHeaderHeightCompensation": "49px", + "euiHeaderLinksGutterSizes": Object { + "gutterL": "24px", + "gutterM": "12px", + "gutterS": "8px", + "gutterXS": "4px", + }, "euiIconColors": Object { "accent": "#f990c0", - "danger": "#ff7575", + "danger": "#ff6666", "ghost": "#ffffff", "primary": "#1ba9f5", "secondary": "#7de2d1", diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx index 9d9055e3ad748..62d169b1169dd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx @@ -25,21 +25,21 @@ NoteContainer.displayName = 'NoteContainer'; interface NoteCardsCompProps { children: React.ReactNode; } +const NoteCardsCompContainer = styled(EuiPanel)` + border: none; + background-color: transparent; + box-shadow: none; +`; +NoteCardsCompContainer.displayName = 'NoteCardsCompContainer'; const NoteCardsComp = React.memo(({ children }) => ( - + {children} - + )); NoteCardsComp.displayName = 'NoteCardsComp'; const NotesContainer = styled(EuiFlexGroup)` - padding: 0 5px; margin-bottom: 5px; `; NotesContainer.displayName = 'NotesContainer'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx index df5c48ad012a6..3b6585013c8d3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -151,17 +151,13 @@ export const EventColumnView = React.memo( />, ] : []), - ...(eventType !== 'raw' - ? [ - , - ] - : []), + , ], [ associateNote, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx index ee68c270e9aba..be8ce5e26b3e1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx @@ -33,7 +33,7 @@ import { EventsTrGroup, EventsTrSupplement, EventsTrSupplementContainer } from ' import { ColumnRenderer } from '../renderers/column_renderer'; import { getRowRenderer } from '../renderers/get_row_renderer'; import { RowRenderer } from '../renderers/row_renderer'; -import { getEventType } from '../helpers'; +import { isEventBuildingBlockType, getEventType } from '../helpers'; import { NoteCards } from '../../../notes/note_cards'; import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context'; import { EventColumnView } from './event_column_view'; @@ -183,6 +183,7 @@ const StatefulEventComponent: React.FC = ({ className={STATEFUL_EVENT_CSS_CLASS_NAME} data-test-subj="event" eventType={getEventType(event.ecs)} + isBuildingBlockType={isEventBuildingBlockType(event.ecs)} showLeftBorder={!isEventViewer} ref={divElement} > diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx index cf9fbfaf19326..d4d77d6fd40a0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx @@ -103,6 +103,9 @@ export const getEventIdToDataMapping = ( }; }, {}); +export const isEventBuildingBlockType = (event: Ecs): boolean => + !isEmpty(event.signal?.rule?.building_block_type); + /** Return eventType raw or signal */ export const getEventType = (event: Ecs): Omit => { if (!isEmpty(event.signal?.rule?.id)) { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx index ab9e47f5ae3f5..04709458a7428 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx @@ -54,6 +54,7 @@ const FormattedFieldValueComponent: React.FC<{ contextId={contextId} fieldName={fieldName} value={!isNumber(value) ? value : String(value)} + truncate={truncate} /> ); } else if (fieldType === DATE_FIELD_TYPE) { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/translations.ts index 92ebd9c2b0e36..ac927f60691e1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/translations.ts @@ -33,6 +33,6 @@ export const NON_EXISTENT = i18n.translate('xpack.securitySolution.auditd.nonExi export const LINK_ELASTIC_ENDPOINT_SECURITY = i18n.translate( 'xpack.securitySolution.event.module.linkToElasticEndpointSecurityDescription', { - defaultMessage: 'Open in Elastic Endpoint Security', + defaultMessage: 'Open in Endpoint Security', } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/__snapshots__/index.test.tsx.snap index 5b14edf818fdc..35e7de2981973 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/__snapshots__/index.test.tsx.snap @@ -77,7 +77,8 @@ exports[`Footer Timeline Component rendering it renders the default timeline foo data-test-subj="paging-control" isLoading={false} onPageClick={[Function]} - totalPages={5} + totalCount={15546} + totalPages={7773} /> { const updatedAt = 1546878704036; const serverSideEventCount = 15546; const itemsCount = 2; - const totalCount = 10; describe('rendering', () => { test('it renders the default timeline footer', () => { @@ -34,8 +33,7 @@ describe('Footer Timeline Component', () => { itemsPerPageOptions={[1, 5, 10, 20]} onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadMore} - serverSideEventCount={serverSideEventCount} - totalCount={totalCount} + totalCount={serverSideEventCount} /> ); @@ -56,8 +54,7 @@ describe('Footer Timeline Component', () => { itemsPerPageOptions={[1, 5, 10, 20]} onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadMore} - serverSideEventCount={serverSideEventCount} - totalCount={totalCount} + totalCount={serverSideEventCount} /> ); @@ -79,8 +76,7 @@ describe('Footer Timeline Component', () => { itemsPerPageOptions={[1, 5, 10, 20]} onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadMore} - serverSideEventCount={serverSideEventCount} - totalCount={totalCount} + totalCount={serverSideEventCount} /> ); @@ -92,6 +88,7 @@ describe('Footer Timeline Component', () => { const wrapper = shallow( { const wrapper = shallow( { itemsPerPageOptions={[1, 5, 10, 20]} onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadMore} - serverSideEventCount={serverSideEventCount} - totalCount={totalCount} + totalCount={serverSideEventCount} /> ); @@ -153,8 +150,7 @@ describe('Footer Timeline Component', () => { itemsPerPageOptions={[1, 5, 10, 20]} onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadMore} - serverSideEventCount={serverSideEventCount} - totalCount={totalCount} + totalCount={serverSideEventCount} /> ); @@ -180,8 +176,7 @@ describe('Footer Timeline Component', () => { itemsPerPageOptions={[1, 5, 10, 20]} onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadMore} - serverSideEventCount={serverSideEventCount} - totalCount={totalCount} + totalCount={serverSideEventCount} /> ); @@ -205,8 +200,7 @@ describe('Footer Timeline Component', () => { itemsPerPageOptions={[1, 5, 10, 20]} onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadMore} - serverSideEventCount={serverSideEventCount} - totalCount={totalCount} + totalCount={serverSideEventCount} /> ); @@ -232,8 +226,7 @@ describe('Footer Timeline Component', () => { itemsPerPageOptions={[1, 5, 10, 20]} onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadMore} - serverSideEventCount={serverSideEventCount} - totalCount={totalCount} + totalCount={serverSideEventCount} /> ); @@ -257,8 +250,7 @@ describe('Footer Timeline Component', () => { itemsPerPageOptions={[1, 5, 10, 20]} onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadMore} - serverSideEventCount={serverSideEventCount} - totalCount={totalCount} + totalCount={serverSideEventCount} /> ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx index 7c10168da3c62..4119127d5a108 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx @@ -178,13 +178,23 @@ interface PagingControlProps { activePage: number; isLoading: boolean; onPageClick: OnChangePage; + totalCount: number; totalPages: number; } +const TimelinePaginationContainer = styled.div<{ hideLastPage: boolean }>` + ul.euiPagination__list { + li.euiPagination__item:last-child { + ${({ hideLastPage }) => `${hideLastPage ? 'display:none' : ''}`}; + } + } +`; + export const PagingControlComponent: React.FC = ({ activePage, isLoading, onPageClick, + totalCount, totalPages, }) => { if (isLoading) { @@ -196,12 +206,14 @@ export const PagingControlComponent: React.FC = ({ } return ( - + 9999}> + + ); }; @@ -222,7 +234,6 @@ interface FooterProps { itemsPerPageOptions: number[]; onChangeItemsPerPage: OnChangeItemsPerPage; onChangePage: OnChangePage; - serverSideEventCount: number; totalCount: number; } @@ -239,7 +250,6 @@ export const FooterComponent = ({ itemsPerPageOptions, onChangeItemsPerPage, onChangePage, - serverSideEventCount, totalCount, }: FooterProps) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -339,7 +349,7 @@ export const FooterComponent = ({ items={rowItems} itemsCount={itemsCount} onClick={onButtonClick} - serverSideEventCount={serverSideEventCount} + serverSideEventCount={totalCount} /> @@ -367,6 +377,7 @@ export const FooterComponent = ({ ) : ( ({ export const EventsTrGroup = styled.div.attrs(({ className = '' }) => ({ className: `siemEventsTable__trGroup ${className}`, -}))<{ className?: string; eventType: Omit; showLeftBorder: boolean }>` +}))<{ + className?: string; + eventType: Omit; + isBuildingBlockType: boolean; + showLeftBorder: boolean; +}>` border-bottom: ${({ theme }) => theme.eui.euiBorderWidthThin} solid ${({ theme }) => theme.eui.euiColorLightShade}; - ${({ theme, eventType, showLeftBorder }) => + ${({ theme, eventType, isBuildingBlockType, showLeftBorder }) => showLeftBorder ? `border-left: 4px solid ${eventType === 'raw' ? theme.eui.euiColorLightShade : theme.eui.euiColorWarning}` : ''}; + ${({ isBuildingBlockType, showLeftBorder }) => + isBuildingBlockType + ? `background: repeating-linear-gradient(127deg, rgba(245, 167, 0, 0.2), rgba(245, 167, 0, 0.2) 1px, rgba(245, 167, 0, 0.05) 2px, rgba(245, 167, 0, 0.05) 10px);` + : ''}; &:hover { background-color: ${({ theme }) => theme.eui.euiTableHoverColor}; @@ -207,7 +216,13 @@ export const EventsTrSupplement = styled.div.attrs(({ className = '' }) => ({ }))<{ className: string }>` font-size: ${({ theme }) => theme.eui.euiFontSizeXS}; line-height: ${({ theme }) => theme.eui.euiLineHeight}; - padding: 0 ${({ theme }) => theme.eui.paddingSizes.xs} 0 52px; + padding: 0 ${({ theme }) => theme.eui.paddingSizes.m}; + .euiAccordion + div { + background-color: ${({ theme }) => theme.eui.euiColorEmptyShade}; + padding: 0 ${({ theme }) => theme.eui.paddingSizes.s}; + border: 1px solid ${({ theme }) => theme.eui.euiColorLightShade}; + border-radius: ${({ theme }) => theme.eui.paddingSizes.xs}; + } `; export const EventsTdGroupActions = styled.div.attrs(({ className = '' }) => ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx index 1097d58b227a8..b72f4f97667c3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx @@ -301,8 +301,7 @@ export const TimelineComponent: React.FC = ({ itemsPerPageOptions={itemsPerPageOptions} onChangeItemsPerPage={onChangeItemsPerPage} onChangePage={loadPage} - serverSideEventCount={totalCount} - totalCount={pageInfo.fakeTotalCount} + totalCount={totalCount} /> ) diff --git a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx index 59d7fd6945637..bc38de85f288c 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx @@ -93,6 +93,7 @@ export const getAllTimeline = memoizeOne( updated: timeline.updated, updatedBy: timeline.updatedBy, timelineType: timeline.timelineType ?? TimelineType.default, + templateTimelineId: timeline.templateTimelineId, })) ); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx index cd72ffb8ac803..2b3d615fe9b32 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx @@ -29,6 +29,8 @@ export interface UseTimelineEventsDetailsProps { skip: boolean; } +const ID = 'timelineEventsDetails'; + export const useTimelineEventsDetails = ({ docValueFields, indexName, @@ -49,7 +51,11 @@ export const useTimelineEventsDetails = ({ ); const timelineDetailsSearch = useCallback( - (request: TimelineEventsDetailsRequestOptions) => { + (request: TimelineEventsDetailsRequestOptions | null) => { + if (request == null) { + return; + } + let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); @@ -102,6 +108,7 @@ export const useTimelineEventsDetails = ({ ...(prevRequest ?? {}), docValueFields, indexName, + id: ID, eventId, factoryQueryType: TimelineEventsQueries.details, }; @@ -113,9 +120,7 @@ export const useTimelineEventsDetails = ({ }, [docValueFields, eventId, indexName, skip]); useEffect(() => { - if (timelineDetailsRequest) { - timelineDetailsSearch(timelineDetailsRequest); - } + timelineDetailsSearch(timelineDetailsRequest); }, [timelineDetailsRequest, timelineDetailsSearch]); return [loading, timelineDetailsResponse]; diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx index 53944fd29a687..0ae60e6ad0131 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx @@ -15,13 +15,12 @@ import { inputsModel } from '../../common/store'; import { useKibana } from '../../common/lib/kibana'; import { createFilter } from '../../common/containers/helpers'; import { DocValueFields } from '../../common/containers/query_template'; -import { generateTablePaginationOptions } from '../../common/components/paginated_table/helpers'; import { timelineActions } from '../../timelines/store/timeline'; import { detectionsTimelineIds, skipQueryForDetectionsPage } from './helpers'; import { getInspectResponse } from '../../helpers'; import { Direction, - PageInfoPaginated, + PaginationInputPaginated, TimelineEventsQueries, TimelineEventsAllStrategyResponse, TimelineEventsAllRequestOptions, @@ -37,7 +36,7 @@ export interface TimelineArgs { id: string; inspect: InspectResponse; loadPage: LoadPage; - pageInfo: PageInfoPaginated; + pageInfo: Pick; refetch: inputsModel.Refetch; totalCount: number; updatedAt: number; @@ -62,6 +61,10 @@ const getTimelineEvents = (timelineEdges: TimelineEdges[]): TimelineItem[] => timelineEdges.map((e: TimelineEdges) => e.node); const ID = 'timelineEventsQuery'; +const initSortDefault = { + field: '@timestamp', + direction: Direction.asc, +}; export const useTimelineEvents = ({ docValueFields, @@ -72,10 +75,7 @@ export const useTimelineEvents = ({ filterQuery, startDate, limit, - sort = { - field: '@timestamp', - direction: Direction.asc, - }, + sort = initSortDefault, skip = false, }: UseTimelineEventsProps): [boolean, TimelineArgs] => { const dispatch = useDispatch(); @@ -87,16 +87,19 @@ export const useTimelineEvents = ({ const [timelineRequest, setTimelineRequest] = useState( !skip ? { - fields, + fields: [], fieldRequested: fields, filterQuery: createFilter(filterQuery), - id, + id: ID, timerange: { interval: '12h', from: startDate, to: endDate, }, - pagination: generateTablePaginationOptions(activePage, limit), + pagination: { + activePage, + querySize: limit, + }, sort, defaultIndex: indexNames, docValueFields: docValueFields ?? [], @@ -130,8 +133,7 @@ export const useTimelineEvents = ({ totalCount: -1, pageInfo: { activePage: 0, - fakeTotalCount: 0, - showMorePagesIndicator: false, + querySize: 0, }, events: [], loadPage: wrappedLoadPage, @@ -205,30 +207,60 @@ export const useTimelineEvents = ({ } setTimelineRequest((prevRequest) => { - const myRequest = { - ...(prevRequest ?? { - fields, - fieldRequested: fields, - id, - factoryQueryType: TimelineEventsQueries.all, - }), + const prevSearchParameters = { + defaultIndex: prevRequest?.defaultIndex ?? [], + filterQuery: prevRequest?.filterQuery ?? '', + querySize: prevRequest?.pagination.querySize ?? 0, + sort: prevRequest?.sort ?? initSortDefault, + timerange: prevRequest?.timerange ?? {}, + }; + + const currentSearchParameters = { defaultIndex: indexNames, - docValueFields: docValueFields ?? [], filterQuery: createFilter(filterQuery), - pagination: generateTablePaginationOptions(activePage, limit), + querySize: limit, + sort, timerange: { interval: '12h', from: startDate, to: endDate, }, + }; + + const newActivePage = deepEqual(prevSearchParameters, currentSearchParameters) + ? activePage + : 0; + + const currentRequest = { + defaultIndex: indexNames, + docValueFields: docValueFields ?? [], + factoryQueryType: TimelineEventsQueries.all, + fieldRequested: fields, + fields: [], + filterQuery: createFilter(filterQuery), + id, + pagination: { + activePage: newActivePage, + querySize: limit, + }, sort, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, }; + + if (activePage !== newActivePage) { + setActivePage(newActivePage); + } + if ( !skip && !skipQueryForDetectionsPage(id, indexNames) && - !deepEqual(prevRequest, myRequest) + !deepEqual(prevRequest, currentRequest) ) { - return myRequest; + return currentRequest; } return prevRequest; }); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/one/index.gql_query.ts b/x-pack/plugins/security_solution/public/timelines/containers/one/index.gql_query.ts index b85fbc15ce3f3..fa0ecb349f9c8 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/one/index.gql_query.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/one/index.gql_query.ts @@ -7,8 +7,8 @@ import gql from 'graphql-tag'; export const oneTimelineQuery = gql` - query GetOneTimeline($id: ID!) { - getOneTimeline(id: $id) { + query GetOneTimeline($id: ID!, $timelineType: TimelineType) { + getOneTimeline(id: $id, timelineType: $timelineType) { savedObjectId columns { aggregatable diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index ef40d34104b72..8daefd155b5e4 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -28,7 +28,7 @@ import { MlPluginSetup, MlPluginStart } from '../../ml/public'; export interface SetupPlugins { home?: HomePublicPluginSetup; security: SecurityPluginSetup; - triggers_actions_ui: TriggersActionsSetup; + triggersActionsUi: TriggersActionsSetup; usageCollection?: UsageCollectionSetup; ml?: MlPluginSetup; } @@ -40,7 +40,7 @@ export interface StartPlugins { ingestManager?: IngestManagerStart; lists?: ListsPluginStart; newsfeed?: NewsfeedStart; - triggers_actions_ui: TriggersActionsStart; + triggersActionsUi: TriggersActionsStart; uiActions: UiActionsStart; ml?: MlPluginStart; } diff --git a/x-pack/plugins/security_solution/server/graphql/timeline/resolvers.ts b/x-pack/plugins/security_solution/server/graphql/timeline/resolvers.ts index 9bd544f6942cf..9e6e9bf6ea22e 100644 --- a/x-pack/plugins/security_solution/server/graphql/timeline/resolvers.ts +++ b/x-pack/plugins/security_solution/server/graphql/timeline/resolvers.ts @@ -44,7 +44,7 @@ export const createTimelineResolvers = ( } => ({ Query: { async getOneTimeline(root, args, { req }) { - return libs.timeline.getTimeline(req, args.id); + return libs.timeline.getTimeline(req, args.id, args.timelineType); }, async getAllTimeline(root, args, { req }) { return libs.timeline.getAllTimeline( diff --git a/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts b/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts index 70596a1b41ea0..2358e78b044ed 100644 --- a/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts +++ b/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts @@ -317,7 +317,7 @@ export const timelineSchema = gql` ######################### extend type Query { - getOneTimeline(id: ID!): TimelineResult! + getOneTimeline(id: ID!, timelineType: TimelineType): TimelineResult! getAllTimeline(pageInfo: PageInfoTimeline!, search: String, sort: SortTimeline, onlyUserFavorite: Boolean, timelineType: TimelineType, status: TimelineStatus): ResponseTimelines! } diff --git a/x-pack/plugins/security_solution/server/graphql/types.ts b/x-pack/plugins/security_solution/server/graphql/types.ts index 7d2ce8a284994..7730cea2b9845 100644 --- a/x-pack/plugins/security_solution/server/graphql/types.ts +++ b/x-pack/plugins/security_solution/server/graphql/types.ts @@ -276,6 +276,11 @@ export enum HostPolicyResponseActionStatus { warning = 'warning', } +export enum TimelineType { + default = 'default', + template = 'template', +} + export enum DataProviderType { default = 'default', template = 'template', @@ -303,11 +308,6 @@ export enum TimelineStatus { immutable = 'immutable', } -export enum TimelineType { - default = 'default', - template = 'template', -} - export enum SortFieldTimeline { title = 'title', description = 'description', @@ -1601,6 +1601,8 @@ export interface SourceQueryArgs { } export interface GetOneTimelineQueryArgs { id: string; + + timelineType?: Maybe; } export interface GetAllTimelineQueryArgs { pageInfo: PageInfoTimeline; @@ -1838,6 +1840,8 @@ export namespace QueryResolvers { > = Resolver; export interface GetOneTimelineArgs { id: string; + + timelineType?: Maybe; } export type GetAllTimelineResolver< diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_installutil_beacon.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_installutil_beacon.json new file mode 100644 index 0000000000000..7437bf27141ec --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_installutil_beacon.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies InstallUtil.exe making outbound network connections. This may indicate adversarial activity as InstallUtil is often leveraged by adversaries to execute code and evade detection.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "InstallUtil Process Making Network Connections", + "query": "/* this can be done without a sequence however, this does include more info on the process */\n\nsequence by process.entity_id\n [process where event.type in (\"start\", \"process_started\") and process.name == \"installutil.exe\"]\n [network where event.type == \"connection\" and process.name == \"installutil.exe\" and network.direction == \"outgoing\"]\n", + "risk_score": 21, + "rule_id": "a13167f1-eec2-4015-9631-1fee60406dcf", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1118", + "name": "InstallUtil", + "reference": "https://attack.mitre.org/techniques/T1118/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_msbuild_beacon_sequence.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_msbuild_beacon_sequence.json new file mode 100644 index 0000000000000..59295c3735a3a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_msbuild_beacon_sequence.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies MsBuild.exe making outbound network connections. This may indicate adversarial activity as MsBuild is often leveraged by adversaries to execute code and evade detection.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "MsBuild Network Connection Sequence", + "query": "sequence by process.entity_id\n [process where event.type in (\"start\", \"process_started\") and process.name == \"MSBuild.exe\"]\n [network where process.name == \"MSBuild.exe\" and\n not (destination.address == \"127.0.0.1\" and source.address == \"127.0.0.1\")]\n", + "risk_score": 21, + "rule_id": "9dc6ed5d-62a9-4feb-a903-fafa1d33b8e9", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1127", + "name": "Trusted Developer Utilities Proxy Execution", + "reference": "https://attack.mitre.org/techniques/T1127/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_mshta_beacon.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_mshta_beacon.json new file mode 100644 index 0000000000000..105f536628777 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_mshta_beacon.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies Mshta.exe making outbound network connections. This may indicate adversarial activity as Mshta is often leveraged by adversaries to execute malicious scripts and evade detection.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Mshta Making Network Connections", + "query": "sequence by process.entity_id with maxspan=2h\n [process where event.type in (\"start\", \"process_started\") and process.name == \"mshta.exe\" and\n process.parent.name != \"Microsoft.ConfigurationManagement.exe\" and\n process.parent.executable not in (\"C:\\\\Amazon\\\\Amazon Assistant\\\\amazonAssistantService.exe\",\n \"C:\\\\TeamViewer\\\\TeamViewer.exe\") and\n process.args != \"ADSelfService_Enroll.hta\"]\n [network where process.name == \"mshta.exe\"]\n", + "risk_score": 21, + "rule_id": "c2d90150-0133-451c-a783-533e736c12d7", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1170", + "name": "Mshta", + "reference": "https://attack.mitre.org/techniques/T1170/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_msxsl_beacon.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_msxsl_beacon.json new file mode 100644 index 0000000000000..27704b3e182ed --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_msxsl_beacon.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies MsXsl.exe making outbound network connections. This may indicate adversarial activity as MsXsl is often leveraged by adversaries to execute malicious scripts and evade detection.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "MsXsl Making Network Connections", + "query": "sequence by process.entity_id\n [process where event.type in (\"start\", \"process_started\") and process.name == \"msxsl.exe\"]\n [network where event.type == \"connection\" and process.name == \"msxsl.exe\" and network.direction == \"outgoing\"]\n", + "risk_score": 21, + "rule_id": "870d1753-1078-403e-92d4-735f142edcca", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1220", + "name": "XSL Script Processing", + "reference": "https://attack.mitre.org/techniques/T1220/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_network_connection_from_windows_binary.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_network_connection_from_windows_binary.json new file mode 100644 index 0000000000000..5652f025952d7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_network_connection_from_windows_binary.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies network activity from unexpected system applications. This may indicate adversarial activity as these applications are often leveraged by adversaries to execute code and evade detection.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Unusual Network Activity from a Windows System Binary", + "query": "sequence by process.entity_id with maxspan=5m\n [process where event.type in (\"start\", \"process_started\") and\n\n /* known applocker bypasses */\n process.name in (\"bginfo.exe\",\n \"cdb.exe\",\n \"control.exe\",\n \"cmstp.exe\",\n \"csi.exe\",\n \"dnx.exe\",\n \"fsi.exe\",\n \"ieexec.exe\",\n \"iexpress.exe\",\n \"installutil.exe\",\n \"Microsoft.Workflow.Compiler.exe\",\n \"MSBuild.exe\",\n \"msdt.exe\",\n \"mshta.exe\",\n \"msiexec.exe\",\n \"msxsl.exe\",\n \"odbcconf.exe\",\n \"rcsi.exe\",\n \"regsvr32.exe\",\n \"xwizard.exe\")]\n [network where event.type == \"connection\" and\n process.name in (\"bginfo.exe\",\n \"cdb.exe\",\n \"control.exe\",\n \"cmstp.exe\",\n \"csi.exe\",\n \"dnx.exe\",\n \"fsi.exe\",\n \"ieexec.exe\",\n \"iexpress.exe\",\n \"installutil.exe\",\n \"Microsoft.Workflow.Compiler.exe\",\n \"MSBuild.exe\",\n \"msdt.exe\",\n \"mshta.exe\",\n \"msiexec.exe\",\n \"msxsl.exe\",\n \"odbcconf.exe\",\n \"rcsi.exe\",\n \"regsvr32.exe\",\n \"xwizard.exe\")]\n", + "risk_score": 21, + "rule_id": "1fe3b299-fbb5-4657-a937-1d746f2c711a", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1127", + "name": "Trusted Developer Utilities Proxy Execution", + "reference": "https://attack.mitre.org/techniques/T1127/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_reg_beacon.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_reg_beacon.json new file mode 100644 index 0000000000000..332c719eaa41d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_reg_beacon.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies registration utilities making outbound network connections. This includes regsvcs, regasm, and regsvr32. This may indicate adversarial activity as these tools are often leveraged by adversaries to execute code and evade detection.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Registration Tool Making Network Connections", + "query": "sequence by process.entity_id\n [process where event.type in (\"start\", \"process_started\") and\n process.name in (\"regasm.exe\", \"regsvcs.exe\", \"regsvr32.exe\")]\n [network where event.type == \"connection\" and process.name in (\"regasm.exe\", \"regsvcs.exe\", \"regsvr32.exe\")]\nuntil\n [process where event.type == \"end\" and process.name in (\"regasm.exe\", \"regsvcs.exe\", \"regsvr32.exe\")]\n", + "risk_score": 21, + "rule_id": "6d3456a5-4a42-49d1-aaf2-7b1fd475b2c6", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1121", + "name": "Regsvcs/Regasm", + "reference": "https://attack.mitre.org/techniques/T1121/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_rundll32_sequence.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_rundll32_sequence.json new file mode 100644 index 0000000000000..6f465325039a6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/c2_rundll32_sequence.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies unusual instances of Rundll32.exe making outbound network connections. This may indicate adversarial activity and may identify malicious DLLs.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Unusual Network Connection Sequence via RunDLL32", + "query": "sequence by process.entity_id with maxspan=2h\n [process where event.type in (\"start\", \"process_started\") and\n (process.name == \"rundll32.exe\" or process.pe.original_file_name == \"rundll32.exe\") and\n\n /* zero arguments excluding the binary itself (and accounting for when the binary may not be logged in args) */\n ((process.args == \"rundll32.exe\" and process.args_count == 1) or\n (process.args != \"rundll32.exe\" and process.args_count == 0))]\n\n [network where event.type == \"connection\" and\n (process.name == \"rundll32.exe\" or process.pe.original_file_name == \"rundll32.exe\")]\n", + "risk_score": 21, + "rule_id": "2b347f66-6739-4ae3-bd94-195036dde8b3", + "severity": "low", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1085", + "name": "Rundll32", + "reference": "https://attack.mitre.org/techniques/T1085/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_cloudtrail_logging_created.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_cloudtrail_logging_created.json index acc6f7724d0b5..3dc084a3af54b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_cloudtrail_logging_created.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_cloudtrail_logging_created.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -48,5 +49,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_gcp_pub_sub_subscription_creation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_gcp_pub_sub_subscription_creation.json new file mode 100644 index 0000000000000..720c6f71dafdd --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_gcp_pub_sub_subscription_creation.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the creation of a subscription in Google Cloud Platform (GCP). In GCP, the publisher-subscriber relationship (Pub/Sub) is an asynchronous messaging service that decouples event-producing and event-processing services. A subscription is a named resource representing the stream of messages to be delivered to the subscribing application.", + "false_positives": [ + "Subscription creations may be done by a system or network administrator. Verify whether the user email, resource name, and/or hostname should be making changes in your environment. Subscription creations from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Pub/Sub Subscription Creation", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:google.pubsub.v*.Subscriber.CreateSubscription and event.outcome:success", + "references": [ + "https://cloud.google.com/pubsub/docs/overview" + ], + "risk_score": 21, + "rule_id": "d62b64a8-a7c9-43e5-aee3-15a725a794e7", + "severity": "low", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Logging" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0009", + "name": "Collection", + "reference": "https://attack.mitre.org/tactics/TA0009/" + }, + "technique": [ + { + "id": "T1530", + "name": "Data from Cloud Storage Object", + "reference": "https://attack.mitre.org/techniques/T1530/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_gcp_pub_sub_topic_creation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_gcp_pub_sub_topic_creation.json new file mode 100644 index 0000000000000..93695334faae2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_gcp_pub_sub_topic_creation.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the creation of a topic in Google Cloud Platform (GCP). In GCP, the publisher-subscriber relationship (Pub/Sub) is an asynchronous messaging service that decouples event-producing and event-processing services. A topic is used to forward messages from publishers to subscribers.", + "false_positives": [ + "Topic creations may be done by a system or network administrator. Verify whether the user email, resource name, and/or hostname should be making changes in your environment. Topic creations from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Pub/Sub Topic Creation", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:google.pubsub.v*.Publisher.CreateTopic and event.outcome:success", + "references": [ + "https://cloud.google.com/pubsub/docs/admin" + ], + "risk_score": 21, + "rule_id": "a10d3d9d-0f65-48f1-8b25-af175e2594f5", + "severity": "low", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Logging" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0009", + "name": "Collection", + "reference": "https://attack.mitre.org/tactics/TA0009/" + }, + "technique": [ + { + "id": "T1530", + "name": "Data from Cloud Storage Object", + "reference": "https://attack.mitre.org/techniques/T1530/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_update_event_hub_auth_rule.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_update_event_hub_auth_rule.json new file mode 100644 index 0000000000000..cddc98ba2e6d7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_update_event_hub_auth_rule.json @@ -0,0 +1,65 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when an Event Hub Authorization Rule is created or updated in Azure. An authorization rule is associated with specific rights, and carries a pair of cryptographic keys. When you create an Event Hubs namespace, a policy rule named RootManageSharedAccessKey is created for the namespace. This has manage permissions for the entire namespace and it's recommended that you treat this rule like an administrative root account and don't use it in your application.", + "false_positives": [ + "Authorization rule additions or modifications may be done by a system or network administrator. Verify whether the username, hostname, and/or resource name should be making changes in your environment. Authorization rule additions or modifications from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Azure Event Hub Authorization Rule Created or Updated", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.activitylogs and azure.activitylogs.operation_name:MICROSOFT.EVENTHUB/NAMESPACES/AUTHORIZATIONRULES/WRITE and event.outcome:Success", + "references": [ + "https://docs.microsoft.com/en-us/azure/event-hubs/authorize-access-shared-access-signature" + ], + "risk_score": 47, + "rule_id": "b6dce542-2b75-4ffb-b7d6-38787298ba9d", + "severity": "medium", + "tags": [ + "Azure", + "Elastic", + "SecOps", + "Continuous Monitoring", + "Logging" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0009", + "name": "Collection", + "reference": "https://attack.mitre.org/tactics/TA0009/" + }, + "technique": [ + { + "id": "T1530", + "name": "Data from Cloud Storage Object", + "reference": "https://attack.mitre.org/techniques/T1530/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0010", + "name": "Exfiltration", + "reference": "https://attack.mitre.org/tactics/TA0010/" + }, + "technique": [ + { + "id": "T1537", + "name": "Transfer Data to Cloud Account", + "reference": "https://attack.mitre.org/techniques/T1537/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_certutil_network_connection.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_certutil_network_connection.json index a8be0fe97524e..f32877da78d99 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_certutil_network_connection.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_certutil_network_connection.json @@ -30,12 +30,12 @@ "technique": [ { "id": "T1105", - "name": "Remote File Copy", + "name": "Ingress Tool Transfer", "reference": "https://attack.mitre.org/techniques/T1105/" } ] } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_cobalt_strike_beacon.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_cobalt_strike_beacon.json new file mode 100644 index 0000000000000..7ebc13ac8079b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_cobalt_strike_beacon.json @@ -0,0 +1,52 @@ +{ + "author": [ + "Elastic" + ], + "description": "Cobalt Strike is a threat emulation platform commonly modified and used by adversaries to conduct network attack and exploitation campaigns. This rule detects a network activity algorithm leveraged by Cobalt Strike implant beacons for command and control.", + "false_positives": [ + "This rule should be tailored to either exclude systems, as sources or destinations, in which this behavior is expected." + ], + "index": [ + "packetbeat-*" + ], + "language": "lucene", + "license": "Elastic License", + "name": "Cobalt Strike Command and Control Beacon", + "note": "This activity has been observed in FIN7 campaigns.", + "query": "event.category:(network OR network_traffic) AND type:(tls OR http) AND network.transport:tcp AND destination.domain:/[a-z]{3}.stage.[0-9]{8}\\..*/", + "references": [ + "https://blog.morphisec.com/fin7-attacks-restaurant-industry", + "https://www.fireeye.com/blog/threat-research/2017/04/fin7-phishing-lnk.html" + ], + "risk_score": 73, + "rule_id": "cf53f532-9cc9-445a-9ae7-fced307ec53c", + "severity": "high", + "tags": [ + "Elastic", + "Network" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0011", + "name": "Command and Control", + "reference": "https://attack.mitre.org/tactics/TA0011/" + }, + "technique": [ + { + "id": "T1071", + "name": "Application Layer Protocol", + "reference": "https://attack.mitre.org/techniques/T1071/" + }, + { + "id": "T1483", + "name": "Domain Generation Algorithms", + "reference": "https://attack.mitre.org/techniques/T1483/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_download_rar_powershell_from_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_download_rar_powershell_from_internet.json new file mode 100644 index 0000000000000..7b739f005a0cb --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_download_rar_powershell_from_internet.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects a Roshal Archive (RAR) file or PowerShell script downloaded from the internet by an internal host. Gaining initial access to a system and then downloading encoded or encrypted tools to move laterally is a common practice for adversaries as a way to protect their more valuable tools and TTPs. This may be atypical behavior for a managed network and can be indicative of malware, exfiltration, or command and control.", + "false_positives": [ + "Downloading RAR or PowerShell files from the Internet may be expected for certain systems. This rule should be tailored to either exclude systems as sources or destinations in which this behavior is expected." + ], + "index": [ + "packetbeat-*" + ], + "language": "lucene", + "license": "Elastic License", + "name": "Roshal Archive (RAR) or PowerShell File Downloaded from the Internet", + "note": "This activity has been observed in FIN7 campaigns.", + "query": "event.category:(network OR network_traffic) AND network.protocol:http AND url.path:/.*(rar|ps1)/ AND source.ip:(10.0.0.0\\/8 OR 172.16.0.0\\/12 OR 192.168.0.0\\/16)", + "references": [ + "https://www.fireeye.com/blog/threat-research/2017/04/fin7-phishing-lnk.html", + "https://www.justice.gov/opa/press-release/file/1084361/download" + ], + "risk_score": 47, + "rule_id": "ff013cb4-274d-434a-96bb-fe15ddd3ae92", + "severity": "medium", + "tags": [ + "Elastic", + "Network" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0011", + "name": "Command and Control", + "reference": "https://attack.mitre.org/tactics/TA0011/" + }, + "technique": [ + { + "id": "T1105", + "name": "Ingress Tool Transfer", + "reference": "https://attack.mitre.org/techniques/T1105/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_fin7_c2_behavior.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_fin7_c2_behavior.json new file mode 100644 index 0000000000000..04d68aff0da1c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_fin7_c2_behavior.json @@ -0,0 +1,51 @@ +{ + "author": [ + "Elastic" + ], + "description": "This rule detects a known command and control pattern in network events. The FIN7 threat group is known to use this command and control technique, while maintaining persistence in their target's network.", + "false_positives": [ + "This rule could identify benign domains that are formatted similarly to FIN7's command and control algorithm. Alerts should be investigated by an analyst to assess the validity of the individual observations." + ], + "index": [ + "packetbeat-*" + ], + "language": "lucene", + "license": "Elastic License", + "name": "Possible FIN7 DGA Command and Control Behavior", + "note": "In the event this rule identifies benign domains in your environment, the `destination.domain` field in the rule can be modified to include those domains. Example: `...AND NOT destination.domain:(zoom.us OR benign.domain1 OR benign.domain2)`.", + "query": "event.category:(network OR network_traffic) AND type:(tls OR http) AND network.transport:tcp AND destination.domain:/[a-zA-Z]{4,5}\\.(pw|us|club|info|site|top)/ AND NOT destination.domain:zoom.us", + "references": [ + "https://www.fireeye.com/blog/threat-research/2018/08/fin7-pursuing-an-enigmatic-and-evasive-global-criminal-operation.html" + ], + "risk_score": 73, + "rule_id": "4a4e23cf-78a2-449c-bac3-701924c269d3", + "severity": "high", + "tags": [ + "Elastic", + "Network" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0011", + "name": "Command and Control", + "reference": "https://attack.mitre.org/tactics/TA0011/" + }, + "technique": [ + { + "id": "T1483", + "name": "Domain Generation Algorithms", + "reference": "https://attack.mitre.org/techniques/T1483/" + }, + { + "id": "T1071", + "name": "Application Layer Protocol", + "reference": "https://attack.mitre.org/techniques/T1071/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_halfbaked_beacon.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_halfbaked_beacon.json new file mode 100644 index 0000000000000..7dacb9afcbd60 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_halfbaked_beacon.json @@ -0,0 +1,52 @@ +{ + "author": [ + "Elastic" + ], + "description": "Halfbaked is a malware family used to establish persistence in a contested network. This rule detects a network activity algorithm leveraged by Halfbaked implant beacons for command and control.", + "false_positives": [ + "This rule should be tailored to exclude systems, either as sources or destinations, in which this behavior is expected." + ], + "index": [ + "packetbeat-*" + ], + "language": "lucene", + "license": "Elastic License", + "name": "Halfbaked Command and Control Beacon", + "note": "This activity has been observed in FIN7 campaigns.", + "query": "event.category:(network OR network_traffic) AND network.protocol:http AND network.transport:tcp AND url.full:/http:\\/\\/[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}\\/cd/ AND destination.port:(53 OR 80 OR 8080 OR 443)", + "references": [ + "https://www.fireeye.com/blog/threat-research/2017/04/fin7-phishing-lnk.html", + "https://attack.mitre.org/software/S0151/" + ], + "risk_score": 73, + "rule_id": "2e580225-2a58-48ef-938b-572933be06fe", + "severity": "high", + "tags": [ + "Elastic", + "Network" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0011", + "name": "Command and Control", + "reference": "https://attack.mitre.org/tactics/TA0011/" + }, + "technique": [ + { + "id": "T1071", + "name": "Application Layer Protocol", + "reference": "https://attack.mitre.org/techniques/T1071/" + }, + { + "id": "T1483", + "name": "Domain Generation Algorithms", + "reference": "https://attack.mitre.org/techniques/T1483/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_irc_internet_relay_chat_protocol_activity_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_irc_internet_relay_chat_protocol_activity_to_the_internet.json index af30861d85e04..0e35d4b1c5ca0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_irc_internet_relay_chat_protocol_activity_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_irc_internet_relay_chat_protocol_activity_to_the_internet.json @@ -42,7 +42,7 @@ "tactic": { "id": "TA0010", "name": "Exfiltration", - "reference": "https://attack.mitre.org/tactics/TA0011/" + "reference": "https://attack.mitre.org/tactics/TA0010/" }, "technique": [ { @@ -54,5 +54,5 @@ } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_port_26_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_port_26_activity.json index ed20554ae8c40..1cdfd44eb2adf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_port_26_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_port_26_activity.json @@ -46,7 +46,7 @@ "tactic": { "id": "TA0010", "name": "Exfiltration", - "reference": "https://attack.mitre.org/tactics/TA0011/" + "reference": "https://attack.mitre.org/tactics/TA0010/" }, "technique": [ { @@ -58,5 +58,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_desktopimgdownldr.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_desktopimgdownldr.json new file mode 100644 index 0000000000000..d5b21dfe2db18 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_desktopimgdownldr.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the desktopimgdownldr utility being used to download a remote file. An adversary may use desktopimgdownldr to download arbitrary files as an alternative to certutil.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Remote File Download via Desktopimgdownldr Utility", + "query": "event.category:process and event.type:(start or process_started) and (process.name:desktopimgdownldr.exe or process.pe.original_file_name:desktopimgdownldr.exe) and process.args:/lockscreenurl\\:http*", + "references": [ + "https://labs.sentinelone.com/living-off-windows-land-a-new-native-file-downldr/" + ], + "risk_score": 47, + "rule_id": "15c0b7a7-9c34-4869-b25b-fa6518414899", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0011", + "name": "Command and Control", + "reference": "https://attack.mitre.org/tactics/TA0011/" + }, + "technique": [ + { + "id": "T1105", + "name": "Ingress Tool Transfer", + "reference": "https://attack.mitre.org/techniques/T1105/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_mpcmdrun.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_mpcmdrun.json new file mode 100644 index 0000000000000..aeadc849eac17 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_mpcmdrun.json @@ -0,0 +1,46 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the Windows Defender configuration utility (MpCmdRun.exe) being used to download a remote file.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Remote File Download via MpCmdRun", + "note": "### Investigating Remote File Download via MpCmdRun\nVerify details such as the parent process, URL reputation, and downloaded file details. Additionally, `MpCmdRun` logs this information in the Appdata Temp folder in `MpCmdRun.log`.", + "query": "event.category:process and event.type:(start or process_started) and (process.name:MpCmdRun.exe or process.pe.original_file_name:MpCmdRun.exe) and process.args:((\"-DownloadFile\" or \"-downloadfile\") and \"-url\" and \"-path\")", + "references": [ + "https://twitter.com/mohammadaskar2/status/1301263551638761477", + "https://www.bleepingcomputer.com/news/microsoft/microsoft-defender-can-ironically-be-used-to-download-malware/" + ], + "risk_score": 47, + "rule_id": "c6453e73-90eb-4fe7-a98c-cde7bbfc504a", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0011", + "name": "Command and Control", + "reference": "https://attack.mitre.org/tactics/TA0011/" + }, + "technique": [ + { + "id": "T1105", + "name": "Ingress Tool Transfer", + "reference": "https://attack.mitre.org/techniques/T1105/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_teamviewer_remote_file_copy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_teamviewer_remote_file_copy.json new file mode 100644 index 0000000000000..793ff4ebda72f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_teamviewer_remote_file_copy.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies an executable or script file remotely downloaded via a TeamViewer transfer session.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Remote File Copy via TeamViewer", + "query": "event.category:file and event.type:creation and process.name:TeamViewer.exe and file.extension:(exe or dll or scr or com or bat or ps1 or vbs or vbe or js or wsh or hta)", + "references": [ + "https://blog.menasec.net/2019/11/hunting-for-suspicious-use-of.html" + ], + "risk_score": 47, + "rule_id": "b25a7df2-120a-4db2-bd3f-3e4b86b24bee", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0011", + "name": "Command and Control", + "reference": "https://attack.mitre.org/tactics/TA0011/" + }, + "technique": [ + { + "id": "T1105", + "name": "Ingress Tool Transfer", + "reference": "https://attack.mitre.org/techniques/T1105/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_telnet_port_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_telnet_port_activity.json index 561a100afa44a..4455d8adfdf83 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_telnet_port_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_telnet_port_activity.json @@ -55,9 +55,9 @@ { "framework": "MITRE ATT&CK", "tactic": { - "id": "TA0011", + "id": "TA0001", "name": "Initial Access", - "reference": "https://attack.mitre.org/tactics/TA0011/" + "reference": "https://attack.mitre.org/tactics/TA0001/" }, "technique": [ { @@ -69,5 +69,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_from_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_from_the_internet.json index 2e039544cfd99..97d2b940a6949 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_from_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_from_the_internet.json @@ -32,7 +32,7 @@ "technique": [ { "id": "T1219", - "name": "Remote Access Tools", + "name": "Remote Access Software", "reference": "https://attack.mitre.org/techniques/T1219/" } ] @@ -54,5 +54,5 @@ } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_to_the_internet.json index e4282539c5a9d..97757af22be0c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_to_the_internet.json @@ -32,12 +32,12 @@ "technique": [ { "id": "T1219", - "name": "Remote Access Tools", + "name": "Remote Access Software", "reference": "https://attack.mitre.org/techniques/T1219/" } ] } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempted_bypass_of_okta_mfa.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempted_bypass_of_okta_mfa.json index a2267755c7376..118f8f6b2ad4a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempted_bypass_of_okta_mfa.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempted_bypass_of_okta_mfa.json @@ -4,13 +4,14 @@ ], "description": "An adversary may attempt to bypass the Okta multi-factor authentication (MFA) policies configured for an organization in order to obtain unauthorized access to an application. This rule detects when an Okta MFA bypass attempt occurs.", "index": [ - "filebeat-*" + "filebeat-*", + "logs-okta*" ], "language": "kuery", "license": "Elastic License", "name": "Attempted Bypass of Okta MFA", "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.module:okta and event.dataset:okta.system and event.action:user.mfa.attempt_bypass", + "query": "event.dataset:okta.system and event.action:user.mfa.attempt_bypass", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" @@ -43,5 +44,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempts_to_brute_force_okta_user_account.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempts_to_brute_force_okta_user_account.json new file mode 100644 index 0000000000000..5aae95476e9da --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempts_to_brute_force_okta_user_account.json @@ -0,0 +1,53 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when an Okta user account is locked out 3 times within a 3 hour window. An adversary may attempt a brute force or password spraying attack to obtain unauthorized access to user accounts. The default Okta authentication policy ensures that a user account is locked out after 10 failed authentication attempts.", + "from": "now-180m", + "index": [ + "filebeat-*", + "logs-okta*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempts to Brute Force an Okta User Account", + "note": "The Okta Filebeat module must be enabled to use this rule.", + "query": "event.dataset:okta.system and event.action:user.account.lock", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 47, + "rule_id": "e08ccd49-0380-4b2b-8d71-8000377d6e49", + "severity": "medium", + "tags": [ + "Elastic", + "Okta", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1110", + "name": "Brute Force", + "reference": "https://attack.mitre.org/techniques/T1110/" + } + ] + } + ], + "threshold": { + "field": "okta.actor.id", + "value": 3 + }, + "type": "threshold", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_aws_iam_assume_role_brute_force.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_aws_iam_assume_role_brute_force.json index 4c79be6fe9045..e350c3697f685 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_aws_iam_assume_role_brute_force.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_aws_iam_assume_role_brute_force.json @@ -5,13 +5,14 @@ "description": "Identifies a high number of failed attempts to assume an AWS Identity and Access Management (IAM) role. IAM roles are used to delegate access to users or services. An adversary may attempt to enumerate IAM roles in order to determine if a role exists before attempting to assume or hijack the discovered role.", "from": "now-20m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "language": "kuery", "license": "Elastic License", "name": "AWS IAM Brute Force of Assume Role Policy", "note": "The AWS Filebeat module must be enabled to use this rule.", - "query": "event.module:aws and event.dataset:aws.cloudtrail and event.provider:iam.amazonaws.com and event.action:UpdateAssumeRolePolicy and aws.cloudtrail.error_code:MalformedPolicyDocumentException and event.outcome:failure", + "query": "event.dataset:aws.cloudtrail and event.provider:iam.amazonaws.com and event.action:UpdateAssumeRolePolicy and aws.cloudtrail.error_code:MalformedPolicyDocumentException and event.outcome:failure", "references": [ "https://www.praetorian.com/blog/aws-iam-assume-role-vulnerabilities", "https://rhinosecuritylabs.com/aws/assume-worst-aws-assume-role-enumeration/" @@ -48,5 +49,5 @@ "value": 25 }, "type": "threshold", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json index f2032b5bef218..a67fa01ab371a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json @@ -33,12 +33,12 @@ "technique": [ { "id": "T1003", - "name": "Credential Dumping", + "name": "OS Credential Dumping", "reference": "https://attack.mitre.org/techniques/T1003/" } ] } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_domain_backup_dpapi_private_keys.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_domain_backup_dpapi_private_keys.json new file mode 100644 index 0000000000000..dc4f5e11754d3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_domain_backup_dpapi_private_keys.json @@ -0,0 +1,46 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the creation or modification of Domain Backup private keys. Adversaries may extract the Data Protection API (DPAPI) domain backup key from a Domain Controller (DC) to be able to decrypt any domain user master key file.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Creation or Modification of Domain Backup DPAPI private key", + "note": "### Domain DPAPI Backup keys are stored on domain controllers and can be dumped remotely with tools such as Mimikatz. The resulting .pvk private key can be used to decrypt ANY domain user masterkeys, which then can be used to decrypt any secrets protected by those keys.", + "query": "event.category:file and not event.type:deletion and file.name:(ntds_capi_*.pfx or ntds_capi_*.pvk)", + "references": [ + "https://www.dsinternals.com/en/retrieving-dpapi-backup-keys-from-active-directory/", + "https://www.harmj0y.net/blog/redteaming/operational-guidance-for-offensive-user-dpapi-abuse/" + ], + "risk_score": 73, + "rule_id": "b83a7e96-2eb3-4edf-8346-427b6858d3bd", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1145", + "name": "Private Keys", + "reference": "https://attack.mitre.org/techniques/T1145/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_gcp_iam_service_account_key_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_gcp_iam_service_account_key_deletion.json new file mode 100644 index 0000000000000..63d5081869f1b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_gcp_iam_service_account_key_deletion.json @@ -0,0 +1,50 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of an Identity and Access Management (IAM) service account key in Google Cloud Platform (GCP). Each service account is associated with two sets of public/private RSA key pairs that are used to authenticate. If a key is deleted, the application will no longer be able to access Google Cloud resources using that key. A security best practice is to rotate your service account keys regularly.", + "false_positives": [ + "Service account key deletions may be done by a system or network administrator. Verify whether the user email, resource name, and/or hostname should be making changes in your environment. Key deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP IAM Service Account Key Deletion", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:google.iam.admin.v*.DeleteServiceAccountKey and event.outcome:success", + "references": [ + "https://cloud.google.com/iam/docs/service-accounts", + "https://cloud.google.com/iam/docs/creating-managing-service-account-keys" + ], + "risk_score": 21, + "rule_id": "9890ee61-d061-403d-9bf6-64934c51f638", + "severity": "low", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_gcp_key_created_for_service_account.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_gcp_key_created_for_service_account.json new file mode 100644 index 0000000000000..c1ae7f5fc1953 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_gcp_key_created_for_service_account.json @@ -0,0 +1,50 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a new key is created for a service account in Google Cloud Platform (GCP). A service account is a special type of account used by an application or a virtual machine (VM) instance, not a person. Applications use service accounts to make authorized API calls, authorized as either the service account itself, or as G Suite or Cloud Identity users through domain-wide delegation. If private keys are not tracked and managed properly, they can present a security risk. An adversary may create a new key for a service account in order to attempt to abuse the permissions assigned to that account and evade detection.", + "false_positives": [ + "Service account keys may be created by system administrators. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Service Account Key Creation", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:google.iam.admin.v*.CreateServiceAccountKey and event.outcome:success", + "references": [ + "https://cloud.google.com/iam/docs/service-accounts", + "https://cloud.google.com/iam/docs/creating-managing-service-account-keys" + ], + "risk_score": 21, + "rule_id": "0e5acaae-6a64-4bbc-adb8-27649c03f7e1", + "severity": "low", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iam_user_addition_to_group.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iam_user_addition_to_group.json index 5b73f849dddc5..7c5aa9bc7f3a8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iam_user_addition_to_group.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iam_user_addition_to_group.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -62,5 +63,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iis_apppoolsa_pwd_appcmd.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iis_apppoolsa_pwd_appcmd.json new file mode 100644 index 0000000000000..dd7bc43c58382 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iis_apppoolsa_pwd_appcmd.json @@ -0,0 +1,45 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the Internet Information Services (IIS) command-line tool, AppCmd, being used to list passwords. An attacker with IIS web server access via a web shell can decrypt and dump the IIS AppPool service account password using AppCmd.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "lucene", + "license": "Elastic License", + "max_signals": 33, + "name": "Microsoft IIS Service Account Password Dumped", + "query": "event.category:process AND event.type:(start OR process_started) AND (process.name:appcmd.exe OR process.pe.original_file_name:appcmd.exe) AND process.args:(/[lL][iI][sS][tT]/ AND /\\/[tT][eE][xX][tT]\\:[pP][aA][sS][sS][wW][oO][rR][dD]/)", + "references": [ + "https://blog.netspi.com/decrypting-iis-passwords-to-break-out-of-the-dmz-part-1/" + ], + "risk_score": 73, + "rule_id": "0564fb9d-90b9-4234-a411-82a546dc1343", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1003", + "name": "OS Credential Dumping", + "reference": "https://attack.mitre.org/techniques/T1003/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iis_connectionstrings_dumping.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iis_connectionstrings_dumping.json new file mode 100644 index 0000000000000..2735fcbbd6130 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iis_connectionstrings_dumping.json @@ -0,0 +1,46 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies use of aspnet_regiis to decrypt Microsoft IIS connection strings. An attacker with Microsoft IIS web server access via a webshell or alike can decrypt and dump any hardcoded connection strings, such as the MSSQL service account password using aspnet_regiis command.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "max_signals": 33, + "name": "Microsoft IIS Connection Strings Decryption", + "query": "event.category:process and event.type:(start or process_started) and (process.name:aspnet_regiis.exe or process.pe.original_file_name:aspnet_regiis.exe) and process.args:(connectionStrings and \"-pdf\")", + "references": [ + "https://blog.netspi.com/decrypting-iis-passwords-to-break-out-of-the-dmz-part-1/", + "https://symantec-enterprise-blogs.security.com/blogs/threat-intelligence/greenbug-espionage-telco-south-asia" + ], + "risk_score": 73, + "rule_id": "c25e9c87-95e1-4368-bfab-9fd34cf867ec", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1003", + "name": "OS Credential Dumping", + "reference": "https://attack.mitre.org/techniques/T1003/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_kerberosdump_kcc.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_kerberosdump_kcc.json new file mode 100644 index 0000000000000..4713d09f8adec --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_kerberosdump_kcc.json @@ -0,0 +1,45 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the use of the Kerberos credential cache (kcc) utility to dump locally cached Kerberos tickets.", + "from": "now-9m", + "index": [ + "auditbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Kerberos Cached Credentials Dumping", + "query": "event.category:process and event.type:(start or process_started) and process.name:kcc and process.args:copy_cred_cache", + "references": [ + "https://github.com/EmpireProject/EmPyre/blob/master/lib/modules/collection/osx/kerberosdump.py", + "https://opensource.apple.com/source/Heimdal/Heimdal-323.12/kuser/kcc-commands.in.auto.html" + ], + "risk_score": 73, + "rule_id": "ad88231f-e2ab-491c-8fc6-64746da26cfe", + "severity": "high", + "tags": [ + "Elastic", + "MacOS" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1003", + "name": "OS Credential Dumping", + "reference": "https://attack.mitre.org/techniques/T1003/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_key_vault_modified.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_key_vault_modified.json new file mode 100644 index 0000000000000..a45591c73dcb3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_key_vault_modified.json @@ -0,0 +1,51 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies modifications to a Key Vault in Azure. The Key Vault is a service that safeguards encryption keys and secrets like certificates, connection strings, and passwords. Because this data is sensitive and business critical, access to key vaults should be secured to allow only authorized applications and users.", + "false_positives": [ + "Key vault modifications may be done by a system or network administrator. Verify whether the username, hostname, and/or resource name should be making changes in your environment. Key vault modifications from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Azure Key Vault Modified", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.activitylogs and azure.activitylogs.operation_name:MICROSOFT.KEYVAULT/VAULTS/WRITE and event.outcome:Success", + "references": [ + "https://docs.microsoft.com/en-us/azure/key-vault/general/basic-concepts", + "https://docs.microsoft.com/en-us/azure/key-vault/general/secure-your-key-vault" + ], + "risk_score": 47, + "rule_id": "792dd7a6-7e00-4a0a-8a9a-a7c24720b5ec", + "severity": "medium", + "tags": [ + "Elastic", + "Azure", + "SecOps", + "Continuous Monitoring", + "Data Protection" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1081", + "name": "Credentials in Files", + "reference": "https://attack.mitre.org/techniques/T1081/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_mimikatz_memssp_default_logs.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_mimikatz_memssp_default_logs.json new file mode 100644 index 0000000000000..fa1f99eef7f00 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_mimikatz_memssp_default_logs.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the password log file from the default Mimikatz memssp module.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Mimikatz Memssp Log File Detected", + "query": "event.category:file and file.name:mimilsa.log and process.name:lsass.exe", + "risk_score": 73, + "rule_id": "ebb200e8-adf0-43f8-a0bb-4ee5b5d852c6", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1003", + "name": "OS Credential Dumping", + "reference": "https://attack.mitre.org/techniques/T1003/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_okta_brute_force_or_password_spraying.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_okta_brute_force_or_password_spraying.json index 2cf24d54b7268..c36f878792ccf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_okta_brute_force_or_password_spraying.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_okta_brute_force_or_password_spraying.json @@ -7,13 +7,14 @@ "Automated processes that attempt to authenticate using expired credentials and unbounded retries may lead to false positives." ], "index": [ - "filebeat-*" + "filebeat-*", + "logs-okta*" ], "language": "kuery", "license": "Elastic License", "name": "Okta Brute Force or Password Spraying Attack", "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.module:okta and event.dataset:okta.system and event.category:authentication and event.outcome:failure", + "query": "event.dataset:okta.system and event.category:authentication and event.outcome:failure", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" @@ -50,5 +51,5 @@ "value": 25 }, "type": "threshold", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_secretsmanager_getsecretvalue.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_secretsmanager_getsecretvalue.json index ef20746fb1d80..879e93750df9c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_secretsmanager_getsecretvalue.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_secretsmanager_getsecretvalue.json @@ -9,7 +9,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -49,5 +50,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_storage_account_key_regenerated.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_storage_account_key_regenerated.json new file mode 100644 index 0000000000000..2a3dc85294a9d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_storage_account_key_regenerated.json @@ -0,0 +1,50 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a rotation to storage account access keys in Azure. Regenerating access keys can affect any applications or Azure services that are dependent on the storage account key. Adversaries may regenerate a key as a means of acquiring credentials to access systems and resources.", + "false_positives": [ + "It's recommended that you rotate your access keys periodically to help keep your storage account secure. Normal key rotation can be exempted from the rule. An abnormal time frame and/or a key rotation from unfamiliar users, hosts, or locations should be investigated." + ], + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Azure Storage Account Key Regenerated", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.activitylogs and azure.activitylogs.operation_name:MICROSOFT.STORAGE/STORAGEACCOUNTS/REGENERATEKEY/ACTION and event.outcome:Success", + "references": [ + "https://docs.microsoft.com/en-us/azure/storage/common/storage-account-keys-manage?tabs=azure-portal" + ], + "risk_score": 21, + "rule_id": "1e0b832e-957e-43ae-b319-db82d228c908", + "severity": "low", + "tags": [ + "Azure", + "Elastic", + "SecOps", + "Continuous Monitoring", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1528", + "name": "Steal Application Access Token", + "reference": "https://attack.mitre.org/techniques/T1528/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_suspicious_okta_user_password_reset_or_unlock_attempts.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_suspicious_okta_user_password_reset_or_unlock_attempts.json new file mode 100644 index 0000000000000..5f115416fa032 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_suspicious_okta_user_password_reset_or_unlock_attempts.json @@ -0,0 +1,86 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a high number of Okta user password reset or account unlock attempts. An adversary may attempt to obtain unauthorized access to an Okta user account using these methods and attempt to blend in with normal activity in their target's environment and evade detection.", + "false_positives": [ + "The number of Okta user password reset or account unlock attempts will likely vary between organizations. To fit this rule to their organization, users can duplicate this rule and edit the schedule and threshold values in the new rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*", + "logs-okta*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "High Number of Okta User Password Reset or Unlock Attempts", + "note": "The Okta Filebeat module must be enabled to use this rule.", + "query": "event.dataset:okta.system and event.action:(system.email.account_unlock.sent_message or system.email.password_reset.sent_message or system.sms.send_account_unlock_message or system.sms.send_password_reset_message or system.voice.send_account_unlock_call or system.voice.send_password_reset_call or user.account.unlock_token)", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 47, + "rule_id": "e90ee3af-45fc-432e-a850-4a58cf14a457", + "severity": "medium", + "tags": [ + "Elastic", + "Okta", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0001", + "name": "Initial Access", + "reference": "https://attack.mitre.org/tactics/TA0001/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + } + ], + "threshold": { + "field": "okta.actor.id", + "value": 5 + }, + "type": "threshold", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_azure_conditional_access_policy_modified.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_azure_conditional_access_policy_modified.json new file mode 100644 index 0000000000000..8d4d4b971316e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_azure_conditional_access_policy_modified.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when an Azure Conditional Access policy is modified. Azure Conditional Access policies control access to resources via if-then statements. For example, if a user wants to access a resource, then they must complete an action such as using multi-factor authentication to access it. An adversary may modify a Conditional Access policy in order to weaken their target's security controls.", + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Azure Conditional Access Policy Modified", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:(azure.activitylogs or azure.auditlogs) and ( azure.activitylogs.operation_name:\"Update policy\" or azure.auditlogs.operation_name:\"Update policy\" ) and event.outcome:success", + "references": [ + "https://docs.microsoft.com/en-us/azure/active-directory/conditional-access/overview" + ], + "risk_score": 47, + "rule_id": "bc48bba7-4a23-4232-b551-eca3ca1e3f20", + "severity": "medium", + "tags": [ + "Elastic", + "Azure", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_azure_diagnostic_settings_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_azure_diagnostic_settings_deletion.json new file mode 100644 index 0000000000000..49d98813dc040 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_azure_diagnostic_settings_deletion.json @@ -0,0 +1,50 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of diagnostic settings in Azure, which send platform logs and metrics to different destinations. An adversary may delete diagnostic settings in an attempt to evade defenses.", + "false_positives": [ + "Deletion of diagnostic settings may be done by a system or network administrator. Verify whether the username, hostname, and/or resource name should be making changes in your environment. Diagnostic settings deletion from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Azure Diagnostic Settings Deletion", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.activitylogs and azure.activitylogs.operation_name:MICROSOFT.INSIGHTS/DIAGNOSTICSETTINGS/DELETE and event.outcome:Success", + "references": [ + "https://docs.microsoft.com/en-us/azure/azure-monitor/platform/diagnostic-settings" + ], + "risk_score": 47, + "rule_id": "5370d4cd-2bb3-4d71-abf5-1e1d0ff5a2de", + "severity": "medium", + "tags": [ + "Elastic", + "Azure", + "SecOps", + "Continuous Monitoring", + "Monitoring" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_azure_privileged_identity_management_role_modified.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_azure_privileged_identity_management_role_modified.json new file mode 100644 index 0000000000000..f675a490c4e05 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_azure_privileged_identity_management_role_modified.json @@ -0,0 +1,63 @@ +{ + "author": [ + "Elastic" + ], + "description": "Azure Active Directory (AD) Privileged Identity Management (PIM) is a service that enables you to manage, control, and monitor access to important resources in an organization. PIM can be used to manage the built-in Azure resource roles such as Global Administrator and Application Administrator. An adversary may add a user to a PIM role in order to maintain persistence in their target's environment or modify a PIM role to weaken their target's security controls.", + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Azure Privilege Identity Management Role Modified", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.auditlogs and azure.auditlogs.operation_name:\"Update role setting in PIM\" and event.outcome:Success", + "references": [ + "https://docs.microsoft.com/en-us/azure/active-directory/privileged-identity-management/pim-resource-roles-assign-roles", + "https://docs.microsoft.com/en-us/azure/active-directory/privileged-identity-management/pim-configure" + ], + "risk_score": 47, + "rule_id": "7882cebf-6cf1-4de3-9662-213aa13e8b80", + "severity": "medium", + "tags": [ + "Elastic", + "Azure", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_deleted.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_deleted.json index b5e76b6ebfa36..64261af2a3105 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_deleted.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_deleted.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -48,5 +49,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_suspended.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_suspended.json index 6ba9503edc260..090073698026d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_suspended.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_suspended.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -48,5 +49,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudwatch_alarm_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudwatch_alarm_deletion.json index 3d31eead43c8f..aeaf0a4168814 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudwatch_alarm_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudwatch_alarm_deletion.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -48,5 +49,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_code_injection_conhost.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_code_injection_conhost.json new file mode 100644 index 0000000000000..63c7ea12b3b6b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_code_injection_conhost.json @@ -0,0 +1,45 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a suspicious Conhost child process which may be an indication of code injection activity.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Suspicious Process from Conhost", + "query": "event.category:process and event.type:(start or process_started) and process.parent.name:conhost.exe", + "references": [ + "https://modexp.wordpress.com/2018/09/12/process-injection-user-data/", + "https://github.com/sbousseaden/EVTX-ATTACK-SAMPLES/blob/master/Defense%20Evasion/evasion_codeinj_odzhan_conhost_sysmon_10_1.evtx" + ], + "risk_score": 73, + "rule_id": "28896382-7d4f-4d50-9b72-67091901fd26", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1055", + "name": "Process Injection", + "reference": "https://attack.mitre.org/techniques/T1055/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_config_service_rule_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_config_service_rule_deletion.json index 22ceb35dfc856..268f52a8efd5e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_config_service_rule_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_config_service_rule_deletion.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -48,5 +49,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_configuration_recorder_stopped.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_configuration_recorder_stopped.json index 95e357e56fe3b..b926937450f5a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_configuration_recorder_stopped.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_configuration_recorder_stopped.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -48,5 +49,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_flow_log_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_flow_log_deletion.json index 809a9a187937d..9f3d4e6b5e379 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_flow_log_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_flow_log_deletion.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -48,5 +49,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_network_acl_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_network_acl_deletion.json index 8467b87f9983c..6ecc9ad3d558d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_network_acl_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_network_acl_deletion.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -50,5 +51,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_event_hub_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_event_hub_deletion.json new file mode 100644 index 0000000000000..29df07cced4d7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_event_hub_deletion.json @@ -0,0 +1,52 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies an Event Hub deletion in Azure. An Event Hub is an event processing service that ingests and processes large volumes of events and data. An adversary may delete an Event Hub in an attempt to evade detection.", + "false_positives": [ + "Event Hub deletions may be done by a system or network administrator. Verify whether the username, hostname, and/or resource name should be making changes in your environment. Event Hub deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Azure Event Hub Deletion", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.activitylogs and azure.activitylogs.operation_name:MICROSOFT.EVENTHUB/NAMESPACES/EVENTHUBS/DELETE and event.outcome:Success", + "references": [ + "https://docs.microsoft.com/en-us/azure/event-hubs/event-hubs-about", + "https://azure.microsoft.com/en-in/services/event-hubs/", + "https://docs.microsoft.com/en-us/azure/event-hubs/event-hubs-features" + ], + "risk_score": 47, + "rule_id": "e0f36de1-0342-453d-95a9-a068b257b053", + "severity": "medium", + "tags": [ + "Azure", + "Elastic", + "SecOps", + "Continuous Monitoring", + "Logging" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_office_app.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_office_app.json index df7fc85b63d4a..a987c00b392ec 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_office_app.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_office_app.json @@ -36,7 +36,7 @@ "technique": [ { "id": "T1127", - "name": "Trusted Developer Utilities", + "name": "Trusted Developer Utilities Proxy Execution", "reference": "https://attack.mitre.org/techniques/T1127/" } ] @@ -51,12 +51,12 @@ "technique": [ { "id": "T1127", - "name": "Trusted Developer Utilities", + "name": "Trusted Developer Utilities Proxy Execution", "reference": "https://attack.mitre.org/techniques/T1127/" } ] } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_script.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_script.json index aa4674f75bcd0..0537f27bad463 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_script.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_script.json @@ -33,7 +33,7 @@ "technique": [ { "id": "T1127", - "name": "Trusted Developer Utilities", + "name": "Trusted Developer Utilities Proxy Execution", "reference": "https://attack.mitre.org/techniques/T1127/" } ] @@ -48,12 +48,12 @@ "technique": [ { "id": "T1127", - "name": "Trusted Developer Utilities", + "name": "Trusted Developer Utilities Proxy Execution", "reference": "https://attack.mitre.org/techniques/T1127/" } ] } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_system_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_system_process.json index da7d91933bd2a..11fdd128475dc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_system_process.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_system_process.json @@ -33,7 +33,7 @@ "technique": [ { "id": "T1127", - "name": "Trusted Developer Utilities", + "name": "Trusted Developer Utilities Proxy Execution", "reference": "https://attack.mitre.org/techniques/T1127/" } ] @@ -48,12 +48,12 @@ "technique": [ { "id": "T1127", - "name": "Trusted Developer Utilities", + "name": "Trusted Developer Utilities Proxy Execution", "reference": "https://attack.mitre.org/techniques/T1127/" } ] } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json index 8e4f7366a7657..a90e5ebc57800 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json @@ -14,7 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "Microsoft Build Engine Using an Alternate Name", - "query": "event.category:process and event.type:(start or process_started) and (pe.original_file_name:MSBuild.exe or winlog.event_data.OriginalFileName:MSBuild.exe) and not process.name: MSBuild.exe", + "query": "event.category:process and event.type:(start or process_started) and (process.pe.original_file_name:MSBuild.exe or winlog.event_data.OriginalFileName:MSBuild.exe) and not process.name: MSBuild.exe", "risk_score": 21, "rule_id": "9d110cb3-5f4b-4c9a-b9f5-53f0a1707ae4", "severity": "low", @@ -40,5 +40,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_suspicious_explorer_winword.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_suspicious_explorer_winword.json new file mode 100644 index 0000000000000..69d334cf13fdb --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_suspicious_explorer_winword.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies an instance of a Windows trusted program that is known to be vulnerable to DLL Search Order Hijacking starting after being renamed or from a non-standard path. This is uncommon behavior and may indicate an attempt to evade defenses via side loading a malicious DLL within the memory space of one of those processes.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Potential DLL SideLoading via Trusted Microsoft Programs", + "query": "event.category:process and event.type:(start or process_started) and process.pe.original_file_name:(WinWord.exe or EXPLORER.EXE or w3wp.exe or DISM.EXE) and not (process.name:(winword.exe or WINWORD.EXE or explorer.exe or w3wp.exe or Dism.exe) or process.executable:(\"C:\\Windows\\explorer.exe\" or C\\:\\\\Program?Files\\\\Microsoft?Office\\\\root\\\\Office*\\\\WINWORD.EXE or C\\:\\\\Program?Files?\\(x86\\)\\\\Microsoft?Office\\\\root\\\\Office*\\\\WINWORD.EXE or \"C:\\Windows\\System32\\Dism.exe\" or \"C:\\Windows\\SysWOW64\\Dism.exe\" or \"C:\\Windows\\System32\\inetsrv\\w3wp.exe\"))", + "risk_score": 73, + "rule_id": "1160dcdb-0a0a-4a79-91d8-9b84616edebd", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1036", + "name": "Masquerading", + "reference": "https://attack.mitre.org/techniques/T1036/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_suspicious_psexesvc.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_suspicious_psexesvc.json new file mode 100644 index 0000000000000..51396fb5995f6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_suspicious_psexesvc.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies suspicious psexec activity which is executing from the psexec service that has been renamed, possibly to evade detection.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Suspicious Process Execution via Renamed PsExec Executable", + "query": "event.category:process and event.type:(start or process_started) and process.pe.original_file_name:(psexesvc.exe or PSEXESVC.exe) and process.parent.name:services.exe and not process.name:(psexesvc.exe or PSEXESVC.exe)", + "risk_score": 47, + "rule_id": "e2f9fdf5-8076-45ad-9427-41e0e03dc9c2", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1035", + "name": "Service Execution", + "reference": "https://attack.mitre.org/techniques/T1035/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_via_trusted_developer_utilities.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_via_trusted_developer_utilities.json index 480169e5ed991..a1d14155cc3b3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_via_trusted_developer_utilities.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_via_trusted_developer_utilities.json @@ -12,7 +12,7 @@ "language": "kuery", "license": "Elastic License", "name": "Trusted Developer Application Usage", - "query": "event.code:1 and process.name:(MSBuild.exe or msxsl.exe)", + "query": "event.category:process and event.type:(start or process_started) and process.name:(MSBuild.exe or msxsl.exe)", "risk_score": 21, "rule_id": "9d110cb3-5f4b-4c9a-b9f5-53f0a1707ae1", "severity": "low", @@ -31,7 +31,7 @@ "technique": [ { "id": "T1127", - "name": "Trusted Developer Utilities", + "name": "Trusted Developer Utilities Proxy Execution", "reference": "https://attack.mitre.org/techniques/T1127/" } ] @@ -46,12 +46,12 @@ "technique": [ { "id": "T1127", - "name": "Trusted Developer Utilities", + "name": "Trusted Developer Utilities Proxy Execution", "reference": "https://attack.mitre.org/techniques/T1127/" } ] } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_firewall_policy_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_firewall_policy_deletion.json new file mode 100644 index 0000000000000..759fc9d5ecb1f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_firewall_policy_deletion.json @@ -0,0 +1,50 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of a firewall policy in Azure. An adversary may delete a firewall policy in an attempt to evade defenses and/or to eliminate barriers in carrying out their initiative.", + "false_positives": [ + "Firewall policy deletions may be done by a system or network administrator. Verify whether the username, hostname, and/or resource name should be making changes in your environment. Firewall policy deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Azure Firewall Policy Deletion", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.activitylogs and azure.activitylogs.operation_name:MICROSOFT.NETWORK/FIREWALLPOLICIES/DELETE and event.outcome:Success", + "references": [ + "https://docs.microsoft.com/en-us/azure/firewall-manager/policy-overview" + ], + "risk_score": 21, + "rule_id": "e02bd3ea-72c6-4181-ac2b-0f83d17ad969", + "severity": "low", + "tags": [ + "Azure", + "Elastic", + "SecOps", + "Continuous Monitoring", + "Network" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_firewall_rule_created.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_firewall_rule_created.json new file mode 100644 index 0000000000000..b80a5f0e17949 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_firewall_rule_created.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a firewall rule is created in Google Cloud Platform (GCP). Virtual Private Cloud (VPC) firewall rules can be configured to allow or deny connections to or from virtual machine (VM) instances. An adversary may create a new firewall rule in order to weaken their target's security controls and allow more permissive ingress or egress traffic flows for their benefit.", + "false_positives": [ + "Firewall rules may be created by system administrators. Verify that the firewall configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Firewall Rule Creation", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:v*.compute.firewalls.insert", + "references": [ + "https://cloud.google.com/vpc/docs/firewalls" + ], + "risk_score": 21, + "rule_id": "30562697-9859-4ae0-a8c5-dab45d664170", + "severity": "low", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1562", + "name": "Impair Defenses", + "reference": "https://attack.mitre.org/techniques/T1562/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_firewall_rule_deleted.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_firewall_rule_deleted.json new file mode 100644 index 0000000000000..64c8d01df47e9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_firewall_rule_deleted.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a firewall rule is deleted in Google Cloud Platform (GCP). Virtual Private Cloud (VPC) firewall rules can be configured to allow or deny connections to or from virtual machine (VM) instances. An adversary may delete a firewall rule in order to weaken their target's security controls.", + "false_positives": [ + "Firewall rules may be deleted by system administrators. Verify that the firewall configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Firewall Rule Deletion", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:v*.compute.firewalls.delete", + "references": [ + "https://cloud.google.com/vpc/docs/firewalls" + ], + "risk_score": 47, + "rule_id": "ff9b571e-61d6-4f6c-9561-eb4cca3bafe1", + "severity": "medium", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1562", + "name": "Impair Defenses", + "reference": "https://attack.mitre.org/techniques/T1562/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_firewall_rule_modified.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_firewall_rule_modified.json new file mode 100644 index 0000000000000..b2c0e259b45e0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_firewall_rule_modified.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a firewall rule is modified in Google Cloud Platform (GCP). Virtual Private Cloud (VPC) firewall rules can be configured to allow or deny connections to or from virtual machine (VM) instances. An adversary may modify a firewall rule in order to weaken their target's security controls.", + "false_positives": [ + "Firewall rules may be modified by system administrators. Verify that the firewall configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Firewall Rule Modification", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:v*.compute.firewalls.patch", + "references": [ + "https://cloud.google.com/vpc/docs/firewalls" + ], + "risk_score": 47, + "rule_id": "2783d84f-5091-4d7d-9319-9fceda8fa71b", + "severity": "medium", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1562", + "name": "Impair Defenses", + "reference": "https://attack.mitre.org/techniques/T1562/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_logging_bucket_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_logging_bucket_deletion.json new file mode 100644 index 0000000000000..62447b789d632 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_logging_bucket_deletion.json @@ -0,0 +1,50 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a Logging bucket deletion in Google Cloud Platform (GCP). Log buckets are containers that store and organize log data. A deleted bucket stays in a pending state for 7 days, and Logging continues to route logs to the bucket during that time. To stop routing logs to a deleted bucket, the log sinks can be deleted that have the bucket as a destination, or the filter for the sinks can be modified to stop routing logs to the deleted bucket. An adversary may delete a log bucket to evade detection.", + "false_positives": [ + "Logging bucket deletions may be done by a system or network administrator. Verify whether the user email, resource name, and/or hostname should be making changes in your environment. Logging bucket deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Logging Bucket Deletion", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:google.logging.v*.ConfigServiceV*.DeleteBucket and event.outcome:success", + "references": [ + "https://cloud.google.com/logging/docs/buckets", + "https://cloud.google.com/logging/docs/storage" + ], + "risk_score": 47, + "rule_id": "5663b693-0dea-4f2e-8275-f1ae5ff2de8e", + "severity": "medium", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Logging" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1562", + "name": "Impair Defenses", + "reference": "https://attack.mitre.org/techniques/T1562/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_logging_sink_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_logging_sink_deletion.json new file mode 100644 index 0000000000000..0fc83070ffbb7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_logging_sink_deletion.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a Logging sink deletion in Google Cloud Platform (GCP). Every time a log entry arrives, Logging compares the log entry to the sinks in that resource. Each sink whose filter matches the log entry writes a copy of the log entry to the sink's export destination. An adversary may delete a Logging sink to evade detection.", + "false_positives": [ + "Logging sink deletions may be done by a system or network administrator. Verify whether the user email, resource name, and/or hostname should be making changes in your environment. Logging sink deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Logging Sink Deletion", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:google.logging.v*.ConfigServiceV*.DeleteSink and event.outcome:success", + "references": [ + "https://cloud.google.com/logging/docs/export" + ], + "risk_score": 47, + "rule_id": "51859fa0-d86b-4214-bf48-ebb30ed91305", + "severity": "medium", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Logging" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1562", + "name": "Impair Defenses", + "reference": "https://attack.mitre.org/techniques/T1562/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_pub_sub_subscription_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_pub_sub_subscription_deletion.json new file mode 100644 index 0000000000000..2ae47140b66a5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_pub_sub_subscription_deletion.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of a subscription in Google Cloud Platform (GCP). In GCP, the publisher-subscriber relationship (Pub/Sub) is an asynchronous messaging service that decouples event-producing and event-processing services. A subscription is a named resource representing the stream of messages to be delivered to the subscribing application.", + "false_positives": [ + "Subscription deletions may be done by a system or network administrator. Verify whether the user email, resource name, and/or hostname should be making changes in your environment. Subscription deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Pub/Sub Subscription Deletion", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:google.pubsub.v*.Subscriber.DeleteSubscription and event.outcome:success", + "references": [ + "https://cloud.google.com/pubsub/docs/overview" + ], + "risk_score": 21, + "rule_id": "cc89312d-6f47-48e4-a87c-4977bd4633c3", + "severity": "low", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Logging" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1562", + "name": "Impair Defenses", + "reference": "https://attack.mitre.org/techniques/T1562/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_pub_sub_topic_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_pub_sub_topic_deletion.json new file mode 100644 index 0000000000000..f276af3e21862 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_pub_sub_topic_deletion.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of a topic in Google Cloud Platform (GCP). In GCP, the publisher-subscriber relationship (Pub/Sub) is an asynchronous messaging service that decouples event-producing and event-processing services. A publisher application creates and sends messages to a topic. Deleting a topic can interrupt message flow in the Pub/Sub pipeline.", + "false_positives": [ + "Topic deletions may be done by a system or network administrator. Verify whether the user email, resource name, and/or hostname should be making changes in your environment. Topic deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Pub/Sub Topic Deletion", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:google.pubsub.v*.Publisher.DeleteTopic and event.outcome:success", + "references": [ + "https://cloud.google.com/pubsub/docs/overview" + ], + "risk_score": 21, + "rule_id": "3202e172-01b1-4738-a932-d024c514ba72", + "severity": "low", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Logging" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1562", + "name": "Impair Defenses", + "reference": "https://attack.mitre.org/techniques/T1562/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_storage_bucket_configuration_modified.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_storage_bucket_configuration_modified.json new file mode 100644 index 0000000000000..3b18732137c32 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_storage_bucket_configuration_modified.json @@ -0,0 +1,32 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when the configuration is modified for a storage bucket in Google Cloud Platform (GCP). An adversary may modify the configuration of a storage bucket in order to weaken the security controls of their target's environment.", + "false_positives": [ + "Storage bucket configuration may be modified by system administrators. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Storage Bucket Configuration Modification", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:storage.buckets.update and event.outcome:success", + "references": [ + "https://cloud.google.com/storage/docs/key-terms#buckets" + ], + "risk_score": 47, + "rule_id": "97359fd8-757d-4b1d-9af1-ef29e4a8680e", + "severity": "medium", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_storage_bucket_permissions_modified.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_storage_bucket_permissions_modified.json new file mode 100644 index 0000000000000..ad6beb9383eea --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_gcp_storage_bucket_permissions_modified.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when the Identity and Access Management (IAM) permissions are modified for a Google Cloud Platform (GCP) storage bucket. An adversary may modify the permissions on a storage bucket to weaken their target's security controls or an administrator may inadvertently modify the permissions, which could lead to data exposure or loss.", + "false_positives": [ + "Storage bucket permissions may be modified by system administrators. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Storage Bucket Permissions Modification", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:storage.setIamPermissions and event.outcome:success", + "references": [ + "https://cloud.google.com/storage/docs/access-control/iam-permissions" + ], + "risk_score": 47, + "rule_id": "2326d1b2-9acf-4dee-bd21-867ea7378b4d", + "severity": "medium", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1222", + "name": "File and Directory Permissions Modification", + "reference": "https://attack.mitre.org/techniques/T1222/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_guardduty_detector_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_guardduty_detector_deletion.json index c2590a2f062cc..3910b8e4039ad 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_guardduty_detector_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_guardduty_detector_deletion.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -48,5 +49,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_iis_httplogging_disabled.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_iis_httplogging_disabled.json new file mode 100644 index 0000000000000..507260f04d016 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_iis_httplogging_disabled.json @@ -0,0 +1,42 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when Internet Information Services (IIS) HTTP Logging is disabled on a server. An attacker with IIS server access via a webshell or other mechanism can disable HTTP Logging as an effective anti-forensics measure.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "max_signals": 33, + "name": "IIS HTTP Logging Disabled", + "query": "event.category:process and event.type:(start or process_started) and (process.name:appcmd.exe or process.pe.original_file_name:appcmd.exe) and process.args:/dontLog\\:\\\"True\\\" and not process.parent.name:iissetup.exe", + "risk_score": 73, + "rule_id": "ebf1adea-ccf2-4943-8b96-7ab11ca173a5", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1070", + "name": "Indicator Removal on Host", + "reference": "https://attack.mitre.org/techniques/T1070/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_as_elastic_endpoint_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_as_elastic_endpoint_process.json new file mode 100644 index 0000000000000..8b7ef47443e2f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_as_elastic_endpoint_process.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "A suspicious Endpoint Security parent process was detected. This may indicate a process hollowing or other form of code injection.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Suspicious Endpoint Security Parent Process", + "query": "event.category:process and event.type:(start or process_started) and process.name:(esensor.exe or \"elastic-endpoint.exe\" or \"elastic-agent.exe\") and not process.parent.executable:\"C:\\Windows\\System32\\services.exe\"", + "risk_score": 47, + "rule_id": "b41a13c6-ba45-4bab-a534-df53d0cfed6a", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1036", + "name": "Masquerading", + "reference": "https://attack.mitre.org/techniques/T1036/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_renamed_autoit.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_renamed_autoit.json new file mode 100644 index 0000000000000..cc964bfdd3e92 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_renamed_autoit.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a suspicious AutoIt process execution. Malware written as AutoIt scripts tend to rename the AutoIt executable to avoid detection.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "lucene", + "license": "Elastic License", + "name": "Renamed AutoIt Scripts Interpreter", + "query": "event.category:process AND event.type:(start OR process_started) AND process.pe.original_file_name:/[aA][uU][tT][oO][iI][tT]\\d\\.[eE][xX][eE]/ AND NOT process.name:/[aA][uU][tT][oO][iI][tT]\\d{1,3}\\.[eE][xX][eE]/", + "risk_score": 47, + "rule_id": "2e1e835d-01e5-48ca-b9fc-7a61f7f11902", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1036", + "name": "Masquerading", + "reference": "https://attack.mitre.org/techniques/T1036/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_suspicious_werfault_childproc.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_suspicious_werfault_childproc.json new file mode 100644 index 0000000000000..3000e7ac86daa --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_suspicious_werfault_childproc.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "A suspicious WerFault child process was detected, which may indicate an attempt to run unnoticed. Verify process details such as command line, network connections, file writes and parent process details as well.", + "false_positives": [ + "Custom Windows Error Reporting Debugger" + ], + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Suspicious WerFault Child Process", + "query": "event.category:process and event.type:(start or process_started) and process.parent.name:WerFault.exe and not process.name:(cofire.exe or psr.exe or VsJITDebugger.exe or TTTracer.exe or rundll32.exe)", + "references": [ + "https://www.hexacorn.com/blog/2019/09/19/silentprocessexit-quick-look-under-the-hood/", + "https://github.com/sbousseaden/EVTX-ATTACK-SAMPLES/blob/master/Persistence/persistence_SilentProcessExit_ImageHijack_sysmon_13_1.evtx" + ], + "risk_score": 47, + "rule_id": "ac5012b8-8da8-440b-aaaf-aedafdea2dff", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1036", + "name": "Masquerading", + "reference": "https://attack.mitre.org/techniques/T1036/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_werfault.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_werfault.json new file mode 100644 index 0000000000000..db421146085ff --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_werfault.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a suspicious WerFault command line parameter, which may indicate an attempt to run unnoticed.", + "false_positives": [ + "Legit Application Crash with rare Werfault commandline value" + ], + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Process Potentially Masquerading as WerFault", + "query": "event.category:process and event.type:(start or process_started) and process.name:WerFault.exe and not process.args:(((\"-u\" or \"-pss\") and \"-p\" and \"-s\") or (\"/h\" and \"/shared\") or (\"-k\" and \"-lcq\"))", + "references": [ + "https://twitter.com/SBousseaden/status/1235533224337641473", + "https://www.hexacorn.com/blog/2019/09/20/werfault-command-line-switches-v0-1/" + ], + "risk_score": 47, + "rule_id": "6ea41894-66c3-4df7-ad6b-2c5074eb3df8", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1036", + "name": "Masquerading", + "reference": "https://attack.mitre.org/techniques/T1036/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_mfa_disabled_for_azure_user.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_mfa_disabled_for_azure_user.json new file mode 100644 index 0000000000000..eda6f5b2bdf62 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_mfa_disabled_for_azure_user.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when multi-factor authentication (MFA) is disabled for an Azure user account. An adversary may disable MFA for a user account in order to weaken the authentication requirements for the account.", + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Multi-Factor Authentication Disabled for an Azure User", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.auditlogs and azure.auditlogs.operation_name:\"Disable Strong Authentication\" and event.outcome:Success", + "risk_score": 47, + "rule_id": "dafa3235-76dc-40e2-9f71-1773b96d24cf", + "severity": "medium", + "tags": [ + "Elastic", + "Azure", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_network_watcher_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_network_watcher_deletion.json new file mode 100644 index 0000000000000..09bbba5a049e4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_network_watcher_deletion.json @@ -0,0 +1,50 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of a Network Watcher in Azure. Network Watchers are used to monitor, diagnose, view metrics, and enable or disable logs for resources in an Azure virtual network. An adversary may delete a Network Watcher in an attempt to evade defenses.", + "false_positives": [ + "Network Watcher deletions may be done by a system or network administrator. Verify whether the username, hostname, and/or resource name should be making changes in your environment. Network Watcher deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Azure Network Watcher Deletion", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.activitylogs and azure.activitylogs.operation_name:MICROSOFT.NETWORK/NETWORKWATCHERS/DELETE and event.outcome:Success", + "references": [ + "https://docs.microsoft.com/en-us/azure/network-watcher/network-watcher-monitoring-overview" + ], + "risk_score": 47, + "rule_id": "323cb487-279d-4218-bcbd-a568efe930c6", + "severity": "medium", + "tags": [ + "Elastic", + "Azure", + "SecOps", + "Continuous Monitoring", + "Network" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_s3_bucket_configuration_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_s3_bucket_configuration_deletion.json index 13d0eb267f640..adbe310b784e5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_s3_bucket_configuration_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_s3_bucket_configuration_deletion.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -51,5 +52,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_sdelete_like_filename_rename.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_sdelete_like_filename_rename.json new file mode 100644 index 0000000000000..ec3030d44ff29 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_sdelete_like_filename_rename.json @@ -0,0 +1,42 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects file name patterns generated by the use of Sysinternals SDelete utility to securely delete a file via multiple file overwrite and rename operations.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "lucene", + "license": "Elastic License", + "name": "Potential Secure File Deletion via SDelete Utility", + "note": "Verify process details such as command line and hash to confirm this activity legitimacy.", + "query": "event.category:file AND event.type:change AND file.name:/.+A+\\.AAA/", + "risk_score": 21, + "rule_id": "5aee924b-6ceb-4633-980e-1bde8cdb40c5", + "severity": "low", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1107", + "name": "File Deletion", + "reference": "https://attack.mitre.org/techniques/T1107/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_suspicious_managedcode_host_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_suspicious_managedcode_host_process.json new file mode 100644 index 0000000000000..1bb3f26c0298f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_suspicious_managedcode_host_process.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a suspicious managed code hosting process which could indicate code injection or other form of suspicious code execution.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Suspicious Managed Code Hosting Process", + "query": "event.category:file and not event.type:deletion and file.name:(wscript.exe.log or mshta.exe.log or wscript.exe.log or wmic.exe.log or svchost.exe.log or dllhost.exe.log or cmstp.exe.log or regsvr32.exe.log)", + "references": [ + "https://blog.menasec.net/2019/07/interesting-difr-traces-of-net-clr.html" + ], + "risk_score": 73, + "rule_id": "acf738b5-b5b2-4acc-bad9-1e18ee234f40", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1055", + "name": "Process Injection", + "reference": "https://attack.mitre.org/techniques/T1055/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_suspicious_zoom_child_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_suspicious_zoom_child_process.json new file mode 100644 index 0000000000000..7b08f5a565424 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_suspicious_zoom_child_process.json @@ -0,0 +1,59 @@ +{ + "author": [ + "Elastic" + ], + "description": "A suspicious Zoom child process was detected, which may indicate an attempt to run unnoticed. Verify process details such as command line, network connections, file writes and associated file signature details as well.", + "false_positives": [ + "New Zoom Executable" + ], + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Suspicious Zoom Child Process", + "query": "event.category:process and event.type:(start or process_started) and process.parent.name:Zoom.exe and not process.name:(Zoom.exe or WerFault.exe or airhost.exe or CptControl.exe or CptHost.exe or cpthost.exe or CptInstall.exe or CptService.exe or Installer.exe or zCrashReport.exe or Zoom_launcher.exe or zTscoder.exe or plugin_Launcher.exe or mDNSResponder.exe or zDevHelper.exe or APcptControl.exe or CrashSender*.exe or aomhost64.exe or Magnify.exe or m_plugin_launcher.exe or com.zoom.us.zTranscode.exe or RoomConnector.exe or tabtip.exe or Explorer.exe or chrome.exe or firefox.exe or iexplore.exe or outlook.exe or lync.exe or ApplicationFrameHost.exe or ZoomAirhostInstaller.exe or narrator.exe or NVDA.exe or Magnify.exe or Outlook.exe or m_plugin_launcher.exe or mphost.exe or APcptControl.exe or winword.exe or excel.exe or powerpnt.exe or ONENOTE.EXE or wpp.exe or debug_message.exe or zAssistant.exe or msiexec.exe or msedge.exe or dwm.exe or vcredist_x86.exe or Controller.exe or Installer.exe or CptInstall.exe or Zoom_launcher.exe or ShellExperienceHost.exe or wps.exe)", + "risk_score": 47, + "rule_id": "97aba1ef-6034-4bd3-8c1a-1e0996b27afa", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1036", + "name": "Masquerading", + "reference": "https://attack.mitre.org/techniques/T1036/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1055", + "name": "Process Injection", + "reference": "https://attack.mitre.org/techniques/T1055/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_system_critical_proc_abnormal_file_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_system_critical_proc_abnormal_file_activity.json new file mode 100644 index 0000000000000..6fea3a75c8e62 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_system_critical_proc_abnormal_file_activity.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies an unexpected executable file being created or modified by a Windows system critical process, which may indicate activity related to remote code execution or other forms of exploitation.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Unusual Executable File Creation by a System Critical Process", + "query": "event.category:file and not event.type:deletion and file.extension:(exe or dll) and process.name:(smss.exe or autochk.exe or csrss.exe or wininit.exe or services.exe or lsass.exe or winlogon.exe or userinit.exe or LogonUI.exe)", + "risk_score": 73, + "rule_id": "e94262f2-c1e9-4d3f-a907-aeab16712e1a", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1211", + "name": "Exploitation for Defense Evasion", + "reference": "https://attack.mitre.org/techniques/T1211/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_unusual_system_vp_child_program.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_unusual_system_vp_child_program.json new file mode 100644 index 0000000000000..4efec948f49a7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_unusual_system_vp_child_program.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a suspicious child process of the Windows virtual system process, which could indicate code injection.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Unusual Child Process from a System Virtual Process", + "query": "event.category:process and event.type:(start or process_started) and process.parent.pid:4 and not process.executable:(Registry or MemCompression or \"C:\\Windows\\System32\\smss.exe\")", + "risk_score": 73, + "rule_id": "de9bd7e0-49e9-4e92-a64d-53ade2e66af1", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1055", + "name": "Process Injection", + "reference": "https://attack.mitre.org/techniques/T1055/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_via_filter_manager.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_via_filter_manager.json index 24d1899fe5593..210e9c778afef 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_via_filter_manager.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_via_filter_manager.json @@ -9,7 +9,7 @@ "language": "kuery", "license": "Elastic License", "name": "Potential Evasion via Filter Manager", - "query": "event.code:1 and process.name:fltMC.exe", + "query": "event.category:process and event.type:(start or process_started) and process.name:fltMC.exe", "risk_score": 21, "rule_id": "06dceabf-adca-48af-ac79-ffdf4c3b1e9a", "severity": "low", @@ -35,5 +35,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_acl_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_acl_deletion.json index ef7667e34be3a..32101029fb107 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_acl_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_acl_deletion.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -48,5 +49,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_rule_or_rule_group_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_rule_or_rule_group_deletion.json index 1e8e1bfa42246..a08c05d0d6ca7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_rule_or_rule_group_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_rule_or_rule_group_deletion.json @@ -8,14 +8,15 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", "license": "Elastic License", "name": "AWS WAF Rule or Rule Group Deletion", "note": "The AWS Filebeat module must be enabled to use this rule.", - "query": "event.module:aws and event.dataset:aws.cloudtrail and event.action:(DeleteRule or DeleteRuleGroup) and event.outcome:success", + "query": "event.dataset:aws.cloudtrail and event.action:(DeleteRule or DeleteRuleGroup) and event.outcome:success", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/waf/delete-rule-group.html", "https://docs.aws.amazon.com/waf/latest/APIReference/API_waf_DeleteRuleGroup.html" @@ -48,5 +49,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_blob_container_access_mod.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_blob_container_access_mod.json new file mode 100644 index 0000000000000..7e601c9928d08 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_blob_container_access_mod.json @@ -0,0 +1,65 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies changes to container access levels in Azure. Anonymous public read access to containers and blobs in Azure is a way to share data broadly, but can present a security risk if access to sensitive data is not managed judiciously.", + "false_positives": [ + "Access level modifications may be done by a system or network administrator. Verify whether the username, hostname, and/or resource name should be making changes in your environment. Access level modifications from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Azure Blob Container Access Level Modification", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.activitylogs and azure.activitylogs.operation_name:MICROSOFT.STORAGE/STORAGEACCOUNTS/BLOBSERVICES/CONTAINERS/WRITE and event.outcome:Success", + "references": [ + "https://docs.microsoft.com/en-us/azure/storage/blobs/anonymous-read-access-prevent" + ], + "risk_score": 21, + "rule_id": "2636aa6c-88b5-4337-9c31-8d0192a8ef45", + "severity": "low", + "tags": [ + "Elastic", + "Azure", + "SecOps", + "Continuous Monitoring", + "Asset Visibility" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007/" + }, + "technique": [ + { + "id": "T1526", + "name": "Cloud Service Discovery", + "reference": "https://attack.mitre.org/techniques/T1526/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0001", + "name": "Initial Access", + "reference": "https://attack.mitre.org/tactics/TA0001/" + }, + "technique": [ + { + "id": "T1190", + "name": "Exploit Public-Facing Application", + "reference": "https://attack.mitre.org/techniques/T1190/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_net_command_system_account.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_net_command_system_account.json index f1a214b7cd436..96c300cfde016 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_net_command_system_account.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_net_command_system_account.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Identifies the SYSTEM account using the Net utility. The Net utility is a component of the Windows operating system. It is used in command line operations for control of users, groups, services, and network connections.", + "description": "Identifies the SYSTEM account using an account discovery utility. This could be a sign of discovery activity after an adversary has achieved privilege escalation.", "from": "now-9m", "index": [ "winlogbeat-*", @@ -11,7 +11,7 @@ "language": "kuery", "license": "Elastic License", "name": "Net command via SYSTEM account", - "query": "event.category:process and event.type:(start or process_started) and (process.name:net.exe or process.name:net1.exe and not process.parent.name:net.exe) and user.name:SYSTEM", + "query": "event.category:process and event.type:(start or process_started) and (process.name:(whoami.exe or net.exe) or process.name:net1.exe and not process.parent.name:net.exe) and user.name:SYSTEM", "risk_score": 21, "rule_id": "2856446a-34e6-435b-9fb5-f8f040bfa7ed", "severity": "low", @@ -37,5 +37,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_post_exploitation_public_ip_reconnaissance.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_post_exploitation_public_ip_reconnaissance.json new file mode 100644 index 0000000000000..952d70ee3589a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_post_exploitation_public_ip_reconnaissance.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies domains commonly used by adversaries for post-exploitation IP reconnaissance. It is common for adversaries to test for Internet access and acquire their public IP address after they have gained access to a system. Among others, this has been observed in campaigns leveraging the information stealer, Trickbot.", + "false_positives": [ + "If the domains listed in this rule are used as part of an authorized workflow, this rule will be triggered by those events. Validate that this is expected activity and tune the rule to fit your environment variables." + ], + "index": [ + "packetbeat-*" + ], + "language": "lucene", + "license": "Elastic License", + "name": "Public IP Reconnaissance Activity", + "note": "This rule takes HTTP redirects and HTTP referrer's into account, however neither HTTP redirect status codes nor HTTP referrer's are visible with TLS traffic which can lead to multiple events per alert.", + "query": "event.category:network AND event.type:connection AND server.domain:(ipecho.net OR ipinfo.io OR ifconfig.co OR ifconfig.me OR icanhazip.com OR myexternalip.com OR api.ipify.org OR bot.whatismyipaddress.com OR ip.anysrc.net OR wtfismyip.com) AND NOT http.response.status_code:302 AND status:OK AND NOT _exists_:http.request.referrer", + "references": [ + "https://community.jisc.ac.uk/blogs/csirt/article/trickbot-analysis-and-mitigation", + "https://www.cybereason.com/blog/dropping-anchor-from-a-trickbot-infection-to-the-discovery-of-the-anchor-malware" + ], + "risk_score": 21, + "rule_id": "1d72d014-e2ab-4707-b056-9b96abe7b511", + "severity": "low", + "tags": [ + "Elastic", + "Network", + "Threat Detection, Preventing and Hunting", + "Post-Execution" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007/" + }, + "technique": [ + { + "id": "T1016", + "name": "System Network Configuration Discovery", + "reference": "https://attack.mitre.org/techniques/T1016/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_process_discovery_via_tasklist_command.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_process_discovery_via_tasklist_command.json index e9a495c752f95..c2d95de4129f9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_process_discovery_via_tasklist_command.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_process_discovery_via_tasklist_command.json @@ -12,7 +12,7 @@ "language": "kuery", "license": "Elastic License", "name": "Process Discovery via Tasklist", - "query": "event.code:1 and process.name:tasklist.exe", + "query": "event.category:process and event.type:(start or process_started) and process.name:tasklist.exe", "risk_score": 21, "rule_id": "cc16f774-59f9-462d-8b98-d27ccd4519ec", "severity": "low", @@ -38,5 +38,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_whoami_command_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_whoami_command_activity.json index 6511ff6e19d80..cb330879be9b3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_whoami_command_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_whoami_command_activity.json @@ -12,7 +12,7 @@ "language": "kuery", "license": "Elastic License", "name": "Whoami Process Activity", - "query": "process.name:whoami.exe and event.code:1", + "query": "event.category:process and event.type:(start or process_started) and process.name:whoami.exe", "risk_score": 21, "rule_id": "ef862985-3f13-4262-a686-5f357bbb9bc2", "severity": "low", @@ -38,5 +38,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json index 1466b4526815b..f3acc5d3a2b5e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Elastic Endpoint alerts.", + "description": "Generates a detection alert each time an Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.", "enabled": true, "exceptions_list": [ { @@ -19,7 +19,7 @@ "language": "kuery", "license": "Elastic License", "max_signals": 10000, - "name": "Elastic Endpoint Security", + "name": "Endpoint Security", "query": "event.kind:alert and event.module:(endpoint and not endgame)", "risk_score": 47, "risk_score_mapping": [ @@ -64,5 +64,5 @@ ], "timestamp_override": "event.ingested", "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_adversary_behavior_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_adversary_behavior_detected.json index 16584a03a3c91..b3bac305bc1f1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_adversary_behavior_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_adversary_behavior_detected.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint Security detected an Adversary Behavior. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", + "description": "Endpoint Security detected an Adversary Behavior. Click the Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Adversary Behavior - Detected - Elastic Endpoint Security", + "name": "Adversary Behavior - Detected - Endpoint Security", "query": "event.kind:alert and event.module:endgame and (event.action:rules_engine_event or endgame.event_subtype_full:rules_engine_event)", "risk_score": 47, "rule_id": "77a3c3df-8ec4-4da4-b758-878f551dee69", @@ -20,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_detected.json index 5717c490114b9..2f91c1fe813f9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_detected.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint Security detected Credential Dumping. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", + "description": "Endpoint Security detected Credential Dumping. Click the Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Credential Dumping - Detected - Elastic Endpoint Security", + "name": "Credential Dumping - Detected - Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:cred_theft_event or endgame.event_subtype_full:cred_theft_event)", "risk_score": 73, "rule_id": "571afc56-5ed9-465d-a2a9-045f099f6e7e", @@ -20,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_prevented.json index 5c1b2cb02b841..75488c2d3a5ed 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_prevented.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint Security prevented Credential Dumping. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", + "description": "Endpoint Security prevented Credential Dumping. Click the Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Credential Dumping - Prevented - Elastic Endpoint Security", + "name": "Credential Dumping - Prevented - Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:cred_theft_event or endgame.event_subtype_full:cred_theft_event)", "risk_score": 47, "rule_id": "db8c33a8-03cd-4988-9e2c-d0a4863adb13", @@ -20,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_detected.json index 16ad12a94ec40..adc29d9106774 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_detected.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint Security detected Credential Manipulation. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", + "description": "Endpoint Security detected Credential Manipulation. Click the Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Credential Manipulation - Detected - Elastic Endpoint Security", + "name": "Credential Manipulation - Detected - Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:token_manipulation_event or endgame.event_subtype_full:token_manipulation_event)", "risk_score": 73, "rule_id": "c0be5f31-e180-48ed-aa08-96b36899d48f", @@ -20,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_prevented.json index 9addcbf2fba30..99def69978a48 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_prevented.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint Security prevented Credential Manipulation. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", + "description": "Endpoint Security prevented Credential Manipulation. Click the Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Credential Manipulation - Prevented - Elastic Endpoint Security", + "name": "Credential Manipulation - Prevented - Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:token_manipulation_event or endgame.event_subtype_full:token_manipulation_event)", "risk_score": 47, "rule_id": "c9e38e64-3f4c-4bf3-ad48-0e61a60ea1fa", @@ -20,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_detected.json index f51a38781c953..80eb3ce637f30 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_detected.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint Security detected an Exploit. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", + "description": "Endpoint Security detected an Exploit. Click the Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Exploit - Detected - Elastic Endpoint Security", + "name": "Exploit - Detected - Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:exploit_event or endgame.event_subtype_full:exploit_event)", "risk_score": 73, "rule_id": "2003cdc8-8d83-4aa5-b132-1f9a8eb48514", @@ -20,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_prevented.json index 8b96c5a63fbef..50444904654de 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_prevented.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint Security prevented an Exploit. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", + "description": "Endpoint Security prevented an Exploit. Click the Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Exploit - Prevented - Elastic Endpoint Security", + "name": "Exploit - Prevented - Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:exploit_event or endgame.event_subtype_full:exploit_event)", "risk_score": 47, "rule_id": "2863ffeb-bf77-44dd-b7a5-93ef94b72036", @@ -20,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_detected.json index 28ff73468deb4..bb2ddf92a83e7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_detected.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint Security detected Malware. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", + "description": "Endpoint Security detected Malware. Click the Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Malware - Detected - Elastic Endpoint Security", + "name": "Malware - Detected - Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:file_classification_event or endgame.event_subtype_full:file_classification_event)", "risk_score": 99, "rule_id": "0a97b20f-4144-49ea-be32-b540ecc445de", @@ -20,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_prevented.json index 3d32abf2bf8f2..fae8a3a0ab5a6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_prevented.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint Security prevented Malware. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", + "description": "Endpoint Security prevented Malware. Click the Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Malware - Prevented - Elastic Endpoint Security", + "name": "Malware - Prevented - Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:file_classification_event or endgame.event_subtype_full:file_classification_event)", "risk_score": 73, "rule_id": "3b382770-efbb-44f4-beed-f5e0a051b895", @@ -20,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_detected.json index a89a7f7d5918c..821c3b0d8a63b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_detected.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint Security detected Permission Theft. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", + "description": "Endpoint Security detected Permission Theft. Click the Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Permission Theft - Detected - Elastic Endpoint Security", + "name": "Permission Theft - Detected - Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:token_protection_event or endgame.event_subtype_full:token_protection_event)", "risk_score": 73, "rule_id": "c3167e1b-f73c-41be-b60b-87f4df707fe3", @@ -20,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_prevented.json index fb9dbe3dadb17..e38afe19e7d38 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_prevented.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint Security prevented Permission Theft. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", + "description": "Endpoint Security prevented Permission Theft. Click the Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Permission Theft - Prevented - Elastic Endpoint Security", + "name": "Permission Theft - Prevented - Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:token_protection_event or endgame.event_subtype_full:token_protection_event)", "risk_score": 47, "rule_id": "453f659e-0429-40b1-bfdb-b6957286e04b", @@ -20,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_detected.json index e022d058d7560..52eb3c2d96bf7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_detected.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint Security detected Process Injection. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", + "description": "Endpoint Security detected Process Injection. Click the Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Process Injection - Detected - Elastic Endpoint Security", + "name": "Process Injection - Detected - Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:kernel_shellcode_event or endgame.event_subtype_full:kernel_shellcode_event)", "risk_score": 73, "rule_id": "80c52164-c82a-402c-9964-852533d58be1", @@ -20,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_prevented.json index 2d189707293f1..76aff15e1588c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_prevented.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint Security prevented Process Injection. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", + "description": "Endpoint Security prevented Process Injection. Click the Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Process Injection - Prevented - Elastic Endpoint Security", + "name": "Process Injection - Prevented - Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:kernel_shellcode_event or endgame.event_subtype_full:kernel_shellcode_event)", "risk_score": 47, "rule_id": "990838aa-a953-4f3e-b3cb-6ddf7584de9e", @@ -20,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_detected.json index 077c20bca5d8e..29efdd910904d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_detected.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint Security detected Ransomware. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", + "description": "Endpoint Security detected Ransomware. Click the Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Ransomware - Detected - Elastic Endpoint Security", + "name": "Ransomware - Detected - Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:ransomware_event or endgame.event_subtype_full:ransomware_event)", "risk_score": 99, "rule_id": "8cb4f625-7743-4dfb-ae1b-ad92be9df7bd", @@ -20,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_prevented.json index b615fcb04895e..c603e503c5dad 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_prevented.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint Security prevented Ransomware. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", + "description": "Endpoint Security prevented Ransomware. Click the Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Ransomware - Prevented - Elastic Endpoint Security", + "name": "Ransomware - Prevented - Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:ransomware_event or endgame.event_subtype_full:ransomware_event)", "risk_score": 73, "rule_id": "e3c5d5cb-41d5-4206-805c-f30561eae3ac", @@ -20,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/escalation_uac_sdclt.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/escalation_uac_sdclt.json new file mode 100644 index 0000000000000..843ba3401b4e4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/escalation_uac_sdclt.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies User Account Control (UAC) bypass via sdclt.exe. Attackers bypass UAC to stealthily execute code with elevated permissions.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Bypass UAC via Sdclt", + "query": "sequence with maxspan=1m\n [process where event.type in (\"start\", \"process_started\") and process.name == \"sdclt.exe\" and\n /* process.code_signature.* fields need to be populated for 7.10 */\n process.code_signature.subject_name == \"Microsoft Corporation\" and process.code_signature.trusted == true and\n process.args == \"/kickoffelev\"\n ] by process.entity_id\n [process where event.type in (\"start\", \"process_started\") and process.parent.name == \"sdclt.exe\" and\n process.executable not in (\"C:\\\\Windows\\\\System32\\\\sdclt.exe\",\n \"C:\\\\Windows\\\\System32\\\\control.exe\",\n \"C:\\\\Windows\\\\SysWOW64\\\\sdclt.exe\",\n \"C:\\\\Windows\\\\SysWOW64\\\\control.exe\")\n ] by process.parent.entity_id\n", + "risk_score": 21, + "rule_id": "9b54e002-034a-47ac-9307-ad12c03fa900", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1088", + "name": "Bypass User Account Control", + "reference": "https://attack.mitre.org/techniques/T1088/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/evasion_rundll32_no_arguments.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/evasion_rundll32_no_arguments.json new file mode 100644 index 0000000000000..06fad7e0f630b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/evasion_rundll32_no_arguments.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies child processes of unusual instances of RunDLL32 where the command line parameters were suspicious. Misuse of RunDLL32 could indicate malicious activity.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Unusual Child Processes of RunDLL32", + "query": "sequence with maxspan=1h\n [process where event.type in (\"start\", \"process_started\") and\n (process.name == \"rundll32.exe\" or process.pe.original_file_name == \"rundll32.exe\") and\n\n /* zero arguments excluding the binary itself (and accounting for when the binary may not be logged in args) */\n ((process.args == \"rundll32.exe\" and process.args_count == 1) or\n (process.args != \"rundll32.exe\" and process.args_count == 0))\n\n ] by process.entity_id\n [process where event.type in (\"start\", \"process_started\") and\n (process.name == \"rundll32.exe\" or process.pe.original_file_name == \"rundll32.exe\")\n ] by process.parent.entity_id\n", + "risk_score": 21, + "rule_id": "f036953a-4615-4707-a1ca-dc53bf69dcd5", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1085", + "name": "Rundll32", + "reference": "https://attack.mitre.org/techniques/T1085/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/evasion_suspicious_scrobj_load.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/evasion_suspicious_scrobj_load.json new file mode 100644 index 0000000000000..7880b86533b53 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/evasion_suspicious_scrobj_load.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies scrobj.dll loaded into unusual Microsoft processes. This usually means a malicious scriptlet is being executed in the target process.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Windows Suspicious Script Object Execution", + "query": "sequence by process.entity_id with maxspan=2m\n [process where event.type in (\"start\", \"process_started\") and\n /* process.code_signature.* fields need to be populated for 7.10 */\n process.code_signature.subject_name == \"Microsoft Corporation\" and process.code_signature.trusted == true and\n process.name not in (\"cscript.exe\",\n \"iexplore.exe\",\n \"MicrosoftEdge.exe\",\n \"msiexec.exe\",\n \"smartscreen.exe\",\n \"taskhostw.exe\",\n \"w3wp.exe\",\n \"wscript.exe\")]\n [library where event.type == \"start\" and file.name == \"scrobj.dll\"]\n", + "risk_score": 21, + "rule_id": "4ed678a9-3a4f-41fb-9fea-f85a6e0a0dff", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1064", + "name": "Scripting", + "reference": "https://attack.mitre.org/techniques/T1064/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/evasion_suspicious_wmi_script.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/evasion_suspicious_wmi_script.json new file mode 100644 index 0000000000000..943471f5801c2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/evasion_suspicious_wmi_script.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies WMIC whitelisting bypass techniques by alerting on suspicious execution of scripts. When WMIC loads scripting libraries it may be indicative of a whitelist bypass.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Suspicious WMIC XSL Script Execution", + "query": "/* lots of wildcards in the args\n need to verify args cleanup is accurate\n*/\n\nsequence by process.entity_id with maxspan=2m\n[process where event.type in (\"start\", \"process_started\") and\n (process.name == \"wmic.exe\" or process.pe.original_file_name == \"wmic.exe\") and\n wildcard(process.args, \"format*:*\", \"/format*:*\", \"*-format*:*\") and\n not process.args in (\"/format:table\", \"/format:table\") or wildcard(process.args, \"format*:*\")]\n[library where event.type == \"start\" and file.name in (\"jscript.dll\", \"vbscript.dll\")]\n", + "risk_score": 21, + "rule_id": "7f370d54-c0eb-4270-ac5a-9a6020585dc6", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1220", + "name": "XSL Script Processing", + "reference": "https://attack.mitre.org/techniques/T1220/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_prompt_connecting_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_prompt_connecting_to_the_internet.json index 46208f3753fa1..e9989fe50019e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_prompt_connecting_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_prompt_connecting_to_the_internet.json @@ -33,7 +33,7 @@ "technique": [ { "id": "T1059", - "name": "Command-Line Interface", + "name": "Command and Scripting Interpreter", "reference": "https://attack.mitre.org/techniques/T1059/" } ] @@ -48,12 +48,12 @@ "technique": [ { "id": "T1105", - "name": "Remote File Copy", + "name": "Ingress Tool Transfer", "reference": "https://attack.mitre.org/techniques/T1105/" } ] } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json index c619d8f764bc4..bb252b1416832 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json @@ -30,7 +30,7 @@ "technique": [ { "id": "T1059", - "name": "Command-Line Interface", + "name": "Command and Scripting Interpreter", "reference": "https://attack.mitre.org/techniques/T1059/" } ] @@ -52,5 +52,5 @@ } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_svchost.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_svchost.json index 140212e4148eb..aeae5518fece1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_svchost.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_svchost.json @@ -30,12 +30,12 @@ "technique": [ { "id": "T1059", - "name": "Command-Line Interface", + "name": "Command and Scripting Interpreter", "reference": "https://attack.mitre.org/techniques/T1059/" } ] } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_unusual_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_unusual_process.json new file mode 100644 index 0000000000000..577bee1ffe6de --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_unusual_process.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a suspicious parent child process relationship with cmd.exe descending from an unusual process.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Unusual Parent Process for cmd.exe", + "query": "event.category:process and event.type:(start or process_started) and process.name:cmd.exe and process.parent.name:(lsass.exe or csrss.exe or notepad.exe or regsvr32.exe or dllhost.exe or LogonUI.exe or wermgr.exe or spoolsv.exe or jucheck.exe or jusched.exe or ctfmon.exe or taskhostw.exe or GoogleUpdate.exe or sppsvc.exe or sihost.exe or slui.exe or SIHClient.exe or SearchIndexer.exe or SearchProtocolHost.exe or FlashPlayerUpdateService.exe or WerFault.exe or WUDFHost.exe or unsecapp.exe or wlanext.exe)", + "risk_score": 47, + "rule_id": "3b47900d-e793-49e8-968f-c90dc3526aa1", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1059", + "name": "Command and Scripting Interpreter", + "reference": "https://attack.mitre.org/techniques/T1059/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_virtual_machine.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_virtual_machine.json new file mode 100644 index 0000000000000..5e7852e1c1b13 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_virtual_machine.json @@ -0,0 +1,52 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies command execution on a virtual machine (VM) in Azure. A Virtual Machine Contributor role lets you manage virtual machines, but not access them, nor access the virtual network or storage account they\u2019re connected to. However, commands can be run via PowerShell on the VM, which execute as System. Other roles, such as certain Administrator roles may be able to execute commands on a VM as well.", + "false_positives": [ + "Command execution on a virtual machine may be done by a system or network administrator. Verify whether the username, hostname, and/or resource name should be making changes in your environment. Command execution from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Azure Command Execution on Virtual Machine", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.activitylogs and azure.activitylogs.operation_name:MICROSOFT.COMPUTE/VIRTUALMACHINES/RUNCOMMAND/ACTION and event.outcome:Success", + "references": [ + "https://adsecurity.org/?p=4277", + "https://posts.specterops.io/attacking-azure-azure-ad-and-introducing-powerzure-ca70b330511a", + "https://docs.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#virtual-machine-contributor" + ], + "risk_score": 47, + "rule_id": "60884af6-f553-4a6c-af13-300047455491", + "severity": "medium", + "tags": [ + "Azure", + "Elastic", + "SecOps", + "Continuous Monitoring", + "Logging" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1059", + "name": "Command and Scripting Interpreter", + "reference": "https://attack.mitre.org/techniques/T1059/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_ms_office_written_file.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_ms_office_written_file.json new file mode 100644 index 0000000000000..35d7a7c969ee7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_ms_office_written_file.json @@ -0,0 +1,51 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies an executable created by a Microsoft Office application and subsequently executed. These processes are often launched via scripts inside documents or during exploitation of MS Office applications.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Execution of File Written or Modified by Microsoft Office", + "query": "sequence with maxspan=2h\n [file where event.type != \"delete\" and file.extension == \"exe\" and\n process.name in (\"winword.exe\",\n \"excel.exe\",\n \"outlook.exe\",\n \"powerpnt.exe\",\n \"eqnedt32.exe\",\n \"fltldr.exe\",\n \"mspub.exe\",\n \"msaccess.exe\")\n ] by host.id, file.path\n [process where event.type in (\"start\", \"process_started\")] by host.id, process.executable\n", + "risk_score": 21, + "rule_id": "0d8ad79f-9025-45d8-80c1-4f0cd3c5e8e5", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1064", + "name": "Scripting", + "reference": "https://attack.mitre.org/techniques/T1064/" + }, + { + "id": "T1192", + "name": "Spearphishing Link", + "reference": "https://attack.mitre.org/techniques/T1192/" + }, + { + "id": "T1193", + "name": "Spearphishing Attachment", + "reference": "https://attack.mitre.org/techniques/T1193/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msbuild_making_network_connections.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msbuild_making_network_connections.json index 629efa90a71ea..fcbbfbdb3d686 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msbuild_making_network_connections.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msbuild_making_network_connections.json @@ -30,12 +30,12 @@ "technique": [ { "id": "T1127", - "name": "Trusted Developer Utilities", + "name": "Trusted Developer Utilities Proxy Execution", "reference": "https://attack.mitre.org/techniques/T1127/" } ] } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_pdf_written_file.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_pdf_written_file.json new file mode 100644 index 0000000000000..3963b3d594902 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_pdf_written_file.json @@ -0,0 +1,51 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a suspicious file that was written by a PDF reader application and subsequently executed. These processes are often launched via exploitation of PDF applications.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Execution of File Written or Modified by PDF Reader", + "query": "sequence with maxspan=2h\n [file where event.type != \"delete\" and file.extension == \"exe\" and\n process.name in (\"acrord32.exe\", \"rdrcef.exe\", \"foxitphantomPDF.exe\", \"foxitreader.exe\") and\n file.name not in (\"foxitphantomPDF.exe\",\n \"FoxitPhantomPDFUpdater.exe\",\n \"foxitreader.exe\",\n \"FoxitReaderUpdater.exe\",\n \"acrord32.exe\",\n \"rdrcef.exe\")\n ] by host.id, file.path\n [process where event.type in (\"start\", \"process_started\")] by host.id, process.executable\n", + "risk_score": 21, + "rule_id": "1defdd62-cd8d-426e-a246-81a37751bb2b", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1064", + "name": "Scripting", + "reference": "https://attack.mitre.org/techniques/T1064/" + }, + { + "id": "T1192", + "name": "Spearphishing Link", + "reference": "https://attack.mitre.org/techniques/T1192/" + }, + { + "id": "T1193", + "name": "Spearphishing Attachment", + "reference": "https://attack.mitre.org/techniques/T1193/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_perl_tty_shell.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_perl_tty_shell.json index 9b6ee099116f3..4502f42bbb4c4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_perl_tty_shell.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_perl_tty_shell.json @@ -30,12 +30,12 @@ "technique": [ { "id": "T1059", - "name": "Command-Line Interface", + "name": "Command and Scripting Interpreter", "reference": "https://attack.mitre.org/techniques/T1059/" } ] } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_python_tty_shell.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_python_tty_shell.json index d9c26a9c26cc9..0e8b5f0218d00 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_python_tty_shell.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_python_tty_shell.json @@ -30,12 +30,12 @@ "technique": [ { "id": "T1059", - "name": "Command-Line Interface", + "name": "Command and Scripting Interpreter", "reference": "https://attack.mitre.org/techniques/T1059/" } ] } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_register_server_program_connecting_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_register_server_program_connecting_to_the_internet.json index b3b6a2b0c7fab..899bb1c20e711 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_register_server_program_connecting_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_register_server_program_connecting_to_the_internet.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Identifies the native Windows tools regsvr32.exe and regsvr64.exe making a network connection. This may be indicative of an attacker bypassing allowlists or running arbitrary scripts via a signed Microsoft binary.", + "description": "Identifies the native Windows tools regsvr32.exe, regsvr64.exe, RegSvcs.exe, or RegAsm.exe making a network connection. This may be indicative of an attacker bypassing allowlists or running arbitrary scripts via a signed Microsoft binary.", "false_positives": [ "Security testing may produce events like this. Activity of this kind performed by non-engineers and ordinary users is unusual." ], @@ -13,8 +13,8 @@ ], "language": "kuery", "license": "Elastic License", - "name": "Network Connection via Regsvr", - "query": "event.category:network and event.type:connection and process.name:(regsvr32.exe or regsvr64.exe) and not destination.ip:(10.0.0.0/8 or 169.254.169.254 or 172.16.0.0/12 or 192.168.0.0/16)", + "name": "Network Connection via Registration Utility", + "query": "event.category:network and event.type:connection and process.name:(regsvr32.exe or regsvr64.exe or RegAsm.exe or RegSvcs.exe) and not destination.ip:(10.0.0.0/8 or 169.254.169.254 or 172.16.0.0/12 or 192.168.0.0/16)", "risk_score": 21, "rule_id": "fb02b8d3-71ee-4af1-bacd-215d23f17efa", "severity": "low", @@ -47,13 +47,13 @@ }, "technique": [ { - "id": "T1117", - "name": "Regsvr32", - "reference": "https://attack.mitre.org/techniques/T1117/" + "id": "T1218", + "name": "Signed Binary Proxy Execution", + "reference": "https://attack.mitre.org/techniques/T1218/" } ] } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_dotnet_compiler_parent_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_dotnet_compiler_parent_process.json new file mode 100644 index 0000000000000..0a675bd7aab74 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_dotnet_compiler_parent_process.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies suspicious .NET code execution. connections.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Suspicious .NET Code Compilation", + "query": "event.category:process and event.type:(start or process_started) and process.name:(csc.exe or vbc.exe) and process.parent.name:(wscript.exe or mshta.exe or wscript.exe or wmic.exe or svchost.exe or rundll32.exe or cmstp.exe or regsvr32.exe)", + "risk_score": 47, + "rule_id": "201200f1-a99b-43fb-88ed-f65a45c4972c", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1055", + "name": "Process Injection", + "reference": "https://attack.mitre.org/techniques/T1055/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_dns_service_children.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_dns_service_children.json new file mode 100644 index 0000000000000..96305b2197bfc --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_dns_service_children.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies an unexpected process spawning from dns.exe, the process responsible for Windows DNS server services, which may indicate activity related to remote code execution or other forms of exploitation.", + "false_positives": [ + "Werfault.exe will legitimately spawn when dns.exe crashes, but the DNS service is very stable and so this is a low occurring event. Denial of Service (DoS) attempts by intentionally crashing the service will also cause werfault.exe to spawn." + ], + "index": [ + "winlogbeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Unusual Child Process of dns.exe", + "note": "### Investigating Unusual Child Process\nDetection alerts from this rule indicate potential suspicious child processes spawned after exploitation from CVE-2020-1350 (SigRed) has occurred. Here are some possible avenues of investigation:\n- Any suspicious or abnormal child process spawned from dns.exe should be reviewed and investigated with care. It's impossible to predict what an adversary may deploy as the follow-on process after the exploit, but built-in discovery/enumeration utilities should be top of mind (whoami.exe, netstat.exe, systeminfo.exe, tasklist.exe).\n- Built-in Windows programs that contain capabilities used to download and execute additional payloads should also be considered. This is not an exhaustive list, but ideal candidates to start out would be: mshta.exe, powershell.exe, regsvr32.exe, rundll32.exe, wscript.exe, wmic.exe.\n- If the DoS exploit is successful and DNS Server service crashes, be mindful of potential child processes related to werfault.exe occurring.\n- Any subsequent activity following the child process spawned related to execution/network activity should be thoroughly reviewed from the endpoint.", + "query": "event.category:process and event.type:start and process.parent.name:dns.exe and not process.name:conhost.exe", + "references": [ + "https://research.checkpoint.com/2020/resolving-your-way-into-domain-admin-exploiting-a-17-year-old-bug-in-windows-dns-servers/", + "https://msrc-blog.microsoft.com/2020/07/14/july-2020-security-update-cve-2020-1350-vulnerability-in-windows-domain-name-system-dns-server/", + "https://github.com/maxpl0it/CVE-2020-1350-DoS" + ], + "risk_score": 73, + "rule_id": "8c37dc0e-e3ac-4c97-8aa0-cf6a9122de45", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1133", + "name": "External Remote Services", + "reference": "https://attack.mitre.org/techniques/T1133/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_dns_service_file_writes.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_dns_service_file_writes.json new file mode 100644 index 0000000000000..c175ecbfa78b6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_dns_service_file_writes.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies an unexpected file being modified by dns.exe, the process responsible for Windows DNS Server services, which may indicate activity related to remote code execution or other forms of exploitation.", + "index": [ + "winlogbeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Unusual File Modification by dns.exe", + "note": "### Investigating Unusual File Write\nDetection alerts from this rule indicate potential unusual/abnormal file writes from the DNS Server service process (`dns.exe`) after exploitation from CVE-2020-1350 (SigRed) has occurred. Here are some possible avenues of investigation:\n- Post-exploitation, adversaries may write additional files or payloads to the system as additional discovery/exploitation/persistence mechanisms. \n- Any suspicious or abnormal files written from `dns.exe` should be reviewed and investigated with care.", + "query": "event.category:file and process.name:dns.exe and not file.name:dns.log", + "references": [ + "https://research.checkpoint.com/2020/resolving-your-way-into-domain-admin-exploiting-a-17-year-old-bug-in-windows-dns-servers/", + "https://msrc-blog.microsoft.com/2020/07/14/july-2020-security-update-cve-2020-1350-vulnerability-in-windows-domain-name-system-dns-server/" + ], + "risk_score": 73, + "rule_id": "c7ce36c0-32ff-4f9a-bfc2-dcb242bf99f9", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1133", + "name": "External Remote Services", + "reference": "https://attack.mitre.org/techniques/T1133/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_process_network_connection.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_process_network_connection.json index 854ecc40d76ab..774e8e9189ced 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_process_network_connection.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_process_network_connection.json @@ -30,12 +30,12 @@ "technique": [ { "id": "T1127", - "name": "Trusted Developer Utilities", + "name": "Trusted Developer Utilities Proxy Execution", "reference": "https://attack.mitre.org/techniques/T1127/" } ] } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_compiled_html_file.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_compiled_html_file.json index f59b41c31b124..fe3e110830420 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_compiled_html_file.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_compiled_html_file.json @@ -12,7 +12,7 @@ "language": "kuery", "license": "Elastic License", "name": "Process Activity via Compiled HTML File", - "query": "event.code:1 and process.name:hh.exe", + "query": "event.category:process and event.type:(start or process_started) and process.name:hh.exe", "risk_score": 21, "rule_id": "e3343ab9-4245-4715-b344-e11c56b0a47f", "severity": "low", @@ -53,5 +53,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_hidden_shell_conhost.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_hidden_shell_conhost.json new file mode 100644 index 0000000000000..7fbf962469f71 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_hidden_shell_conhost.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects when the Console Window Host (conhost.exe) process is spawned by a suspicious parent process, which could be indicative of code injection.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Conhost Spawned By Suspicious Parent Process", + "query": "event.category:process and event.type:(start or process_started) and process.name:conhost.exe and process.parent.name:(svchost.exe or lsass.exe or services.exe or smss.exe or winlogon.exe or explorer.exe or dllhost.exe or rundll32.exe or regsvr32.exe or userinit.exe or wininit.exe or spoolsv.exe or wermgr.exe or csrss.exe or ctfmon.exe)", + "references": [ + "https://www.fireeye.com/blog/threat-research/2017/08/monitoring-windows-console-activity-part-one.html" + ], + "risk_score": 73, + "rule_id": "05b358de-aa6d-4f6c-89e6-78f74018b43b", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1059", + "name": "Command and Scripting Interpreter", + "reference": "https://attack.mitre.org/techniques/T1059/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_system_manager.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_system_manager.json index 5e3e44604e9b1..081ebcb518999 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_system_manager.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_system_manager.json @@ -8,14 +8,15 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", "license": "Elastic License", "name": "AWS Execution via System Manager", "note": "The AWS Filebeat module must be enabled to use this rule.", - "query": "event.module:aws and event.dataset:aws.cloudtrail and event.provider:ssm.amazonaws.com and event.action:SendCommand and event.outcome:success", + "query": "event.dataset:aws.cloudtrail and event.provider:ssm.amazonaws.com and event.action:SendCommand and event.outcome:success", "references": [ "https://docs.aws.amazon.com/systems-manager/latest/userguide/ssm-plugins.html" ], @@ -62,5 +63,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_xp_cmdshell_mssql_stored_procedure.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_xp_cmdshell_mssql_stored_procedure.json new file mode 100644 index 0000000000000..8769e641fad90 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_xp_cmdshell_mssql_stored_procedure.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies execution via MSSQL xp_cmdshell stored procedure. Malicious users may attempt to elevate their privileges by using xp_cmdshell, which is disabled by default, thus, it's important to review the context of it's use.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Execution via MSSQL xp_cmdshell Stored Procedure", + "query": "event.category:process and event.type:(start or process_started) and process.name:cmd.exe and process.parent.name:sqlservr.exe", + "risk_score": 73, + "rule_id": "4ed493fc-d637-4a36-80ff-ac84937e5461", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1059", + "name": "Command and Scripting Interpreter", + "reference": "https://attack.mitre.org/techniques/T1059/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_wpad_exploitation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_wpad_exploitation.json new file mode 100644 index 0000000000000..03c4482b60340 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_wpad_exploitation.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies probable exploitation of the Web Proxy Auto-Discovery Protocol (WPAD) service. Attackers who have access to the local network or upstream DNS traffic can inject malicious JavaScript to the WPAD service which can lead to a full system compromise.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "WPAD Service Exploit", + "query": "/* preference would be to use user.sid rather than domain+name, once it is available in ECS + datasources */\n\nsequence with maxspan=5s\n [process where event.type in (\"start\", \"process_started\") and process.name == \"svchost.exe\" and\n user.domain == \"NT AUTHORITY\" and user.name == \"LOCAL SERVICE\"] by process.entity_id\n [network where network.protocol == \"dns\" and process.name == \"svchost.exe\" and\n dns.question.name == \"wpad\" and process.name == \"svchost.exe\"] by process.entity_id\n [network where event.type == \"connection\" and process.name == \"svchost.exe\"\n and network.direction == \"outgoing\" and destination.port == 80] by process.entity_id\n [library where event.type == \"start\" and process.name == \"svchost.exe\" and\n file.name == \"jscript.dll\" and process.name == \"svchost.exe\"] by process.entity_id\n [process where event.type in (\"start\", \"process_started\") and\n process.parent.name == \"svchost.exe\"] by process.parent.entity_id\n", + "risk_score": 21, + "rule_id": "ec328da1-d5df-482b-866c-4a435692b1f3", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1068", + "name": "Exploitation for Privilege Escalation", + "reference": "https://attack.mitre.org/techniques/T1068/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_compress_credentials_keychains.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_compress_credentials_keychains.json new file mode 100644 index 0000000000000..bf2a52066ae1c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_compress_credentials_keychains.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Adversaries may collect the keychain storage data from a system to acquire credentials. Keychains are the built-in way for macOS to keep track of users' passwords and credentials for many services and features such as WiFi passwords, websites, secure notes, certificates, and Kerberos.", + "from": "now-9m", + "index": [ + "auditbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Compression of Keychain Credentials Directories", + "query": "event.category:process and event.type:(start or process_started) and process.name:(zip or tar or gzip or 7za or hdiutil) and process.args:(\"/Library/Keychains/\" or \"/Network/Library/Keychains/\" or \"~/Library/Keychains/\")", + "references": [ + "https://objective-see.com/blog/blog_0x25.html" + ], + "risk_score": 73, + "rule_id": "96e90768-c3b7-4df6-b5d9-6237f8bc36a8", + "severity": "high", + "tags": [ + "Elastic", + "MacOS" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1142", + "name": "Keychain", + "reference": "https://attack.mitre.org/techniques/T1142/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_ec2_snapshot_change_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_ec2_snapshot_change_activity.json index 81d82670e794a..fc18a516be0f4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_ec2_snapshot_change_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_ec2_snapshot_change_activity.json @@ -8,14 +8,15 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", "license": "Elastic License", "name": "AWS EC2 Snapshot Activity", "note": "The AWS Filebeat module must be enabled to use this rule.", - "query": "event.module:aws and event.dataset:aws.cloudtrail and event.provider:ec2.amazonaws.com and event.action:ModifySnapshotAttribute", + "query": "event.dataset:aws.cloudtrail and event.provider:ec2.amazonaws.com and event.action:ModifySnapshotAttribute", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ec2/modify-snapshot-attribute.html", "https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_ModifySnapshotAttribute.html" @@ -48,5 +49,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_gcp_logging_sink_modification.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_gcp_logging_sink_modification.json new file mode 100644 index 0000000000000..4e8954c3441cd --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_gcp_logging_sink_modification.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a modification to a Logging sink in Google Cloud Platform (GCP). Logging compares the log entry to the sinks in that resource. Each sink whose filter matches the log entry writes a copy of the log entry to the sink's export destination. An adversary may update a Logging sink to exfiltrate logs to a different export destination.", + "false_positives": [ + "Logging sink modifications may be done by a system or network administrator. Verify whether the user email, resource name, and/or hostname should be making changes in your environment. Sink modifications from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Logging Sink Modification", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:google.logging.v*.ConfigServiceV*.UpdateSink and event.outcome:success", + "references": [ + "https://cloud.google.com/logging/docs/export#how_sinks_work" + ], + "risk_score": 21, + "rule_id": "184dfe52-2999-42d9-b9d1-d1ca54495a61", + "severity": "low", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Logging" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0010", + "name": "Exfiltration", + "reference": "https://attack.mitre.org/tactics/TA0010/" + }, + "technique": [ + { + "id": "T1537", + "name": "Transfer Data to Cloud Account", + "reference": "https://attack.mitre.org/techniques/T1537/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_attempt_to_revoke_okta_api_token.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_attempt_to_revoke_okta_api_token.json index ee434efa019d6..f9d71a2e1cbff 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_attempt_to_revoke_okta_api_token.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_attempt_to_revoke_okta_api_token.json @@ -7,13 +7,14 @@ "If the behavior of revoking Okta API tokens is expected, consider adding exceptions to this rule to filter false positives." ], "index": [ - "filebeat-*" + "filebeat-*", + "logs-okta*" ], "language": "kuery", "license": "Elastic License", "name": "Attempt to Revoke Okta API Token", "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.module:okta and event.dataset:okta.system and event.action:system.api_token.revoke", + "query": "event.dataset:okta.system and event.action:system.api_token.revoke", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" @@ -46,5 +47,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_azure_automation_runbook_deleted.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_azure_automation_runbook_deleted.json new file mode 100644 index 0000000000000..662709774f5ba --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_azure_automation_runbook_deleted.json @@ -0,0 +1,33 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when an Azure Automation runbook is deleted. An adversary may delete an Azure Automation runbook in order to disrupt their target's automated business operations or to remove a malicious runbook that was used for persistence.", + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Azure Automation Runbook Deleted", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.activitylogs and azure.activitylogs.operation_name:MICROSOFT.AUTOMATION/AUTOMATIONACCOUNTS/RUNBOOKS/DELETE and event.outcome:Success", + "references": [ + "https://powerzure.readthedocs.io/en/latest/Functions/operational.html#create-backdoor", + "https://github.com/hausec/PowerZure", + "https://posts.specterops.io/attacking-azure-azure-ad-and-introducing-powerzure-ca70b330511a", + "https://azure.microsoft.com/en-in/blog/azure-automation-runbook-management/" + ], + "risk_score": 21, + "rule_id": "8ddab73b-3d15-4e5d-9413-47f05553c1d7", + "severity": "low", + "tags": [ + "Elastic", + "Azure", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudtrail_logging_updated.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudtrail_logging_updated.json index 2de24a8155254..c648ae1ea4b5b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudtrail_logging_updated.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudtrail_logging_updated.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -63,5 +64,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_group_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_group_deletion.json index 9fe0d97ceda32..f7e9077c14314 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_group_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_group_deletion.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -63,5 +64,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_stream_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_stream_deletion.json index 085acb9a2fb13..b50efb21e42f0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_stream_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_stream_deletion.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -63,5 +64,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_ec2_disable_ebs_encryption.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_ec2_disable_ebs_encryption.json index f75e4d15f1e6a..370a65c31e7c1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_ec2_disable_ebs_encryption.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_ec2_disable_ebs_encryption.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -49,5 +50,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_iam_role_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_iam_role_deletion.json new file mode 100644 index 0000000000000..2c67be7408d1d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_iam_role_deletion.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies an Identity and Access Management (IAM) role deletion in Google Cloud Platform (GCP). A role contains a set of permissions that allows you to perform specific actions on Google Cloud resources. An adversary may delete an IAM role to inhibit access to accounts utilized by legitimate users.", + "false_positives": [ + "Role deletions may be done by a system or network administrator. Verify whether the user email, resource name, and/or hostname should be making changes in your environment. Role deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP IAM Role Deletion", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:google.iam.admin.v*.DeleteRole and event.outcome:success", + "references": [ + "https://cloud.google.com/iam/docs/understanding-roles" + ], + "risk_score": 21, + "rule_id": "e2fb5b18-e33c-4270-851e-c3d675c9afcd", + "severity": "low", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0040", + "name": "Impact", + "reference": "https://attack.mitre.org/tactics/TA0040/" + }, + "technique": [ + { + "id": "T1531", + "name": "Account Access Removal", + "reference": "https://attack.mitre.org/techniques/T1531/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_service_account_deleted.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_service_account_deleted.json new file mode 100644 index 0000000000000..2aa702e5ca4d1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_service_account_deleted.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a service account is deleted in Google Cloud Platform (GCP). A service account is a special type of account used by an application or a virtual machine (VM) instance, not a person. Applications use service accounts to make authorized API calls, authorized as either the service account itself, or as G Suite or Cloud Identity users through domain-wide delegation. An adversary may delete a service account in order to disrupt their target's business operations.", + "false_positives": [ + "Service accounts may be deleted by system administrators. Verify that the behavior was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Service Account Deletion", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:google.iam.admin.v*.DeleteServiceAccount and event.outcome:success", + "references": [ + "https://cloud.google.com/iam/docs/service-accounts" + ], + "risk_score": 47, + "rule_id": "8fb75dda-c47a-4e34-8ecd-34facf7aad13", + "severity": "medium", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0040", + "name": "Impact", + "reference": "https://attack.mitre.org/tactics/TA0040/" + }, + "technique": [ + { + "id": "T1531", + "name": "Account Access Removal", + "reference": "https://attack.mitre.org/techniques/T1531/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_service_account_disabled.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_service_account_disabled.json new file mode 100644 index 0000000000000..9b5188f43633d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_service_account_disabled.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a service account is disabled in Google Cloud Platform (GCP). A service account is a special type of account used by an application or a virtual machine (VM) instance, not a person. Applications use service accounts to make authorized API calls, authorized as either the service account itself, or as G Suite or Cloud Identity users through domain-wide delegation. An adversary may disable a service account in order to disrupt to disrupt their target's business operations.", + "false_positives": [ + "Service accounts may be disabled by system administrators. Verify that the behavior was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Service Account Disabled", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:google.iam.admin.v*.DisableServiceAccount and event.outcome:success", + "references": [ + "https://cloud.google.com/iam/docs/service-accounts" + ], + "risk_score": 47, + "rule_id": "bca7d28e-4a48-47b1-adb7-5074310e9a61", + "severity": "medium", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0040", + "name": "Impact", + "reference": "https://attack.mitre.org/tactics/TA0040/" + }, + "technique": [ + { + "id": "T1531", + "name": "Account Access Removal", + "reference": "https://attack.mitre.org/techniques/T1531/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_storage_bucket_deleted.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_storage_bucket_deleted.json new file mode 100644 index 0000000000000..6adad4b687de7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_storage_bucket_deleted.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a Google Cloud Platform (GCP) storage bucket is deleted. An adversary may delete a storage bucket in order to disrupt their target's business operations.", + "false_positives": [ + "Storage buckets may be deleted by a system or network administrator. Verify whether the user email, resource name, and/or hostname should be making changes in your environment. Bucket deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Storage Bucket Deletion", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:storage.buckets.delete", + "references": [ + "https://cloud.google.com/storage/docs/key-terms#buckets" + ], + "risk_score": 47, + "rule_id": "bc0f2d83-32b8-4ae2-b0e6-6a45772e9331", + "severity": "medium", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Monitoring" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0040", + "name": "Impact", + "reference": "https://attack.mitre.org/tactics/TA0040/" + }, + "technique": [ + { + "id": "T1485", + "name": "Data Destruction", + "reference": "https://attack.mitre.org/techniques/T1485/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_virtual_private_cloud_network_deleted.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_virtual_private_cloud_network_deleted.json new file mode 100644 index 0000000000000..c5dc9f25f893f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_virtual_private_cloud_network_deleted.json @@ -0,0 +1,32 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a Virtual Private Cloud (VPC) network is deleted in Google Cloud Platform (GCP). A VPC network is a virtual version of a physical network within a GCP project. Each VPC network has its own subnets, routes, and firewall, as well as other elements. An adversary may delete a VPC network in order to disrupt their target's network and business operations.", + "false_positives": [ + "Virtual Private Cloud networks may be deleted by system administrators. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Virtual Private Cloud Network Deletion", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:v*.compute.networks.delete and event.outcome:success", + "references": [ + "https://cloud.google.com/vpc/docs/vpc" + ], + "risk_score": 47, + "rule_id": "c58c3081-2e1d-4497-8491-e73a45d1a6d6", + "severity": "medium", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_virtual_private_cloud_route_created.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_virtual_private_cloud_route_created.json new file mode 100644 index 0000000000000..5e8fea09befc4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_virtual_private_cloud_route_created.json @@ -0,0 +1,33 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a Virtual Private Cloud (VPC) route is created in Google Cloud Platform (GCP). Google Cloud routes define the paths that network traffic takes from a virtual machine (VM) instance to other destinations. These destinations can be inside a Google VPC network or outside it. An adversary may create a route in order to impact the flow of network traffic in their target's cloud environment.", + "false_positives": [ + "Virtual Private Cloud routes may be created by system administrators. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Virtual Private Cloud Route Creation", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:(v*.compute.routes.insert or beta.compute.routes.insert)", + "references": [ + "https://cloud.google.com/vpc/docs/routes", + "https://cloud.google.com/vpc/docs/using-routes" + ], + "risk_score": 21, + "rule_id": "9180ffdf-f3d0-4db3-bf66-7a14bcff71b8", + "severity": "low", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_virtual_private_cloud_route_deleted.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_virtual_private_cloud_route_deleted.json new file mode 100644 index 0000000000000..8482e0efbb036 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_gcp_virtual_private_cloud_route_deleted.json @@ -0,0 +1,33 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a Virtual Private Cloud (VPC) route is deleted in Google Cloud Platform (GCP). Google Cloud routes define the paths that network traffic takes from a virtual machine (VM) instance to other destinations. These destinations can be inside a Google VPC network or outside it. An adversary may delete a route in order to impact the flow of network traffic in their target's cloud environment.", + "false_positives": [ + "Virtual Private Cloud routes may be deleted by system administrators. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Virtual Private Cloud Route Deletion", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:v*.compute.routes.delete and event.outcome:success", + "references": [ + "https://cloud.google.com/vpc/docs/routes", + "https://cloud.google.com/vpc/docs/using-routes" + ], + "risk_score": 47, + "rule_id": "a17bcc91-297b-459b-b5ce-bc7460d8f82a", + "severity": "medium", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_hosts_file_modified.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_hosts_file_modified.json new file mode 100644 index 0000000000000..bf04626dee277 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_hosts_file_modified.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "The hosts file on endpoints is used to control manual IP address to hostname resolutions. The hosts file is the first point of lookup for DNS hostname resolution so if adversaries can modify the endpoint hosts file, they can route traffic to malicious infrastructure. This rule detects modifications to the hosts file on Microsoft Windows, Linux (Ubuntu or RHEL) and macOS systems.", + "from": "now-9m", + "index": [ + "auditbeat-*", + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Hosts File Modified", + "note": "For Windows systems using Auditbeat, this rule requires adding 'C:/Windows/System32/drivers/etc' as an additional path in the 'file_integrity' module of auditbeat.yml.", + "query": "event.category:file and event.type:(change or creation) and file.path:(\"/private/etc/hosts\" or \"/etc/hosts\" or \"C:\\Windows\\System32\\drivers\\etc\\hosts\")", + "references": [ + "https://www.elastic.co/guide/en/beats/auditbeat/current/auditbeat-reference-yml.html" + ], + "risk_score": 47, + "rule_id": "9c260313-c811-4ec8-ab89-8f6530e0246c", + "severity": "medium", + "tags": [ + "Elastic", + "Linux", + "Windows", + "macOS" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0040", + "name": "Impact", + "reference": "https://attack.mitre.org/tactics/TA0040/" + }, + "technique": [ + { + "id": "T1492", + "name": "Stored Data Manipulation", + "reference": "https://attack.mitre.org/techniques/T1492/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_deactivate_mfa_device.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_deactivate_mfa_device.json index 68ad10977f4d3..ca5cfd4ae596b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_deactivate_mfa_device.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_deactivate_mfa_device.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -48,5 +49,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_group_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_group_deletion.json index aab2deff3a266..00a10772d4d9a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_group_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_group_deletion.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -48,5 +49,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_possible_okta_dos_attack.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_possible_okta_dos_attack.json index abcc6f65fbc67..9bc44bf4e6da9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_possible_okta_dos_attack.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_possible_okta_dos_attack.json @@ -4,13 +4,14 @@ ], "description": "An adversary may attempt to disrupt an organization's business operations by performing a denial of service (DoS) attack against its Okta infrastructure.", "index": [ - "filebeat-*" + "filebeat-*", + "logs-okta*" ], "language": "kuery", "license": "Elastic License", "name": "Possible Okta DoS Attack", "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.module:okta and event.dataset:okta.system and event.action:(application.integration.rate_limit_exceeded or system.org.rate_limit.warning or system.org.rate_limit.violation or core.concurrency.org.limit.violation)", + "query": "event.dataset:okta.system and event.action:(application.integration.rate_limit_exceeded or system.org.rate_limit.warning or system.org.rate_limit.violation or core.concurrency.org.limit.violation)", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" @@ -48,5 +49,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_cluster_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_cluster_deletion.json index b0615cf032386..829c244dd45c0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_cluster_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_cluster_deletion.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -50,5 +51,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_instance_cluster_stoppage.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_instance_cluster_stoppage.json index d77533e5183ad..68459d0e777b9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_instance_cluster_stoppage.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_instance_cluster_stoppage.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -50,5 +51,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_resource_group_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_resource_group_deletion.json new file mode 100644 index 0000000000000..a0c56c19b964e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_resource_group_deletion.json @@ -0,0 +1,65 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of a resource group in Azure, which includes all resources within the group. Deletion is permanent and irreversible. An adversary may delete a resource group in an attempt to evade defenses or intentionally destroy data.", + "false_positives": [ + "Deletion of a resource group may be done by a system or network administrator. Verify whether the username, hostname, and/or resource name should be making changes in your environment. Resource group deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Azure Resource Group Deletion", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.activitylogs and azure.activitylogs.operation_name:MICROSOFT.RESOURCES/SUBSCRIPTIONS/RESOURCEGROUPS/DELETE and event.outcome:Success", + "references": [ + "https://docs.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-portal" + ], + "risk_score": 47, + "rule_id": "bb4fe8d2-7ae2-475c-8b5d-55b449e4264f", + "severity": "medium", + "tags": [ + "Elastic", + "Azure", + "SecOps", + "Continuous Monitoring", + "Logging" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0040", + "name": "Impact", + "reference": "https://attack.mitre.org/tactics/TA0040/" + }, + "technique": [ + { + "id": "T1485", + "name": "Data Destruction", + "reference": "https://attack.mitre.org/techniques/T1485/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts index 685c869630ca3..6e376930617de 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts @@ -137,81 +137,200 @@ import rule125 from './ml_windows_rare_user_runas_event.json'; import rule126 from './ml_windows_rare_user_type10_remote_login.json'; import rule127 from './execution_suspicious_pdf_reader.json'; import rule128 from './privilege_escalation_sudoers_file_mod.json'; -import rule129 from './execution_python_tty_shell.json'; -import rule130 from './execution_perl_tty_shell.json'; -import rule131 from './defense_evasion_base16_or_base32_encoding_or_decoding_activity.json'; -import rule132 from './defense_evasion_base64_encoding_or_decoding_activity.json'; -import rule133 from './defense_evasion_hex_encoding_or_decoding_activity.json'; -import rule134 from './defense_evasion_file_mod_writable_dir.json'; -import rule135 from './defense_evasion_disable_selinux_attempt.json'; -import rule136 from './discovery_kernel_module_enumeration.json'; -import rule137 from './lateral_movement_telnet_network_activity_external.json'; -import rule138 from './lateral_movement_telnet_network_activity_internal.json'; -import rule139 from './privilege_escalation_setgid_bit_set_via_chmod.json'; -import rule140 from './privilege_escalation_setuid_bit_set_via_chmod.json'; -import rule141 from './defense_evasion_attempt_to_disable_iptables_or_firewall.json'; -import rule142 from './defense_evasion_kernel_module_removal.json'; -import rule143 from './defense_evasion_attempt_to_disable_syslog_service.json'; -import rule144 from './defense_evasion_file_deletion_via_shred.json'; -import rule145 from './discovery_virtual_machine_fingerprinting.json'; -import rule146 from './defense_evasion_hidden_file_dir_tmp.json'; -import rule147 from './defense_evasion_deletion_of_bash_command_line_history.json'; -import rule148 from './impact_cloudwatch_log_group_deletion.json'; -import rule149 from './impact_cloudwatch_log_stream_deletion.json'; -import rule150 from './impact_rds_instance_cluster_stoppage.json'; -import rule151 from './persistence_attempt_to_deactivate_mfa_for_okta_user_account.json'; -import rule152 from './persistence_rds_cluster_creation.json'; -import rule153 from './credential_access_attempted_bypass_of_okta_mfa.json'; -import rule154 from './defense_evasion_waf_acl_deletion.json'; -import rule155 from './impact_attempt_to_revoke_okta_api_token.json'; -import rule156 from './impact_iam_group_deletion.json'; -import rule157 from './impact_possible_okta_dos_attack.json'; -import rule158 from './impact_rds_cluster_deletion.json'; -import rule159 from './initial_access_suspicious_activity_reported_by_okta_user.json'; -import rule160 from './okta_attempt_to_deactivate_okta_mfa_rule.json'; -import rule161 from './okta_attempt_to_modify_okta_mfa_rule.json'; -import rule162 from './okta_attempt_to_modify_okta_network_zone.json'; -import rule163 from './okta_attempt_to_modify_okta_policy.json'; -import rule164 from './okta_threat_detected_by_okta_threatinsight.json'; -import rule165 from './persistence_administrator_privileges_assigned_to_okta_group.json'; -import rule166 from './persistence_attempt_to_create_okta_api_token.json'; -import rule167 from './persistence_attempt_to_deactivate_okta_policy.json'; -import rule168 from './persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json'; -import rule169 from './defense_evasion_cloudtrail_logging_deleted.json'; -import rule170 from './defense_evasion_ec2_network_acl_deletion.json'; -import rule171 from './impact_iam_deactivate_mfa_device.json'; -import rule172 from './defense_evasion_s3_bucket_configuration_deletion.json'; -import rule173 from './defense_evasion_guardduty_detector_deletion.json'; -import rule174 from './okta_attempt_to_delete_okta_policy.json'; -import rule175 from './credential_access_iam_user_addition_to_group.json'; -import rule176 from './persistence_ec2_network_acl_creation.json'; -import rule177 from './impact_ec2_disable_ebs_encryption.json'; -import rule178 from './persistence_iam_group_creation.json'; -import rule179 from './defense_evasion_waf_rule_or_rule_group_deletion.json'; -import rule180 from './collection_cloudtrail_logging_created.json'; -import rule181 from './defense_evasion_cloudtrail_logging_suspended.json'; -import rule182 from './impact_cloudtrail_logging_updated.json'; -import rule183 from './initial_access_console_login_root.json'; -import rule184 from './defense_evasion_cloudwatch_alarm_deletion.json'; -import rule185 from './defense_evasion_ec2_flow_log_deletion.json'; -import rule186 from './defense_evasion_configuration_recorder_stopped.json'; -import rule187 from './exfiltration_ec2_snapshot_change_activity.json'; -import rule188 from './defense_evasion_config_service_rule_deletion.json'; -import rule189 from './okta_attempt_to_modify_or_delete_application_sign_on_policy.json'; -import rule190 from './initial_access_password_recovery.json'; -import rule191 from './credential_access_secretsmanager_getsecretvalue.json'; -import rule192 from './execution_via_system_manager.json'; -import rule193 from './privilege_escalation_root_login_without_mfa.json'; -import rule194 from './privilege_escalation_updateassumerolepolicy.json'; -import rule195 from './elastic_endpoint.json'; -import rule196 from './external_alerts.json'; -import rule197 from './ml_cloudtrail_error_message_spike.json'; -import rule198 from './ml_cloudtrail_rare_error_code.json'; -import rule199 from './ml_cloudtrail_rare_method_by_city.json'; -import rule200 from './ml_cloudtrail_rare_method_by_country.json'; -import rule201 from './ml_cloudtrail_rare_method_by_user.json'; -import rule202 from './credential_access_aws_iam_assume_role_brute_force.json'; -import rule203 from './credential_access_okta_brute_force_or_password_spraying.json'; +import rule129 from './defense_evasion_iis_httplogging_disabled.json'; +import rule130 from './execution_python_tty_shell.json'; +import rule131 from './execution_perl_tty_shell.json'; +import rule132 from './defense_evasion_base16_or_base32_encoding_or_decoding_activity.json'; +import rule133 from './defense_evasion_base64_encoding_or_decoding_activity.json'; +import rule134 from './defense_evasion_hex_encoding_or_decoding_activity.json'; +import rule135 from './defense_evasion_file_mod_writable_dir.json'; +import rule136 from './defense_evasion_disable_selinux_attempt.json'; +import rule137 from './discovery_kernel_module_enumeration.json'; +import rule138 from './lateral_movement_telnet_network_activity_external.json'; +import rule139 from './lateral_movement_telnet_network_activity_internal.json'; +import rule140 from './privilege_escalation_setgid_bit_set_via_chmod.json'; +import rule141 from './privilege_escalation_setuid_bit_set_via_chmod.json'; +import rule142 from './defense_evasion_attempt_to_disable_iptables_or_firewall.json'; +import rule143 from './defense_evasion_kernel_module_removal.json'; +import rule144 from './defense_evasion_attempt_to_disable_syslog_service.json'; +import rule145 from './defense_evasion_file_deletion_via_shred.json'; +import rule146 from './discovery_virtual_machine_fingerprinting.json'; +import rule147 from './defense_evasion_hidden_file_dir_tmp.json'; +import rule148 from './defense_evasion_deletion_of_bash_command_line_history.json'; +import rule149 from './impact_cloudwatch_log_group_deletion.json'; +import rule150 from './impact_cloudwatch_log_stream_deletion.json'; +import rule151 from './impact_rds_instance_cluster_stoppage.json'; +import rule152 from './persistence_attempt_to_deactivate_mfa_for_okta_user_account.json'; +import rule153 from './persistence_rds_cluster_creation.json'; +import rule154 from './credential_access_attempted_bypass_of_okta_mfa.json'; +import rule155 from './defense_evasion_waf_acl_deletion.json'; +import rule156 from './impact_attempt_to_revoke_okta_api_token.json'; +import rule157 from './impact_iam_group_deletion.json'; +import rule158 from './impact_possible_okta_dos_attack.json'; +import rule159 from './impact_rds_cluster_deletion.json'; +import rule160 from './initial_access_suspicious_activity_reported_by_okta_user.json'; +import rule161 from './okta_attempt_to_deactivate_okta_mfa_rule.json'; +import rule162 from './okta_attempt_to_modify_okta_mfa_rule.json'; +import rule163 from './okta_attempt_to_modify_okta_network_zone.json'; +import rule164 from './okta_attempt_to_modify_okta_policy.json'; +import rule165 from './okta_threat_detected_by_okta_threatinsight.json'; +import rule166 from './persistence_administrator_privileges_assigned_to_okta_group.json'; +import rule167 from './persistence_attempt_to_create_okta_api_token.json'; +import rule168 from './persistence_attempt_to_deactivate_okta_policy.json'; +import rule169 from './persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json'; +import rule170 from './defense_evasion_cloudtrail_logging_deleted.json'; +import rule171 from './defense_evasion_ec2_network_acl_deletion.json'; +import rule172 from './impact_iam_deactivate_mfa_device.json'; +import rule173 from './defense_evasion_s3_bucket_configuration_deletion.json'; +import rule174 from './defense_evasion_guardduty_detector_deletion.json'; +import rule175 from './okta_attempt_to_delete_okta_policy.json'; +import rule176 from './credential_access_iam_user_addition_to_group.json'; +import rule177 from './persistence_ec2_network_acl_creation.json'; +import rule178 from './impact_ec2_disable_ebs_encryption.json'; +import rule179 from './persistence_iam_group_creation.json'; +import rule180 from './defense_evasion_waf_rule_or_rule_group_deletion.json'; +import rule181 from './collection_cloudtrail_logging_created.json'; +import rule182 from './defense_evasion_cloudtrail_logging_suspended.json'; +import rule183 from './impact_cloudtrail_logging_updated.json'; +import rule184 from './initial_access_console_login_root.json'; +import rule185 from './defense_evasion_cloudwatch_alarm_deletion.json'; +import rule186 from './defense_evasion_ec2_flow_log_deletion.json'; +import rule187 from './defense_evasion_configuration_recorder_stopped.json'; +import rule188 from './exfiltration_ec2_snapshot_change_activity.json'; +import rule189 from './defense_evasion_config_service_rule_deletion.json'; +import rule190 from './okta_attempt_to_modify_or_delete_application_sign_on_policy.json'; +import rule191 from './command_and_control_download_rar_powershell_from_internet.json'; +import rule192 from './initial_access_password_recovery.json'; +import rule193 from './command_and_control_cobalt_strike_beacon.json'; +import rule194 from './command_and_control_fin7_c2_behavior.json'; +import rule195 from './command_and_control_halfbaked_beacon.json'; +import rule196 from './credential_access_secretsmanager_getsecretvalue.json'; +import rule197 from './execution_via_system_manager.json'; +import rule198 from './privilege_escalation_root_login_without_mfa.json'; +import rule199 from './privilege_escalation_updateassumerolepolicy.json'; +import rule200 from './impact_hosts_file_modified.json'; +import rule201 from './elastic_endpoint.json'; +import rule202 from './external_alerts.json'; +import rule203 from './ml_cloudtrail_error_message_spike.json'; +import rule204 from './ml_cloudtrail_rare_error_code.json'; +import rule205 from './ml_cloudtrail_rare_method_by_city.json'; +import rule206 from './ml_cloudtrail_rare_method_by_country.json'; +import rule207 from './ml_cloudtrail_rare_method_by_user.json'; +import rule208 from './credential_access_aws_iam_assume_role_brute_force.json'; +import rule209 from './credential_access_okta_brute_force_or_password_spraying.json'; +import rule210 from './execution_unusual_dns_service_children.json'; +import rule211 from './execution_unusual_dns_service_file_writes.json'; +import rule212 from './lateral_movement_dns_server_overflow.json'; +import rule213 from './initial_access_root_console_failure_brute_force.json'; +import rule214 from './initial_access_unsecure_elasticsearch_node.json'; +import rule215 from './credential_access_domain_backup_dpapi_private_keys.json'; +import rule216 from './lateral_movement_gpo_schtask_service_creation.json'; +import rule217 from './credential_access_kerberosdump_kcc.json'; +import rule218 from './defense_evasion_execution_suspicious_psexesvc.json'; +import rule219 from './execution_via_xp_cmdshell_mssql_stored_procedure.json'; +import rule220 from './exfiltration_compress_credentials_keychains.json'; +import rule221 from './privilege_escalation_printspooler_service_suspicious_file.json'; +import rule222 from './privilege_escalation_printspooler_suspicious_spl_file.json'; +import rule223 from './defense_evasion_azure_diagnostic_settings_deletion.json'; +import rule224 from './execution_command_virtual_machine.json'; +import rule225 from './execution_via_hidden_shell_conhost.json'; +import rule226 from './impact_resource_group_deletion.json'; +import rule227 from './persistence_via_telemetrycontroller_scheduledtask_hijack.json'; +import rule228 from './persistence_via_update_orchestrator_service_hijack.json'; +import rule229 from './collection_update_event_hub_auth_rule.json'; +import rule230 from './credential_access_iis_apppoolsa_pwd_appcmd.json'; +import rule231 from './credential_access_iis_connectionstrings_dumping.json'; +import rule232 from './defense_evasion_event_hub_deletion.json'; +import rule233 from './defense_evasion_firewall_policy_deletion.json'; +import rule234 from './defense_evasion_sdelete_like_filename_rename.json'; +import rule235 from './lateral_movement_remote_ssh_login_enabled.json'; +import rule236 from './persistence_azure_automation_account_created.json'; +import rule237 from './persistence_azure_automation_runbook_created_or_modified.json'; +import rule238 from './persistence_azure_automation_webhook_created.json'; +import rule239 from './privilege_escalation_uac_bypass_diskcleanup_hijack.json'; +import rule240 from './credential_access_attempts_to_brute_force_okta_user_account.json'; +import rule241 from './credential_access_storage_account_key_regenerated.json'; +import rule242 from './credential_access_suspicious_okta_user_password_reset_or_unlock_attempts.json'; +import rule243 from './defense_evasion_system_critical_proc_abnormal_file_activity.json'; +import rule244 from './defense_evasion_unusual_system_vp_child_program.json'; +import rule245 from './defense_evasion_mfa_disabled_for_azure_user.json'; +import rule246 from './discovery_blob_container_access_mod.json'; +import rule247 from './persistence_user_added_as_owner_for_azure_application.json'; +import rule248 from './persistence_user_added_as_owner_for_azure_service_principal.json'; +import rule249 from './defense_evasion_suspicious_managedcode_host_process.json'; +import rule250 from './execution_command_shell_started_by_unusual_process.json'; +import rule251 from './execution_suspicious_dotnet_compiler_parent_process.json'; +import rule252 from './defense_evasion_masquerading_as_elastic_endpoint_process.json'; +import rule253 from './defense_evasion_masquerading_suspicious_werfault_childproc.json'; +import rule254 from './defense_evasion_masquerading_werfault.json'; +import rule255 from './credential_access_key_vault_modified.json'; +import rule256 from './credential_access_mimikatz_memssp_default_logs.json'; +import rule257 from './defense_evasion_code_injection_conhost.json'; +import rule258 from './defense_evasion_network_watcher_deletion.json'; +import rule259 from './initial_access_external_guest_user_invite.json'; +import rule260 from './defense_evasion_azure_conditional_access_policy_modified.json'; +import rule261 from './defense_evasion_azure_privileged_identity_management_role_modified.json'; +import rule262 from './defense_evasion_masquerading_renamed_autoit.json'; +import rule263 from './impact_azure_automation_runbook_deleted.json'; +import rule264 from './initial_access_consent_grant_attack_via_azure_registered_application.json'; +import rule265 from './c2_installutil_beacon.json'; +import rule266 from './c2_msbuild_beacon_sequence.json'; +import rule267 from './c2_mshta_beacon.json'; +import rule268 from './c2_msxsl_beacon.json'; +import rule269 from './c2_network_connection_from_windows_binary.json'; +import rule270 from './c2_reg_beacon.json'; +import rule271 from './c2_rundll32_sequence.json'; +import rule272 from './command_and_control_teamviewer_remote_file_copy.json'; +import rule273 from './escalation_uac_sdclt.json'; +import rule274 from './evasion_rundll32_no_arguments.json'; +import rule275 from './evasion_suspicious_scrobj_load.json'; +import rule276 from './evasion_suspicious_wmi_script.json'; +import rule277 from './execution_ms_office_written_file.json'; +import rule278 from './execution_pdf_written_file.json'; +import rule279 from './execution_wpad_exploitation.json'; +import rule280 from './lateral_movement_cmd_service.json'; +import rule281 from './persistence_app_compat_shim.json'; +import rule282 from './command_and_control_remote_file_copy_desktopimgdownldr.json'; +import rule283 from './command_and_control_remote_file_copy_mpcmdrun.json'; +import rule284 from './defense_evasion_execution_suspicious_explorer_winword.json'; +import rule285 from './defense_evasion_suspicious_zoom_child_process.json'; +import rule286 from './ml_linux_anomalous_compiler_activity.json'; +import rule287 from './ml_linux_anomalous_kernel_module_arguments.json'; +import rule288 from './ml_linux_anomalous_sudo_activity.json'; +import rule289 from './ml_linux_system_information_discovery.json'; +import rule290 from './ml_linux_system_network_configuration_discovery.json'; +import rule291 from './ml_linux_system_network_connection_discovery.json'; +import rule292 from './ml_linux_system_process_discovery.json'; +import rule293 from './ml_linux_system_user_discovery.json'; +import rule294 from './discovery_post_exploitation_public_ip_reconnaissance.json'; +import rule295 from './defense_evasion_gcp_logging_sink_deletion.json'; +import rule296 from './defense_evasion_gcp_pub_sub_topic_deletion.json'; +import rule297 from './credential_access_gcp_iam_service_account_key_deletion.json'; +import rule298 from './credential_access_gcp_key_created_for_service_account.json'; +import rule299 from './defense_evasion_gcp_firewall_rule_created.json'; +import rule300 from './defense_evasion_gcp_firewall_rule_deleted.json'; +import rule301 from './defense_evasion_gcp_firewall_rule_modified.json'; +import rule302 from './defense_evasion_gcp_logging_bucket_deletion.json'; +import rule303 from './defense_evasion_gcp_storage_bucket_permissions_modified.json'; +import rule304 from './impact_gcp_storage_bucket_deleted.json'; +import rule305 from './initial_access_gcp_iam_custom_role_creation.json'; +import rule306 from './defense_evasion_gcp_storage_bucket_configuration_modified.json'; +import rule307 from './exfiltration_gcp_logging_sink_modification.json'; +import rule308 from './impact_gcp_iam_role_deletion.json'; +import rule309 from './impact_gcp_service_account_deleted.json'; +import rule310 from './impact_gcp_service_account_disabled.json'; +import rule311 from './impact_gcp_virtual_private_cloud_network_deleted.json'; +import rule312 from './impact_gcp_virtual_private_cloud_route_created.json'; +import rule313 from './impact_gcp_virtual_private_cloud_route_deleted.json'; +import rule314 from './ml_linux_anomalous_metadata_process.json'; +import rule315 from './ml_linux_anomalous_metadata_user.json'; +import rule316 from './ml_windows_anomalous_metadata_process.json'; +import rule317 from './ml_windows_anomalous_metadata_user.json'; +import rule318 from './persistence_gcp_service_account_created.json'; +import rule319 from './collection_gcp_pub_sub_subscription_creation.json'; +import rule320 from './collection_gcp_pub_sub_topic_creation.json'; +import rule321 from './defense_evasion_gcp_pub_sub_subscription_deletion.json'; +import rule322 from './persistence_azure_pim_user_added_global_admin.json'; export const rawRules = [ rule1, @@ -417,4 +536,123 @@ export const rawRules = [ rule201, rule202, rule203, + rule204, + rule205, + rule206, + rule207, + rule208, + rule209, + rule210, + rule211, + rule212, + rule213, + rule214, + rule215, + rule216, + rule217, + rule218, + rule219, + rule220, + rule221, + rule222, + rule223, + rule224, + rule225, + rule226, + rule227, + rule228, + rule229, + rule230, + rule231, + rule232, + rule233, + rule234, + rule235, + rule236, + rule237, + rule238, + rule239, + rule240, + rule241, + rule242, + rule243, + rule244, + rule245, + rule246, + rule247, + rule248, + rule249, + rule250, + rule251, + rule252, + rule253, + rule254, + rule255, + rule256, + rule257, + rule258, + rule259, + rule260, + rule261, + rule262, + rule263, + rule264, + rule265, + rule266, + rule267, + rule268, + rule269, + rule270, + rule271, + rule272, + rule273, + rule274, + rule275, + rule276, + rule277, + rule278, + rule279, + rule280, + rule281, + rule282, + rule283, + rule284, + rule285, + rule286, + rule287, + rule288, + rule289, + rule290, + rule291, + rule292, + rule293, + rule294, + rule295, + rule296, + rule297, + rule298, + rule299, + rule300, + rule301, + rule302, + rule303, + rule304, + rule305, + rule306, + rule307, + rule308, + rule309, + rule310, + rule311, + rule312, + rule313, + rule314, + rule315, + rule316, + rule317, + rule318, + rule319, + rule320, + rule321, + rule322, ]; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_consent_grant_attack_via_azure_registered_application.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_consent_grant_attack_via_azure_registered_application.json new file mode 100644 index 0000000000000..8147859fa4e6f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_consent_grant_attack_via_azure_registered_application.json @@ -0,0 +1,62 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a user grants permissions to an Azure-registered application or when an administrator grants tenant-wide permissions to an application. An adversary may create an Azure-registered application that requests access to data such as contact information, email, or documents.", + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Possible Consent Grant Attack via Azure-Registered Application", + "note": "- The Azure Filebeat module must be enabled to use this rule.\n- In a consent grant attack, an attacker tricks an end user into granting a malicious application consent to access their data, usually via a phishing attack. After the malicious application has been granted consent, it has account-level access to data without the need for an organizational account.\n- Normal remediation steps, like resetting passwords for breached accounts or requiring Multi-Factor Authentication (MFA) on accounts, are not effective against this type of attack, since these are third-party applications and are external to the organization.\n- Security analysts should review the list of trusted applications for any suspicious items.\n", + "query": "event.dataset:(azure.activitylogs or azure.auditlogs) and ( azure.activitylogs.operation_name:\"Consent to application\" or azure.auditlogs.operation_name:\"Consent to application\" ) and event.outcome:success", + "references": [ + "https://docs.microsoft.com/en-us/microsoft-365/security/office-365-security/detect-and-remediate-illicit-consent-grants?view=o365-worldwide" + ], + "risk_score": 47, + "rule_id": "1c6a8c7a-5cb6-4a82-ba27-d5a5b8a40a38", + "severity": "medium", + "tags": [ + "Elastic", + "Azure", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0001", + "name": "Initial Access", + "reference": "https://attack.mitre.org/tactics/TA0001/" + }, + "technique": [ + { + "id": "T1192", + "name": "Spearphishing Link", + "reference": "https://attack.mitre.org/techniques/T1192/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1528", + "name": "Steal Application Access Token", + "reference": "https://attack.mitre.org/techniques/T1528/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_console_login_root.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_console_login_root.json index 026e1e549b574..621881e264138 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_console_login_root.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_console_login_root.json @@ -8,14 +8,15 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", "license": "Elastic License", "name": "AWS Management Console Root Login", "note": "The AWS Filebeat module must be enabled to use this rule.", - "query": "event.action:ConsoleLogin and event.module:aws and event.dataset:aws.cloudtrail and event.provider:signin.amazonaws.com and aws.cloudtrail.user_identity.type:Root and event.outcome:success", + "query": "event.action:ConsoleLogin and event.dataset:aws.cloudtrail and event.provider:signin.amazonaws.com and aws.cloudtrail.user_identity.type:Root and event.outcome:success", "references": [ "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-user.html" ], @@ -62,5 +63,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_external_guest_user_invite.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_external_guest_user_invite.json new file mode 100644 index 0000000000000..392e0ec745fc2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_external_guest_user_invite.json @@ -0,0 +1,65 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies an invitation to an external user in Azure Active Directory (AD). Azure AD is extended to include collaboration, allowing you to invite people from outside your organization to be guest users in your cloud account. Unless there is a business need to provision guest access, it is best practice avoid creating guest users. Guest users could potentially be overlooked indefinitely leading to a potential vulnerability.", + "false_positives": [ + "Guest user invitations may be sent out by a system or network administrator. Verify whether the username, hostname, and/or resource name should be making changes in your environment. Guest user invitations from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Azure External Guest User Invitation", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.auditlogs and azure.auditlogs.operation_name:\"Invite external user\" and azure.auditlogs.properties.target_resources.*.display_name:guest and event.outcome:Success", + "references": [ + "https://docs.microsoft.com/en-us/azure/governance/policy/samples/cis-azure-1-1-0" + ], + "risk_score": 21, + "rule_id": "141e9b3a-ff37-4756-989d-05d7cbf35b0e", + "severity": "low", + "tags": [ + "Elastic", + "Azure", + "SecOps", + "Continuous Monitoring", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0001", + "name": "Initial Access", + "reference": "https://attack.mitre.org/tactics/TA0001/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_gcp_iam_custom_role_creation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_gcp_iam_custom_role_creation.json new file mode 100644 index 0000000000000..0eab41ad8c4bd --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_gcp_iam_custom_role_creation.json @@ -0,0 +1,64 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies an Identity and Access Management (IAM) custom role creation in Google Cloud Platform (GCP). Custom roles are user-defined, and allow for the bundling of one or more supported permissions to meet specific needs. Custom roles will not be updated automatically and could lead to privilege creep if not carefully scrutinized.", + "false_positives": [ + "Custom role creations may be done by a system or network administrator. Verify whether the user email, resource name, and/or hostname should be making changes in your environment. Role creations from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP IAM Custom Role Creation", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:google.iam.admin.v*.CreateRole and event.outcome:success", + "references": [ + "https://cloud.google.com/iam/docs/understanding-custom-roles" + ], + "risk_score": 47, + "rule_id": "aa8007f0-d1df-49ef-8520-407857594827", + "severity": "medium", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0001", + "name": "Initial Access", + "reference": "https://attack.mitre.org/tactics/TA0001/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_password_recovery.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_password_recovery.json index bd20be0924d05..2f0eed31d05be 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_password_recovery.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_password_recovery.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -47,5 +48,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rdp_remote_desktop_protocol_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rdp_remote_desktop_protocol_to_the_internet.json index 2d5f96492cc36..15c3c81a551bd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rdp_remote_desktop_protocol_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rdp_remote_desktop_protocol_to_the_internet.json @@ -25,15 +25,15 @@ { "framework": "MITRE ATT&CK", "tactic": { - "id": "TA0011", + "id": "TA0001", "name": "Initial Access", - "reference": "https://attack.mitre.org/tactics/TA0011/" + "reference": "https://attack.mitre.org/tactics/TA0001/" }, "technique": [ { - "id": "T1043", + "id": "T1190", "name": "Exploit Public-Facing Application", - "reference": "https://attack.mitre.org/techniques/T1043/" + "reference": "https://attack.mitre.org/techniques/T1190/" } ] }, @@ -54,5 +54,5 @@ } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_root_console_failure_brute_force.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_root_console_failure_brute_force.json new file mode 100644 index 0000000000000..5f7781be82efd --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_root_console_failure_brute_force.json @@ -0,0 +1,55 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a high number of failed authentication attempts to the AWS management console for the Root user identity. An adversary may attempt to brute force the password for the Root user identity, as it has complete access to all services and resources for the AWS account.", + "false_positives": [ + "Automated processes that attempt to authenticate using expired credentials and unbounded retries may lead to false positives." + ], + "from": "now-20m", + "index": [ + "filebeat-*", + "logs-aws*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "AWS Management Console Brute Force of Root User Identity", + "note": "The AWS Filebeat module must be enabled to use this rule.", + "query": "event.dataset:aws.cloudtrail and event.provider:signin.amazonaws.com and event.action:ConsoleLogin and aws.cloudtrail.user_identity.type:Root and event.outcome:failure", + "references": [ + "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-user.html" + ], + "risk_score": 73, + "rule_id": "4d50a94f-2844-43fa-8395-6afbd5e1c5ef", + "severity": "high", + "tags": [ + "AWS", + "Continuous Monitoring", + "Elastic", + "Identity and Access", + "SecOps" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1110", + "name": "Brute Force", + "reference": "https://attack.mitre.org/techniques/T1110/" + } + ] + } + ], + "threshold": { + "field": "cloud.account.id", + "value": 10 + }, + "type": "threshold", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_from_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_from_the_internet.json index d28e52c163d3c..7c61f95f9e9f3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_from_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_from_the_internet.json @@ -22,9 +22,9 @@ { "framework": "MITRE ATT&CK", "tactic": { - "id": "TA0011", + "id": "TA0001", "name": "Initial Access", - "reference": "https://attack.mitre.org/tactics/TA0011/" + "reference": "https://attack.mitre.org/tactics/TA0001/" }, "technique": [ { @@ -36,5 +36,5 @@ } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_to_the_internet.json index 01c661af5609d..e8da93ed9d1c7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_to_the_internet.json @@ -22,9 +22,9 @@ { "framework": "MITRE ATT&CK", "tactic": { - "id": "TA0011", + "id": "TA0001", "name": "Initial Access", - "reference": "https://attack.mitre.org/tactics/TA0011/" + "reference": "https://attack.mitre.org/tactics/TA0001/" }, "technique": [ { @@ -36,5 +36,5 @@ } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_smb_windows_file_sharing_activity_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_smb_windows_file_sharing_activity_to_the_internet.json index 7ef56023eba55..aff8a415b7e35 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_smb_windows_file_sharing_activity_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_smb_windows_file_sharing_activity_to_the_internet.json @@ -22,9 +22,9 @@ { "framework": "MITRE ATT&CK", "tactic": { - "id": "TA0011", + "id": "TA0001", "name": "Initial Access", - "reference": "https://attack.mitre.org/tactics/TA0011/" + "reference": "https://attack.mitre.org/tactics/TA0001/" }, "technique": [ { @@ -51,5 +51,5 @@ } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_suspicious_activity_reported_by_okta_user.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_suspicious_activity_reported_by_okta_user.json index 2344346c8d61d..24837084c8381 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_suspicious_activity_reported_by_okta_user.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_suspicious_activity_reported_by_okta_user.json @@ -7,13 +7,14 @@ "A user may report suspicious activity on their Okta account in error." ], "index": [ - "filebeat-*" + "filebeat-*", + "logs-okta*" ], "language": "kuery", "license": "Elastic License", "name": "Suspicious Activity Reported by Okta User", "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.module:okta and event.dataset:okta.system and event.action:user.account.report_suspicious_activity_by_enduser", + "query": "event.dataset:okta.system and event.action:user.account.report_suspicious_activity_by_enduser", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" @@ -91,5 +92,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_unsecure_elasticsearch_node.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_unsecure_elasticsearch_node.json new file mode 100644 index 0000000000000..e6d718a23eb96 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_unsecure_elasticsearch_node.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies Elasticsearch nodes that do not have Transport Layer Security (TLS), and/or lack authentication, and are accepting inbound network connections over the default Elasticsearch port.", + "false_positives": [ + "If you have front-facing proxies that provide authentication and TLS, this rule would need to be tuned to eliminate the source IP address of your reverse-proxy." + ], + "index": [ + "packetbeat-*" + ], + "language": "lucene", + "license": "Elastic License", + "name": "Inbound Connection to an Unsecure Elasticsearch Node", + "note": "This rule requires the addition of port `9200` and `send_all_headers` to the `HTTP` protocol configuration in `packetbeat.yml`. See the References section for additional configuration documentation.", + "query": "event.category:network_traffic AND network.protocol:http AND status:OK AND destination.port:9200 AND network.direction:inbound AND NOT http.response.headers.content-type:\"image/x-icon\" AND NOT _exists_:http.request.headers.authorization", + "references": [ + "https://www.elastic.co/guide/en/elasticsearch/reference/current/configuring-security.html", + "https://www.elastic.co/guide/en/beats/packetbeat/current/packetbeat-http-options.html#_send_all_headers" + ], + "risk_score": 47, + "rule_id": "31295df3-277b-4c56-a1fb-84e31b4222a9", + "severity": "medium", + "tags": [ + "Elastic", + "Network" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0001", + "name": "Initial Access", + "reference": "https://attack.mitre.org/tactics/TA0001/" + }, + "technique": [ + { + "id": "T1190", + "name": "Exploit Public-Facing Application", + "reference": "https://attack.mitre.org/techniques/T1190/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_cmd_service.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_cmd_service.json new file mode 100644 index 0000000000000..bd14db77b9fe9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_cmd_service.json @@ -0,0 +1,51 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies use of sc.exe to create, modify, or start services on remote hosts. This could be indicative of adversary lateral movement but will be noisy if commonly done by admins.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Service Command Lateral Movement", + "query": "/* dependent on a wildcard for remote path */\n\nsequence by process.entity_id with maxspan=1m\n [process where event.type in (\"start\", \"process_started\") and\n (process.name == \"sc.exe\" or process.pe.original_file_name == \"sc.exe\") and\n wildcard(process.args, \"\\\\\\\\*\") and wildcard(process.args, \"binPath*\", \"binpath*\") and\n process.args in (\"create\", \"config\", \"failure\", \"start\")]\n [network where event.type == \"connection\" and process.name == \"sc.exe\" and destination.address != \"127.0.0.1\"]\n", + "risk_score": 21, + "rule_id": "d61cbcf8-1bc1-4cff-85ba-e7b21c5beedc", + "severity": "low", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1021", + "name": "Remote Services", + "reference": "https://attack.mitre.org/techniques/T1021/" + }, + { + "id": "T1050", + "name": "New Service", + "reference": "https://attack.mitre.org/techniques/T1050/" + }, + { + "id": "T1035", + "name": "Service Execution", + "reference": "https://attack.mitre.org/techniques/T1035/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_dns_server_overflow.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_dns_server_overflow.json new file mode 100644 index 0000000000000..2a86dcac12e7b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_dns_server_overflow.json @@ -0,0 +1,50 @@ +{ + "author": [ + "Elastic" + ], + "description": "Specially crafted DNS requests can manipulate a known overflow vulnerability in some Windows DNS servers which result in Remote Code Execution (RCE) or a Denial of Service (DoS) from crashing the service.", + "false_positives": [ + "Environments that leverage DNS responses over 60k bytes will result in false positives - if this traffic is predictable and expected, it should be filtered out. Additionally, this detection rule could be triggered by an authorized vulnerability scan or compromise assessment." + ], + "index": [ + "packetbeat-*", + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Abnormally Large DNS Response", + "note": "### Investigating Large DNS Responses\nDetection alerts from this rule indicate an attempt was made to exploit CVE-2020-1350 (SigRed) through the use of large DNS responses on a Windows DNS server. Here are some possible avenues of investigation:\n- Investigate any corresponding Intrusion Detection Signatures (IDS) alerts that can validate this detection alert.\n- Examine the `dns.question_type` network fieldset with a protocol analyzer, such as Zeek, Packetbeat, or Suricata, for `SIG` or `RRSIG` data.\n- Validate the patch level and OS of the targeted DNS server to validate the observed activity was not large-scale Internet vulnerability scanning.\n- Validate that the source of the network activity was not from an authorized vulnerability scan or compromise assessment.", + "query": "event.category:(network or network_traffic) and destination.port:53 and (event.dataset:zeek.dns or type:dns or event.type:connection) and network.bytes > 60000", + "references": [ + "https://research.checkpoint.com/2020/resolving-your-way-into-domain-admin-exploiting-a-17-year-old-bug-in-windows-dns-servers/", + "https://msrc-blog.microsoft.com/2020/07/14/july-2020-security-update-cve-2020-1350-vulnerability-in-windows-domain-name-system-dns-server/", + "https://github.com/maxpl0it/CVE-2020-1350-DoS" + ], + "risk_score": 47, + "rule_id": "11013227-0301-4a8c-b150-4db924484475", + "severity": "medium", + "tags": [ + "Elastic", + "Network", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1210", + "name": "Exploitation of Remote Services", + "reference": "https://attack.mitre.org/techniques/T1210/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_gpo_schtask_service_creation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_gpo_schtask_service_creation.json new file mode 100644 index 0000000000000..fbf6fddcb8c00 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_gpo_schtask_service_creation.json @@ -0,0 +1,56 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects the creation or modification of a new Group Policy based scheduled task or service. These methods are used for legitimate system administration, but can also be abused by an attacker with domain admin permissions to execute a malicious payload remotely on all or a subset of the domain joined machines.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Creation or Modification of a new GPO Scheduled Task or Service", + "query": "event.category:file and not event.type:deletion and file.path:(C\\:\\\\Windows\\\\SYSVOL\\\\domain\\\\Policies\\\\*\\\\MACHINE\\\\Preferences\\\\ScheduledTasks\\\\ScheduledTasks.xml or C\\:\\\\Windows\\\\SYSVOL\\\\domain\\\\Policies\\\\*\\\\MACHINE\\\\Preferences\\\\Preferences\\\\Services\\\\Services.xml) and not process.name:dfsrs.exe", + "risk_score": 21, + "rule_id": "c0429aa8-9974-42da-bfb6-53a0a515a145", + "severity": "low", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1053", + "name": "Scheduled Task/Job", + "reference": "https://attack.mitre.org/techniques/T1053/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1021", + "name": "Remote Services", + "reference": "https://attack.mitre.org/techniques/T1021/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_remote_ssh_login_enabled.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_remote_ssh_login_enabled.json new file mode 100644 index 0000000000000..f1ce68abf8302 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_remote_ssh_login_enabled.json @@ -0,0 +1,45 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects use of the systemsetup command to enable remote SSH Login.", + "from": "now-9m", + "index": [ + "auditbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Remote SSH Login Enabled via systemsetup Command", + "query": "event.category:process and event.type:(start or process_started) and process.name:systemsetup and process.args:(\"-f\" and \"-setremotelogin\" and on)", + "references": [ + "https://documents.trendmicro.com/assets/pdf/XCSSET_Technical_Brief.pdf", + "https://ss64.com/osx/systemsetup.html" + ], + "risk_score": 47, + "rule_id": "5ae4e6f8-d1bf-40fa-96ba-e29645e1e4dc", + "severity": "medium", + "tags": [ + "Elastic", + "MacOS" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1021", + "name": "Remote Services", + "reference": "https://attack.mitre.org/techniques/T1021/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json index 37d5468c773bf..99d087fe675a6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json @@ -16,7 +16,7 @@ "name": "Mknod Process Activity", "query": "event.category:process and event.type:(start or process_started) and process.name:mknod", "references": [ - "https://pen-testing.sans.org/blog/2013/05/06/netcat-without-e-no-problem" + "https://web.archive.org/web/20191218024607/https://pen-testing.sans.org/blog/2013/05/06/netcat-without-e-no-problem/" ], "risk_score": 21, "rule_id": "61c31c14-507f-4627-8c31-072556b89a9c", @@ -26,5 +26,5 @@ "Linux" ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_compiler_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_compiler_activity.json new file mode 100644 index 0000000000000..eb764c5e40817 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_compiler_activity.json @@ -0,0 +1,25 @@ +{ + "anomaly_threshold": 50, + "author": [ + "Elastic" + ], + "description": "Looks for compiler activity by a user context which does not normally run compilers. This can be the result of ad-hoc software changes or unauthorized software deployment. This can also be due to local privilege elevation via locally run exploits or malware activity.", + "false_positives": [ + "Uncommon compiler activity can be due to an engineer running a local build on a prod or staging instance in the course of troubleshooting or fixing a software issue." + ], + "from": "now-45m", + "interval": "15m", + "license": "Elastic License", + "machine_learning_job_id": "linux_rare_user_compiler", + "name": "Anomalous Linux Compiler Activity", + "risk_score": 21, + "rule_id": "cd66a419-9b3f-4f57-8ff8-ac4cd2d5f530", + "severity": "low", + "tags": [ + "Elastic", + "Linux", + "ML" + ], + "type": "machine_learning", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_kernel_module_arguments.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_kernel_module_arguments.json new file mode 100644 index 0000000000000..d289e0ba6f008 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_kernel_module_arguments.json @@ -0,0 +1,45 @@ +{ + "anomaly_threshold": 25, + "author": [ + "Elastic" + ], + "description": "Looks for unusual kernel module activity. Kernel modules are sometimes used by malware and persistence mechanisms for stealth.", + "false_positives": [ + "A Linux host running unusual device drivers or other kinds of kernel modules could trigger this detection. Troubleshooting or debugging activity using unusual arguments could also trigger this detection." + ], + "from": "now-45m", + "interval": "15m", + "license": "Elastic License", + "machine_learning_job_id": "linux_rare_kernel_module_arguments", + "name": "Anomalous Kernel Module Activity", + "references": [ + "references" + ], + "risk_score": 21, + "rule_id": "37b0816d-af40-40b4-885f-bb162b3c88a9", + "severity": "low", + "tags": [ + "Elastic", + "Linux", + "ML" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1215", + "name": "Kernel Modules and Extensions", + "reference": "https://attack.mitre.org/techniques/T1215/" + } + ] + } + ], + "type": "machine_learning", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_metadata_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_metadata_process.json new file mode 100644 index 0000000000000..c1cc619164b1f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_metadata_process.json @@ -0,0 +1,25 @@ +{ + "anomaly_threshold": 50, + "author": [ + "Elastic" + ], + "description": "Looks for anomalous access to the metadata service by an unusual process. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", + "false_positives": [ + "A newly installed program or one that runs very rarely as part of a monthly or quarterly workflow could trigger this detection rule." + ], + "from": "now-45m", + "interval": "15m", + "license": "Elastic License", + "machine_learning_job_id": "linux_rare_metadata_process", + "name": "Unusual Process Calling the Metadata Service", + "risk_score": 21, + "rule_id": "9d302377-d226-4e12-b54c-1906b5aec4f6", + "severity": "low", + "tags": [ + "Elastic", + "Linux", + "ML" + ], + "type": "machine_learning", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_metadata_user.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_metadata_user.json new file mode 100644 index 0000000000000..59a04dd54dd89 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_metadata_user.json @@ -0,0 +1,25 @@ +{ + "anomaly_threshold": 75, + "author": [ + "Elastic" + ], + "description": "Looks for anomalous access to the cloud platform metadata service by an unusual user. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", + "false_positives": [ + "A newly installed program, or one that runs under a new or rarely used user context, could trigger this detection rule. Manual interrogation of the metadata service during debugging or troubleshooting could trigger this rule." + ], + "from": "now-45m", + "interval": "15m", + "license": "Elastic License", + "machine_learning_job_id": "linux_rare_metadata_user", + "name": "Unusual Linux User Calling the Metadata Service", + "risk_score": 21, + "rule_id": "1faec04b-d902-4f89-8aff-92cd9043c16f", + "severity": "low", + "tags": [ + "Elastic", + "Linux", + "ML" + ], + "type": "machine_learning", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_sudo_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_sudo_activity.json new file mode 100644 index 0000000000000..8f03b24a6bd18 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_sudo_activity.json @@ -0,0 +1,57 @@ +{ + "anomaly_threshold": 75, + "author": [ + "Elastic" + ], + "description": "Looks for sudo activity from an unusual user context. An unusual sudo user could be due to troubleshooting activity or it could be a sign of credentialed access via compromised accounts.", + "false_positives": [ + "Uncommon sudo activity can be due to an engineer logging onto a server instance in order to perform manual troubleshooting or reconfiguration." + ], + "from": "now-45m", + "interval": "15m", + "license": "Elastic License", + "machine_learning_job_id": "linux_rare_sudo_user", + "name": "Unusual Sudo Activity", + "risk_score": 21, + "rule_id": "1e9fc667-9ff1-4b33-9f40-fefca8537eb0", + "severity": "low", + "tags": [ + "Elastic", + "Linux", + "ML" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1548", + "name": "Abuse Elevation Control Mechanism", + "reference": "https://attack.mitre.org/techniques/T1548/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1548", + "name": "Abuse Elevation Control Mechanism", + "reference": "https://attack.mitre.org/techniques/T1548/" + } + ] + } + ], + "type": "machine_learning", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_system_information_discovery.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_system_information_discovery.json new file mode 100644 index 0000000000000..40f117c6a5708 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_system_information_discovery.json @@ -0,0 +1,42 @@ +{ + "anomaly_threshold": 75, + "author": [ + "Elastic" + ], + "description": "Looks for commands related to system information discovery from an unusual user context. This can be due to uncommon troubleshooting activity or due to a compromised account. A compromised account may be used to engage in system information discovery in order to gather detailed information about system configuration and software versions. This may be a precursor to selection of a persistence mechanism or a method of privilege elevation.", + "false_positives": [ + "Uncommon user command activity can be due to an engineer logging onto a server instance in order to perform manual troubleshooting or reconfiguration." + ], + "from": "now-45m", + "interval": "15m", + "license": "Elastic License", + "machine_learning_job_id": "linux_system_information_discovery", + "name": "Unusual Linux System Information Discovery Activity", + "risk_score": 21, + "rule_id": "d4af3a06-1e0a-48ec-b96a-faf2309fae46", + "severity": "low", + "tags": [ + "Elastic", + "Linux", + "ML" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007/" + }, + "technique": [ + { + "id": "T1082", + "name": "System Information Discovery", + "reference": "https://attack.mitre.org/techniques/T1082/" + } + ] + } + ], + "type": "machine_learning", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_system_network_configuration_discovery.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_system_network_configuration_discovery.json new file mode 100644 index 0000000000000..326024114f145 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_system_network_configuration_discovery.json @@ -0,0 +1,42 @@ +{ + "anomaly_threshold": 25, + "author": [ + "Elastic" + ], + "description": "Looks for commands related to system network configuration discovery from an unusual user context. This can be due to uncommon troubleshooting activity or due to a compromised account. A compromised account may be used by a threat actor to engage in system network configuration discovery in order to increase their understanding of connected networks and hosts. This information may be used to shape follow-up behaviors such as lateral movement or additional discovery.", + "false_positives": [ + "Uncommon user command activity can be due to an engineer logging onto a server instance in order to perform manual troubleshooting or reconfiguration." + ], + "from": "now-45m", + "interval": "15m", + "license": "Elastic License", + "machine_learning_job_id": "linux_network_configuration_discovery", + "name": "Unusual Linux System Network Configuration Discovery", + "risk_score": 21, + "rule_id": "f9590f47-6bd5-4a49-bd49-a2f886476fb9", + "severity": "low", + "tags": [ + "Elastic", + "Linux", + "ML" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007/" + }, + "technique": [ + { + "id": "T1016", + "name": "System Network Configuration Discovery", + "reference": "https://attack.mitre.org/techniques/T1016/" + } + ] + } + ], + "type": "machine_learning", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_system_network_connection_discovery.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_system_network_connection_discovery.json new file mode 100644 index 0000000000000..881a2f9fa3410 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_system_network_connection_discovery.json @@ -0,0 +1,42 @@ +{ + "anomaly_threshold": 25, + "author": [ + "Elastic" + ], + "description": "Looks for commands related to system network connection discovery from an unusual user context. This can be due to uncommon troubleshooting activity or due to a compromised account. A compromised account may be used by a threat actor to engage in system network connection discovery in order to increase their understanding of connected services and systems. This information may be used to shape follow-up behaviors such as lateral movement or additional discovery.", + "false_positives": [ + "Uncommon user command activity can be due to an engineer logging onto a server instance in order to perform manual troubleshooting or reconfiguration." + ], + "from": "now-45m", + "interval": "15m", + "license": "Elastic License", + "machine_learning_job_id": "linux_network_connection_discovery", + "name": "Unusual Linux Network Connection Discovery", + "risk_score": 21, + "rule_id": "c28c4d8c-f014-40ef-88b6-79a1d67cd499", + "severity": "low", + "tags": [ + "Elastic", + "Linux", + "ML" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007/" + }, + "technique": [ + { + "id": "T1049", + "name": "System Network Connections Discovery", + "reference": "https://attack.mitre.org/techniques/T1049/" + } + ] + } + ], + "type": "machine_learning", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_system_process_discovery.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_system_process_discovery.json new file mode 100644 index 0000000000000..66859e2f9ccbf --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_system_process_discovery.json @@ -0,0 +1,42 @@ +{ + "anomaly_threshold": 50, + "author": [ + "Elastic" + ], + "description": "Looks for commands related to system process discovery from an unusual user context. This can be due to uncommon troubleshooting activity or due to a compromised account. A compromised account may be used by a threat actor to engage in system process discovery in order to increase their understanding of software applications running on a target host or network. This may be a precursor to selection of a persistence mechanism or a method of privilege elevation.", + "false_positives": [ + "Uncommon user command activity can be due to an engineer logging onto a server instance in order to perform manual troubleshooting or reconfiguration." + ], + "from": "now-45m", + "interval": "15m", + "license": "Elastic License", + "machine_learning_job_id": "linux_system_process_discovery", + "name": "Unusual Linux Process Discovery Activity", + "risk_score": 21, + "rule_id": "5c983105-4681-46c3-9890-0c66d05e776b", + "severity": "low", + "tags": [ + "Elastic", + "Linux", + "ML" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007/" + }, + "technique": [ + { + "id": "T1057", + "name": "Process Discovery", + "reference": "https://attack.mitre.org/techniques/T1057/" + } + ] + } + ], + "type": "machine_learning", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_system_user_discovery.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_system_user_discovery.json new file mode 100644 index 0000000000000..4437334b0aa1f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_system_user_discovery.json @@ -0,0 +1,42 @@ +{ + "anomaly_threshold": 75, + "author": [ + "Elastic" + ], + "description": "Looks for commands related to system user or owner discovery from an unusual user context. This can be due to uncommon troubleshooting activity or due to a compromised account. A compromised account may be used to engage in system owner or user discovery in order to identify currently active or primary users of a system. This may be a precursor to additional discovery, credential dumping or privilege elevation activity.", + "false_positives": [ + "Uncommon user command activity can be due to an engineer logging onto a server instance in order to perform manual troubleshooting or reconfiguration." + ], + "from": "now-45m", + "interval": "15m", + "license": "Elastic License", + "machine_learning_job_id": "linux_system_user_discovery", + "name": "Unusual Linux System Owner or User Discovery Activity", + "risk_score": 21, + "rule_id": "59756272-1998-4b8c-be14-e287035c4d10", + "severity": "low", + "tags": [ + "Elastic", + "Linux", + "ML" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007/" + }, + "technique": [ + { + "id": "T1033", + "name": "System Owner/User Discovery", + "reference": "https://attack.mitre.org/techniques/T1033/" + } + ] + } + ], + "type": "machine_learning", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_metadata_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_metadata_process.json new file mode 100644 index 0000000000000..56874ec371b43 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_metadata_process.json @@ -0,0 +1,25 @@ +{ + "anomaly_threshold": 50, + "author": [ + "Elastic" + ], + "description": "Looks for anomalous access to the metadata service by an unusual process. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", + "false_positives": [ + "A newly installed program or one that runs very rarely as part of a monthly or quarterly workflow could trigger this detection rule." + ], + "from": "now-45m", + "interval": "15m", + "license": "Elastic License", + "machine_learning_job_id": "windows_rare_metadata_process", + "name": "Unusual Windows Process Calling the Metadata Service", + "risk_score": 21, + "rule_id": "abae61a8-c560-4dbd-acca-1e1438bff36b", + "severity": "low", + "tags": [ + "Elastic", + "ML", + "Windows" + ], + "type": "machine_learning", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_metadata_user.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_metadata_user.json new file mode 100644 index 0000000000000..f124cda7717c3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_metadata_user.json @@ -0,0 +1,25 @@ +{ + "anomaly_threshold": 75, + "author": [ + "Elastic" + ], + "description": "Looks for anomalous access to the cloud platform metadata service by an unusual user. The metadata service may be targeted in order to harvest credentials or user data scripts containing secrets.", + "false_positives": [ + "A newly installed program, or one that runs under a new or rarely used user context, could trigger this detection rule. Manual interrogation of the metadata service during debugging or troubleshooting could trigger this rule." + ], + "from": "now-45m", + "interval": "15m", + "license": "Elastic License", + "machine_learning_job_id": "windows_rare_metadata_user", + "name": "Unusual Windows User Calling the Metadata Service", + "risk_score": 21, + "rule_id": "df197323-72a8-46a9-a08e-3f5b04a4a97a", + "severity": "low", + "tags": [ + "Elastic", + "ML", + "Windows" + ], + "type": "machine_learning", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_mfa_rule.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_mfa_rule.json index b4c2d6522fb01..c503d2298adad 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_mfa_rule.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_mfa_rule.json @@ -7,13 +7,14 @@ "Consider adding exceptions to this rule to filter false positives if Okta MFA rules are regularly deactivated in your organization." ], "index": [ - "filebeat-*" + "filebeat-*", + "logs-okta*" ], "language": "kuery", "license": "Elastic License", "name": "Attempt to Deactivate Okta MFA Rule", "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.module:okta and event.dataset:okta.system and event.action:policy.rule.deactivate", + "query": "event.dataset:okta.system and event.action:policy.rule.deactivate", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" @@ -29,5 +30,5 @@ "Continuous Monitoring" ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy.json index f64db94fbc7b2..d095d7c1166de 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy.json @@ -7,13 +7,14 @@ "Consider adding exceptions to this rule to filter false positives if Okta policies are regularly deleted in your organization." ], "index": [ - "filebeat-*" + "filebeat-*", + "logs-okta*" ], "language": "kuery", "license": "Elastic License", "name": "Attempt to Delete Okta Policy", "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.module:okta and event.dataset:okta.system and event.action:policy.lifecycle.delete", + "query": "event.dataset:okta.system and event.action:policy.lifecycle.delete", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" @@ -29,5 +30,5 @@ "Continuous Monitoring" ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_mfa_rule.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_mfa_rule.json index 30e52eed86110..2fe27575b7b2a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_mfa_rule.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_mfa_rule.json @@ -7,13 +7,14 @@ "Consider adding exceptions to this rule to filter false positives if Okta MFA rules are regularly modified in your organization." ], "index": [ - "filebeat-*" + "filebeat-*", + "logs-okta*" ], "language": "kuery", "license": "Elastic License", "name": "Attempt to Modify Okta MFA Rule", "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.module:okta and event.dataset:okta.system and event.action:(policy.rule.update or policy.rule.delete)", + "query": "event.dataset:okta.system and event.action:(policy.rule.update or policy.rule.delete)", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" @@ -29,5 +30,5 @@ "Continuous Monitoring" ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_network_zone.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_network_zone.json index 18a72a331219e..23b3313488847 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_network_zone.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_network_zone.json @@ -7,13 +7,14 @@ "Consider adding exceptions to this rule to filter false positives if Oyour organization's Okta network zones are regularly modified." ], "index": [ - "filebeat-*" + "filebeat-*", + "logs-okta*" ], "language": "kuery", "license": "Elastic License", "name": "Attempt to Modify Okta Network Zone", "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.module:okta and event.dataset:okta.system and event.action:(zone.update or zone.deactivate or zone.delete or network_zone.rule.disabled or zone.remove_blacklist)", + "query": "event.dataset:okta.system and event.action:(zone.update or zone.deactivate or zone.delete or network_zone.rule.disabled or zone.remove_blacklist)", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" @@ -29,5 +30,5 @@ "Continuous Monitoring" ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy.json index b9c6e390effd9..5b19031046b66 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy.json @@ -7,13 +7,14 @@ "Consider adding exceptions to this rule to filter false positives if Okta policies are regularly modified in your organization." ], "index": [ - "filebeat-*" + "filebeat-*", + "logs-okta*" ], "language": "kuery", "license": "Elastic License", "name": "Attempt to Modify Okta Policy", "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.module:okta and event.dataset:okta.system and event.action:policy.lifecycle.update", + "query": "event.dataset:okta.system and event.action:policy.lifecycle.update", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" @@ -29,5 +30,5 @@ "Continuous Monitoring" ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_or_delete_application_sign_on_policy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_or_delete_application_sign_on_policy.json index 786fdd1ac16c0..58ba13e147a38 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_or_delete_application_sign_on_policy.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_or_delete_application_sign_on_policy.json @@ -7,13 +7,14 @@ "Consider adding exceptions to this rule to filter false positives if sign on policies for Okta applications are regularly modified or deleted in your organization." ], "index": [ - "filebeat-*" + "filebeat-*", + "logs-okta*" ], "language": "kuery", "license": "Elastic License", "name": "Modification or Removal of an Okta Application Sign-On Policy", "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.module:okta and event.dataset:okta.system and event.action:(application.policy.sign_on.update or application.policy.sign_on.rule.delete)", + "query": "event.dataset:okta.system and event.action:(application.policy.sign_on.update or application.policy.sign_on.rule.delete)", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" @@ -29,5 +30,5 @@ "Continuous Monitoring" ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_threat_detected_by_okta_threatinsight.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_threat_detected_by_okta_threatinsight.json index 06089272f0e8c..1efcf0474c049 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_threat_detected_by_okta_threatinsight.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_threat_detected_by_okta_threatinsight.json @@ -4,13 +4,14 @@ ], "description": "This rule detects when Okta ThreatInsight identifies a request from a malicious IP address. Investigating requests from IP addresses identified as malicious by Okta ThreatInsight can help security teams monitor for and respond to credential based attacks against their organization, such as brute force and password spraying attacks.", "index": [ - "filebeat-*" + "filebeat-*", + "logs-okta*" ], "language": "kuery", "license": "Elastic License", "name": "Threat Detected by Okta ThreatInsight", "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.module:okta and event.dataset:okta.system and event.action:security.threat.detected", + "query": "event.dataset:okta.system and event.action:security.threat.detected", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" @@ -26,5 +27,5 @@ "Continuous Monitoring" ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_privileges_assigned_to_okta_group.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_privileges_assigned_to_okta_group.json index a9e6d2feef813..87d5bf3e0f48c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_privileges_assigned_to_okta_group.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_privileges_assigned_to_okta_group.json @@ -7,13 +7,14 @@ "Consider adding exceptions to this rule to filter false positives if administrator privileges are regularly assigned to Okta groups in your organization." ], "index": [ - "filebeat-*" + "filebeat-*", + "logs-okta*" ], "language": "kuery", "license": "Elastic License", "name": "Administrator Privileges Assigned to Okta Group", "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.module:okta and event.dataset:okta.system and event.action:group.privilege.grant", + "query": "event.dataset:okta.system and event.action:group.privilege.grant", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" @@ -46,5 +47,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_app_compat_shim.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_app_compat_shim.json new file mode 100644 index 0000000000000..e9e2e044ddc04 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_app_compat_shim.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the installation of custom Application Compatibility Shim databases. This Windows functionality has been abused by attackers to stealthily gain persistence and arbitrary code execution in legitimate Windows processes.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Installation of Custom Shim Databases", + "query": "/* dependent on wildcard for registry.value */\n\nsequence by process.entity_id with maxspan=5m\n [process where event.type in (\"start\", \"process_started\") and\n not (process.name == \"sdbinst.exe\" and process.parent.name == \"msiexec.exe\")]\n [registry where event.type in (\"creation\", \"change\") and\n wildcard(registry.path, \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\AppCompatFlags\\\\Custom\\\\*.sdb\")]\n", + "risk_score": 21, + "rule_id": "c5ce48a6-7f57-4ee8-9313-3d0024caee10", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1138", + "name": "Application Shimming", + "reference": "https://attack.mitre.org/techniques/T1138/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_create_okta_api_token.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_create_okta_api_token.json index 49b9a7501a3aa..c1d7d51f1401e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_create_okta_api_token.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_create_okta_api_token.json @@ -7,13 +7,14 @@ "If the behavior of creating Okta API tokens is expected, consider adding exceptions to this rule to filter false positives." ], "index": [ - "filebeat-*" + "filebeat-*", + "logs-okta*" ], "language": "kuery", "license": "Elastic License", "name": "Attempt to Create Okta API Token", "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.module:okta and event.dataset:okta.system and event.action:system.api_token.create", + "query": "event.dataset:okta.system and event.action:system.api_token.create", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" @@ -46,5 +47,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_mfa_for_okta_user_account.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_mfa_for_okta_user_account.json index f289e8341a0d0..9cd9572400a6c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_mfa_for_okta_user_account.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_mfa_for_okta_user_account.json @@ -7,13 +7,14 @@ "If the behavior of deactivating MFA for Okta user accounts is expected, consider adding exceptions to this rule to filter false positives." ], "index": [ - "filebeat-*" + "filebeat-*", + "logs-okta*" ], "language": "kuery", "license": "Elastic License", "name": "Attempt to Deactivate MFA for Okta User Account", "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.module:okta and event.dataset:okta.system and event.action:user.mfa.factor.deactivate", + "query": "event.dataset:okta.system and event.action:user.mfa.factor.deactivate", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" @@ -46,5 +47,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_okta_policy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_okta_policy.json index 9393f7e4ef515..d5c9e505659f6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_okta_policy.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_okta_policy.json @@ -7,13 +7,14 @@ "If the behavior of deactivating Okta policies is expected, consider adding exceptions to this rule to filter false positives." ], "index": [ - "filebeat-*" + "filebeat-*", + "logs-okta*" ], "language": "kuery", "license": "Elastic License", "name": "Attempt to Deactivate Okta Policy", "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.module:okta and event.dataset:okta.system and event.action:policy.lifecycle.deactivate", + "query": "event.dataset:okta.system and event.action:policy.lifecycle.deactivate", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" @@ -46,5 +47,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json index 09aeed65f1ef0..302618773e323 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json @@ -7,13 +7,14 @@ "Consider adding exceptions to this rule to filter false positives if the MFA factors for Okta user accounts are regularly reset in your organization." ], "index": [ - "filebeat-*" + "filebeat-*", + "logs-okta*" ], "language": "kuery", "license": "Elastic License", "name": "Attempt to Reset MFA Factors for Okta User Account", "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.module:okta and event.dataset:okta.system and event.action:user.mfa.factor.reset_all", + "query": "event.dataset:okta.system and event.action:user.mfa.factor.reset_all", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" @@ -46,5 +47,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_azure_automation_account_created.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_azure_automation_account_created.json new file mode 100644 index 0000000000000..645c025ec4738 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_azure_automation_account_created.json @@ -0,0 +1,65 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when an Azure Automation account is created. Azure Automation accounts can be used to automate management tasks and orchestrate actions across systems. An adversary may create an Automation account in order to maintain persistence in their target's environment.", + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Azure Automation Account Created", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.activitylogs and azure.activitylogs.operation_name:MICROSOFT.AUTOMATION/AUTOMATIONACCOUNTS/WRITE and event.outcome:Success", + "references": [ + "https://powerzure.readthedocs.io/en/latest/Functions/operational.html#create-backdoor", + "https://github.com/hausec/PowerZure", + "https://posts.specterops.io/attacking-azure-azure-ad-and-introducing-powerzure-ca70b330511a", + "https://azure.microsoft.com/en-in/blog/azure-automation-runbook-management/" + ], + "risk_score": 21, + "rule_id": "df26fd74-1baa-4479-b42e-48da84642330", + "severity": "low", + "tags": [ + "Azure", + "Continuous Monitoring", + "Elastic", + "Identity and Access", + "SecOps" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_azure_automation_runbook_created_or_modified.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_azure_automation_runbook_created_or_modified.json new file mode 100644 index 0000000000000..e96700e409090 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_azure_automation_runbook_created_or_modified.json @@ -0,0 +1,33 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when an Azure Automation runbook is created or modified. An adversary may create or modify an Azure Automation runbook to execute malicious code and maintain persistence in their target's environment.", + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Azure Automation Runbook Created or Modified", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.activitylogs and azure.activitylogs.operation_name:(MICROSOFT.AUTOMATION/AUTOMATIONACCOUNTS/RUNBOOKS/DRAFT/WRITE or MICROSOFT.AUTOMATION/AUTOMATIONACCOUNTS/RUNBOOKS/WRITE or MICROSOFT.AUTOMATION/AUTOMATIONACCOUNTS/RUNBOOKS/PUBLISH/ACTION) and event.outcome:Success", + "references": [ + "https://powerzure.readthedocs.io/en/latest/Functions/operational.html#create-backdoor", + "https://github.com/hausec/PowerZure", + "https://posts.specterops.io/attacking-azure-azure-ad-and-introducing-powerzure-ca70b330511a", + "https://azure.microsoft.com/en-in/blog/azure-automation-runbook-management/" + ], + "risk_score": 21, + "rule_id": "16280f1e-57e6-4242-aa21-bb4d16f13b2f", + "severity": "low", + "tags": [ + "Elastic", + "Azure", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_azure_automation_webhook_created.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_azure_automation_webhook_created.json new file mode 100644 index 0000000000000..f31fdcc18978e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_azure_automation_webhook_created.json @@ -0,0 +1,34 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when an Azure Automation webhook is created. Azure Automation runbooks can be configured to execute via a webhook. A webhook uses a custom URL passed to Azure Automation along with a data payload specific to the runbook. An adversary may create a webhook in order to trigger a runbook that contains malicious code.", + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Azure Automation Webhook Created", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.activitylogs and azure.activitylogs.operation_name:(MICROSOFT.AUTOMATION/AUTOMATIONACCOUNTS/WEBHOOKS/ACTION or MICROSOFT.AUTOMATION/AUTOMATIONACCOUNTS/WEBHOOKS/WRITE) and event.outcome:Success", + "references": [ + "https://powerzure.readthedocs.io/en/latest/Functions/operational.html#create-backdoor", + "https://github.com/hausec/PowerZure", + "https://posts.specterops.io/attacking-azure-azure-ad-and-introducing-powerzure-ca70b330511a", + "https://www.ciraltos.com/webhooks-and-azure-automation-runbooks/" + ], + "risk_score": 21, + "rule_id": "e9ff9c1c-fe36-4d0d-b3fd-9e0bf4853a62", + "severity": "low", + "tags": [ + "Elastic", + "Azure", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "to": "now-25m", + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_azure_pim_user_added_global_admin.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_azure_pim_user_added_global_admin.json new file mode 100644 index 0000000000000..b8ea2c55dd3f9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_azure_pim_user_added_global_admin.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies an Azure Active Directory (AD) Global Administrator role addition to a Privileged Identity Management (PIM) user account. PIM is a service that enables you to manage, control, and monitor access to important resources in an organization. Users who are assigned to the Global administrator role can read and modify any administrative setting in your Azure AD organization.", + "false_positives": [ + "Global administrator additions may be done by a system or network administrator. Verify whether the username, hostname, and/or resource name should be making changes in your environment. Global administrator additions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Azure Global Administrator Role Addition to PIM User", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.auditlogs and azure.auditlogs.properties.category:RoleManagement and azure.auditlogs.operation_name:(\"Add eligible member to role in PIM completed (permanent)\" or \"Add member to role in PIM completed (timebound)\") and azure.auditlogs.properties.target_resources.*.display_name:\"Global Administrator\" and event.outcome:Success", + "references": [ + "https://docs.microsoft.com/en-us/azure/active-directory/users-groups-roles/directory-assign-admin-roles" + ], + "risk_score": 73, + "rule_id": "ed9ecd27-e3e6-4fd9-8586-7754803f7fc8", + "severity": "high", + "tags": [ + "Elastic", + "Azure", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_ec2_network_acl_creation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_ec2_network_acl_creation.json index 229286d4e234d..1b98b9744cd5b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_ec2_network_acl_creation.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_ec2_network_acl_creation.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -50,5 +51,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_gcp_service_account_created.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_gcp_service_account_created.json new file mode 100644 index 0000000000000..aa9d48459262e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_gcp_service_account_created.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a new service account is created in Google Cloud Platform (GCP). A service account is a special type of account used by an application or a virtual machine (VM) instance, not a person. Applications use service accounts to make authorized API calls, authorized as either the service account itself, or as G Suite or Cloud Identity users through domain-wide delegation. If service accounts are not tracked and managed properly, they can present a security risk. An adversary may create a new service account to use during their operations in order to avoid using a standard user account and attempt to evade detection.", + "false_positives": [ + "Service accounts can be created by system administrators. Verify that the behavior was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "GCP Service Account Creation", + "note": "The GCP Filebeat module must be enabled to use this rule.", + "query": "event.dataset:googlecloud.audit and event.action:google.iam.admin.v*.CreateServiceAccount and event.outcome:success", + "references": [ + "https://cloud.google.com/iam/docs/service-accounts" + ], + "risk_score": 21, + "rule_id": "7ceb2216-47dd-4e64-9433-cddc99727623", + "severity": "low", + "tags": [ + "Elastic", + "GCP", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1136", + "name": "Create Account", + "reference": "https://attack.mitre.org/techniques/T1136/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_iam_group_creation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_iam_group_creation.json index b62384f5bd76a..0addb86b8d031 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_iam_group_creation.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_iam_group_creation.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -48,5 +49,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_local_scheduled_task_commands.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_local_scheduled_task_commands.json index 8b81789f6aa8f..78f035318c614 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_local_scheduled_task_commands.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_local_scheduled_task_commands.json @@ -33,12 +33,12 @@ "technique": [ { "id": "T1053", - "name": "Scheduled Task", + "name": "Scheduled Task/Job", "reference": "https://attack.mitre.org/techniques/T1053/" } ] } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_priv_escalation_via_accessibility_features.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_priv_escalation_via_accessibility_features.json index b96d14881ae3d..5899b58bce4d5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_priv_escalation_via_accessibility_features.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_priv_escalation_via_accessibility_features.json @@ -9,7 +9,7 @@ "language": "kuery", "license": "Elastic License", "name": "Potential Modification of Accessibility Binaries", - "query": "event.code:1 and process.parent.name:winlogon.exe and process.name:(atbroker.exe or displayswitch.exe or magnify.exe or narrator.exe or osk.exe or sethc.exe or utilman.exe)", + "query": "event.category:process and event.type:(start or process_started) and process.parent.name:winlogon.exe and not process.name:(atbroker.exe or displayswitch.exe or magnify.exe or narrator.exe or osk.exe or sethc.exe or utilman.exe)", "risk_score": 21, "rule_id": "7405ddf1-6c8e-41ce-818f-48bea6bcaed8", "severity": "low", @@ -50,5 +50,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_rds_cluster_creation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_rds_cluster_creation.json index 830d2d956125b..eb77c183d90ea 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_rds_cluster_creation.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_rds_cluster_creation.json @@ -8,7 +8,8 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", @@ -65,5 +66,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_user_added_as_owner_for_azure_application.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_user_added_as_owner_for_azure_application.json new file mode 100644 index 0000000000000..8882b87e91291 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_user_added_as_owner_for_azure_application.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a user is added as an owner for an Azure application. An adversary may add a user account as an owner for an Azure application in order to grant additional permissions and modify the application's configuration using another account.", + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "User Added as Owner for Azure Application", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.auditlogs and azure.auditlogs.operation_name:\"Add owner to application\" and event.outcome:Success", + "risk_score": 21, + "rule_id": "774f5e28-7b75-4a58-b94e-41bf060fdd86", + "severity": "low", + "tags": [ + "Elastic", + "Azure", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_user_added_as_owner_for_azure_service_principal.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_user_added_as_owner_for_azure_service_principal.json new file mode 100644 index 0000000000000..f7c0af67692e7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_user_added_as_owner_for_azure_service_principal.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a user is added as an owner for an Azure service principal. The service principal object defines what the application can do in the specific tenant, who can access the application, and what resources the app can access. A service principal object is created when an application is given permission to access resources in a tenant. An adversary may add a user account as an owner for a service principal and use that account in order to define what an application can do in the Azure AD tenant.", + "from": "now-25m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "User Added as Owner for Azure Service Principal", + "note": "The Azure Filebeat module must be enabled to use this rule.", + "query": "event.dataset:azure.auditlogs and azure.auditlogs.operation_name:\"Add owner to service principal\" and event.outcome:Success", + "references": [ + "https://docs.microsoft.com/en-us/azure/active-directory/develop/app-objects-and-service-principals" + ], + "risk_score": 21, + "rule_id": "38e5acdd-5f20-4d99-8fe4-f0a1a592077f", + "severity": "low", + "tags": [ + "Elastic", + "Azure", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_application_shimming.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_application_shimming.json index a5a9676053c2d..f20cc75dfa38b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_application_shimming.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_application_shimming.json @@ -9,7 +9,7 @@ "language": "kuery", "license": "Elastic License", "name": "Potential Application Shimming via Sdbinst", - "query": "event.code:1 and process.name:sdbinst.exe", + "query": "event.category:process and event.type:(start or process_started) and process.name:sdbinst.exe", "risk_score": 21, "rule_id": "fd4a992d-6130-4802-9ff8-829b89ae801f", "severity": "low", @@ -50,5 +50,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_telemetrycontroller_scheduledtask_hijack.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_telemetrycontroller_scheduledtask_hijack.json new file mode 100644 index 0000000000000..b7f4ec5d8a73c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_telemetrycontroller_scheduledtask_hijack.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects the successful hijack of Microsoft Compatibility Appraiser scheduled task to establish persistence with an integrity level of system.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Persistence via TelemetryController Scheduled Task Hijack", + "query": "event.category:process and event.type:(start or process_started) and process.parent.name:(CompatTelRunner.exe or compattelrunner.exe) and not process.name:(conhost.exe or DeviceCensus.exe or devicecensus.exe or CompatTelRunner.exe or compattelrunner.exe or DismHost.exe or dismhost.exe or rundll32.exe)", + "references": [ + "https://www.trustedsec.com/blog/abusing-windows-telemetry-for-persistence/?utm_content=131234033&utm_medium=social&utm_source=twitter&hss_channel=tw-403811306" + ], + "risk_score": 73, + "rule_id": "68921d85-d0dc-48b3-865f-43291ca2c4f2", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1053", + "name": "Scheduled Task/Job", + "reference": "https://attack.mitre.org/techniques/T1053/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_update_orchestrator_service_hijack.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_update_orchestrator_service_hijack.json new file mode 100644 index 0000000000000..e512e92a31560 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_update_orchestrator_service_hijack.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies potential hijacking of the Microsoft Update Orchestrator Service to establish persistence with an integrity level of SYSTEM.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Persistence via Update Orchestrator Service Hijack", + "query": "event.category:process and event.type:(start or process_started) and process.parent.name:svchost.exe and process.parent.args:(UsoSvc or usosvc) and not process.name:(UsoClient.exe or usoclient.exe or MusNotification.exe or musnotification.exe or MusNotificationUx.exe or musnotificationux.exe)", + "references": [ + "https://github.com/irsl/CVE-2020-1313" + ], + "risk_score": 73, + "rule_id": "265db8f5-fc73-4d0d-b434-6483b56372e2", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1050", + "name": "New Service", + "reference": "https://attack.mitre.org/techniques/T1050/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_printspooler_service_suspicious_file.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_printspooler_service_suspicious_file.json new file mode 100644 index 0000000000000..1a76e077a7465 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_printspooler_service_suspicious_file.json @@ -0,0 +1,45 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects attempts to exploit privilege escalation vulnerabilities related to the Print Spooler service. For more information refer to the following CVE's - CVE-2020-1048, CVE-2020-1337 and CVE-2020-1300 and verify that the impacted system is patched.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Suspicious PrintSpooler Service Executable File Creation", + "query": "event.category:file and not event.type:deletion and process.name:spoolsv.exe and file.extension:(exe or dll) and not file.path:(C\\:\\\\Windows\\\\System32\\\\spool\\\\* or C\\:\\\\Windows\\\\Temp\\\\* or C\\:\\\\Users\\\\*)", + "references": [ + "https://voidsec.com/cve-2020-1337-printdemon-is-dead-long-live-printdemon/", + "https://www.thezdi.com/blog/2020/7/8/cve-2020-1300-remote-code-execution-through-microsoft-windows-cab-files" + ], + "risk_score": 74, + "rule_id": "5bb4a95d-5a08-48eb-80db-4c3a63ec78a8", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1068", + "name": "Exploitation for Privilege Escalation", + "reference": "https://attack.mitre.org/techniques/T1068/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_printspooler_suspicious_spl_file.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_printspooler_suspicious_spl_file.json new file mode 100644 index 0000000000000..c5ffe5a9f6a11 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_printspooler_suspicious_spl_file.json @@ -0,0 +1,45 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects attempts to exploit privilege escalation vulnerabilities related to the Print Spooler service including CVE-2020-1048 and CVE-2020-1337. .", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Suspicious PrintSpooler SPL File Created", + "note": "Refer to CVEs, CVE-2020-1048 and CVE-2020-1337 for further information on the vulnerability and exploit. Verify that the relevant system is patched.", + "query": "event.category:file and not event.type:deletion and file.extension:(spl or SPL) and file.path:C\\:\\\\Windows\\\\System32\\\\spool\\\\PRINTERS\\\\* and not process.name:(spoolsv.exe or printfilterpipelinesvc.exe or PrintIsolationHost.exe or splwow64.exe or msiexec.exe or poqexec.exe)", + "references": [ + "https://safebreach.com/Post/How-we-bypassed-CVE-2020-1048-Patch-and-got-CVE-2020-1337" + ], + "risk_score": 74, + "rule_id": "a7ccae7b-9d2c-44b2-a061-98e5946971fa", + "severity": "high", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1068", + "name": "Exploitation for Privilege Escalation", + "reference": "https://attack.mitre.org/techniques/T1068/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_root_login_without_mfa.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_root_login_without_mfa.json index aff6df969d90b..16389d43945f1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_root_login_without_mfa.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_root_login_without_mfa.json @@ -4,24 +4,25 @@ ], "description": "Identifies attempts to login to AWS as the root user without using multi-factor authentication (MFA). Amazon AWS best practices indicate that the root user should be protected by MFA.", "false_positives": [ - "Some organizations allow login with the root user without MFA, however this is not considered best practice by AWS and increases the risk of compromised credentials." + "Some organizations allow login with the root user without MFA, however, this is not considered best practice by AWS and increases the risk of compromised credentials." ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", "license": "Elastic License", "name": "AWS Root Login Without MFA", "note": "The AWS Filebeat module must be enabled to use this rule.", - "query": "event.module:aws and event.dataset:aws.cloudtrail and event.provider:signin.amazonaws.com and event.action:ConsoleLogin and aws.cloudtrail.user_identity.type:Root and aws.cloudtrail.console_login.additional_eventdata.mfa_used:false and event.outcome:success", + "query": "event.dataset:aws.cloudtrail and event.provider:signin.amazonaws.com and event.action:ConsoleLogin and aws.cloudtrail.user_identity.type:Root and aws.cloudtrail.console_login.additional_eventdata.mfa_used:false and event.outcome:success", "references": [ "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-user.html" ], - "risk_score": 21, + "risk_score": 73, "rule_id": "bc0c6f0d-dab0-47a3-b135-0925f0a333bc", - "severity": "low", + "severity": "high", "tags": [ "AWS", "Elastic", @@ -47,5 +48,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setgid_bit_set_via_chmod.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setgid_bit_set_via_chmod.json index bb0856c0452d5..e72e58132adee 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setgid_bit_set_via_chmod.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setgid_bit_set_via_chmod.json @@ -39,9 +39,9 @@ { "framework": "MITRE ATT&CK", "tactic": { - "id": "TA0004", + "id": "TA0003", "name": "Persistence", - "reference": "https://attack.mitre.org/tactics/TA0004/" + "reference": "https://attack.mitre.org/tactics/TA0003/" }, "technique": [ { @@ -53,5 +53,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setuid_bit_set_via_chmod.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setuid_bit_set_via_chmod.json index 4cf60d2c9d0de..5e560097d2545 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setuid_bit_set_via_chmod.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setuid_bit_set_via_chmod.json @@ -39,9 +39,9 @@ { "framework": "MITRE ATT&CK", "tactic": { - "id": "TA0004", + "id": "TA0003", "name": "Persistence", - "reference": "https://attack.mitre.org/tactics/TA0004/" + "reference": "https://attack.mitre.org/tactics/TA0003/" }, "technique": [ { @@ -53,5 +53,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_diskcleanup_hijack.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_diskcleanup_hijack.json new file mode 100644 index 0000000000000..b22457db49e49 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_diskcleanup_hijack.json @@ -0,0 +1,41 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies User Account Control (UAC) bypass via hijacking DiskCleanup Scheduled Task. Attackers bypass UAC to stealthily execute code with elevated permissions.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "UAC Bypass via DiskCleanup Scheduled Task Hijack", + "query": "event.category:process and event.type:(start or process_started) and process.args:(/autoclean or /AUTOCLEAN) and process.parent.name:svchost.exe and not process.executable:(\"C:\\Windows\\System32\\cleanmgr.exe\" or \"C:\\Windows\\SysWOW64\\cleanmgr.exe\")", + "risk_score": 47, + "rule_id": "1dcc51f6-ba26-49e7-9ef4-2655abb2361e", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1088", + "name": "Bypass User Account Control", + "reference": "https://attack.mitre.org/techniques/T1088/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json index c6c5cbce2c095..0cac8561c7e9c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json @@ -11,7 +11,7 @@ "language": "kuery", "license": "Elastic License", "name": "Unusual Parent-Child Relationship", - "query": "event.category:process and event.type:(start or process_started) and process.parent.executable:* and (process.name:smss.exe and not process.parent.name:(System or smss.exe) or process.name:csrss.exe and not process.parent.name:(smss.exe or svchost.exe) or process.name:wininit.exe and not process.parent.name:smss.exe or process.name:winlogon.exe and not process.parent.name:smss.exe or process.name:lsass.exe and not process.parent.name:wininit.exe or process.name:LogonUI.exe and not process.parent.name:(wininit.exe or winlogon.exe) or process.name:services.exe and not process.parent.name:wininit.exe or process.name:svchost.exe and not process.parent.name:(MsMpEng.exe or services.exe) or process.name:spoolsv.exe and not process.parent.name:services.exe or process.name:taskhost.exe and not process.parent.name:(services.exe or svchost.exe) or process.name:taskhostw.exe and not process.parent.name:(services.exe or svchost.exe) or process.name:userinit.exe and not process.parent.name:(dwm.exe or winlogon.exe))", + "query": "event.category:process and event.type:(start or process_started) and process.parent.executable:* and (process.parent.name:autochk.exe and not process.name:(chkdsk.exe or doskey.exe or WerFault.exe) or process.parent.name:smss.exe and not process.name:(autochk.exe or smss.exe or csrss.exe or wininit.exe or winlogon.exe or WerFault.exe) or process.name:autochk.exe and not process.parent.name:smss.exe or process.name:(fontdrvhost.exe or dwm.exe) and not process.parent.name:(wininit.exe or winlogon.exe) or process.name:(consent.exe or RuntimeBroker.exe or TiWorker.exe) and not process.parent.name:svchost.exe or process.name:wermgr.exe and not process.parent.name:(svchost.exe or TiWorker.exe) or process.name:SearchIndexer.exe and not process.parent.name:services.exe or process.name:SearchProtocolHost.exe and not process.parent.name:(SearchIndexer.exe or dllhost.exe) or process.name:dllhost.exe and not process.parent.name:(services.exe or svchost.exe) or process.name:smss.exe and not process.parent.name:(System or smss.exe) or process.name:csrss.exe and not process.parent.name:(smss.exe or svchost.exe) or process.name:wininit.exe and not process.parent.name:smss.exe or process.name:winlogon.exe and not process.parent.name:smss.exe or process.name:(lsass.exe or LsaIso.exe) and not process.parent.name:wininit.exe or process.name:LogonUI.exe and not process.parent.name:(wininit.exe or winlogon.exe) or process.name:services.exe and not process.parent.name:wininit.exe or process.name:svchost.exe and not process.parent.name:(MsMpEng.exe or services.exe) or process.name:spoolsv.exe and not process.parent.name:services.exe or process.name:taskhost.exe and not process.parent.name:(services.exe or svchost.exe) or process.name:taskhostw.exe and not process.parent.name:(services.exe or svchost.exe) or process.name:userinit.exe and not process.parent.name:(dwm.exe or winlogon.exe))", "risk_score": 47, "rule_id": "35df0dd8-092d-4a83-88c1-5151a804f31b", "severity": "medium", @@ -37,5 +37,5 @@ } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_updateassumerolepolicy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_updateassumerolepolicy.json index e271f855e4424..55947e00170ae 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_updateassumerolepolicy.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_updateassumerolepolicy.json @@ -8,14 +8,15 @@ ], "from": "now-60m", "index": [ - "filebeat-*" + "filebeat-*", + "logs-aws*" ], "interval": "10m", "language": "kuery", "license": "Elastic License", "name": "AWS IAM Assume Role Policy Update", "note": "The AWS Filebeat module must be enabled to use this rule.", - "query": "event.module:aws and event.dataset:aws.cloudtrail and event.provider:iam.amazonaws.com and event.action:UpdateAssumeRolePolicy and event.outcome:success", + "query": "event.dataset:aws.cloudtrail and event.provider:iam.amazonaws.com and event.action:UpdateAssumeRolePolicy and event.outcome:success", "references": [ "https://labs.bishopfox.com/tech-blog/5-privesc-attack-vectors-in-aws" ], @@ -47,5 +48,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts index 23ea3e6213469..83566a6190610 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts @@ -58,7 +58,11 @@ export interface ResponseTemplateTimeline { } export interface Timeline { - getTimeline: (request: FrameworkRequest, timelineId: string) => Promise; + getTimeline: ( + request: FrameworkRequest, + timelineId: string, + timelineType?: TimelineTypeLiteralWithNull + ) => Promise; getAllTimeline: ( request: FrameworkRequest, @@ -95,9 +99,27 @@ export interface Timeline { export const getTimeline = async ( request: FrameworkRequest, - timelineId: string + timelineId: string, + timelineType: TimelineTypeLiteralWithNull = TimelineType.default ): Promise => { - return getSavedTimeline(request, timelineId); + let timelineIdToUse = timelineId; + try { + if (timelineType === TimelineType.template) { + const options = { + type: timelineSavedObjectType, + perPage: 1, + page: 1, + filter: `siem-ui-timeline.attributes.templateTimelineId: ${timelineId}`, + }; + const result = await getAllSavedTimeline(request, options); + if (result.totalCount === 1) { + timelineIdToUse = result.timeline[0].savedObjectId; + } + } + } catch { + // TO DO, we need to bring the logger here + } + return getSavedTimeline(request, timelineIdToUse); }; export const getTimelineByTemplateTimelineId = async ( diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/constants.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/constants.ts index 3f4af1fbcb362..6631484f025c6 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/constants.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/constants.ts @@ -7,6 +7,7 @@ export const TIMELINE_EVENTS_FIELDS = [ '@timestamp', 'signal.status', + 'signal.group.id', 'signal.original_time', 'signal.rule.filters', 'signal.rule.from', @@ -136,6 +137,7 @@ export const TIMELINE_EVENTS_FIELDS = [ 'signal.rule.note', 'signal.rule.threshold', 'signal.rule.exceptions_list', + 'signal.rule.building_block_type', 'suricata.eve.proto', 'suricata.eve.flow_id', 'suricata.eve.alert.signature', diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts index b2ce57d87ae6d..b2e3989f99d4f 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts @@ -11,8 +11,7 @@ import { toArray } from '../../../../helpers/to_array'; export const formatTimelineData = ( dataFields: readonly string[], ecsFields: readonly string[], - hit: EventHit, - fieldMap: Readonly> + hit: EventHit ) => uniq([...ecsFields, ...dataFields]).reduce( (flattenedFields, fieldName) => { @@ -25,14 +24,7 @@ export const formatTimelineData = ( flattenedFields.cursor.value = hit.sort[0]; flattenedFields.cursor.tiebreaker = hit.sort[1]; } - return mergeTimelineFieldsWithHit( - fieldName, - flattenedFields, - fieldMap, - hit, - dataFields, - ecsFields - ); + return mergeTimelineFieldsWithHit(fieldName, flattenedFields, hit, dataFields, ecsFields); }, { node: { ecs: { _id: '' }, data: [], _id: '', _index: '' }, @@ -48,13 +40,12 @@ const specialFields = ['_id', '_index', '_type', '_score']; const mergeTimelineFieldsWithHit = ( fieldName: string, flattenedFields: T, - fieldMap: Readonly>, hit: { _source: {} }, dataFields: readonly string[], ecsFields: readonly string[] ) => { - if (fieldMap[fieldName] != null || dataFields.includes(fieldName)) { - const esField = dataFields.includes(fieldName) ? fieldName : fieldMap[fieldName]; + if (fieldName != null || dataFields.includes(fieldName)) { + const esField = fieldName; if (has(esField, hit._source) || specialFields.includes(esField)) { const objectWithProperty = { node: { diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/index.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/index.ts index 6b28fc2598d41..182197432da81 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/index.ts @@ -14,10 +14,9 @@ import { TimelineEventsAllRequestOptions, TimelineEdges, } from '../../../../../../common/search_strategy/timeline'; -import { inspectStringifyObject, reduceFields } from '../../../../../utils/build_query'; +import { inspectStringifyObject } from '../../../../../utils/build_query'; import { SecuritySolutionTimelineFactory } from '../../types'; import { buildTimelineEventsAllQuery } from './query.events_all.dsl'; -import { eventFieldsMap } from '../../../../../lib/ecs_fields'; import { TIMELINE_EVENTS_FIELDS } from './constants'; import { formatTimelineData } from './helpers'; @@ -27,10 +26,7 @@ export const timelineEventsAll: SecuritySolutionTimelineFactory ): Promise => { const { fieldRequested, ...queryOptions } = cloneDeep(options); - queryOptions.fields = uniq([ - ...fieldRequested, - ...reduceFields(TIMELINE_EVENTS_FIELDS, eventFieldsMap), - ]); - const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; - + queryOptions.fields = uniq([...fieldRequested, ...TIMELINE_EVENTS_FIELDS]); + const { activePage, querySize } = options.pagination; const totalCount = getOr(0, 'hits.total.value', response.rawResponse); const hits = response.rawResponse.hits.hits; - const edges: TimelineEdges[] = hits.splice(cursorStart, querySize - cursorStart).map((hit) => + const edges: TimelineEdges[] = hits.map((hit) => // @ts-expect-error - formatTimelineData(options.fieldRequested, TIMELINE_EVENTS_FIELDS, hit, eventFieldsMap) + formatTimelineData(options.fieldRequested, TIMELINE_EVENTS_FIELDS, hit) ); const inspect = { dsl: [inspectStringifyObject(buildTimelineEventsAllQuery(queryOptions))], }; - const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; - const showMorePagesIndicator = totalCount > fakeTotalCount; return { ...response, @@ -63,8 +53,7 @@ export const timelineEventsAll: SecuritySolutionTimelineFactory d._source); + const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields)); setRowCount(resp.hits.total.value); setTableItems(docs); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 420df7618b934..556866b8b9c30 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9094,9 +9094,6 @@ "xpack.ingestManager.enrollemntAPIKeyList.emptyMessage": "登録トークンが見つかりません。", "xpack.ingestManager.enrollemntAPIKeyList.loadingTokensMessage": "登録トークンを読み込んでいます...", "xpack.ingestManager.enrollmentInstructions.descriptionText": "エージェントのディレクトリから、該当するコマンドを実行し、Elasticエージェントを登録して起動します。再度これらのコマンドを実行すれば、複数のコンピューターでエージェントを設定できます。登録ステップは必ずシステムで管理者権限をもつユーザーとして実行してください。", - "xpack.ingestManager.enrollmentInstructions.linuxDebRpmTitle": "Linux(.debおよび.rpm)", - "xpack.ingestManager.enrollmentInstructions.macLinuxTarInstructions": "エージェントのシステムが再起動する場合は、{command}を実行する必要があります。", - "xpack.ingestManager.enrollmentInstructions.macLinuxTarTitle": "macOS / Linux (.tar.gz)", "xpack.ingestManager.enrollmentInstructions.windowsTitle": "Windows", "xpack.ingestManager.enrollmentTokenDeleteModal.cancelButton": "キャンセル", "xpack.ingestManager.enrollmentTokenDeleteModal.deleteButton": "削除", @@ -10550,7 +10547,6 @@ "xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixTestingHelpText": "データセットをテストするための正規化された混同行列", "xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixTooltip": "マルチクラス混同行列には、分析が実際のクラスで正しくデータポイントを分類した発生数と、別のクラスで誤分類した発生数が含まれます。", "xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixTrainingHelpText": "データセットを学習するための正規化された混同行列", - "xpack.ml.dataframe.analytics.classificationExploration.evaluateJobIdTitle": "分類ジョブID {jobId}の評価", "xpack.ml.dataframe.analytics.classificationExploration.generalizationDocsCount": "{docsCount, plural, one {# doc} other {# docs}}が評価されました", "xpack.ml.dataframe.analytics.classificationExploration.showActions": "アクションを表示", "xpack.ml.dataframe.analytics.classificationExploration.showAllColumns": "すべての列を表示", @@ -10753,13 +10749,11 @@ "xpack.ml.dataframe.analytics.errorCallout.queryParsingErrorTitle": "クエリをパースできません。", "xpack.ml.dataframe.analytics.exploration.colorRangeLegendTitle": "機能影響スコア", "xpack.ml.dataframe.analytics.explorationResults.documentsShownHelpText": "予測があるドキュメントを示す", - "xpack.ml.dataframe.analytics.explorationResults.fieldSelection": "{docFieldsCount, number}件中 showing {selectedFieldsLength, number}件の{docFieldsCount, plural, one {フィールド} other {フィールド}}", "xpack.ml.dataframe.analytics.explorationResults.firstDocumentsShownHelpText": "予測がある最初の{searchSize}のドキュメントを示す", "xpack.ml.dataframe.analytics.indexPatternPromptLinkText": "インデックスパターンを作成します", "xpack.ml.dataframe.analytics.indexPatternPromptMessage": "{destIndex}のインデックス{destIndex}. {linkToIndexPatternManagement}にはインデックスパターンが存在しません。", "xpack.ml.dataframe.analytics.jobCaps.errorTitle": "結果を取得できません。インデックスのフィールドデータの読み込み中にエラーが発生しました。", "xpack.ml.dataframe.analytics.jobConfig.errorTitle": "結果を取得できません。ジョブ構成データの読み込み中にエラーが発生しました。", - "xpack.ml.dataframe.analytics.regressionExploration.evaluateJobIdTitle": "回帰ジョブID {jobId}の評価", "xpack.ml.dataframe.analytics.regressionExploration.generalizationDocsCount": "{docsCount, plural, one {# doc} other {# docs}}が評価されました", "xpack.ml.dataframe.analytics.regressionExploration.generalizationErrorTitle": "一般化エラー", "xpack.ml.dataframe.analytics.regressionExploration.generalizationFilterText": ".学習データをフィルタリングしています。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a17be0f992b65..8b25dfe0a8d49 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9100,9 +9100,6 @@ "xpack.ingestManager.enrollemntAPIKeyList.emptyMessage": "未找到任何注册令牌。", "xpack.ingestManager.enrollemntAPIKeyList.loadingTokensMessage": "正在加载注册令牌......", "xpack.ingestManager.enrollmentInstructions.descriptionText": "从代理的目录,运行相应命令以注册并启动 Elastic 代理。您可以重复使用这些命令在多台机器上设置代理。请务必以具有系统“管理员”权限的用户身份执行注册步骤。", - "xpack.ingestManager.enrollmentInstructions.linuxDebRpmTitle": "Linux(.deb 和 .rpm)", - "xpack.ingestManager.enrollmentInstructions.macLinuxTarInstructions": "如果代理的系统重新启动,您需要运行 {command}。", - "xpack.ingestManager.enrollmentInstructions.macLinuxTarTitle": "macOS/Linux (.tar.gz)", "xpack.ingestManager.enrollmentInstructions.windowsTitle": "Windows", "xpack.ingestManager.enrollmentTokenDeleteModal.cancelButton": "取消", "xpack.ingestManager.enrollmentTokenDeleteModal.deleteButton": "删除", @@ -10556,7 +10553,6 @@ "xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixTestingHelpText": "用于测试数据集的标准化混淆矩阵", "xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixTooltip": "多类混淆矩阵包含分析使用数据点的实际类正确分类数据点的次数以及分析使用其他类错误分类这些数据点的次数", "xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixTrainingHelpText": "用于训练数据集的标准化混淆矩阵", - "xpack.ml.dataframe.analytics.classificationExploration.evaluateJobIdTitle": "分类作业 ID {jobId} 的评估", "xpack.ml.dataframe.analytics.classificationExploration.generalizationDocsCount": "{docsCount, plural, one {# 个文档} other {# 个文档}}已评估", "xpack.ml.dataframe.analytics.classificationExploration.showActions": "显示操作", "xpack.ml.dataframe.analytics.classificationExploration.showAllColumns": "显示所有列", @@ -10759,13 +10755,11 @@ "xpack.ml.dataframe.analytics.errorCallout.queryParsingErrorTitle": "无法解析查询。", "xpack.ml.dataframe.analytics.exploration.colorRangeLegendTitle": "功能影响分数", "xpack.ml.dataframe.analytics.explorationResults.documentsShownHelpText": "正在显示有相关预测存在的文档", - "xpack.ml.dataframe.analytics.explorationResults.fieldSelection": "已选择 {docFieldsCount, number} 个{docFieldsCount, plural, one {字段} other {字段}}中的 {selectedFieldsLength, number} 个", "xpack.ml.dataframe.analytics.explorationResults.firstDocumentsShownHelpText": "正在显示有相关预测存在的前 {searchSize} 个文档", "xpack.ml.dataframe.analytics.indexPatternPromptLinkText": "创建索引模式", "xpack.ml.dataframe.analytics.indexPatternPromptMessage": "不存在索引 {destIndex} 的索引模式。{destIndex} 的{linkToIndexPatternManagement}。", "xpack.ml.dataframe.analytics.jobCaps.errorTitle": "无法提取结果。加载索引的字段数据时发生错误。", "xpack.ml.dataframe.analytics.jobConfig.errorTitle": "无法提取结果。加载作业配置数据时发生错误。", - "xpack.ml.dataframe.analytics.regressionExploration.evaluateJobIdTitle": "回归作业 ID {jobId} 的评估", "xpack.ml.dataframe.analytics.regressionExploration.generalizationDocsCount": "{docsCount, plural, one {# 个文档} other {# 个文档}}已评估", "xpack.ml.dataframe.analytics.regressionExploration.generalizationErrorTitle": "泛化误差", "xpack.ml.dataframe.analytics.regressionExploration.generalizationFilterText": ".筛留训练数据。", diff --git a/x-pack/plugins/triggers_actions_ui/README.md b/x-pack/plugins/triggers_actions_ui/README.md index 5a0a0c3219d7e..aabb9899cb343 100644 --- a/x-pack/plugins/triggers_actions_ui/README.md +++ b/x-pack/plugins/triggers_actions_ui/README.md @@ -291,7 +291,7 @@ function getSomeNewAlertType() { return { ... } as AlertTypeModel; } -triggers_actions_ui.alertTypeRegistry.register(getSomeNewAlertType()); +triggersActionsUi.alertTypeRegistry.register(getSomeNewAlertType()); ``` ## Create and register new alert type UI example @@ -640,7 +640,7 @@ Follow the instructions bellow to embed the Create Alert flyout within any Kiban 1. Add TriggersAndActionsUIPublicPluginSetup to Kibana plugin setup dependencies: ``` -triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; +triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; ``` Then this dependency will be used to embed Create Alert flyout or register new alert/action type. @@ -669,8 +669,8 @@ const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false); { - const { http, triggers_actions_ui, notifications } = useKibana().services; - const actionTypeRegistry = triggers_actions_ui.actionTypeRegistry; + const { http, triggersActionsUi, notifications } = useKibana().services; + const actionTypeRegistry = triggersActionsUi.actionTypeRegistry; const initialAlert = ({ name: 'test', params: {}, @@ -1393,10 +1393,10 @@ import { TriggersAndActionsUIPublicPluginStart, } from '../../../../../x-pack/plugins/triggers_actions_ui/public'; -triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; +triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; ... -triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; +triggersActionsUi: TriggersAndActionsUIPublicPluginStart; ``` Then this dependency will be used to embed Create Connector flyout or register new action type. @@ -1409,7 +1409,7 @@ import { ActionsConnectorsContextProvider, ConnectorAddFlyout } from '../../../. const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); // load required dependancied -const { http, triggers_actions_ui, notifications, application, docLinks } = useKibana().services; +const { http, triggersActionsUi, notifications, application, docLinks } = useKibana().services; const connector = { secrets: {}, @@ -1439,7 +1439,7 @@ const connector = { value={{ http: http, toastNotifications: notifications.toasts, - actionTypeRegistry: triggers_actions_ui.actionTypeRegistry, + actionTypeRegistry: triggersActionsUi.actionTypeRegistry, capabilities: application.capabilities, docLinks, }} @@ -1510,10 +1510,10 @@ import { TriggersAndActionsUIPublicPluginStart, } from '../../../../../x-pack/plugins/triggers_actions_ui/public'; -triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; +triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; ... -triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; +triggersActionsUi: TriggersAndActionsUIPublicPluginStart; ``` Then this dependency will be used to embed Edit Connector flyout. @@ -1526,7 +1526,7 @@ import { ActionsConnectorsContextProvider, ConnectorEditFlyout } from '../../../ const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); // load required dependancied -const { http, triggers_actions_ui, notifications, application } = useKibana().services; +const { http, triggersActionsUi, notifications, application } = useKibana().services; // UI control item for open flyout diff --git a/x-pack/plugins/triggers_actions_ui/config.ts b/x-pack/plugins/triggers_actions_ui/config.ts new file mode 100644 index 0000000000000..07376e1d11f59 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/config.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 { schema, TypeOf } from '@kbn/config-schema'; + +export const configSchema = schema.object({ + enableGeoTrackingThresholdAlert: schema.maybe(schema.boolean({ defaultValue: false })), +}); + +export type ConfigSchema = TypeOf; diff --git a/x-pack/plugins/triggers_actions_ui/kibana.json b/x-pack/plugins/triggers_actions_ui/kibana.json index 03fef90e8ca7f..be7e2332e630f 100644 --- a/x-pack/plugins/triggers_actions_ui/kibana.json +++ b/x-pack/plugins/triggers_actions_ui/kibana.json @@ -1,10 +1,10 @@ { - "id": "triggers_actions_ui", + "id": "triggersActionsUi", "version": "kibana", - "server": false, + "server": true, "ui": true, "optionalPlugins": ["alerts", "alertingBuiltins"], - "requiredPlugins": ["management", "charts", "data"], + "requiredPlugins": ["management", "charts", "data", "kibanaReact"], "configPath": ["xpack", "trigger_actions_ui"], "extraPublicDirs": ["public/common", "public/common/constants"], "requiredBundles": ["alerts", "esUiShared"] diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/extract_action_variable.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/extract_action_variable.ts new file mode 100644 index 0000000000000..bb42951b9435d --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/extract_action_variable.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fromNullable, Option } from 'fp-ts/lib/Option'; +import { ActionVariable } from '../../../types'; + +export function extractActionVariable( + actionVariables: ActionVariable[], + variableName: string +): Option { + return fromNullable(actionVariables?.find((variable) => variable.name === variableName)); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx index a0194ed5c81e4..0990baa30eb15 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx @@ -90,7 +90,7 @@ describe('JiraParamsFields renders', () => { errors={{ title: [] }} editAction={() => {}} index={0} - messageVariables={[]} + messageVariables={[{ name: 'alertId', description: '' }]} docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} toastNotifications={mocks.notifications.toasts} http={mocks.http} @@ -107,6 +107,27 @@ describe('JiraParamsFields renders', () => { expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="labelsComboBox"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="commentsTextArea"]').length > 0).toBeTruthy(); + + // ensure savedObjectIdInput isnt rendered + expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length === 0).toBeTruthy(); + }); + + test('the savedObjectId fields is rendered if we cant find an alertId in the messageVariables', () => { + const wrapper = mountWithIntl( + {}} + index={0} + messageVariables={[]} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + toastNotifications={mocks.notifications.toasts} + http={mocks.http} + actionConnector={connector} + /> + ); + + expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length > 0).toBeTruthy(); }); test('it shows loading when loading issue types', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx index c19d2c4048665..880e39aada444 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx @@ -6,12 +6,20 @@ import React, { Fragment, useEffect, useState, useMemo } from 'react'; import { map } from 'lodash/fp'; -import { EuiFormRow, EuiComboBox, EuiSelectOption, EuiHorizontalRule } from '@elastic/eui'; +import { isSome } from 'fp-ts/lib/Option'; import { i18n } from '@kbn/i18n'; -import { EuiSelect } from '@elastic/eui'; -import { EuiFlexGroup } from '@elastic/eui'; -import { EuiFlexItem } from '@elastic/eui'; -import { EuiSpacer } from '@elastic/eui'; +import { + EuiFormRow, + EuiComboBox, + EuiSelectOption, + EuiHorizontalRule, + EuiSelect, + EuiFormControlLayout, + EuiIconTip, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; import { ActionParamsProps } from '../../../../types'; import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; @@ -20,6 +28,7 @@ import { JiraActionParams } from './types'; import { useGetIssueTypes } from './use_get_issue_types'; import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; import { SearchIssues } from './search_issues'; +import { extractActionVariable } from '../extract_action_variable'; const JiraParamsFields: React.FunctionComponent> = ({ actionParams, @@ -38,6 +47,10 @@ const JiraParamsFields: React.FunctionComponent([]); + const isActionBeingConfiguredByAnAlert = messageVariables + ? isSome(extractActionVariable(messageVariables, 'alertId')) + : false; + useEffect(() => { setFirstLoad(true); }, []); @@ -127,7 +140,7 @@ const JiraParamsFields: React.FunctionComponent variable.name === 'alertId')) { + if (!savedObjectId && isActionBeingConfiguredByAnAlert) { editSubActionProperty('savedObjectId', '{{alertId}}'); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -261,6 +274,45 @@ const JiraParamsFields: React.FunctionComponent + {!isActionBeingConfiguredByAnAlert && ( + + + + + } + > + + + + + + + )} {hasLabels && ( <> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx index 5f03a548bf16e..a04e213e04872 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx @@ -86,7 +86,7 @@ describe('ResilientParamsFields renders', () => { errors={{ title: [] }} editAction={() => {}} index={0} - messageVariables={[]} + messageVariables={[{ name: 'alertId', description: '' }]} docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} toastNotifications={mocks.notifications.toasts} http={mocks.http} @@ -100,6 +100,27 @@ describe('ResilientParamsFields renders', () => { expect(wrapper.find('[data-test-subj="titleInput"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="commentsTextArea"]').length > 0).toBeTruthy(); + + // ensure savedObjectIdInput isnt rendered + expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length === 0).toBeTruthy(); + }); + + test('the savedObjectId fields is rendered if we cant find an alertId in the messageVariables', () => { + const wrapper = mountWithIntl( + {}} + index={0} + messageVariables={[]} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + toastNotifications={mocks.notifications.toasts} + http={mocks.http} + actionConnector={connector} + /> + ); + + expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length > 0).toBeTruthy(); }); test('it shows loading when loading incident types', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx index b150c97506b69..996e83b87f059 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx @@ -13,8 +13,11 @@ import { EuiTitle, EuiComboBoxOptionOption, EuiSelectOption, + EuiFormControlLayout, + EuiIconTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { isSome } from 'fp-ts/lib/Option'; import { ActionParamsProps } from '../../../../types'; import { ResilientActionParams } from './types'; @@ -23,6 +26,7 @@ import { TextFieldWithMessageVariables } from '../../text_field_with_message_var import { useGetIncidentTypes } from './use_get_incident_types'; import { useGetSeverity } from './use_get_severity'; +import { extractActionVariable } from '../extract_action_variable'; const ResilientParamsFields: React.FunctionComponent> = ({ actionParams, @@ -38,6 +42,10 @@ const ResilientParamsFields: React.FunctionComponent> >([]); @@ -98,7 +106,7 @@ const ResilientParamsFields: React.FunctionComponent variable.name === 'alertId')) { + if (!savedObjectId && isActionBeingConfiguredByAnAlert) { editSubActionProperty('savedObjectId', '{{alertId}}'); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -218,6 +226,43 @@ const ResilientParamsFields: React.FunctionComponent + {!isActionBeingConfiguredByAnAlert && ( + + + + } + > + + + + + + )} { errors={{ title: [] }} editAction={() => {}} index={0} - messageVariables={[]} + messageVariables={[{ name: 'alertId', description: '' }]} docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} toastNotifications={mocks.notifications.toasts} http={mocks.http} @@ -46,5 +46,41 @@ describe('ServiceNowParamsFields renders', () => { expect(wrapper.find('[data-test-subj="titleInput"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="commentTextArea"]').length > 0).toBeTruthy(); + + // ensure savedObjectIdInput isnt rendered + expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length === 0).toBeTruthy(); + }); + + test('the savedObjectId fields is rendered if we cant find an alertId in the messageVariables', () => { + const mocks = coreMock.createSetup(); + const actionParams = { + subAction: 'pushToService', + subActionParams: { + title: 'sn title', + description: 'some description', + comment: 'comment for sn', + severity: '1', + urgency: '2', + impact: '3', + savedObjectId: '123', + externalId: null, + }, + }; + + const wrapper = mountWithIntl( + {}} + index={0} + messageVariables={[]} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + toastNotifications={mocks.notifications.toasts} + http={mocks.http} + /> + ); + + // ensure savedObjectIdInput isnt rendered + expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length > 0).toBeTruthy(); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx index 2a2efdfbe35b1..3e59f2199153b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx @@ -5,23 +5,34 @@ */ import React, { Fragment, useEffect } from 'react'; -import { EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { EuiSelect } from '@elastic/eui'; -import { EuiFlexGroup } from '@elastic/eui'; -import { EuiFlexItem } from '@elastic/eui'; -import { EuiSpacer } from '@elastic/eui'; -import { EuiTitle } from '@elastic/eui'; +import { + EuiFormRow, + EuiSelect, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, + EuiFormControlLayout, + EuiIconTip, +} from '@elastic/eui'; +import { isSome } from 'fp-ts/lib/Option'; import { ActionParamsProps } from '../../../../types'; import { ServiceNowActionParams } from './types'; import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; +import { extractActionVariable } from '../extract_action_variable'; const ServiceNowParamsFields: React.FunctionComponent> = ({ actionParams, editAction, index, errors, messageVariables }) => { const { title, description, comment, severity, urgency, impact, savedObjectId } = actionParams.subActionParams || {}; + + const isActionBeingConfiguredByAnAlert = messageVariables + ? isSome(extractActionVariable(messageVariables, 'alertId')) + : false; + const selectOptions = [ { value: '1', @@ -61,7 +72,7 @@ const ServiceNowParamsFields: React.FunctionComponent variable.name === 'alertId')) { + if (!savedObjectId && isActionBeingConfiguredByAnAlert) { editSubActionProperty('savedObjectId', '{{alertId}}'); } if (!urgency) { @@ -174,6 +185,43 @@ const ServiceNowParamsFields: React.FunctionComponent + {!isActionBeingConfiguredByAnAlert && ( + + + + } + > + + + + + + )} { + return { + id: '.geo-threshold', + name: i18n.translate('xpack.triggersActionsUI.geoThreshold.name.trackingThreshold', { + defaultMessage: 'Tracking threshold', + }), + iconClass: 'globe', + alertParamsExpression: lazy(() => import('./query_builder')), + validate: validateExpression, + requiresAppContext: false, + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/expressions/boundary_index_expression.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/expressions/boundary_index_expression.tsx new file mode 100644 index 0000000000000..497e053a4ed60 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/expressions/boundary_index_expression.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FunctionComponent, useEffect, useRef } from 'react'; +import { EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { IErrorObject } from '../../../../../../types'; +import { ES_GEO_SHAPE_TYPES, GeoThresholdAlertParams } from '../../types'; +import { AlertsContextValue } from '../../../../../context/alerts_context'; +import { GeoIndexPatternSelect } from '../util_components/geo_index_pattern_select'; +import { SingleFieldSelect } from '../util_components/single_field_select'; +import { ExpressionWithPopover } from '../util_components/expression_with_popover'; +import { IFieldType } from '../../../../../../../../../../src/plugins/data/common/index_patterns/fields'; +import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/common/index_patterns'; + +interface Props { + alertParams: GeoThresholdAlertParams; + alertsContext: AlertsContextValue; + errors: IErrorObject; + boundaryIndexPattern: IIndexPattern; + boundaryNameField?: string; + setBoundaryIndexPattern: (boundaryIndexPattern?: IIndexPattern) => void; + setBoundaryGeoField: (boundaryGeoField?: string) => void; + setBoundaryNameField: (boundaryNameField?: string) => void; +} + +export const BoundaryIndexExpression: FunctionComponent = ({ + alertParams, + alertsContext, + errors, + boundaryIndexPattern, + boundaryNameField, + setBoundaryIndexPattern, + setBoundaryGeoField, + setBoundaryNameField, +}) => { + const BOUNDARY_NAME_ENTITY_TYPES = ['string', 'number', 'ip']; + const { dataUi, dataIndexPatterns, http } = alertsContext; + const IndexPatternSelect = (dataUi && dataUi.IndexPatternSelect) || null; + const { boundaryGeoField } = alertParams; + const nothingSelected: IFieldType = { + name: '', + type: 'string', + }; + + const usePrevious = (value: T): T | undefined => { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; + }; + + const oldIndexPattern = usePrevious(boundaryIndexPattern); + const fields = useRef<{ + geoFields: IFieldType[]; + boundaryNameFields: IFieldType[]; + }>({ + geoFields: [], + boundaryNameFields: [], + }); + useEffect(() => { + if (oldIndexPattern !== boundaryIndexPattern) { + fields.current.geoFields = + (boundaryIndexPattern.fields.length && + boundaryIndexPattern.fields.filter((field: IFieldType) => + ES_GEO_SHAPE_TYPES.includes(field.type) + )) || + []; + if (fields.current.geoFields.length) { + setBoundaryGeoField(fields.current.geoFields[0].name); + } + + fields.current.boundaryNameFields = [ + ...boundaryIndexPattern.fields.filter((field: IFieldType) => { + return ( + BOUNDARY_NAME_ENTITY_TYPES.includes(field.type) && + !field.name.startsWith('_') && + !field.name.endsWith('keyword') + ); + }), + nothingSelected, + ]; + if (fields.current.boundaryNameFields.length) { + setBoundaryNameField(fields.current.boundaryNameFields[0].name); + } + } + }, [ + BOUNDARY_NAME_ENTITY_TYPES, + boundaryIndexPattern, + nothingSelected, + oldIndexPattern, + setBoundaryGeoField, + setBoundaryNameField, + ]); + + const indexPopover = ( + + + { + if (!_indexPattern) { + return; + } + setBoundaryIndexPattern(_indexPattern); + }} + value={boundaryIndexPattern.id} + IndexPatternSelectComponent={IndexPatternSelect} + indexPatternService={dataIndexPatterns} + http={http} + includedGeoTypes={ES_GEO_SHAPE_TYPES} + /> + + + + + + { + setBoundaryNameField(name === nothingSelected.name ? undefined : name); + }} + fields={fields.current.boundaryNameFields} + /> + + + ); + + return ( + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/expressions/entity_by_expression.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/expressions/entity_by_expression.tsx new file mode 100644 index 0000000000000..f355d25796b7c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/expressions/entity_by_expression.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent, useEffect, useRef } from 'react'; +import { EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import _ from 'lodash'; +import { IErrorObject } from '../../../../../../types'; +import { SingleFieldSelect } from '../util_components/single_field_select'; +import { ExpressionWithPopover } from '../util_components/expression_with_popover'; +import { IFieldType } from '../../../../../../../../../../src/plugins/data/common/index_patterns/fields'; + +interface Props { + errors: IErrorObject; + entity: string; + setAlertParamsEntity: (entity: string) => void; + indexFields: IFieldType[]; + isInvalid: boolean; +} + +export const EntityByExpression: FunctionComponent = ({ + errors, + entity, + setAlertParamsEntity, + indexFields, + isInvalid, +}) => { + const ENTITY_TYPES = ['string', 'number', 'ip']; + + const usePrevious = (value: T): T | undefined => { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; + }; + + const oldIndexFields = usePrevious(indexFields); + const fields = useRef<{ + indexFields: IFieldType[]; + }>({ + indexFields: [], + }); + useEffect(() => { + if (!_.isEqual(oldIndexFields, indexFields)) { + fields.current.indexFields = indexFields.filter( + (field: IFieldType) => ENTITY_TYPES.includes(field.type) && !field.name.startsWith('_') + ); + if (!entity && fields.current.indexFields.length) { + setAlertParamsEntity(fields.current.indexFields[0].name); + } + } + }, [ENTITY_TYPES, indexFields, oldIndexFields, setAlertParamsEntity, entity]); + + const indexPopover = ( + + _entity && setAlertParamsEntity(_entity)} + fields={fields.current.indexFields} + /> + + ); + + return ( + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/expressions/entity_index_expression.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/expressions/entity_index_expression.tsx new file mode 100644 index 0000000000000..506530c171cd4 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/expressions/entity_index_expression.tsx @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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, FunctionComponent, useEffect, useRef } from 'react'; +import { EuiFormRow } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { IErrorObject } from '../../../../../../types'; +import { ES_GEO_FIELD_TYPES } from '../../types'; +import { AlertsContextValue } from '../../../../../context/alerts_context'; +import { GeoIndexPatternSelect } from '../util_components/geo_index_pattern_select'; +import { SingleFieldSelect } from '../util_components/single_field_select'; +import { ExpressionWithPopover } from '../util_components/expression_with_popover'; +import { IFieldType } from '../../../../../../../../../../src/plugins/data/common/index_patterns/fields'; +import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/common/index_patterns'; + +interface Props { + dateField: string; + geoField: string; + alertsContext: AlertsContextValue; + errors: IErrorObject; + setAlertParamsDate: (date: string) => void; + setAlertParamsGeoField: (geoField: string) => void; + setAlertProperty: (alertProp: string, alertParams: unknown) => void; + setIndexPattern: (indexPattern: IIndexPattern) => void; + indexPattern: IIndexPattern; + isInvalid: boolean; +} + +export const EntityIndexExpression: FunctionComponent = ({ + setAlertParamsDate, + setAlertParamsGeoField, + errors, + alertsContext, + setIndexPattern, + indexPattern, + isInvalid, + dateField: timeField, + geoField, +}) => { + const { dataUi, dataIndexPatterns, http } = alertsContext; + const IndexPatternSelect = (dataUi && dataUi.IndexPatternSelect) || null; + + const usePrevious = (value: T): T | undefined => { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; + }; + + const oldIndexPattern = usePrevious(indexPattern); + const fields = useRef<{ + dateFields: IFieldType[]; + geoFields: IFieldType[]; + }>({ + dateFields: [], + geoFields: [], + }); + useEffect(() => { + if (oldIndexPattern !== indexPattern) { + fields.current.geoFields = + (indexPattern.fields.length && + indexPattern.fields.filter((field: IFieldType) => + ES_GEO_FIELD_TYPES.includes(field.type) + )) || + []; + if (fields.current.geoFields.length) { + setAlertParamsGeoField(fields.current.geoFields[0].name); + } + + fields.current.dateFields = + (indexPattern.fields.length && + indexPattern.fields.filter((field: IFieldType) => field.type === 'date')) || + []; + if (fields.current.dateFields.length) { + setAlertParamsDate(fields.current.dateFields[0].name); + } + } + }, [indexPattern, oldIndexPattern, setAlertParamsDate, setAlertParamsGeoField]); + + const indexPopover = ( + + + { + // reset time field and expression fields if indices are deleted + if (!_indexPattern) { + return; + } + setIndexPattern(_indexPattern); + }} + value={indexPattern.id} + IndexPatternSelectComponent={IndexPatternSelect} + indexPatternService={dataIndexPatterns} + http={http} + includedGeoTypes={ES_GEO_FIELD_TYPES} + /> + + + } + > + + _timeField && setAlertParamsDate(_timeField) + } + fields={fields.current.dateFields} + /> + + + + _geoField && setAlertParamsGeoField(_geoField) + } + fields={fields.current.geoFields} + /> + + + ); + + return ( + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/index.tsx new file mode 100644 index 0000000000000..b33d5e16c6eb9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/index.tsx @@ -0,0 +1,322 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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, useEffect, useState } from 'react'; +import { + EuiCallOut, + EuiFieldNumber, + EuiFlexGrid, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIconTip, + EuiSelect, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { AlertTypeParamsExpressionProps } from '../../../../../types'; +import { GeoThresholdAlertParams, TrackingEvent } from '../types'; +import { AlertsContextValue } from '../../../../context/alerts_context'; +import { ExpressionWithPopover } from './util_components/expression_with_popover'; +import { EntityIndexExpression } from './expressions/entity_index_expression'; +import { EntityByExpression } from './expressions/entity_by_expression'; +import { BoundaryIndexExpression } from './expressions/boundary_index_expression'; +import { IIndexPattern } from '../../../../../../../../../src/plugins/data/common/index_patterns'; +import { getTimeOptions } from '../../../../../common/lib/get_time_options'; + +const DEFAULT_VALUES = { + TRACKING_EVENT: '', + ENTITY: '', + INDEX: '', + INDEX_ID: '', + DATE_FIELD: '', + BOUNDARY_TYPE: 'entireIndex', // Only one supported currently. Will eventually be more + GEO_FIELD: '', + BOUNDARY_INDEX: '', + BOUNDARY_INDEX_ID: '', + BOUNDARY_GEO_FIELD: '', + BOUNDARY_NAME_FIELD: '', + DELAY_OFFSET_WITH_UNITS: '0m', +}; + +const conditionOptions = Object.keys(TrackingEvent).map((key) => ({ + text: (TrackingEvent as any)[key], + value: (TrackingEvent as any)[key], +})); + +const labelForDelayOffset = ( + <> + {' '} + + +); + +export const GeoThresholdAlertTypeExpression: React.FunctionComponent> = ({ alertParams, alertInterval, setAlertParams, setAlertProperty, errors, alertsContext }) => { + const { + index, + indexId, + geoField, + entity, + dateField, + trackingEvent, + boundaryType, + boundaryIndexTitle, + boundaryIndexId, + boundaryGeoField, + boundaryNameField, + delayOffsetWithUnits, + } = alertParams; + + const [indexPattern, _setIndexPattern] = useState({ + id: '', + fields: [], + title: '', + }); + const setIndexPattern = (_indexPattern?: IIndexPattern) => { + if (_indexPattern) { + _setIndexPattern(_indexPattern); + if (_indexPattern.title) { + setAlertParams('index', _indexPattern.title); + } + if (_indexPattern.id) { + setAlertParams('indexId', _indexPattern.id); + } + } + }; + const [boundaryIndexPattern, _setBoundaryIndexPattern] = useState({ + id: '', + fields: [], + title: '', + }); + const setBoundaryIndexPattern = (_indexPattern?: IIndexPattern) => { + if (_indexPattern) { + _setBoundaryIndexPattern(_indexPattern); + if (_indexPattern.title) { + setAlertParams('boundaryIndexTitle', _indexPattern.title); + } + if (_indexPattern.id) { + setAlertParams('boundaryIndexId', _indexPattern.id); + } + } + }; + const [delayOffset, _setDelayOffset] = useState(0); + function setDelayOffset(_delayOffset: number) { + setAlertParams('delayOffsetWithUnits', `${_delayOffset}${delayOffsetUnit}`); + _setDelayOffset(_delayOffset); + } + const [delayOffsetUnit, setDelayOffsetUnit] = useState('m'); + + const hasExpressionErrors = false; + const expressionErrorMessage = i18n.translate( + 'xpack.triggersActionsUI.geoThreshold.fixErrorInExpressionBelowValidationMessage', + { + defaultMessage: 'Expression contains errors.', + } + ); + + useEffect(() => { + const initToDefaultParams = async () => { + setAlertProperty('params', { + ...alertParams, + index: index ?? DEFAULT_VALUES.INDEX, + indexId: indexId ?? DEFAULT_VALUES.INDEX_ID, + entity: entity ?? DEFAULT_VALUES.ENTITY, + dateField: dateField ?? DEFAULT_VALUES.DATE_FIELD, + trackingEvent: trackingEvent ?? DEFAULT_VALUES.TRACKING_EVENT, + boundaryType: boundaryType ?? DEFAULT_VALUES.BOUNDARY_TYPE, + geoField: geoField ?? DEFAULT_VALUES.GEO_FIELD, + boundaryIndexTitle: boundaryIndexTitle ?? DEFAULT_VALUES.BOUNDARY_INDEX, + boundaryIndexId: boundaryIndexId ?? DEFAULT_VALUES.BOUNDARY_INDEX_ID, + boundaryGeoField: boundaryGeoField ?? DEFAULT_VALUES.BOUNDARY_GEO_FIELD, + boundaryNameField: boundaryNameField ?? DEFAULT_VALUES.BOUNDARY_NAME_FIELD, + delayOffsetWithUnits: delayOffsetWithUnits ?? DEFAULT_VALUES.DELAY_OFFSET_WITH_UNITS, + }); + if (!alertsContext.dataIndexPatterns) { + return; + } + if (indexId) { + const _indexPattern = await alertsContext.dataIndexPatterns.get(indexId); + setIndexPattern(_indexPattern); + } + if (boundaryIndexId) { + const _boundaryIndexPattern = await alertsContext.dataIndexPatterns.get(boundaryIndexId); + setBoundaryIndexPattern(_boundaryIndexPattern); + } + if (delayOffsetWithUnits) { + setDelayOffset(+delayOffsetWithUnits.replace(/\D/g, '')); + } + }; + initToDefaultParams(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {hasExpressionErrors ? ( + + + + + + ) : null} + + +
    + +
    +
    + + + + + + + { + setDelayOffset(+e.target.value); + }} + /> + + + { + setDelayOffsetUnit(e.target.value); + }} + /> + + + + + + + +
    + +
    +
    + + setAlertParams('dateField', _date)} + setAlertParamsGeoField={(_geoField) => setAlertParams('geoField', _geoField)} + setAlertProperty={setAlertProperty} + setIndexPattern={setIndexPattern} + indexPattern={indexPattern} + isInvalid={!indexId || !dateField || !geoField} + /> + setAlertParams('entity', entityName)} + indexFields={indexPattern.fields} + isInvalid={indexId && dateField && geoField ? !entity : false} + /> + + + +
    + +
    +
    + + +
    + setAlertParams('trackingEvent', e.target.value)} + options={[conditionOptions[0]]} // TODO: Make all options avab. before merge + /> +
    + + } + expressionDescription={i18n.translate( + 'xpack.triggersActionsUI.geoThreshold.whenEntityLabel', + { + defaultMessage: 'when entity', + } + )} + /> + + + +
    + +
    +
    + + + _geoField && setAlertParams('boundaryGeoField', _geoField) + } + setBoundaryNameField={(_boundaryNameField: string | undefined) => + _boundaryNameField + ? setAlertParams('boundaryNameField', _boundaryNameField) + : setAlertParams('boundaryNameField', '') + } + boundaryNameField={boundaryNameField} + /> +
    + ); +}; + +// eslint-disable-next-line import/no-default-export +export { GeoThresholdAlertTypeExpression as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/util_components/expression_with_popover.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/util_components/expression_with_popover.tsx new file mode 100644 index 0000000000000..a86ecfb4210a5 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/util_components/expression_with_popover.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 React, { useState } from 'react'; +import { + EuiButtonIcon, + EuiExpression, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiPopoverTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export const ExpressionWithPopover: ({ + popoverContent, + expressionDescription, + defaultValue, + value, + isInvalid, +}: { + popoverContent: any; + expressionDescription: any; + defaultValue?: any; + value?: any; + isInvalid?: boolean; +}) => JSX.Element = ({ popoverContent, expressionDescription, defaultValue, value, isInvalid }) => { + const [popoverOpen, setPopoverOpen] = useState(false); + + return ( + setPopoverOpen(true)} + isInvalid={isInvalid} + /> + } + isOpen={popoverOpen} + closePopover={() => setPopoverOpen(false)} + ownFocus + withTitle + anchorPosition="downLeft" + zIndex={8000} + display="block" + > +
    + + + {expressionDescription} + + setPopoverOpen(false)} + /> + + + + {popoverContent} +
    +
    + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/util_components/geo_index_pattern_select.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/util_components/geo_index_pattern_select.tsx new file mode 100644 index 0000000000000..42995dfb1b9d6 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/util_components/geo_index_pattern_select.tsx @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component } from 'react'; +import { EuiCallOut, EuiFormRow, EuiLink, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { IndexPattern, IndexPatternsContract } from 'src/plugins/data/public'; +import { HttpSetup } from 'kibana/public'; + +interface Props { + onChange: (indexPattern: IndexPattern) => void; + value: string | undefined; + IndexPatternSelectComponent: any; + indexPatternService: IndexPatternsContract | undefined; + http: HttpSetup; + includedGeoTypes: string[]; +} + +interface State { + noGeoIndexPatternsExist: boolean; +} + +export class GeoIndexPatternSelect extends Component { + private _isMounted: boolean = false; + + state = { + noGeoIndexPatternsExist: false, + }; + + componentWillUnmount() { + this._isMounted = false; + } + + componentDidMount() { + this._isMounted = true; + } + + _onIndexPatternSelect = async (indexPatternId: any) => { + if (!indexPatternId || indexPatternId.length === 0 || !this.props.indexPatternService) { + return; + } + + let indexPattern; + try { + indexPattern = await this.props.indexPatternService.get(indexPatternId); + } catch (err) { + return; + } + + // method may be called again before 'get' returns + // ignore response when fetched index pattern does not match active index pattern + if (this._isMounted && indexPattern.id === indexPatternId) { + this.props.onChange(indexPattern); + } + }; + + _onNoIndexPatterns = () => { + this.setState({ noGeoIndexPatternsExist: true }); + }; + + _renderNoIndexPatternWarning() { + if (!this.state.noGeoIndexPatternsExist) { + return null; + } + + return ( + <> + +

    + + + + + +

    +

    + + + + +

    +
    + + + ); + } + + render() { + const IndexPatternSelectComponent = this.props.IndexPatternSelectComponent; + return ( + <> + {this._renderNoIndexPatternWarning()} + + + {IndexPatternSelectComponent ? ( + + ) : ( +
    + )} + + + ); + } +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/util_components/single_field_select.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/util_components/single_field_select.tsx new file mode 100644 index 0000000000000..30389b31ce8c8 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/query_builder/util_components/single_field_select.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React from 'react'; +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiHighlight, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { IFieldType } from 'src/plugins/data/public'; +import { FieldIcon } from '../../../../../../../../../../src/plugins/kibana_react/public'; + +function fieldsToOptions(fields?: IFieldType[]): Array> { + if (!fields) { + return []; + } + + return fields + .map((field) => ({ + value: field, + label: field.name, + })) + .sort((a, b) => { + return a.label.toLowerCase().localeCompare(b.label.toLowerCase()); + }); +} + +interface Props { + placeholder: string; + value: string | null; // index pattern field name + onChange: (fieldName?: string) => void; + fields: IFieldType[]; +} + +export function SingleFieldSelect({ placeholder, value, onChange, fields }: Props) { + function renderOption( + option: EuiComboBoxOptionOption, + searchValue: string, + contentClassName: string + ) { + return ( + + + + + + {option.label} + + + ); + } + + const onSelection = (selectedOptions: Array>) => { + onChange(_.get(selectedOptions, '0.value.name')); + }; + + const selectedOptions: Array> = []; + if (value && fields) { + const selectedField = fields.find((field: IFieldType) => field.name === value); + if (selectedField) { + selectedOptions.push({ value: selectedField, label: value }); + } + } + + return ( + + ); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/types.ts new file mode 100644 index 0000000000000..0358fcd66a467 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/types.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. + */ + +export enum TrackingEvent { + entered = 'entered', + exited = 'exited', +} + +export interface GeoThresholdAlertParams { + index: string; + indexId: string; + geoField: string; + entity: string; + dateField: string; + trackingEvent: string; + boundaryType: string; + boundaryIndexTitle: string; + boundaryIndexId: string; + boundaryGeoField: string; + boundaryNameField?: string; + delayOffsetWithUnits?: string; +} + +// Will eventually include 'geo_shape' +export const ES_GEO_FIELD_TYPES = ['geo_point']; +export const ES_GEO_SHAPE_TYPES = ['geo_shape']; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/validation.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/validation.test.ts new file mode 100644 index 0000000000000..9cc5b1eb069ae --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/validation.test.ts @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GeoThresholdAlertParams } from './types'; +import { validateExpression } from './validation'; + +describe('expression params validation', () => { + test('if index property is invalid should return proper error message', () => { + const initialParams: GeoThresholdAlertParams = { + index: '', + indexId: 'testIndexId', + geoField: 'testField', + entity: 'testField', + dateField: 'testField', + trackingEvent: 'testEvent', + boundaryType: 'testType', + boundaryIndexTitle: 'testIndex', + boundaryIndexId: 'testIndexId', + boundaryGeoField: 'testField', + }; + expect(validateExpression(initialParams).errors.index.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.index[0]).toBe('Index pattern is required.'); + }); + + test('if geoField property is invalid should return proper error message', () => { + const initialParams: GeoThresholdAlertParams = { + index: 'testIndex', + indexId: 'testIndexId', + geoField: '', + entity: 'testField', + dateField: 'testField', + trackingEvent: 'testEvent', + boundaryType: 'testType', + boundaryIndexTitle: 'testIndex', + boundaryIndexId: 'testIndexId', + boundaryGeoField: 'testField', + }; + expect(validateExpression(initialParams).errors.geoField.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.geoField[0]).toBe('Geo field is required.'); + }); + + test('if entity property is invalid should return proper error message', () => { + const initialParams: GeoThresholdAlertParams = { + index: 'testIndex', + indexId: 'testIndexId', + geoField: 'testField', + entity: '', + dateField: 'testField', + trackingEvent: 'testEvent', + boundaryType: 'testType', + boundaryIndexTitle: 'testIndex', + boundaryIndexId: 'testIndexId', + boundaryGeoField: 'testField', + }; + expect(validateExpression(initialParams).errors.entity.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.entity[0]).toBe('Entity is required.'); + }); + + test('if dateField property is invalid should return proper error message', () => { + const initialParams: GeoThresholdAlertParams = { + index: 'testIndex', + indexId: 'testIndexId', + geoField: 'testField', + entity: 'testField', + dateField: '', + trackingEvent: 'testEvent', + boundaryType: 'testType', + boundaryIndexTitle: 'testIndex', + boundaryIndexId: 'testIndexId', + boundaryGeoField: 'testField', + }; + expect(validateExpression(initialParams).errors.dateField.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.dateField[0]).toBe('Date field is required.'); + }); + + test('if trackingEvent property is invalid should return proper error message', () => { + const initialParams: GeoThresholdAlertParams = { + index: 'testIndex', + indexId: 'testIndexId', + geoField: 'testField', + entity: 'testField', + dateField: 'testField', + trackingEvent: '', + boundaryType: 'testType', + boundaryIndexTitle: 'testIndex', + boundaryIndexId: 'testIndexId', + boundaryGeoField: 'testField', + }; + expect(validateExpression(initialParams).errors.trackingEvent.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.trackingEvent[0]).toBe( + 'Tracking event is required.' + ); + }); + + test('if boundaryType property is invalid should return proper error message', () => { + const initialParams: GeoThresholdAlertParams = { + index: 'testIndex', + indexId: 'testIndexId', + geoField: 'testField', + entity: 'testField', + dateField: 'testField', + trackingEvent: 'testEvent', + boundaryType: '', + boundaryIndexTitle: 'testIndex', + boundaryIndexId: 'testIndexId', + boundaryGeoField: 'testField', + }; + expect(validateExpression(initialParams).errors.boundaryType.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.boundaryType[0]).toBe( + 'Boundary type is required.' + ); + }); + + test('if boundaryIndexTitle property is invalid should return proper error message', () => { + const initialParams: GeoThresholdAlertParams = { + index: 'testIndex', + indexId: 'testIndexId', + geoField: 'testField', + entity: 'testField', + dateField: 'testField', + trackingEvent: 'testEvent', + boundaryType: 'testType', + boundaryIndexTitle: '', + boundaryIndexId: 'testIndexId', + boundaryGeoField: 'testField', + }; + expect(validateExpression(initialParams).errors.boundaryIndexTitle.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.boundaryIndexTitle[0]).toBe( + 'Boundary index pattern title is required.' + ); + }); + + test('if boundaryGeoField property is invalid should return proper error message', () => { + const initialParams: GeoThresholdAlertParams = { + index: 'testIndex', + indexId: 'testIndexId', + geoField: 'testField', + entity: 'testField', + dateField: 'testField', + trackingEvent: 'testEvent', + boundaryType: 'testType', + boundaryIndexTitle: 'testIndex', + boundaryIndexId: 'testIndexId', + boundaryGeoField: '', + }; + expect(validateExpression(initialParams).errors.boundaryGeoField.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.boundaryGeoField[0]).toBe( + 'Boundary geo field is required.' + ); + }); + + test('if boundaryNameField property is missing should not return error', () => { + const initialParams: GeoThresholdAlertParams = { + index: 'testIndex', + indexId: 'testIndexId', + geoField: 'testField', + entity: 'testField', + dateField: 'testField', + trackingEvent: 'testEvent', + boundaryType: 'testType', + boundaryIndexTitle: 'testIndex', + boundaryIndexId: 'testIndexId', + boundaryGeoField: 'testField', + boundaryNameField: '', + }; + expect(validateExpression(initialParams).errors.boundaryGeoField.length).toBe(0); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/validation.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/validation.ts new file mode 100644 index 0000000000000..078a88d9e8415 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/validation.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { ValidationResult } from '../../../../types'; +import { GeoThresholdAlertParams } from './types'; + +export const validateExpression = (alertParams: GeoThresholdAlertParams): ValidationResult => { + const { + index, + geoField, + entity, + dateField, + trackingEvent, + boundaryType, + boundaryIndexTitle, + boundaryGeoField, + } = alertParams; + const validationResult = { errors: {} }; + const errors = { + index: new Array(), + indexId: new Array(), + geoField: new Array(), + entity: new Array(), + dateField: new Array(), + trackingEvent: new Array(), + boundaryType: new Array(), + boundaryIndexTitle: new Array(), + boundaryIndexId: new Array(), + boundaryGeoField: new Array(), + }; + validationResult.errors = errors; + + if (!index) { + errors.index.push( + i18n.translate('xpack.triggersActionsUI.geoThreshold.error.requiredIndexTitleText', { + defaultMessage: 'Index pattern is required.', + }) + ); + } + + if (!geoField) { + errors.geoField.push( + i18n.translate('xpack.triggersActionsUI.geoThreshold.error.requiredGeoFieldText', { + defaultMessage: 'Geo field is required.', + }) + ); + } + + if (!entity) { + errors.entity.push( + i18n.translate('xpack.triggersActionsUI.geoThreshold.error.requiredEntityText', { + defaultMessage: 'Entity is required.', + }) + ); + } + + if (!dateField) { + errors.dateField.push( + i18n.translate('xpack.triggersActionsUI.geoThreshold.error.requiredDateFieldText', { + defaultMessage: 'Date field is required.', + }) + ); + } + + if (!trackingEvent) { + errors.trackingEvent.push( + i18n.translate('xpack.triggersActionsUI.geoThreshold.error.requiredTrackingEventText', { + defaultMessage: 'Tracking event is required.', + }) + ); + } + + if (!boundaryType) { + errors.boundaryType.push( + i18n.translate('xpack.triggersActionsUI.geoThreshold.error.requiredBoundaryTypeText', { + defaultMessage: 'Boundary type is required.', + }) + ); + } + + if (!boundaryIndexTitle) { + errors.boundaryIndexTitle.push( + i18n.translate('xpack.triggersActionsUI.geoThreshold.error.requiredBoundaryIndexTitleText', { + defaultMessage: 'Boundary index pattern title is required.', + }) + ); + } + + if (!boundaryGeoField) { + errors.boundaryGeoField.push( + i18n.translate('xpack.triggersActionsUI.geoThreshold.error.requiredBoundaryGeoFieldText', { + defaultMessage: 'Boundary geo field is required.', + }) + ); + } + + return validationResult; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/index.ts index 436a034e06051..4b2860dcf9b72 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/index.ts @@ -4,14 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ +import { getAlertType as getGeoThresholdAlertType } from './geo_threshold'; import { getAlertType as getThresholdAlertType } from './threshold'; import { TypeRegistry } from '../../type_registry'; import { AlertTypeModel } from '../../../types'; +import { TriggersActionsUiConfigType } from '../../../plugin'; export function registerBuiltInAlertTypes({ alertTypeRegistry, + triggerActionsUiConfig, }: { alertTypeRegistry: TypeRegistry; + triggerActionsUiConfig: TriggersActionsUiConfigType; }) { + if (triggerActionsUiConfig.enableGeoTrackingThresholdAlert) { + alertTypeRegistry.register(getGeoThresholdAlertType()); + } alertTypeRegistry.register(getThresholdAlertType()); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/constants/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/application/constants/plugin.ts index 63ba7df2556de..86a64f3588111 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/constants/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/constants/plugin.ts @@ -5,7 +5,7 @@ */ export const PLUGIN = { - ID: 'triggers_actions_ui', + ID: 'triggersActionsUi', getI18nName: (i18n: any): string => { return i18n.translate('xpack.triggersActionsUI.appName', { defaultMessage: 'Alerts and Actions', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx b/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx index 953a444500084..b4cf13538d64d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx @@ -13,7 +13,11 @@ import { ApplicationStart, } from 'kibana/public'; import { ChartsPluginSetup } from 'src/plugins/charts/public'; -import { DataPublicPluginSetup } from 'src/plugins/data/public'; +import { + DataPublicPluginSetup, + DataPublicPluginStartUi, + IndexPatternsContract, +} from 'src/plugins/data/public'; import { TypeRegistry } from '../type_registry'; import { AlertTypeModel, ActionTypeModel } from '../../types'; @@ -29,6 +33,8 @@ export interface AlertsContextValue> { capabilities: ApplicationStart['capabilities']; dataFieldsFormats?: DataPublicPluginSetup['fieldFormats']; metadata?: MetaData; + dataUi?: DataPublicPluginStartUi; + dataIndexPatterns?: IndexPatternsContract; } const AlertsContext = createContext(null as any); 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 c2c7139d13bf0..149b1f8ee93ad 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 @@ -76,7 +76,7 @@ export async function executeAction({ http: HttpSetup; params: Record; }): Promise> { - return await http.post(`${BASE_ACTION_API_PATH}/action/${id}/_execute`, { + return http.post(`${BASE_ACTION_API_PATH}/action/${id}/_execute`, { body: JSON.stringify({ params }), }); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 6c7a1cbdc3c70..4d8981f25aedc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -32,7 +32,10 @@ import { updateActionConnector, executeAction } from '../../lib/action_connector import { hasSaveActionsCapability } from '../../lib/capabilities'; import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; import { PLUGIN } from '../../constants/plugin'; -import { ActionTypeExecutorResult } from '../../../../../actions/common'; +import { + ActionTypeExecutorResult, + isActionTypeExecutorResult, +} from '../../../../../actions/common'; import './connector_edit_flyout.scss'; export interface ConnectorEditProps { @@ -204,13 +207,24 @@ export const ConnectorEditFlyout = ({ const onExecutAction = () => { setIsExecutinAction(true); - return executeAction({ id: connector.id, params: testExecutionActionParams, http }).then( - (result) => { + return executeAction({ id: connector.id, params: testExecutionActionParams, http }) + .then((result) => { setIsExecutinAction(false); setTestExecutionResult(some(result)); return result; - } - ); + }) + .catch((ex: Error | ActionTypeExecutorResult) => { + const result: ActionTypeExecutorResult = isActionTypeExecutorResult(ex) + ? ex + : { + actionId: connector.id, + status: 'error', + message: ex.message, + }; + setIsExecutinAction(false); + setTestExecutionResult(some(result)); + return result; + }); }; const onSaveClicked = async (closeAfterSave: boolean = true) => { diff --git a/x-pack/plugins/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 42a25b399ddd3..0af01114731a3 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 @@ -177,6 +177,8 @@ export const AlertDetails: React.FunctionComponent = ({ dataFieldsFormats: dataPlugin.fieldFormats, reloadAlerts: setAlert, capabilities, + dataUi: dataPlugin.ui, + dataIndexPatterns: dataPlugin.indexPatterns, }} > { charts, dataFieldsFormats: dataPlugin.fieldFormats, capabilities, + dataUi: dataPlugin.ui, + dataIndexPatterns: dataPlugin.indexPatterns, }} > ; alertTypeRegistry: TypeRegistry; @@ -56,6 +60,7 @@ export class Plugin > { private actionTypeRegistry: TypeRegistry; private alertTypeRegistry: TypeRegistry; + private initializerContext: PluginInitializerContext; constructor(initializerContext: PluginInitializerContext) { const actionTypeRegistry = new TypeRegistry(); @@ -63,6 +68,8 @@ export class Plugin const alertTypeRegistry = new TypeRegistry(); this.alertTypeRegistry = alertTypeRegistry; + + this.initializerContext = initializerContext; } public setup(core: CoreSetup, plugins: PluginsSetup): TriggersAndActionsUIPublicPluginSetup { @@ -110,6 +117,7 @@ export class Plugin registerBuiltInAlertTypes({ alertTypeRegistry: this.alertTypeRegistry, + triggerActionsUiConfig: this.initializerContext.config.get(), }); return { diff --git a/x-pack/plugins/triggers_actions_ui/server/index.ts b/x-pack/plugins/triggers_actions_ui/server/index.ts new file mode 100644 index 0000000000000..c12572f4ea7e9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/server/index.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 { PluginConfigDescriptor } from 'kibana/server'; +import { configSchema, ConfigSchema } from '../config'; + +export const config: PluginConfigDescriptor = { + exposeToBrowser: { + enableGeoTrackingThresholdAlert: true, + }, + schema: configSchema, +}; + +export const plugin = () => ({ + setup() {}, + start() {}, +}); diff --git a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx index e935798179402..7a705f03c0650 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx @@ -80,7 +80,7 @@ export interface ActionWizardProps< /** * List of possible triggers in current context */ - supportedTriggers: TriggerId[]; + triggers: TriggerId[]; triggerPickerDocsLink?: string; } @@ -94,7 +94,7 @@ export const ActionWizard: React.FC = ({ context, onSelectedTriggersChange, getTriggerInfo, - supportedTriggers, + triggers, triggerPickerDocsLink, }) => { // auto pick action factory if there is only 1 available @@ -108,14 +108,14 @@ export const ActionWizard: React.FC = ({ // auto pick selected trigger if none is picked if (currentActionFactory && !((context.triggers?.length ?? 0) > 0)) { - const triggers = getTriggersForActionFactory(currentActionFactory, supportedTriggers); - if (triggers.length > 0) { - onSelectedTriggersChange([triggers[0]]); + const actionTriggers = getTriggersForActionFactory(currentActionFactory, triggers); + if (actionTriggers.length > 0) { + onSelectedTriggersChange([actionTriggers[0]]); } } if (currentActionFactory && config) { - const allTriggers = getTriggersForActionFactory(currentActionFactory, supportedTriggers); + const allTriggers = getTriggersForActionFactory(currentActionFactory, triggers); return (

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

    + No triggers provided in trigger prop. +

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

  • `); + }); +}); diff --git a/src/plugins/security_oss/public/insecure_cluster_service/components/default_alert.tsx b/src/plugins/security_oss/public/insecure_cluster_service/components/default_alert.tsx new file mode 100644 index 0000000000000..f2eeedb5b7372 --- /dev/null +++ b/src/plugins/security_oss/public/insecure_cluster_service/components/default_alert.tsx @@ -0,0 +1,97 @@ +/* + * 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 { + EuiButton, + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { I18nProvider, FormattedMessage } from '@kbn/i18n/react'; +import { MountPoint } from 'kibana/public'; +import React, { useState } from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; + +export const defaultAlertTitle = i18n.translate('security.checkup.insecureClusterTitle', { + defaultMessage: 'Please secure your installation', +}); + +export const defaultAlertText: (onDismiss: (persist: boolean) => void) => MountPoint = ( + onDismiss +) => (e) => { + const AlertText = () => { + const [persist, setPersist] = useState(false); + + return ( + +
    + + + + + setPersist(changeEvent.target.checked)} + label={i18n.translate('security.checkup.dontShowAgain', { + defaultMessage: `Don't show again`, + })} + /> + + + + + {i18n.translate('security.checkup.learnMoreButtonText', { + defaultMessage: `Learn more`, + })} + + + + onDismiss(persist)} + data-test-subj="defaultDismissAlertButton" + > + {i18n.translate('security.checkup.dismissButtonText', { + defaultMessage: `Dismiss`, + })} + + + +
    +
    + ); + }; + + render(, e); + + return () => unmountComponentAtNode(e); +}; diff --git a/src/plugins/security_oss/public/insecure_cluster_service/components/index.ts b/src/plugins/security_oss/public/insecure_cluster_service/components/index.ts new file mode 100644 index 0000000000000..9334dad2b8193 --- /dev/null +++ b/src/plugins/security_oss/public/insecure_cluster_service/components/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 { defaultAlertTitle, defaultAlertText } from './default_alert'; diff --git a/src/plugins/security_oss/public/insecure_cluster_service/index.ts b/src/plugins/security_oss/public/insecure_cluster_service/index.ts new file mode 100644 index 0000000000000..7817dc383c168 --- /dev/null +++ b/src/plugins/security_oss/public/insecure_cluster_service/index.ts @@ -0,0 +1,24 @@ +/* + * 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 { + InsecureClusterService, + InsecureClusterServiceSetup, + InsecureClusterServiceStart, +} from './insecure_cluster_service'; diff --git a/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.mock.tsx b/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.mock.tsx new file mode 100644 index 0000000000000..630becb49dd4c --- /dev/null +++ b/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.mock.tsx @@ -0,0 +1,37 @@ +/* + * 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 { + InsecureClusterServiceSetup, + InsecureClusterServiceStart, +} from './insecure_cluster_service'; + +export const mockInsecureClusterService = { + createSetup: () => { + return { + setAlertTitle: jest.fn(), + setAlertText: jest.fn(), + } as InsecureClusterServiceSetup; + }, + createStart: () => { + return { + hideAlert: jest.fn(), + } as InsecureClusterServiceStart; + }, +}; diff --git a/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.test.tsx b/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.test.tsx new file mode 100644 index 0000000000000..a81f361689743 --- /dev/null +++ b/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.test.tsx @@ -0,0 +1,336 @@ +/* + * 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 { InsecureClusterService } from './insecure_cluster_service'; +import { ConfigType } from '../config'; +import { coreMock } from '../../../../core/public/mocks'; +import { nextTick } from 'test_utils/enzyme_helpers'; + +let mockOnDismissCallback: (persist: boolean) => void = jest.fn().mockImplementation(() => { + throw new Error('expected callback to be replaced!'); +}); + +jest.mock('./components', () => { + return { + defaultAlertTitle: 'mocked default alert title', + defaultAlertText: (onDismiss: any) => { + mockOnDismissCallback = onDismiss; + return 'mocked default alert text'; + }, + }; +}); + +interface InitOpts { + displayAlert?: boolean; + isAnonymousPath?: boolean; + tenant?: string; +} + +function initCore({ + displayAlert = true, + isAnonymousPath = false, + tenant = '/server-base-path', +}: InitOpts = {}) { + const coreSetup = coreMock.createSetup(); + (coreSetup.http.basePath.serverBasePath as string) = tenant; + + const coreStart = coreMock.createStart(); + coreStart.http.get.mockImplementation(async (url: unknown) => { + if (url === '/internal/security_oss/display_insecure_cluster_alert') { + return { displayAlert }; + } + throw new Error(`unexpected call to http.get: ${url}`); + }); + coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(isAnonymousPath); + + coreStart.notifications.toasts.addWarning.mockReturnValue({ id: 'mock_alert_id' }); + return { coreSetup, coreStart }; +} + +describe('InsecureClusterService', () => { + describe('display scenarios', () => { + it('does not display an alert when the warning is explicitly disabled via config', async () => { + const config: ConfigType = { showInsecureClusterWarning: false }; + const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const storage = coreMock.createStorage(); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).not.toHaveBeenCalled(); + expect(coreStart.notifications.toasts.addWarning).not.toHaveBeenCalled(); + + expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it('does not display an alert when the endpoint check returns false', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ displayAlert: false }); + const storage = coreMock.createStorage(); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addWarning).not.toHaveBeenCalled(); + + expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it('does not display an alert when on an anonymous path', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ displayAlert: true, isAnonymousPath: true }); + const storage = coreMock.createStorage(); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).not.toHaveBeenCalled(); + expect(coreStart.notifications.toasts.addWarning).not.toHaveBeenCalled(); + + expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it('only reads storage information from the current tenant', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ + displayAlert: true, + tenant: '/my-specific-tenant', + }); + + const storage = coreMock.createStorage(); + storage.getItem.mockReturnValue(JSON.stringify({ show: false })); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + service.start({ core: coreStart }); + + await nextTick(); + + expect(storage.getItem).toHaveBeenCalledTimes(1); + expect(storage.getItem).toHaveBeenCalledWith( + 'insecureClusterWarningVisibility/my-specific-tenant' + ); + }); + + it('does not display an alert when hidden via storage', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ displayAlert: true }); + + const storage = coreMock.createStorage(); + storage.getItem.mockReturnValue(JSON.stringify({ show: false })); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).not.toHaveBeenCalled(); + expect(coreStart.notifications.toasts.addWarning).not.toHaveBeenCalled(); + + expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it('displays an alert when persisted preference is corrupted', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ displayAlert: true }); + + const storage = coreMock.createStorage(); + storage.getItem.mockReturnValue('{ this is a string of invalid JSON'); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1); + + expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it('displays an alert when enabled via config and endpoint checks', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const storage = coreMock.createStorage(); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addWarning.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "iconType": "alert", + "text": "mocked default alert text", + "title": "mocked default alert title", + }, + Object { + "toastLifeTimeMs": 864000000, + }, + ] + `); + + expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it('dismisses the alert when requested, and remembers this preference', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const storage = coreMock.createStorage(); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1); + + mockOnDismissCallback(true); + + expect(coreStart.notifications.toasts.remove).toHaveBeenCalledTimes(1); + expect(storage.setItem).toHaveBeenCalledWith( + 'insecureClusterWarningVisibility/server-base-path', + JSON.stringify({ show: false }) + ); + }); + }); + + describe('#setup', () => { + it('allows the alert title and text to be replaced exactly once', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const storage = coreMock.createStorage(); + + const { coreSetup } = initCore(); + + const service = new InsecureClusterService(config, storage); + const { setAlertTitle, setAlertText } = service.setup({ core: coreSetup }); + setAlertTitle('some new title'); + setAlertText('some new alert text'); + + expect(() => setAlertTitle('')).toThrowErrorMatchingInlineSnapshot( + `"alert title has already been set"` + ); + expect(() => setAlertText('')).toThrowErrorMatchingInlineSnapshot( + `"alert text has already been set"` + ); + }); + + it('allows the alert title and text to be replaced', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const storage = coreMock.createStorage(); + + const service = new InsecureClusterService(config, storage); + const { setAlertTitle, setAlertText } = service.setup({ core: coreSetup }); + setAlertTitle('some new title'); + setAlertText('some new alert text'); + + service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addWarning.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "iconType": "alert", + "text": "some new alert text", + "title": "some new title", + }, + Object { + "toastLifeTimeMs": 864000000, + }, + ] + `); + + expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('#start', () => { + it('allows the alert to be hidden via start contract, and remembers this preference', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const storage = coreMock.createStorage(); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + const { hideAlert } = service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1); + + hideAlert(true); + + expect(coreStart.notifications.toasts.remove).toHaveBeenCalledTimes(1); + expect(storage.setItem).toHaveBeenCalledWith( + 'insecureClusterWarningVisibility/server-base-path', + JSON.stringify({ show: false }) + ); + }); + + it('allows the alert to be hidden via start contract, and does not remember the preference', async () => { + const config: ConfigType = { showInsecureClusterWarning: true }; + const { coreSetup, coreStart } = initCore({ displayAlert: true }); + const storage = coreMock.createStorage(); + + const service = new InsecureClusterService(config, storage); + service.setup({ core: coreSetup }); + const { hideAlert } = service.start({ core: coreStart }); + + await nextTick(); + + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1); + + hideAlert(false); + + expect(coreStart.notifications.toasts.remove).toHaveBeenCalledTimes(1); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.tsx b/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.tsx new file mode 100644 index 0000000000000..e6255233354b7 --- /dev/null +++ b/src/plugins/security_oss/public/insecure_cluster_service/insecure_cluster_service.tsx @@ -0,0 +1,164 @@ +/* + * 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 { CoreSetup, CoreStart, MountPoint, Toast } from 'kibana/public'; + +import { BehaviorSubject, combineLatest, from } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; +import { ConfigType } from '../config'; +import { defaultAlertText, defaultAlertTitle } from './components'; + +interface SetupDeps { + core: Pick; +} + +interface StartDeps { + core: Pick; +} + +export interface InsecureClusterServiceSetup { + setAlertTitle: (alertTitle: string | MountPoint) => void; + setAlertText: (alertText: string | MountPoint) => void; +} + +export interface InsecureClusterServiceStart { + hideAlert: (persist: boolean) => void; +} + +export class InsecureClusterService { + private enabled: boolean; + + private alertVisibility$: BehaviorSubject; + + private storage: Storage; + + private alertToast?: Toast; + + private alertTitle?: string | MountPoint; + + private alertText?: string | MountPoint; + + private storageKey?: string; + + constructor(config: Pick, storage: Storage) { + this.storage = storage; + this.enabled = config.showInsecureClusterWarning; + this.alertVisibility$ = new BehaviorSubject(this.enabled); + } + + public setup({ core }: SetupDeps): InsecureClusterServiceSetup { + const tenant = core.http.basePath.serverBasePath; + this.storageKey = `insecureClusterWarningVisibility${tenant}`; + this.enabled = this.enabled && this.getPersistedVisibilityPreference(); + this.alertVisibility$.next(this.enabled); + + return { + setAlertTitle: (alertTitle: string | MountPoint) => { + if (this.alertTitle) { + throw new Error('alert title has already been set'); + } + this.alertTitle = alertTitle; + }, + setAlertText: (alertText: string | MountPoint) => { + if (this.alertText) { + throw new Error('alert text has already been set'); + } + this.alertText = alertText; + }, + }; + } + + public start({ core }: StartDeps): InsecureClusterServiceStart { + const shouldInitialize = + this.enabled && !core.http.anonymousPaths.isAnonymous(window.location.pathname); + + if (shouldInitialize) { + this.initializeAlert(core); + } + + return { + hideAlert: (persist: boolean) => this.setAlertVisibility(false, persist), + }; + } + + private initializeAlert(core: StartDeps['core']) { + const displayAlert$ = from( + core.http + .get<{ displayAlert: boolean }>('/internal/security_oss/display_insecure_cluster_alert') + .catch((e) => { + // in the event we can't make this call, assume we shouldn't display this alert. + return { displayAlert: false }; + }) + ); + + // 10 days is reasonably long enough to call "forever" for a page load. + // Can't go too much longer than this. See https://github.com/elastic/kibana/issues/64264#issuecomment-618400354 + const oneMinute = 60000; + const tenDays = oneMinute * 60 * 24 * 10; + + combineLatest([displayAlert$, this.alertVisibility$]) + .pipe( + map(([{ displayAlert }, isAlertVisible]) => displayAlert && isAlertVisible), + distinctUntilChanged() + ) + .subscribe((showAlert) => { + if (showAlert && !this.alertToast) { + this.alertToast = core.notifications.toasts.addWarning( + { + title: this.alertTitle ?? defaultAlertTitle, + text: + this.alertText ?? + defaultAlertText((persist: boolean) => this.setAlertVisibility(false, persist)), + iconType: 'alert', + }, + { + toastLifeTimeMs: tenDays, + } + ); + } else if (!showAlert && this.alertToast) { + core.notifications.toasts.remove(this.alertToast); + this.alertToast = undefined; + } + }); + } + + private setAlertVisibility(show: boolean, persist: boolean) { + if (!this.enabled) { + return; + } + this.alertVisibility$.next(show); + if (persist) { + this.setPersistedVisibilityPreference(show); + } + } + + private getPersistedVisibilityPreference() { + const entry = this.storage.getItem(this.storageKey!) ?? '{}'; + try { + const { show = true } = JSON.parse(entry); + return show; + } catch (e) { + return true; + } + } + + private setPersistedVisibilityPreference(show: boolean) { + this.storage.setItem(this.storageKey!, JSON.stringify({ show })); + } +} diff --git a/src/plugins/security_oss/public/mocks.ts b/src/plugins/security_oss/public/mocks.ts new file mode 100644 index 0000000000000..f4913d2de671b --- /dev/null +++ b/src/plugins/security_oss/public/mocks.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 { mockSecurityOssPlugin } from './plugin.mock'; diff --git a/src/plugins/security_oss/public/plugin.mock.ts b/src/plugins/security_oss/public/plugin.mock.ts new file mode 100644 index 0000000000000..c513d241dccbb --- /dev/null +++ b/src/plugins/security_oss/public/plugin.mock.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. + */ + +import { mockInsecureClusterService } from './insecure_cluster_service/insecure_cluster_service.mock'; +import { SecurityOssPluginSetup, SecurityOssPluginStart } from './plugin'; + +export const mockSecurityOssPlugin = { + createSetup: () => { + return { + insecureCluster: mockInsecureClusterService.createSetup(), + } as DeeplyMockedKeys; + }, + createStart: () => { + return { + insecureCluster: mockInsecureClusterService.createStart(), + } as DeeplyMockedKeys; + }, +}; diff --git a/src/plugins/security_oss/public/plugin.ts b/src/plugins/security_oss/public/plugin.ts new file mode 100644 index 0000000000000..2f3eed0bde5eb --- /dev/null +++ b/src/plugins/security_oss/public/plugin.ts @@ -0,0 +1,58 @@ +/* + * 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 { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; +import { ConfigType } from './config'; +import { + InsecureClusterService, + InsecureClusterServiceSetup, + InsecureClusterServiceStart, +} from './insecure_cluster_service'; + +export interface SecurityOssPluginSetup { + insecureCluster: InsecureClusterServiceSetup; +} + +export interface SecurityOssPluginStart { + insecureCluster: InsecureClusterServiceStart; +} + +export class SecurityOssPlugin + implements Plugin { + private readonly config: ConfigType; + + private insecureClusterService: InsecureClusterService; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.config = this.initializerContext.config.get(); + this.insecureClusterService = new InsecureClusterService(this.config, localStorage); + } + + public setup(core: CoreSetup) { + return { + insecureCluster: this.insecureClusterService.setup({ core }), + }; + } + + public start(core: CoreStart) { + return { + insecureCluster: this.insecureClusterService.start({ core }), + }; + } +} diff --git a/src/plugins/security_oss/server/check_cluster_data.test.ts b/src/plugins/security_oss/server/check_cluster_data.test.ts new file mode 100644 index 0000000000000..a8245931daf04 --- /dev/null +++ b/src/plugins/security_oss/server/check_cluster_data.test.ts @@ -0,0 +1,148 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { elasticsearchServiceMock, loggingSystemMock } from '../../../core/server/mocks'; +import { createClusterDataCheck } from './check_cluster_data'; + +describe('checkClusterForUserData', () => { + it('returns false if no data is found', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + esClient.cat.indices.mockResolvedValue( + elasticsearchServiceMock.createApiResponse({ body: [] }) + ); + + const log = loggingSystemMock.createLogger(); + + const response = await createClusterDataCheck()(esClient, log); + expect(response).toEqual(false); + expect(esClient.cat.indices).toHaveBeenCalledTimes(1); + }); + + it('returns false if data only exists in system indices', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + esClient.cat.indices.mockResolvedValue( + elasticsearchServiceMock.createApiResponse({ + body: [ + { + index: '.kibana', + 'docs.count': 500, + }, + { + index: 'kibana_sample_ecommerce_data', + 'docs.count': 20, + }, + { + index: '.somethingElse', + 'docs.count': 20, + }, + ], + }) + ); + + const log = loggingSystemMock.createLogger(); + + const response = await createClusterDataCheck()(esClient, log); + expect(response).toEqual(false); + expect(esClient.cat.indices).toHaveBeenCalledTimes(1); + }); + + it('returns true if data exists in non-system indices', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + esClient.cat.indices.mockResolvedValue( + elasticsearchServiceMock.createApiResponse({ + body: [ + { + index: '.kibana', + 'docs.count': 500, + }, + { + index: 'some_real_index', + 'docs.count': 20, + }, + ], + }) + ); + + const log = loggingSystemMock.createLogger(); + + const response = await createClusterDataCheck()(esClient, log); + expect(response).toEqual(true); + }); + + it('checks each time until the first true response is returned, then stops checking', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + esClient.cat.indices + .mockResolvedValueOnce( + elasticsearchServiceMock.createApiResponse({ + body: [], + }) + ) + .mockRejectedValueOnce(new Error('something terrible happened')) + .mockResolvedValueOnce( + elasticsearchServiceMock.createApiResponse({ + body: [ + { + index: '.kibana', + 'docs.count': 500, + }, + ], + }) + ) + .mockResolvedValueOnce( + elasticsearchServiceMock.createApiResponse({ + body: [ + { + index: 'some_real_index', + 'docs.count': 20, + }, + ], + }) + ); + + const log = loggingSystemMock.createLogger(); + + const doesClusterHaveUserData = createClusterDataCheck(); + + let response = await doesClusterHaveUserData(esClient, log); + expect(response).toEqual(false); + + response = await doesClusterHaveUserData(esClient, log); + expect(response).toEqual(false); + + response = await doesClusterHaveUserData(esClient, log); + expect(response).toEqual(false); + + response = await doesClusterHaveUserData(esClient, log); + expect(response).toEqual(true); + + expect(esClient.cat.indices).toHaveBeenCalledTimes(4); + expect(log.warn.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "Error encountered while checking cluster for user data: Error: something terrible happened", + ], + ] + `); + + response = await doesClusterHaveUserData(esClient, log); + expect(response).toEqual(true); + // Same number of calls as above. We should not have to interrogate again. + expect(esClient.cat.indices).toHaveBeenCalledTimes(4); + }); +}); diff --git a/src/plugins/security_oss/server/check_cluster_data.ts b/src/plugins/security_oss/server/check_cluster_data.ts new file mode 100644 index 0000000000000..a3aeb50ae280a --- /dev/null +++ b/src/plugins/security_oss/server/check_cluster_data.ts @@ -0,0 +1,47 @@ +/* + * 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 { ElasticsearchClient, Logger } from 'kibana/server'; + +export const createClusterDataCheck = () => { + let clusterHasUserData = false; + + return async function doesClusterHaveUserData(esClient: ElasticsearchClient, log: Logger) { + if (!clusterHasUserData) { + try { + const indices = await esClient.cat.indices< + Array<{ index: string; ['docs.count']: string }> + >({ + format: 'json', + h: ['index', 'docs.count'], + }); + clusterHasUserData = indices.body.some((indexCount) => { + const isInternalIndex = + indexCount.index.startsWith('.') || indexCount.index.startsWith('kibana_sample_'); + + return !isInternalIndex && parseInt(indexCount['docs.count'], 10) > 0; + }); + } catch (e) { + log.warn(`Error encountered while checking cluster for user data: ${e}`); + clusterHasUserData = false; + } + } + return clusterHasUserData; + }; +}; diff --git a/src/plugins/security_oss/server/config.ts b/src/plugins/security_oss/server/config.ts new file mode 100644 index 0000000000000..17fb46065aee5 --- /dev/null +++ b/src/plugins/security_oss/server/config.ts @@ -0,0 +1,26 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; + +export type ConfigType = TypeOf; + +export const ConfigSchema = schema.object({ + showInsecureClusterWarning: schema.boolean({ defaultValue: true }), +}); diff --git a/src/plugins/security_oss/server/index.ts b/src/plugins/security_oss/server/index.ts new file mode 100644 index 0000000000000..f35ae39daaff3 --- /dev/null +++ b/src/plugins/security_oss/server/index.ts @@ -0,0 +1,35 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; + +import { PluginConfigDescriptor, PluginInitializerContext } from 'kibana/server'; +import { ConfigSchema } from './config'; +import { SecurityOssPlugin } from './plugin'; + +export { SecurityOssPluginSetup } from './plugin'; + +export const config: PluginConfigDescriptor> = { + schema: ConfigSchema, + exposeToBrowser: { + showInsecureClusterWarning: true, + }, +}; + +export const plugin = (context: PluginInitializerContext) => new SecurityOssPlugin(context); diff --git a/src/plugins/security_oss/server/plugin.test.ts b/src/plugins/security_oss/server/plugin.test.ts new file mode 100644 index 0000000000000..417da0c7e73bb --- /dev/null +++ b/src/plugins/security_oss/server/plugin.test.ts @@ -0,0 +1,37 @@ +/* + * 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 { coreMock } from '../../../core/server/mocks'; +import { SecurityOssPlugin } from './plugin'; + +describe('SecurityOss Plugin', () => { + describe('#setup', () => { + it('exposes the proper contract', async () => { + const context = coreMock.createPluginInitializerContext(); + const plugin = new SecurityOssPlugin(context); + const core = coreMock.createSetup(); + const contract = plugin.setup(core); + expect(Object.keys(contract)).toMatchInlineSnapshot(` + Array [ + "showInsecureClusterWarning$", + ] + `); + }); + }); +}); diff --git a/src/plugins/security_oss/server/plugin.ts b/src/plugins/security_oss/server/plugin.ts new file mode 100644 index 0000000000000..e48827f21a13a --- /dev/null +++ b/src/plugins/security_oss/server/plugin.ts @@ -0,0 +1,62 @@ +/* + * 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 { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { createClusterDataCheck } from './check_cluster_data'; +import { ConfigType } from './config'; +import { setupDisplayInsecureClusterAlertRoute } from './routes'; + +export interface SecurityOssPluginSetup { + /** + * Allows consumers to show/hide the insecure cluster warning. + */ + showInsecureClusterWarning$: BehaviorSubject; +} + +export class SecurityOssPlugin implements Plugin { + private readonly config$: Observable; + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.config$ = initializerContext.config.create(); + this.logger = initializerContext.logger.get(); + } + + public setup(core: CoreSetup) { + const router = core.http.createRouter(); + const showInsecureClusterWarning$ = new BehaviorSubject(true); + + setupDisplayInsecureClusterAlertRoute({ + router, + log: this.logger, + config$: this.config$, + displayModifier$: showInsecureClusterWarning$, + doesClusterHaveUserData: createClusterDataCheck(), + }); + + return { + showInsecureClusterWarning$, + }; + } + + public start() {} + + public stop() {} +} diff --git a/src/plugins/security_oss/server/routes/display_insecure_cluster_alert.ts b/src/plugins/security_oss/server/routes/display_insecure_cluster_alert.ts new file mode 100644 index 0000000000000..0f0f72f054b4c --- /dev/null +++ b/src/plugins/security_oss/server/routes/display_insecure_cluster_alert.ts @@ -0,0 +1,63 @@ +/* + * 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 { IRouter, Logger } from 'kibana/server'; +import { combineLatest, Observable } from 'rxjs'; +import { createClusterDataCheck } from '../check_cluster_data'; +import { ConfigType } from '../config'; + +interface Deps { + router: IRouter; + log: Logger; + config$: Observable; + displayModifier$: Observable; + doesClusterHaveUserData: ReturnType; +} + +export const setupDisplayInsecureClusterAlertRoute = ({ + router, + log, + config$, + displayModifier$, + doesClusterHaveUserData, +}: Deps) => { + let showInsecureClusterWarning = false; + + combineLatest([config$, displayModifier$]).subscribe(([config, displayModifier]) => { + showInsecureClusterWarning = config.showInsecureClusterWarning && displayModifier; + }); + + router.get( + { + path: '/internal/security_oss/display_insecure_cluster_alert', + validate: false, + }, + async (context, request, response) => { + if (!showInsecureClusterWarning) { + return response.ok({ body: { displayAlert: false } }); + } + + const hasData = await doesClusterHaveUserData( + context.core.elasticsearch.client.asInternalUser, + log + ); + return response.ok({ body: { displayAlert: hasData } }); + } + ); +}; diff --git a/src/plugins/security_oss/server/routes/index.ts b/src/plugins/security_oss/server/routes/index.ts new file mode 100644 index 0000000000000..ceff0b12c9cb1 --- /dev/null +++ b/src/plugins/security_oss/server/routes/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 { setupDisplayInsecureClusterAlertRoute } from './display_insecure_cluster_alert'; diff --git a/src/plugins/security_oss/server/routes/integration_tests/display_insecure_cluster_alert.test.ts b/src/plugins/security_oss/server/routes/integration_tests/display_insecure_cluster_alert.test.ts new file mode 100644 index 0000000000000..d62a5040be6b3 --- /dev/null +++ b/src/plugins/security_oss/server/routes/integration_tests/display_insecure_cluster_alert.test.ts @@ -0,0 +1,134 @@ +/* + * 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 { loggingSystemMock } from '../../../../../core/server/mocks'; +import { setupServer } from '../../../../../core/server/test_utils'; +import { setupDisplayInsecureClusterAlertRoute } from '../display_insecure_cluster_alert'; +import { ConfigType } from '../../config'; +import { BehaviorSubject, of } from 'rxjs'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { createClusterDataCheck } from '../../check_cluster_data'; +import supertest from 'supertest'; + +type SetupServerReturn = UnwrapPromise>; +const pluginId = Symbol('securityOss'); + +interface SetupOpts { + config?: ConfigType; + displayModifier$?: BehaviorSubject; + doesClusterHaveUserData?: ReturnType; +} + +describe('GET /internal/security_oss/display_insecure_cluster_alert', () => { + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + + const setupTestServer = async ({ + config = { showInsecureClusterWarning: true }, + displayModifier$ = new BehaviorSubject(true), + doesClusterHaveUserData = jest.fn().mockResolvedValue(true), + }: SetupOpts) => { + ({ server, httpSetup } = await setupServer(pluginId)); + + const router = httpSetup.createRouter('/'); + const log = loggingSystemMock.createLogger(); + + setupDisplayInsecureClusterAlertRoute({ + router, + log, + config$: of(config), + displayModifier$, + doesClusterHaveUserData, + }); + + await server.start(); + + return { + log, + }; + }; + + afterEach(async () => { + await server.stop(); + }); + + it('responds `false` if plugin is not configured to display alerts', async () => { + await setupTestServer({ + config: { showInsecureClusterWarning: false }, + }); + + await supertest(httpSetup.server.listener) + .get('/internal/security_oss/display_insecure_cluster_alert') + .expect(200, { displayAlert: false }); + }); + + it('responds `false` if cluster does not contain user data', async () => { + await setupTestServer({ + config: { showInsecureClusterWarning: true }, + doesClusterHaveUserData: jest.fn().mockResolvedValue(false), + }); + + await supertest(httpSetup.server.listener) + .get('/internal/security_oss/display_insecure_cluster_alert') + .expect(200, { displayAlert: false }); + }); + + it('responds `false` if displayModifier$ is set to false', async () => { + await setupTestServer({ + config: { showInsecureClusterWarning: true }, + doesClusterHaveUserData: jest.fn().mockResolvedValue(true), + displayModifier$: new BehaviorSubject(false), + }); + + await supertest(httpSetup.server.listener) + .get('/internal/security_oss/display_insecure_cluster_alert') + .expect(200, { displayAlert: false }); + }); + + it('responds `true` if cluster contains user data', async () => { + await setupTestServer({ + config: { showInsecureClusterWarning: true }, + doesClusterHaveUserData: jest.fn().mockResolvedValue(true), + }); + + await supertest(httpSetup.server.listener) + .get('/internal/security_oss/display_insecure_cluster_alert') + .expect(200, { displayAlert: true }); + }); + + it('responds to changing displayModifier$ values', async () => { + const displayModifier$ = new BehaviorSubject(true); + + await setupTestServer({ + config: { showInsecureClusterWarning: true }, + doesClusterHaveUserData: jest.fn().mockResolvedValue(true), + displayModifier$, + }); + + await supertest(httpSetup.server.listener) + .get('/internal/security_oss/display_insecure_cluster_alert') + .expect(200, { displayAlert: true }); + + displayModifier$.next(false); + + await supertest(httpSetup.server.listener) + .get('/internal/security_oss/display_insecure_cluster_alert') + .expect(200, { displayAlert: false }); + }); +}); diff --git a/src/plugins/share/public/index.ts b/src/plugins/share/public/index.ts index e3d6c41a278cd..950ecebeaadc7 100644 --- a/src/plugins/share/public/index.ts +++ b/src/plugins/share/public/index.ts @@ -40,4 +40,6 @@ export { import { SharePlugin } from './plugin'; +export { KibanaURL } from './kibana_url'; + export const plugin = () => new SharePlugin(); diff --git a/src/plugins/share/public/kibana_url.ts b/src/plugins/share/public/kibana_url.ts new file mode 100644 index 0000000000000..40c3372579f6a --- /dev/null +++ b/src/plugins/share/public/kibana_url.ts @@ -0,0 +1,44 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// TODO: Replace this logic with KibanaURL once it is available. +// https://github.com/elastic/kibana/issues/64497 +export class KibanaURL { + public readonly path: string; + public readonly appName: string; + public readonly appPath: string; + + constructor(path: string) { + const match = path.match(/^.*\/app\/([^\/#]+)(.+)$/); + + if (!match) { + throw new Error('Unexpected URL path.'); + } + + const [, appName, appPath] = match; + + if (!appName || !appPath) { + throw new Error('Could not parse URL path.'); + } + + this.path = path; + this.appName = appName; + this.appPath = appPath; + } +} diff --git a/src/plugins/share/public/types.ts b/src/plugins/share/public/types.ts index 9dcfc3d9e8143..19f33a820a11a 100644 --- a/src/plugins/share/public/types.ts +++ b/src/plugins/share/public/types.ts @@ -18,7 +18,8 @@ */ import { ComponentType } from 'react'; -import { EuiContextMenuPanelDescriptor, EuiContextMenuPanelItemDescriptor } from '@elastic/eui'; +import { EuiContextMenuPanelDescriptor } from '@elastic/eui'; +import { EuiContextMenuPanelItemDescriptorEntry } from '@elastic/eui/src/components/context_menu/context_menu'; /** * @public @@ -53,7 +54,8 @@ export interface ShareContext { * used to order the individual items in a flat list returned by all registered * menu providers. * */ -export interface ShareContextMenuPanelItem extends Omit { +export interface ShareContextMenuPanelItem + extends Omit { name: string; // EUI will accept a `ReactNode` for the `name` prop, but `ShareContentMenu` assumes a `string`. sortOrder?: number; } diff --git a/src/plugins/telemetry/server/fetcher.test.ts b/src/plugins/telemetry/server/fetcher.test.ts index 245adf59799cc..45712df772e1c 100644 --- a/src/plugins/telemetry/server/fetcher.test.ts +++ b/src/plugins/telemetry/server/fetcher.test.ts @@ -23,19 +23,93 @@ import { coreMock } from '../../../core/server/mocks'; describe('FetcherTask', () => { describe('sendIfDue', () => { - it('returns undefined and warns when it fails to get telemetry configs', async () => { + it('stops when it fails to get telemetry configs', async () => { const initializerContext = coreMock.createPluginInitializerContext({}); const fetcherTask = new FetcherTask(initializerContext); const mockError = new Error('Some message.'); - fetcherTask['getCurrentConfigs'] = async () => { - throw mockError; - }; + const getCurrentConfigs = jest.fn().mockRejectedValue(mockError); + const fetchTelemetry = jest.fn(); + const sendTelemetry = jest.fn(); + Object.assign(fetcherTask, { + getCurrentConfigs, + fetchTelemetry, + sendTelemetry, + }); const result = await fetcherTask['sendIfDue'](); expect(result).toBe(undefined); + expect(getCurrentConfigs).toBeCalledTimes(1); + expect(fetchTelemetry).toBeCalledTimes(0); + expect(sendTelemetry).toBeCalledTimes(0); expect(fetcherTask['logger'].warn).toBeCalledTimes(1); expect(fetcherTask['logger'].warn).toHaveBeenCalledWith( - `Error fetching telemetry configs: ${mockError}` + `Error getting telemetry configs. (${mockError})` ); }); + + it('stops when all collectors are not ready', async () => { + const initializerContext = coreMock.createPluginInitializerContext({}); + const fetcherTask = new FetcherTask(initializerContext); + const getCurrentConfigs = jest.fn().mockResolvedValue({}); + const areAllCollectorsReady = jest.fn().mockResolvedValue(false); + const shouldSendReport = jest.fn().mockReturnValue(true); + const fetchTelemetry = jest.fn(); + const sendTelemetry = jest.fn(); + const updateReportFailure = jest.fn(); + + Object.assign(fetcherTask, { + getCurrentConfigs, + areAllCollectorsReady, + shouldSendReport, + fetchTelemetry, + updateReportFailure, + sendTelemetry, + }); + + await fetcherTask['sendIfDue'](); + + expect(fetchTelemetry).toBeCalledTimes(0); + expect(sendTelemetry).toBeCalledTimes(0); + + expect(areAllCollectorsReady).toBeCalledTimes(1); + expect(updateReportFailure).toBeCalledTimes(0); + expect(fetcherTask['logger'].warn).toBeCalledTimes(1); + expect(fetcherTask['logger'].warn).toHaveBeenCalledWith( + `Error fetching usage. (Error: Not all collectors are ready.)` + ); + }); + + it('fetches usage and send telemetry', async () => { + const initializerContext = coreMock.createPluginInitializerContext({}); + const fetcherTask = new FetcherTask(initializerContext); + const mockTelemetryUrl = 'mock_telemetry_url'; + const mockClusters = ['cluster_1', 'cluster_2']; + const getCurrentConfigs = jest.fn().mockResolvedValue({ + telemetryUrl: mockTelemetryUrl, + }); + const areAllCollectorsReady = jest.fn().mockResolvedValue(true); + const shouldSendReport = jest.fn().mockReturnValue(true); + + const fetchTelemetry = jest.fn().mockResolvedValue(mockClusters); + const sendTelemetry = jest.fn(); + const updateReportFailure = jest.fn(); + + Object.assign(fetcherTask, { + getCurrentConfigs, + areAllCollectorsReady, + shouldSendReport, + fetchTelemetry, + updateReportFailure, + sendTelemetry, + }); + + await fetcherTask['sendIfDue'](); + + expect(areAllCollectorsReady).toBeCalledTimes(1); + expect(fetchTelemetry).toBeCalledTimes(1); + expect(sendTelemetry).toBeCalledTimes(2); + expect(sendTelemetry).toHaveBeenNthCalledWith(1, mockTelemetryUrl, mockClusters[0]); + expect(sendTelemetry).toHaveBeenNthCalledWith(2, mockTelemetryUrl, mockClusters[1]); + expect(updateReportFailure).toBeCalledTimes(0); + }); }); }); diff --git a/src/plugins/telemetry/server/fetcher.ts b/src/plugins/telemetry/server/fetcher.ts index 75cfac721bcd3..fadfc01f628f5 100644 --- a/src/plugins/telemetry/server/fetcher.ts +++ b/src/plugins/telemetry/server/fetcher.ts @@ -18,11 +18,14 @@ */ import moment from 'moment'; -import { Observable } from 'rxjs'; +import { Observable, Subscription, timer } from 'rxjs'; import { take } from 'rxjs/operators'; // @ts-ignore import fetch from 'node-fetch'; -import { TelemetryCollectionManagerPluginStart } from 'src/plugins/telemetry_collection_manager/server'; +import { + TelemetryCollectionManagerPluginStart, + UsageStatsPayload, +} from 'src/plugins/telemetry_collection_manager/server'; import { PluginInitializerContext, Logger, @@ -58,7 +61,7 @@ export class FetcherTask { private readonly config$: Observable; private readonly currentKibanaVersion: string; private readonly logger: Logger; - private intervalId?: NodeJS.Timeout; + private intervalId?: Subscription; private lastReported?: number; private isSending = false; private internalRepository?: SavedObjectsClientContract; @@ -79,21 +82,24 @@ export class FetcherTask { this.telemetryCollectionManager = telemetryCollectionManager; this.elasticsearchClient = elasticsearch.legacy.createClient('telemetry-fetcher'); - setTimeout(() => { - this.sendIfDue(); - this.intervalId = setInterval(() => this.sendIfDue(), this.checkIntervalMs); - }, this.initialCheckDelayMs); + this.intervalId = timer(this.initialCheckDelayMs, this.checkIntervalMs).subscribe(() => + this.sendIfDue() + ); } public stop() { if (this.intervalId) { - clearInterval(this.intervalId); + this.intervalId.unsubscribe(); } if (this.elasticsearchClient) { this.elasticsearchClient.close(); } } + private async areAllCollectorsReady() { + return (await this.telemetryCollectionManager?.areAllCollectorsReady()) ?? false; + } + private async sendIfDue() { if (this.isSending) { return; @@ -103,7 +109,7 @@ export class FetcherTask { try { telemetryConfig = await this.getCurrentConfigs(); } catch (err) { - this.logger.warn(`Error fetching telemetry configs: ${err}`); + this.logger.warn(`Error getting telemetry configs. (${err})`); return; } @@ -111,9 +117,22 @@ export class FetcherTask { return; } + let clusters: Array = []; + this.isSending = true; + + try { + const allCollectorsReady = await this.areAllCollectorsReady(); + if (!allCollectorsReady) { + throw new Error('Not all collectors are ready.'); + } + clusters = await this.fetchTelemetry(); + } catch (err) { + this.logger.warn(`Error fetching usage. (${err})`); + this.isSending = false; + return; + } + try { - this.isSending = true; - const clusters = await this.fetchTelemetry(); const { telemetryUrl } = telemetryConfig; for (const cluster of clusters) { await this.sendTelemetry(telemetryUrl, cluster); @@ -123,7 +142,7 @@ export class FetcherTask { } catch (err) { await this.updateReportFailure(telemetryConfig); - this.logger.warn(`Error sending telemetry usage data: ${err}`); + this.logger.warn(`Error sending telemetry usage data. (${err})`); } this.isSending = false; } diff --git a/src/plugins/telemetry/server/plugin.ts b/src/plugins/telemetry/server/plugin.ts index dfbbe3355e69c..b423cbb07ba32 100644 --- a/src/plugins/telemetry/server/plugin.ts +++ b/src/plugins/telemetry/server/plugin.ts @@ -18,7 +18,7 @@ */ import { URL } from 'url'; -import { Observable } from 'rxjs'; +import { AsyncSubject, Observable } from 'rxjs'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { TelemetryCollectionManagerPluginSetup, @@ -30,11 +30,11 @@ import { PluginInitializerContext, ISavedObjectsRepository, CoreStart, - IUiSettingsClient, SavedObjectsClient, Plugin, Logger, IClusterClient, + UiSettingsServiceStart, } from '../../../core/server'; import { registerRoutes } from './routes'; import { registerCollection } from './telemetry_collection'; @@ -82,8 +82,11 @@ export class TelemetryPlugin implements Plugin; private readonly isDev: boolean; private readonly fetcherTask: FetcherTask; + /** + * @private Used to mark the completion of the old UI Settings migration + */ + private readonly oldUiSettingsHandled$ = new AsyncSubject(); private savedObjectsClient?: ISavedObjectsRepository; - private uiSettingsClient?: IUiSettingsClient; private elasticsearchClient?: IClusterClient; constructor(initializerContext: PluginInitializerContext) { @@ -97,10 +100,10 @@ export class TelemetryPlugin implements Plugin { + ): TelemetryPluginSetup { const currentKibanaVersion = this.currentKibanaVersion; const config$ = this.config$; const isDev = this.isDev; @@ -131,25 +134,21 @@ export class TelemetryPlugin implements Plugin { - const internalRepository = new SavedObjectsClient(savedObjects.createInternalRepository()); - const telemetrySavedObject = await getTelemetrySavedObject(internalRepository!); + await this.oldUiSettingsHandled$.pipe(take(1)).toPromise(); // Wait for the old settings to be handled + const internalRepository = new SavedObjectsClient(savedObjectsInternalRepository); + const telemetrySavedObject = await getTelemetrySavedObject(internalRepository); const config = await this.config$.pipe(take(1)).toPromise(); const allowChangingOptInStatus = config.allowChangingOptInStatus; const configTelemetryOptIn = typeof config.optIn === 'undefined' ? null : config.optIn; @@ -166,6 +165,27 @@ export class TelemetryPlugin implements Plugin { + return await this.usageCollection?.areAllCollectorsReady(); + }; + private getOptInStatsForCollection = async ( collection: Collection, optInStatus: boolean, diff --git a/src/plugins/telemetry_collection_manager/server/types.ts b/src/plugins/telemetry_collection_manager/server/types.ts index 44970df30fd16..3b0936fb73a60 100644 --- a/src/plugins/telemetry_collection_manager/server/types.ts +++ b/src/plugins/telemetry_collection_manager/server/types.ts @@ -34,6 +34,7 @@ export interface TelemetryCollectionManagerPluginSetup { ) => void; getOptInStats: TelemetryCollectionManagerPlugin['getOptInStats']; getStats: TelemetryCollectionManagerPlugin['getStats']; + areAllCollectorsReady: TelemetryCollectionManagerPlugin['areAllCollectorsReady']; } export interface TelemetryCollectionManagerPluginStart { @@ -42,6 +43,7 @@ export interface TelemetryCollectionManagerPluginStart { ) => void; getOptInStats: TelemetryCollectionManagerPlugin['getOptInStats']; getStats: TelemetryCollectionManagerPlugin['getStats']; + areAllCollectorsReady: TelemetryCollectionManagerPlugin['areAllCollectorsReady']; } export interface TelemetryOptInStats { diff --git a/src/plugins/tile_map/public/components/tile_map_options.tsx b/src/plugins/tile_map/public/components/tile_map_options.tsx index f7fb4daff63f0..1a7b11ccf6e20 100644 --- a/src/plugins/tile_map/public/components/tile_map_options.tsx +++ b/src/plugins/tile_map/public/components/tile_map_options.tsx @@ -76,7 +76,7 @@ function TileMapOptions(props: TileMapOptionsProps) { (triggerId: T, action: UiActionsActionDefinition | Action) => void; + readonly addTriggerAction: (triggerId: T, action: UiActionsActionDefinition | Action) => void; // (undocumented) readonly attachAction: (triggerId: T, actionId: string) => void; readonly clear: () => void; @@ -239,21 +246,21 @@ export class UiActionsService { readonly executionService: UiActionsExecutionService; readonly fork: () => UiActionsService; // (undocumented) - readonly getAction: >(id: string) => Action, "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK">; + readonly getAction: >(id: string) => Action, "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK">; // Warning: (ae-forgotten-export) The symbol "TriggerContract" needs to be exported by the entry point index.d.ts // // (undocumented) readonly getTrigger: (triggerId: T) => TriggerContract; // (undocumented) - readonly getTriggerActions: (triggerId: T) => Action[]; + readonly getTriggerActions: (triggerId: T) => Action[]; // (undocumented) - readonly getTriggerCompatibleActions: (triggerId: T, context: TriggerContextMapping[T]) => Promise[]>; + readonly getTriggerCompatibleActions: (triggerId: T, context: TriggerContextMapping[T]) => Promise[]>; // (undocumented) readonly hasAction: (actionId: string) => boolean; // Warning: (ae-forgotten-export) The symbol "ActionContext" needs to be exported by the entry point index.d.ts // // (undocumented) - readonly registerAction:
    >(definition: A) => Action, "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK">; + readonly registerAction: >(definition: A) => Action, "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK">; // (undocumented) readonly registerTrigger: (trigger: Trigger) => void; // Warning: (ae-forgotten-export) The symbol "TriggerRegistry" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index b00f4628ffb96..0be3c19fc1c4d 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -57,10 +57,12 @@ export interface TriggerContextMapping { const DEFAULT_ACTION = ''; export const ACTION_VISUALIZE_FIELD = 'ACTION_VISUALIZE_FIELD'; export const ACTION_VISUALIZE_GEO_FIELD = 'ACTION_VISUALIZE_GEO_FIELD'; +export const ACTION_VISUALIZE_LENS_FIELD = 'ACTION_VISUALIZE_LENS_FIELD'; export type ActionType = keyof ActionContextMapping; export interface ActionContextMapping { [DEFAULT_ACTION]: BaseContext; [ACTION_VISUALIZE_FIELD]: VisualizeFieldContext; [ACTION_VISUALIZE_GEO_FIELD]: VisualizeFieldContext; + [ACTION_VISUALIZE_LENS_FIELD]: VisualizeFieldContext; } diff --git a/src/plugins/usage_collection/README.md b/src/plugins/usage_collection/README.md index 9955f9fac81ca..aae633a956c48 100644 --- a/src/plugins/usage_collection/README.md +++ b/src/plugins/usage_collection/README.md @@ -325,3 +325,8 @@ By storing these metrics and their counts as key-value pairs, we can add more me to worry about exceeding the 1000-field soft limit in Elasticsearch. The only caveat is that it makes it harder to consume in Kibana when analysing each entry in the array separately. In the telemetry team we are working to find a solution to this. + +# Routes registered by this plugin + +- `/api/ui_metric/report`: Used by `ui_metrics` usage collector instances to report their usage data to the server +- `/api/stats`: Get the metrics and usage ([details](./server/routes/stats/README.md)) diff --git a/src/plugins/usage_collection/server/collector/collector_set.ts b/src/plugins/usage_collection/server/collector/collector_set.ts index 6861be7f4f76b..7bf4e19c72cc0 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.ts @@ -76,23 +76,27 @@ export class CollectorSet { }; public areAllCollectorsReady = async (collectorSet: CollectorSet = this) => { - // Kept this for runtime validation in JS code. if (!(collectorSet instanceof CollectorSet)) { throw new Error( `areAllCollectorsReady method given bad collectorSet parameter: ` + typeof collectorSet ); } - const collectorTypesNotReady = ( - await Promise.all( - [...collectorSet.collectors.values()].map(async (collector) => { - if (!(await collector.isReady())) { - return collector.type; - } - }) - ) - ).filter((collectorType): collectorType is string => !!collectorType); - const allReady = collectorTypesNotReady.length === 0; + const collectors = [...collectorSet.collectors.values()]; + const collectorsWithStatus = await Promise.all( + collectors.map(async (collector) => { + return { + isReady: await collector.isReady(), + collector, + }; + }) + ); + + const collectorsTypesNotReady = collectorsWithStatus + .filter((collectorWithStatus) => collectorWithStatus.isReady === false) + .map((collectorWithStatus) => collectorWithStatus.collector.type); + + const allReady = collectorsTypesNotReady.length === 0; if (!allReady && this.maximumWaitTimeForAllCollectorsInS >= 0) { const nowTimestamp = +new Date(); @@ -102,10 +106,11 @@ export class CollectorSet { const timeLeftInMS = this.maximumWaitTimeForAllCollectorsInS * 1000 - timeWaitedInMS; if (timeLeftInMS <= 0) { this.logger.debug( - `All collectors are not ready (waiting for ${collectorTypesNotReady.join(',')}) ` + + `All collectors are not ready (waiting for ${collectorsTypesNotReady.join(',')}) ` + `but we have waited the required ` + `${this.maximumWaitTimeForAllCollectorsInS}s and will return data from all collectors that are ready.` ); + return true; } else { this.logger.debug(`All collectors are not ready. Waiting for ${timeLeftInMS}ms longer.`); diff --git a/src/plugins/usage_collection/server/routes/stats/README.md b/src/plugins/usage_collection/server/routes/stats/README.md new file mode 100644 index 0000000000000..09dabefbab44a --- /dev/null +++ b/src/plugins/usage_collection/server/routes/stats/README.md @@ -0,0 +1,20 @@ +# `/api/stats` + +This API returns the metrics for the Kibana server and usage stats. It allows the [Metricbeat Kibana module](https://www.elastic.co/guide/en/beats/metricbeat/current/metricbeat-module-kibana.html) to collect the [stats metricset](https://www.elastic.co/guide/en/beats/metricbeat/current/metricbeat-metricset-kibana-stats.html). + +By default, it returns the simplest level of stats; consisting of the Kibana server's ops metrics, version, status, and basic config like the server name, host, port, and locale. + +However, the information detailed above can be extended, with the combination of the following 3 query parameters: + +| Query Parameter | Default value | Description | +|:----------------|:-------------:|:------------| +|`extended`|`false`|When `true`, it adds `clusterUuid` and `usage`. The latter contains the information reported by all the Usage Collectors registered in the Kibana server. It may throw `503 Stats not ready` if any of the collectors is not fully initialized yet.| +|`legacy`|`false`|By default, when `extended=true`, the key names of the data in `usage` are transformed into API-friendlier `snake_case` format (i.e.: `clusterUuid` is transformed to `cluster_uuid`). When this parameter is `true`, the data is returned as-is.| +|`exclude_usage`|`false`|When `true`, and `extended=true`, it will report `clusterUuid` but no `usage`.| + +## Known use cases + +Metricbeat Kibana' stats metricset ([code](https://github.com/elastic/beats/blob/master/metricbeat/module/kibana/stats/stats.go)) uses this API to collect the metrics (every 10s) and usage (only once every 24h), and then reports them to the Monitoring cluster. They call this API in 2 ways: + +1. Metrics-only collection (every 10 seconds): `GET /api/stats?extended=true&legacy=true&exclude_usage=true` +2. Metrics+usage (every 24 hours): `GET /api/stats?extended=true&legacy=true&exclude_usage=false` diff --git a/src/plugins/usage_collection/server/routes/stats/index.ts b/src/plugins/usage_collection/server/routes/stats/index.ts new file mode 100644 index 0000000000000..8871ee599e56b --- /dev/null +++ b/src/plugins/usage_collection/server/routes/stats/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { registerStatsRoute } from './stats'; diff --git a/src/plugins/usage_collection/server/routes/stats.ts b/src/plugins/usage_collection/server/routes/stats/stats.ts similarity index 91% rename from src/plugins/usage_collection/server/routes/stats.ts rename to src/plugins/usage_collection/server/routes/stats/stats.ts index ef5da2eb11ba6..bee25fef669f1 100644 --- a/src/plugins/usage_collection/server/routes/stats.ts +++ b/src/plugins/usage_collection/server/routes/stats/stats.ts @@ -30,8 +30,8 @@ import { MetricsServiceSetup, ServiceStatus, ServiceStatusLevels, -} from '../../../../core/server'; -import { CollectorSet } from '../collector'; +} from '../../../../../core/server'; +import { CollectorSet } from '../../collector'; const STATS_NOT_READY_MESSAGE = i18n.translate('usageCollection.stats.notReadyMessage', { defaultMessage: 'Stats are not ready yet. Please try again later.', @@ -101,10 +101,12 @@ export function registerStatsRoute({ if (isExtended) { const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser; const esClient = context.core.elasticsearch.client.asCurrentUser; - const collectorsReady = await collectorSet.areAllCollectorsReady(); - if (shouldGetUsage && !collectorsReady) { - return res.customError({ statusCode: 503, body: { message: STATS_NOT_READY_MESSAGE } }); + if (shouldGetUsage) { + const collectorsReady = await collectorSet.areAllCollectorsReady(); + if (!collectorsReady) { + return res.customError({ statusCode: 503, body: { message: STATS_NOT_READY_MESSAGE } }); + } } const usagePromise = shouldGetUsage ? getUsage(callCluster, esClient) : Promise.resolve({}); @@ -152,9 +154,8 @@ export function registerStatsRoute({ } } - // Guranteed to resolve immediately due to replay effect on getOpsMetrics$ - // eslint-disable-next-line @typescript-eslint/naming-convention - const { collected_at, ...lastMetrics } = await metrics + // Guaranteed to resolve immediately due to replay effect on getOpsMetrics$ + const { collected_at: collectedAt, ...lastMetrics } = await metrics .getOpsMetrics$() .pipe(first()) .toPromise(); @@ -173,7 +174,7 @@ export function registerStatsRoute({ snapshot: SNAPSHOT_REGEX.test(config.kibanaVersion), status: ServiceStatusToLegacyState[overallStatus.level.toString()], }, - last_updated: collected_at.toISOString(), + last_updated: collectedAt.toISOString(), collection_interval_in_millis: metrics.collectionInterval, }); diff --git a/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx b/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx index c0a6b48794970..d6b69a769e0a3 100644 --- a/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx +++ b/src/plugins/vis_default_editor/public/components/sidebar/sidebar.tsx @@ -18,7 +18,7 @@ */ import React, { useMemo, useState, useCallback, KeyboardEventHandler, useEffect } from 'react'; -import { get, isEqual } from 'lodash'; +import { isEqual } from 'lodash'; import { i18n } from '@kbn/i18n'; import { keys, EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { EventEmitter } from 'events'; @@ -71,7 +71,7 @@ function DefaultEditorSideBar({ ]); const metricSchemas = (vis.type.schemas.metrics || []).map((s: Schema) => s.name); const metricAggs = useMemo( - () => responseAggs.filter((agg) => metricSchemas.includes(get(agg, 'schema'))), + () => responseAggs.filter((agg) => agg.schema && metricSchemas.includes(agg.schema)), [responseAggs, metricSchemas] ); const hasHistogramAgg = useMemo(() => responseAggs.some((agg) => agg.type.name === 'histogram'), [ diff --git a/src/plugins/vis_type_metric/public/to_ast.ts b/src/plugins/vis_type_metric/public/to_ast.ts index 7eefd8328ab76..23e4664b82414 100644 --- a/src/plugins/vis_type_metric/public/to_ast.ts +++ b/src/plugins/vis_type_metric/public/to_ast.ts @@ -39,7 +39,7 @@ export const toExpressionAst = (vis: Vis, params: any) => { const esaggs = buildExpressionFunction('esaggs', { index: vis.data.indexPattern!.id!, metricsAtAllLevels: vis.isHierarchical(), - partialRows: vis.type.requiresPartialRows || vis.params.showPartialRows || false, + partialRows: vis.params.showPartialRows || false, aggConfigs: JSON.stringify(vis.data.aggs!.aggs), includeFormatHints: false, }); diff --git a/src/plugins/vis_type_table/public/table_vis_controller.test.ts b/src/plugins/vis_type_table/public/table_vis_controller.test.ts index 2b4017ae0ee81..035ca044137e9 100644 --- a/src/plugins/vis_type_table/public/table_vis_controller.test.ts +++ b/src/plugins/vis_type_table/public/table_vis_controller.test.ts @@ -249,13 +249,13 @@ describe('Table Vis - Controller', () => { const vis = getRangeVis({ showPartialRows: true }); initController(vis); - expect(vis.type.hierarchicalData(vis)).toEqual(true); + expect((vis.type.hierarchicalData as Function)(vis)).toEqual(true); }); test('passes partialRows:false to tabify based on the vis params', () => { const vis = getRangeVis({ showPartialRows: false }); initController(vis); - expect(vis.type.hierarchicalData(vis)).toEqual(false); + expect((vis.type.hierarchicalData as Function)(vis)).toEqual(false); }); }); diff --git a/src/plugins/vis_type_table/public/table_vis_type.ts b/src/plugins/vis_type_table/public/table_vis_type.ts index c1419a4847458..95f4f06ee6111 100644 --- a/src/plugins/vis_type_table/public/table_vis_type.ts +++ b/src/plugins/vis_type_table/public/table_vis_type.ts @@ -20,7 +20,7 @@ import { CoreSetup, PluginInitializerContext } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { AggGroupNames } from '../../data/public'; import { Schemas } from '../../vis_default_editor/public'; -import { BaseVisTypeOptions, Vis } from '../../visualizations/public'; +import { BaseVisTypeOptions } from '../../visualizations/public'; import { tableVisResponseHandler } from './table_vis_response_handler'; // @ts-ignore import tableVisTemplate from './table_vis.html'; @@ -99,7 +99,7 @@ export function getTableVisTypeDefinition( ]), }, responseHandler: tableVisResponseHandler, - hierarchicalData: (vis: Vis) => { + hierarchicalData: (vis) => { return Boolean(vis.params.showPartialRows || vis.params.showMetricsAtAllLevels); }, }; diff --git a/src/plugins/vis_type_table/public/vis_controller.ts b/src/plugins/vis_type_table/public/vis_controller.ts index 5e82796e66339..1781808660260 100644 --- a/src/plugins/vis_type_table/public/vis_controller.ts +++ b/src/plugins/vis_type_table/public/vis_controller.ts @@ -103,7 +103,9 @@ export function getTableVisualizationControllerClass( this.$scope = this.$rootScope.$new(); this.$scope.uiState = this.vis.getUiState(); updateScope(); - this.el.find('div').append(this.$compile(this.vis.type!.visConfig.template)(this.$scope)); + this.el + .find('div') + .append(this.$compile(this.vis.type.visConfig?.template ?? '')(this.$scope)); this.$scope.$apply(); } else { updateScope(); diff --git a/src/plugins/vis_type_tagcloud/public/to_ast.ts b/src/plugins/vis_type_tagcloud/public/to_ast.ts index a284bba307348..876784cc10140 100644 --- a/src/plugins/vis_type_tagcloud/public/to_ast.ts +++ b/src/plugins/vis_type_tagcloud/public/to_ast.ts @@ -38,7 +38,7 @@ export const toExpressionAst = (vis: Vis, params: BuildPipeli const esaggs = buildExpressionFunction('esaggs', { index: vis.data.indexPattern!.id!, metricsAtAllLevels: vis.isHierarchical(), - partialRows: vis.type.requiresPartialRows || false, + partialRows: false, aggConfigs: JSON.stringify(vis.data.aggs!.aggs), includeFormatHints: false, }); diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.js b/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.js index c1d7aa9d40bd9..146e7a4bae15a 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.js @@ -25,6 +25,7 @@ export function getSupportedFieldsByMetricType(type) { case METRIC_TYPES.CARDINALITY: return Object.values(KBN_FIELD_TYPES).filter((t) => t !== KBN_FIELD_TYPES.HISTOGRAM); case METRIC_TYPES.VALUE_COUNT: + return Object.values(KBN_FIELD_TYPES); case METRIC_TYPES.AVERAGE: case METRIC_TYPES.SUM: return [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.HISTOGRAM]; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js b/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js index 3cd3fac191bf1..4aed5348c0c18 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js @@ -18,18 +18,23 @@ */ import { getSupportedFieldsByMetricType } from './get_supported_fields_by_metric_type'; +import { KBN_FIELD_TYPES } from '../../../../../../plugins/data/public'; describe('getSupportedFieldsByMetricType', () => { const shouldHaveHistogramAndNumbers = (type) => it(`should return numbers and histogram for ${type}`, () => { expect(getSupportedFieldsByMetricType(type)).toEqual(['number', 'histogram']); }); + const shouldSupportAllFieldTypes = (type) => + it(`should return all field types for ${type}`, () => { + expect(getSupportedFieldsByMetricType(type)).toEqual(Object.values(KBN_FIELD_TYPES)); + }); const shouldHaveOnlyNumbers = (type) => it(`should return only numbers for ${type}`, () => { expect(getSupportedFieldsByMetricType(type)).toEqual(['number']); }); - shouldHaveHistogramAndNumbers('value_count'); + shouldSupportAllFieldTypes('value_count'); shouldHaveHistogramAndNumbers('avg'); shouldHaveHistogramAndNumbers('sum'); diff --git a/src/plugins/vis_type_vega/public/components/experimental_map_vis_info.tsx b/src/plugins/vis_type_vega/public/components/experimental_map_vis_info.tsx new file mode 100644 index 0000000000000..4f8bc50bb1b3b --- /dev/null +++ b/src/plugins/vis_type_vega/public/components/experimental_map_vis_info.tsx @@ -0,0 +1,71 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { parse } from 'hjson'; +import React from 'react'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Vis } from '../../../visualizations/public'; + +function ExperimentalMapLayerInfo() { + const title = ( + + GitHub + + ), + }} + /> + ); + + return ( + + ); +} + +export const getInfoMessage = (vis: Vis) => { + if (vis.params.spec) { + try { + const spec = parse(vis.params.spec, { legacyRoot: false, keepWsc: true }); + + if (spec.config?.kibana?.type === 'map') { + return ; + } + } catch (e) { + // spec is invalid + } + } + + return null; +}; diff --git a/src/plugins/vis_type_vega/public/vega_type.ts b/src/plugins/vis_type_vega/public/vega_type.ts index 46fd2fbc5587e..0496f765e5e99 100644 --- a/src/plugins/vis_type_vega/public/vega_type.ts +++ b/src/plugins/vis_type_vega/public/vega_type.ts @@ -18,6 +18,7 @@ */ import { i18n } from '@kbn/i18n'; +import { BaseVisTypeOptions } from 'src/plugins/visualizations/public'; import { DefaultEditorSize } from '../../vis_default_editor/public'; import { VegaVisualizationDependencies } from './plugin'; import { VegaVisEditor } from './components'; @@ -29,13 +30,18 @@ import { getDefaultSpec } from './default_spec'; import { createInspectorAdapters } from './vega_inspector'; import { VIS_EVENT_TO_TRIGGER } from '../../visualizations/public'; -export const createVegaTypeDefinition = (dependencies: VegaVisualizationDependencies) => { +import { getInfoMessage } from './components/experimental_map_vis_info'; + +export const createVegaTypeDefinition = ( + dependencies: VegaVisualizationDependencies +): BaseVisTypeOptions => { const requestHandler = createVegaRequestHandler(dependencies); const visualization = createVegaVisualization(dependencies); return { name: 'vega', title: 'Vega', + getInfoMessage, description: i18n.translate('visTypeVega.type.vegaDescription', { defaultMessage: 'Create custom visualizations using Vega and Vega-Lite', description: 'Vega and Vega-Lite are product names and should not be translated', diff --git a/src/plugins/vis_type_vislib/public/components/options/metrics_axes/index.test.tsx b/src/plugins/vis_type_vislib/public/components/options/metrics_axes/index.test.tsx index 524792d1460fe..0cc737f19e5c6 100644 --- a/src/plugins/vis_type_vislib/public/components/options/metrics_axes/index.test.tsx +++ b/src/plugins/vis_type_vislib/public/components/options/metrics_axes/index.test.tsx @@ -134,34 +134,6 @@ describe('MetricsAxisOptions component', () => { const updatedSeries = [{ ...chart, data: { id: agg.id, label: agg.makeLabel() } }]; expect(setValue).toHaveBeenCalledWith(SERIES_PARAMS, updatedSeries); }); - - it('should update visType when one seriesParam', () => { - const comp = mount(); - expect(defaultProps.vis.type.type).toBe(ChartTypes.AREA); - - comp.setProps({ - stateParams: { - ...defaultProps.stateParams, - seriesParams: [{ ...chart, type: ChartTypes.LINE }], - }, - }); - - expect(defaultProps.vis.setState).toHaveBeenLastCalledWith({ type: ChartTypes.LINE }); - }); - - it('should set histogram visType when multiple seriesParam', () => { - const comp = mount(); - expect(defaultProps.vis.type.type).toBe(ChartTypes.AREA); - - comp.setProps({ - stateParams: { - ...defaultProps.stateParams, - seriesParams: [chart, { ...chart, type: ChartTypes.LINE }], - }, - }); - - expect(defaultProps.vis.setState).toHaveBeenLastCalledWith({ type: ChartTypes.HISTOGRAM }); - }); }); describe('updateAxisTitle', () => { diff --git a/src/plugins/vis_type_vislib/public/components/options/metrics_axes/index.tsx b/src/plugins/vis_type_vislib/public/components/options/metrics_axes/index.tsx index d885f8fb0b12f..18687404b9114 100644 --- a/src/plugins/vis_type_vislib/public/components/options/metrics_axes/index.tsx +++ b/src/plugins/vis_type_vislib/public/components/options/metrics_axes/index.tsx @@ -18,7 +18,7 @@ */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { cloneDeep, uniq, get } from 'lodash'; +import { cloneDeep, get } from 'lodash'; import { EuiSpacer } from '@elastic/eui'; import { IAggConfig } from 'src/plugins/data/public'; @@ -293,15 +293,6 @@ function MetricsAxisOptions(props: ValidationVisOptionsProps) updateAxisTitle(updatedSeries); }, [metrics, firstValueAxesId, setValue, stateParams.seriesParams, updateAxisTitle]); - const visType = useMemo(() => { - const types = uniq(stateParams.seriesParams.map(({ type }) => type)); - return types.length === 1 ? types[0] : 'histogram'; - }, [stateParams.seriesParams]); - - useEffect(() => { - vis.setState({ ...vis.serialize(), type: visType }); - }, [vis, visType]); - return isTabSelected ? ( <> ) { const { stateParams, setValue, vis } = props; + const currentChartTypes = useMemo(() => uniq(stateParams.seriesParams.map(({ type }) => type)), [ + stateParams.seriesParams, + ]); + return ( <> @@ -68,7 +73,7 @@ function PointSeriesOptions(props: ValidationVisOptionsProps) /> )} - {vis.type.name === ChartTypes.HISTOGRAM && ( + {currentChartTypes.includes(ChartTypes.HISTOGRAM) && ( { const { vis } = this.props; const Visualization = vis.type.visualization; + if (!Visualization) { + throw new Error( + 'Tried to use VisualizationChart component with a vis without visualization property.' + ); + } + this.visualization = new Visualization(this.chartDiv.current, vis); // We know that containerDiv.current will never be null, since we will always diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx index 75e53e8e92dbe..87f78f5639ff0 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx @@ -18,7 +18,7 @@ */ import { i18n } from '@kbn/i18n'; -import { SavedObjectMetaData } from 'src/plugins/saved_objects/public'; +import { SavedObjectMetaData, OnSaveProps } from 'src/plugins/saved_objects/public'; import { first } from 'rxjs/operators'; import { SavedObjectAttributes } from '../../../../core/public'; import { @@ -51,6 +51,7 @@ import { StartServicesGetter } from '../../../kibana_utils/public'; import { VisualizationsStartDeps } from '../plugin'; import { VISUALIZE_ENABLE_LABS_SETTING } from '../../common/constants'; import { AttributeService } from '../../../dashboard/public'; +import { checkForDuplicateTitle } from '../../../saved_objects/public'; interface VisualizationAttributes extends SavedObjectAttributes { visState: string; @@ -58,7 +59,7 @@ interface VisualizationAttributes extends SavedObjectAttributes { export interface VisualizeEmbeddableFactoryDeps { start: StartServicesGetter< - Pick + Pick >; } @@ -129,7 +130,10 @@ export class VisualizeEmbeddableFactory VisualizeSavedObjectAttributes, VisualizeByValueInput, VisualizeByReferenceInput - >(this.type, { customSaveMethod: this.onSave }); + >(this.type, { + saveMethod: this.saveMethod.bind(this), + checkForDuplicateTitle: this.checkTitle.bind(this), + }); } return this.attributeService!; } @@ -183,7 +187,7 @@ export class VisualizeEmbeddableFactory } } - private async onSave( + private async saveMethod( type: string, attributes: VisualizeSavedObjectAttributes ): Promise<{ id: string }> { @@ -225,4 +229,24 @@ export class VisualizeEmbeddableFactory throw error; } } + + public async checkTitle(props: OnSaveProps): Promise { + const savedObjectsClient = await this.deps.start().core.savedObjects.client; + const overlays = await this.deps.start().core.overlays; + return checkForDuplicateTitle( + { + title: props.newTitle, + copyOnSave: false, + lastSavedTitle: '', + getEsType: () => this.type, + getDisplayName: this.getDisplayName || (() => this.type), + }, + props.isTitleDuplicateConfirmed, + props.onTitleDuplicate, + { + savedObjectsClient, + overlays, + } + ); + } } diff --git a/src/plugins/visualizations/public/expressions/vis.ts b/src/plugins/visualizations/public/expressions/vis.ts index 5a99dceda20bd..87f77e589c9ca 100644 --- a/src/plugins/visualizations/public/expressions/vis.ts +++ b/src/plugins/visualizations/public/expressions/vis.ts @@ -36,7 +36,7 @@ import { VisType } from '../vis_types'; export interface ExprVisState { title?: string; - type: VisType | string; + type: VisType | string; params?: VisParams; } @@ -52,7 +52,7 @@ export interface ExprVisAPI { export class ExprVis extends EventEmitter { public title: string = ''; - public type: VisType; + public type: VisType; public params: VisParams = {}; public sessionState: Record = {}; public API: ExprVisAPI; @@ -92,7 +92,7 @@ export class ExprVis extends EventEmitter { }; } - private getType(type: string | VisType) { + private getType(type: string | VisType) { if (_.isString(type)) { const newType = getTypes().get(type); if (!newType) { diff --git a/src/plugins/visualizations/public/legacy/build_pipeline.ts b/src/plugins/visualizations/public/legacy/build_pipeline.ts index 79e1c1cca2155..9f6a4d5553292 100644 --- a/src/plugins/visualizations/public/legacy/build_pipeline.ts +++ b/src/plugins/visualizations/public/legacy/build_pipeline.ts @@ -86,7 +86,10 @@ const vislibCharts: string[] = [ 'line', ]; -export const getSchemas = (vis: Vis, { timeRange, timefilter }: BuildPipelineParams): Schemas => { +export const getSchemas = ( + vis: Vis, + { timeRange, timefilter }: BuildPipelineParams +): Schemas => { const createSchemaConfig = (accessor: number, agg: IAggConfig): SchemaConfig => { if (isDateHistogramBucketAggConfig(agg)) { agg.params.timeRange = timeRange; @@ -155,7 +158,8 @@ export const getSchemas = (vis: Vis, { timeRange, timefilter }: BuildPipelinePar } } if (schemaName === 'split') { - schemaName = `split_${vis.params.row ? 'row' : 'column'}`; + // TODO: We should check if there's a better way then casting to `any` here + schemaName = `split_${(vis.params as any).row ? 'row' : 'column'}`; skipMetrics = responseAggs.length - metrics.length > 1; } if (!schemas[schemaName]) { @@ -410,7 +414,7 @@ export const buildPipeline = async (vis: Vis, params: BuildPipelineParams) => { pipeline += `esaggs ${prepareString('index', indexPattern!.id)} metricsAtAllLevels=${vis.isHierarchical()} - partialRows=${vis.type.requiresPartialRows || vis.params.showPartialRows || false} + partialRows=${vis.params.showPartialRows || false} ${prepareJson('aggConfigs', vis.data.aggs!.aggs)} | `; } @@ -433,7 +437,7 @@ export const buildPipeline = async (vis: Vis, params: BuildPipelineParams) => { pipeline += `visualization type='${vis.type.name}' ${prepareJson('visConfig', visConfig)} metricsAtAllLevels=${vis.isHierarchical()} - partialRows=${vis.type.requiresPartialRows || vis.params.showPartialRows || false} `; + partialRows=${vis.params.showPartialRows || false} `; if (indexPattern) { pipeline += `${prepareString('index', indexPattern.id)} `; if (vis.data.aggs) { diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index 646acc49a6a83..90e4936a58b45 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -72,6 +72,7 @@ const createInstance = async () => { embeddable: embeddablePluginMock.createStartContract(), dashboard: dashboardPluginMock.createStartContract(), getAttributeService: jest.fn(), + savedObjectsClient: coreMock.createStart().savedObjects.client, }); return { diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index 0ba80887b513f..37a9972983421 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -25,6 +25,7 @@ import { CoreStart, Plugin, ApplicationStart, + SavedObjectsClientContract, } from '../../../core/public'; import { TypesService, TypesSetup, TypesStart } from './vis_types'; import { @@ -112,6 +113,7 @@ export interface VisualizationsStartDeps { application: ApplicationStart; dashboard: DashboardStart; getAttributeService: DashboardStart['getAttributeService']; + savedObjectsClient: SavedObjectsClientContract; } /** diff --git a/src/plugins/visualizations/public/vis.test.ts b/src/plugins/visualizations/public/vis.test.ts index c271888b7c7a4..e1b188f2e460b 100644 --- a/src/plugins/visualizations/public/vis.test.ts +++ b/src/plugins/visualizations/public/vis.test.ts @@ -121,7 +121,7 @@ describe('Vis Class', function () { }); it('should return true for hierarchical vis (like pie)', function () { - vis.type.hierarchicalData = true; + (vis.type as any).hierarchicalData = true; expect(vis.isHierarchical()).toBe(true); }); }); diff --git a/src/plugins/visualizations/public/vis.ts b/src/plugins/visualizations/public/vis.ts index c6773e5a1bee3..5c3233a8de896 100644 --- a/src/plugins/visualizations/public/vis.ts +++ b/src/plugins/visualizations/public/vis.ts @@ -84,7 +84,7 @@ const getSearchSource = async (inputSearchSource: ISearchSource, savedSearchId?: type PartialVisState = Assign }>; export class Vis { - public readonly type: VisType; + public readonly type: VisType; public readonly id?: string; public title: string = ''; public description: string = ''; @@ -97,14 +97,14 @@ export class Vis { public readonly uiState: PersistedState; constructor(visType: string, visState: SerializedVis = {} as any) { - this.type = this.getType(visType); + this.type = this.getType(visType); this.params = this.getParams(visState.params); this.uiState = new PersistedState(visState.uiState); this.id = visState.id; } - private getType(visType: string) { - const type = getTypes().get(visType); + private getType(visType: string) { + const type = getTypes().get(visType); if (!type) { const errorMessage = i18n.translate('visualizations.visualizationTypeInvalidMessage', { defaultMessage: 'Invalid visualization type "{visType}"', @@ -118,7 +118,7 @@ export class Vis { } private getParams(params: VisParams) { - return defaults({}, cloneDeep(params || {}), cloneDeep(this.type.visConfig.defaults || {})); + return defaults({}, cloneDeep(params ?? {}), cloneDeep(this.type.visConfig?.defaults ?? {})); } async setState(state: PartialVisState) { @@ -202,10 +202,6 @@ export class Vis { }; } - toExpressionAst() { - return this.type.toExpressionAst(this.params); - } - // deprecated isHierarchical() { if (isFunction(this.type.hierarchicalData)) { diff --git a/src/plugins/visualizations/public/vis_types/base_vis_type.ts b/src/plugins/visualizations/public/vis_types/base_vis_type.ts index de1afc254e0d3..f2933de723a39 100644 --- a/src/plugins/visualizations/public/vis_types/base_vis_type.ts +++ b/src/plugins/visualizations/public/vis_types/base_vis_type.ts @@ -17,118 +17,113 @@ * under the License. */ -import _ from 'lodash'; -import { ReactElement } from 'react'; -import { VisParams, VisToExpressionAst, VisualizationControllerConstructor } from '../types'; -import { TriggerContextMapping } from '../../../ui_actions/public'; -import { Adapters } from '../../../inspector/public'; -import { Vis } from '../vis'; +import { defaultsDeep } from 'lodash'; +import { ISchemas } from 'src/plugins/vis_default_editor/public'; +import { VisParams } from '../types'; +import { VisType, VisTypeOptions } from './types'; -interface CommonBaseVisTypeOptions { - name: string; - title: string; - description?: string; - getSupportedTriggers?: () => Array; - icon?: string; - image?: string; - stage?: 'experimental' | 'beta' | 'production'; - options?: Record; - visConfig?: Record; - editor?: any; - editorConfig?: Record; - hidden?: boolean; - requestHandler?: string | unknown; - responseHandler?: string | unknown; - hierarchicalData?: boolean | unknown; - setup?: unknown; - useCustomNoDataScreen?: boolean; - inspectorAdapters?: Adapters | (() => Adapters); - isDeprecated?: boolean; - getDeprecationMessage?: (vis: Vis) => ReactElement<{}>; +interface CommonBaseVisTypeOptions + extends Pick< + VisType, + | 'description' + | 'editor' + | 'getInfoMessage' + | 'getSupportedTriggers' + | 'hierarchicalData' + | 'icon' + | 'image' + | 'inspectorAdapters' + | 'name' + | 'requestHandler' + | 'responseHandler' + | 'setup' + | 'title' + >, + Pick< + Partial>, + 'editorConfig' | 'hidden' | 'stage' | 'useCustomNoDataScreen' | 'visConfig' + > { + options?: Partial['options']>; } -interface ExpressionBaseVisTypeOptions extends CommonBaseVisTypeOptions { - toExpressionAst: VisToExpressionAst; +interface ExpressionBaseVisTypeOptions extends CommonBaseVisTypeOptions { + toExpressionAst: VisType['toExpressionAst']; visualization?: undefined; } -interface VisualizationBaseVisTypeOptions extends CommonBaseVisTypeOptions { +interface VisualizationBaseVisTypeOptions extends CommonBaseVisTypeOptions { toExpressionAst?: undefined; - visualization: VisualizationControllerConstructor | undefined; + visualization: VisType['visualization']; } export type BaseVisTypeOptions = | ExpressionBaseVisTypeOptions - | VisualizationBaseVisTypeOptions; + | VisualizationBaseVisTypeOptions; -export class BaseVisType { - name: string; - title: string; - description: string; - getSupportedTriggers?: () => Array; - icon?: string; - image?: string; - stage: 'experimental' | 'beta' | 'production'; - isExperimental: boolean; - options: Record; - visualization: VisualizationControllerConstructor | undefined; - visConfig: Record; - editor: any; - editorConfig: Record; - hidden: boolean; - requiresSearch: boolean; - requestHandler: string | unknown; - responseHandler: string | unknown; - hierarchicalData: boolean | unknown; - setup?: unknown; - useCustomNoDataScreen: boolean; - inspectorAdapters?: Adapters | (() => Adapters); - toExpressionAst?: VisToExpressionAst; - getDeprecationMessage?: (vis: Vis) => ReactElement<{}>; +const defaultOptions: VisTypeOptions = { + showTimePicker: true, + showQueryBar: true, + showFilterBar: true, + showIndexSelection: true, + hierarchicalData: false, // we should get rid of this i guess ? +}; + +export class BaseVisType implements VisType { + public readonly name; + public readonly title; + public readonly description; + public readonly getSupportedTriggers; + public readonly icon; + public readonly image; + public readonly stage; + public readonly options; + public readonly visualization; + public readonly visConfig; + public readonly editor; + public readonly editorConfig; + public hidden; + public readonly requestHandler; + public readonly responseHandler; + public readonly hierarchicalData; + public readonly setup; + public readonly useCustomNoDataScreen; + public readonly inspectorAdapters; + public readonly toExpressionAst; + public readonly getInfoMessage; constructor(opts: BaseVisTypeOptions) { if (!opts.icon && !opts.image) { throw new Error('vis_type must define its icon or image'); } - const defaultOptions = { - // controls the visualize editor - showTimePicker: true, - showQueryBar: true, - showFilterBar: true, - showIndexSelection: true, - hierarchicalData: false, // we should get rid of this i guess ? - }; - this.name = opts.name; - this.description = opts.description || ''; + this.description = opts.description ?? ''; this.getSupportedTriggers = opts.getSupportedTriggers; this.title = opts.title; this.icon = opts.icon; this.image = opts.image; this.visualization = opts.visualization; - this.visConfig = _.defaultsDeep({}, opts.visConfig, { defaults: {} }); + this.visConfig = defaultsDeep({}, opts.visConfig, { defaults: {} }); this.editor = opts.editor; - this.editorConfig = _.defaultsDeep({}, opts.editorConfig, { collections: {} }); - this.options = _.defaultsDeep({}, opts.options, defaultOptions); - this.stage = opts.stage || 'production'; - this.isExperimental = opts.stage === 'experimental'; - this.hidden = opts.hidden || false; - this.requestHandler = opts.requestHandler || 'courier'; - this.responseHandler = opts.responseHandler || 'none'; + this.editorConfig = defaultsDeep({}, opts.editorConfig, { collections: {} }); + this.options = defaultsDeep({}, opts.options, defaultOptions); + this.stage = opts.stage ?? 'production'; + this.hidden = opts.hidden ?? false; + this.requestHandler = opts.requestHandler ?? 'courier'; + this.responseHandler = opts.responseHandler ?? 'none'; this.setup = opts.setup; - this.requiresSearch = this.requestHandler !== 'none'; - this.hierarchicalData = opts.hierarchicalData || false; - this.useCustomNoDataScreen = opts.useCustomNoDataScreen || false; + this.hierarchicalData = opts.hierarchicalData ?? false; + this.useCustomNoDataScreen = opts.useCustomNoDataScreen ?? false; this.inspectorAdapters = opts.inspectorAdapters; this.toExpressionAst = opts.toExpressionAst; - this.getDeprecationMessage = opts.getDeprecationMessage; + this.getInfoMessage = opts.getInfoMessage; } - public get schemas() { - if (this.editorConfig && this.editorConfig.schemas) { - return this.editorConfig.schemas; - } - return []; + public get schemas(): ISchemas { + return this.editorConfig?.schemas ?? []; + } + + public get requiresSearch(): boolean { + return this.requestHandler !== 'none'; } } diff --git a/src/plugins/visualizations/public/vis_types/index.ts b/src/plugins/visualizations/public/vis_types/index.ts index 8f38e33569162..22561decabea4 100644 --- a/src/plugins/visualizations/public/vis_types/index.ts +++ b/src/plugins/visualizations/public/vis_types/index.ts @@ -18,5 +18,6 @@ */ export * from './types_service'; +export { VisType } from './types'; export type { BaseVisTypeOptions } from './base_vis_type'; export type { ReactVisTypeOptions } from './react_vis_type'; diff --git a/src/plugins/visualizations/public/vis_types/react_vis_type.ts b/src/plugins/visualizations/public/vis_types/react_vis_type.ts index 047d36d804111..f6bd51df26695 100644 --- a/src/plugins/visualizations/public/vis_types/react_vis_type.ts +++ b/src/plugins/visualizations/public/vis_types/react_vis_type.ts @@ -19,15 +19,21 @@ import { BaseVisType, BaseVisTypeOptions } from './base_vis_type'; import { ReactVisController } from './react_vis_controller'; +import { VisType } from './types'; -export type ReactVisTypeOptions = Omit; +export type ReactVisTypeOptions = Omit< + BaseVisTypeOptions, + 'visualization' | 'toExpressionAst' +>; /** * This class should only be used for visualizations not using the `toExpressionAst` with a custom renderer. * If you implement a custom renderer you should just mount a react component inside this. */ -export class ReactVisType extends BaseVisType { - constructor(opts: ReactVisTypeOptions) { +export class ReactVisType + extends BaseVisType + implements VisType { + constructor(opts: ReactVisTypeOptions) { super({ ...opts, visualization: ReactVisController, diff --git a/src/plugins/visualizations/public/vis_types/types.ts b/src/plugins/visualizations/public/vis_types/types.ts new file mode 100644 index 0000000000000..0cf345bf07be6 --- /dev/null +++ b/src/plugins/visualizations/public/vis_types/types.ts @@ -0,0 +1,80 @@ +/* + * 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 { IconType } from '@elastic/eui'; +import React from 'react'; +import { Adapters } from 'src/plugins/inspector'; +import { ISchemas } from 'src/plugins/vis_default_editor/public'; +import { TriggerContextMapping } from '../../../ui_actions/public'; +import { Vis, VisToExpressionAst, VisualizationControllerConstructor } from '../types'; + +export interface VisTypeOptions { + showTimePicker: boolean; + showQueryBar: boolean; + showFilterBar: boolean; + showIndexSelection: boolean; + hierarchicalData: boolean; +} + +/** + * A visualization type representing one specific type of "classical" + * visualizations (i.e. not Lens visualizations). + */ +export interface VisType { + readonly name: string; + readonly title: string; + readonly description?: string; + readonly getSupportedTriggers?: () => Array; + readonly isAccessible?: boolean; + readonly requestHandler?: string | unknown; + readonly responseHandler?: string | unknown; + readonly icon?: IconType; + readonly image?: string; + readonly stage: 'experimental' | 'beta' | 'production'; + readonly requiresSearch: boolean; + readonly useCustomNoDataScreen: boolean; + readonly hierarchicalData?: boolean | ((vis: { params: TVisParams }) => boolean); + readonly inspectorAdapters?: Adapters | (() => Adapters); + /** + * When specified this visualization is deprecated. This function + * should return a ReactElement that will render a deprecation warning. + * It will be shown in the editor when editing/creating visualizations + * of this type. + */ + readonly getInfoMessage?: (vis: Vis) => React.ReactNode; + + readonly toExpressionAst?: VisToExpressionAst; + readonly visualization?: VisualizationControllerConstructor; + + readonly setup?: (vis: Vis) => Promise>; + hidden: boolean; + + readonly schemas: ISchemas; + + readonly options: VisTypeOptions; + + // TODO: The following types still need to be refined properly. + + /** + * The editor that should be used to edit visualizations of this type. + */ + readonly editor?: any; + readonly editorConfig: Record; + readonly visConfig: Record; +} diff --git a/src/plugins/visualizations/public/vis_types/types_service.ts b/src/plugins/visualizations/public/vis_types/types_service.ts index 1afbd6901a195..5d619064c240e 100644 --- a/src/plugins/visualizations/public/vis_types/types_service.ts +++ b/src/plugins/visualizations/public/vis_types/types_service.ts @@ -17,33 +17,10 @@ * under the License. */ -import { IconType } from '@elastic/eui'; import { visTypeAliasRegistry, VisTypeAlias } from './vis_type_alias_registry'; import { BaseVisType, BaseVisTypeOptions } from './base_vis_type'; import { ReactVisType, ReactVisTypeOptions } from './react_vis_type'; -import { TriggerContextMapping } from '../../../ui_actions/public'; - -export interface VisType { - name: string; - title: string; - description?: string; - getSupportedTriggers?: () => Array; - visualization: any; - isAccessible?: boolean; - requestHandler: string | unknown; - responseHandler: string | unknown; - icon?: IconType; - image?: string; - stage: 'experimental' | 'beta' | 'production'; - requiresSearch: boolean; - hidden: boolean; - - // Since we haven't typed everything here yet, we basically "any" the rest - // of that interface. This should be removed as soon as this type definition - // has been completed. But that way we at least have typing for a couple of - // properties on that type. - [key: string]: any; -} +import { VisType } from './types'; /** * Vis Types Service @@ -51,21 +28,21 @@ export interface VisType { * @internal */ export class TypesService { - private types: Record = {}; + private types: Record> = {}; private unregisteredHiddenTypes: string[] = []; - public setup() { - const registerVisualization = (registerFn: () => VisType) => { - const visDefinition = registerFn(); - if (this.unregisteredHiddenTypes.includes(visDefinition.name)) { - visDefinition.hidden = true; - } + private registerVisualization(visDefinition: VisType) { + if (this.unregisteredHiddenTypes.includes(visDefinition.name)) { + visDefinition.hidden = true; + } - if (this.types[visDefinition.name]) { - throw new Error('type already exists!'); - } - this.types[visDefinition.name] = visDefinition; - }; + if (this.types[visDefinition.name]) { + throw new Error('type already exists!'); + } + this.types[visDefinition.name] = visDefinition; + } + + public setup() { return { /** * registers a visualization type @@ -73,15 +50,15 @@ export class TypesService { */ createBaseVisualization: (config: BaseVisTypeOptions): void => { const vis = new BaseVisType(config); - registerVisualization(() => vis); + this.registerVisualization(vis); }, /** * registers a visualization which uses react for rendering * @param config - visualization type definition */ - createReactVisualization: (config: ReactVisTypeOptions): void => { + createReactVisualization: (config: ReactVisTypeOptions): void => { const vis = new ReactVisType(config); - registerVisualization(() => vis); + this.registerVisualization(vis); }, /** * registers a visualization alias @@ -93,7 +70,7 @@ export class TypesService { * allows to hide specific visualization types from create visualization dialog * @param {string[]} typeNames - list of type ids to hide */ - hideTypes: (typeNames: string[]) => { + hideTypes: (typeNames: string[]): void => { typeNames.forEach((name: string) => { if (this.types[name]) { this.types[name].hidden = true; @@ -111,13 +88,13 @@ export class TypesService { * returns specific visualization or undefined if not found * @param {string} visualization - id of visualization to return */ - get: (visualization: string) => { + get: (visualization: string): VisType => { return this.types[visualization]; }, /** * returns all registered visualization types */ - all: () => { + all: (): VisType[] => { return [...Object.values(this.types)]; }, /** diff --git a/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap b/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap index c2a2b27457f8d..2d55059efb5bb 100644 --- a/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap +++ b/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap @@ -238,12 +238,43 @@ exports[`NewVisModal filter for visualization types should render as expected 1` aria-live="polite" class="euiScreenReaderOnly" > - 2 types found + 3 types found
      +
    • + +