diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 9235fc1198b12a..b59545cbb85a64 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -28,9 +28,7 @@ allowing users to configure their advanced settings, also known as uiSettings within the code. -|{kib-repo}blob/{branch}/src/plugins/apm_oss[apmOss] -|WARNING: Missing README. - +|{kib-repo}blob/{branch}/src/plugins/apm_oss/README.asciidoc[apmOss] |{kib-repo}blob/{branch}/src/plugins/bfetch/README.md[bfetch] |bfetch allows to batch HTTP requests and streams responses back. diff --git a/docs/management/images/management-index-patterns.png b/docs/management/images/management-index-patterns.png deleted file mode 100644 index 232d32893b96d3..00000000000000 Binary files a/docs/management/images/management-index-patterns.png and /dev/null differ diff --git a/docs/management/managing-fields.asciidoc b/docs/management/managing-fields.asciidoc index 3734655edd91b3..1b9d22699d359e 100644 --- a/docs/management/managing-fields.asciidoc +++ b/docs/management/managing-fields.asciidoc @@ -1,70 +1,29 @@ [[managing-fields]] -== Index patterns and fields +== Field management -The *Index patterns* UI helps you create and manage -the index patterns that retrieve your data from {es}. +Whenever possible, +{kib} uses the same field type for display as {es}. However, a few field types +{es} supports are not available in {kib}. Use field formatters to customize how your +fields are displayed in Kibana, regardless of how they are stored in {es}. -[role="screenshot"] -image::images/management-index-patterns.png[] - -[float] -=== Required permissions - -The `Index Pattern Management` {kib} privilege is required to access the *Index patterns* UI. - -To add the privilege, open the menu, then click *Stack Management > Roles*. - -[float] -=== Create an index pattern - -An index pattern is the glue that connects {kib} to your {es} data. Create an -index pattern whenever you load your own data into {kib}. To get started, -click *Create index pattern*, and then follow the guided steps. Refer to -<> for the types of index patterns -that you can create. - -[float] -=== Manage your index pattern - -To view the fields and associated data types in an index pattern, click its name in -the *Index patterns* overview. - -[role="screenshot"] -image::management/index-patterns/images/new-index-pattern.png["Index files and data types"] - -Use the icons to perform the following actions: +Kibana provides these field formatters: -* [[set-default-pattern]]*Set the default index pattern.* {kib} uses a badge to make users -aware of which index pattern is the default. The first pattern -you create is automatically designated as the default pattern. The default -index pattern is loaded when you open *Discover*. +* <> +* <> +* <> +* <> -* *Refresh the index fields list.* You can refresh the index fields list to -pick up any newly-added fields. Doing so also resets the {kib} popularity counters -for the fields. The popularity counters are used in *Discover* to sort fields in lists. +To format a field: -* [[delete-pattern]]*Delete the index pattern.* This action removes the pattern from the list of -Saved Objects in {kib}. You will not be able to recover field formatters, -scripted fields, source filters, and field popularity data associated with the index pattern. -Deleting an index pattern does -not remove any indices or data documents from {es}. +. Open the main menu, and click *Stack Management > Index Patterns*. +. Click the index pattern that contains the field you want to format. +. Find the field you want to format and click the edit icon (image:management/index-patterns/images/edit_icon.png[]). +. Select a format and fill in the details. + -WARNING: Deleting an index pattern breaks all visualizations, saved searches, and -other saved objects that reference the pattern. - -[float] -=== Edit a field - -To edit a field's properties, click the edit icon -image:management/index-patterns/images/edit_icon.png[] in the detail view. -You can set the field's format and popularity value. +[role="screenshot"] +image:management/index-patterns/images/edit-field-format.png["Edit field format"] -Kibana has field formatters for the following field types: -* <> -* <> -* <> -* <> [[field-formatters-string]] === String field formatters @@ -121,12 +80,8 @@ WARNING: Computing data on the fly with scripted fields can be very resource int {kib} performance. Keep in mind that there's no built-in validation of a scripted field. If your scripts are buggy, you'll get exceptions whenever you try to view the dynamically generated data. -When you define a scripted field in {kib}, you have a choice of scripting languages. In 5.0 and later, the default -options are {ref}/modules-scripting-expression.html[Lucene expressions] and {ref}/modules-scripting-painless.html[Painless]. -While you can use other scripting languages if you enable dynamic scripting for them in {es}, this is not recommended -because they cannot be sufficiently {ref}/modules-scripting-security.html[sandboxed]. - -WARNING: In 5.0 and later, Groovy, JavaScript, and Python scripting are deprecated and unsupported. +When you define a scripted field in {kib}, you have a choice of the {ref}/modules-scripting-expression.html[Lucene expressions] or the +{ref}/modules-scripting-painless.html[Painless] scripting language. You can reference any single value numeric field in your expressions, for example: diff --git a/package.json b/package.json index 44a0c833eae278..1c218307b35c39 100644 --- a/package.json +++ b/package.json @@ -567,7 +567,7 @@ "@types/zen-observable": "^0.8.0", "@typescript-eslint/eslint-plugin": "^3.10.0", "@typescript-eslint/parser": "^3.10.0", - "@welldone-software/why-did-you-render": "^4.0.0", + "@welldone-software/why-did-you-render": "^5.0.0", "@yarnpkg/lockfile": "^1.1.0", "abab": "^1.0.4", "angular-aria": "^1.8.0", diff --git a/rfcs/text/0013_saved_object_migrations.md b/rfcs/text/0013_saved_object_migrations.md index c5069625cb8a66..1a0967d110d06a 100644 --- a/rfcs/text/0013_saved_object_migrations.md +++ b/rfcs/text/0013_saved_object_migrations.md @@ -212,39 +212,68 @@ Note: If none of the aliases exists, this is a new Elasticsearch cluster and no migrations are necessary. Create the `.kibana_7.10.0_001` index with the following aliases: `.kibana_current` and `.kibana_7.10.0`. -2. If `.kibana_current` and `.kibana_7.10.0` both exists and are pointing to the same index this version's migration has already been completed. +2. If the source is a < v6.5 `.kibana` index or < 7.4 `.kibana_task_manager` + index prepare the legacy index for a migration: + 1. Mark the legacy index as read-only and wait for all in-flight operations to drain (requires https://github.com/elastic/elasticsearch/pull/58094). This prevents any further writes from outdated nodes. Assuming this API is similar to the existing `//_close` API, we expect to receive `"acknowledged" : true` and `"shards_acknowledged" : true`. If all shards don’t acknowledge within the timeout, retry the operation until it succeeds. + 2. Clone the legacy index into a new index which has writes enabled. Use a fixed index name i.e `.kibana_pre6.5.0_001` or `.kibana_task_manager_pre7.4.0_001`. `POST /.kibana/_clone/.kibana_pre6.5.0_001?wait_for_active_shards=all {"settings": {"index.blocks.write": false}}`. Ignore errors if the clone already exists. Ignore errors if the legacy source doesn't exist. + 3. Wait for the cloning to complete `GET /_cluster/health/.kibana_pre6.5.0_001?wait_for_status=green&timeout=60s` If cloning doesn’t complete within the 60s timeout, log a warning for visibility and poll again. + 4. Apply the `convertToAlias` script if defined `POST /.kibana_pre6.5.0_001/_update_by_query?conflicts=proceed {"script": {...}}`. The `convertToAlias` script will have to be idempotent, preferably setting `ctx.op="noop"` on subsequent runs to avoid unecessary writes. + 5. Delete the legacy index and replace it with an alias of the same name + ``` + POST /_aliases + { + "actions" : [ + { "add": { "index": ".kibana_pre6.5.0_001", "alias": ".kibana" } }, + { "remove_index": { "index": ".kibana" } } + ] + } + ```. + Unlike the delete index API, the `remove_index` action will fail if + provided with an _alias_. Ignore "The provided expression [.kibana] + matches an alias, specify the corresponding concrete indices instead." + or "index_not_found_exception" errors. These actions are applied + atomically so that other Kibana instances will always see either a + `.kibana` index or an alias, but never neither. + 6. Use the cloned `.kibana_pre6.5.0_001` as the source for the rest of the migration algorithm. +3. If `.kibana_current` and `.kibana_7.10.0` both exists and are pointing to the same index this version's migration has already been completed. 1. Because the same version can have plugins enabled at any point in time, perform the mappings update in step (6) and migrate outdated documents with step (7). 2. Skip to step (9) to start serving traffic. -3. Fail the migration if: +4. Fail the migration if: 1. `.kibana_current` is pointing to an index that belongs to a later version of Kibana .e.g. `.kibana_7.12.0_001` 2. (Only in 8.x) The source index contains documents that belong to an unknown Saved Object type (from a disabled plugin). Log an error explaining that the plugin that created these documents needs to be enabled again or that these objects should be deleted. See section (4.2.1.4). -4. Mark the source index as read-only and wait for all in-flight operations to drain (requires https://github.com/elastic/elasticsearch/pull/58094). This prevents any further writes from outdated nodes. Assuming this API is similar to the existing `//_close` API, we expect to receive `"acknowledged" : true` and `"shards_acknowledged" : true`. If all shards don’t acknowledge within the timeout, retry the operation until it succeeds. -5. Clone the source index into a new target index which has writes enabled. All nodes on the same version will use the same fixed index name e.g. `.kibana_7.10.0_001`. The `001` postfix isn't used by Kibana, but allows for re-indexing an index should this be required by an Elasticsearch upgrade. E.g. re-index `.kibana_7.10.0_001` into `.kibana_7.10.0_002` and point the `.kibana_7.10.0` alias to `.kibana_7.10.0_002`. +5. Mark the source index as read-only and wait for all in-flight operations to drain (requires https://github.com/elastic/elasticsearch/pull/58094). This prevents any further writes from outdated nodes. Assuming this API is similar to the existing `//_close` API, we expect to receive `"acknowledged" : true` and `"shards_acknowledged" : true`. If all shards don’t acknowledge within the timeout, retry the operation until it succeeds. +6. Clone the source index into a new target index which has writes enabled. All nodes on the same version will use the same fixed index name e.g. `.kibana_7.10.0_001`. The `001` postfix isn't used by Kibana, but allows for re-indexing an index should this be required by an Elasticsearch upgrade. E.g. re-index `.kibana_7.10.0_001` into `.kibana_7.10.0_002` and point the `.kibana_7.10.0` alias to `.kibana_7.10.0_002`. 1. `POST /.kibana_n/_clone/.kibana_7.10.0_001?wait_for_active_shards=all {"settings": {"index.blocks.write": false}}`. Ignore errors if the clone already exists. 2. Wait for the cloning to complete `GET /_cluster/health/.kibana_7.10.0_001?wait_for_status=green&timeout=60s` If cloning doesn’t complete within the 60s timeout, log a warning for visibility and poll again. -6. Update the mappings of the target index +7. Update the mappings of the target index 1. Retrieve the existing mappings including the `migrationMappingPropertyHashes` metadata. 2. Update the mappings with `PUT /.kibana_7.10.0_001/_mapping`. The API deeply merges any updates so this won't remove the mappings of any plugins that were enabled in a previous version but are now disabled. 3. Ensure that fields are correctly indexed using the target index's latest mappings `POST /.kibana_7.10.0_001/_update_by_query?conflicts=proceed`. In the future we could optimize this query by only targeting documents: 1. That belong to a known saved object type. 2. Which don't have outdated migrationVersion numbers since these will be transformed anyway. 3. That belong to a type whose mappings were changed by comparing the `migrationMappingPropertyHashes`. (Metadata, unlike the mappings isn't commutative, so there is a small chance that the metadata hashes do not accurately reflect the latest mappings, however, this will just result in an less efficient query). -7. Transform documents by reading batches of outdated documents from the target index then transforming and updating them with optimistic concurrency control. +8. Transform documents by reading batches of outdated documents from the target index then transforming and updating them with optimistic concurrency control. 1. Ignore any version conflict errors. 2. If a document transform throws an exception, add the document to a failure list and continue trying to transform all other documents. If any failures occured, log the complete list of documents that failed to transform. Fail the migration. -8. Mark the migration as complete by doing a single atomic operation (requires https://github.com/elastic/elasticsearch/pull/58100) that: - 1. Checks that `.kibana-current` alias is still pointing to the source index - 2. Points the `.kibana-7.10.0` and `.kibana_current` aliases to the target index. - 3. If this fails with a "required alias [.kibana_current] does not exist" error fetch `.kibana_current` again: +9. Mark the migration as complete by doing a single atomic operation (requires https://github.com/elastic/elasticsearch/pull/58100) that: + 3. Checks that `.kibana_current` alias is still pointing to the source index + 4. Points the `.kibana_7.10.0` and `.kibana_current` aliases to the target index. + 5. If this fails with a "required alias [.kibana_current] does not exist" error fetch `.kibana_current` again: 1. If `.kibana_current` is _not_ pointing to our target index fail the migration. 2. If `.kibana_current` is pointing to our target index the migration has succeeded and we can proceed to step (9). -9. Start serving traffic. +10. Start serving traffic. + +This algorithm shares a weakness with our existing migration algorithm +(since v7.4). When the task manager index gets reindexed a reindex script is +applied. Because we delete the original task manager index there is no way to +rollback a failed task manager migration without a snapshot. Together with the limitations, this algorithm ensures that migrations are idempotent. If two nodes are started simultaneously, both of them will start -transforming documents in that version's target index, but because migrations are idempotent, it doesn’t matter which node’s writes win. +transforming documents in that version's target index, but because migrations +are idempotent, it doesn’t matter which node’s writes win.
In the future, this algorithm could enable (2.6) "read-only functionality during the downtime window" but this is outside of the scope of this RFC. diff --git a/src/plugins/apm_oss/README.asciidoc b/src/plugins/apm_oss/README.asciidoc new file mode 100644 index 00000000000000..c3c060a99ee272 --- /dev/null +++ b/src/plugins/apm_oss/README.asciidoc @@ -0,0 +1,5 @@ +# APM OSS plugin + +OSS plugin for APM. Includes index configuration and tutorial resources. + +See <<../../x-pack/plugins/apm/readme.md,the X-Pack APM plugin README>> for information about the main APM plugin. diff --git a/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx index 650a273314412d..feb30b248c066f 100644 --- a/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx @@ -134,19 +134,15 @@ test('Add to library is not compatible when embeddable is not in a dashboard con expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false); }); -test('Add to library replaces embeddableId but retains panel count', async () => { +test('Add to library replaces embeddableId and retains panel count', async () => { const dashboard = embeddable.getRoot() as IContainer; const originalPanelCount = Object.keys(dashboard.getInput().panels).length; - const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); + const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount); - - const newPanelId = Object.keys(container.getInput().panels).find( - (key) => !originalPanelKeySet.has(key) - ); - expect(newPanelId).toBeDefined(); - const newPanel = container.getInput().panels[newPanelId!]; + expect(Object.keys(container.getInput().panels)).toContain(embeddable.id); + const newPanel = container.getInput().panels[embeddable.id!]; expect(newPanel.type).toEqual(embeddable.type); }); @@ -162,15 +158,10 @@ test('Add to library returns reference type input', async () => { mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: embeddable.id }, mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id } as EmbeddableInput, }); - const dashboard = embeddable.getRoot() as IContainer; - const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); - const newPanelId = Object.keys(container.getInput().panels).find( - (key) => !originalPanelKeySet.has(key) - ); - expect(newPanelId).toBeDefined(); - const newPanel = container.getInput().panels[newPanelId!]; + expect(Object.keys(container.getInput().panels)).toContain(embeddable.id); + const newPanel = container.getInput().panels[embeddable.id!]; expect(newPanel.type).toEqual(embeddable.type); expect(newPanel.explicitInput.attributes).toBeUndefined(); expect(newPanel.explicitInput.savedObjectId).toBe('testSavedObjectId'); diff --git a/src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx b/src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx index 193376ae97c0b0..25179fd7ccd387 100644 --- a/src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx @@ -108,7 +108,12 @@ test('Clone adds a new embeddable', async () => { ); expect(newPanelId).toBeDefined(); const newPanel = container.getInput().panels[newPanelId!]; - expect(newPanel.type).toEqual(embeddable.type); + expect(newPanel.type).toEqual('placeholder'); + // let the placeholder load + await dashboard.untilEmbeddableLoaded(newPanelId!); + // now wait for the full embeddable to replace it + const loadedPanel = await dashboard.untilEmbeddableLoaded(newPanelId!); + expect(loadedPanel.type).toEqual(embeddable.type); }); test('Clones an embeddable without a saved object ID', async () => { diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx index 4f668ec9ea04c9..f191be6f7baad3 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx @@ -132,19 +132,14 @@ test('Unlink is not compatible when embeddable is not in a dashboard container', expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false); }); -test('Unlink replaces embeddableId but retains panel count', async () => { +test('Unlink replaces embeddableId and retains panel count', async () => { const dashboard = embeddable.getRoot() as IContainer; const originalPanelCount = Object.keys(dashboard.getInput().panels).length; - const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); const action = new UnlinkFromLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount); - - const newPanelId = Object.keys(container.getInput().panels).find( - (key) => !originalPanelKeySet.has(key) - ); - expect(newPanelId).toBeDefined(); - const newPanel = container.getInput().panels[newPanelId!]; + expect(Object.keys(container.getInput().panels)).toContain(embeddable.id); + const newPanel = container.getInput().panels[embeddable.id!]; expect(newPanel.type).toEqual(embeddable.type); }); @@ -164,15 +159,10 @@ test('Unlink unwraps all attributes from savedObject', async () => { mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: embeddable.id }, mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id }, }); - const dashboard = embeddable.getRoot() as IContainer; - const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); const action = new UnlinkFromLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); - const newPanelId = Object.keys(container.getInput().panels).find( - (key) => !originalPanelKeySet.has(key) - ); - expect(newPanelId).toBeDefined(); - const newPanel = container.getInput().panels[newPanelId!]; + expect(Object.keys(container.getInput().panels)).toContain(embeddable.id); + const newPanel = container.getInput().panels[embeddable.id!]; expect(newPanel.type).toEqual(embeddable.type); expect(newPanel.explicitInput.attributes).toEqual(complicatedAttributes); }); diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx index 89aacf2a84029c..caa8321d7b8b23 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx @@ -27,6 +27,7 @@ import { ContactCardEmbeddableInput, ContactCardEmbeddable, ContactCardEmbeddableOutput, + EMPTY_EMBEDDABLE, } from '../../embeddable_plugin_test_samples'; import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; @@ -100,6 +101,48 @@ test('DashboardContainer.addNewEmbeddable', async () => { expect(embeddableInContainer.id).toBe(embeddable.id); }); +test('DashboardContainer.replacePanel', async (done) => { + const ID = '123'; + const initialInput = getSampleDashboardInput({ + panels: { + [ID]: getSampleDashboardPanel({ + explicitInput: { firstName: 'Sam', id: ID }, + type: CONTACT_CARD_EMBEDDABLE, + }), + }, + }); + + const container = new DashboardContainer(initialInput, options); + let counter = 0; + + const subscriptionHandler = jest.fn(({ panels }) => { + counter++; + expect(panels[ID]).toBeDefined(); + // It should be called exactly 2 times and exit the second time + switch (counter) { + case 1: + return expect(panels[ID].type).toBe(CONTACT_CARD_EMBEDDABLE); + + case 2: { + expect(panels[ID].type).toBe(EMPTY_EMBEDDABLE); + subscription.unsubscribe(); + done(); + } + + default: + throw Error('Called too many times!'); + } + }); + + const subscription = container.getInput$().subscribe(subscriptionHandler); + + // replace the panel now + container.replacePanel(container.getInput().panels[ID], { + type: EMPTY_EMBEDDABLE, + explicitInput: { id: ID }, + }); +}); + test('Container view mode change propagates to existing children', async () => { const initialInput = getSampleDashboardInput({ panels: { diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index 757488185fe8e5..051a7ef8bfb929 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -154,42 +154,43 @@ export class DashboardContainer extends Container) => - this.replacePanel(placeholderPanelState, newPanelState) - ); + + // wait until the placeholder is ready, then replace it with new panel + // this is useful as sometimes panels can load faster than the placeholder one (i.e. by value embeddables) + this.untilEmbeddableLoaded(originalPanelState.explicitInput.id) + .then(() => newStateComplete) + .then((newPanelState: Partial) => + this.replacePanel(placeholderPanelState, newPanelState) + ); } public replacePanel( previousPanelState: DashboardPanelState, newPanelState: Partial ) { - // TODO: In the current infrastructure, embeddables in a container do not react properly to - // changes. Removing the existing embeddable, and adding a new one is a temporary workaround - // until the container logic is fixed. - - const finalPanels = { ...this.input.panels }; - delete finalPanels[previousPanelState.explicitInput.id]; - const newPanelId = newPanelState.explicitInput?.id ? newPanelState.explicitInput.id : uuid.v4(); - finalPanels[newPanelId] = { - ...previousPanelState, - ...newPanelState, - gridData: { - ...previousPanelState.gridData, - i: newPanelId, - }, - explicitInput: { - ...newPanelState.explicitInput, - id: newPanelId, + // Because the embeddable type can change, we have to operate at the container level here + return this.updateInput({ + panels: { + ...this.input.panels, + [previousPanelState.explicitInput.id]: { + ...previousPanelState, + ...newPanelState, + gridData: { + ...previousPanelState.gridData, + }, + explicitInput: { + ...newPanelState.explicitInput, + id: previousPanelState.explicitInput.id, + }, + }, }, - }; - this.updateInput({ - panels: finalPanels, lastReloadRequestTime: new Date().getTime(), }); } @@ -201,16 +202,15 @@ export class DashboardContainer extends Container(type: string, explicitInput: Partial, embeddableId?: string) { const idToReplace = embeddableId || explicitInput.id; if (idToReplace && this.input.panels[idToReplace]) { - this.replacePanel(this.input.panels[idToReplace], { + return this.replacePanel(this.input.panels[idToReplace], { type, explicitInput: { ...explicitInput, - id: uuid.v4(), + id: idToReplace, }, }); - } else { - this.addNewEmbeddable(type, explicitInput); } + return this.addNewEmbeddable(type, explicitInput); } public render(dom: HTMLElement) { diff --git a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx index d4d8fd0a4374b9..03c92d91a80ccb 100644 --- a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx +++ b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx @@ -265,6 +265,7 @@ class DashboardGridUi extends React.Component {
{ @@ -272,6 +273,8 @@ class DashboardGridUi extends React.Component { }} > this.maybeUpdateChildren()); + this.subscription = this.getInput$() + // At each update event, get both the previous and current state + .pipe(startWith(input), pairwise()) + .subscribe(([{ panels: prevPanels }, { panels: currentPanels }]) => { + this.maybeUpdateChildren(currentPanels, prevPanels); + }); } public updateInputForChild( @@ -329,16 +335,30 @@ export abstract class Container< return embeddable; } - private maybeUpdateChildren() { - const allIds = Object.keys({ ...this.input.panels, ...this.output.embeddableLoaded }); + private panelHasChanged(currentPanel: PanelState, prevPanel: PanelState) { + if (currentPanel.type !== prevPanel.type) { + return true; + } + } + + private maybeUpdateChildren( + currentPanels: TContainerInput['panels'], + prevPanels: TContainerInput['panels'] + ) { + const allIds = Object.keys({ ...currentPanels, ...this.output.embeddableLoaded }); allIds.forEach((id) => { - if (this.input.panels[id] !== undefined && this.output.embeddableLoaded[id] === undefined) { - this.onPanelAdded(this.input.panels[id]); - } else if ( - this.input.panels[id] === undefined && - this.output.embeddableLoaded[id] !== undefined - ) { - this.onPanelRemoved(id); + if (currentPanels[id] !== undefined && this.output.embeddableLoaded[id] === undefined) { + return this.onPanelAdded(currentPanels[id]); + } + if (currentPanels[id] === undefined && this.output.embeddableLoaded[id] !== undefined) { + return this.onPanelRemoved(id); + } + // In case of type change, remove and add a panel with the same id + if (currentPanels[id] && prevPanels[id]) { + if (this.panelHasChanged(currentPanels[id], prevPanels[id])) { + this.onPanelRemoved(id); + this.onPanelAdded(currentPanels[id]); + } } }); } diff --git a/src/plugins/inspector/public/plugin.tsx b/src/plugins/inspector/public/plugin.tsx index f906dbcab80439..07ef7c8fbab0d4 100644 --- a/src/plugins/inspector/public/plugin.tsx +++ b/src/plugins/inspector/public/plugin.tsx @@ -70,7 +70,7 @@ export class InspectorPublicPlugin implements Plugin { public async setup(core: CoreSetup) { this.views = new InspectorViewRegistry(); - this.views.register(getDataViewDescription(core.uiSettings)); + this.views.register(getDataViewDescription()); this.views.register(getRequestsViewDescription()); return { @@ -101,7 +101,14 @@ export class InspectorPublicPlugin implements Plugin { } return core.overlays.openFlyout( - toMountPoint(), + toMountPoint( + + ), { 'data-test-subj': 'inspectorPanel', closeButtonAriaLabel: closeButtonLabel, diff --git a/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap b/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap index 709c0bfe69f0bd..7fb00fe8d40c41 100644 --- a/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap +++ b/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap @@ -10,6 +10,11 @@ exports[`InspectorPanel should render as expected 1`] = ` }, } } + dependencies={ + Object { + "uiSettings": Object {}, + } + } intl={ Object { "defaultFormats": Object {}, @@ -135,216 +140,228 @@ exports[`InspectorPanel should render as expected 1`] = ` ] } > - -
- -
- -
- -

- Inspector -

-
-
-
- -
+ Inspector + + +
+
+ - + - - - + } + } + views={ + Array [ + Object { + "component": [Function], + "order": 200, + "title": "View 1", + }, + Object { + "component": [Function], + "order": 100, + "shouldShow": [Function], + "title": "Foo View", + }, + Object { + "component": [Function], + "order": 200, + "shouldShow": [Function], + "title": "Never", + }, + ] } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="inspectorViewChooser" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - repositionOnScroll={true} > - + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="inspectorViewChooser" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + repositionOnScroll={true} > -
- - - + + + +
-
- - - -
- -
- - - - -
+ + +
+ + + + + +
- -

- View 1 -

-
+ } + > + +

+ View 1 +

+
+
+
- -
+
+ `; diff --git a/src/plugins/inspector/public/ui/inspector_panel.scss b/src/plugins/inspector/public/ui/inspector_panel.scss index ff0b491e1222b9..2a6cfed66e4ff8 100644 --- a/src/plugins/inspector/public/ui/inspector_panel.scss +++ b/src/plugins/inspector/public/ui/inspector_panel.scss @@ -1,11 +1,15 @@ .insInspectorPanel__flyoutBody { - // TODO: EUI to allow for custom classNames to inner elements - // Or supply this as default - > div { + .euiFlyoutBody__overflowContent { + height: 100%; display: flex; + flex-wrap: nowrap; flex-direction: column; - > div { + >div { + flex-grow: 0; + } + + .insRequestCodeViewer { flex-grow: 1; } } diff --git a/src/plugins/inspector/public/ui/inspector_panel.test.tsx b/src/plugins/inspector/public/ui/inspector_panel.test.tsx index 23f698c23793b1..67e197abe7134e 100644 --- a/src/plugins/inspector/public/ui/inspector_panel.test.tsx +++ b/src/plugins/inspector/public/ui/inspector_panel.test.tsx @@ -22,10 +22,12 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { InspectorPanel } from './inspector_panel'; import { InspectorViewDescription } from '../types'; import { Adapters } from '../../common'; +import type { IUiSettingsClient } from 'kibana/public'; describe('InspectorPanel', () => { let adapters: Adapters; let views: InspectorViewDescription[]; + const uiSettings: IUiSettingsClient = {} as IUiSettingsClient; beforeEach(() => { adapters = { @@ -62,12 +64,16 @@ describe('InspectorPanel', () => { }); it('should render as expected', () => { - const component = mountWithIntl(); + const component = mountWithIntl( + + ); expect(component).toMatchSnapshot(); }); it('should not allow updating adapters', () => { - const component = mountWithIntl(); + const component = mountWithIntl( + + ); adapters.notAllowed = {}; expect(() => component.setProps({ adapters })).toThrow(); }); diff --git a/src/plugins/inspector/public/ui/inspector_panel.tsx b/src/plugins/inspector/public/ui/inspector_panel.tsx index 37a51257112d63..dbad202953b0b5 100644 --- a/src/plugins/inspector/public/ui/inspector_panel.tsx +++ b/src/plugins/inspector/public/ui/inspector_panel.tsx @@ -19,12 +19,21 @@ import './inspector_panel.scss'; import { i18n } from '@kbn/i18n'; -import React, { Component } from 'react'; +import React, { Component, Suspense } from 'react'; import PropTypes from 'prop-types'; -import { EuiFlexGroup, EuiFlexItem, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiLoadingSpinner, +} from '@elastic/eui'; +import { IUiSettingsClient } from 'kibana/public'; import { InspectorViewDescription } from '../types'; import { Adapters } from '../../common'; import { InspectorViewChooser } from './inspector_view_chooser'; +import { KibanaContextProvider } from '../../../kibana_react/public'; function hasAdaptersChanged(oldAdapters: Adapters, newAdapters: Adapters) { return ( @@ -41,6 +50,9 @@ interface InspectorPanelProps { adapters: Adapters; title?: string; views: InspectorViewDescription[]; + dependencies: { + uiSettings: IUiSettingsClient; + }; } interface InspectorPanelState { @@ -95,19 +107,21 @@ export class InspectorPanel extends Component + }> + + ); } render() { - const { views, title } = this.props; + const { views, title, dependencies } = this.props; const { selectedView } = this.state; return ( - + @@ -127,7 +141,7 @@ export class InspectorPanel extends Component {this.renderSelectedPanel()} - + ); } } diff --git a/src/plugins/inspector/public/views/data/components/__snapshots__/data_view.test.tsx.snap b/src/plugins/inspector/public/views/data/components/__snapshots__/data_view.test.tsx.snap index 2632afff2f63be..3bd3bb6531cc7f 100644 --- a/src/plugins/inspector/public/views/data/components/__snapshots__/data_view.test.tsx.snap +++ b/src/plugins/inspector/public/views/data/components/__snapshots__/data_view.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Inspector Data View component should render empty state 1`] = ` - - + `; exports[`Inspector Data View component should render loading state 1`] = ` - + loading + } intl={ Object { @@ -431,204 +439,9 @@ exports[`Inspector Data View component should render loading state 1`] = ` "timeZone": null, } } - title="Test Data" > - - -
- -
- -
- - - - - - - - - -
- - -
-

- - Gathering data - -

-
-
-
- -
- -
- - - +
+ loading +
+ `; diff --git a/src/plugins/inspector/public/views/data/components/data_view.test.tsx b/src/plugins/inspector/public/views/data/components/data_view.test.tsx index bd78bca42c4796..6a7f878ef807e8 100644 --- a/src/plugins/inspector/public/views/data/components/data_view.test.tsx +++ b/src/plugins/inspector/public/views/data/components/data_view.test.tsx @@ -17,11 +17,10 @@ * under the License. */ -import React from 'react'; +import React, { Suspense } from 'react'; import { getDataViewDescription } from '../index'; import { DataAdapter } from '../../../../common/adapters/data'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { IUiSettingsClient } from '../../../../../../core/public'; jest.mock('../lib/export_csv', () => ({ exportAsCsv: jest.fn(), @@ -31,9 +30,7 @@ describe('Inspector Data View', () => { let DataView: any; beforeEach(() => { - const uiSettings = {} as IUiSettingsClient; - - DataView = getDataViewDescription(uiSettings); + DataView = getDataViewDescription(); }); it('should only show if data adapter is present', () => { @@ -51,7 +48,12 @@ describe('Inspector Data View', () => { }); it('should render loading state', () => { - const component = mountWithIntl(); // eslint-disable-line react/jsx-pascal-case + const DataViewComponent = DataView.component; + const component = mountWithIntl( + loading
}> + + + ); expect(component).toMatchSnapshot(); }); diff --git a/src/plugins/inspector/public/views/data/components/data_view.tsx b/src/plugins/inspector/public/views/data/components/data_view.tsx index 1a2b6f9922d2d0..100fa7787321ca 100644 --- a/src/plugins/inspector/public/views/data/components/data_view.tsx +++ b/src/plugins/inspector/public/views/data/components/data_view.tsx @@ -38,6 +38,7 @@ import { TabularCallback, } from '../../../../common/adapters/data/types'; import { IUiSettingsClient } from '../../../../../../core/public'; +import { withKibana, KibanaReactContextValue } from '../../../../../kibana_react/public'; interface DataViewComponentState { tabularData: TabularData | null; @@ -47,20 +48,23 @@ interface DataViewComponentState { } interface DataViewComponentProps extends InspectorViewProps { - uiSettings: IUiSettingsClient; + kibana: KibanaReactContextValue<{ uiSettings: IUiSettingsClient }>; } -export class DataViewComponent extends Component { +class DataViewComponent extends Component { static propTypes = { - uiSettings: PropTypes.object.isRequired, adapters: PropTypes.object.isRequired, title: PropTypes.string.isRequired, + kibana: PropTypes.object, }; state = {} as DataViewComponentState; _isMounted = false; - static getDerivedStateFromProps(nextProps: InspectorViewProps, state: DataViewComponentState) { + static getDerivedStateFromProps( + nextProps: DataViewComponentProps, + state: DataViewComponentState + ) { if (state && nextProps.adapters === state.adapters) { return null; } @@ -172,8 +176,12 @@ export class DataViewComponent extends Component ); } } + +// default export required for React.Lazy +// eslint-disable-next-line import/no-default-export +export default withKibana(DataViewComponent); diff --git a/src/plugins/inspector/public/views/data/index.tsx b/src/plugins/inspector/public/views/data/index.ts similarity index 72% rename from src/plugins/inspector/public/views/data/index.tsx rename to src/plugins/inspector/public/views/data/index.ts index b02e02bbe6b6b6..d201ad89022be9 100644 --- a/src/plugins/inspector/public/views/data/index.tsx +++ b/src/plugins/inspector/public/views/data/index.ts @@ -16,17 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; +import { lazy } from 'react'; import { i18n } from '@kbn/i18n'; -import { DataViewComponent } from './components/data_view'; -import { InspectorViewDescription, InspectorViewProps } from '../../types'; +import { InspectorViewDescription } from '../../types'; import { Adapters } from '../../../common'; -import { IUiSettingsClient } from '../../../../../core/public'; -export const getDataViewDescription = ( - uiSettings: IUiSettingsClient -): InspectorViewDescription => ({ +const DataViewComponent = lazy(() => import('./components/data_view')); + +export const getDataViewDescription = (): InspectorViewDescription => ({ title: i18n.translate('inspector.data.dataTitle', { defaultMessage: 'Data', }), @@ -37,7 +35,5 @@ export const getDataViewDescription = ( shouldShow(adapters: Adapters) { return Boolean(adapters.data); }, - component: (props: InspectorViewProps) => ( - - ), + component: DataViewComponent, }); diff --git a/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx b/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx new file mode 100644 index 00000000000000..71499d46071c87 --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/req_code_viewer.tsx @@ -0,0 +1,82 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexItem, EuiFlexGroup, EuiCopy, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; + +import { CodeEditor } from '../../../../../../kibana_react/public'; + +interface RequestCodeViewerProps { + json: string; +} + +const copyToClipboardLabel = i18n.translate('inspector.requests.copyToClipboardLabel', { + defaultMessage: 'Copy to clipboard', +}); + +/** + * @internal + */ +export const RequestCodeViewer = ({ json }: RequestCodeViewerProps) => ( + + + +
+ + {(copy) => ( + + {copyToClipboardLabel} + + )} + +
+
+ + {}} + options={{ + readOnly: true, + lineNumbers: 'off', + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + automaticLayout: true, + }} + /> + +
+); diff --git a/src/plugins/inspector/public/views/requests/components/details/req_details_request.tsx b/src/plugins/inspector/public/views/requests/components/details/req_details_request.tsx index d7cb8f57456138..47ed226c24a5ce 100644 --- a/src/plugins/inspector/public/views/requests/components/details/req_details_request.tsx +++ b/src/plugins/inspector/public/views/requests/components/details/req_details_request.tsx @@ -19,9 +19,9 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { EuiCodeBlock } from '@elastic/eui'; import { Request } from '../../../../../common/adapters/request/types'; import { RequestDetailsProps } from '../types'; +import { RequestCodeViewer } from './req_code_viewer'; export class RequestDetailsRequest extends Component { static propTypes = { @@ -37,15 +37,6 @@ export class RequestDetailsRequest extends Component { return null; } - return ( - - {JSON.stringify(json, null, 2)} - - ); + return ; } } diff --git a/src/plugins/inspector/public/views/requests/components/details/req_details_response.tsx b/src/plugins/inspector/public/views/requests/components/details/req_details_response.tsx index 933495ff473961..5ad5cc0537adaa 100644 --- a/src/plugins/inspector/public/views/requests/components/details/req_details_response.tsx +++ b/src/plugins/inspector/public/views/requests/components/details/req_details_response.tsx @@ -19,9 +19,9 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { EuiCodeBlock } from '@elastic/eui'; import { Request } from '../../../../../common/adapters/request/types'; import { RequestDetailsProps } from '../types'; +import { RequestCodeViewer } from './req_code_viewer'; export class RequestDetailsResponse extends Component { static propTypes = { @@ -40,15 +40,6 @@ export class RequestDetailsResponse extends Component { return null; } - return ( - - {JSON.stringify(responseJSON, null, 2)} - - ); + return ; } } diff --git a/src/plugins/inspector/public/views/requests/components/requests_view.tsx b/src/plugins/inspector/public/views/requests/components/requests_view.tsx index 13575de0c5064f..7762689daf4e68 100644 --- a/src/plugins/inspector/public/views/requests/components/requests_view.tsx +++ b/src/plugins/inspector/public/views/requests/components/requests_view.tsx @@ -175,3 +175,7 @@ export class RequestsViewComponent extends Component import('./components/requests_view')); + export const getRequestsViewDescription = (): InspectorViewDescription => ({ title: i18n.translate('inspector.requests.requestsTitle', { defaultMessage: 'Requests', diff --git a/test/functional/page_objects/tile_map_page.ts b/test/functional/page_objects/tile_map_page.ts index 609e6ebddd50ac..7881c9b1f7155c 100644 --- a/test/functional/page_objects/tile_map_page.ts +++ b/test/functional/page_objects/tile_map_page.ts @@ -50,12 +50,14 @@ export function TileMapPageProvider({ getService, getPageObjects }: FtrProviderC await testSubjects.click('inspectorViewChooser'); await testSubjects.click('inspectorViewChooserRequests'); await testSubjects.click('inspectorRequestDetailRequest'); - return await testSubjects.getVisibleText('inspectorRequestBody'); + + return await inspector.getCodeEditorValue(); } public async getMapBounds(): Promise { const request = await this.getVisualizationRequest(); const requestObject = JSON.parse(request); + return requestObject.aggs.filter_agg.filter.geo_bounding_box['geo.coordinates']; } diff --git a/test/functional/services/inspector.ts b/test/functional/services/inspector.ts index 1c0bf7ad46df15..e256cf14541a7e 100644 --- a/test/functional/services/inspector.ts +++ b/test/functional/services/inspector.ts @@ -23,6 +23,7 @@ import { FtrProviderContext } from '../ftr_provider_context'; export function InspectorProvider({ getService }: FtrProviderContext) { const log = getService('log'); const retry = getService('retry'); + const browser = getService('browser'); const renderable = getService('renderable'); const flyout = getService('flyout'); const testSubjects = getService('testSubjects'); @@ -245,6 +246,18 @@ export function InspectorProvider({ getService }: FtrProviderContext) { public getOpenRequestDetailResponseButton() { return testSubjects.find('inspectorRequestDetailResponse'); } + + public async getCodeEditorValue() { + let request: string = ''; + + await retry.try(async () => { + request = await browser.execute( + () => (window as any).monaco.editor.getModels()[0].getValue() as string + ); + }); + + return request; + } } return new Inspector(); diff --git a/x-pack/examples/alerting_example/public/alert_types/always_firing.tsx b/x-pack/examples/alerting_example/public/alert_types/always_firing.tsx index 9c420f4425d04e..a5d158fca836b3 100644 --- a/x-pack/examples/alerting_example/public/alert_types/always_firing.tsx +++ b/x-pack/examples/alerting_example/public/alert_types/always_firing.tsx @@ -22,6 +22,7 @@ export function getAlertType(): AlertTypeModel { name: 'Always Fires', description: 'Alert when called', iconClass: 'bolt', + documentationUrl: null, alertParamsExpression: AlwaysFiringExpression, validate: (alertParams: AlwaysFiringParamsProps['alertParams']) => { const { instances } = alertParams; diff --git a/x-pack/examples/alerting_example/public/alert_types/astros.tsx b/x-pack/examples/alerting_example/public/alert_types/astros.tsx index 343f6b10ef85bb..73c7dfea1263bb 100644 --- a/x-pack/examples/alerting_example/public/alert_types/astros.tsx +++ b/x-pack/examples/alerting_example/public/alert_types/astros.tsx @@ -47,6 +47,7 @@ export function getAlertType(): AlertTypeModel { name: 'People Are In Space Right Now', description: 'Alert when people are in space right now', iconClass: 'globe', + documentationUrl: null, alertParamsExpression: PeopleinSpaceExpression, validate: (alertParams: PeopleinSpaceParamsProps['alertParams']) => { const { outerSpaceCapacity, craft, op } = alertParams; diff --git a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts index 0eeb31927b2f59..988e335af5b7cc 100644 --- a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts +++ b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts @@ -22,6 +22,9 @@ export function registerApmAlerts( 'Alert when the number of errors in a service exceeds a defined threshold.', }), iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; + }, alertParamsExpression: lazy(() => import('./ErrorCountAlertTrigger')), validate: () => ({ errors: [], @@ -53,6 +56,9 @@ export function registerApmAlerts( } ), iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; + }, alertParamsExpression: lazy( () => import('./TransactionDurationAlertTrigger') ), @@ -87,6 +93,9 @@ export function registerApmAlerts( } ), iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; + }, alertParamsExpression: lazy( () => import('./TransactionErrorRateAlertTrigger') ), @@ -121,6 +130,9 @@ export function registerApmAlerts( } ), iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; + }, alertParamsExpression: lazy( () => import('./TransactionDurationAnomalyAlertTrigger') ), 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 b5a558621e9ca9..1f34a0cef1ccf5 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 @@ -826,7 +826,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` query={ Object { "end": "2018-01-10T10:06:41.050Z", - "kuery": "error.exception.type:AssertionError", + "kuery": "error.exception.type:\\"AssertionError\\"", "page": 0, "serviceName": "opbeans-python", "start": "2018-01-10T09:51:41.050Z", @@ -838,12 +838,12 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -1065,7 +1065,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` query={ Object { "end": "2018-01-10T10:06:41.050Z", - "kuery": "error.exception.type:AssertionError", + "kuery": "error.exception.type:\\"AssertionError\\"", "page": 0, "serviceName": "opbeans-python", "start": "2018-01-10T09:51:41.050Z", @@ -1077,12 +1077,12 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -1304,7 +1304,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` query={ Object { "end": "2018-01-10T10:06:41.050Z", - "kuery": "error.exception.type:AssertionError", + "kuery": "error.exception.type:\\"AssertionError\\"", "page": 0, "serviceName": "opbeans-python", "start": "2018-01-10T09:51:41.050Z", @@ -1316,12 +1316,12 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -1543,7 +1543,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` query={ Object { "end": "2018-01-10T10:06:41.050Z", - "kuery": "error.exception.type:AssertionError", + "kuery": "error.exception.type:\\"AssertionError\\"", "page": 0, "serviceName": "opbeans-python", "start": "2018-01-10T09:51:41.050Z", @@ -1555,12 +1555,12 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx index 33105189f9c3e4..e1f6239112555e 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx @@ -107,7 +107,7 @@ function ErrorGroupList({ items, serviceName }: Props) { query={ { ...urlParams, - kuery: `error.exception.type:${type}`, + kuery: `error.exception.type:"${type}"`, } as APMQueryParams } > diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/router.mock.ts b/x-pack/plugins/enterprise_search/server/__mocks__/router.mock.ts index e3471d7268cb17..f00e0f2807e8d3 100644 --- a/x-pack/plugins/enterprise_search/server/__mocks__/router.mock.ts +++ b/x-pack/plugins/enterprise_search/server/__mocks__/router.mock.ts @@ -21,6 +21,7 @@ type PayloadType = 'params' | 'query' | 'body'; interface IMockRouterProps { method: MethodType; + path: string; payload?: PayloadType; } interface IMockRouterRequest { @@ -33,12 +34,14 @@ type TMockRouterRequest = KibanaRequest | IMockRouterRequest; export class MockRouter { public router!: jest.Mocked; public method: MethodType; + public path: string; public payload?: PayloadType; public response = httpServerMock.createResponseFactory(); - constructor({ method, payload }: IMockRouterProps) { + constructor({ method, path, payload }: IMockRouterProps) { this.createRouter(); this.method = method; + this.path = path; this.payload = payload; } @@ -47,8 +50,13 @@ export class MockRouter { }; public callRoute = async (request: TMockRouterRequest) => { - const [, handler] = this.router[this.method].mock.calls[0]; + const routerCalls = this.router[this.method].mock.calls as any[]; + if (!routerCalls.length) throw new Error('No routes registered.'); + const route = routerCalls.find(([router]: any) => router.path === this.path); + if (!route) throw new Error('No matching registered routes found - check method/path keys'); + + const [, handler] = route; const context = {} as jest.Mocked; await handler(context, httpServerMock.createKibanaRequest(request as any), this.response); }; @@ -81,7 +89,11 @@ export class MockRouter { /** * Example usage: */ -// const mockRouter = new MockRouter({ method: 'get', payload: 'body' }); +// const mockRouter = new MockRouter({ +// method: 'get', +// path: '/api/app_search/test', +// payload: 'body' +// }); // // beforeEach(() => { // jest.clearAllMocks(); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts index 357b49de934122..af498e346529a9 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/credentials.test.ts @@ -14,7 +14,11 @@ describe('credentials routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'get', payload: 'query' }); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/credentials', + payload: 'query', + }); registerCredentialsRoutes({ ...mockDependencies, @@ -46,7 +50,11 @@ describe('credentials routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'post', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/credentials', + payload: 'body', + }); registerCredentialsRoutes({ ...mockDependencies, @@ -155,7 +163,11 @@ describe('credentials routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'get', payload: 'query' }); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/credentials/details', + payload: 'query', + }); registerCredentialsRoutes({ ...mockDependencies, @@ -175,7 +187,11 @@ describe('credentials routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'put', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'put', + path: '/api/app_search/credentials/{name}', + payload: 'body', + }); registerCredentialsRoutes({ ...mockDependencies, @@ -292,7 +308,11 @@ describe('credentials routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'delete', payload: 'params' }); + mockRouter = new MockRouter({ + method: 'delete', + path: '/api/app_search/credentials/{name}', + payload: 'params', + }); registerCredentialsRoutes({ ...mockDependencies, diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index cd22ff98b01cee..3bfe8abf8a2dff 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -25,7 +25,11 @@ describe('engine routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'get', payload: 'query' }); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines', + payload: 'query', + }); registerEnginesRoute({ ...mockDependencies, diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/settings.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/settings.test.ts index 095c0ac2b6ab14..be3b2632eb67d9 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/settings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/settings.test.ts @@ -14,7 +14,10 @@ describe('log settings routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'get' }); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/log_settings', + }); registerSettingsRoutes({ ...mockDependencies, @@ -36,7 +39,11 @@ describe('log settings routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'put', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'put', + path: '/api/app_search/log_settings', + payload: 'body', + }); registerSettingsRoutes({ ...mockDependencies, diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/config_data.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/config_data.test.ts index 253c9a418d60b6..b6f449ced2599f 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/config_data.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/config_data.test.ts @@ -18,7 +18,10 @@ describe('Enterprise Search Config Data API', () => { let mockRouter: MockRouter; beforeEach(() => { - mockRouter = new MockRouter({ method: 'get' }); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/enterprise_search/config_data', + }); registerConfigDataRoute({ ...mockDependencies, diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts index bd6f4b9da91fd6..2229860d87a000 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts @@ -25,7 +25,11 @@ describe('Enterprise Search Telemetry API', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'put', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'put', + path: '/api/enterprise_search/stats', + payload: 'body', + }); registerTelemetryRoute({ ...mockDependencies, diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.test.ts index 31e055565ead12..2f244022be0378 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/groups.test.ts @@ -25,7 +25,11 @@ describe('groups routes', () => { }); it('creates a request handler', () => { - mockRouter = new MockRouter({ method: 'get', payload: 'query' }); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/groups', + payload: 'query', + }); registerGroupsRoute({ ...mockDependencies, @@ -43,7 +47,11 @@ describe('groups routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'post', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/workplace_search/groups', + payload: 'body', + }); registerGroupsRoute({ ...mockDependencies, @@ -71,7 +79,11 @@ describe('groups routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'post', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/workplace_search/groups/search', + payload: 'body', + }); registerSearchGroupsRoute({ ...mockDependencies, @@ -141,7 +153,11 @@ describe('groups routes', () => { }); it('creates a request handler', () => { - mockRouter = new MockRouter({ method: 'get', payload: 'params' }); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/groups/{id}', + payload: 'params', + }); registerGroupRoute({ ...mockDependencies, @@ -176,7 +192,11 @@ describe('groups routes', () => { }; it('creates a request handler', () => { - mockRouter = new MockRouter({ method: 'put', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'put', + path: '/api/workplace_search/groups/{id}', + payload: 'body', + }); registerGroupRoute({ ...mockDependencies, @@ -204,7 +224,11 @@ describe('groups routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'delete', payload: 'params' }); + mockRouter = new MockRouter({ + method: 'delete', + path: '/api/workplace_search/groups/{id}', + payload: 'params', + }); registerGroupRoute({ ...mockDependencies, @@ -227,7 +251,7 @@ describe('groups routes', () => { }); }); - describe('GET /api/workplace_search/groups/{id}/users', () => { + describe('GET /api/workplace_search/groups/{id}/group_users', () => { let mockRouter: MockRouter; beforeEach(() => { @@ -235,7 +259,11 @@ describe('groups routes', () => { }); it('creates a request handler', () => { - mockRouter = new MockRouter({ method: 'get', payload: 'params' }); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/groups/{id}/group_users', + payload: 'params', + }); registerGroupUsersRoute({ ...mockDependencies, @@ -261,7 +289,11 @@ describe('groups routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'post', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/workplace_search/groups/{id}/share', + payload: 'body', + }); registerShareGroupRoute({ ...mockDependencies, @@ -291,7 +323,11 @@ describe('groups routes', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'post', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/workplace_search/groups/{id}/assign', + payload: 'body', + }); registerAssignGroupRoute({ ...mockDependencies, @@ -330,7 +366,11 @@ describe('groups routes', () => { }; it('creates a request handler', () => { - mockRouter = new MockRouter({ method: 'put', payload: 'body' }); + mockRouter = new MockRouter({ + method: 'put', + path: '/api/workplace_search/groups/{id}/boosts', + payload: 'body', + }); registerBoostsGroupRoute({ ...mockDependencies, diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts index a387cab31c17ac..9317b1ada85afa 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts @@ -14,7 +14,11 @@ describe('Overview route', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouter = new MockRouter({ method: 'get', payload: 'query' }); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/overview', + payload: 'query', + }); registerOverviewRoute({ ...mockDependencies, diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx index 60a00371e5ade5..54d3b783d22f6c 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx @@ -12,7 +12,7 @@ import { AlertsContextValue } from '../../../../../triggers_actions_ui/public/ap // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { InventoryMetricConditions } from '../../../../server/lib/alerting/inventory_metric_threshold/types'; import React from 'react'; -import { Expressions, AlertContextMeta, ExpressionRow } from './expression'; +import { Expressions, AlertContextMeta, ExpressionRow, defaultExpression } from './expression'; import { act } from 'react-dom/test-utils'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { Comparator } from '../../../../server/lib/alerting/metric_threshold/types'; @@ -105,6 +105,7 @@ describe('Expression', () => { threshold: [], timeSize: 1, timeUnit: 'm', + customMetric: defaultExpression.customMetric, }, ]); }); @@ -155,6 +156,7 @@ describe('ExpressionRow', () => { alertsContextMetadata={{ customMetrics: [], }} + fields={[{ name: 'some.system.field', type: 'bzzz' }]} /> ); diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx index 66d547eb50d9c2..097e0f1f1690b4 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { set } from '@elastic/safer-lodash-set'; -import { debounce, pick, uniqBy, isEqual } from 'lodash'; +import { debounce, pick } from 'lodash'; import { Unit } from '@elastic/datemath'; import React, { useCallback, useMemo, useEffect, useState, ChangeEvent } from 'react'; +import { IFieldType } from 'src/plugins/data/public'; import { EuiFlexGroup, EuiFlexItem, @@ -23,7 +23,6 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { getCustomMetricLabel } from '../../../../common/formatters/get_custom_metric_label'; import { toMetricOpt } from '../../../../common/snapshot_metric_i18n'; import { AlertPreview } from '../../common'; import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from '../../../../common/alerting/metrics'; @@ -95,13 +94,18 @@ interface Props { setAlertProperty(key: string, value: any): void; } -const defaultExpression = { +export const defaultExpression = { metric: 'cpu' as SnapshotMetricType, comparator: Comparator.GT, threshold: [], timeSize: 1, timeUnit: 'm', - customMetric: undefined, + customMetric: { + type: 'custom', + id: 'alert-custom-metric', + field: '', + aggregation: 'avg', + }, } as InventoryMetricConditions; export const Expressions: React.FC = (props) => { @@ -226,7 +230,7 @@ export const Expressions: React.FC = (props) => { metric: md.options.metric!.type, customMetric: SnapshotCustomMetricInputRT.is(md.options.metric) ? md.options.metric - : undefined, + : defaultExpression.customMetric, } as InventoryMetricConditions, ]); } else { @@ -306,6 +310,7 @@ export const Expressions: React.FC = (props) => { errors={errors[idx] || emptyError} expression={e || {}} alertsContextMetadata={alertsContext.metadata} + fields={derivedIndexPattern.fields} /> ); })} @@ -415,6 +420,7 @@ interface ExpressionRowProps { remove(id: number): void; setAlertParams(id: number, params: Partial): void; alertsContextMetadata: AlertsContextValue['metadata']; + fields: IFieldType[]; } const StyledExpressionRow = euiStyled(EuiFlexGroup)` @@ -428,48 +434,25 @@ const StyledExpression = euiStyled.div` `; export const ExpressionRow: React.FC = (props) => { - const { - setAlertParams, - expression, - errors, - expressionId, - remove, - canDelete, - alertsContextMetadata, - } = props; + const { setAlertParams, expression, errors, expressionId, remove, canDelete, fields } = props; const { metric, comparator = Comparator.GT, threshold = [], customMetric } = expression; - const [customMetrics, updateCustomMetrics] = useState([]); - - // Create and uniquify a list of custom metrics including: - // - The alert metadata context (which only gives us custom metrics on the inventory page) - // - The custom metric stored in the expression (necessary when editing this alert without having - // access to the metadata context) - // - Whatever custom metrics were previously stored in this list (to preserve the custom metric in the dropdown - // if the user edits the alert and switches away from the custom metric) - useEffect(() => { - const ctxCustomMetrics = alertsContextMetadata?.customMetrics ?? []; - const expressionCustomMetrics = customMetric ? [customMetric] : []; - const newCustomMetrics = uniqBy( - [...customMetrics, ...ctxCustomMetrics, ...expressionCustomMetrics], - (cm: SnapshotCustomMetricInput) => cm.id - ); - if (!isEqual(customMetrics, newCustomMetrics)) updateCustomMetrics(newCustomMetrics); - }, [alertsContextMetadata, customMetric, customMetrics, updateCustomMetrics]); const updateMetric = useCallback( (m?: SnapshotMetricType | string) => { - const newMetric = SnapshotMetricTypeRT.is(m) ? m : 'custom'; + const newMetric = SnapshotMetricTypeRT.is(m) ? m : Boolean(m) ? 'custom' : undefined; const newAlertParams = { ...expression, metric: newMetric }; - if (newMetric === 'custom' && customMetrics) { - set( - newAlertParams, - 'customMetric', - customMetrics.find((cm) => cm.id === m) - ); - } setAlertParams(expressionId, newAlertParams); }, - [expressionId, expression, setAlertParams, customMetrics] + [expressionId, expression, setAlertParams] + ); + + const updateCustomMetric = useCallback( + (cm?: SnapshotCustomMetricInput) => { + if (SnapshotCustomMetricInputRT.is(cm)) { + setAlertParams(expressionId, { ...expression, customMetric: cm }); + } + }, + [expressionId, expression, setAlertParams] ); const updateComparator = useCallback( @@ -515,17 +498,8 @@ export const ExpressionRow: React.FC = (props) => { myMetrics = containerMetricTypes; break; } - const baseMetricOpts = myMetrics.map(toMetricOpt); - const customMetricOpts = customMetrics - ? customMetrics.map((m, i) => ({ - text: getCustomMetricLabel(m), - value: m.id, - })) - : []; - return [...baseMetricOpts, ...customMetricOpts]; - }, [props.nodeType, customMetrics]); - - const selectedMetricValue = metric === 'custom' && customMetric ? customMetric.id : metric!; + return myMetrics.map(toMetricOpt); + }, [props.nodeType]); return ( <> @@ -535,8 +509,8 @@ export const ExpressionRow: React.FC = (props) => { v?.value === selectedMetricValue)?.text || '', + value: metric!, + text: ofFields.find((v) => v?.value === metric)?.text || '', }} metrics={ ofFields.filter((m) => m !== undefined && m.value !== undefined) as Array<{ @@ -545,7 +519,10 @@ export const ExpressionRow: React.FC = (props) => { }> } onChange={updateMetric} + onChangeCustom={updateCustomMetric} errors={errors} + customMetric={customMetric} + fields={fields} /> diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/metric.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/metric.tsx index 5418eab3c5fc22..2dd2938dfd55af 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/metric.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/metric.tsx @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { debounce } from 'lodash'; import { EuiExpression, EuiPopover, @@ -14,16 +15,33 @@ import { EuiFlexItem, EuiFormRow, EuiComboBox, + EuiButtonGroup, + EuiSpacer, + EuiSelect, + EuiText, + EuiFieldText, } from '@elastic/eui'; +import { IFieldType } from 'src/plugins/data/public'; import { EuiPopoverTitle, EuiButtonIcon } from '@elastic/eui'; +import { getCustomMetricLabel } from '../../../../common/formatters/get_custom_metric_label'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { IErrorObject } from '../../../../../triggers_actions_ui/public/types'; +import { + SnapshotCustomMetricInput, + SnapshotCustomMetricInputRT, + SnapshotCustomAggregation, + SNAPSHOT_CUSTOM_AGGREGATIONS, + SnapshotCustomAggregationRT, +} from '../../../../common/http_api/snapshot_api'; interface Props { metric?: { value: string; text: string }; metrics: Array<{ value: string; text: string }>; errors: IErrorObject; onChange: (metric?: string) => void; + onChangeCustom: (customMetric?: SnapshotCustomMetricInput) => void; + customMetric?: SnapshotCustomMetricInput; + fields: IFieldType[]; popupPosition?: | 'upCenter' | 'upLeft' @@ -39,8 +57,40 @@ interface Props { | 'rightDown'; } -export const MetricExpression = ({ metric, metrics, errors, onChange, popupPosition }: Props) => { - const [aggFieldPopoverOpen, setAggFieldPopoverOpen] = useState(false); +const AGGREGATION_LABELS = { + ['avg']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLables.avg', { + defaultMessage: 'Average', + }), + ['max']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLables.max', { + defaultMessage: 'Max', + }), + ['min']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLables.min', { + defaultMessage: 'Min', + }), + ['rate']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLables.rate', { + defaultMessage: 'Rate', + }), +}; +const aggregationOptions = SNAPSHOT_CUSTOM_AGGREGATIONS.map((k) => ({ + text: AGGREGATION_LABELS[k as SnapshotCustomAggregation], + value: k, +})); + +export const MetricExpression = ({ + metric, + metrics, + customMetric, + fields, + errors, + onChange, + onChangeCustom, + popupPosition, +}: Props) => { + const [popoverOpen, setPopoverOpen] = useState(false); + const [customMetricTabOpen, setCustomMetricTabOpen] = useState(metric?.value === 'custom'); + const [selectedOption, setSelectedOption] = useState(metric?.value); + const [fieldDisplayedCustomLabel, setFieldDisplayedCustomLabel] = useState(customMetric?.label); + const firstFieldOption = { text: i18n.translate('xpack.infra.metrics.alertFlyout.expression.metric.selectFieldLabel', { defaultMessage: 'Select a metric', @@ -48,13 +98,84 @@ export const MetricExpression = ({ metric, metrics, errors, onChange, popupPosit value: '', }; + const fieldOptions = useMemo( + () => + fields + .filter((f) => f.aggregatable && f.type === 'number' && !(customMetric?.field === f.name)) + .map((f) => ({ label: f.name })), + [fields, customMetric?.field] + ); + + const expressionDisplayValue = useMemo( + () => { + return customMetricTabOpen + ? customMetric?.field && getCustomMetricLabel(customMetric) + : metric?.text || firstFieldOption.text; + }, + // The ?s are confusing eslint here, so... + // eslint-disable-next-line react-hooks/exhaustive-deps + [customMetricTabOpen, metric, customMetric, firstFieldOption] + ); + + const onChangeTab = useCallback( + (id) => { + if (id === 'metric-popover-custom') { + setCustomMetricTabOpen(true); + onChange('custom'); + } else { + setCustomMetricTabOpen(false); + onChange(selectedOption); + } + }, + [setCustomMetricTabOpen, onChange, selectedOption] + ); + + const onAggregationChange = useCallback( + (e) => { + const value = e.target.value; + const aggValue: SnapshotCustomAggregation = SnapshotCustomAggregationRT.is(value) + ? value + : 'avg'; + const newCustomMetric = { + ...customMetric, + aggregation: aggValue, + }; + if (SnapshotCustomMetricInputRT.is(newCustomMetric)) onChangeCustom(newCustomMetric); + }, + [customMetric, onChangeCustom] + ); + + const onFieldChange = useCallback( + (selectedOptions: Array<{ label: string }>) => { + const newCustomMetric = { + ...customMetric, + field: selectedOptions[0].label, + }; + if (SnapshotCustomMetricInputRT.is(newCustomMetric)) onChangeCustom(newCustomMetric); + }, + [customMetric, onChangeCustom] + ); + + const debouncedOnChangeCustom = debounce(onChangeCustom, 500); + const onLabelChange = useCallback( + (e) => { + setFieldDisplayedCustomLabel(e.target.value); + const newCustomMetric = { + ...customMetric, + label: e.target.value, + }; + if (SnapshotCustomMetricInputRT.is(newCustomMetric)) debouncedOnChangeCustom(newCustomMetric); + }, + [customMetric, debouncedOnChangeCustom] + ); + const availablefieldsOptions = metrics.map((m) => { return { label: m.text, value: m.value }; }, []); return ( 0))} + value={expressionDisplayValue} + isActive={Boolean(popoverOpen || (errors.metric && errors.metric.length > 0))} onClick={() => { - setAggFieldPopoverOpen(true); + setPopoverOpen(true); }} color={errors.metric?.length ? 'danger' : 'secondary'} /> } - isOpen={aggFieldPopoverOpen} + isOpen={popoverOpen} closePopover={() => { - setAggFieldPopoverOpen(false); + setPopoverOpen(false); }} anchorPosition={popupPosition ?? 'downRight'} zIndex={8000} > -
- setAggFieldPopoverOpen(false)}> +
+ setPopoverOpen(false)}> - - - 0} error={errors.metric}> - + + {customMetricTabOpen ? ( + <> + + + + + + + + + {i18n.translate('xpack.infra.waffle.customMetrics.of', { + defaultMessage: 'of', + })} + + + + + 0} + /> + + + + of ".', + })} + > + 0} - placeholder={firstFieldOption.text} - options={availablefieldsOptions} - noSuggestions={!availablefieldsOptions.length} - selectedOptions={ - metric ? availablefieldsOptions.filter((a) => a.value === metric.value) : [] - } - renderOption={(o: any) => o.label} - onChange={(selectedOptions) => { - if (selectedOptions.length > 0) { - onChange(selectedOptions[0].value); - setAggFieldPopoverOpen(false); - } else { - onChange(); - } - }} + onChange={onLabelChange} /> - - + + ) : ( + + + + 0} + placeholder={firstFieldOption.text} + options={availablefieldsOptions} + noSuggestions={!availablefieldsOptions.length} + selectedOptions={ + metric ? availablefieldsOptions.filter((a) => a.value === metric.value) : [] + } + renderOption={(o: any) => o.label} + onChange={(selectedOptions) => { + if (selectedOptions.length > 0) { + onChange(selectedOptions[0].value); + setSelectedOption(selectedOptions[0].value); + } else { + onChange(); + } + }} + /> + + + + )}
); diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx index 47ecd3c527fadd..4b522d7d97730b 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/validation.tsx @@ -6,14 +6,14 @@ import { i18n } from '@kbn/i18n'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { MetricExpressionParams } from '../../../../server/lib/alerting/metric_threshold/types'; +import { InventoryMetricConditions } from '../../../../server/lib/alerting/inventory_metric_threshold/types'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ValidationResult } from '../../../../../triggers_actions_ui/public/types'; export function validateMetricThreshold({ criteria, }: { - criteria: MetricExpressionParams[]; + criteria: InventoryMetricConditions[]; }): ValidationResult { const validationResult = { errors: {} }; const errors: { @@ -81,14 +81,20 @@ export function validateMetricThreshold({ }) ); } - - if (!c.metric && c.aggType !== 'count') { + if (!c.metric) { errors[id].metric.push( i18n.translate('xpack.infra.metrics.alertFlyout.error.metricRequired', { defaultMessage: 'Metric is required.', }) ); } + if (c.metric === 'custom' && !c.customMetric?.field) { + errors[id].metric.push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.customMetricFieldRequired', { + defaultMessage: 'Field is required.', + }) + ); + } }); return validationResult; diff --git a/x-pack/plugins/infra/public/alerting/inventory/index.ts b/x-pack/plugins/infra/public/alerting/inventory/index.ts index b49465db051356..d7afd73c0e3a71 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/index.ts +++ b/x-pack/plugins/infra/public/alerting/inventory/index.ts @@ -21,6 +21,9 @@ export function createInventoryMetricAlertType(): AlertTypeModel { defaultMessage: 'Alert when the inventory exceeds a defined threshold.', }), iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/observability/${docLinks.DOC_LINK_VERSION}/infrastructure-threshold-alert.html`; + }, alertParamsExpression: React.lazy(() => import('./components/expression')), validate: validateMetricThreshold, defaultActionMessage: i18n.translate( diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/log_threshold_alert_type.ts b/x-pack/plugins/infra/public/alerting/log_threshold/log_threshold_alert_type.ts index 2e4cb2a53b6b58..60c22c42c00b6f 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/log_threshold_alert_type.ts +++ b/x-pack/plugins/infra/public/alerting/log_threshold/log_threshold_alert_type.ts @@ -19,6 +19,9 @@ export function getAlertType(): AlertTypeModel { defaultMessage: 'Alert when the log aggregation exceeds the threshold.', }), iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/observability/${docLinks.DOC_LINK_VERSION}/logs-threshold-alert.html`; + }, alertParamsExpression: React.lazy(() => import('./components/expression_editor/editor')), validate: validateExpression, defaultActionMessage: i18n.translate( diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/index.ts b/x-pack/plugins/infra/public/alerting/metric_threshold/index.ts index a48837792a3ccb..05c69e5ccb78bd 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/index.ts +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/index.ts @@ -21,6 +21,9 @@ export function createMetricThresholdAlertType(): AlertTypeModel { defaultMessage: 'Alert when the metrics aggregation exceeds the threshold.', }), iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/observability/${docLinks.DOC_LINK_VERSION}/metrics-threshold-alert.html`; + }, alertParamsExpression: React.lazy(() => import('./components/expression')), validate: validateMetricThreshold, defaultActionMessage: i18n.translate( diff --git a/x-pack/plugins/ingest_manager/common/services/package_to_package_policy.test.ts b/x-pack/plugins/ingest_manager/common/services/package_to_package_policy.test.ts index 8927b5ab3ca4b8..91396bce359b04 100644 --- a/x-pack/plugins/ingest_manager/common/services/package_to_package_policy.test.ts +++ b/x-pack/plugins/ingest_manager/common/services/package_to_package_policy.test.ts @@ -25,7 +25,7 @@ describe('Ingest Manager - packageToPackagePolicy', () => { dashboard: [], visualization: [], search: [], - 'index-pattern': [], + index_pattern: [], map: [], }, }, 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 a32322ecff62a9..c5fc208bfb2dc8 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/epm.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/epm.ts @@ -35,7 +35,21 @@ export type ServiceName = 'kibana' | 'elasticsearch'; export type AgentAssetType = typeof agentAssetTypes; export type AssetType = KibanaAssetType | ElasticsearchAssetType | ValueOf; +/* + Enum mapping of a saved object asset type to how it would appear in a package file path (snake cased) +*/ export enum KibanaAssetType { + dashboard = 'dashboard', + visualization = 'visualization', + search = 'search', + indexPattern = 'index_pattern', + map = 'map', +} + +/* + Enum of saved object types that are allowed to be installed +*/ +export enum KibanaSavedObjectType { dashboard = 'dashboard', visualization = 'visualization', search = 'search', @@ -271,7 +285,7 @@ export type NotInstalled = T & { export type AssetReference = KibanaAssetReference | EsAssetReference; export type KibanaAssetReference = Pick & { - type: KibanaAssetType; + type: KibanaSavedObjectType; }; export type EsAssetReference = Pick & { type: ElasticsearchAssetType; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/constants.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/constants.tsx index da3cab1a4b8a3d..1dad25e9cf0595 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/constants.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/constants.tsx @@ -20,7 +20,7 @@ export const AssetTitleMap: Record = { ilm_policy: 'ILM Policy', ingest_pipeline: 'Ingest Pipeline', transform: 'Transform', - 'index-pattern': 'Index Pattern', + index_pattern: 'Index Pattern', index_template: 'Index Template', component_template: 'Component Template', search: 'Saved Search', @@ -36,7 +36,7 @@ export const ServiceTitleMap: Record = { export const AssetIcons: Record = { dashboard: 'dashboardApp', - 'index-pattern': 'indexPatternApp', + index_pattern: 'indexPatternApp', search: 'searchProfilerApp', visualization: 'visualizeApp', map: 'mapApp', diff --git a/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts index 652a7789f65a30..f42f5da2695d06 100644 --- a/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts @@ -5,7 +5,7 @@ */ import { RequestHandler, SavedObjectsClientContract } from 'src/core/server'; import { DataStream } from '../../types'; -import { GetDataStreamsResponse, KibanaAssetType } from '../../../common'; +import { GetDataStreamsResponse, KibanaAssetType, KibanaSavedObjectType } from '../../../common'; import { getPackageSavedObjects, getKibanaSavedObject } from '../../services/epm/packages/get'; import { defaultIngestErrorHandler } from '../../errors'; @@ -124,7 +124,7 @@ export const getListHandler: RequestHandler = async (context, request, response) // then pick the dashboards from the package saved object const dashboards = pkgSavedObject[0].attributes?.installed_kibana?.filter( - (o) => o.type === KibanaAssetType.dashboard + (o) => o.type === KibanaSavedObjectType.dashboard ) || []; // and then pick the human-readable titles from the dashboard saved objects const enhancedDashboards = await getEnhancedDashboards( 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 index 91ed489b3a5bbe..395f9c15b3b878 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/archive/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/archive/index.ts @@ -18,7 +18,7 @@ import { import { PackageInvalidArchiveError, PackageUnsupportedMediaTypeError } from '../../../errors'; import { pkgToPkgKey } from '../registry'; import { cacheGet, cacheSet, setArchiveFilelist } from '../registry/cache'; -import { unzipBuffer, untarBuffer, ArchiveEntry } from '../registry/extract'; +import { ArchiveEntry, getBufferExtractor } from '../registry/extract'; export async function loadArchivePackage({ archiveBuffer, @@ -37,24 +37,17 @@ export async function loadArchivePackage({ }; } -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 bufferExtractor = getBufferExtractor({ contentType }); + if (!bufferExtractor) { + throw new PackageUnsupportedMediaTypeError( + `Unsupported media type ${contentType}. Please use 'application/gzip' or 'application/zip'` + ); + } const paths: string[] = []; try { await bufferExtractor(archiveBuffer, filter, (entry: ArchiveEntry) => { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts index 201003629e5ea3..e7b251ef133c5b 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/assets/install.ts @@ -11,17 +11,49 @@ import { } from 'src/core/server'; import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../common'; import * as Registry from '../../registry'; -import { AssetType, KibanaAssetType, AssetReference } from '../../../../types'; +import { + AssetType, + KibanaAssetType, + AssetReference, + AssetParts, + KibanaSavedObjectType, +} from '../../../../types'; import { savedObjectTypes } from '../../packages'; +import { indexPatternTypes } from '../index_pattern/install'; type SavedObjectToBe = Required> & { - type: AssetType; + type: KibanaSavedObjectType; }; export type ArchiveAsset = Pick< SavedObject, 'id' | 'attributes' | 'migrationVersion' | 'references' > & { - type: AssetType; + type: KibanaSavedObjectType; +}; + +// KibanaSavedObjectTypes are used to ensure saved objects being created for a given +// KibanaAssetType have the correct type +const KibanaSavedObjectTypeMapping: Record = { + [KibanaAssetType.dashboard]: KibanaSavedObjectType.dashboard, + [KibanaAssetType.indexPattern]: KibanaSavedObjectType.indexPattern, + [KibanaAssetType.map]: KibanaSavedObjectType.map, + [KibanaAssetType.search]: KibanaSavedObjectType.search, + [KibanaAssetType.visualization]: KibanaSavedObjectType.visualization, +}; + +// Define how each asset type will be installed +const AssetInstallers: Record< + KibanaAssetType, + (args: { + savedObjectsClient: SavedObjectsClientContract; + kibanaAssets: ArchiveAsset[]; + }) => Promise>> +> = { + [KibanaAssetType.dashboard]: installKibanaSavedObjects, + [KibanaAssetType.indexPattern]: installKibanaIndexPatterns, + [KibanaAssetType.map]: installKibanaSavedObjects, + [KibanaAssetType.search]: installKibanaSavedObjects, + [KibanaAssetType.visualization]: installKibanaSavedObjects, }; export async function getKibanaAsset(key: string): Promise { @@ -47,16 +79,22 @@ export function createSavedObjectKibanaAsset(asset: ArchiveAsset): SavedObjectTo export async function installKibanaAssets(options: { savedObjectsClient: SavedObjectsClientContract; pkgName: string; - kibanaAssets: ArchiveAsset[]; + kibanaAssets: Record; }): Promise { const { savedObjectsClient, kibanaAssets } = options; // install the assets const kibanaAssetTypes = Object.values(KibanaAssetType); const installedAssets = await Promise.all( - kibanaAssetTypes.map((assetType) => - installKibanaSavedObjects({ savedObjectsClient, assetType, kibanaAssets }) - ) + kibanaAssetTypes.map((assetType) => { + if (kibanaAssets[assetType]) { + return AssetInstallers[assetType]({ + savedObjectsClient, + kibanaAssets: kibanaAssets[assetType], + }); + } + return []; + }) ); return installedAssets.flat(); } @@ -74,25 +112,50 @@ export const deleteKibanaInstalledRefs = async ( installed_kibana: installedAssetsToSave, }); }; -export async function getKibanaAssets(paths: string[]) { - const isKibanaAssetType = (path: string) => Registry.pathParts(path).type in KibanaAssetType; - const filteredPaths = paths.filter(isKibanaAssetType); - const kibanaAssets = await Promise.all(filteredPaths.map((path) => getKibanaAsset(path))); - return kibanaAssets; +export async function getKibanaAssets( + paths: string[] +): Promise> { + const kibanaAssetTypes = Object.values(KibanaAssetType); + const isKibanaAssetType = (path: string) => { + const parts = Registry.pathParts(path); + + return parts.service === 'kibana' && (kibanaAssetTypes as string[]).includes(parts.type); + }; + + const filteredPaths = paths + .filter(isKibanaAssetType) + .map<[string, AssetParts]>((path) => [path, Registry.pathParts(path)]); + + const assetArrays: Array> = []; + for (const assetType of kibanaAssetTypes) { + const matching = filteredPaths.filter(([path, parts]) => parts.type === assetType); + + assetArrays.push(Promise.all(matching.map(([path]) => path).map(getKibanaAsset))); + } + + const resolvedAssets = await Promise.all(assetArrays); + + const result = {} as Record; + + for (const [index, assetType] of kibanaAssetTypes.entries()) { + const expectedType = KibanaSavedObjectTypeMapping[assetType]; + const properlyTypedAssets = resolvedAssets[index].filter(({ type }) => type === expectedType); + + result[assetType] = properlyTypedAssets; + } + + return result; } + async function installKibanaSavedObjects({ savedObjectsClient, - assetType, kibanaAssets, }: { savedObjectsClient: SavedObjectsClientContract; - assetType: KibanaAssetType; kibanaAssets: ArchiveAsset[]; }) { - const isSameType = (asset: ArchiveAsset) => assetType === asset.type; - const filteredKibanaAssets = kibanaAssets.filter((asset) => isSameType(asset)); const toBeSavedObjects = await Promise.all( - filteredKibanaAssets.map((asset) => createSavedObjectKibanaAsset(asset)) + kibanaAssets.map((asset) => createSavedObjectKibanaAsset(asset)) ); if (toBeSavedObjects.length === 0) { @@ -105,8 +168,23 @@ async function installKibanaSavedObjects({ } } +async function installKibanaIndexPatterns({ + savedObjectsClient, + kibanaAssets, +}: { + savedObjectsClient: SavedObjectsClientContract; + kibanaAssets: ArchiveAsset[]; +}) { + // Filter out any reserved index patterns + const reservedPatterns = indexPatternTypes.map((pattern) => `${pattern}-*`); + + const nonReservedPatterns = kibanaAssets.filter((asset) => !reservedPatterns.includes(asset.id)); + + return installKibanaSavedObjects({ savedObjectsClient, kibanaAssets: nonReservedPatterns }); +} + export function toAssetReference({ id, type }: SavedObject) { - const reference: AssetReference = { id, type: type as KibanaAssetType }; + const reference: AssetReference = { id, type: type as KibanaSavedObjectType }; return reference; } 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 4ca8e9d52c337e..d18f43d62436a4 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 @@ -72,6 +72,7 @@ export interface IndexPatternField { readFromDocValues: boolean; } +export const indexPatternTypes = Object.values(dataTypes); // TODO: use a function overload and make pkgName and pkgVersion required for install/update // and not for an update removal. or separate out the functions export async function installIndexPatterns( @@ -116,7 +117,6 @@ export async function installIndexPatterns( const packageVersionsInfo = await Promise.all(packageVersionsFetchInfoPromise); // for each index pattern type, create an index pattern - const indexPatternTypes = Object.values(dataTypes); indexPatternTypes.forEach(async (indexPatternType) => { // if this is an update because a package is being uninstalled (no pkgkey argument passed) and no other packages are installed, remove the index pattern if (!pkgName && installedPackages.length === 0) { 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 aaff5df39bac31..4ad6fc96218dea 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchAssetType, Installation, KibanaAssetType } from '../../../types'; +import { ElasticsearchAssetType, Installation, KibanaSavedObjectType } from '../../../types'; import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; jest.mock('./install'); @@ -41,7 +41,7 @@ const mockInstallation: SavedObject = { type: 'epm-packages', attributes: { id: 'test-pkg', - installed_kibana: [{ type: KibanaAssetType.dashboard, id: 'dashboard-1' }], + installed_kibana: [{ type: KibanaSavedObjectType.dashboard, id: 'dashboard-1' }], installed_es: [{ type: ElasticsearchAssetType.ingestPipeline, id: 'pipeline' }], es_index_patterns: { pattern: 'pattern-name' }, name: 'test package', 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 a04bfaafe7570b..a41511260c6e72 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { SavedObject } from 'src/core/server'; -import { ElasticsearchAssetType, Installation, KibanaAssetType } from '../../../types'; +import { ElasticsearchAssetType, Installation, KibanaSavedObjectType } from '../../../types'; import { getInstallType } from './install'; const mockInstallation: SavedObject = { @@ -13,7 +13,7 @@ const mockInstallation: SavedObject = { type: 'epm-packages', attributes: { id: 'test-pkg', - installed_kibana: [{ type: KibanaAssetType.dashboard, id: 'dashboard-1' }], + installed_kibana: [{ type: KibanaSavedObjectType.dashboard, id: 'dashboard-1' }], installed_es: [{ type: ElasticsearchAssetType.ingestPipeline, id: 'pipeline' }], es_index_patterns: { pattern: 'pattern-name' }, name: 'test packagek', @@ -30,7 +30,7 @@ const mockInstallationUpdateFail: SavedObject = { type: 'epm-packages', attributes: { id: 'test-pkg', - installed_kibana: [{ type: KibanaAssetType.dashboard, id: 'dashboard-1' }], + installed_kibana: [{ type: KibanaSavedObjectType.dashboard, id: 'dashboard-1' }], installed_es: [{ type: ElasticsearchAssetType.ingestPipeline, id: 'pipeline' }], es_index_patterns: { pattern: 'pattern-name' }, name: 'test packagek', 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 23666162e91ef4..0496a6e9aeef1e 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 @@ -18,6 +18,7 @@ import { KibanaAssetReference, EsAssetReference, InstallType, + KibanaAssetType, } from '../../../types'; import * as Registry from '../registry'; import { @@ -364,9 +365,9 @@ export async function createInstallation(options: { export const saveKibanaAssetsRefs = async ( savedObjectsClient: SavedObjectsClientContract, pkgName: string, - kibanaAssets: ArchiveAsset[] + kibanaAssets: Record ) => { - const assetRefs = kibanaAssets.map(toAssetReference); + const assetRefs = Object.values(kibanaAssets).flat().map(toAssetReference); await savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { installed_kibana: assetRefs, }); 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 4b4fe9540dd956..5db47adc983c2a 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 @@ -12,6 +12,9 @@ import { AssetType, CallESAsCurrentUser, ElasticsearchAssetType, + EsAssetReference, + KibanaAssetReference, + Installation, } from '../../../types'; import { getInstallation, savedObjectTypes } from './index'; import { deletePipeline } from '../elasticsearch/ingest_pipeline/'; @@ -46,7 +49,7 @@ export async function removeInstallation(options: { // Delete the installed assets const installedAssets = [...installation.installed_kibana, ...installation.installed_es]; - await deleteAssets(installedAssets, savedObjectsClient, callCluster); + await deleteAssets(installation, savedObjectsClient, callCluster); // Delete the manager saved object with references to the asset objects // could also update with [] or some other state @@ -64,17 +67,20 @@ export async function removeInstallation(options: { // successful delete's in SO client return {}. return something more useful return installedAssets; } -async function deleteAssets( - installedObjects: AssetReference[], - savedObjectsClient: SavedObjectsClientContract, - callCluster: CallESAsCurrentUser + +function deleteKibanaAssets( + installedObjects: KibanaAssetReference[], + savedObjectsClient: SavedObjectsClientContract ) { - const logger = appContextService.getLogger(); - const deletePromises = installedObjects.map(async ({ id, type }) => { + return installedObjects.map(async ({ id, type }) => { + return savedObjectsClient.delete(type, id); + }); +} + +function deleteESAssets(installedObjects: EsAssetReference[], callCluster: CallESAsCurrentUser) { + return installedObjects.map(async ({ id, type }) => { const assetType = type as AssetType; - if (savedObjectTypes.includes(assetType)) { - return savedObjectsClient.delete(assetType, id); - } else if (assetType === ElasticsearchAssetType.ingestPipeline) { + if (assetType === ElasticsearchAssetType.ingestPipeline) { return deletePipeline(callCluster, id); } else if (assetType === ElasticsearchAssetType.indexTemplate) { return deleteTemplate(callCluster, id); @@ -82,8 +88,22 @@ async function deleteAssets( return deleteTransforms(callCluster, [id]); } }); +} + +async function deleteAssets( + { installed_es: installedEs, installed_kibana: installedKibana }: Installation, + savedObjectsClient: SavedObjectsClientContract, + callCluster: CallESAsCurrentUser +) { + const logger = appContextService.getLogger(); + + const deletePromises: Array> = [ + ...deleteESAssets(installedEs, callCluster), + ...deleteKibanaAssets(installedKibana, savedObjectsClient), + ]; + try { - await Promise.all([...deletePromises]); + await Promise.all(deletePromises); } catch (err) { logger.error(err); } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/extract.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/extract.ts index 6d029b54a63171..b79218638ce247 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/extract.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/extract.ts @@ -17,7 +17,7 @@ export async function untarBuffer( buffer: Buffer, filter = (entry: ArchiveEntry): boolean => true, onEntry = (entry: ArchiveEntry): void => {} -): Promise { +): Promise { const deflatedStream = bufferToStream(buffer); // use tar.list vs .extract to avoid writing to disk const inflateStream = tar.list().on('entry', (entry: tar.FileStat) => { @@ -36,7 +36,7 @@ export async function unzipBuffer( buffer: Buffer, filter = (entry: ArchiveEntry): boolean => true, onEntry = (entry: ArchiveEntry): void => {} -): Promise { +): Promise { const zipfile = await yauzlFromBuffer(buffer, { lazyEntries: true }); zipfile.readEntry(); zipfile.on('entry', async (entry: yauzl.Entry) => { @@ -50,6 +50,26 @@ export async function unzipBuffer( return new Promise((resolve, reject) => zipfile.on('end', resolve).on('error', reject)); } +type BufferExtractor = typeof unzipBuffer | typeof untarBuffer; +export function getBufferExtractor( + args: { contentType: string } | { archivePath: string } +): BufferExtractor | undefined { + if ('contentType' in args) { + if (args.contentType === 'application/gzip') { + return untarBuffer; + } else if (args.contentType === 'application/zip') { + return unzipBuffer; + } + } else if ('archivePath' in args) { + if (args.archivePath.endsWith('.zip')) { + return unzipBuffer; + } + if (args.archivePath.endsWith('.gz')) { + return untarBuffer; + } + } +} + function yauzlFromBuffer(buffer: Buffer, opts: yauzl.Options): Promise { return new Promise((resolve, reject) => yauzl.fromBuffer(buffer, opts, (err?: Error, handle?: yauzl.ZipFile) => 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 ba51636c13f369..a2d5c8147002de 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 @@ -82,14 +82,38 @@ describe('splitPkgKey tests', () => { }); }); -describe('getBufferExtractor', () => { - it('returns unzipBuffer if the archive key ends in .zip', () => { - const extractor = getBufferExtractor('.zip'); +describe('getBufferExtractor called with { archivePath }', () => { + it('returns unzipBuffer if `archivePath` ends in .zip', () => { + const extractor = getBufferExtractor({ archivePath: '.zip' }); expect(extractor).toBe(unzipBuffer); }); - it('returns untarBuffer if the key ends in anything else', () => { - const extractor = getBufferExtractor('.xyz'); + it('returns untarBuffer if `archivePath` ends in .gz', () => { + const extractor = getBufferExtractor({ archivePath: '.gz' }); expect(extractor).toBe(untarBuffer); + const extractor2 = getBufferExtractor({ archivePath: '.tar.gz' }); + expect(extractor2).toBe(untarBuffer); + }); + + it('returns `undefined` if `archivePath` ends in anything else', () => { + const extractor = getBufferExtractor({ archivePath: '.xyz' }); + expect(extractor).toEqual(undefined); + }); +}); + +describe('getBufferExtractor called with { contentType }', () => { + it('returns unzipBuffer if `contentType` is `application/zip`', () => { + const extractor = getBufferExtractor({ contentType: 'application/zip' }); + expect(extractor).toBe(unzipBuffer); + }); + + it('returns untarBuffer if `contentType` is `application/gzip`', () => { + const extractor = getBufferExtractor({ contentType: 'application/gzip' }); + expect(extractor).toBe(untarBuffer); + }); + + it('returns `undefined` if `contentType` ends in anything else', () => { + const extractor = getBufferExtractor({ contentType: '.xyz' }); + expect(extractor).toEqual(undefined); }); }); 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 66f28fe58599a5..e6d14a7846c225 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 @@ -26,14 +26,14 @@ import { setArchiveFilelist, deleteArchiveFilelist, } from './cache'; -import { ArchiveEntry, untarBuffer, unzipBuffer } from './extract'; +import { ArchiveEntry, getBufferExtractor } from './extract'; import { fetchUrl, getResponse, getResponseStream } from './requests'; import { streamToBuffer } from './streams'; import { getRegistryUrl } from './registry_url'; import { appContextService } from '../..'; import { PackageNotFoundError, PackageCacheError } from '../../../errors'; -export { ArchiveEntry } from './extract'; +export { ArchiveEntry, getBufferExtractor } from './extract'; export interface SearchParams { category?: CategoryId; @@ -139,7 +139,10 @@ export async function unpackRegistryPackageToCache( ): Promise { const paths: string[] = []; const { archiveBuffer, archivePath } = await fetchArchiveBuffer(pkgName, pkgVersion); - const bufferExtractor = getBufferExtractor(archivePath); + const bufferExtractor = getBufferExtractor({ archivePath }); + if (!bufferExtractor) { + throw new Error('Unknown compression format. Please use .zip or .gz'); + } await bufferExtractor(archiveBuffer, filter, (entry: ArchiveEntry) => { const { path, buffer } = entry; const { file } = pathParts(path); @@ -199,13 +202,6 @@ export function pathParts(path: string): AssetParts { } as AssetParts; } -export function getBufferExtractor(archivePath: string) { - const isZip = archivePath.endsWith('.zip'); - const bufferExtractor = isZip ? unzipBuffer : untarBuffer; - - return bufferExtractor; -} - export async function ensureCachedArchiveInfo( name: string, version: string, @@ -242,10 +238,12 @@ export function getAsset(key: string) { } export function groupPathsByService(paths: string[]): AssetsGroupedByServiceByType { + const kibanaAssetTypes = Object.values(KibanaAssetType); + // ASK: best way, if any, to avoid `any`? const assets = paths.reduce((map: any, path) => { const parts = pathParts(path.replace(/^\/package\//, '')); - if (parts.type in KibanaAssetType) { + if (parts.service === 'kibana' && kibanaAssetTypes.includes(parts.type)) { if (!map[parts.service]) map[parts.service] = {}; if (!map[parts.service][parts.type]) map[parts.service][parts.type] = []; map[parts.service][parts.type].push(parts); diff --git a/x-pack/plugins/ingest_manager/server/types/index.tsx b/x-pack/plugins/ingest_manager/server/types/index.tsx index 3518daa1aba631..5cf43d2830489a 100644 --- a/x-pack/plugins/ingest_manager/server/types/index.tsx +++ b/x-pack/plugins/ingest_manager/server/types/index.tsx @@ -56,6 +56,7 @@ export { AssetType, Installable, KibanaAssetType, + KibanaSavedObjectType, AssetParts, AssetsGroupedByServiceByType, CategoryId, 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 5658f029c48ab8..9f9d7fef9c7b4f 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 @@ -139,6 +139,44 @@ describe('embeddable', () => { | expression`); }); + it('should initialize output with deduped list of index patterns', async () => { + attributeService = attributeServiceMockFromSavedVis({ + ...savedVis, + references: [ + { type: 'index-pattern', id: '123', name: 'abc' }, + { type: 'index-pattern', id: '123', name: 'def' }, + { type: 'index-pattern', id: '456', name: 'ghi' }, + ], + }); + const embeddable = new Embeddable( + { + timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter, + attributeService, + expressionRenderer, + basePath, + indexPatternService: ({ + get: (id: string) => Promise.resolve({ id }), + } as unknown) as IndexPatternsContract, + editable: true, + getTrigger, + documentToExpression: () => + Promise.resolve({ + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }), + }, + {} as LensEmbeddableInput + ); + await embeddable.initializeSavedVis({} as LensEmbeddableInput); + const outputIndexPatterns = embeddable.getOutput().indexPatterns!; + expect(outputIndexPatterns.length).toEqual(2); + expect(outputIndexPatterns[0].id).toEqual('123'); + expect(outputIndexPatterns[1].id).toEqual('456'); + }); + it('should re-render if new input is pushed', async () => { const timeRange: TimeRange = { from: 'now-15d', to: 'now' }; const query: Query = { language: 'kquery', query: '' }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index fdb267835f44c9..33e5dee99081ff 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -259,8 +259,10 @@ export class Embeddable if (!this.savedVis) { return; } - const promises = this.savedVis.references - .filter(({ type }) => type === 'index-pattern') + const promises = _.uniqBy( + this.savedVis.references.filter(({ type }) => type === 'index-pattern'), + 'id' + ) .map(async ({ id }) => { try { return await this.deps.indexPatternService.get(id); diff --git a/x-pack/plugins/monitoring/public/alerts/cpu_usage_alert/cpu_usage_alert.tsx b/x-pack/plugins/monitoring/public/alerts/cpu_usage_alert/cpu_usage_alert.tsx index 11ba8214ff81eb..5054c47245f0fc 100644 --- a/x-pack/plugins/monitoring/public/alerts/cpu_usage_alert/cpu_usage_alert.tsx +++ b/x-pack/plugins/monitoring/public/alerts/cpu_usage_alert/cpu_usage_alert.tsx @@ -16,6 +16,9 @@ export function createCpuUsageAlertType(): AlertTypeModel { name: ALERT_DETAILS[ALERT_CPU_USAGE].label, description: ALERT_DETAILS[ALERT_CPU_USAGE].description, iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-cpu-threshold`; + }, alertParamsExpression: (props: Props) => ( ), diff --git a/x-pack/plugins/monitoring/public/alerts/disk_usage_alert/index.tsx b/x-pack/plugins/monitoring/public/alerts/disk_usage_alert/index.tsx index 7c44e37904ec50..00b70658e4289a 100644 --- a/x-pack/plugins/monitoring/public/alerts/disk_usage_alert/index.tsx +++ b/x-pack/plugins/monitoring/public/alerts/disk_usage_alert/index.tsx @@ -18,6 +18,9 @@ export function createDiskUsageAlertType(): AlertTypeModel { name: ALERT_DETAILS[ALERT_DISK_USAGE].label, description: ALERT_DETAILS[ALERT_DISK_USAGE].description, iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-disk-usage-threshold`; + }, alertParamsExpression: (props: Props) => ( ), diff --git a/x-pack/plugins/monitoring/public/alerts/legacy_alert/legacy_alert.tsx b/x-pack/plugins/monitoring/public/alerts/legacy_alert/legacy_alert.tsx index ca7af2fe64e782..c8d0a7a5d49f2a 100644 --- a/x-pack/plugins/monitoring/public/alerts/legacy_alert/legacy_alert.tsx +++ b/x-pack/plugins/monitoring/public/alerts/legacy_alert/legacy_alert.tsx @@ -18,6 +18,9 @@ export function createLegacyAlertTypes(): AlertTypeModel[] { name: LEGACY_ALERT_DETAILS[legacyAlert].label, description: LEGACY_ALERT_DETAILS[legacyAlert].description, iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/cluster-alerts.html`; + }, alertParamsExpression: () => ( 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 index 14fb7147179c13..062c32c7587942 100644 --- a/x-pack/plugins/monitoring/public/alerts/memory_usage_alert/index.tsx +++ b/x-pack/plugins/monitoring/public/alerts/memory_usage_alert/index.tsx @@ -18,6 +18,9 @@ export function createMemoryUsageAlertType(): AlertTypeModel { name: ALERT_DETAILS[ALERT_MEMORY_USAGE].label, description: ALERT_DETAILS[ALERT_MEMORY_USAGE].description, iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-jvm-memory-threshold`; + }, alertParamsExpression: (props: Props) => ( ), diff --git a/x-pack/plugins/monitoring/public/alerts/missing_monitoring_data_alert/missing_monitoring_data_alert.tsx b/x-pack/plugins/monitoring/public/alerts/missing_monitoring_data_alert/missing_monitoring_data_alert.tsx index 4c8f00f8385c26..ec97a45a8a8005 100644 --- a/x-pack/plugins/monitoring/public/alerts/missing_monitoring_data_alert/missing_monitoring_data_alert.tsx +++ b/x-pack/plugins/monitoring/public/alerts/missing_monitoring_data_alert/missing_monitoring_data_alert.tsx @@ -16,6 +16,9 @@ export function createMissingMonitoringDataAlertType(): AlertTypeModel { name: ALERT_DETAILS[ALERT_MISSING_MONITORING_DATA].label, description: ALERT_DETAILS[ALERT_MISSING_MONITORING_DATA].description, iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/kibana-alerts.html#kibana-alerts-missing-monitoring-data`; + }, alertParamsExpression: (props: any) => ( ( <> 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 9e46a537030412..69094cad7456e9 100644 --- a/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts @@ -13,7 +13,7 @@ import { OVERVIEW_URL } from '../urls/navigation'; describe('Overview Page', () => { it('Host stats render with correct values', () => { - cy.stubSearchStrategyApi('overviewHostQuery', 'overview_search_strategy'); + cy.stubSearchStrategyApi('overview_search_strategy'); loginAndWaitForPage(OVERVIEW_URL); expandHostStats(); @@ -23,7 +23,7 @@ describe('Overview Page', () => { }); it('Network stats render with correct values', () => { - cy.stubSearchStrategyApi('overviewNetworkQuery', 'overview_search_strategy'); + cy.stubSearchStrategyApi('overview_search_strategy'); loginAndWaitForPage(OVERVIEW_URL); expandNetworkStats(); diff --git a/x-pack/plugins/security_solution/cypress/support/commands.js b/x-pack/plugins/security_solution/cypress/support/commands.js index dbd60cdd31a5a6..e13a76736205c7 100644 --- a/x-pack/plugins/security_solution/cypress/support/commands.js +++ b/x-pack/plugins/security_solution/cypress/support/commands.js @@ -40,7 +40,6 @@ Cypress.Commands.add('stubSecurityApi', function (dataFileName) { }); Cypress.Commands.add('stubSearchStrategyApi', function ( - queryId, dataFileName, searchStrategyName = 'securitySolutionSearchStrategy' ) { @@ -49,7 +48,7 @@ Cypress.Commands.add('stubSearchStrategyApi', function ( }); cy.server(); cy.fixture(dataFileName).as(`${dataFileName}JSON`); - cy.route('POST', `internal/search/${searchStrategyName}/${queryId}`, `@${dataFileName}JSON`); + cy.route('POST', `internal/search/${searchStrategyName}`, `@${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 0cf3cf614cdb9e..fb55a2890c8b7f 100644 --- a/x-pack/plugins/security_solution/cypress/support/index.d.ts +++ b/x-pack/plugins/security_solution/cypress/support/index.d.ts @@ -8,11 +8,7 @@ declare namespace Cypress { interface Chainable { promisify(): Promise; stubSecurityApi(dataFileName: string): Chainable; - stubSearchStrategyApi( - queryId: string, - dataFileName: string, - searchStrategyName?: string - ): Chainable; + stubSearchStrategyApi(dataFileName: string, searchStrategyName?: string): Chainable; attachFile(fileName: string, fileType?: string): Chainable; waitUntil( fn: (subject: Subject) => boolean | Chainable, diff --git a/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx b/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx index d68ab3a171151e..7555f6e7342145 100644 --- a/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/query_bar/index.tsx @@ -62,7 +62,10 @@ export const QueryBar = memo( const [draftQuery, setDraftQuery] = useState(filterQuery); useEffect(() => { - // Reset draftQuery when `Create new timeline` is clicked + setDraftQuery(filterQuery); + }, [filterQuery]); + + useEffect(() => { if (filterQueryDraft == null) { setDraftQuery(filterQuery); } diff --git a/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx b/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx index 2dc44fd48e66dc..acc01ac4f76aa8 100644 --- a/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/search_bar/index.tsx @@ -132,7 +132,7 @@ export const SearchBarComponent = memo( if (!isStateUpdated) { // That mean we are doing a refresh! - if (isQuickSelection) { + if (isQuickSelection && payload.dateRange.to !== payload.dateRange.from) { updateSearchBar.updateTime = true; updateSearchBar.end = payload.dateRange.to; updateSearchBar.start = payload.dateRange.from; @@ -313,7 +313,7 @@ const makeMapStateToProps = () => { fromStr: getFromStrSelector(inputsRange), filterQuery: getFilterQuerySelector(inputsRange), isLoading: getIsLoadingSelector(inputsRange), - queries: getQueriesSelector(inputsRange), + queries: getQueriesSelector(state, id), savedQuery: getSavedQuerySelector(inputsRange), start: getStartSelector(inputsRange), toStr: getToStrSelector(inputsRange), @@ -351,15 +351,27 @@ export const dispatchUpdateSearch = (dispatch: Dispatch) => ({ const fromDate = formatDate(start); let toDate = formatDate(end, { roundUp: true }); if (isQuickSelection) { - dispatch( - inputsActions.setRelativeRangeDatePicker({ - id, - fromStr: start, - toStr: end, - from: fromDate, - to: toDate, - }) - ); + if (end === start) { + dispatch( + inputsActions.setAbsoluteRangeDatePicker({ + id, + fromStr: start, + toStr: end, + from: fromDate, + to: toDate, + }) + ); + } else { + dispatch( + inputsActions.setRelativeRangeDatePicker({ + id, + fromStr: start, + toStr: end, + from: fromDate, + to: toDate, + }) + ); + } } else { toDate = formatDate(end); dispatch( diff --git a/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx index 956ee4b05f9d65..bcb10f8fd26c33 100644 --- a/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx @@ -139,7 +139,7 @@ describe('SIEM Super Date Picker', () => { expect(store.getState().inputs.global.timerange.toStr).toBe('now'); }); - test('Make Sure it is Today date', () => { + test('Make Sure it is Today date is an absolute date', () => { wrapper .find('[data-test-subj="superDatePickerToggleQuickMenuButton"]') .first() @@ -151,8 +151,22 @@ describe('SIEM Super Date Picker', () => { .first() .simulate('click'); wrapper.update(); - expect(store.getState().inputs.global.timerange.fromStr).toBe('now/d'); - expect(store.getState().inputs.global.timerange.toStr).toBe('now/d'); + expect(store.getState().inputs.global.timerange.kind).toBe('absolute'); + }); + + test('Make Sure it is This Week date is an absolute date', () => { + wrapper + .find('[data-test-subj="superDatePickerToggleQuickMenuButton"]') + .first() + .simulate('click'); + wrapper.update(); + + wrapper + .find('[data-test-subj="superDatePickerCommonlyUsed_This_week"]') + .first() + .simulate('click'); + wrapper.update(); + expect(store.getState().inputs.global.timerange.kind).toBe('absolute'); }); test('Make Sure to (end date) is superior than from (start date)', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.tsx b/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.tsx index 4443d24531b22e..97e023176647f8 100644 --- a/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.tsx @@ -91,12 +91,12 @@ export const SuperDatePickerComponent = React.memo( toStr, updateReduxTime, }) => { - const [isQuickSelection, setIsQuickSelection] = useState(true); const [recentlyUsedRanges, setRecentlyUsedRanges] = useState( [] ); const onRefresh = useCallback( ({ start: newStart, end: newEnd }: OnRefreshProps): void => { + const isQuickSelection = newStart.includes('now') || newEnd.includes('now'); const { kqlHaveBeenUpdated } = updateReduxTime({ end: newEnd, id, @@ -117,12 +117,13 @@ export const SuperDatePickerComponent = React.memo( refetchQuery(queries); } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [end, id, isQuickSelection, kqlQuery, start, timelineId] + [end, id, kqlQuery, queries, start, timelineId, updateReduxTime] ); const onRefreshChange = useCallback( ({ isPaused, refreshInterval }: OnRefreshChangeProps): void => { + const isQuickSelection = + (fromStr != null && fromStr.includes('now')) || (toStr != null && toStr.includes('now')); if (duration !== refreshInterval) { setDuration({ id, duration: refreshInterval }); } @@ -137,27 +138,22 @@ export const SuperDatePickerComponent = React.memo( refetchQuery(queries); } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [id, isQuickSelection, duration, policy, toStr] + [fromStr, toStr, duration, policy, setDuration, id, stopAutoReload, startAutoReload, queries] ); - const refetchQuery = (newQueries: inputsModel.GlobalGraphqlQuery[]) => { + const refetchQuery = (newQueries: inputsModel.GlobalQuery[]) => { newQueries.forEach((q) => q.refetch && (q.refetch as inputsModel.Refetch)()); }; const onTimeChange = useCallback( - ({ - start: newStart, - end: newEnd, - isQuickSelection: newIsQuickSelection, - isInvalid, - }: OnTimeChangeProps) => { + ({ start: newStart, end: newEnd, isInvalid }: OnTimeChangeProps) => { + const isQuickSelection = newStart.includes('now') || newEnd.includes('now'); if (!isInvalid) { updateReduxTime({ end: newEnd, id, isInvalid, - isQuickSelection: newIsQuickSelection, + isQuickSelection, kql: kqlQuery, start: newStart, timelineId, @@ -174,15 +170,13 @@ export const SuperDatePickerComponent = React.memo( ]; setRecentlyUsedRanges(newRecentlyUsedRanges); - setIsQuickSelection(newIsQuickSelection); } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [recentlyUsedRanges, kqlQuery] + [updateReduxTime, id, kqlQuery, timelineId, recentlyUsedRanges] ); - const endDate = kind === 'relative' ? toStr : new Date(end).toISOString(); - const startDate = kind === 'relative' ? fromStr : new Date(start).toISOString(); + const endDate = toStr != null ? toStr : new Date(end).toISOString(); + const startDate = fromStr != null ? fromStr : new Date(start).toISOString(); const [quickRanges] = useUiSetting$(DEFAULT_TIMEPICKER_QUICK_RANGES); const commonlyUsedRanges = isEmpty(quickRanges) @@ -232,15 +226,27 @@ export const dispatchUpdateReduxTime = (dispatch: Dispatch) => ({ const fromDate = formatDate(start); let toDate = formatDate(end, { roundUp: true }); if (isQuickSelection) { - dispatch( - inputsActions.setRelativeRangeDatePicker({ - id, - fromStr: start, - toStr: end, - from: fromDate, - to: toDate, - }) - ); + if (end === start) { + dispatch( + inputsActions.setAbsoluteRangeDatePicker({ + id, + fromStr: start, + toStr: end, + from: fromDate, + to: toDate, + }) + ); + } else { + dispatch( + inputsActions.setRelativeRangeDatePicker({ + id, + fromStr: start, + toStr: end, + from: fromDate, + to: toDate, + }) + ); + } } else { toDate = formatDate(end); dispatch( @@ -284,6 +290,7 @@ export const makeMapStateToProps = () => { const getToStrSelector = toStrSelector(); return (state: State, { id }: OwnProps) => { const inputsRange: InputsRange = getOr({}, `inputs.${id}`, state); + return { duration: getDurationSelector(inputsRange), end: getEndSelector(inputsRange), @@ -292,7 +299,7 @@ export const makeMapStateToProps = () => { kind: getKindSelector(inputsRange), kqlQuery: getKqlQuerySelector(inputsRange) as inputsModel.GlobalKqlQuery, policy: getPolicySelector(inputsRange), - queries: getQueriesSelector(inputsRange) as inputsModel.GlobalGraphqlQuery[], + queries: getQueriesSelector(state, id), start: getStartSelector(inputsRange), toStr: getToStrSelector(inputsRange), }; diff --git a/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.test.ts b/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.test.ts index 7cb4ea9ada93fd..ee19aef717f4f8 100644 --- a/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.test.ts @@ -17,6 +17,8 @@ import { } from './selectors'; import { InputsRange, AbsoluteTimeRange, RelativeTimeRange } from '../../store/inputs/model'; import { cloneDeep } from 'lodash/fp'; +import { mockGlobalState } from '../../mock'; +import { State } from '../../store'; describe('selectors', () => { let absoluteTime: AbsoluteTimeRange = { @@ -42,6 +44,8 @@ describe('selectors', () => { filters: [], }; + let mockState: State = mockGlobalState; + const getPolicySelector = policySelector(); const getDurationSelector = durationSelector(); const getKindSelector = kindSelector(); @@ -75,6 +79,8 @@ describe('selectors', () => { }, filters: [], }; + + mockState = mockGlobalState; }); describe('#policySelector', () => { @@ -375,34 +381,61 @@ describe('selectors', () => { describe('#queriesSelector', () => { test('returns the same reference given the same identical input twice', () => { - const result1 = getQueriesSelector(inputState); - const result2 = getQueriesSelector(inputState); + const myMock = { + ...mockState, + inputs: { + ...mockState.inputs, + global: inputState, + }, + }; + const result1 = getQueriesSelector(myMock, 'global'); + const result2 = getQueriesSelector(myMock, 'global'); expect(result1).toBe(result2); }); test('DOES NOT return the same reference given different input twice but with different deep copies since the query is not a primitive', () => { - const clone = cloneDeep(inputState); - const result1 = getQueriesSelector(inputState); - const result2 = getQueriesSelector(clone); + const myMock = { + ...mockState, + inputs: { + ...mockState.inputs, + global: inputState, + }, + }; + const clone = cloneDeep(myMock); + const result1 = getQueriesSelector(myMock, 'global'); + const result2 = getQueriesSelector(clone, 'global'); expect(result1).not.toBe(result2); }); test('returns a different reference even if the contents are the same since query is an array and not a primitive', () => { - const result1 = getQueriesSelector(inputState); - const change: InputsRange = { - ...inputState, - queries: [ - { - loading: false, - id: '1', - inspect: { dsl: [], response: [] }, - isInspected: false, - refetch: null, - selectedInspectIndex: 0, + const myMock = { + ...mockState, + inputs: { + ...mockState.inputs, + global: inputState, + }, + }; + const result1 = getQueriesSelector(myMock, 'global'); + const myMockChange: State = { + ...myMock, + inputs: { + ...mockState.inputs, + global: { + ...mockState.inputs.global, + queries: [ + { + loading: false, + id: '1', + inspect: { dsl: [], response: [] }, + isInspected: false, + refetch: null, + selectedInspectIndex: 0, + }, + ], }, - ], + }, }; - const result2 = getQueriesSelector(change); + const result2 = getQueriesSelector(myMockChange, 'global'); expect(result1).not.toBe(result2); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.ts b/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.ts index d4b990890ebbad..840dd1f4a6b9f2 100644 --- a/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.ts @@ -4,7 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty } from 'lodash'; import { createSelector } from 'reselect'; +import { State } from '../../store'; +import { InputsModelId } from '../../store/inputs/constants'; import { Policy, InputsRange, TimeRange, GlobalQuery } from '../../store/inputs/model'; export const getPolicy = (inputState: InputsRange): Policy => inputState.policy; @@ -13,6 +16,16 @@ export const getTimerange = (inputState: InputsRange): TimeRange => inputState.t export const getQueries = (inputState: InputsRange): GlobalQuery[] => inputState.queries; +export const getGlobalQueries = (state: State, id: InputsModelId): GlobalQuery[] => { + const inputsRange = state.inputs[id]; + return !isEmpty(inputsRange.linkTo) + ? inputsRange.linkTo.reduce((acc, linkToId) => { + const linkToIdInputsRange: InputsRange = state.inputs[linkToId]; + return [...acc, ...linkToIdInputsRange.queries]; + }, inputsRange.queries) + : inputsRange.queries; +}; + export const policySelector = () => createSelector(getPolicy, (policy) => policy.kind); export const durationSelector = () => createSelector(getPolicy, (policy) => policy.duration); @@ -31,7 +44,7 @@ export const isLoadingSelector = () => createSelector(getQueries, (queries) => queries.some((i) => i.loading === true)); export const queriesSelector = () => - createSelector(getQueries, (queries) => queries.filter((q) => q.id !== 'kql')); + createSelector(getGlobalQueries, (queries) => queries.filter((q) => q.id !== 'kql')); export const kqlQuerySelector = () => createSelector(getQueries, (queries) => queries.find((q) => q.id === 'kql')); diff --git a/x-pack/plugins/security_solution/public/common/store/inputs/actions.ts b/x-pack/plugins/security_solution/public/common/store/inputs/actions.ts index 5d00882f778c07..db911365972157 100644 --- a/x-pack/plugins/security_solution/public/common/store/inputs/actions.ts +++ b/x-pack/plugins/security_solution/public/common/store/inputs/actions.ts @@ -16,6 +16,8 @@ export const setAbsoluteRangeDatePicker = actionCreator<{ id: InputsModelId; from: string; to: string; + fromStr?: string; + toStr?: string; }>('SET_ABSOLUTE_RANGE_DATE_PICKER'); export const setTimelineRangeDatePicker = actionCreator<{ diff --git a/x-pack/plugins/security_solution/public/common/store/inputs/model.ts b/x-pack/plugins/security_solution/public/common/store/inputs/model.ts index a8db48c7b31bba..f4e2c2f28f4776 100644 --- a/x-pack/plugins/security_solution/public/common/store/inputs/model.ts +++ b/x-pack/plugins/security_solution/public/common/store/inputs/model.ts @@ -11,8 +11,8 @@ import { Query, Filter, SavedQuery } from '../../../../../../../src/plugins/data export interface AbsoluteTimeRange { kind: 'absolute'; - fromStr: undefined; - toStr: undefined; + fromStr?: string; + toStr?: string; from: string; to: string; } diff --git a/x-pack/plugins/security_solution/public/common/store/inputs/reducer.ts b/x-pack/plugins/security_solution/public/common/store/inputs/reducer.ts index a94f0f6ca24ee5..59ae029a9207e7 100644 --- a/x-pack/plugins/security_solution/public/common/store/inputs/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/inputs/reducer.ts @@ -149,16 +149,19 @@ export const inputsReducer = reducerWithInitialState(initialInputsState) }, }; }) - .case(setAbsoluteRangeDatePicker, (state, { id, from, to }) => { - const timerange: TimeRange = { - kind: 'absolute', - fromStr: undefined, - toStr: undefined, - from, - to, - }; - return updateInputTimerange(id, timerange, state); - }) + .case( + setAbsoluteRangeDatePicker, + (state, { id, from, to, fromStr = undefined, toStr = undefined }) => { + const timerange: TimeRange = { + kind: 'absolute', + fromStr, + toStr, + from, + to, + }; + return updateInputTimerange(id, timerange, state); + } + ) .case(setRelativeRangeDatePicker, (state, { id, fromStr, from, to, toStr }) => { const timerange: TimeRange = { kind: 'relative', diff --git a/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts b/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts index e7bd6234cb207b..6ebc00133c0cdc 100644 --- a/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts @@ -86,18 +86,25 @@ export const defaultIndexNamesSelector = () => { return mapStateToProps; }; -const EXLCUDE_ELASTIC_CLOUD_INDEX = '-*elastic-cloud-logs-*'; +const EXCLUDE_ELASTIC_CLOUD_INDEX = '-*elastic-cloud-logs-*'; export const getSourcererScopeSelector = () => { const getScopesSelector = scopesSelector(); - const mapStateToProps = (state: State, scopeId: SourcererScopeName): ManageScope => ({ - ...getScopesSelector(state)[scopeId], - selectedPatterns: getScopesSelector(state)[scopeId].selectedPatterns.some( + const mapStateToProps = (state: State, scopeId: SourcererScopeName): ManageScope => { + const selectedPatterns = getScopesSelector(state)[scopeId].selectedPatterns.some( (index) => index === 'logs-*' ) - ? [...getScopesSelector(state)[scopeId].selectedPatterns, EXLCUDE_ELASTIC_CLOUD_INDEX] - : getScopesSelector(state)[scopeId].selectedPatterns, - }); + ? [...getScopesSelector(state)[scopeId].selectedPatterns, EXCLUDE_ELASTIC_CLOUD_INDEX] + : getScopesSelector(state)[scopeId].selectedPatterns; + return { + ...getScopesSelector(state)[scopeId], + selectedPatterns, + indexPattern: { + ...getScopesSelector(state)[scopeId].indexPattern, + title: selectedPatterns.join(), + }, + }; + }; return mapStateToProps; }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.test.tsx index bf89cc7fa9084a..1d8d0f789d6b7e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.test.tsx @@ -20,6 +20,7 @@ import { reputationRenderer, DefaultFieldRenderer, DEFAULT_MORE_MAX_HEIGHT, + DefaultFieldRendererOverflow, MoreContainer, } from './field_renderers'; import { mockData } from '../../../network/components/details/mock'; @@ -330,4 +331,45 @@ describe('Field Renderers', () => { expect(render).toHaveBeenCalledTimes(2); }); }); + + describe('DefaultFieldRendererOverflow', () => { + const idPrefix = 'prefix-1'; + const rowItems = ['item1', 'item2', 'item3', 'item4', 'item5', 'item6', 'item7']; + + test('it should render the length of items after the overflowIndexStart', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.text()).toEqual(' ,+2 More'); + expect(wrapper.find('[data-test-subj="more-container"]').first().exists()).toBe(false); + }); + + test('it should render the items after overflowIndexStart in the popover', () => { + const wrapper = mount( + + + + ); + + wrapper.find('button').first().simulate('click'); + wrapper.update(); + expect(wrapper.find('.euiPopover').first().exists()).toBe(true); + expect(wrapper.find('[data-test-subj="more-container"]').first().text()).toEqual( + 'item6item7' + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.tsx b/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.tsx index cb913287b24d89..7f543ab859bb4a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.tsx @@ -260,12 +260,12 @@ MoreContainer.displayName = 'MoreContainer'; export const DefaultFieldRendererOverflow = React.memo( ({ idPrefix, moreMaxHeight, overflowIndexStart = 5, render, rowItems }) => { const [isOpen, setIsOpen] = useState(false); - const handleClose = useCallback(() => setIsOpen(false), []); + const togglePopover = useCallback(() => setIsOpen((currentIsOpen) => !currentIsOpen), []); const button = useMemo( () => ( <> {' ,'} - + {`+${rowItems.length - overflowIndexStart} `} ), - [handleClose, overflowIndexStart, rowItems.length] + [togglePopover, overflowIndexStart, rowItems.length] ); return ( @@ -284,7 +284,7 @@ export const DefaultFieldRendererOverflow = React.memo = ({ columnHeaders={columnHeaders} columnRenderers={columnRenderers} containerElementRef={containerElementRef} - disableSensorVisibility={data != null && data.length < 101} docValueFields={docValueFields} event={event} eventIdToNoteIds={eventIdToNoteIds} 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 4f385a46564833..83e824aa2450a6 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 @@ -6,7 +6,6 @@ import React, { useRef, useState, useCallback } from 'react'; import uuid from 'uuid'; -import VisibilitySensor from 'react-visibility-sensor'; import { BrowserFields, DocValueFields } from '../../../../../common/containers/source'; import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector'; @@ -19,7 +18,6 @@ import { import { Note } from '../../../../../common/lib/note'; import { ColumnHeaderOptions, TimelineModel } from '../../../../../timelines/store/timeline/model'; import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers'; -import { SkeletonRow } from '../../skeleton_row'; import { OnColumnResized, OnPinEvent, @@ -38,6 +36,8 @@ import { NoteCards } from '../../../notes/note_cards'; import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context'; import { EventColumnView } from './event_column_view'; import { inputsModel } from '../../../../../common/store'; +import { TimelineId } from '../../../../../../common/types/timeline'; +import { activeTimeline } from '../../../../containers/active_timeline_context'; interface Props { actionsColumnWidth: number; @@ -46,7 +46,6 @@ interface Props { browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; columnRenderers: ColumnRenderer[]; - disableSensorVisibility: boolean; docValueFields: DocValueFields[]; event: TimelineItem; eventIdToNoteIds: Readonly>; @@ -73,33 +72,6 @@ export const getNewNoteId = (): string => uuid.v4(); const emptyDetails: TimelineEventsDetailsItem[] = []; -/** - * This is the default row height whenever it is a plain row renderer and not a custom row height. - * We use this value when we do not know the height of a particular row. - */ -const DEFAULT_ROW_HEIGHT = '32px'; - -/** - * This is the top offset in pixels of the top part of the timeline. The UI area where you do your - * drag and drop and filtering. It is a positive number in pixels of _PART_ of the header but not - * the entire header. We leave room for some rows to render behind the drag and drop so they might be - * visible by the time the user scrolls upwards. All other DOM elements are replaced with their "blank" - * rows. - */ -const TOP_OFFSET = 50; - -/** - * This is the bottom offset in pixels of the bottom part of the timeline. The UI area right below the - * timeline which is the footer. Since the footer is so incredibly small we don't have enough room to - * render around 5 rows below the timeline to get the user the best chance of always scrolling without seeing - * "blank rows". The negative number is to give the bottom of the browser window a bit of invisible space to - * keep around 5 rows rendering below it. All other DOM elements are replaced with their "blank" - * rows. - */ -const BOTTOM_OFFSET = -500; - -const VISIBILITY_SENSOR_OFFSET = { top: TOP_OFFSET, bottom: BOTTOM_OFFSET }; - const emptyNotes: string[] = []; const EventsTrSupplementContainerWrapper = React.memo(({ children }) => { @@ -116,7 +88,6 @@ const StatefulEventComponent: React.FC = ({ containerElementRef, columnHeaders, columnRenderers, - disableSensorVisibility = true, docValueFields, event, eventIdToNoteIds, @@ -138,7 +109,9 @@ const StatefulEventComponent: React.FC = ({ toggleColumn, updateNote, }) => { - const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>({}); + const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>( + timelineId === TimelineId.active ? activeTimeline.getExpandedEventIds() : {} + ); const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); const { status: timelineStatus } = useShallowEqualSelector( (state) => state.timeline.timelineById[timelineId] @@ -148,21 +121,21 @@ const StatefulEventComponent: React.FC = ({ docValueFields, indexName: event._index!, eventId: event._id, - skip: !expanded[event._id], + skip: !expanded || !expanded[event._id], }); const onToggleShowNotes = useCallback(() => { const eventId = event._id; - setShowNotes({ ...showNotes, [eventId]: !showNotes[eventId] }); - }, [event, showNotes]); + setShowNotes((prevShowNotes) => ({ ...prevShowNotes, [eventId]: !prevShowNotes[eventId] })); + }, [event]); const onToggleExpanded = useCallback(() => { const eventId = event._id; - setExpanded({ - ...expanded, - [eventId]: !expanded[eventId], - }); - }, [event, expanded]); + setExpanded((prevExpanded) => ({ ...prevExpanded, [eventId]: !prevExpanded[eventId] })); + if (timelineId === TimelineId.active) { + activeTimeline.toggleExpandedEvent(eventId); + } + }, [event._id, timelineId]); const associateNote = useCallback( (noteId: string) => { @@ -174,152 +147,87 @@ const StatefulEventComponent: React.FC = ({ [addNoteToEvent, event, isEventPinned, onPinEvent] ); - // Number of current columns plus one for actions. - const columnCount = columnHeaders.length + 1; - - const VisibilitySensorContent = useCallback( - ({ isVisible }) => { - if (isVisible || disableSensorVisibility) { - return ( - - - - - - - - - {getRowRenderer(event.ecs, rowRenderers).renderRow({ - browserFields, - data: event.ecs, - timelineId, - })} - - - - - - - ); - } else { - // Height place holder for visibility detection as well as re-rendering sections. - const height = - divElement.current != null && divElement.current!.clientHeight - ? `${divElement.current!.clientHeight}px` - : DEFAULT_ROW_HEIGHT; - - return ; - } - }, - [ - actionsColumnWidth, - associateNote, - browserFields, - columnCount, - columnHeaders, - columnRenderers, - detailsData, - disableSensorVisibility, - event._id, - event.data, - event.ecs, - eventIdToNoteIds, - expanded, - getNotesByIds, - isEventPinned, - isEventViewer, - loading, - loadingEventIds, - onColumnResized, - onPinEvent, - onRowSelected, - onToggleExpanded, - onToggleShowNotes, - onUnPinEvent, - onUpdateColumns, - refetch, - onRuleChange, - rowRenderers, - selectedEventIds, - showCheckboxes, - showNotes, - timelineId, - timelineStatus, - toggleColumn, - updateNote, - ] - ); - return ( - - {VisibilitySensorContent} - + + + + + + + + {getRowRenderer(event.ecs, rowRenderers).renderRow({ + browserFields, + data: event.ecs, + timelineId, + })} + + + + + + ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index 0c7b1e0cdecd5e..35d31e034e7f38 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -27,6 +27,11 @@ export interface OwnProps { export type Props = OwnProps & PropsFromRedux; +const isTimerangeSame = (prevProps: Props, nextProps: Props) => + prevProps.end === nextProps.end && + prevProps.start === nextProps.start && + prevProps.timerangeKind === nextProps.timerangeKind; + const StatefulTimelineComponent = React.memo( ({ columns, @@ -51,6 +56,7 @@ const StatefulTimelineComponent = React.memo( start, status, timelineType, + timerangeKind, updateItemsPerPage, upsertColumn, usersViewing, @@ -125,13 +131,14 @@ const StatefulTimelineComponent = React.memo( status={status} toggleColumn={toggleColumn} timelineType={timelineType} + timerangeKind={timerangeKind} usersViewing={usersViewing} /> ); }, (prevProps, nextProps) => { return ( - prevProps.end === nextProps.end && + isTimerangeSame(prevProps, nextProps) && prevProps.graphEventId === nextProps.graphEventId && prevProps.id === nextProps.id && prevProps.isLive === nextProps.isLive && @@ -142,7 +149,6 @@ const StatefulTimelineComponent = React.memo( prevProps.kqlQueryExpression === nextProps.kqlQueryExpression && prevProps.show === nextProps.show && prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg && - prevProps.start === nextProps.start && prevProps.timelineType === nextProps.timelineType && prevProps.status === nextProps.status && deepEqual(prevProps.columns, nextProps.columns) && @@ -209,6 +215,7 @@ const makeMapStateToProps = () => { start: input.timerange.from, status, timelineType, + timerangeKind: input.timerange.kind, }; }; return mapStateToProps; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx index 630a71693d182c..7fc269c954ac40 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx @@ -116,6 +116,7 @@ describe('Timeline', () => { start: startDate, status: TimelineStatus.active, timelineType: TimelineType.default, + timerangeKind: 'absolute', toggleColumn: jest.fn(), usersViewing: ['elastic'], }; 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 b860011c2ddaff..f7c76c110ac3f1 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 @@ -112,6 +112,7 @@ export interface Props { start: string; status: TimelineStatusLiteral; timelineType: TimelineType; + timerangeKind: 'absolute' | 'relative'; toggleColumn: (column: ColumnHeaderOptions) => void; usersViewing: string[]; } @@ -143,6 +144,7 @@ export const TimelineComponent: React.FC = ({ status, sort, timelineType, + timerangeKind, toggleColumn, usersViewing, }) => { @@ -212,6 +214,7 @@ export const TimelineComponent: React.FC = ({ startDate: start, skip: !canQueryTimeline, sort: timelineQuerySortField, + timerangeKind, }); useEffect(() => { diff --git a/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts b/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts new file mode 100644 index 00000000000000..50bf8b37adf28d --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/containers/active_timeline_context.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TimelineArgs } from '.'; +import { TimelineEventsAllRequestOptions } from '../../../common/search_strategy/timeline'; + +/* + * Future Engineer + * This class is just there to manage temporarily the reload of the active timeline when switching tabs + * because of the bootstrap of the security solution app, we will always trigger the query + * to avoid it we will cache its request and response so we can go back where the user was before switching tabs + * + * !!! Important !!! this is just there until, we will have a better way to bootstrap the app + * I did not want to put in the store because I was feeling it will feel less temporarily and I did not want other engineer using it + * + */ +class ActiveTimelineEvents { + private _activePage: number = 0; + private _expandedEventIds: Record = {}; + private _pageName: string = ''; + private _request: TimelineEventsAllRequestOptions | null = null; + private _response: TimelineArgs | null = null; + + getActivePage() { + return this._activePage; + } + + setActivePage(activePage: number) { + this._activePage = activePage; + } + + getExpandedEventIds() { + return this._expandedEventIds; + } + + toggleExpandedEvent(eventId: string) { + this._expandedEventIds = { + ...this._expandedEventIds, + [eventId]: !this._expandedEventIds[eventId], + }; + } + + setExpandedEventIds(expandedEventIds: Record) { + this._expandedEventIds = expandedEventIds; + } + + getPageName() { + return this._pageName; + } + + setPageName(pageName: string) { + this._pageName = pageName; + } + + getRequest() { + return this._request; + } + + setRequest(req: TimelineEventsAllRequestOptions) { + this._request = req; + } + + getResponse() { + return this._response; + } + + setResponse(resp: TimelineArgs | null) { + this._response = resp; + } +} + +export const activeTimeline = new ActiveTimelineEvents(); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx new file mode 100644 index 00000000000000..a5f8300546b5bd --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.test.tsx @@ -0,0 +1,210 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { initSortDefault, TimelineArgs, useTimelineEvents, UseTimelineEventsProps } from '.'; +import { SecurityPageName } from '../../../common/constants'; +import { TimelineId } from '../../../common/types/timeline'; +import { mockTimelineData } from '../../common/mock'; +import { useRouteSpy } from '../../common/utils/route/use_route_spy'; + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +const mockEvents = mockTimelineData.filter((i, index) => index <= 11); + +const mockSearch = jest.fn(); + +jest.mock('../../common/lib/kibana', () => ({ + useKibana: jest.fn().mockReturnValue({ + services: { + application: { + capabilities: { + siem: { + crud: true, + }, + }, + }, + data: { + search: { + search: jest.fn().mockImplementation((args) => { + mockSearch(); + return { + subscribe: jest.fn().mockImplementation(({ next }) => { + next({ + isRunning: false, + isPartial: false, + inspect: { + dsl: [], + response: [], + }, + edges: mockEvents.map((item) => ({ node: item })), + pageInfo: { + activePage: 0, + totalPages: 10, + }, + rawResponse: {}, + totalCount: mockTimelineData.length, + }); + return { unsubscribe: jest.fn() }; + }), + }; + }), + }, + }, + notifications: { + toasts: { + addWarning: jest.fn(), + }, + }, + }, + }), +})); + +const mockUseRouteSpy: jest.Mock = useRouteSpy as jest.Mock; +jest.mock('../../common/utils/route/use_route_spy', () => ({ + useRouteSpy: jest.fn(), +})); + +mockUseRouteSpy.mockReturnValue([ + { + pageName: SecurityPageName.overview, + detailName: undefined, + tabName: undefined, + search: '', + pathName: '/overview', + }, +]); + +describe('useTimelineEvents', () => { + beforeEach(() => { + mockSearch.mockReset(); + }); + + const startDate: string = '2020-07-07T08:20:18.966Z'; + const endDate: string = '3000-01-01T00:00:00.000Z'; + const props: UseTimelineEventsProps = { + docValueFields: [], + endDate: '', + id: TimelineId.active, + indexNames: ['filebeat-*'], + fields: [], + filterQuery: '', + startDate: '', + limit: 25, + sort: initSortDefault, + skip: false, + }; + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseTimelineEventsProps, + [boolean, TimelineArgs] + >((args) => useTimelineEvents(args), { + initialProps: { ...props }, + }); + + // useEffect on params request + await waitForNextUpdate(); + expect(result.current).toEqual([ + false, + { + events: [], + id: TimelineId.active, + inspect: result.current[1].inspect, + loadPage: result.current[1].loadPage, + pageInfo: result.current[1].pageInfo, + refetch: result.current[1].refetch, + totalCount: -1, + updatedAt: 0, + }, + ]); + }); + }); + + test('happy path query', async () => { + await act(async () => { + const { result, waitForNextUpdate, rerender } = renderHook< + UseTimelineEventsProps, + [boolean, TimelineArgs] + >((args) => useTimelineEvents(args), { + initialProps: { ...props }, + }); + + // useEffect on params request + await waitForNextUpdate(); + rerender({ ...props, startDate, endDate }); + // useEffect on params request + await waitForNextUpdate(); + + expect(mockSearch).toHaveBeenCalledTimes(1); + expect(result.current).toEqual([ + false, + { + events: mockEvents, + id: TimelineId.active, + inspect: result.current[1].inspect, + loadPage: result.current[1].loadPage, + pageInfo: result.current[1].pageInfo, + refetch: result.current[1].refetch, + totalCount: 31, + updatedAt: result.current[1].updatedAt, + }, + ]); + }); + }); + + test('Mock cache for active timeline when switching page', async () => { + await act(async () => { + const { result, waitForNextUpdate, rerender } = renderHook< + UseTimelineEventsProps, + [boolean, TimelineArgs] + >((args) => useTimelineEvents(args), { + initialProps: { ...props }, + }); + + // useEffect on params request + await waitForNextUpdate(); + rerender({ ...props, startDate, endDate }); + // useEffect on params request + await waitForNextUpdate(); + + mockUseRouteSpy.mockReturnValue([ + { + pageName: SecurityPageName.timelines, + detailName: undefined, + tabName: undefined, + search: '', + pathName: '/timelines', + }, + ]); + + expect(mockSearch).toHaveBeenCalledTimes(1); + + expect(result.current).toEqual([ + false, + { + events: mockEvents, + id: TimelineId.active, + inspect: result.current[1].inspect, + loadPage: result.current[1].loadPage, + pageInfo: result.current[1].pageInfo, + refetch: result.current[1].refetch, + totalCount: 31, + updatedAt: result.current[1].updatedAt, + }, + ]); + }); + }); +}); 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 65f8a3dc78e4db..5f92596f033114 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx @@ -30,6 +30,9 @@ import { } from '../../../common/search_strategy'; import { InspectResponse } from '../../types'; import * as i18n from './translations'; +import { TimelineId } from '../../../common/types/timeline'; +import { useRouteSpy } from '../../common/utils/route/use_route_spy'; +import { activeTimeline } from './active_timeline_context'; export interface TimelineArgs { events: TimelineItem[]; @@ -44,7 +47,7 @@ export interface TimelineArgs { type LoadPage = (newActivePage: number) => void; -interface UseTimelineEventsProps { +export interface UseTimelineEventsProps { docValueFields?: DocValueFields[]; filterQuery?: ESQuery | string; skip?: boolean; @@ -55,17 +58,26 @@ interface UseTimelineEventsProps { limit: number; sort: SortField; startDate: string; + timerangeKind?: 'absolute' | 'relative'; } const getTimelineEvents = (timelineEdges: TimelineEdges[]): TimelineItem[] => timelineEdges.map((e: TimelineEdges) => e.node); const ID = 'timelineEventsQuery'; -const initSortDefault = { +export const initSortDefault = { field: '@timestamp', direction: Direction.asc, }; +function usePreviousRequest(value: TimelineEventsAllRequestOptions | null) { + const ref = useRef(value); + useEffect(() => { + ref.current = value; + }); + return ref.current; +} + export const useTimelineEvents = ({ docValueFields, endDate, @@ -77,13 +89,17 @@ export const useTimelineEvents = ({ limit, sort = initSortDefault, skip = false, + timerangeKind, }: UseTimelineEventsProps): [boolean, TimelineArgs] => { + const [{ pageName }] = useRouteSpy(); const dispatch = useDispatch(); const { data, notifications } = useKibana().services; const refetch = useRef(noop); const abortCtrl = useRef(new AbortController()); const [loading, setLoading] = useState(false); - const [activePage, setActivePage] = useState(0); + const [activePage, setActivePage] = useState( + id === TimelineId.active ? activeTimeline.getActivePage() : 0 + ); const [timelineRequest, setTimelineRequest] = useState( !skip ? { @@ -106,6 +122,7 @@ export const useTimelineEvents = ({ } : null ); + const prevTimelineRequest = usePreviousRequest(timelineRequest); const clearSignalsState = useCallback(() => { if (id != null && detectionsTimelineIds.some((timelineId) => timelineId === id)) { @@ -117,18 +134,31 @@ export const useTimelineEvents = ({ const wrappedLoadPage = useCallback( (newActivePage: number) => { clearSignalsState(); + + if (id === TimelineId.active) { + activeTimeline.setExpandedEventIds({}); + activeTimeline.setActivePage(newActivePage); + } + setActivePage(newActivePage); }, - [clearSignalsState] + [clearSignalsState, id] ); + const refetchGrid = useCallback(() => { + if (refetch.current != null) { + refetch.current(); + } + wrappedLoadPage(0); + }, [wrappedLoadPage]); + const [timelineResponse, setTimelineResponse] = useState({ - id: ID, + id, inspect: { dsl: [], response: [], }, - refetch: refetch.current, + refetch: refetchGrid, totalCount: -1, pageInfo: { activePage: 0, @@ -141,15 +171,13 @@ export const useTimelineEvents = ({ const timelineSearch = useCallback( (request: TimelineEventsAllRequestOptions | null) => { - if (request == null) { + if (request == null || pageName === '') { return; } - let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); setLoading(true); - const searchSubscription$ = data.search .search(request, { strategy: 'securitySolutionTimelineSearchStrategy', @@ -157,26 +185,39 @@ export const useTimelineEvents = ({ }) .subscribe({ next: (response) => { - if (isCompleteResponse(response)) { - if (!didCancel) { - setLoading(false); - setTimelineResponse((prevResponse) => ({ - ...prevResponse, - events: getTimelineEvents(response.edges), - inspect: getInspectResponse(response, prevResponse.inspect), - pageInfo: response.pageInfo, - refetch: refetch.current, - totalCount: response.totalCount, - updatedAt: Date.now(), - })); - } - searchSubscription$.unsubscribe(); - } else if (isErrorResponse(response)) { - if (!didCancel) { - setLoading(false); + try { + if (isCompleteResponse(response)) { + if (!didCancel) { + setLoading(false); + + setTimelineResponse((prevResponse) => { + const newTimelineResponse = { + ...prevResponse, + events: getTimelineEvents(response.edges), + inspect: getInspectResponse(response, prevResponse.inspect), + pageInfo: response.pageInfo, + totalCount: response.totalCount, + updatedAt: Date.now(), + }; + if (id === TimelineId.active) { + activeTimeline.setExpandedEventIds({}); + activeTimeline.setPageName(pageName); + activeTimeline.setRequest(request); + activeTimeline.setResponse(newTimelineResponse); + } + return newTimelineResponse; + }); + } + searchSubscription$.unsubscribe(); + } else if (isErrorResponse(response)) { + if (!didCancel) { + setLoading(false); + } + notifications.toasts.addWarning(i18n.ERROR_TIMELINE_EVENTS); + searchSubscription$.unsubscribe(); } + } catch { notifications.toasts.addWarning(i18n.ERROR_TIMELINE_EVENTS); - searchSubscription$.unsubscribe(); } }, error: (msg) => { @@ -189,15 +230,43 @@ export const useTimelineEvents = ({ }, }); }; + + if ( + id === TimelineId.active && + activeTimeline.getPageName() !== '' && + pageName !== activeTimeline.getPageName() + ) { + activeTimeline.setPageName(pageName); + + abortCtrl.current.abort(); + setLoading(false); + refetch.current = asyncSearch.bind(null, activeTimeline.getRequest()); + setTimelineResponse((prevResp) => { + const resp = activeTimeline.getResponse(); + if (resp != null) { + return { + ...resp, + refetch: refetchGrid, + loadPage: wrappedLoadPage, + }; + } + return prevResp; + }); + if (activeTimeline.getResponse() != null) { + return; + } + } + abortCtrl.current.abort(); asyncSearch(); refetch.current = asyncSearch; + return () => { didCancel = true; abortCtrl.current.abort(); }; }, - [data.search, notifications.toasts] + [data.search, id, notifications.toasts, pageName, refetchGrid, wrappedLoadPage] ); useEffect(() => { @@ -251,8 +320,10 @@ export const useTimelineEvents = ({ if (activePage !== newActivePage) { setActivePage(newActivePage); + if (id === TimelineId.active) { + activeTimeline.setActivePage(newActivePage); + } } - if ( !skip && !skipQueryForDetectionsPage(id, indexNames) && @@ -263,12 +334,13 @@ export const useTimelineEvents = ({ return prevRequest; }); }, [ + dispatch, + indexNames, activePage, docValueFields, endDate, filterQuery, id, - indexNames, limit, startDate, sort, @@ -277,8 +349,13 @@ export const useTimelineEvents = ({ ]); useEffect(() => { - timelineSearch(timelineRequest); - }, [timelineRequest, timelineSearch]); + if ( + id !== TimelineId.active || + timerangeKind === 'absolute' || + !deepEqual(prevTimelineRequest, timelineRequest) + ) + timelineSearch(timelineRequest); + }, [id, prevTimelineRequest, timelineRequest, timelineSearch, timerangeKind]); return [loading, timelineResponse]; }; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx index 1992b1f88f0641..d6597df71526f1 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx @@ -102,6 +102,7 @@ describe('epicLocalStorage', () => { status: TimelineStatus.active, sort, timelineType: TimelineType.default, + timerangeKind: 'absolute', toggleColumn: jest.fn(), usersViewing: ['elastic'], }; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index 30d0796443ab50..d4e807b4a9a073 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -26,12 +26,14 @@ import { TimelineTypeLiteral, TimelineType, RowRendererId, + TimelineId, } from '../../../../common/types/timeline'; import { normalizeTimeRange } from '../../../common/components/url_state/normalize_time_range'; import { timelineDefaults } from './defaults'; import { ColumnHeaderOptions, KqlMode, TimelineModel } from './model'; import { TimelineById } from './types'; +import { activeTimeline } from '../../containers/active_timeline_context'; export const isNotNull = (value: T | null): value is T => value !== null; @@ -113,6 +115,17 @@ interface AddTimelineParams { timelineById: TimelineById; } +export const shouldResetActiveTimelineContext = ( + id: string, + oldTimeline: TimelineModel, + newTimeline: TimelineModel +) => { + if (id === TimelineId.active && oldTimeline.savedObjectId !== newTimeline.savedObjectId) { + return true; + } + return false; +}; + /** * Add a saved object timeline to the store * and default the value to what need to be if values are null @@ -121,13 +134,19 @@ export const addTimelineToStore = ({ id, timeline, timelineById, -}: AddTimelineParams): TimelineById => ({ - ...timelineById, - [id]: { - ...timeline, - isLoading: timelineById[id].isLoading, - }, -}); +}: AddTimelineParams): TimelineById => { + if (shouldResetActiveTimelineContext(id, timelineById[id], timeline)) { + activeTimeline.setActivePage(0); + activeTimeline.setExpandedEventIds({}); + } + return { + ...timelineById, + [id]: { + ...timeline, + isLoading: timelineById[id].isLoading, + }, + }; +}; interface AddNewTimelineParams { columns: ColumnHeaderOptions[]; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/index.ts index 9f33e2c2495c57..00d9ebbbbc0660 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/geo_threshold/index.ts @@ -20,6 +20,8 @@ export function getAlertType(): AlertTypeModel import('./query_builder')), validate: validateExpression, requiresAppContext: false, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx index 7c42c43dc79a2b..e309d97b57f341 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx @@ -281,7 +281,6 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent ) : null} -
import('./expression')), validate: validateExpression, requiresAppContext: false, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx index 2a69580d7185c9..d66c5ba5121b83 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx @@ -99,6 +99,7 @@ describe('alert_add', () => { iconClass: 'test', name: 'test-alert', description: 'test', + documentationUrl: null, validate: (): ValidationResult => { return { errors: {} }; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx index 34f9f29274f8f1..31c61f0bba7688 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx @@ -52,6 +52,7 @@ describe('alert_edit', () => { iconClass: 'test', name: 'test-alert', description: 'test', + documentationUrl: null, validate: (): ValidationResult => { return { errors: {} }; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx index 98eaea64797b2d..4041f6f451a23c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx @@ -31,7 +31,8 @@ describe('alert_form', () => { id: 'my-alert-type', iconClass: 'test', name: 'test-alert', - description: 'test', + description: 'Alert when testing', + documentationUrl: 'https://localhost.local/docs', validate: (): ValidationResult => { return { errors: {} }; }, @@ -59,6 +60,7 @@ describe('alert_form', () => { iconClass: 'test', name: 'non edit alert', description: 'test', + documentationUrl: null, validate: (): ValidationResult => { return { errors: {} }; }, @@ -182,6 +184,22 @@ describe('alert_form', () => { ); expect(alertTypeSelectOptions.exists()).toBeFalsy(); }); + + it('renders alert type description', async () => { + await setup(); + wrapper.find('[data-test-subj="my-alert-type-SelectOption"]').first().simulate('click'); + const alertDescription = wrapper.find('[data-test-subj="alertDescription"]'); + expect(alertDescription.exists()).toBeTruthy(); + expect(alertDescription.first().text()).toContain('Alert when testing'); + }); + + it('renders alert type documentation link', async () => { + await setup(); + wrapper.find('[data-test-subj="my-alert-type-SelectOption"]').first().simulate('click'); + const alertDocumentationLink = wrapper.find('[data-test-subj="alertDocumentationLink"]'); + expect(alertDocumentationLink.exists()).toBeTruthy(); + expect(alertDocumentationLink.first().prop('href')).toBe('https://localhost.local/docs'); + }); }); describe('alert_form create alert non alerting consumer and producer', () => { @@ -244,6 +262,7 @@ describe('alert_form', () => { iconClass: 'test', name: 'test-alert', description: 'test', + documentationUrl: null, validate: (): ValidationResult => { return { errors: {} }; }, @@ -255,6 +274,7 @@ describe('alert_form', () => { iconClass: 'test', name: 'test-alert', description: 'test', + documentationUrl: null, validate: (): ValidationResult => { return { errors: {} }; }, @@ -423,5 +443,19 @@ describe('alert_form', () => { const throttleFieldAfterUpdate = wrapper.find('[data-test-subj="throttleInput"]'); expect(throttleFieldAfterUpdate.at(1).prop('value')).toEqual(newThrottle); }); + + it('renders alert type description', async () => { + await setup(); + const alertDescription = wrapper.find('[data-test-subj="alertDescription"]'); + expect(alertDescription.exists()).toBeTruthy(); + expect(alertDescription.first().text()).toContain('Alert when testing'); + }); + + it('renders alert type documentation link', async () => { + await setup(); + const alertDocumentationLink = wrapper.find('[data-test-subj="alertDocumentationLink"]'); + expect(alertDocumentationLink.exists()).toBeTruthy(); + expect(alertDocumentationLink.first().prop('href')).toBe('https://localhost.local/docs'); + }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index bdc11fd543ee14..9a637ea750f815 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -25,6 +25,8 @@ import { EuiHorizontalRule, EuiLoadingSpinner, EuiEmptyPrompt, + EuiLink, + EuiText, } from '@elastic/eui'; import { some, filter, map, fold } from 'fp-ts/lib/Option'; import { pipe } from 'fp-ts/lib/pipeable'; @@ -247,6 +249,33 @@ export const AlertForm = ({ ) : null} + {alertTypeModel?.description && ( + + + + {alertTypeModel.description}  + {alertTypeModel?.documentationUrl && ( + + + + )} + + + + )} + {AlertParamsExpressionComponent ? ( }> { return { errors: {} }; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts index 311f366df74e05..f875bcabdcde82 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts @@ -17,6 +17,7 @@ const getTestAlertType = (id?: string, name?: string, iconClass?: string) => { name: name || 'Test alert type', description: 'Test description', iconClass: iconClass || 'icon', + documentationUrl: null, validate: (): ValidationResult => { return { errors: {} }; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index bf1ff26af42e2a..1a6b68080c9a4b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -176,6 +176,7 @@ export interface AlertTypeModel name: string | JSX.Element; description: string; iconClass: string; + documentationUrl: string | ((docLinks: DocLinksStart) => string) | null; validate: (alertParams: AlertParamsType) => ValidationResult; alertParamsExpression: | React.FunctionComponent diff --git a/x-pack/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts b/x-pack/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts index 5106fcbc97bcd0..8da45276fa532f 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts +++ b/x-pack/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts @@ -204,6 +204,7 @@ describe('monitor status alert type', () => { "alertParamsExpression": [Function], "defaultActionMessage": "Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} is {{state.statusMessage}} from {{state.observerLocation}}. The latest error message is {{{state.latestErrorMessage}}}", "description": "Alert when a monitor is down or an availability threshold is breached.", + "documentationUrl": [Function], "iconClass": "uptimeApp", "id": "xpack.uptime.alerts.monitorStatus", "name": ({ id: CLIENT_ALERT_TYPES.DURATION_ANOMALY, iconClass: 'uptimeApp', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/uptime/${docLinks.DOC_LINK_VERSION}/uptime-alerting.html`; + }, alertParamsExpression: (params: unknown) => ( ), diff --git a/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.tsx b/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.tsx index 4e3d9a3c6e0ac0..43aaa26d86642a 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.tsx +++ b/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.tsx @@ -31,6 +31,9 @@ export const initMonitorStatusAlertType: AlertTypeInitializer = ({ ), description, iconClass: 'uptimeApp', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/uptime/${docLinks.DOC_LINK_VERSION}/uptime-alerting.html#_monitor_status_alerts`; + }, alertParamsExpression: (params: any) => ( ), diff --git a/x-pack/plugins/uptime/public/lib/alert_types/tls.tsx b/x-pack/plugins/uptime/public/lib/alert_types/tls.tsx index 41ff08b0da97cd..83c4792e26f597 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/tls.tsx +++ b/x-pack/plugins/uptime/public/lib/alert_types/tls.tsx @@ -15,6 +15,9 @@ const TLSAlert = React.lazy(() => import('./lazy_wrapper/tls_alert')); export const initTlsAlertType: AlertTypeInitializer = ({ core, plugins }): AlertTypeModel => ({ id: CLIENT_ALERT_TYPES.TLS, iconClass: 'uptimeApp', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/uptime/${docLinks.DOC_LINK_VERSION}/uptime-alerting.html#_tls_alerts`; + }, alertParamsExpression: (params: any) => ( ), diff --git a/x-pack/test/functional/apps/lens/dashboard.ts b/x-pack/test/functional/apps/lens/dashboard.ts index fa13d013ea1157..c24f4ccf01bcd5 100644 --- a/x-pack/test/functional/apps/lens/dashboard.ts +++ b/x-pack/test/functional/apps/lens/dashboard.ts @@ -8,7 +8,14 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { - const PageObjects = getPageObjects(['header', 'common', 'dashboard', 'timePicker', 'lens']); + const PageObjects = getPageObjects([ + 'header', + 'common', + 'dashboard', + 'timePicker', + 'lens', + 'discover', + ]); const find = getService('find'); const dashboardAddPanel = getService('dashboardAddPanel'); @@ -18,6 +25,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const filterBar = getService('filterBar'); const security = getService('security'); + const panelActions = getService('dashboardPanelActions'); async function clickInChart(x: number, y: number) { const el = await elasticChart.getCanvas(); @@ -27,7 +35,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('lens dashboard tests', () => { before(async () => { await PageObjects.common.navigateToApp('dashboard'); - await security.testUser.setRoles(['global_dashboard_all', 'test_logstash_reader'], false); + await security.testUser.setRoles( + ['global_dashboard_all', 'global_discover_all', 'test_logstash_reader'], + false + ); }); after(async () => { await security.testUser.restoreDefaults(); @@ -68,6 +79,26 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const hasIpFilter = await filterBar.hasFilter('ip', '97.220.3.248'); expect(hasIpFilter).to.be(true); }); + + it('should be able to drill down to discover', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.filterEmbeddableNames('lnsXYvis'); + await find.clickByButtonText('lnsXYvis'); + await dashboardAddPanel.closeAddPanel(); + await PageObjects.lens.goToTimeRange(); + await PageObjects.dashboard.saveDashboard('lnsDrilldown'); + await panelActions.openContextMenu(); + await testSubjects.clickWhenNotDisabled('embeddablePanelAction-ACTION_EXPLORE_DATA'); + await PageObjects.discover.waitForDiscoverAppOnScreen(); + + const el = await testSubjects.find('indexPattern-switch-link'); + const text = await el.getVisibleText(); + + expect(text).to.be('logstash-*'); + }); + it('should be able to add filters by clicking in pie chart', async () => { await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.clickNewDashboard(); diff --git a/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js b/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js index 4edee0a0b78ba8..a336ebc0d57dbb 100644 --- a/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js +++ b/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js @@ -26,7 +26,7 @@ export default function ({ getPageObjects, getService }) { await inspector.open(); await inspector.openInspectorRequestsView(); await testSubjects.click('inspectorRequestDetailResponse'); - const responseBody = await testSubjects.getVisibleText('inspectorResponseBody'); + const responseBody = await inspector.getCodeEditorValue(); await inspector.close(); return JSON.parse(responseBody); } 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 c738ce0697f750..af4aedda06ef75 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 @@ -31,6 +31,7 @@ export class AlertingFixturePlugin implements Plugin React.createElement('div', null, 'Test Always Firing'), validate: () => { return { errors: {} }; @@ -43,6 +44,7 @@ export class AlertingFixturePlugin implements Plugin React.createElement('div', null, 'Test Noop'), validate: () => { return { errors: {} }; 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 72ea9cb4e7ef35..8e8e4f010bcb55 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 @@ -184,6 +184,16 @@ export default function (providerContext: FtrProviderContext) { resSearch = err; } expect(resSearch.response.data.statusCode).equal(404); + let resIndexPattern; + try { + resIndexPattern = await kibanaServer.savedObjects.get({ + type: 'index-pattern', + id: 'test-*', + }); + } catch (err) { + resIndexPattern = err; + } + expect(resIndexPattern.response.data.statusCode).equal(404); }); it('should have removed the fields from the index patterns', async () => { // The reason there is an expect inside the try and inside the catch in this test case is to guard against two @@ -345,6 +355,7 @@ const expectAssetsInstalled = ({ expect(res.statusCode).equal(200); }); it('should have installed the kibana assets', async function () { + // These are installed from Fleet along with every package const resIndexPatternLogs = await kibanaServer.savedObjects.get({ type: 'index-pattern', id: 'logs-*', @@ -355,6 +366,8 @@ const expectAssetsInstalled = ({ id: 'metrics-*', }); expect(resIndexPatternMetrics.id).equal('metrics-*'); + + // These are the assets from the package const resDashboard = await kibanaServer.savedObjects.get({ type: 'dashboard', id: 'sample_dashboard', @@ -375,6 +388,22 @@ const expectAssetsInstalled = ({ id: 'sample_search', }); expect(resSearch.id).equal('sample_search'); + const resIndexPattern = await kibanaServer.savedObjects.get({ + type: 'index-pattern', + id: 'test-*', + }); + expect(resIndexPattern.id).equal('test-*'); + + let resInvalidTypeIndexPattern; + try { + resInvalidTypeIndexPattern = await kibanaServer.savedObjects.get({ + type: 'invalid-type', + id: 'invalid', + }); + } catch (err) { + resInvalidTypeIndexPattern = err; + } + expect(resInvalidTypeIndexPattern.response.data.statusCode).equal(404); }); it('should create an index pattern with the package fields', async () => { const resIndexPatternLogs = await kibanaServer.savedObjects.get({ @@ -415,6 +444,10 @@ const expectAssetsInstalled = ({ id: 'sample_dashboard2', type: 'dashboard', }, + { + id: 'test-*', + type: 'index-pattern', + }, { id: 'sample_search', type: 'search', 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 90dce92a2c6b56..b16cf039f0dadb 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 @@ -283,14 +283,14 @@ export default function (providerContext: FtrProviderContext) { id: 'sample_dashboard', type: 'dashboard', }, - { - id: 'sample_search2', - type: 'search', - }, { id: 'sample_visualization', type: 'visualization', }, + { + id: 'sample_search2', + type: 'search', + }, ], installed_es: [ { diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/index_pattern/invalid.json b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/index_pattern/invalid.json new file mode 100644 index 00000000000000..bffc52ded73d65 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/index_pattern/invalid.json @@ -0,0 +1,11 @@ +{ + "attributes": { + "fieldFormatMap": "{}", + "fields": "[]", + "timeFieldName": "@timestamp", + "title": "invalid" + }, + "id": "invalid", + "references": [], + "type": "invalid-type" +} diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/index_pattern/test_index_pattern.json b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/index_pattern/test_index_pattern.json new file mode 100644 index 00000000000000..48ba36a1167093 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/index_pattern/test_index_pattern.json @@ -0,0 +1,11 @@ +{ + "attributes": { + "fieldFormatMap": "{}", + "fields": "[]", + "timeFieldName": "@timestamp", + "title": "test-*" + }, + "id": "test-*", + "references": [], + "type": "index-pattern" +} diff --git a/yarn.lock b/yarn.lock index 6ba53d0e4dd43e..b79e246b27851b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6242,10 +6242,10 @@ text-table "^0.2.0" webpack-log "^1.1.2" -"@welldone-software/why-did-you-render@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@welldone-software/why-did-you-render/-/why-did-you-render-4.0.0.tgz#cc98c996f5a06ea55bd07dc99ba4b4d68af93332" - integrity sha512-PjqriZ8Ak9biP2+kOcIrg+NwsFwWVhGV03Hm+ns84YBCArn+hWBKM9rMBEU6e62I1qyrYF2/G9yktNpEmfWfJA== +"@welldone-software/why-did-you-render@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@welldone-software/why-did-you-render/-/why-did-you-render-5.0.0.tgz#5dd8d20ad9f00fd500de852dd06eea0c057a0bce" + integrity sha512-A6xUP/55vJQwA1+L6iZbG81cQanSQQVR15yPcjLIp6lHmybXEOXsYcuXaDZHYqiNStZRzv64YPcYJC9wdphfhw== dependencies: lodash "^4"