diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5dd41581914ed..8f2c27ac7c3cf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,20 +6,16 @@ # used for the 'team' designator within Kibana Stats # App -/x-pack/plugins/dashboard_enhanced/ @elastic/kibana-app /x-pack/plugins/discover_enhanced/ @elastic/kibana-app /x-pack/plugins/lens/ @elastic/kibana-app /x-pack/plugins/graph/ @elastic/kibana-app /src/plugins/advanced_settings/ @elastic/kibana-app /src/plugins/charts/ @elastic/kibana-app -/src/plugins/dashboard/ @elastic/kibana-app /src/plugins/discover/ @elastic/kibana-app -/src/plugins/input_control_vis/ @elastic/kibana-app /src/plugins/management/ @elastic/kibana-app /src/plugins/kibana_legacy/ @elastic/kibana-app /src/plugins/timelion/ @elastic/kibana-app /src/plugins/vis_default_editor/ @elastic/kibana-app -/src/plugins/vis_type_markdown/ @elastic/kibana-app /src/plugins/vis_type_metric/ @elastic/kibana-app /src/plugins/vis_type_table/ @elastic/kibana-app /src/plugins/vis_type_tagcloud/ @elastic/kibana-app @@ -35,10 +31,8 @@ #CC# /src/legacy/core_plugins/kibana/common/utils @elastic/kibana-app #CC# /src/legacy/core_plugins/kibana/migrations @elastic/kibana-app #CC# /src/legacy/core_plugins/kibana/public @elastic/kibana-app -#CC# /src/legacy/core_plugins/kibana/public/dashboard/ @elastic/kibana-app #CC# /src/legacy/core_plugins/kibana/public/discover/ @elastic/kibana-app #CC# /src/legacy/core_plugins/kibana/public/local_application_service/ @elastic/kibana-app -#CC# /src/legacy/core_plugins/input_control_vis @elastic/kibana-app #CC# /src/legacy/core_plugins/timelion @elastic/kibana-app #CC# /src/legacy/core_plugins/vis_type_tagcloud @elastic/kibana-app #CC# /src/legacy/core_plugins/vis_type_vega @elastic/kibana-app @@ -46,8 +40,6 @@ #CC# /src/legacy/server/url_shortening/ @elastic/kibana-app #CC# /src/legacy/ui/public/state_management @elastic/kibana-app #CC# /src/plugins/index_pattern_management/public @elastic/kibana-app -#CC# /x-pack/legacy/plugins/dashboard_mode/ @elastic/kibana-app -#CC# /x-pack/plugins/dashboard_mode @elastic/kibana-app # App Architecture /examples/bfetch_explorer/ @elastic/kibana-app-arch @@ -127,10 +119,18 @@ #CC# /x-pack/plugins/beats_management/ @elastic/beats # Canvas +/src/plugins/dashboard/ @elastic/kibana-app +/src/plugins/input_control_vis/ @elastic/kibana-app +/src/plugins/vis_type_markdown/ @elastic/kibana-app /x-pack/plugins/canvas/ @elastic/kibana-canvas +/x-pack/plugins/dashboard_enhanced/ @elastic/kibana-app /x-pack/test/functional/apps/canvas/ @elastic/kibana-canvas +#CC# /src/legacy/core_plugins/kibana/public/dashboard/ @elastic/kibana-app +#CC# /src/legacy/core_plugins/input_control_vis @elastic/kibana-app #CC# /src/plugins/kibana_react/public/code_editor/ @elastic/kibana-canvas #CC# /x-pack/legacy/plugins/canvas/ @elastic/kibana-canvas +#CC# /x-pack/plugins/dashboard_mode @elastic/kibana-app +#CC# /x-pack/legacy/plugins/dashboard_mode/ @elastic/kibana-app # Core UI # Exclude tutorials folder for now because they are not owned by Kibana app and most will move out soon diff --git a/.github/ISSUE_TEMPLATE/security_solution_bug_report.md b/.github/ISSUE_TEMPLATE/security_solution_bug_report.md new file mode 100644 index 0000000000000..86d2b1405d4eb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/security_solution_bug_report.md @@ -0,0 +1,37 @@ +--- +name: Security Solution Bug Report +about: Things break. Help us identify those things so we can fix them! +title: '[Security Solution]' +--- + +**Describe the bug:** + +**Kibana/Elasticsearch Stack version:** + +**Server OS version:** + +**Browser and Browser OS versions:** + +**Elastic Endpoint version:** + +**Original install method (e.g. download page, yum, from source, etc.):** + +**Functional Area (e.g. Endpoint management, timelines, resolver, etc.):** + +**Steps to reproduce:** + +1. +2. +3. + +**Current behavior:** + +**Expected behavior:** + +**Screenshots (if relevant):** + +**Errors in browser console (if relevant):** + +**Provide logs and/or server output (if relevant):** + +**Any additional context (logs, chat logs, magical formulas, etc.):** diff --git a/docs/fleet/fleet.asciidoc b/docs/fleet/fleet.asciidoc index 7039468f4b185..06b2b96c0035c 100644 --- a/docs/fleet/fleet.asciidoc +++ b/docs/fleet/fleet.asciidoc @@ -3,7 +3,7 @@ [[fleet]] = {fleet} -experimental[] +beta[] {fleet} in {kib} enables you to add and manage integrations for popular services and platforms, as well as manage {elastic-agent} installations in diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts index 86a02d74dea15..d6a4224d9fab0 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts @@ -36,7 +36,33 @@ function generator({ # set -euo pipefail - docker pull ${baseOSImage} + retry_docker_pull() { + image=$1 + attempt=0 + max_retries=5 + + while true + do + attempt=$((attempt+1)) + + if [ $attempt -gt $max_retries ] + then + echo "Docker pull retries exceeded, aborting." + exit 1 + fi + + if docker pull "$image" + then + echo "Docker pull successful." + break + else + echo "Docker pull unsuccessful, attempt '$attempt'." + fi + + done + } + + retry_docker_pull ${baseOSImage} echo "Building: kibana${imageFlavor}${ubiImageFlavor}-docker"; \\ docker build -t ${imageTag}${imageFlavor}${ubiImageFlavor}:${version} -f Dockerfile . || exit 1; 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 755269d1a31be..3f7d05e8692c2 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 @@ -87,19 +87,19 @@ beforeEach(async () => { }); test('Add to library is compatible when embeddable on dashboard has value type input', async () => { - const action = new AddToLibraryAction(); + const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts }); embeddable.updateInput(await embeddable.getInputAsValueType()); expect(await action.isCompatible({ embeddable })).toBe(true); }); test('Add to library is not compatible when embeddable input is by reference', async () => { - const action = new AddToLibraryAction(); + const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts }); embeddable.updateInput(await embeddable.getInputAsRefType()); expect(await action.isCompatible({ embeddable })).toBe(false); }); test('Add to library is not compatible when view mode is set to view', async () => { - const action = new AddToLibraryAction(); + const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts }); embeddable.updateInput(await embeddable.getInputAsRefType()); embeddable.updateInput({ viewMode: ViewMode.VIEW }); expect(await action.isCompatible({ embeddable })).toBe(false); @@ -120,7 +120,7 @@ test('Add to library is not compatible when embeddable is not in a dashboard con mockedByReferenceInput: { savedObjectId: 'test', id: orphanContactCard.id }, mockedByValueInput: { firstName: 'Kibanana', id: orphanContactCard.id }, }); - const action = new AddToLibraryAction(); + const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts }); expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false); }); @@ -128,7 +128,7 @@ test('Add to library replaces embeddableId but 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(); + const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount); @@ -154,7 +154,7 @@ test('Add to library returns reference type input', async () => { }); const dashboard = embeddable.getRoot() as IContainer; const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); - const action = new AddToLibraryAction(); + const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); const newPanelId = Object.keys(container.getInput().panels).find( (key) => !originalPanelKeySet.has(key) diff --git a/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx b/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx index 3cc1a8a1dffe1..d89c38f297e8f 100644 --- a/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx +++ b/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx @@ -27,6 +27,7 @@ import { EmbeddableInput, isReferenceOrValueEmbeddable, } from '../../../../embeddable/public'; +import { NotificationsStart } from '../../../../../core/public'; import { DashboardPanelState, DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '..'; export const ACTION_ADD_TO_LIBRARY = 'addToFromLibrary'; @@ -40,7 +41,7 @@ export class AddToLibraryAction implements ActionByType { coreStart = coreMock.createStart(); + unlinkAction = ({ + getDisplayName: () => 'unlink from dat library', + execute: jest.fn(), + } as unknown) as UnlinkFromLibraryAction; + const containerOptions = { ExitFullScreenButton: () => null, SavedObjectFinder: () => null, @@ -81,19 +88,19 @@ beforeEach(async () => { }); test('Notification is shown when embeddable on dashboard has reference type input', async () => { - const action = new LibraryNotificationAction(); + const action = new LibraryNotificationAction(unlinkAction); embeddable.updateInput(await embeddable.getInputAsRefType()); expect(await action.isCompatible({ embeddable })).toBe(true); }); test('Notification is not shown when embeddable input is by value', async () => { - const action = new LibraryNotificationAction(); + const action = new LibraryNotificationAction(unlinkAction); embeddable.updateInput(await embeddable.getInputAsValueType()); expect(await action.isCompatible({ embeddable })).toBe(false); }); test('Notification is not shown when view mode is set to view', async () => { - const action = new LibraryNotificationAction(); + const action = new LibraryNotificationAction(unlinkAction); embeddable.updateInput(await embeddable.getInputAsRefType()); embeddable.updateInput({ viewMode: ViewMode.VIEW }); expect(await action.isCompatible({ embeddable })).toBe(false); diff --git a/src/plugins/dashboard/public/application/actions/library_notification_action.tsx b/src/plugins/dashboard/public/application/actions/library_notification_action.tsx index bff0236c802f1..6a0b71d8250be 100644 --- a/src/plugins/dashboard/public/application/actions/library_notification_action.tsx +++ b/src/plugins/dashboard/public/application/actions/library_notification_action.tsx @@ -17,12 +17,13 @@ * under the License. */ -import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiBadge } from '@elastic/eui'; +import React from 'react'; import { IEmbeddable, ViewMode, isReferenceOrValueEmbeddable } from '../../embeddable_plugin'; import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; import { reactToUiComponent } from '../../../../kibana_react/public'; +import { UnlinkFromLibraryAction } from '.'; +import { LibraryNotificationPopover } from './library_notification_popover'; export const ACTION_LIBRARY_NOTIFICATION = 'ACTION_LIBRARY_NOTIFICATION'; @@ -35,23 +36,32 @@ export class LibraryNotificationAction implements ActionByType ( - - {this.displayName} - - )); + private LibraryNotification: React.FC<{ context: LibraryNotificationActionContext }> = ({ + context, + }: { + context: LibraryNotificationActionContext; + }) => { + const { embeddable } = context; + return ( + + ); + }; + + public readonly MenuItem = reactToUiComponent(this.LibraryNotification); public getDisplayName({ embeddable }: LibraryNotificationActionContext) { if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) { @@ -67,16 +77,6 @@ export class LibraryNotificationAction implements ActionByType { - if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) { - throw new IncompatibleActionError(); - } - return i18n.translate('dashboard.panel.libraryNotification.toolTip', { - defaultMessage: - 'This panel is linked to a Library item. Editing the panel might affect other dashboards.', - }); - }; - public isCompatible = async ({ embeddable }: LibraryNotificationActionContext) => { return ( embeddable.getRoot().isContainer && diff --git a/src/plugins/dashboard/public/application/actions/library_notification_popover.test.tsx b/src/plugins/dashboard/public/application/actions/library_notification_popover.test.tsx new file mode 100644 index 0000000000000..c6f223fa45c23 --- /dev/null +++ b/src/plugins/dashboard/public/application/actions/library_notification_popover.test.tsx @@ -0,0 +1,127 @@ +/* + * 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 { DashboardContainer } from '..'; +import { isErrorEmbeddable } from '../../embeddable_plugin'; +import { mountWithIntl } from '../../../../../test_utils/public/enzyme_helpers'; +import { embeddablePluginMock } from '../../../../embeddable/public/mocks'; +import { getSampleDashboardInput } from '../test_helpers'; +import { + CONTACT_CARD_EMBEDDABLE, + ContactCardEmbeddableFactory, + ContactCardEmbeddable, + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, +} from '../../embeddable_plugin_test_samples'; +import { + LibraryNotificationPopover, + LibraryNotificationProps, +} from './library_notification_popover'; +import { CoreStart } from '../../../../../core/public'; +import { coreMock } from '../../../../../core/public/mocks'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { EuiPopover } from '@elastic/eui'; + +describe('LibraryNotificationPopover', () => { + const { setup, doStart } = embeddablePluginMock.createInstance(); + setup.registerEmbeddableFactory( + CONTACT_CARD_EMBEDDABLE, + new ContactCardEmbeddableFactory((() => null) as any, {} as any) + ); + const start = doStart(); + + let container: DashboardContainer; + let defaultProps: LibraryNotificationProps; + let coreStart: CoreStart; + + beforeEach(async () => { + coreStart = coreMock.createStart(); + + const containerOptions = { + ExitFullScreenButton: () => null, + SavedObjectFinder: () => null, + application: {} as any, + embeddable: start, + inspector: {} as any, + notifications: {} as any, + overlays: coreStart.overlays, + savedObjectMetaData: {} as any, + uiActions: {} as any, + }; + + container = new DashboardContainer(getSampleDashboardInput(), containerOptions); + const contactCardEmbeddable = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Kibanana', + }); + + if (isErrorEmbeddable(contactCardEmbeddable)) { + throw new Error('Failed to create embeddable'); + } + + defaultProps = { + unlinkAction: ({ + execute: jest.fn(), + getDisplayName: () => 'test unlink', + } as unknown) as LibraryNotificationProps['unlinkAction'], + displayName: 'test display', + context: { embeddable: contactCardEmbeddable }, + icon: 'testIcon', + id: 'testId', + }; + }); + + function mountComponent(props?: Partial) { + return mountWithIntl(); + } + + test('click library notification badge should open and close popover', () => { + const component = mountComponent(); + const btn = findTestSubject(component, `embeddablePanelNotification-${defaultProps.id}`); + btn.simulate('click'); + let popover = component.find(EuiPopover); + expect(popover.prop('isOpen')).toBe(true); + btn.simulate('click'); + popover = component.find(EuiPopover); + expect(popover.prop('isOpen')).toBe(false); + }); + + test('popover should contain button with unlink action display name', () => { + const component = mountComponent(); + const btn = findTestSubject(component, `embeddablePanelNotification-${defaultProps.id}`); + btn.simulate('click'); + const popover = component.find(EuiPopover); + const unlinkButton = findTestSubject(popover, 'libraryNotificationUnlinkButton'); + expect(unlinkButton.text()).toEqual('test unlink'); + }); + + test('clicking unlink executes unlink action', () => { + const component = mountComponent(); + const btn = findTestSubject(component, `embeddablePanelNotification-${defaultProps.id}`); + btn.simulate('click'); + const popover = component.find(EuiPopover); + const unlinkButton = findTestSubject(popover, 'libraryNotificationUnlinkButton'); + unlinkButton.simulate('click'); + expect(defaultProps.unlinkAction.execute).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/dashboard/public/application/actions/library_notification_popover.tsx b/src/plugins/dashboard/public/application/actions/library_notification_popover.tsx new file mode 100644 index 0000000000000..8bc81b3296c3d --- /dev/null +++ b/src/plugins/dashboard/public/application/actions/library_notification_popover.tsx @@ -0,0 +1,102 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useState } from 'react'; +import { + EuiButton, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiPopoverFooter, + EuiPopoverTitle, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { LibraryNotificationActionContext, UnlinkFromLibraryAction } from '.'; + +export interface LibraryNotificationProps { + context: LibraryNotificationActionContext; + unlinkAction: UnlinkFromLibraryAction; + displayName: string; + icon: string; + id: string; +} + +export function LibraryNotificationPopover({ + unlinkAction, + displayName, + context, + icon, + id, +}: LibraryNotificationProps) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const { embeddable } = context; + + return ( + setIsPopoverOpen(!isPopoverOpen)} + /> + } + isOpen={isPopoverOpen} + closePopover={() => setIsPopoverOpen(false)} + anchorPosition="upCenter" + > + {displayName} +
+ +

+ {i18n.translate('dashboard.panel.libraryNotification.toolTip', { + defaultMessage: + 'This panel is linked to a library item. Editing the panel might affect other dashboards.', + })} +

+
+
+ + + + unlinkAction.execute({ embeddable })} + > + {unlinkAction.getDisplayName({ embeddable })} + + + + +
+ ); +} 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 b4178fd40c768..0f61a74cd7036 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 @@ -81,19 +81,19 @@ beforeEach(async () => { }); test('Unlink is compatible when embeddable on dashboard has reference type input', async () => { - const action = new UnlinkFromLibraryAction(); + const action = new UnlinkFromLibraryAction({ toasts: coreStart.notifications.toasts }); embeddable.updateInput(await embeddable.getInputAsRefType()); expect(await action.isCompatible({ embeddable })).toBe(true); }); test('Unlink is not compatible when embeddable input is by value', async () => { - const action = new UnlinkFromLibraryAction(); + const action = new UnlinkFromLibraryAction({ toasts: coreStart.notifications.toasts }); embeddable.updateInput(await embeddable.getInputAsValueType()); expect(await action.isCompatible({ embeddable })).toBe(false); }); test('Unlink is not compatible when view mode is set to view', async () => { - const action = new UnlinkFromLibraryAction(); + const action = new UnlinkFromLibraryAction({ toasts: coreStart.notifications.toasts }); embeddable.updateInput(await embeddable.getInputAsRefType()); embeddable.updateInput({ viewMode: ViewMode.VIEW }); expect(await action.isCompatible({ embeddable })).toBe(false); @@ -114,7 +114,7 @@ test('Unlink is not compatible when embeddable is not in a dashboard container', mockedByReferenceInput: { savedObjectId: 'test', id: orphanContactCard.id }, mockedByValueInput: { firstName: 'Kibanana', id: orphanContactCard.id }, }); - const action = new UnlinkFromLibraryAction(); + const action = new UnlinkFromLibraryAction({ toasts: coreStart.notifications.toasts }); expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false); }); @@ -122,7 +122,7 @@ test('Unlink replaces embeddableId but 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(); + const action = new UnlinkFromLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount); @@ -152,7 +152,7 @@ test('Unlink unwraps all attributes from savedObject', async () => { }); const dashboard = embeddable.getRoot() as IContainer; const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); - const action = new UnlinkFromLibraryAction(); + const action = new UnlinkFromLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); const newPanelId = Object.keys(container.getInput().panels).find( (key) => !originalPanelKeySet.has(key) diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx index e2a6ec7dd3947..f5cf8b4e866a8 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx @@ -27,6 +27,7 @@ import { EmbeddableInput, isReferenceOrValueEmbeddable, } from '../../../../embeddable/public'; +import { NotificationsStart } from '../../../../../core/public'; import { DashboardPanelState, DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '..'; export const ACTION_UNLINK_FROM_LIBRARY = 'unlinkFromLibrary'; @@ -40,14 +41,14 @@ export class UnlinkFromLibraryAction implements ActionByType) { this.logger = initializerContext.logger.get(); @@ -110,7 +112,8 @@ export class TelemetryPlugin implements Plugin this.elasticsearchClient + () => this.elasticsearchClient, + () => this.savedObjectsService ); const router = http.createRouter(); @@ -139,6 +142,7 @@ export class TelemetryPlugin implements Plugin { - const usage = await usageCollection.bulkFetch(callWithInternalUser, asInternalUser); + const usage = await usageCollection.bulkFetch(callWithInternalUser, asInternalUser, soClient); return usageCollection.toObject(usage); } diff --git a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts index 0c8b0b249f7d1..fcecbca23038e 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.test.ts @@ -20,7 +20,10 @@ import { merge, omit } from 'lodash'; import { getLocalStats, handleLocalStats } from './get_local_stats'; -import { usageCollectionPluginMock } from '../../../usage_collection/server/mocks'; +import { + usageCollectionPluginMock, + createCollectorFetchContextMock, +} from '../../../usage_collection/server/mocks'; import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; function mockUsageCollection(kibanaUsage = {}) { @@ -79,6 +82,16 @@ function mockGetLocalStats(clusterInfo: any, clusterStats: any) { return esClient; } +function mockStatsCollectionConfig(clusterInfo: any, clusterStats: any, kibana: {}) { + return { + ...createCollectorFetchContextMock(), + esClient: mockGetLocalStats(clusterInfo, clusterStats), + usageCollection: mockUsageCollection(kibana), + start: '', + end: '', + }; +} + describe('get_local_stats', () => { const clusterUuid = 'abc123'; const clusterName = 'my-cool-cluster'; @@ -224,12 +237,10 @@ describe('get_local_stats', () => { describe('getLocalStats', () => { it('returns expected object with kibana data', async () => { - const callCluster = jest.fn(); - const usageCollection = mockUsageCollection(kibana); - const esClient = mockGetLocalStats(clusterInfo, clusterStats); + const statsCollectionConfig = mockStatsCollectionConfig(clusterInfo, clusterStats, kibana); const response = await getLocalStats( [{ clusterUuid: 'abc123' }], - { callCluster, usageCollection, esClient, start: '', end: '' }, + { ...statsCollectionConfig }, context ); const result = response[0]; @@ -244,14 +255,8 @@ describe('get_local_stats', () => { }); it('returns an empty array when no cluster uuid is provided', async () => { - const callCluster = jest.fn(); - const usageCollection = mockUsageCollection(kibana); - const esClient = mockGetLocalStats(clusterInfo, clusterStats); - const response = await getLocalStats( - [], - { callCluster, usageCollection, esClient, start: '', end: '' }, - context - ); + const statsCollectionConfig = mockStatsCollectionConfig(clusterInfo, clusterStats, kibana); + const response = await getLocalStats([], { ...statsCollectionConfig }, context); expect(response).toBeDefined(); expect(response.length).toEqual(0); }); diff --git a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts index 6244c6fac51d3..4aeefb1d81d6a 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts @@ -68,10 +68,10 @@ export type TelemetryLocalStats = ReturnType; */ export const getLocalStats: StatsGetter<{}, TelemetryLocalStats> = async ( clustersDetails, // array of cluster uuid's - config, // contains the new esClient already scoped contains usageCollection, callCluster, esClient, start, end + config, // contains the new esClient already scoped contains usageCollection, callCluster, esClient, start, end and the saved objects client scoped to the request or the internal repository context // StatsCollectionContext contains logger and version (string) ) => { - const { callCluster, usageCollection, esClient } = config; + const { callCluster, usageCollection, esClient, soClient } = config; return await Promise.all( clustersDetails.map(async (clustersDetail) => { @@ -79,7 +79,7 @@ export const getLocalStats: StatsGetter<{}, TelemetryLocalStats> = async ( getClusterInfo(esClient), // cluster info getClusterStats(esClient), // cluster stats (not to be confused with cluster _state_) getNodesUsage(esClient), // nodes_usage info - getKibana(usageCollection, callCluster, esClient), + getKibana(usageCollection, callCluster, esClient, soClient), getDataTelemetry(esClient), ]); return handleLocalStats( diff --git a/src/plugins/telemetry/server/telemetry_collection/register_collection.ts b/src/plugins/telemetry/server/telemetry_collection/register_collection.ts index 9dac4900f5f10..27ca5ae746512 100644 --- a/src/plugins/telemetry/server/telemetry_collection/register_collection.ts +++ b/src/plugins/telemetry/server/telemetry_collection/register_collection.ts @@ -36,7 +36,7 @@ * under the License. */ -import { ILegacyClusterClient } from 'kibana/server'; +import { ILegacyClusterClient, SavedObjectsServiceStart } from 'kibana/server'; import { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; import { IClusterClient } from '../../../../../src/core/server'; import { getLocalStats } from './get_local_stats'; @@ -46,11 +46,13 @@ import { getLocalLicense } from './get_local_license'; export function registerCollection( telemetryCollectionManager: TelemetryCollectionManagerPluginSetup, esCluster: ILegacyClusterClient, - esClientGetter: () => IClusterClient | undefined + esClientGetter: () => IClusterClient | undefined, + soServiceGetter: () => SavedObjectsServiceStart | undefined ) { telemetryCollectionManager.setCollection({ esCluster, esClientGetter, + soServiceGetter, title: 'local', priority: 0, statsGetter: getLocalStats, diff --git a/src/plugins/telemetry_collection_manager/server/plugin.ts b/src/plugins/telemetry_collection_manager/server/plugin.ts index ff63262004cf5..4900e75a1936b 100644 --- a/src/plugins/telemetry_collection_manager/server/plugin.ts +++ b/src/plugins/telemetry_collection_manager/server/plugin.ts @@ -25,6 +25,7 @@ import { Plugin, Logger, IClusterClient, + SavedObjectsServiceStart, } from '../../../core/server'; import { @@ -90,6 +91,7 @@ export class TelemetryCollectionManagerPlugin priority, esCluster, esClientGetter, + soServiceGetter, statsGetter, clusterDetailsGetter, licenseGetter, @@ -112,6 +114,9 @@ export class TelemetryCollectionManagerPlugin if (!esClientGetter) { throw Error('esClientGetter method not set.'); } + if (!soServiceGetter) { + throw Error('soServiceGetter method not set.'); + } if (!clusterDetailsGetter) { throw Error('Cluster UUIds method is not set.'); } @@ -126,6 +131,7 @@ export class TelemetryCollectionManagerPlugin esCluster, title, esClientGetter, + soServiceGetter, }); this.usageGetterMethodPriority = priority; } @@ -135,6 +141,7 @@ export class TelemetryCollectionManagerPlugin config: StatsGetterConfig, collection: Collection, collectionEsClient: IClusterClient, + collectionSoService: SavedObjectsServiceStart, usageCollection: UsageCollectionSetup ): StatsCollectionConfig { const { start, end, request } = config; @@ -146,7 +153,11 @@ export class TelemetryCollectionManagerPlugin const esClient = config.unencrypted ? collectionEsClient.asScoped(config.request).asCurrentUser : collectionEsClient.asInternalUser; - return { callCluster, start, end, usageCollection, esClient }; + // Scope the saved objects client appropriately and pass to the stats collection config + const soClient = config.unencrypted + ? collectionSoService.getScopedClient(config.request) + : collectionSoService.createInternalRepository(); + return { callCluster, start, end, usageCollection, esClient, soClient }; } private async getOptInStats(optInStatus: boolean, config: StatsGetterConfig) { @@ -156,11 +167,13 @@ export class TelemetryCollectionManagerPlugin for (const collection of this.collections) { // first fetch the client and make sure it's not undefined. const collectionEsClient = collection.esClientGetter(); - if (collectionEsClient !== undefined) { + const collectionSoService = collection.soServiceGetter(); + if (collectionEsClient !== undefined && collectionSoService !== undefined) { const statsCollectionConfig = this.getStatsCollectionConfig( config, collection, collectionEsClient, + collectionSoService, this.usageCollection ); @@ -215,11 +228,13 @@ export class TelemetryCollectionManagerPlugin } for (const collection of this.collections) { const collectionEsClient = collection.esClientGetter(); - if (collectionEsClient !== undefined) { + const collectionSavedObjectsService = collection.soServiceGetter(); + if (collectionEsClient !== undefined && collectionSavedObjectsService !== undefined) { const statsCollectionConfig = this.getStatsCollectionConfig( config, collection, collectionEsClient, + collectionSavedObjectsService, this.usageCollection ); try { diff --git a/src/plugins/telemetry_collection_manager/server/types.ts b/src/plugins/telemetry_collection_manager/server/types.ts index 3b0936fb73a60..d6e4fdce2b188 100644 --- a/src/plugins/telemetry_collection_manager/server/types.ts +++ b/src/plugins/telemetry_collection_manager/server/types.ts @@ -23,6 +23,9 @@ import { KibanaRequest, ILegacyClusterClient, IClusterClient, + SavedObjectsServiceStart, + SavedObjectsClientContract, + ISavedObjectsRepository, } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { ElasticsearchClient } from '../../../../src/core/server'; @@ -77,6 +80,7 @@ export interface StatsCollectionConfig { start: string | number; end: string | number; esClient: ElasticsearchClient; + soClient: SavedObjectsClientContract | ISavedObjectsRepository; } export interface BasicStatsPayload { @@ -141,6 +145,7 @@ export interface CollectionConfig< priority: number; esCluster: ILegacyClusterClient; esClientGetter: () => IClusterClient | undefined; // --> by now we know that the client getter will return the IClusterClient but we assure that through a code check + soServiceGetter: () => SavedObjectsServiceStart | undefined; // --> by now we know that the service getter will return the SavedObjectsServiceStart but we assure that through a code check statsGetter: StatsGetter; clusterDetailsGetter: ClusterDetailsGetter; licenseGetter: LicenseGetter; @@ -157,5 +162,6 @@ export interface Collection< clusterDetailsGetter: ClusterDetailsGetter; esCluster: ILegacyClusterClient; esClientGetter: () => IClusterClient | undefined; // the collection could still return undefined for the es client getter. + soServiceGetter: () => SavedObjectsServiceStart | undefined; // the collection could still return undefined for the Saved Objects Service getter. title: string; } diff --git a/src/plugins/usage_collection/README.md b/src/plugins/usage_collection/README.md index 1d706843b8b4e..430241cbe0a05 100644 --- a/src/plugins/usage_collection/README.md +++ b/src/plugins/usage_collection/README.md @@ -64,7 +64,7 @@ All you need to provide is a `type` for organizing your fields, `schema` field t }, fetch: async (collectorFetchContext: CollectorFetchContext) => { - // query ES and get some data + // query ES or saved objects and get some data // summarize the data into a model // return the modeled object that includes whatever you want to track @@ -85,9 +85,11 @@ Some background: - `MY_USAGE_TYPE` can be any string. It usually matches the plugin name. As a safety mechanism, we double check there are no duplicates at the moment of registering the collector. - The `fetch` method needs to support multiple contexts in which it is called. For example, when stats are pulled from a Kibana Metricbeat module, the Beat calls Kibana's stats API to invoke usage collection. -In this case, the `fetch` method is called as a result of an HTTP API request and `callCluster` wraps `callWithRequest` or `esClient` wraps `asCurrentUser`, where the request headers are expected to have read privilege on the entire `.kibana' index. +In this case, the `fetch` method is called as a result of an HTTP API request and `callCluster` wraps `callWithRequest` or `esClient` wraps `asCurrentUser`, where the request headers are expected to have read privilege on the entire `.kibana' index. The `fetch` method also exposes the saved objects client that will have the correct scope when the collectors' `fetch` method is called. -Note: there will be many cases where you won't need to use the `callCluster` (or `esClient`) function that gets passed in to your `fetch` method at all. Your feature might have an accumulating value in server memory, or read something from the OS, or use other clients like a custom SavedObjects client. In that case it's up to the plugin to initialize those clients like the example below: +Note: there will be many cases where you won't need to use the `callCluster`, `esClient` or `soClient` function that gets passed in to your `fetch` method at all. Your feature might have an accumulating value in server memory, or read something from the OS. + +In the case of using a custom SavedObjects client, it is up to the plugin to initialize the client to save the data and it is strongly recommended to scope that client to the `kibana_system` user. ```ts // server/plugin.ts @@ -98,7 +100,7 @@ class Plugin { private savedObjectsRepository?: ISavedObjectsRepository; public setup(core: CoreSetup, plugins: { usageCollection?: UsageCollectionSetup }) { - registerMyPluginUsageCollector(() => this.savedObjectsRepository, plugins.usageCollection); + registerMyPluginUsageCollector(plugins.usageCollection); } public start(core: CoreStart) { diff --git a/src/plugins/usage_collection/server/collector/collector.ts b/src/plugins/usage_collection/server/collector/collector.ts index 6608849ae8b99..11a709c037783 100644 --- a/src/plugins/usage_collection/server/collector/collector.ts +++ b/src/plugins/usage_collection/server/collector/collector.ts @@ -17,7 +17,13 @@ * under the License. */ -import { Logger, LegacyAPICaller, ElasticsearchClient } from 'kibana/server'; +import { + Logger, + LegacyAPICaller, + ElasticsearchClient, + ISavedObjectsRepository, + SavedObjectsClientContract, +} from 'kibana/server'; export type CollectorFormatForBulkUpload = (result: T) => { type: string; payload: U }; @@ -56,7 +62,14 @@ export interface CollectorFetchContext { * - When building the telemetry data payload to report to the remote cluster, the requests are scoped to the `kibana` internal user */ esClient: ElasticsearchClient; + /** + * Request-scoped Saved Objects client: + * - When users are requesting a sample of data, it is scoped to their role to avoid exposing data they should't read + * - When building the telemetry data payload to report to the remote cluster, the requests are scoped to the `kibana` internal user + */ + soClient: SavedObjectsClientContract | ISavedObjectsRepository; } + export interface CollectorOptions { type: string; init?: Function; diff --git a/src/plugins/usage_collection/server/collector/collector_set.test.ts b/src/plugins/usage_collection/server/collector/collector_set.test.ts index c8114269f30e4..45a3437777c5f 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.test.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.test.ts @@ -21,7 +21,11 @@ import { noop } from 'lodash'; import { Collector } from './collector'; import { CollectorSet } from './collector_set'; import { UsageCollector } from './usage_collector'; -import { elasticsearchServiceMock, loggingSystemMock } from '../../../../core/server/mocks'; +import { + elasticsearchServiceMock, + loggingSystemMock, + savedObjectsRepositoryMock, +} from '../../../../core/server/mocks'; const logger = loggingSystemMock.createLogger(); @@ -40,9 +44,9 @@ describe('CollectorSet', () => { loggerSpies.debug.mockRestore(); loggerSpies.warn.mockRestore(); }); - const mockCallCluster = jest.fn().mockResolvedValue({ passTest: 1000 }); const mockEsClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + const mockSoClient = savedObjectsRepositoryMock.create(); it('should throw an error if non-Collector type of object is registered', () => { const collectors = new CollectorSet({ logger }); @@ -88,7 +92,7 @@ describe('CollectorSet', () => { }) ); - const result = await collectors.bulkFetch(mockCallCluster, mockEsClient); + const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient); expect(loggerSpies.debug).toHaveBeenCalledTimes(1); expect(loggerSpies.debug).toHaveBeenCalledWith( 'Fetching data from MY_TEST_COLLECTOR collector' @@ -113,7 +117,7 @@ describe('CollectorSet', () => { let result; try { - result = await collectors.bulkFetch(mockCallCluster, mockEsClient); + result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient); } catch (err) { // Do nothing } @@ -131,7 +135,7 @@ describe('CollectorSet', () => { }) ); - const result = await collectors.bulkFetch(mockCallCluster, mockEsClient); + const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient); expect(result).toStrictEqual([ { type: 'MY_TEST_COLLECTOR', @@ -149,7 +153,7 @@ describe('CollectorSet', () => { } as any) ); - const result = await collectors.bulkFetch(mockCallCluster, mockEsClient); + const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient); expect(result).toStrictEqual([ { type: 'MY_TEST_COLLECTOR', @@ -172,7 +176,7 @@ describe('CollectorSet', () => { }) ); - const result = await collectors.bulkFetch(mockCallCluster, mockEsClient); + const result = await collectors.bulkFetch(mockCallCluster, mockEsClient, mockSoClient); expect(result).toStrictEqual([ { type: 'MY_TEST_COLLECTOR', diff --git a/src/plugins/usage_collection/server/collector/collector_set.ts b/src/plugins/usage_collection/server/collector/collector_set.ts index 88204c61bfd8d..4e64cbc1bf30f 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.ts @@ -18,7 +18,13 @@ */ import { snakeCase } from 'lodash'; -import { Logger, LegacyAPICaller, ElasticsearchClient } from 'kibana/server'; +import { + Logger, + LegacyAPICaller, + ElasticsearchClient, + ISavedObjectsRepository, + SavedObjectsClientContract, +} from 'kibana/server'; import { Collector, CollectorOptions } from './collector'; import { UsageCollector } from './usage_collector'; @@ -125,6 +131,7 @@ export class CollectorSet { public bulkFetch = async ( callCluster: LegacyAPICaller, esClient: ElasticsearchClient, + soClient: SavedObjectsClientContract | ISavedObjectsRepository, collectors: Map> = this.collectors ) => { const responses = await Promise.all( @@ -133,7 +140,7 @@ export class CollectorSet { try { return { type: collector.type, - result: await collector.fetch({ callCluster, esClient }), + result: await collector.fetch({ callCluster, esClient, soClient }), }; } catch (err) { this.logger.warn(err); @@ -155,9 +162,18 @@ export class CollectorSet { return this.makeCollectorSetFromArray(filtered); }; - public bulkFetchUsage = async (callCluster: LegacyAPICaller, esClient: ElasticsearchClient) => { + public bulkFetchUsage = async ( + callCluster: LegacyAPICaller, + esClient: ElasticsearchClient, + savedObjectsClient: SavedObjectsClientContract | ISavedObjectsRepository + ) => { const usageCollectors = this.getFilteredCollectorSet((c) => c instanceof UsageCollector); - return await this.bulkFetch(callCluster, esClient, usageCollectors.collectors); + return await this.bulkFetch( + callCluster, + esClient, + savedObjectsClient, + usageCollectors.collectors + ); }; // convert an array of fetched stats results into key/object diff --git a/src/plugins/usage_collection/server/routes/stats/stats.ts b/src/plugins/usage_collection/server/routes/stats/stats.ts index bee25fef669f1..ef64d15fabc2d 100644 --- a/src/plugins/usage_collection/server/routes/stats/stats.ts +++ b/src/plugins/usage_collection/server/routes/stats/stats.ts @@ -26,8 +26,10 @@ import { first } from 'rxjs/operators'; import { ElasticsearchClient, IRouter, + ISavedObjectsRepository, LegacyAPICaller, MetricsServiceSetup, + SavedObjectsClientContract, ServiceStatus, ServiceStatusLevels, } from '../../../../../core/server'; @@ -64,9 +66,10 @@ export function registerStatsRoute({ }) { const getUsage = async ( callCluster: LegacyAPICaller, - esClient: ElasticsearchClient + esClient: ElasticsearchClient, + savedObjectsClient: SavedObjectsClientContract | ISavedObjectsRepository ): Promise => { - const usage = await collectorSet.bulkFetchUsage(callCluster, esClient); + const usage = await collectorSet.bulkFetchUsage(callCluster, esClient, savedObjectsClient); return collectorSet.toObject(usage); }; @@ -101,6 +104,7 @@ export function registerStatsRoute({ if (isExtended) { const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser; const esClient = context.core.elasticsearch.client.asCurrentUser; + const savedObjectsClient = context.core.savedObjects.client; if (shouldGetUsage) { const collectorsReady = await collectorSet.areAllCollectorsReady(); @@ -109,7 +113,9 @@ export function registerStatsRoute({ } } - const usagePromise = shouldGetUsage ? getUsage(callCluster, esClient) : Promise.resolve({}); + const usagePromise = shouldGetUsage + ? getUsage(callCluster, esClient, savedObjectsClient) + : Promise.resolve({}); const [usage, clusterUuid] = await Promise.all([usagePromise, getClusterUuid(callCluster)]); let modifiedUsage = usage; diff --git a/src/plugins/usage_collection/server/usage_collection.mock.ts b/src/plugins/usage_collection/server/usage_collection.mock.ts index 4e5e84200d65e..c31756c60e32d 100644 --- a/src/plugins/usage_collection/server/usage_collection.mock.ts +++ b/src/plugins/usage_collection/server/usage_collection.mock.ts @@ -17,7 +17,10 @@ * under the License. */ -import { elasticsearchServiceMock } from '../../../../src/core/server/mocks'; +import { + elasticsearchServiceMock, + savedObjectsRepositoryMock, +} from '../../../../src/core/server/mocks'; import { CollectorOptions } from './collector/collector'; import { UsageCollectionSetup, CollectorFetchContext } from './index'; @@ -52,6 +55,7 @@ export function createCollectorFetchContextMock(): jest.Mocked = { callCluster: elasticsearchServiceMock.createLegacyClusterClient().callAsInternalUser, esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, + soClient: savedObjectsRepositoryMock.create(), }; return collectorFetchClientsMock; } diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/props.json b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__fixtures__/props.json similarity index 89% rename from x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/props.json rename to x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__fixtures__/props.json index 7f24ad8b0d308..2e213c44bccf0 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/props.json +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__fixtures__/props.json @@ -11,10 +11,7 @@ "value": 46.06666666666667, "timeseries": [] }, - "avgResponseTime": null, - "environments": [ - "test" - ] + "environments": ["test"] }, { "serviceName": "opbeans-python", diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js deleted file mode 100644 index 7c306c16cca1f..0000000000000 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { shallow } from 'enzyme'; -import { ServiceList, SERVICE_COLUMNS } from '../index'; -import props from './props.json'; -import { mockMoment } from '../../../../../utils/testHelpers'; -import { ServiceHealthStatus } from '../../../../../../common/service_health_status'; - -describe('ServiceOverview -> List', () => { - beforeAll(() => { - mockMoment(); - }); - - it('renders empty state', () => { - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); - - it('renders with data', () => { - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); - - it('renders columns correctly', () => { - const service = { - serviceName: 'opbeans-python', - agentName: 'python', - transactionsPerMinute: { - value: 86.93333333333334, - timeseries: [], - }, - errorsPerMinute: { - value: 12.6, - timeseries: [], - }, - avgResponseTime: { - value: 91535.42944785276, - timeseries: [], - }, - environments: ['test'], - }; - const renderedColumns = SERVICE_COLUMNS.map((c) => - c.render(service[c.field], service) - ); - - expect(renderedColumns[0]).toMatchSnapshot(); - }); - - describe('without ML data', () => { - it('does not render health column', () => { - const wrapper = shallow(); - - const columns = wrapper.props().columns; - - expect(columns[0].field).not.toBe('healthStatus'); - }); - }); - - describe('with ML data', () => { - it('renders health column', () => { - const wrapper = shallow( - ({ - ...item, - healthStatus: ServiceHealthStatus.warning, - }))} - /> - ); - - const columns = wrapper.props().columns; - - expect(columns[0].field).toBe('healthStatus'); - }); - }); -}); diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap deleted file mode 100644 index e6a9823f3ee28..0000000000000 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap +++ /dev/null @@ -1,153 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ServiceOverview -> List renders columns correctly 1`] = ` - -`; - -exports[`ServiceOverview -> List renders empty state 1`] = ` - -`; - -exports[`ServiceOverview -> List renders with data 1`] = ` - -`; diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx index aa0222582b891..49319f167703c 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx @@ -191,18 +191,20 @@ export function ServiceList({ items, noItemsMessage }: Props) { const columns = displayHealthStatus ? SERVICE_COLUMNS : SERVICE_COLUMNS.filter((column) => column.field !== 'healthStatus'); + const initialSortField = displayHealthStatus + ? 'healthStatus' + : 'transactionsPerMinute'; return ( { // For healthStatus, sort items by healthStatus first, then by TPM - return sortField === 'healthStatus' ? orderBy( itemsToSort, diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/service_list.test.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/service_list.test.tsx new file mode 100644 index 0000000000000..daddd0a60fe1f --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/service_list.test.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { ReactNode } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { ServiceHealthStatus } from '../../../../../common/service_health_status'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ServiceListAPIResponse } from '../../../../../server/lib/services/get_services'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; +import { mockMoment, renderWithTheme } from '../../../../utils/testHelpers'; +import { ServiceList, SERVICE_COLUMNS } from './'; +import props from './__fixtures__/props.json'; + +function Wrapper({ children }: { children?: ReactNode }) { + return ( + + {children} + + ); +} + +describe('ServiceList', () => { + beforeAll(() => { + mockMoment(); + }); + + it('renders empty state', () => { + expect(() => + renderWithTheme(, { wrapper: Wrapper }) + ).not.toThrowError(); + }); + + it('renders with data', () => { + expect(() => + // Types of property 'avgResponseTime' are incompatible. + // Type 'null' is not assignable to type '{ value: number | null; timeseries: { x: number; y: number | null; }[]; } | undefined'.ts(2322) + renderWithTheme( + , + { wrapper: Wrapper } + ) + ).not.toThrowError(); + }); + + it('renders columns correctly', () => { + const service: any = { + serviceName: 'opbeans-python', + agentName: 'python', + transactionsPerMinute: { + value: 86.93333333333334, + timeseries: [], + }, + errorsPerMinute: { + value: 12.6, + timeseries: [], + }, + avgResponseTime: { + value: 91535.42944785276, + timeseries: [], + }, + environments: ['test'], + }; + const renderedColumns = SERVICE_COLUMNS.map((c) => + c.render!(service[c.field!], service) + ); + + expect(renderedColumns[0]).toMatchInlineSnapshot(` + + `); + }); + + describe('without ML data', () => { + it('does not render the health column', () => { + const { queryByText } = renderWithTheme( + , + { + wrapper: Wrapper, + } + ); + const healthHeading = queryByText('Health'); + + expect(healthHeading).toBeNull(); + }); + + it('sorts by transactions per minute', async () => { + const { findByTitle } = renderWithTheme( + , + { + wrapper: Wrapper, + } + ); + + expect( + await findByTitle('Trans. per minute; Sorted in descending order') + ).toBeInTheDocument(); + }); + }); + + describe('with ML data', () => { + it('renders the health column', async () => { + const { findByTitle } = renderWithTheme( + ({ + ...item, + healthStatus: ServiceHealthStatus.warning, + }) + )} + />, + { wrapper: Wrapper } + ); + + expect( + await findByTitle('Health; Sorted in descending order') + ).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/infra/public/components/loading_page.tsx b/x-pack/plugins/infra/public/components/loading_page.tsx index ae8e18a2f98ea..87cd1e9aebf6a 100644 --- a/x-pack/plugins/infra/public/components/loading_page.tsx +++ b/x-pack/plugins/infra/public/components/loading_page.tsx @@ -27,10 +27,8 @@ export const LoadingPage = ({ - - - - + + {message} diff --git a/x-pack/plugins/infra/public/components/saved_views/manage_views_flyout.tsx b/x-pack/plugins/infra/public/components/saved_views/manage_views_flyout.tsx index 698034f8154d1..1515175b5115f 100644 --- a/x-pack/plugins/infra/public/components/saved_views/manage_views_flyout.tsx +++ b/x-pack/plugins/infra/public/components/saved_views/manage_views_flyout.tsx @@ -16,6 +16,7 @@ import { EuiInMemoryTable, EuiFlexGroup, EuiButton, + EuiPortal, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -157,34 +158,36 @@ export function SavedViewManageViewsFlyout({ ]; return ( - - - -

- -

-
-
+ + + + +

+ +

+
+
- - - + + + - - - - - -
+ + + + + +
+ ); } diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index ac2c87248ae77..022c62b6bb06b 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -121,24 +121,29 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { ]} /> - - - - - - - - - - {ADD_DATA_LABEL} - + + + + + + + + + + + {ADD_DATA_LABEL} + + diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx index 9cb84c7fff438..7c6e58125b48b 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/bottom_drawer.tsx @@ -47,15 +47,12 @@ export const BottomDrawer: React.FC<{ style={{ position: 'relative', minWidth: 400, - alignSelf: 'center', height: '16px', }} > {children} - - - + @@ -85,3 +82,7 @@ const BottomActionTopBar = euiStyled(EuiFlexGroup).attrs({ const ShowHideButton = euiStyled(EuiButtonEmpty).attrs({ size: 's' })` width: 140px; `; + +const RightSideSpacer = euiStyled(EuiSpacer).attrs({ size: 'xs' })` + width: 140px; +`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx index 712578be7dffd..b9caef704d071 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx @@ -104,46 +104,57 @@ export const Layout = () => { <> - - - - - - - - - - - - - - - - {({ measureRef, bounds: { height = 0 } }) => ( + {({ measureRef: topActionMeasureRef, bounds: { height: topActionHeight = 0 } }) => ( <> - - - - + + + + + + + + + + + + + + + + + {({ measureRef, bounds: { height = 0 } }) => ( + <> + + + + + + )} + )} diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx index a705a0be3a39e..aa6157dc48d5c 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/nodes_overview.tsx @@ -6,6 +6,7 @@ import { i18n } from '@kbn/i18n'; import React, { useCallback } from 'react'; +import { getBreakpoint } from '@elastic/eui'; import { InventoryItemType } from '../../../../../common/inventory_models/types'; import { euiStyled } from '../../../../../../observability/public'; @@ -35,6 +36,7 @@ interface Props { autoBounds: boolean; formatter: InfraFormatter; bottomMargin: number; + topMargin: number; } export const NodesOverview = ({ @@ -50,6 +52,7 @@ export const NodesOverview = ({ formatter, onDrilldown, bottomMargin, + topMargin, }: Props) => { const handleDrilldown = useCallback( (filter: string) => { @@ -94,6 +97,7 @@ export const NodesOverview = ({ } const dataBounds = calculateBoundsFromNodes(nodes); const bounds = autoBounds ? dataBounds : boundsOverride; + const isStatic = ['xs', 's'].includes(getBreakpoint(window.innerWidth)!); if (view === 'table') { return ( @@ -110,7 +114,7 @@ export const NodesOverview = ({ ); } return ( - + ); @@ -130,10 +135,10 @@ const TableContainer = euiStyled.div` padding: ${(props) => props.theme.eui.paddingSizes.l}; `; -const MapContainer = euiStyled.div` - position: absolute; +const MapContainer = euiStyled.div<{ top: number; positionStatic: boolean }>` + position: ${(props) => (props.positionStatic ? 'static' : 'absolute')}; display: flex; - top: 70px; + top: ${(props) => props.top}px; right: 0; bottom: 0; left: 0; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/timeline/timeline.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/timeline/timeline.tsx index a3b02b858385e..d66fd44feba56 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/timeline/timeline.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/timeline/timeline.tsx @@ -27,7 +27,7 @@ import { EuiIcon } from '@elastic/eui'; import { useUiSetting } from '../../../../../../../../../src/plugins/kibana_react/public'; import { toMetricOpt } from '../../../../../../common/snapshot_metric_i18n'; import { MetricsExplorerAggregation } from '../../../../../../common/http_api'; -import { Color } from '../../../../../../common/color_palette'; +import { colorTransformer, Color } from '../../../../../../common/color_palette'; import { useSourceContext } from '../../../../../containers/source'; import { useTimeline } from '../../hooks/use_timeline'; import { useWaffleOptionsContext } from '../../hooks/use_waffle_options'; @@ -102,11 +102,12 @@ export const Timeline: React.FC = ({ interval, yAxisFormatter, isVisible }, [nodeType, metricsHostsAnomalies, metricsK8sAnomalies]); const metricLabel = toMetricOpt(metric.type)?.textLC; + const metricPopoverLabel = toMetricOpt(metric.type)?.text; const chartMetric = { color: Color.color0, aggregation: 'avg' as MetricsExplorerAggregation, - label: metricLabel, + label: metricPopoverLabel, }; const dateFormatter = useMemo(() => { @@ -225,10 +226,7 @@ export const Timeline: React.FC = ({ interval, yAxisFormatter, isVisible - + @@ -335,11 +333,11 @@ const TimelineLoadingContainer = euiStyled.div` `; const noHistoryDataTitle = i18n.translate('xpack.infra.inventoryTimeline.noHistoryDataTitle', { - defaultMessage: 'There is no history data to display.', + defaultMessage: 'There is no historical data to display.', }); const errorTitle = i18n.translate('xpack.infra.inventoryTimeline.errorTitle', { - defaultMessage: 'Unable to display history data.', + defaultMessage: 'Unable to show historical data.', }); const checkNewDataButtonLabel = i18n.translate( diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar_wrapper.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar_wrapper.tsx index 449c0a89b4642..6922398e57d70 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar_wrapper.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar_wrapper.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { EuiFlexItem } from '@elastic/eui'; +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { fieldToName } from '../../lib/field_to_display_name'; import { useSourceContext } from '../../../../../containers/source'; import { useWaffleOptionsContext } from '../../hooks/use_waffle_options'; @@ -38,7 +38,7 @@ export const ToolbarWrapper = (props: Props) => { } = useWaffleOptionsContext(); const { createDerivedIndexPattern } = useSourceContext(); return ( - <> + @@ -62,7 +62,7 @@ export const ToolbarWrapper = (props: Props) => { customMetrics, changeCustomMetrics, })} - + ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/map.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/map.tsx index 89b1b9b2211d9..6621b110a6dfd 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/map.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/map.tsx @@ -27,6 +27,7 @@ interface Props { bounds: InfraWaffleMapBounds; dataBounds: InfraWaffleMapBounds; bottomMargin: number; + staticHeight: boolean; } export const Map: React.FC = ({ @@ -39,6 +40,7 @@ export const Map: React.FC = ({ nodeType, dataBounds, bottomMargin, + staticHeight, }) => { const sortedNodes = sortNodes(options.sort, nodes); const map = nodesToWaffleMap(sortedNodes); @@ -51,6 +53,7 @@ export const Map: React.FC = ({ ref={(el: any) => measureRef(el)} bottomMargin={bottomMargin} data-test-subj="waffleMap" + staticHeight={staticHeight} > {groupsWithLayout.map((group) => { @@ -92,7 +95,7 @@ export const Map: React.FC = ({ ); }; -const WaffleMapOuterContainer = euiStyled.div<{ bottomMargin: number }>` +const WaffleMapOuterContainer = euiStyled.div<{ bottomMargin: number; staticHeight: boolean }>` flex: 1 0 0%; display: flex; justify-content: flex-start; @@ -100,6 +103,7 @@ const WaffleMapOuterContainer = euiStyled.div<{ bottomMargin: number }>` overflow-x: hidden; overflow-y: auto; margin-bottom: ${(props) => props.bottomMargin}px; + ${(props) => props.staticHeight && 'min-height: 300px;'} `; const WaffleMapInnerContainer = euiStyled.div` diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/view_switcher.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/view_switcher.tsx index 76756637eb69e..3dbe881cd5dc4 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/view_switcher.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/view_switcher.tsx @@ -6,7 +6,6 @@ import { EuiButtonGroup, EuiButtonGroupProps } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; - import React from 'react'; interface Props { diff --git a/x-pack/plugins/ingest_manager/common/services/full_agent_policy_kibana_config.test.ts b/x-pack/plugins/ingest_manager/common/services/full_agent_policy_kibana_config.test.ts new file mode 100644 index 0000000000000..07d3f68d7b971 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/full_agent_policy_kibana_config.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getFullAgentPolicyKibanaConfig } from './full_agent_policy_kibana_config'; + +describe('Fleet - getFullAgentPolicyKibanaConfig', () => { + it('should return no path when there is no path', () => { + expect(getFullAgentPolicyKibanaConfig(['http://localhost:5601'])).toEqual({ + hosts: ['localhost:5601'], + protocol: 'http', + }); + }); + it('should return correct config when there is a path', () => { + expect(getFullAgentPolicyKibanaConfig(['http://localhost:5601/ssg'])).toEqual({ + hosts: ['localhost:5601'], + protocol: 'http', + path: '/ssg/', + }); + }); + it('should return correct config when there is a path that ends in a slash', () => { + expect(getFullAgentPolicyKibanaConfig(['http://localhost:5601/ssg/'])).toEqual({ + hosts: ['localhost:5601'], + protocol: 'http', + path: '/ssg/', + }); + }); + it('should return correct config when there are multiple hosts', () => { + expect( + getFullAgentPolicyKibanaConfig(['http://localhost:5601/ssg/', 'http://localhost:3333/ssg/']) + ).toEqual({ + hosts: ['localhost:5601', 'localhost:3333'], + protocol: 'http', + path: '/ssg/', + }); + }); +}); diff --git a/x-pack/plugins/ingest_manager/common/services/full_agent_policy_kibana_config.ts b/x-pack/plugins/ingest_manager/common/services/full_agent_policy_kibana_config.ts new file mode 100644 index 0000000000000..ae6e34fe82d1d --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/full_agent_policy_kibana_config.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FullAgentPolicyKibanaConfig } from '../types'; + +export function getFullAgentPolicyKibanaConfig(kibanaUrls: string[]): FullAgentPolicyKibanaConfig { + // paths and protocol are validated to be the same for all urls, so use the first to get them + const firstUrlParsed = new URL(kibanaUrls[0]); + const config: FullAgentPolicyKibanaConfig = { + // remove the : from http: + protocol: firstUrlParsed.protocol.replace(':', ''), + hosts: kibanaUrls.map((url) => new URL(url).host), + }; + + // add path if user provided one + if (firstUrlParsed.pathname !== '/') { + // make sure the path ends with / + config.path = firstUrlParsed.pathname.endsWith('/') + ? firstUrlParsed.pathname + : `${firstUrlParsed.pathname}/`; + } + return config; +} diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent_policy.ts b/x-pack/plugins/ingest_manager/common/types/models/agent_policy.ts index 8d8344aed6c4c..0232bd766ca53 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent_policy.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent_policy.ts @@ -62,10 +62,7 @@ export interface FullAgentPolicy { }; }; fleet?: { - kibana: { - hosts: string[]; - protocol: string; - }; + kibana: FullAgentPolicyKibanaConfig; }; inputs: FullAgentPolicyInput[]; revision?: number; @@ -78,3 +75,9 @@ export interface FullAgentPolicy { }; }; } + +export interface FullAgentPolicyKibanaConfig { + hosts: string[]; + protocol: string; + path?: string; +} diff --git a/x-pack/plugins/ingest_manager/server/services/agent_policy.ts b/x-pack/plugins/ingest_manager/server/services/agent_policy.ts index ff8c2fece8589..75c16df483a76 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_policy.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_policy.ts @@ -33,6 +33,7 @@ import { outputService } from './output'; import { agentPolicyUpdateEventHandler } from './agent_policy_update'; import { getSettings } from './settings'; import { normalizeKuery, escapeSearchQueryPhrase } from './saved_object'; +import { getFullAgentPolicyKibanaConfig } from '../../common/services/full_agent_policy_kibana_config'; const SAVED_OBJECT_TYPE = AGENT_POLICY_SAVED_OBJECT_TYPE; @@ -537,18 +538,11 @@ class AgentPolicyService { } if (!settings.kibana_urls || !settings.kibana_urls.length) throw new Error('kibana_urls is missing'); - const hostsWithoutProtocol = settings.kibana_urls.map((url) => { - const parsedURL = new URL(url); - return `${parsedURL.host}${parsedURL.pathname !== '/' ? parsedURL.pathname : ''}`; - }); + fullAgentPolicy.fleet = { - kibana: { - protocol: new URL(settings.kibana_urls[0]).protocol.replace(':', ''), - hosts: hostsWithoutProtocol, - }, + kibana: getFullAgentPolicyKibanaConfig(settings.kibana_urls), }; } - return fullAgentPolicy; } } diff --git a/x-pack/plugins/lists/server/scripts/lists/files/hosts.txt b/x-pack/plugins/lists/server/scripts/lists/files/hosts.txt index aee32e3a4bd92..5a8a1f4123910 100644 --- a/x-pack/plugins/lists/server/scripts/lists/files/hosts.txt +++ b/x-pack/plugins/lists/server/scripts/lists/files/hosts.txt @@ -1,2 +1,3 @@ -kibana +siem-kibana +siem-windows rock01 diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_chart.tsx b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_chart.tsx index e3ab0abc18e71..53065c624543d 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_chart.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_chart.tsx @@ -25,6 +25,7 @@ import React, { useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import euiVars from '@elastic/eui/dist/eui_theme_light.json'; import { DecisionPathPlotData } from './use_classification_path_data'; +import { formatSingleValue } from '../../../formatters/format_value'; const { euiColorFullShade, euiColorMediumShade } = euiVars; const axisColor = euiColorMediumShade; @@ -79,7 +80,6 @@ interface DecisionPathChartProps { const DECISION_PATH_MARGIN = 125; const DECISION_PATH_ROW_HEIGHT = 10; -const NUM_PRECISION = 3; const AnnotationBaselineMarker = ; export const DecisionPathChart = ({ @@ -95,7 +95,7 @@ export const DecisionPathChart = ({ () => [ { dataValue: baseline, - header: baseline ? baseline.toPrecision(NUM_PRECISION) : '', + header: baseline ? formatSingleValue(baseline).toString() : '', details: i18n.translate( 'xpack.ml.dataframe.analytics.explorationResults.decisionPathBaselineText', { @@ -110,7 +110,7 @@ export const DecisionPathChart = ({ // if regression, guarantee up to num_precision significant digits without having it in scientific notation // if classification, hide the numeric values since we only want to show the path const tickFormatter = useCallback( - (d) => (showValues === false ? '' : Number(d.toPrecision(NUM_PRECISION)).toString()), + (d) => (showValues === false ? '' : formatSingleValue(d).toString()), [] ); diff --git a/x-pack/plugins/ml/public/application/formatters/format_value.ts b/x-pack/plugins/ml/public/application/formatters/format_value.ts index 1a696d6e01dde..36425c65374bd 100644 --- a/x-pack/plugins/ml/public/application/formatters/format_value.ts +++ b/x-pack/plugins/ml/public/application/formatters/format_value.ts @@ -53,9 +53,9 @@ export function formatValue( // For time_of_day or time_of_week functions the anomaly record // containing the timestamp of the anomaly should be supplied in // order to correctly format the day or week offset to the time of the anomaly. -function formatSingleValue( +export function formatSingleValue( value: number, - mlFunction: string, + mlFunction?: string, fieldFormat?: any, record?: AnomalyRecordDoc ) { diff --git a/x-pack/plugins/monitoring/server/plugin.ts b/x-pack/plugins/monitoring/server/plugin.ts index 73f03a0e6774a..79c8e01c4cffd 100644 --- a/x-pack/plugins/monitoring/server/plugin.ts +++ b/x-pack/plugins/monitoring/server/plugin.ts @@ -21,6 +21,7 @@ import { CustomHttpResponseOptions, ResponseError, IClusterClient, + SavedObjectsServiceStart, } from 'kibana/server'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; import { @@ -76,6 +77,7 @@ export class Plugin { private legacyShimDependencies = {} as LegacyShimDependencies; private bulkUploader: IBulkUploader = {} as IBulkUploader; private telemetryElasticsearchClient: IClusterClient | undefined; + private telemetrySavedObjectsService: SavedObjectsServiceStart | undefined; constructor(initializerContext: PluginInitializerContext) { this.initializerContext = initializerContext; @@ -145,14 +147,15 @@ export class Plugin { // Initialize telemetry if (plugins.telemetryCollectionManager) { - registerMonitoringCollection( - plugins.telemetryCollectionManager, - this.cluster, - () => this.telemetryElasticsearchClient, - { + registerMonitoringCollection({ + telemetryCollectionManager: plugins.telemetryCollectionManager, + esCluster: this.cluster, + esClientGetter: () => this.telemetryElasticsearchClient, + soServiceGetter: () => this.telemetrySavedObjectsService, + customContext: { maxBucketSize: config.ui.max_bucket_size, - } - ); + }, + }); } // Register collector objects for stats to show up in the APIs @@ -249,12 +252,15 @@ export class Plugin { }; } - start({ elasticsearch }: CoreStart) { + start({ elasticsearch, savedObjects }: CoreStart) { // TODO: For the telemetry plugin to work, we need to provide the new ES client. // The new client should be inititalized with a similar config to `this.cluster` but, since we're not using - // the new client in Monitoring Telemetry collection yet, setting the local client allos progress for now. + // the new client in Monitoring Telemetry collection yet, setting the local client allows progress for now. + // The usage collector `fetch` method has been refactored to accept a `collectorFetchContext` object, + // exposing both es clients and the saved objects client. // We will update the client in a follow up PR. this.telemetryElasticsearchClient = elasticsearch.client; + this.telemetrySavedObjectsService = savedObjects; } stop() { diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts index 89f09d349014f..129b798740806 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts @@ -16,6 +16,7 @@ describe('get_all_stats', () => { const end = 1; const callCluster = sinon.stub(); const esClient = sinon.stub(); + const soClient = sinon.stub(); const esClusters = [ { cluster_uuid: 'a' }, @@ -178,6 +179,7 @@ describe('get_all_stats', () => { { callCluster: callCluster as any, esClient: esClient as any, + soClient: soClient as any, usageCollection: {} as any, start, end, @@ -204,6 +206,7 @@ describe('get_all_stats', () => { { callCluster: callCluster as any, esClient: esClient as any, + soClient: soClient as any, usageCollection: {} as any, start, end, diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.ts index 1170380b26ac8..9ebd73ffbc833 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.ts @@ -28,7 +28,7 @@ export interface CustomContext { */ export const getAllStats: StatsGetter = async ( clustersDetails, - { callCluster, start, end, esClient }, + { callCluster, start, end, esClient, soClient }, { maxBucketSize } ) => { const clusterUuids = clustersDetails.map((clusterDetails) => clusterDetails.clusterUuid); diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.test.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.test.ts index b2f3cb6c61526..c885bc9be4408 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.test.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_cluster_uuids.test.ts @@ -5,7 +5,7 @@ */ import sinon from 'sinon'; -import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { elasticsearchServiceMock, savedObjectsRepositoryMock } from 'src/core/server/mocks'; import { getClusterUuids, fetchClusterUuids, @@ -15,6 +15,7 @@ import { describe('get_cluster_uuids', () => { const callCluster = sinon.stub(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + const soClient = savedObjectsRepositoryMock.create(); const response = { aggregations: { cluster_uuids: { @@ -32,9 +33,12 @@ describe('get_cluster_uuids', () => { it('returns cluster UUIDs', async () => { callCluster.withArgs('search').returns(Promise.resolve(response)); expect( - await getClusterUuids({ callCluster, esClient, start, end, usageCollection: {} as any }, { - maxBucketSize: 1, - } as any) + await getClusterUuids( + { callCluster, esClient, soClient, start, end, usageCollection: {} as any }, + { + maxBucketSize: 1, + } as any + ) ).toStrictEqual(expectedUuids); }); }); @@ -43,9 +47,12 @@ describe('get_cluster_uuids', () => { it('searches for clusters', async () => { callCluster.returns(Promise.resolve(response)); expect( - await fetchClusterUuids({ callCluster, esClient, start, end, usageCollection: {} as any }, { - maxBucketSize: 1, - } as any) + await fetchClusterUuids( + { callCluster, esClient, soClient, start, end, usageCollection: {} as any }, + { + maxBucketSize: 1, + } as any + ) ).toStrictEqual(response); }); }); diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_collection.ts b/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_collection.ts index 3648ae4bd8551..109fefd2eb8de 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_collection.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/register_monitoring_collection.ts @@ -4,21 +4,33 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ILegacyCustomClusterClient, IClusterClient } from 'kibana/server'; +import { + ILegacyCustomClusterClient, + IClusterClient, + SavedObjectsServiceStart, +} from 'kibana/server'; import { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; import { getAllStats, CustomContext } from './get_all_stats'; import { getClusterUuids } from './get_cluster_uuids'; import { getLicenses } from './get_licenses'; -export function registerMonitoringCollection( - telemetryCollectionManager: TelemetryCollectionManagerPluginSetup, - esCluster: ILegacyCustomClusterClient, - esClientGetter: () => IClusterClient | undefined, - customContext: CustomContext -) { +export function registerMonitoringCollection({ + telemetryCollectionManager, + esCluster, + esClientGetter, + soServiceGetter, + customContext, +}: { + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup; + esCluster: ILegacyCustomClusterClient; + esClientGetter: () => IClusterClient | undefined; + soServiceGetter: () => SavedObjectsServiceStart | undefined; + customContext: CustomContext; +}) { telemetryCollectionManager.setCollection({ esCluster, esClientGetter, + soServiceGetter, title: 'monitoring', priority: 2, statsGetter: getAllStats, diff --git a/x-pack/plugins/security_solution/common/ecs/geo/index.ts b/x-pack/plugins/security_solution/common/ecs/geo/index.ts index 409b5bbdc17a4..4a4c76adb097b 100644 --- a/x-pack/plugins/security_solution/common/ecs/geo/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/geo/index.ts @@ -6,22 +6,15 @@ export interface GeoEcs { city_name?: string[]; - continent_name?: string[]; - country_iso_code?: string[]; - country_name?: string[]; - location?: Location; - region_iso_code?: string[]; - region_name?: string[]; } export interface Location { lon?: number[]; - lat?: number[]; } diff --git a/x-pack/plugins/security_solution/common/ecs/source/index.ts b/x-pack/plugins/security_solution/common/ecs/source/index.ts index 9e6b6563cec68..2c8618f4edcd0 100644 --- a/x-pack/plugins/security_solution/common/ecs/source/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/source/index.ts @@ -8,14 +8,9 @@ import { GeoEcs } from '../geo'; export interface SourceEcs { bytes?: number[]; - ip?: string[]; - port?: number[]; - domain?: string[]; - geo?: GeoEcs; - packets?: number[]; } 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 e2f5ca9025bd9..7ccd588e16a89 100644 --- a/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts @@ -4,12 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { HOST_STATS, NETWORK_STATS } from '../screens/overview'; +import { HOST_STATS, NETWORK_STATS, OVERVIEW_EMPTY_PAGE } from '../screens/overview'; import { expandHostStats, expandNetworkStats } from '../tasks/overview'; import { loginAndWaitForPage } from '../tasks/login'; import { OVERVIEW_URL } from '../urls/navigation'; +import { esArchiverUnload, esArchiverLoad } from '../tasks/es_archiver'; describe('Overview Page', () => { before(() => { @@ -33,4 +34,19 @@ describe('Overview Page', () => { cy.get(stat.domId).invoke('text').should('eq', stat.value); }); }); + + describe('with no data', () => { + before(() => { + esArchiverUnload('auditbeat'); + loginAndWaitForPage(OVERVIEW_URL); + }); + + after(() => { + esArchiverLoad('auditbeat'); + }); + + it('Splash screen should be here', () => { + cy.get(OVERVIEW_EMPTY_PAGE).should('be.visible'); + }); + }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/overview.ts b/x-pack/plugins/security_solution/cypress/screens/overview.ts index 95facc8974400..006d5fdf5a665 100644 --- a/x-pack/plugins/security_solution/cypress/screens/overview.ts +++ b/x-pack/plugins/security_solution/cypress/screens/overview.ts @@ -142,3 +142,5 @@ export const NETWORK_STATS = [ export const OVERVIEW_HOST_STATS = '[data-test-subj="overview-hosts-stats"]'; export const OVERVIEW_NETWORK_STATS = '[data-test-subj="overview-network-stats"]'; + +export const OVERVIEW_EMPTY_PAGE = '[data-test-subj="empty-page"]'; diff --git a/x-pack/plugins/security_solution/public/common/components/tables/__snapshots__/helpers.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/tables/__snapshots__/helpers.test.tsx.snap index 5372ccfcd1188..b585bfc613315 100644 --- a/x-pack/plugins/security_solution/public/common/components/tables/__snapshots__/helpers.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/tables/__snapshots__/helpers.test.tsx.snap @@ -47,8 +47,6 @@ exports[`Table Helpers #getRowItemDraggables it returns correctly against snapsh key="idPrefix-attrName-item1-0" render={[Function]} /> - , - - , - - {index !== 0 && ( - <> - {','} - - - )} = ({ AnomaliesTableComponent, flowTarget, ip, + hostName, indexNames, }) => { const { jobs } = useInstalledSecurityJobs(); @@ -71,6 +72,7 @@ const AnomaliesQueryTabBodyComponent: React.FC = ({ narrowDateRange={narrowDateRange} flowTarget={flowTarget} ip={ip} + hostName={hostName} /> ); diff --git a/x-pack/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/types.ts b/x-pack/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/types.ts index 3ce4b8b6d4494..7621749348a90 100644 --- a/x-pack/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/types.ts +++ b/x-pack/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/types.ts @@ -32,4 +32,5 @@ export type AnomaliesQueryTabBodyProps = QueryTabBodyProps & { updateDateRange?: UpdateDateRange; hideHistogramIfEmpty?: boolean; ip?: string; + hostName?: string; }; diff --git a/x-pack/plugins/security_solution/public/common/store/reducer.test.ts b/x-pack/plugins/security_solution/public/common/store/reducer.test.ts new file mode 100644 index 0000000000000..3e47478b783eb --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/store/reducer.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createInitialState } from './reducer'; + +jest.mock('../lib/kibana', () => ({ + KibanaServices: { + get: jest.fn(() => ({ uiSettings: { get: () => ({ from: 'now-24h', to: 'now' }) } })), + }, +})); + +describe('createInitialState', () => { + describe('sourcerer -> default -> indicesExist', () => { + test('indicesExist should be TRUE if configIndexPatterns is NOT empty', () => { + const initState = createInitialState( + {}, + { + kibanaIndexPatterns: [{ id: '1234567890987654321', title: 'mock-kibana' }], + configIndexPatterns: ['auditbeat-*', 'filebeat'], + } + ); + + expect(initState.sourcerer?.sourcererScopes.default.indicesExist).toEqual(true); + }); + + test('indicesExist should be FALSE if configIndexPatterns is empty', () => { + const initState = createInitialState( + {}, + { + kibanaIndexPatterns: [{ id: '1234567890987654321', title: 'mock-kibana' }], + configIndexPatterns: [], + } + ); + + expect(initState.sourcerer?.sourcererScopes.default.indicesExist).toEqual(false); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/store/reducer.ts b/x-pack/plugins/security_solution/public/common/store/reducer.ts index 60cb6a4e960bd..8d528f4279955 100644 --- a/x-pack/plugins/security_solution/public/common/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/reducer.ts @@ -43,6 +43,13 @@ export const createInitialState = ( inputs: createInitialInputsState(), sourcerer: { ...sourcererModel.initialSourcererState, + sourcererScopes: { + ...sourcererModel.initialSourcererState.sourcererScopes, + default: { + ...sourcererModel.initialSourcererState.sourcererScopes.default, + indicesExist: configIndexPatterns.length > 0, + }, + }, kibanaIndexPatterns, configIndexPatterns, }, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx index 88297c4e3701a..fc03e07442f9e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx @@ -122,9 +122,13 @@ const StepAboutRuleComponent: FC = ({ }, [onSubmit]); useEffect(() => { - if (setForm) { + let didCancel = false; + if (setForm && !didCancel) { setForm(RuleStep.aboutRule, getData); } + return () => { + didCancel = true; + }; }, [getData, setForm]); return isReadOnlyView ? ( diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx index 63b283308dd59..27d69c6887011 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx @@ -190,9 +190,13 @@ const StepDefineRuleComponent: FC = ({ }, [getFormData, submit]); useEffect(() => { - if (setForm) { + let didCancel = false; + if (setForm && !didCancel) { setForm(RuleStep.defineRule, getData); } + return () => { + didCancel = true; + }; }, [getData, setForm]); const handleResetIndices = useCallback(() => { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx index de473b6fc6aec..349a79d4e40f9 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx @@ -120,9 +120,13 @@ const StepRuleActionsComponent: FC = ({ }, [getFormData, submit]); useEffect(() => { - if (setForm) { + let didCancel = false; + if (setForm && !didCancel) { setForm(RuleStep.ruleActions, getData); } + return () => { + didCancel = true; + }; }, [getData, setForm]); const throttleOptions = useMemo(() => { @@ -142,55 +146,59 @@ const StepRuleActionsComponent: FC = ({ [isLoading, throttleOptions] ); - if (isReadOnlyView) { - return ( - - - - ); - } - - const displayActionsOptions = - throttle !== stepActionsDefaultValue.throttle ? ( + const displayActionsOptions = useMemo( + () => + throttle !== stepActionsDefaultValue.throttle ? ( + <> + + + + ) : ( + + ), + [throttle, actionMessageParams] + ); + // only display the actions dropdown if the user has "read" privileges for actions + const displayActionsDropDown = useMemo(() => { + return application.capabilities.actions.show ? ( <> - + {displayActionsOptions} + + ) : ( - + <> + {I18n.NO_ACTIONS_READ_PERMISSIONS} + + + + + ); + }, [application.capabilities.actions.show, displayActionsOptions, throttleFieldComponentProps]); - // only display the actions dropdown if the user has "read" privileges for actions - const displayActionsDropDown = application.capabilities.actions.show ? ( - <> - - {displayActionsOptions} - - - - ) : ( - <> - {I18n.NO_ACTIONS_READ_PERMISSIONS} - - - - - - ); + if (isReadOnlyView) { + return ( + + + + ); + } return ( <> diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx index d451932a6b634..0bc06e3dafc69 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx @@ -64,9 +64,13 @@ const StepScheduleRuleComponent: FC = ({ }, [getFormData, submit]); useEffect(() => { - if (setForm) { + let didCancel = false; + if (setForm && !didCancel) { setForm(RuleStep.scheduleRule, getData); } + return () => { + didCancel = true; + }; }, [getData, setForm]); return isReadOnlyView ? ( diff --git a/x-pack/plugins/security_solution/public/detections/components/user_info/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/user_info/index.test.tsx index b01edac2605eb..9b15007136b2e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/user_info/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/user_info/index.test.tsx @@ -4,20 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { renderHook } from '@testing-library/react-hooks'; -import { useUserInfo } from './index'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { useUserInfo, ManageUserInfo } from './index'; -import { usePrivilegeUser } from '../../containers/detection_engine/alerts/use_privilege_user'; -import { useSignalIndex } from '../../containers/detection_engine/alerts/use_signal_index'; import { useKibana } from '../../../common/lib/kibana'; -jest.mock('../../containers/detection_engine/alerts/use_privilege_user'); -jest.mock('../../containers/detection_engine/alerts/use_signal_index'); +import * as api from '../../containers/detection_engine/alerts/api'; + jest.mock('../../../common/lib/kibana'); +jest.mock('../../containers/detection_engine/alerts/api'); describe('useUserInfo', () => { beforeAll(() => { - (usePrivilegeUser as jest.Mock).mockReturnValue({}); - (useSignalIndex as jest.Mock).mockReturnValue({}); (useKibana as jest.Mock).mockReturnValue({ services: { application: { @@ -30,21 +27,40 @@ describe('useUserInfo', () => { }, }); }); - it('returns default state', () => { - const { result } = renderHook(() => useUserInfo()); + it('returns default state', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useUserInfo()); + await waitForNextUpdate(); - expect(result).toEqual({ - current: { - canUserCRUD: null, - hasEncryptionKey: null, - hasIndexManage: null, - hasIndexWrite: null, - isAuthenticated: null, - isSignalIndexExists: null, - loading: true, - signalIndexName: null, - }, - error: undefined, + expect(result).toEqual({ + current: { + canUserCRUD: null, + hasEncryptionKey: null, + hasIndexManage: null, + hasIndexWrite: null, + isAuthenticated: null, + isSignalIndexExists: null, + loading: true, + signalIndexName: null, + signalIndexTemplateOutdated: null, + }, + error: undefined, + }); + }); + }); + + it('calls createSignalIndex if signal index template is outdated', async () => { + const spyOnCreateSignalIndex = jest.spyOn(api, 'createSignalIndex'); + const spyOnGetSignalIndex = jest.spyOn(api, 'getSignalIndex').mockResolvedValueOnce({ + name: 'mock-signal-index', + template_outdated: true, + }); + await act(async () => { + const { waitForNextUpdate } = renderHook(() => useUserInfo(), { wrapper: ManageUserInfo }); + await waitForNextUpdate(); + await waitForNextUpdate(); }); + expect(spyOnGetSignalIndex).toHaveBeenCalledTimes(2); + expect(spyOnCreateSignalIndex).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/user_info/index.tsx b/x-pack/plugins/security_solution/public/detections/components/user_info/index.tsx index 92d1491707260..ac2bf438d7fa6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/user_info/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/user_info/index.tsx @@ -20,6 +20,7 @@ export interface State { hasEncryptionKey: boolean | null; loading: boolean; signalIndexName: string | null; + signalIndexTemplateOutdated: boolean | null; } export const initialState: State = { @@ -31,6 +32,7 @@ export const initialState: State = { hasEncryptionKey: null, loading: true, signalIndexName: null, + signalIndexTemplateOutdated: null, }; export type Action = @@ -62,6 +64,10 @@ export type Action = | { type: 'updateSignalIndexName'; signalIndexName: string | null; + } + | { + type: 'updateSignalIndexTemplateOutdated'; + signalIndexTemplateOutdated: boolean | null; }; export const userInfoReducer = (state: State, action: Action): State => { @@ -114,6 +120,12 @@ export const userInfoReducer = (state: State, action: Action): State => { signalIndexName: action.signalIndexName, }; } + case 'updateSignalIndexTemplateOutdated': { + return { + ...state, + signalIndexTemplateOutdated: action.signalIndexTemplateOutdated, + }; + } default: return state; } @@ -144,6 +156,7 @@ export const useUserInfo = (): State => { hasEncryptionKey, loading, signalIndexName, + signalIndexTemplateOutdated, }, dispatch, ] = useUserData(); @@ -158,6 +171,7 @@ export const useUserInfo = (): State => { loading: indexNameLoading, signalIndexExists: isApiSignalIndexExists, signalIndexName: apiSignalIndexName, + signalIndexTemplateOutdated: apiSignalIndexTemplateOutdated, createDeSignalIndex: createSignalIndex, } = useSignalIndex(); @@ -166,7 +180,7 @@ export const useUserInfo = (): State => { typeof uiCapabilities.siem.crud === 'boolean' ? uiCapabilities.siem.crud : false; useEffect(() => { - if (loading !== privilegeLoading || indexNameLoading) { + if (loading !== (privilegeLoading || indexNameLoading)) { dispatch({ type: 'updateLoading', loading: privilegeLoading || indexNameLoading }); } }, [dispatch, loading, privilegeLoading, indexNameLoading]); @@ -217,18 +231,38 @@ export const useUserInfo = (): State => { } }, [dispatch, loading, signalIndexName, apiSignalIndexName]); + useEffect(() => { + if ( + !loading && + signalIndexTemplateOutdated !== apiSignalIndexTemplateOutdated && + apiSignalIndexTemplateOutdated != null + ) { + dispatch({ + type: 'updateSignalIndexTemplateOutdated', + signalIndexTemplateOutdated: apiSignalIndexTemplateOutdated, + }); + } + }, [dispatch, loading, signalIndexTemplateOutdated, apiSignalIndexTemplateOutdated]); + useEffect(() => { if ( isAuthenticated && hasEncryptionKey && hasIndexManage && - isSignalIndexExists != null && - !isSignalIndexExists && + ((isSignalIndexExists != null && !isSignalIndexExists) || + (signalIndexTemplateOutdated != null && signalIndexTemplateOutdated)) && createSignalIndex != null ) { createSignalIndex(); } - }, [createSignalIndex, isAuthenticated, hasEncryptionKey, isSignalIndexExists, hasIndexManage]); + }, [ + createSignalIndex, + isAuthenticated, + hasEncryptionKey, + isSignalIndexExists, + hasIndexManage, + signalIndexTemplateOutdated, + ]); return { loading, @@ -239,5 +273,6 @@ export const useUserInfo = (): State => { hasIndexManage, hasIndexWrite, signalIndexName, + signalIndexTemplateOutdated, }; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts index cd2cc1fe390ba..4fd240348f0f3 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts @@ -980,6 +980,7 @@ export const mockStatusAlertQuery: object = { export const mockSignalIndex: AlertsIndex = { name: 'mock-signal-index', + template_outdated: false, }; export const mockUserPrivilege: Privilege = { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts index 2eb2145c6c34d..59ab416ecc824 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts @@ -44,6 +44,7 @@ export interface UpdateAlertStatusProps { export interface AlertsIndex { name: string; + template_outdated: boolean; } export interface Privilege { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx index d0571bfca5b2b..1db952526414a 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx @@ -26,6 +26,7 @@ describe('useSignalIndex', () => { loading: true, signalIndexExists: null, signalIndexName: null, + signalIndexTemplateOutdated: null, }); }); }); @@ -42,6 +43,7 @@ describe('useSignalIndex', () => { loading: false, signalIndexExists: true, signalIndexName: 'mock-signal-index', + signalIndexTemplateOutdated: false, }); }); }); @@ -62,6 +64,7 @@ describe('useSignalIndex', () => { loading: false, signalIndexExists: true, signalIndexName: 'mock-signal-index', + signalIndexTemplateOutdated: false, }); }); }); @@ -101,6 +104,7 @@ describe('useSignalIndex', () => { loading: false, signalIndexExists: false, signalIndexName: null, + signalIndexTemplateOutdated: null, }); }); }); @@ -121,6 +125,7 @@ describe('useSignalIndex', () => { loading: false, signalIndexExists: false, signalIndexName: null, + signalIndexTemplateOutdated: null, }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx index 14fd9ffa50843..f7d2202736169 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx @@ -17,6 +17,7 @@ export interface ReturnSignalIndex { loading: boolean; signalIndexExists: boolean | null; signalIndexName: string | null; + signalIndexTemplateOutdated: boolean | null; createDeSignalIndex: Func | null; } @@ -27,11 +28,10 @@ export interface ReturnSignalIndex { */ export const useSignalIndex = (): ReturnSignalIndex => { const [loading, setLoading] = useState(true); - const [signalIndex, setSignalIndex] = useState< - Pick - >({ + const [signalIndex, setSignalIndex] = useState>({ signalIndexExists: null, signalIndexName: null, + signalIndexTemplateOutdated: null, createDeSignalIndex: null, }); const [, dispatchToaster] = useStateToaster(); @@ -49,6 +49,7 @@ export const useSignalIndex = (): ReturnSignalIndex => { setSignalIndex({ signalIndexExists: true, signalIndexName: signal.name, + signalIndexTemplateOutdated: signal.template_outdated, createDeSignalIndex: createIndex, }); } @@ -57,6 +58,7 @@ export const useSignalIndex = (): ReturnSignalIndex => { setSignalIndex({ signalIndexExists: false, signalIndexName: null, + signalIndexTemplateOutdated: null, createDeSignalIndex: createIndex, }); if (isSecurityAppError(error) && error.body.status_code !== 404) { @@ -87,6 +89,7 @@ export const useSignalIndex = (): ReturnSignalIndex => { setSignalIndex({ signalIndexExists: false, signalIndexName: null, + signalIndexTemplateOutdated: null, createDeSignalIndex: createIndex, }); errorToToaster({ title: i18n.SIGNAL_POST_FAILURE, error, dispatchToaster }); diff --git a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx index 3d291d9bf7b28..88fd1ad5f98b0 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx @@ -256,12 +256,7 @@ const getAuthenticationColumns = (): AuthTableColumns => [ hideForMobile: false, render: ({ node }) => getRowItemDraggables({ - rowItems: - node.lastSuccess != null && - node.lastSuccess.source != null && - node.lastSuccess.source.ip != null - ? node.lastSuccess.source.ip - : null, + rowItems: node.lastSuccess?.source?.ip || null, attrName: 'source.ip', idPrefix: `authentications-table-${node._id}-lastSuccessSource`, render: (item) => , @@ -273,12 +268,7 @@ const getAuthenticationColumns = (): AuthTableColumns => [ hideForMobile: false, render: ({ node }) => getRowItemDraggables({ - rowItems: - node.lastSuccess != null && - node.lastSuccess.host != null && - node.lastSuccess.host.name != null - ? node.lastSuccess.host.name - : null, + rowItems: node.lastSuccess?.host?.name ?? null, attrName: 'host.name', idPrefix: `authentications-table-${node._id}-lastSuccessfulDestination`, render: (item) => , @@ -301,12 +291,7 @@ const getAuthenticationColumns = (): AuthTableColumns => [ hideForMobile: false, render: ({ node }) => getRowItemDraggables({ - rowItems: - node.lastFailure != null && - node.lastFailure.source != null && - node.lastFailure.source.ip != null - ? node.lastFailure.source.ip - : null, + rowItems: node.lastFailure?.source?.ip || null, attrName: 'source.ip', idPrefix: `authentications-table-${node._id}-lastFailureSource`, render: (item) => , @@ -318,12 +303,7 @@ const getAuthenticationColumns = (): AuthTableColumns => [ hideForMobile: false, render: ({ node }) => getRowItemDraggables({ - rowItems: - node.lastFailure != null && - node.lastFailure.host != null && - node.lastFailure.host.name != null - ? node.lastFailure.host.name - : null, + rowItems: node.lastFailure?.host?.name || null, attrName: 'host.name', idPrefix: `authentications-table-${node._id}-lastFailureDestination`, render: (item) => , diff --git a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx index 41f443f14cafe..54cb0c0883e14 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx @@ -129,7 +129,7 @@ describe('Uncommon Process Table Component', () => { ); expect(wrapper.find('.euiTableRow').at(2).find('.euiTableRowCell').at(3).text()).toBe( - 'Host nameshello-world,hello-world-2 ' + 'Host nameshello-worldhello-world-2 ' ); }); @@ -214,7 +214,7 @@ describe('Uncommon Process Table Component', () => { ); expect(wrapper.find('.euiTableRow').at(4).find('.euiTableRowCell').at(3).text()).toBe( - 'Host nameshello-world,hello-world-2 ' + 'Host nameshello-worldhello-world-2 ' ); }); }); diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx index 7ae8aecdab606..ac7c5078e4ba0 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx @@ -198,15 +198,13 @@ export const EmbeddedMapComponent = ({ if (embeddable != null) { embeddable.updateInput({ query }); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query]); + }, [embeddable, query]); useEffect(() => { if (embeddable != null) { embeddable.updateInput({ filters }); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filters]); + }, [embeddable, filters]); // DateRange updated useEffect useEffect(() => { @@ -217,8 +215,7 @@ export const EmbeddedMapComponent = ({ }; embeddable.updateInput({ timeRange }); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [startDate, endDate]); + }, [embeddable, startDate, endDate]); return isError ? null : ( diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/__snapshots__/map_tool_tip.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/__snapshots__/map_tool_tip.test.tsx.snap index 775329553cbeb..dc94b1039dfc5 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/__snapshots__/map_tool_tip.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/__snapshots__/map_tool_tip.test.tsx.snap @@ -1,29 +1,37 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`MapToolTip full component renders correctly against snapshot 1`] = ` - - - - - + + + + + `; exports[`MapToolTip placeholder component renders correctly against snapshot 1`] = ` - - - - - + + + + + `; diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/__snapshots__/point_tool_tip_content.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/__snapshots__/point_tool_tip_content.test.tsx.snap index 8927e492993d0..8801e455c95b6 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/__snapshots__/point_tool_tip_content.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/__snapshots__/point_tool_tip_content.test.tsx.snap @@ -2,7 +2,6 @@ exports[`PointToolTipContent renders correctly against snapshot 1`] = ` (null); const [, setLayerName] = useState(''); + const handleCloseTooltip = useCallback(() => { + if (closeTooltip != null) { + closeTooltip(); + setFeatureIndex(0); + } + }, [closeTooltip]); + + const handlePreviousFeature = useCallback(() => { + setFeatureIndex((prevFeatureIndex) => prevFeatureIndex - 1); + setIsLoadingNextFeature(true); + }, []); + + const handleNextFeature = useCallback(() => { + setFeatureIndex((prevFeatureIndex) => prevFeatureIndex + 1); + setIsLoadingNextFeature(true); + }, []); + + const content = useMemo(() => { + if (isError) { + return ( + + {i18n.MAP_TOOL_TIP_ERROR} + + ); + } + + if (isLoading && !isLoadingNextFeature) { + return ( + + + + + + ); + } + + return ( +
+ {featureGeometry != null && featureGeometry.type === 'LineString' ? ( + + ) : ( + + )} + {features.length > 1 && ( + + )} + {isLoadingNextFeature && } +
+ ); + }, [ + featureGeometry, + featureIndex, + featureProps, + features, + handleNextFeature, + handlePreviousFeature, + isError, + isLoading, + isLoadingNextFeature, + ]); + useEffect(() => { // Early return if component doesn't yet have props -- result of mounting in portal before actual rendering if ( @@ -77,69 +149,17 @@ export const MapToolTipComponent = ({ }; fetchFeatureProps(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ featureIndex, - // eslint-disable-next-line react-hooks/exhaustive-deps - features - .map((f) => `${f.id}-${f.layerId}`) - .sort() - .join(), + features, + getLayerName, + isLoadingNextFeature, + loadFeatureGeometry, + loadFeatureProperties, ]); - if (isError) { - return ( - - {i18n.MAP_TOOL_TIP_ERROR} - - ); - } - - return isLoading && !isLoadingNextFeature ? ( - - - - - - ) : ( - { - if (closeTooltip != null) { - closeTooltip(); - setFeatureIndex(0); - } - }} - > -
- {featureGeometry != null && featureGeometry.type === 'LineString' ? ( - - ) : ( - - )} - {features.length > 1 && ( - { - setFeatureIndex(featureIndex - 1); - setIsLoadingNextFeature(true); - }} - nextFeature={() => { - setFeatureIndex(featureIndex + 1); - setIsLoadingNextFeature(true); - }} - /> - )} - {isLoadingNextFeature && } -
-
+ return ( + {content} ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/point_tool_tip_content.test.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/point_tool_tip_content.test.tsx index 27fe27adc99c2..87b972e9d7053 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/point_tool_tip_content.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/point_tool_tip_content.test.tsx @@ -24,15 +24,9 @@ describe('PointToolTipContent', () => { ]; test('renders correctly against snapshot', () => { - const closeTooltip = jest.fn(); - const wrapper = shallow( - + ); expect(wrapper.find('PointToolTipContentComponent')).toMatchSnapshot(); diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/point_tool_tip_content.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/point_tool_tip_content.tsx index 57113a1395778..a3a5ddf4d53b3 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/point_tool_tip_content.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/map_tool_tip/point_tool_tip_content.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { sourceDestinationFieldMappings } from '../map_config'; import { getEmptyTagValue, @@ -20,36 +20,38 @@ import { ITooltipProperty } from '../../../../../../maps/public/classes/tooltips interface PointToolTipContentProps { contextId: string; featureProps: ITooltipProperty[]; - closeTooltip?(): void; } export const PointToolTipContentComponent = ({ contextId, featureProps, - closeTooltip, }: PointToolTipContentProps) => { - const featureDescriptionListItems = featureProps.map((featureProp) => { - const key = featureProp.getPropertyKey(); - const value = featureProp.getRawValue() ?? []; + const featureDescriptionListItems = useMemo( + () => + featureProps.map((featureProp) => { + const key = featureProp.getPropertyKey(); + const value = featureProp.getRawValue() ?? []; - return { - title: sourceDestinationFieldMappings[key], - description: ( - <> - {value != null ? ( - getRenderedFieldValue(key, item)} - /> - ) : ( - getEmptyTagValue() - )} - - ), - }; - }); + return { + title: sourceDestinationFieldMappings[key], + description: ( + <> + {value != null ? ( + getRenderedFieldValue(key, item)} + /> + ) : ( + getEmptyTagValue() + )} + + ), + }; + }), + [contextId, featureProps] + ); return ; }; diff --git a/x-pack/plugins/security_solution/public/network/components/source_destination/country_flag.tsx b/x-pack/plugins/security_solution/public/network/components/source_destination/country_flag.tsx index cb1af5513c846..31ad679ce41bf 100644 --- a/x-pack/plugins/security_solution/public/network/components/source_destination/country_flag.tsx +++ b/x-pack/plugins/security_solution/public/network/components/source_destination/country_flag.tsx @@ -9,6 +9,13 @@ import { isEmpty } from 'lodash/fp'; import { EuiToolTip } from '@elastic/eui'; import countries from 'i18n-iso-countries'; import countryJson from 'i18n-iso-countries/langs/en.json'; +import styled from 'styled-components'; + +// Fixes vertical alignment of the flag +const FlagWrapper = styled.span` + position: relative; + top: 1px; +`; /** * Returns the flag for the specified country code, or null if the specified @@ -38,10 +45,10 @@ export const CountryFlag = memo<{ if (flag !== null) { return displayCountryNameOnHover ? ( - {flag} + {flag} ) : ( - {flag} + {flag} ); } return null; @@ -49,7 +56,7 @@ export const CountryFlag = memo<{ CountryFlag.displayName = 'CountryFlag'; -/** Renders an emjoi flag with country name for the specified country code */ +/** Renders an emoji flag with country name for the specified country code */ export const CountryFlagAndName = memo<{ countryCode: string; displayCountryNameOnHover?: boolean; @@ -67,10 +74,13 @@ export const CountryFlagAndName = memo<{ if (flag !== null && localesLoaded) { return displayCountryNameOnHover ? ( - {flag} + {flag} ) : ( - {`${flag} ${countries.getName(countryCode, 'en')}`} + <> + {flag} + {` ${countries.getName(countryCode, 'en')}`} + ); } return null; diff --git a/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx index 12c3cc481cfc1..356173fa2ac71 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx @@ -198,6 +198,7 @@ export const useNetworkHttp = ({ factoryQueryType: NetworkQueries.http, filterQuery: createFilter(filterQuery), id: ID, + ip, pagination: generateTablePaginationOptions(activePage, limit), sort: sort as SortField, timerange: { @@ -211,7 +212,7 @@ export const useNetworkHttp = ({ } return prevRequest; }); - }, [activePage, indexNames, endDate, filterQuery, limit, startDate, sort, skip]); + }, [activePage, indexNames, endDate, filterQuery, ip, limit, startDate, sort, skip]); useEffect(() => { networkHttpSearch(networkHttpRequest); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx index 0b864d66842d1..c2dc638fa719f 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx @@ -61,6 +61,7 @@ export const useNetworkTopCountries = ({ filterQuery, flowTarget, indexNames, + ip, skip, startDate, type, @@ -86,6 +87,7 @@ export const useNetworkTopCountries = ({ filterQuery: createFilter(filterQuery), flowTarget, id: queryId, + ip, pagination: generateTablePaginationOptions(activePage, limit), sort, timerange: { @@ -203,6 +205,7 @@ export const useNetworkTopCountries = ({ filterQuery: createFilter(filterQuery), flowTarget, id: queryId, + ip, pagination: generateTablePaginationOptions(activePage, limit), sort, timerange: { @@ -221,6 +224,7 @@ export const useNetworkTopCountries = ({ indexNames, endDate, filterQuery, + ip, limit, startDate, sort, diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx index c68ad2422c514..87968e7a03522 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx @@ -61,6 +61,7 @@ export const useNetworkTopNFlow = ({ filterQuery, flowTarget, indexNames, + ip, skip, startDate, type, @@ -84,7 +85,8 @@ export const useNetworkTopNFlow = ({ factoryQueryType: NetworkQueries.topNFlow, filterQuery: createFilter(filterQuery), flowTarget, - id: ID, + id: `${ID}-${flowTarget}`, + ip, pagination: generateTablePaginationOptions(activePage, limit), sort, timerange: { @@ -199,7 +201,8 @@ export const useNetworkTopNFlow = ({ factoryQueryType: NetworkQueries.topNFlow, filterQuery: createFilter(filterQuery), flowTarget, - id: ID, + id: `${ID}-${flowTarget}`, + ip, pagination: generateTablePaginationOptions(activePage, limit), timerange: { interval: '12h', @@ -213,7 +216,7 @@ export const useNetworkTopNFlow = ({ } return prevRequest; }); - }, [activePage, indexNames, endDate, filterQuery, limit, startDate, sort, skip, flowTarget]); + }, [activePage, endDate, filterQuery, indexNames, ip, limit, startDate, sort, skip, flowTarget]); useEffect(() => { networkTopNFlowSearch(networkTopNFlowRequest); diff --git a/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts b/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts index 5555578e44f7b..d121b9c9c81c4 100644 --- a/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts @@ -57,8 +57,8 @@ describe('date', () => { const almostAYear = new Date(initialTime + 11.9 * month).getTime(); const threeYears = new Date(initialTime + 3 * year).getTime(); - it('should return null if invalid times are given', () => { - expect(getFriendlyElapsedTime(initialTime, 'ImTimeless')).toEqual(null); + it('should return undefined if invalid times are given', () => { + expect(getFriendlyElapsedTime(initialTime, 'ImTimeless')).toEqual(undefined); }); it('should return the correct singular relative time', () => { diff --git a/x-pack/plugins/security_solution/public/resolver/lib/date.ts b/x-pack/plugins/security_solution/public/resolver/lib/date.ts index 3cd0c910f46f3..ff8119a5e25fb 100644 --- a/x-pack/plugins/security_solution/public/resolver/lib/date.ts +++ b/x-pack/plugins/security_solution/public/resolver/lib/date.ts @@ -9,7 +9,7 @@ import { DurationDetails, DurationTypes } from '../types'; /** * Given a time, it will convert it to a unix timestamp if not one already. If it is unable to do so, it will return NaN */ -export const getUnixTime = (time: number | string): number | typeof NaN => { +export const getUnixTime = (time: number | string): number => { if (!time) { return NaN; } @@ -30,16 +30,17 @@ export const getUnixTime = (time: number | string): number | typeof NaN => { * Given two unix timestamps, it will return an object containing the time difference and properly pluralized friendly version of the time difference. * i.e. a time difference of 1000ms will yield => { duration: 1, durationType: 'second' } and 10000ms will yield => { duration: 10, durationType: 'seconds' } * + * If `from` or `to` cannot be parsed, `undefined` will be returned. */ export const getFriendlyElapsedTime = ( from: number | string, to: number | string -): DurationDetails | null => { +): DurationDetails | undefined => { const startTime = getUnixTime(from); const endTime = getUnixTime(to); if (Number.isNaN(startTime) || Number.isNaN(endTime)) { - return null; + return undefined; } const elapsedTimeInMs = endTime - startTime; diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap index fc0d646fd62ca..b77a5d09008cc 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap @@ -182,7 +182,7 @@ Object { "edgeLineSegments": Array [ Object { "metadata": Object { - "uniqueId": "parentToMidedge:0:1", + "reactKey": "parentToMidedge:0:1", }, "points": Array [ Array [ @@ -197,7 +197,7 @@ Object { }, Object { "metadata": Object { - "uniqueId": "midwayedge:0:1", + "reactKey": "midwayedge:0:1", }, "points": Array [ Array [ @@ -216,7 +216,7 @@ Object { "duration": "<1", "durationType": "millisecond", }, - "uniqueId": "edge:0:1", + "reactKey": "edge:0:1", }, "points": Array [ Array [ @@ -235,7 +235,7 @@ Object { "duration": "<1", "durationType": "millisecond", }, - "uniqueId": "edge:0:2", + "reactKey": "edge:0:2", }, "points": Array [ Array [ @@ -254,7 +254,7 @@ Object { "duration": "<1", "durationType": "millisecond", }, - "uniqueId": "edge:0:8", + "reactKey": "edge:0:8", }, "points": Array [ Array [ @@ -269,7 +269,7 @@ Object { }, Object { "metadata": Object { - "uniqueId": "parentToMidedge:1:3", + "reactKey": "parentToMidedge:1:3", }, "points": Array [ Array [ @@ -284,7 +284,7 @@ Object { }, Object { "metadata": Object { - "uniqueId": "midwayedge:1:3", + "reactKey": "midwayedge:1:3", }, "points": Array [ Array [ @@ -303,7 +303,7 @@ Object { "duration": "<1", "durationType": "millisecond", }, - "uniqueId": "edge:1:3", + "reactKey": "edge:1:3", }, "points": Array [ Array [ @@ -322,7 +322,7 @@ Object { "duration": "<1", "durationType": "millisecond", }, - "uniqueId": "edge:1:4", + "reactKey": "edge:1:4", }, "points": Array [ Array [ @@ -337,7 +337,7 @@ Object { }, Object { "metadata": Object { - "uniqueId": "parentToMidedge:2:5", + "reactKey": "parentToMidedge:2:5", }, "points": Array [ Array [ @@ -352,7 +352,7 @@ Object { }, Object { "metadata": Object { - "uniqueId": "midwayedge:2:5", + "reactKey": "midwayedge:2:5", }, "points": Array [ Array [ @@ -371,7 +371,7 @@ Object { "duration": "<1", "durationType": "millisecond", }, - "uniqueId": "edge:2:5", + "reactKey": "edge:2:5", }, "points": Array [ Array [ @@ -390,7 +390,7 @@ Object { "duration": "<1", "durationType": "millisecond", }, - "uniqueId": "edge:2:6", + "reactKey": "edge:2:6", }, "points": Array [ Array [ @@ -409,7 +409,7 @@ Object { "duration": "<1", "durationType": "millisecond", }, - "uniqueId": "edge:6:7", + "reactKey": "edge:6:7", }, "points": Array [ Array [ @@ -620,7 +620,7 @@ Object { "duration": "<1", "durationType": "millisecond", }, - "uniqueId": "edge:0:1", + "reactKey": "edge:0:1", }, "points": Array [ Array [ diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts index f0880fa635a24..0003be827aca8 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts @@ -191,7 +191,6 @@ function processEdgeLineSegments( ): EdgeLineSegment[] { const edgeLineSegments: EdgeLineSegment[] = []; for (const metadata of levelOrderWithWidths(indexedProcessTree, widths)) { - const edgeLineMetadata: EdgeLineMetadata = { uniqueId: '' }; /** * We only handle children, drawing lines back to their parents. The root has no parent, so we skip it */ @@ -219,10 +218,16 @@ function processEdgeLineSegments( const parentTime = eventModel.timestampSafeVersion(parent); const processTime = eventModel.timestampSafeVersion(process); - if (parentTime && processTime) { - edgeLineMetadata.elapsedTime = elapsedTime(parentTime, processTime) ?? undefined; - } - edgeLineMetadata.uniqueId = edgeLineID; + + const timeBetweenParentAndNode = + parentTime !== undefined && processTime !== undefined + ? elapsedTime(parentTime, processTime) + : undefined; + + const edgeLineMetadata: EdgeLineMetadata = { + elapsedTime: timeBetweenParentAndNode, + reactKey: edgeLineID, + }; /** * The point halfway between the parent and child on the y axis, we sometimes have a hard angle here in the edge line @@ -270,7 +275,7 @@ function processEdgeLineSegments( const lineFromParentToMidwayLine: EdgeLineSegment = { points: [parentPosition, [parentPosition[0], midwayY]], - metadata: { uniqueId: `parentToMid${edgeLineID}` }, + metadata: { reactKey: `parentToMid${edgeLineID}` }, }; const widthOfMidline = parentWidth - firstChildWidth / 2 - lastChildWidth / 2; @@ -291,7 +296,7 @@ function processEdgeLineSegments( midwayY, ], ], - metadata: { uniqueId: `midway${edgeLineID}` }, + metadata: { reactKey: `midway${edgeLineID}` }, }; edgeLineSegments.push( @@ -501,13 +506,26 @@ const distanceBetweenNodesInUnits = 2; */ const distanceBetweenNodes = distanceBetweenNodesInUnits * unit; -export function nodePosition( +/** + * @deprecated use `nodePosition` + */ +export function processPosition( model: IsometricTaxiLayout, node: SafeResolverEvent ): Vector2 | undefined { return model.processNodePositions.get(node); } +export function nodePosition(model: IsometricTaxiLayout, nodeID: string): Vector2 | undefined { + // Find the indexed object matching the nodeID + // NB: this is O(n) now, but we will be indexing the nodeIDs in the future. + for (const candidate of model.processNodePositions.keys()) { + if (eventModel.entityIDSafeVersion(candidate) === nodeID) { + return processPosition(model, candidate); + } + } +} + /** * Return a clone of `model` with all positions incremented by `translation`. * Use this to move the layout around. @@ -525,7 +543,7 @@ export function translated(model: IsometricTaxiLayout, translation: Vector2): Is ]) ), edgeLineSegments: model.edgeLineSegments.map(({ points, metadata }) => ({ - points: points.map((point) => vector2.add(point, translation)), + points: [vector2.add(points[0], translation), vector2.add(points[1], translation)], metadata, })), // these are unchanged diff --git a/x-pack/plugins/security_solution/public/resolver/store/actions.ts b/x-pack/plugins/security_solution/public/resolver/store/actions.ts index 3348c962efdea..66a32ba29cd74 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/actions.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/actions.ts @@ -4,19 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ import { CameraAction } from './camera'; -import { SafeResolverEvent } from '../../../common/endpoint/types'; import { DataAction } from './data/action'; /** - * When the user wants to bring a process node front-and-center on the map. + * When the user wants to bring a node front-and-center on the map. */ -interface UserBroughtProcessIntoView { - readonly type: 'userBroughtProcessIntoView'; +interface UserBroughtNodeIntoView { + readonly type: 'userBroughtNodeIntoView'; readonly payload: { /** - * Used to identify the process node that should be brought into view. + * Used to identify the node that should be brought into view. */ - readonly process: SafeResolverEvent; + readonly nodeID: string; /** * The time (since epoch in milliseconds) when the action was dispatched. */ @@ -97,7 +96,7 @@ export type ResolverAction = | CameraAction | DataAction | AppReceivedNewExternalProperties - | UserBroughtProcessIntoView + | UserBroughtNodeIntoView | UserFocusedOnResolverNode | UserSelectedResolverNode | UserRequestedRelatedEventData; diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 5eb920ca835f4..505e6cfc3ee72 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -16,6 +16,7 @@ import { AABB, VisibleEntites, TreeFetcherParameters, + IsometricTaxiLayout, } from '../../types'; import { isGraphableProcess, isTerminatedProcess } from '../../models/process_event'; import * as indexedProcessTreeModel from '../../models/indexed_process_tree'; @@ -346,7 +347,7 @@ export function treeParametersToFetch(state: DataState): TreeFetcherParameters | } } -export const layout = createSelector( +export const layout: (state: DataState) => IsometricTaxiLayout = createSelector( tree, originID, function processNodePositionsAndEdgeLineSegments( @@ -372,7 +373,7 @@ export const layout = createSelector( } // Find the position of the origin, we'll center the map on it intrinsically - const originPosition = isometricTaxiLayoutModel.nodePosition(taxiLayout, originNode); + const originPosition = isometricTaxiLayoutModel.processPosition(taxiLayout, originNode); // adjust the position of everything so that the origin node is at `(0, 0)` if (originPosition === undefined) { diff --git a/x-pack/plugins/security_solution/public/resolver/store/methods.ts b/x-pack/plugins/security_solution/public/resolver/store/methods.ts deleted file mode 100644 index f121b2aa86888..0000000000000 --- a/x-pack/plugins/security_solution/public/resolver/store/methods.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { animatePanning } from './camera/methods'; -import { layout } from './selectors'; -import { ResolverState } from '../types'; -import { SafeResolverEvent } from '../../../common/endpoint/types'; - -const animationDuration = 1000; - -/** - * Return new `ResolverState` with the camera animating to focus on `process`. - */ -export function animateProcessIntoView( - state: ResolverState, - startTime: number, - process: SafeResolverEvent -): ResolverState { - const { processNodePositions } = layout(state); - const position = processNodePositions.get(process); - if (position) { - return { - ...state, - camera: animatePanning(state.camera, startTime, position, animationDuration), - }; - } - return state; -} diff --git a/x-pack/plugins/security_solution/public/resolver/store/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/reducer.ts index ae1e9a58a2097..997a3d0ae6b38 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/reducer.ts @@ -3,13 +3,15 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { Reducer, combineReducers } from 'redux'; -import { animateProcessIntoView } from './methods'; +import { animatePanning } from './camera/methods'; +import { layout } from './selectors'; import { cameraReducer } from './camera/reducer'; import { dataReducer } from './data/reducer'; import { ResolverAction } from './actions'; import { ResolverState, ResolverUIState } from '../types'; -import * as eventModel from '../../../common/endpoint/models/event'; +import { nodePosition } from '../models/indexed_process_tree/isometric_taxi_layout'; const uiReducer: Reducer = ( state = { @@ -37,18 +39,15 @@ const uiReducer: Reducer = ( selectedNode: action.payload, }; return next; - } else if (action.type === 'userBroughtProcessIntoView') { - const nodeID = eventModel.entityIDSafeVersion(action.payload.process); - if (nodeID !== undefined) { - const next: ResolverUIState = { - ...state, - ariaActiveDescendant: nodeID, - selectedNode: nodeID, - }; - return next; - } else { - return state; - } + } else if (action.type === 'userBroughtNodeIntoView') { + const { nodeID } = action.payload; + const next: ResolverUIState = { + ...state, + // Select the node. NB: Animation is handled in the reducer as well. + ariaActiveDescendant: nodeID, + selectedNode: nodeID, + }; + return next; } else if (action.type === 'appReceivedNewExternalProperties') { const next: ResolverUIState = { ...state, @@ -66,11 +65,21 @@ const concernReducers = combineReducers({ data: dataReducer, ui: uiReducer, }); +const animationDuration = 1000; export const resolverReducer: Reducer = (state, action) => { const nextState = concernReducers(state, action); - if (action.type === 'userBroughtProcessIntoView') { - return animateProcessIntoView(nextState, action.payload.time, action.payload.process); + if (action.type === 'userBroughtNodeIntoView') { + const position = nodePosition(layout(nextState), action.payload.nodeID); + if (position) { + const withAnimation: ResolverState = { + ...nextState, + camera: animatePanning(nextState.camera, action.payload.time, position, animationDuration), + }; + return withAnimation; + } else { + return nextState; + } } else { return nextState; } diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 5007b7cffa5c6..fb57f85639e33 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -429,20 +429,22 @@ export interface DurationDetails { * Values shared between two vertices joined by an edge line. */ export interface EdgeLineMetadata { + /** + * Represents a time duration for this edge line segment. Used to show a time duration in the UI. + * This is only ever present on one of the segments in an edge. + */ elapsedTime?: DurationDetails; - // A string of the two joined process nodes concatenated together. - uniqueId: string; + /** + * Used to represent a react key value for the edge line. + */ + reactKey: string; } -/** - * A tuple of 2 vector2 points forming a poly-line. Used to connect process nodes in the graph. - */ -export type EdgeLinePoints = Vector2[]; /** * Edge line components including the points joining the edge-line and any optional associated metadata */ export interface EdgeLineSegment { - points: EdgeLinePoints; + points: [Vector2, Vector2]; metadata: EdgeLineMetadata; } @@ -538,6 +540,7 @@ export interface IsometricTaxiLayout { * A map of events to position. Each event represents its own node. */ processNodePositions: Map; + /** * A map of edge-line segments, which graphically connect nodes. */ diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx index 06e3acfb3dc6d..9ef72c414bb63 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx @@ -164,14 +164,14 @@ function NodeDetailLink({ (mouseEvent: React.MouseEvent) => { linkProps.onClick(mouseEvent); dispatch({ - type: 'userBroughtProcessIntoView', + type: 'userBroughtNodeIntoView', payload: { - process: event, + nodeID, time: timestamp(), }, }); }, - [timestamp, linkProps, dispatch, event] + [timestamp, linkProps, dispatch, nodeID] ); return ( diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx index 13dcfcabe50cb..ed969b913a72e 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx @@ -106,7 +106,7 @@ export const ResolverWithoutProviders = React.memo( ({ points: [startPosition, endPosition], metadata }) => ( { let element: HTMLElement; @@ -198,11 +199,15 @@ describe('useCamera on an unpainted element', () => { throw new Error('missing the process to bring into view'); } simulator.controls.time = 0; + const nodeID = entityIDSafeVersion(process); + if (!nodeID) { + throw new Error('could not find nodeID for process'); + } const cameraAction: ResolverAction = { - type: 'userBroughtProcessIntoView', + type: 'userBroughtNodeIntoView', payload: { time: simulator.controls.time, - process, + nodeID, }, }; await waitFor(() => { 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 1f76c2840e8b7..cb913287b24d8 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 @@ -7,7 +7,7 @@ import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPopover, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { getOr } from 'lodash/fp'; -import React, { Fragment, useState } from 'react'; +import React, { useCallback, Fragment, useMemo, useState } from 'react'; import styled from 'styled-components'; import { HostEcs } from '../../../../common/ecs/host'; @@ -260,25 +260,31 @@ 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 button = useMemo( + () => ( + <> + {' ,'} + + {`+${rowItems.length - overflowIndexStart} `} + + + + ), + [handleClose, overflowIndexStart, rowItems.length] + ); + return ( {rowItems.length > overflowIndexStart && ( - {' ,'} - setIsOpen(!isOpen)}> - {`+${rowItems.length - overflowIndexStart} `} - - - - } + button={button} isOpen={isOpen} - closePopover={() => setIsOpen(!isOpen)} + closePopover={handleClose} repositionOnScroll > ( - - {i18n.BACK_TO_EVENTS} + + {i18n.CLOSE_ANALYZER} diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/translations.ts index c7cd9253de038..58e7045128182 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/translations.ts @@ -6,9 +6,9 @@ import { i18n } from '@kbn/i18n'; -export const BACK_TO_EVENTS = i18n.translate( - 'xpack.securitySolution.timeline.graphOverlay.backToEventsButton', +export const CLOSE_ANALYZER = i18n.translate( + 'xpack.securitySolution.timeline.graphOverlay.closeAnalyzerButton', { - defaultMessage: '< Back to events', + defaultMessage: 'Close analyzer', } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.test.tsx index 87956647c11f1..36116de8d33d9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.test.tsx @@ -13,11 +13,16 @@ import { mockBrowserFields } from '../../../../common/containers/source/mock'; import { convertKueryToElasticSearchQuery } from '../../../../common/lib/keury'; import { mockIndexPattern, TestProviders } from '../../../../common/mock'; import { QueryBar } from '../../../../common/components/query_bar'; -import { FilterManager } from '../../../../../../../../src/plugins/data/public'; +import { esFilters, FilterManager } from '../../../../../../../../src/plugins/data/public'; import { mockDataProviders } from '../data_providers/mock/mock_data_providers'; import { buildGlobalQuery } from '../helpers'; -import { QueryBarTimeline, QueryBarTimelineComponentProps, getDataProviderFilter } from './index'; +import { + QueryBarTimeline, + QueryBarTimelineComponentProps, + getDataProviderFilter, + TIMELINE_FILTER_DROP_AREA, +} from './index'; const mockUiSettingsForFilterManager = coreMock.createStart().uiSettings; @@ -39,13 +44,43 @@ describe('Timeline QueryBar ', () => { }); test('check if we format the appropriate props to QueryBar', () => { + const filters = [ + { + $state: { store: esFilters.FilterStateStore.APP_STATE }, + meta: { + alias: null, + controlledBy: TIMELINE_FILTER_DROP_AREA, + disabled: false, + index: undefined, + key: 'event.category', + negate: true, + params: { query: 'file' }, + type: 'phrase', + }, + query: { match: { 'event.category': { query: 'file', type: 'phrase' } } }, + }, + { + $state: { store: esFilters.FilterStateStore.APP_STATE }, + meta: { + alias: null, + controlledBy: undefined, + disabled: false, + index: undefined, + key: 'event.category', + negate: true, + params: { query: 'process' }, + type: 'phrase', + }, + query: { match: { 'event.category': { query: 'process', type: 'phrase' } } }, + }, + ]; const wrapper = mount( { expect(queryBarProps.dateRangeTo).toEqual('now'); expect(queryBarProps.filterQuery).toEqual({ query: 'here: query', language: 'kuery' }); expect(queryBarProps.savedQuery).toEqual(null); + expect(queryBarProps.filters).toHaveLength(1); + expect(queryBarProps.filters[0].query).toEqual(filters[1].query); }); describe('#onChangeQuery', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx index 74f21fecd0fda..3b882c1e1bd14 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_bar/index.tsx @@ -53,7 +53,10 @@ export interface QueryBarTimelineComponentProps { updateReduxTime: DispatchUpdateReduxTime; } -const timelineFilterDropArea = 'timeline-filter-drop-area'; +export const TIMELINE_FILTER_DROP_AREA = 'timeline-filter-drop-area'; + +const getNonDropAreaFilters = (filters: Filter[] = []) => + filters.filter((f: Filter) => f.meta.controlledBy !== TIMELINE_FILTER_DROP_AREA); export const QueryBarTimeline = memo( ({ @@ -91,7 +94,9 @@ export const QueryBarTimeline = memo( query: filterQuery != null ? filterQuery.expression : '', language: filterQuery != null ? filterQuery.kind : 'kuery', }); - const [queryBarFilters, setQueryBarFilters] = useState([]); + const [queryBarFilters, setQueryBarFilters] = useState( + getNonDropAreaFilters(filters) + ); const [dataProvidersDsl, setDataProvidersDsl] = useState( convertKueryToElasticSearchQuery(buildGlobalQuery(dataProviders, browserFields), indexPattern) ); @@ -106,9 +111,7 @@ export const QueryBarTimeline = memo( filterManager.getUpdates$().subscribe({ next: () => { if (isSubscribed) { - const filterWithoutDropArea = filterManager - .getFilters() - .filter((f: Filter) => f.meta.controlledBy !== timelineFilterDropArea); + const filterWithoutDropArea = getNonDropAreaFilters(filterManager.getFilters()); setFilters(filterWithoutDropArea); setQueryBarFilters(filterWithoutDropArea); } @@ -124,9 +127,7 @@ export const QueryBarTimeline = memo( }, []); useEffect(() => { - const filterWithoutDropArea = filterManager - .getFilters() - .filter((f: Filter) => f.meta.controlledBy !== timelineFilterDropArea); + const filterWithoutDropArea = getNonDropAreaFilters(filterManager.getFilters()); if (!deepEqual(filters, filterWithoutDropArea)) { filterManager.setFilters(filters); } @@ -175,7 +176,7 @@ export const QueryBarTimeline = memo( ...mySavedQuery, attributes: { ...mySavedQuery.attributes, - filters: filters.filter((f) => f.meta.controlledBy !== timelineFilterDropArea), + filters: getNonDropAreaFilters(filters), }, }); } @@ -250,7 +251,7 @@ export const QueryBarTimeline = memo( const dataProviderFilterExists = newSavedQuery.attributes.filters != null ? newSavedQuery.attributes.filters.findIndex( - (f) => f.meta.controlledBy === timelineFilterDropArea + (f) => f.meta.controlledBy === TIMELINE_FILTER_DROP_AREA ) : -1; savedQueryServices.saveQuery( @@ -311,8 +312,8 @@ export const getDataProviderFilter = (dataProviderDsl: string): Filter => { return { ...dslObject, meta: { - alias: timelineFilterDropArea, - controlledBy: timelineFilterDropArea, + alias: TIMELINE_FILTER_DROP_AREA, + controlledBy: TIMELINE_FILTER_DROP_AREA, negate: false, disabled: false, type: 'custom', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/check_template_version.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/check_template_version.ts new file mode 100644 index 0000000000000..473a2dad37f19 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/check_template_version.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { LegacyAPICaller } from '../../../../../../../../src/core/server'; +import { getSignalsTemplate } from './get_signals_template'; +import { getTemplateExists } from '../../index/get_template_exists'; + +export const templateNeedsUpdate = async (callCluster: LegacyAPICaller, index: string) => { + const templateExists = await getTemplateExists(callCluster, index); + let existingTemplateVersion: number | undefined; + if (templateExists) { + const existingTemplate: unknown = await callCluster('indices.getTemplate', { + name: index, + }); + existingTemplateVersion = get(existingTemplate, [index, 'version']); + } + const newTemplate = getSignalsTemplate(index); + if (existingTemplateVersion === undefined || existingTemplateVersion < newTemplate.version) { + return true; + } + return false; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts index a09fd9e0c9bd9..a801bc18db439 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/create_index_route.ts @@ -12,9 +12,9 @@ import { getPolicyExists } from '../../index/get_policy_exists'; import { setPolicy } from '../../index/set_policy'; import { setTemplate } from '../../index/set_template'; import { getSignalsTemplate } from './get_signals_template'; -import { getTemplateExists } from '../../index/get_template_exists'; import { createBootstrapIndex } from '../../index/create_bootstrap_index'; import signalsPolicy from './signals_policy.json'; +import { templateNeedsUpdate } from './check_template_version'; export const createIndexRoute = (router: IRouter) => { router.post( @@ -39,24 +39,20 @@ export const createIndexRoute = (router: IRouter) => { const index = siemClient.getSignalsIndex(); const indexExists = await getIndexExists(callCluster, index); - if (indexExists) { - return siemResponse.error({ - statusCode: 409, - body: `index: "${index}" already exists`, - }); - } else { + if (await templateNeedsUpdate(callCluster, index)) { const policyExists = await getPolicyExists(callCluster, index); if (!policyExists) { await setPolicy(callCluster, index, signalsPolicy); } - const templateExists = await getTemplateExists(callCluster, index); - if (!templateExists) { - const template = getSignalsTemplate(index); - await setTemplate(callCluster, index, template); + await setTemplate(callCluster, index, getSignalsTemplate(index)); + if (indexExists) { + await callCluster('indices.rollover', { alias: index }); } + } + if (!indexExists) { await createBootstrapIndex(callCluster, index); - return response.ok({ body: { acknowledged: true } }); } + return response.ok({ body: { acknowledged: true } }); } catch (err) { const error = transformError(err); return siemResponse.error({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_index_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_index_route.ts index 7debe0931abd6..b9ae8b546b8bd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_index_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/read_index_route.ts @@ -8,6 +8,7 @@ import { IRouter } from '../../../../../../../../src/core/server'; import { DETECTION_ENGINE_INDEX_URL } from '../../../../../common/constants'; import { transformError, buildSiemResponse } from '../utils'; import { getIndexExists } from '../../index/get_index_exists'; +import { templateNeedsUpdate } from './check_template_version'; export const readIndexRoute = (router: IRouter) => { router.get( @@ -31,9 +32,10 @@ export const readIndexRoute = (router: IRouter) => { const index = siemClient.getSignalsIndex(); const indexExists = await getIndexExists(clusterClient.callAsCurrentUser, index); + const templateOutdated = await templateNeedsUpdate(clusterClient.callAsCurrentUser, index); if (indexExists) { - return response.ok({ body: { name: index } }); + return response.ok({ body: { name: index, template_outdated: templateOutdated } }); } else { return siemResponse.error({ statusCode: 404, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts index 12f4ae124ed31..037f91240edfa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts @@ -60,6 +60,7 @@ export const createThreatSignal = async ({ searchAfter: currentThreatList.hits.hits[currentThreatList.hits.hits.length - 1].sort, sortField: undefined, sortOrder: undefined, + listClient, }); const threatFilter = buildThreatMappingFilter({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts index eeace508c9bfe..8be76dc8caf0f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts @@ -62,6 +62,7 @@ export const createThreatSignals = async ({ query: threatQuery, language: threatLanguage, index: threatIndex, + listClient, searchAfter: undefined, sortField: undefined, sortOrder: undefined, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.test.ts index f600463c213c2..8a689f455c31d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.test.ts @@ -9,23 +9,83 @@ import { getSortWithTieBreaker } from './get_threat_list'; describe('get_threat_signals', () => { describe('getSortWithTieBreaker', () => { test('it should return sort field of just timestamp if given no sort order', () => { - const sortOrder = getSortWithTieBreaker({ sortField: undefined, sortOrder: undefined }); + const sortOrder = getSortWithTieBreaker({ + sortField: undefined, + sortOrder: undefined, + index: ['index-123'], + listItemIndex: 'list-index-123', + }); expect(sortOrder).toEqual([{ '@timestamp': 'asc' }]); }); + test('it should return sort field of just tie_breaker_id if given no sort order for a list item index', () => { + const sortOrder = getSortWithTieBreaker({ + sortField: undefined, + sortOrder: undefined, + index: ['list-item-index-123'], + listItemIndex: 'list-item-index-123', + }); + expect(sortOrder).toEqual([{ tie_breaker_id: 'asc' }]); + }); + test('it should return sort field of timestamp with asc even if sortOrder is changed as it is hard wired in', () => { - const sortOrder = getSortWithTieBreaker({ sortField: undefined, sortOrder: 'desc' }); + const sortOrder = getSortWithTieBreaker({ + sortField: undefined, + sortOrder: 'desc', + index: ['index-123'], + listItemIndex: 'list-index-123', + }); expect(sortOrder).toEqual([{ '@timestamp': 'asc' }]); }); + test('it should return sort field of tie_breaker_id with asc even if sortOrder is changed as it is hard wired in for a list item index', () => { + const sortOrder = getSortWithTieBreaker({ + sortField: undefined, + sortOrder: 'desc', + index: ['list-index-123'], + listItemIndex: 'list-index-123', + }); + expect(sortOrder).toEqual([{ tie_breaker_id: 'asc' }]); + }); + test('it should return sort field of an extra field if given one', () => { - const sortOrder = getSortWithTieBreaker({ sortField: 'some-field', sortOrder: undefined }); + const sortOrder = getSortWithTieBreaker({ + sortField: 'some-field', + sortOrder: undefined, + index: ['index-123'], + listItemIndex: 'list-index-123', + }); expect(sortOrder).toEqual([{ 'some-field': 'asc', '@timestamp': 'asc' }]); }); + test('it should return sort field of an extra field if given one for a list item index', () => { + const sortOrder = getSortWithTieBreaker({ + sortField: 'some-field', + sortOrder: undefined, + index: ['list-index-123'], + listItemIndex: 'list-index-123', + }); + expect(sortOrder).toEqual([{ 'some-field': 'asc', tie_breaker_id: 'asc' }]); + }); + test('it should return sort field of desc if given one', () => { - const sortOrder = getSortWithTieBreaker({ sortField: 'some-field', sortOrder: 'desc' }); + const sortOrder = getSortWithTieBreaker({ + sortField: 'some-field', + sortOrder: 'desc', + index: ['index-123'], + listItemIndex: 'list-index-123', + }); expect(sortOrder).toEqual([{ 'some-field': 'desc', '@timestamp': 'asc' }]); }); + + test('it should return sort field of desc if given one for a list item index', () => { + const sortOrder = getSortWithTieBreaker({ + sortField: 'some-field', + sortOrder: 'desc', + index: ['list-index-123'], + listItemIndex: 'list-index-123', + }); + expect(sortOrder).toEqual([{ 'some-field': 'desc', tie_breaker_id: 'asc' }]); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts index 3c3f5b544bb17..3147eb1705168 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts @@ -29,6 +29,7 @@ export const getThreatList = async ({ sortOrder, exceptionItems, threatFilters, + listClient, }: GetThreatListOptions): Promise> => { const calculatedPerPage = perPage ?? MAX_PER_PAGE; if (calculatedPerPage > 10000) { @@ -41,11 +42,17 @@ export const getThreatList = async ({ index, exceptionItems ); + const response: SearchResponse = await callCluster('search', { body: { query: queryFilter, search_after: searchAfter, - sort: getSortWithTieBreaker({ sortField, sortOrder }), + sort: getSortWithTieBreaker({ + sortField, + sortOrder, + index, + listItemIndex: listClient.getListItemIndex(), + }), }, ignoreUnavailable: true, index, @@ -54,14 +61,31 @@ export const getThreatList = async ({ return response; }; +/** + * This returns the sort with a tiebreaker if we find out we are only + * querying against the list items index. If we are querying against any + * other index we are assuming we are 1 or more ECS compatible indexes and + * will query against those indexes using just timestamp since we don't have + * a tiebreaker. + */ export const getSortWithTieBreaker = ({ sortField, sortOrder, + index, + listItemIndex, }: GetSortWithTieBreakerOptions): SortWithTieBreaker[] => { const ascOrDesc = sortOrder ?? 'asc'; - if (sortField != null) { - return [{ [sortField]: ascOrDesc, '@timestamp': 'asc' }]; + if (index.length === 1 && index[0] === listItemIndex) { + if (sortField != null) { + return [{ [sortField]: ascOrDesc, tie_breaker_id: 'asc' }]; + } else { + return [{ tie_breaker_id: 'asc' }]; + } } else { - return [{ '@timestamp': 'asc' }]; + if (sortField != null) { + return [{ [sortField]: ascOrDesc, '@timestamp': 'asc' }]; + } else { + return [{ '@timestamp': 'asc' }]; + } } }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts index da7aab3ad56e1..0078cf1b3c64f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts @@ -155,11 +155,14 @@ export interface GetThreatListOptions { sortOrder: 'asc' | 'desc' | undefined; threatFilters: PartialFilter[]; exceptionItems: ExceptionListItemSchema[]; + listClient: ListClient; } export interface GetSortWithTieBreakerOptions { sortField: string | undefined; sortOrder: 'asc' | 'desc' | undefined; + index: string[]; + listItemIndex: string; } /** @@ -171,6 +174,5 @@ export interface ThreatListItem { } export interface SortWithTieBreaker { - '@timestamp': 'asc'; [key: string]: string; } diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts index acee75abddcd9..88ce963757f6d 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts @@ -150,7 +150,13 @@ export class TelemetryEventsSender { })); this.queue = []; - await this.sendEvents(toSend, telemetryUrl, clusterInfo.cluster_uuid, licenseInfo?.uid); + await this.sendEvents( + toSend, + telemetryUrl, + clusterInfo.cluster_uuid, + clusterInfo.version?.number, + licenseInfo?.uid + ); } catch (err) { this.logger.warn(`Error sending telemetry events data: ${err}`); this.queue = []; @@ -202,6 +208,7 @@ export class TelemetryEventsSender { events: unknown[], telemetryUrl: string, clusterUuid: string, + clusterVersionNumber: string | undefined, licenseId: string | undefined ) { // this.logger.debug(`Sending events: ${JSON.stringify(events, null, 2)}`); @@ -213,8 +220,8 @@ export class TelemetryEventsSender { headers: { 'Content-Type': 'application/x-ndjson', 'X-Elastic-Cluster-ID': clusterUuid, + 'X-Elastic-Stack-Version': clusterVersionNumber ? clusterVersionNumber : '7.10.0', ...(licenseId ? { 'X-Elastic-License-ID': licenseId } : {}), - 'X-Elastic-Telemetry': '1', // TODO: no longer needed? }, }); this.logger.debug(`Events sent!. Response: ${resp.status} ${JSON.stringify(resp.data)}`); diff --git a/x-pack/plugins/security_solution/server/search_strategy/helpers/format_response_object_values.ts b/x-pack/plugins/security_solution/server/search_strategy/helpers/format_response_object_values.ts new file mode 100644 index 0000000000000..482734c73a257 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/helpers/format_response_object_values.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mapValues, isObject, isArray } from 'lodash/fp'; + +import { toArray } from './to_array'; + +export const mapObjectValuesToStringArray = (object: object): object => + mapValues((o) => { + if (isObject(o) && !isArray(o)) { + return mapObjectValuesToStringArray(o); + } + + return toArray(o); + }, object); + +export const formatResponseObjectValues = (object: T | T[] | null) => { + if (object && typeof object === 'object') { + return mapObjectValuesToStringArray(object as object); + } + + return object; +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/helpers/to_array.ts b/x-pack/plugins/security_solution/server/search_strategy/helpers/to_array.ts index f7d9f408c5e2d..1aba6660677cd 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/helpers/to_array.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/helpers/to_array.ts @@ -4,5 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export const toArray = (value: T | T[] | null) => +export const toArray = (value: T | T[] | null): T[] => + Array.isArray(value) ? value : value == null ? [] : [value]; + +export const toStringArray = (value: T | T[] | null): T[] | string[] => Array.isArray(value) ? value : value == null ? [] : [`${value}`]; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/helpers.ts index b06c36fd24e1a..55b54c8975214 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/helpers.ts @@ -9,7 +9,7 @@ import { hostFieldsMap } from '../../../../../../common/ecs/ecs_fields'; import { HostsEdges } from '../../../../../../common/search_strategy/security_solution/hosts'; import { HostAggEsItem, HostBuckets, HostValue } from '../../../../../lib/hosts/types'; -import { toArray } from '../../../../helpers/to_array'; +import { toStringArray } from '../../../../helpers/to_array'; export const HOSTS_FIELDS: readonly string[] = [ '_id', @@ -31,7 +31,7 @@ export const formatHostEdgesData = ( flattenedFields.cursor.value = hostId || ''; const fieldValue = getHostFieldValue(fieldName, bucket); if (fieldValue != null) { - return set(`node.${fieldName}`, toArray(fieldValue), flattenedFields); + return set(`node.${fieldName}`, toStringArray(fieldValue), flattenedFields); } return flattenedFields; }, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/helpers.ts index ce8900a578102..e1924d6c27940 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/helpers.ts @@ -6,7 +6,7 @@ import { get, getOr, isEmpty } from 'lodash/fp'; import { set } from '@elastic/safer-lodash-set/fp'; import { mergeFieldsWithHit } from '../../../../../utils/build_query'; -import { toArray } from '../../../../helpers/to_array'; +import { toStringArray } from '../../../../helpers/to_array'; import { AuthenticationsEdges, AuthenticationHit, @@ -53,7 +53,7 @@ export const formatAuthenticationData = ( const fieldPath = `node.${fieldName}`; const fieldValue = get(fieldPath, mergedResult); if (!isEmpty(fieldValue)) { - return set(fieldPath, toArray(fieldValue), mergedResult); + return set(fieldPath, toStringArray(fieldValue), mergedResult); } else { return mergedResult; } diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts index 644278963742d..36cf025304e76 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts @@ -7,7 +7,7 @@ import { set } from '@elastic/safer-lodash-set/fp'; import { get, has, head } from 'lodash/fp'; import { hostFieldsMap } from '../../../../../../common/ecs/ecs_fields'; import { HostItem } from '../../../../../../common/search_strategy/security_solution/hosts'; -import { toArray } from '../../../../helpers/to_array'; +import { toStringArray } from '../../../../helpers/to_array'; import { HostAggEsItem, HostBuckets, HostValue } from '../../../../../lib/hosts/types'; @@ -40,7 +40,7 @@ export const formatHostItem = (bucket: HostAggEsItem): HostItem => if (fieldName === '_id') { return set('_id', fieldValue, flattenedFields); } - return set(fieldName, toArray(fieldValue), flattenedFields); + return set(fieldName, toStringArray(fieldValue), flattenedFields); } return flattenedFields; }, {}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/uncommon_processes/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/uncommon_processes/helpers.ts index 20b3f5b05bc87..7d9351993bc85 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/uncommon_processes/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/uncommon_processes/helpers.ts @@ -12,7 +12,7 @@ import { HostsUncommonProcessesEdges, HostsUncommonProcessHit, } from '../../../../../../common/search_strategy/security_solution/hosts/uncommon_processes'; -import { toArray } from '../../../../helpers/to_array'; +import { toStringArray } from '../../../../helpers/to_array'; import { HostHits } from '../../../../../../common/search_strategy'; export const uncommonProcessesFields = [ @@ -79,7 +79,7 @@ export const formatUncommonProcessesData = ( fieldPath = `node.hosts.0.name`; fieldValue = get(fieldPath, mergedResult); } - return set(fieldPath, toArray(fieldValue), mergedResult); + return set(fieldPath, toStringArray(fieldValue), mergedResult); }, { node: { diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/__mocks__/index.ts index b1470b17eea5d..3e4070a28a9f8 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/__mocks__/index.ts @@ -11,6 +11,7 @@ import { FlowTargetSourceDest, NetworkQueries, NetworkTopNFlowRequestOptions, + NetworkTopNFlowStrategyResponse, NetworkTopTablesFields, } from '../../../../../../../common/search_strategy'; @@ -554,7 +555,7 @@ export const mockSearchStrategyResponse: IEsSearchResponse = { loaded: 21, }; -export const formattedSearchStrategyResponse = { +export const formattedSearchStrategyResponse: NetworkTopNFlowStrategyResponse = { edges: [ { node: { @@ -579,13 +580,16 @@ export const formattedSearchStrategyResponse = { ip: '35.232.239.42', location: { geo: { - continent_name: 'North America', - region_iso_code: 'US-VA', - country_iso_code: 'US', - region_name: 'Virginia', - location: { lon: -77.2481, lat: 38.6583 }, + continent_name: ['North America'], + region_iso_code: ['US-VA'], + country_iso_code: ['US'], + region_name: ['Virginia'], + location: { + lon: [-77.2481], + lat: [38.6583], + }, }, - flowTarget: 'source', + flowTarget: FlowTargetSourceDest.source, }, autonomous_system: { number: 15169, name: 'Google LLC' }, flows: 922, @@ -603,14 +607,17 @@ export const formattedSearchStrategyResponse = { ip: '151.101.200.204', location: { geo: { - continent_name: 'North America', - region_iso_code: 'US-VA', - city_name: 'Ashburn', - country_iso_code: 'US', - region_name: 'Virginia', - location: { lon: -77.4728, lat: 39.0481 }, - }, - flowTarget: 'source', + continent_name: ['North America'], + region_iso_code: ['US-VA'], + city_name: ['Ashburn'], + country_iso_code: ['US'], + region_name: ['Virginia'], + location: { + lon: [-77.4728], + lat: [39.0481], + }, + }, + flowTarget: FlowTargetSourceDest.source, }, autonomous_system: { number: 54113, name: 'Fastly' }, flows: 2, @@ -628,14 +635,17 @@ export const formattedSearchStrategyResponse = { ip: '91.189.92.39', location: { geo: { - continent_name: 'Europe', - region_iso_code: 'GB-ENG', - city_name: 'London', - country_iso_code: 'GB', - region_name: 'England', - location: { lon: -0.0961, lat: 51.5132 }, - }, - flowTarget: 'source', + continent_name: ['Europe'], + region_iso_code: ['GB-ENG'], + city_name: ['London'], + country_iso_code: ['GB'], + region_name: ['England'], + location: { + lon: [-0.0961], + lat: [51.5132], + }, + }, + flowTarget: FlowTargetSourceDest.source, }, autonomous_system: { number: 41231, name: 'Canonical Group Limited' }, flows: 1, @@ -668,14 +678,17 @@ export const formattedSearchStrategyResponse = { ip: '151.101.248.204', location: { geo: { - continent_name: 'North America', - region_iso_code: 'US-VA', - city_name: 'Ashburn', - country_iso_code: 'US', - region_name: 'Virginia', - location: { lon: -77.539, lat: 39.018 }, - }, - flowTarget: 'source', + continent_name: ['North America'], + region_iso_code: ['US-VA'], + city_name: ['Ashburn'], + country_iso_code: ['US'], + region_name: ['Virginia'], + location: { + lon: [-77.539], + lat: [39.018], + }, + }, + flowTarget: FlowTargetSourceDest.source, }, autonomous_system: { number: 54113, name: 'Fastly' }, flows: 6, @@ -693,13 +706,16 @@ export const formattedSearchStrategyResponse = { ip: '35.196.129.83', location: { geo: { - continent_name: 'North America', - region_iso_code: 'US-VA', - country_iso_code: 'US', - region_name: 'Virginia', - location: { lon: -77.2481, lat: 38.6583 }, + continent_name: ['North America'], + region_iso_code: ['US-VA'], + country_iso_code: ['US'], + region_name: ['Virginia'], + location: { + lon: [-77.2481], + lat: [38.6583], + }, }, - flowTarget: 'source', + flowTarget: FlowTargetSourceDest.source, }, autonomous_system: { number: 15169, name: 'Google LLC' }, flows: 1, @@ -717,11 +733,14 @@ export const formattedSearchStrategyResponse = { ip: '151.101.2.217', location: { geo: { - continent_name: 'North America', - country_iso_code: 'US', - location: { lon: -97.822, lat: 37.751 }, + continent_name: ['North America'], + country_iso_code: ['US'], + location: { + lon: [-97.822], + lat: [37.751], + }, }, - flowTarget: 'source', + flowTarget: FlowTargetSourceDest.source, }, autonomous_system: { number: 54113, name: 'Fastly' }, flows: 24, @@ -739,14 +758,17 @@ export const formattedSearchStrategyResponse = { ip: '91.189.91.38', location: { geo: { - continent_name: 'North America', - region_iso_code: 'US-MA', - city_name: 'Boston', - country_iso_code: 'US', - region_name: 'Massachusetts', - location: { lon: -71.0631, lat: 42.3562 }, - }, - flowTarget: 'source', + continent_name: ['North America'], + region_iso_code: ['US-MA'], + city_name: ['Boston'], + country_iso_code: ['US'], + region_name: ['Massachusetts'], + location: { + lon: [-71.0631], + lat: [42.3562], + }, + }, + flowTarget: FlowTargetSourceDest.source, }, autonomous_system: { number: 41231, name: 'Canonical Group Limited' }, flows: 1, @@ -764,11 +786,14 @@ export const formattedSearchStrategyResponse = { ip: '193.228.91.123', location: { geo: { - continent_name: 'North America', - country_iso_code: 'US', - location: { lon: -97.822, lat: 37.751 }, + continent_name: ['North America'], + country_iso_code: ['US'], + location: { + lon: [-97.822], + lat: [37.751], + }, }, - flowTarget: 'source', + flowTarget: FlowTargetSourceDest.source, }, autonomous_system: { number: 133766, name: 'YHSRV.LLC' }, flows: 33, @@ -846,6 +871,7 @@ export const formattedSearchStrategyResponse = { }, pageInfo: { activePage: 0, fakeTotalCount: 50, showMorePagesIndicator: true }, totalCount: 738, + rawResponse: {} as NetworkTopNFlowStrategyResponse['rawResponse'], }; export const expectedDsl = { diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/helpers.ts index 720661e12bd96..0bf99aeea8a2d 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/helpers.ts @@ -20,6 +20,7 @@ import { AutonomousSystemItem, } from '../../../../../../common/search_strategy'; import { getOppositeField } from '../helpers'; +import { formatResponseObjectValues } from '../../../../helpers/format_response_object_values'; export const getTopNFlowEdges = ( response: IEsSearchResponse, @@ -66,12 +67,14 @@ const getFlowTargetFromString = (flowAsString: string) => const getGeoItem = (result: NetworkTopNFlowBuckets): GeoItem | null => result.location.top_geo.hits.hits.length > 0 && result.location.top_geo.hits.hits[0]._source ? { - geo: getOr( - '', - `location.top_geo.hits.hits[0]._source.${ - Object.keys(result.location.top_geo.hits.hits[0]._source)[0] - }.geo`, - result + geo: formatResponseObjectValues( + getOr( + '', + `location.top_geo.hits.hits[0]._source.${ + Object.keys(result.location.top_geo.hits.hits[0]._source)[0] + }.geo`, + result + ) ), flowTarget: getFlowTargetFromString( Object.keys(result.location.top_geo.hits.hits[0]._source)[0] diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts index b2e3989f99d4f..8e2bfb5426610 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts @@ -6,7 +6,7 @@ import { get, has, merge, uniq } from 'lodash/fp'; import { EventHit, TimelineEdges } from '../../../../../../common/search_strategy'; -import { toArray } from '../../../../helpers/to_array'; +import { toStringArray } from '../../../../helpers/to_array'; export const formatTimelineData = ( dataFields: readonly string[], @@ -56,8 +56,8 @@ const mergeTimelineFieldsWithHit = ( { field: fieldName, value: specialFields.includes(esField) - ? toArray(get(esField, hit)) - : toArray(get(esField, hit._source)), + ? toStringArray(get(esField, hit)) + : toStringArray(get(esField, hit._source)), }, ] : get('node.data', flattenedFields), @@ -68,7 +68,7 @@ const mergeTimelineFieldsWithHit = ( ...fieldName.split('.').reduceRight( // @ts-expect-error (obj, next) => ({ [next]: obj }), - toArray(get(esField, hit._source)) + toStringArray(get(esField, hit._source)) ), } : get('node.ecs', flattenedFields), diff --git a/x-pack/plugins/telemetry_collection_xpack/server/plugin.ts b/x-pack/plugins/telemetry_collection_xpack/server/plugin.ts index 6ef44e325b0a7..524b4c5616c73 100644 --- a/x-pack/plugins/telemetry_collection_xpack/server/plugin.ts +++ b/x-pack/plugins/telemetry_collection_xpack/server/plugin.ts @@ -10,6 +10,7 @@ import { CoreStart, Plugin, IClusterClient, + SavedObjectsServiceStart, } from 'kibana/server'; import { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; import { getClusterUuids, getLocalLicense } from '../../../../src/plugins/telemetry/server'; @@ -21,12 +22,14 @@ interface TelemetryCollectionXpackDepsSetup { export class TelemetryCollectionXpackPlugin implements Plugin { private elasticsearchClient?: IClusterClient; + private savedObjectsService?: SavedObjectsServiceStart; constructor(initializerContext: PluginInitializerContext) {} public setup(core: CoreSetup, { telemetryCollectionManager }: TelemetryCollectionXpackDepsSetup) { telemetryCollectionManager.setCollection({ esCluster: core.elasticsearch.legacy.client, esClientGetter: () => this.elasticsearchClient, + soServiceGetter: () => this.savedObjectsService, title: 'local_xpack', priority: 1, statsGetter: getStatsWithXpack, @@ -37,5 +40,6 @@ export class TelemetryCollectionXpackPlugin implements Plugin { public start(core: CoreStart) { this.elasticsearchClient = core.elasticsearch.client; + this.savedObjectsService = core.savedObjects; } } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 3ed658cbcb625..47a5476bf8e76 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -16405,7 +16405,6 @@ "xpack.securitySolution.timeline.flyout.pane.timelinePropertiesAriaLabel": "タイムラインのプロパティ", "xpack.securitySolution.timeline.flyoutTimelineTemplateLabel": "タイムラインテンプレート", "xpack.securitySolution.timeline.fullScreenButton": "全画面", - "xpack.securitySolution.timeline.graphOverlay.backToEventsButton": "< イベントに戻る", "xpack.securitySolution.timeline.properties.attachTimelineToCaseTooltip": "ケースに関連付けるには、タイムラインのタイトルを入力してください", "xpack.securitySolution.timeline.properties.attachToExistingCaseButtonLabel": "既存のケースに添付...", "xpack.securitySolution.timeline.properties.attachToNewCaseButtonLabel": "新しいケースに添付", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 40656f328a2a2..892d9f4763fed 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -16415,7 +16415,6 @@ "xpack.securitySolution.timeline.flyout.pane.timelinePropertiesAriaLabel": "时间线属性", "xpack.securitySolution.timeline.flyoutTimelineTemplateLabel": "时间线模板", "xpack.securitySolution.timeline.fullScreenButton": "全屏", - "xpack.securitySolution.timeline.graphOverlay.backToEventsButton": "< 返回至事件", "xpack.securitySolution.timeline.properties.attachTimelineToCaseTooltip": "请为您的时间线提供标题,以便将其附加到案例", "xpack.securitySolution.timeline.properties.attachToExistingCaseButtonLabel": "附加到现有案例......", "xpack.securitySolution.timeline.properties.attachToNewCaseButtonLabel": "附加到新案例", diff --git a/x-pack/plugins/xpack_legacy/server/routes/settings.test.ts b/x-pack/plugins/xpack_legacy/server/routes/settings.test.ts index e4afbb0902830..5d22e22ee0eb6 100644 --- a/x-pack/plugins/xpack_legacy/server/routes/settings.test.ts +++ b/x-pack/plugins/xpack_legacy/server/routes/settings.test.ts @@ -13,7 +13,11 @@ import { ServiceStatus, ServiceStatusLevels, } from '../../../../../src/core/server'; -import { contextServiceMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; +import { + contextServiceMock, + elasticsearchServiceMock, + savedObjectsServiceMock, +} from '../../../../../src/core/server/mocks'; import { createHttpServer } from '../../../../../src/core/server/test_utils'; import { registerSettingsRoute } from './settings'; @@ -42,6 +46,9 @@ describe('/api/settings', () => { asCurrentUser: elasticsearchServiceMock.createScopedClusterClient().asCurrentUser, }, }, + savedObjects: { + client: savedObjectsServiceMock.create(), + }, }, }), }); diff --git a/x-pack/plugins/xpack_legacy/server/routes/settings.ts b/x-pack/plugins/xpack_legacy/server/routes/settings.ts index 091344ae4e894..9a30ca30616b7 100644 --- a/x-pack/plugins/xpack_legacy/server/routes/settings.ts +++ b/x-pack/plugins/xpack_legacy/server/routes/settings.ts @@ -45,6 +45,7 @@ export function registerSettingsRoute({ const collectorFetchContext = { callCluster: callAsCurrentUser, esClient: context.core.elasticsearch.client.asCurrentUser, + soClient: context.core.savedObjects.client, }; const settingsCollector = usageCollection.getCollectorByType(KIBANA_SETTINGS_TYPE) as diff --git a/x-pack/test/api_integration/apis/security_solution/authentications.ts b/x-pack/test/api_integration/apis/security_solution/authentications.ts index c0a3570c9d8e2..7073658ab3ccc 100644 --- a/x-pack/test/api_integration/apis/security_solution/authentications.ts +++ b/x-pack/test/api_integration/apis/security_solution/authentications.ts @@ -14,6 +14,7 @@ const TO = '3000-01-01T00:00:00.000Z'; // typical values that have to change after an update from "scripts/es_archiver" const HOST_NAME = 'zeek-newyork-sha-aa8df15'; +const LAST_SUCCESS_SOURCE_IP = '8.42.77.171'; const TOTAL_COUNT = 3; const EDGE_LENGTH = 1; @@ -78,6 +79,9 @@ export default function ({ getService }: FtrProviderContext) { expect(authentications.edges.length).to.be(EDGE_LENGTH); expect(authentications.totalCount).to.be(TOTAL_COUNT); + expect(authentications.edges[0]!.node.lastSuccess!.source!.ip).to.eql([ + LAST_SUCCESS_SOURCE_IP, + ]); expect(authentications.edges[0]!.node.lastSuccess!.host!.name).to.eql([HOST_NAME]); }); });