diff --git a/examples/controls_example/public/edit_example.tsx b/examples/controls_example/public/edit_example.tsx index d8bfd515ca7da..cf5430cad48f1 100644 --- a/examples/controls_example/public/edit_example.tsx +++ b/examples/controls_example/public/edit_example.tsx @@ -6,10 +6,12 @@ * Side Public License, v 1. */ +import { pickBy } from 'lodash'; import React, { useState } from 'react'; import { EuiButton, EuiButtonEmpty, + EuiButtonGroup, EuiFlexGroup, EuiFlexItem, EuiLoadingContent, @@ -19,8 +21,13 @@ import { EuiTitle, } from '@elastic/eui'; import { ViewMode } from '@kbn/embeddable-plugin/public'; -import { LazyControlGroupRenderer, ControlGroupContainer } from '@kbn/controls-plugin/public'; +import { + LazyControlGroupRenderer, + ControlGroupContainer, + ControlGroupInput, +} from '@kbn/controls-plugin/public'; import { withSuspense } from '@kbn/presentation-util-plugin/public'; +import { ACTION_EDIT_CONTROL, ACTION_DELETE_CONTROL } from '@kbn/controls-plugin/public'; const ControlGroupRenderer = withSuspense(LazyControlGroupRenderer); @@ -30,6 +37,27 @@ export const EditExample = () => { const [isSaving, setIsSaving] = useState(false); const [isLoading, setIsLoading] = useState(false); const [controlGroup, setControlGroup] = useState(); + const [toggleIconIdToSelectedMapIcon, setToggleIconIdToSelectedMapIcon] = useState<{ + [id: string]: boolean; + }>({}); + + function onChangeIconsMultiIcons(optionId: string) { + const newToggleIconIdToSelectedMapIcon = { + ...toggleIconIdToSelectedMapIcon, + ...{ + [optionId]: !toggleIconIdToSelectedMapIcon[optionId], + }, + }; + + if (controlGroup) { + const disabledActions: string[] = Object.keys( + pickBy(newToggleIconIdToSelectedMapIcon, (value) => value) + ); + controlGroup.updateInput({ disabledActions }); + } + + setToggleIconIdToSelectedMapIcon(newToggleIconIdToSelectedMapIcon); + } async function onSave() { setIsSaving(true); @@ -48,16 +76,20 @@ export const EditExample = () => { // simulated async load await await new Promise((resolve) => setTimeout(resolve, 1000)); - let input = {}; + let input: Partial = {}; const inputAsString = localStorage.getItem(INPUT_KEY); if (inputAsString) { try { input = JSON.parse(inputAsString); + const disabledActions = input.disabledActions ?? []; + setToggleIconIdToSelectedMapIcon({ + [ACTION_EDIT_CONTROL]: disabledActions.includes(ACTION_EDIT_CONTROL), + [ACTION_DELETE_CONTROL]: disabledActions.includes(ACTION_DELETE_CONTROL), + }); } catch (e) { // ignore parse errors } } - setIsLoading(false); return input; } @@ -72,7 +104,7 @@ export const EditExample = () => { - + { Add control + + onChangeIconsMultiIcons(id)} + /> + diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts index 09c0f98fed866..b919bac313c8f 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -122,7 +122,7 @@ describe('checking migration metadata changes on all registered SO types', () => "osquery-pack": "edd84b2c59ef36214ece0676706da8f22175c660", "osquery-pack-asset": "18e08979d46ee7e5538f54c080aec4d8c58516ca", "osquery-saved-query": "f5e4e303f65c7607248ea8b2672f1ee30e4fb15e", - "query": "f94de164936da788e9215c0e9b824f8b948ea859", + "query": "ec6000b775f06f81470df42d23f7a88cb31d64ba", "rules-settings": "9854495c3b54b16a6625fb250c35e5504da72266", "sample-data-telemetry": "c38daf1a49ed24f2a4fb091e6e1e833fccf19935", "search": "01bc42d635e9ea0588741c4c7a2bbd3feb3ac5dc", diff --git a/src/plugins/controls/kibana.jsonc b/src/plugins/controls/kibana.jsonc index defb62693b55c..0defa22bd351f 100644 --- a/src/plugins/controls/kibana.jsonc +++ b/src/plugins/controls/kibana.jsonc @@ -15,10 +15,9 @@ "embeddable", "dataViews", "data", - "unifiedSearch" + "unifiedSearch", + "uiActions" ], - "extraPublicDirs": [ - "common" - ] + "extraPublicDirs": ["common"] } } diff --git a/src/plugins/controls/public/control_group/actions/delete_control_action.test.tsx b/src/plugins/controls/public/control_group/actions/delete_control_action.test.tsx new file mode 100644 index 0000000000000..19d036ae5a6b3 --- /dev/null +++ b/src/plugins/controls/public/control_group/actions/delete_control_action.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + lazyLoadReduxEmbeddablePackage, + ReduxEmbeddablePackage, +} from '@kbn/presentation-util-plugin/public'; +import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public'; + +import { ControlOutput } from '../../types'; +import { ControlGroupInput } from '../types'; +import { pluginServices } from '../../services'; +import { DeleteControlAction } from './delete_control_action'; +import { OptionsListEmbeddableInput } from '../../options_list'; +import { controlGroupInputBuilder } from '../control_group_input_builder'; +import { ControlGroupContainer } from '../embeddable/control_group_container'; +import { OptionsListEmbeddable } from '../../options_list/embeddable/options_list_embeddable'; + +let container: ControlGroupContainer; +let embeddable: OptionsListEmbeddable; +let reduxEmbeddablePackage: ReduxEmbeddablePackage; + +beforeAll(async () => { + reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage(); + + const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput; + controlGroupInputBuilder.addOptionsListControl(controlGroupInput, { + dataViewId: 'test-data-view', + title: 'test', + fieldName: 'test-field', + width: 'medium', + grow: false, + }); + container = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput); + await container.untilInitialized(); + + embeddable = container.getChild(container.getChildIds()[0]); +}); + +test('Action is incompatible with Error Embeddables', async () => { + const deleteControlAction = new DeleteControlAction(); + const errorEmbeddable = new ErrorEmbeddable('Wow what an awful error', { id: ' 404' }); + expect(await deleteControlAction.isCompatible({ embeddable: errorEmbeddable as any })).toBe( + false + ); +}); + +test('Execute throws an error when called with an embeddable not in a parent', async () => { + const deleteControlAction = new DeleteControlAction(); + const optionsListEmbeddable = new OptionsListEmbeddable( + reduxEmbeddablePackage, + {} as OptionsListEmbeddableInput, + {} as ControlOutput + ); + await expect(async () => { + await deleteControlAction.execute({ embeddable: optionsListEmbeddable }); + }).rejects.toThrow(Error); +}); + +describe('Execute should open a confirm modal', () => { + test('Canceling modal will keep control', async () => { + const spyOn = jest.fn().mockResolvedValue(false); + pluginServices.getServices().overlays.openConfirm = spyOn; + + const deleteControlAction = new DeleteControlAction(); + await deleteControlAction.execute({ embeddable }); + expect(spyOn).toHaveBeenCalled(); + + expect(container.getPanelCount()).toBe(1); + }); + + test('Confirming modal will delete control', async () => { + const spyOn = jest.fn().mockResolvedValue(true); + pluginServices.getServices().overlays.openConfirm = spyOn; + + const deleteControlAction = new DeleteControlAction(); + await deleteControlAction.execute({ embeddable }); + expect(spyOn).toHaveBeenCalled(); + + expect(container.getPanelCount()).toBe(0); + }); +}); diff --git a/src/plugins/controls/public/control_group/actions/delete_control_action.tsx b/src/plugins/controls/public/control_group/actions/delete_control_action.tsx new file mode 100644 index 0000000000000..7a44c537d1d9d --- /dev/null +++ b/src/plugins/controls/public/control_group/actions/delete_control_action.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { ViewMode, isErrorEmbeddable } from '@kbn/embeddable-plugin/public'; +import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; + +import { ACTION_DELETE_CONTROL } from '.'; +import { pluginServices } from '../../services'; +import { ControlGroupStrings } from '../control_group_strings'; +import { ControlEmbeddable, DataControlInput } from '../../types'; +import { isControlGroup } from '../embeddable/control_group_helpers'; + +export interface DeleteControlActionContext { + embeddable: ControlEmbeddable; +} + +export class DeleteControlAction implements Action { + public readonly type = ACTION_DELETE_CONTROL; + public readonly id = ACTION_DELETE_CONTROL; + public order = 2; + + private openConfirm; + + constructor() { + ({ + overlays: { openConfirm: this.openConfirm }, + } = pluginServices.getServices()); + } + + public readonly MenuItem = ({ context }: { context: DeleteControlActionContext }) => { + return ( + + this.execute(context)} + color="danger" + /> + + ); + }; + + public getDisplayName({ embeddable }: DeleteControlActionContext) { + if (!embeddable.parent || !isControlGroup(embeddable.parent)) { + throw new IncompatibleActionError(); + } + return ControlGroupStrings.floatingActions.getRemoveButtonTitle(); + } + + public getIconType({ embeddable }: DeleteControlActionContext) { + if (!embeddable.parent || !isControlGroup(embeddable.parent)) { + throw new IncompatibleActionError(); + } + return 'cross'; + } + + public async isCompatible({ embeddable }: DeleteControlActionContext) { + if (isErrorEmbeddable(embeddable)) return false; + const controlGroup = embeddable.parent; + return Boolean( + controlGroup && + isControlGroup(controlGroup) && + controlGroup.getInput().viewMode === ViewMode.EDIT + ); + } + + public async execute({ embeddable }: DeleteControlActionContext) { + if (!embeddable.parent || !isControlGroup(embeddable.parent)) { + throw new IncompatibleActionError(); + } + this.openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), + cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), + title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), + buttonColor: 'danger', + }).then((confirmed) => { + if (confirmed) { + embeddable.parent?.removeEmbeddable(embeddable.id); + } + }); + } +} diff --git a/src/plugins/controls/public/control_group/actions/edit_control_action.test.tsx b/src/plugins/controls/public/control_group/actions/edit_control_action.test.tsx new file mode 100644 index 0000000000000..7c4f15f091b9c --- /dev/null +++ b/src/plugins/controls/public/control_group/actions/edit_control_action.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + lazyLoadReduxEmbeddablePackage, + ReduxEmbeddablePackage, +} from '@kbn/presentation-util-plugin/public'; +import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public'; + +import { ControlOutput } from '../../types'; +import { ControlGroupInput } from '../types'; +import { pluginServices } from '../../services'; +import { EditControlAction } from './edit_control_action'; +import { DeleteControlAction } from './delete_control_action'; +import { TimeSliderEmbeddableFactory } from '../../time_slider'; +import { OptionsListEmbeddableFactory, OptionsListEmbeddableInput } from '../../options_list'; +import { ControlGroupContainer } from '../embeddable/control_group_container'; +import { OptionsListEmbeddable } from '../../options_list/embeddable/options_list_embeddable'; + +let reduxEmbeddablePackage: ReduxEmbeddablePackage; + +const controlGroupInput = { chainingSystem: 'NONE', panels: {} } as ControlGroupInput; +const deleteControlAction = new DeleteControlAction(); + +beforeAll(async () => { + reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage(); +}); + +test('Action is incompatible with Error Embeddables', async () => { + const editControlAction = new EditControlAction(deleteControlAction); + const errorEmbeddable = new ErrorEmbeddable('Wow what an awful error', { id: ' 404' }); + expect(await editControlAction.isCompatible({ embeddable: errorEmbeddable as any })).toBe(false); +}); + +test('Action is incompatible with embeddables that are not editable', async () => { + const mockEmbeddableFactory = new TimeSliderEmbeddableFactory(); + const mockGetFactory = jest.fn().mockReturnValue(mockEmbeddableFactory); + pluginServices.getServices().controls.getControlFactory = mockGetFactory; + pluginServices.getServices().embeddable.getEmbeddableFactory = mockGetFactory; + + const editControlAction = new EditControlAction(deleteControlAction); + const emptyContainer = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput); + await emptyContainer.untilInitialized(); + await emptyContainer.addTimeSliderControl(); + + expect( + await editControlAction.isCompatible({ + embeddable: emptyContainer.getChild(emptyContainer.getChildIds()[0]) as any, + }) + ).toBe(false); +}); + +test('Action is compatible with embeddables that are editable', async () => { + const mockEmbeddableFactory = new OptionsListEmbeddableFactory(); + const mockGetFactory = jest.fn().mockReturnValue(mockEmbeddableFactory); + pluginServices.getServices().controls.getControlFactory = mockGetFactory; + pluginServices.getServices().embeddable.getEmbeddableFactory = mockGetFactory; + + const editControlAction = new EditControlAction(deleteControlAction); + const emptyContainer = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput); + await emptyContainer.untilInitialized(); + await emptyContainer.addOptionsListControl({ + dataViewId: 'test-data-view', + title: 'test', + fieldName: 'test-field', + width: 'medium', + grow: false, + }); + + expect( + await editControlAction.isCompatible({ + embeddable: emptyContainer.getChild(emptyContainer.getChildIds()[0]) as any, + }) + ).toBe(true); +}); + +test('Execute throws an error when called with an embeddable not in a parent', async () => { + const editControlAction = new EditControlAction(deleteControlAction); + const optionsListEmbeddable = new OptionsListEmbeddable( + reduxEmbeddablePackage, + {} as OptionsListEmbeddableInput, + {} as ControlOutput + ); + await expect(async () => { + await editControlAction.execute({ embeddable: optionsListEmbeddable }); + }).rejects.toThrow(Error); +}); + +test('Execute should open a flyout', async () => { + const spyOn = jest.fn().mockResolvedValue(undefined); + pluginServices.getServices().overlays.openFlyout = spyOn; + + const emptyContainer = new ControlGroupContainer(reduxEmbeddablePackage, controlGroupInput); + await emptyContainer.untilInitialized(); + await emptyContainer.addOptionsListControl({ + dataViewId: 'test-data-view', + title: 'test', + fieldName: 'test-field', + width: 'medium', + grow: false, + }); + const embeddable: OptionsListEmbeddable = emptyContainer.getChild( + emptyContainer.getChildIds()[0] + ); + + const editControlAction = new EditControlAction(deleteControlAction); + await editControlAction.execute({ embeddable }); + expect(spyOn).toHaveBeenCalled(); +}); diff --git a/src/plugins/controls/public/control_group/actions/edit_control_action.tsx b/src/plugins/controls/public/control_group/actions/edit_control_action.tsx new file mode 100644 index 0000000000000..9500640332446 --- /dev/null +++ b/src/plugins/controls/public/control_group/actions/edit_control_action.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import { isErrorEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public'; +import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public'; + +import { pluginServices } from '../../services'; +import { EditControlFlyout } from './edit_control_flyout'; +import { DeleteControlAction } from './delete_control_action'; +import { ControlGroupStrings } from '../control_group_strings'; +import { ACTION_EDIT_CONTROL, ControlGroupContainer } from '..'; +import { ControlEmbeddable, DataControlInput } from '../../types'; +import { setFlyoutRef } from '../embeddable/control_group_container'; +import { isControlGroup } from '../embeddable/control_group_helpers'; + +export interface EditControlActionContext { + embeddable: ControlEmbeddable; +} + +export class EditControlAction implements Action { + public readonly type = ACTION_EDIT_CONTROL; + public readonly id = ACTION_EDIT_CONTROL; + public order = 1; + + private getEmbeddableFactory; + private openFlyout; + private theme$; + + constructor(private deleteControlAction: DeleteControlAction) { + ({ + embeddable: { getEmbeddableFactory: this.getEmbeddableFactory }, + overlays: { openFlyout: this.openFlyout }, + theme: { theme$: this.theme$ }, + } = pluginServices.getServices()); + } + + public readonly MenuItem = ({ context }: { context: EditControlActionContext }) => { + const { embeddable } = context; + return ( + + this.execute(context)} + color="text" + /> + + ); + }; + + public getDisplayName({ embeddable }: EditControlActionContext) { + if (!embeddable.parent || !isControlGroup(embeddable.parent)) { + throw new IncompatibleActionError(); + } + return ControlGroupStrings.floatingActions.getEditButtonTitle(); + } + + public getIconType({ embeddable }: EditControlActionContext) { + if (!embeddable.parent || !isControlGroup(embeddable.parent)) { + throw new IncompatibleActionError(); + } + return 'pencil'; + } + + public async isCompatible({ embeddable }: EditControlActionContext) { + if (isErrorEmbeddable(embeddable)) return false; + const controlGroup = embeddable.parent; + const factory = this.getEmbeddableFactory(embeddable.type); + + return Boolean( + controlGroup && + isControlGroup(controlGroup) && + controlGroup.getInput().viewMode === ViewMode.EDIT && + factory && + (await factory.isEditable()) + ); + } + + public async execute({ embeddable }: EditControlActionContext) { + if (!embeddable.parent || !isControlGroup(embeddable.parent)) { + throw new IncompatibleActionError(); + } + const controlGroup = embeddable.parent as ControlGroupContainer; + const ReduxWrapper = controlGroup.getReduxEmbeddableTools().Wrapper; + + const flyoutInstance = this.openFlyout( + toMountPoint( + + this.deleteControlAction.execute({ embeddable })} + closeFlyout={() => { + setFlyoutRef(undefined); + flyoutInstance.close(); + }} + /> + , + + { theme$: this.theme$ } + ), + { + 'aria-label': ControlGroupStrings.manageControl.getFlyoutEditTitle(), + outsideClickCloses: false, + onClose: (flyout) => { + setFlyoutRef(undefined); + flyout.close(); + }, + ownFocus: true, + // @ts-ignore - TODO: Remove this once https://github.com/elastic/eui/pull/6645 lands in Kibana + focusTrapProps: { scrollLock: true }, + } + ); + setFlyoutRef(flyoutInstance); + } +} diff --git a/src/plugins/controls/public/control_group/actions/edit_control_flyout.tsx b/src/plugins/controls/public/control_group/actions/edit_control_flyout.tsx new file mode 100644 index 0000000000000..c5f3dda8f5f8c --- /dev/null +++ b/src/plugins/controls/public/control_group/actions/edit_control_flyout.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { isEqual } from 'lodash'; +import React, { useState } from 'react'; + +import { EmbeddableFactoryNotFoundError } from '@kbn/embeddable-plugin/public'; + +import { DataControlInput, ControlEmbeddable, IEditableControlFactory } from '../../types'; +import { pluginServices } from '../../services'; +import { ControlGroupStrings } from '../control_group_strings'; +import { useControlGroupContainerContext } from '../control_group_renderer'; +import { ControlEditor } from '../editor/control_editor'; + +export const EditControlFlyout = ({ + embeddable, + closeFlyout, + removeControl, +}: { + embeddable: ControlEmbeddable; + closeFlyout: () => void; + removeControl: () => void; +}) => { + // Controls Services Context + const { + overlays: { openConfirm }, + controls: { getControlFactory }, + } = pluginServices.getServices(); + // Redux embeddable container Context + const reduxContext = useControlGroupContainerContext(); + const { + embeddableInstance: controlGroup, + actions: { setControlWidth, setControlGrow }, + useEmbeddableSelector, + useEmbeddableDispatch, + } = reduxContext; + const dispatch = useEmbeddableDispatch(); + + // current state + const panels = useEmbeddableSelector((state) => state.explicitInput.panels); + const panel = panels[embeddable.id]; + + const [currentGrow, setCurrentGrow] = useState(panel.grow); + const [currentWidth, setCurrentWidth] = useState(panel.width); + const [inputToReturn, setInputToReturn] = useState>({}); + + const onCancel = () => { + if ( + isEqual(panel.explicitInput, { + ...panel.explicitInput, + ...inputToReturn, + }) && + currentGrow === panel.grow && + currentWidth === panel.width + ) { + closeFlyout(); + return; + } + openConfirm(ControlGroupStrings.management.discardChanges.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.discardChanges.getConfirm(), + cancelButtonText: ControlGroupStrings.management.discardChanges.getCancel(), + title: ControlGroupStrings.management.discardChanges.getTitle(), + buttonColor: 'danger', + }).then((confirmed) => { + if (confirmed) { + closeFlyout(); + } + }); + }; + + const onSave = async (type?: string) => { + if (!type) { + closeFlyout(); + return; + } + + const factory = getControlFactory(type) as IEditableControlFactory; + if (!factory) throw new EmbeddableFactoryNotFoundError(type); + if (factory.presaveTransformFunction) { + setInputToReturn(factory.presaveTransformFunction(inputToReturn, embeddable)); + } + + if (currentWidth !== panel.width) + dispatch(setControlWidth({ width: currentWidth, embeddableId: embeddable.id })); + if (currentGrow !== panel.grow) + dispatch(setControlGrow({ grow: currentGrow, embeddableId: embeddable.id })); + + closeFlyout(); + await controlGroup.replaceEmbeddable(embeddable.id, inputToReturn, type); + }; + + return ( + onCancel()} + updateTitle={(newTitle) => (inputToReturn.title = newTitle)} + setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)} + updateWidth={(newWidth) => setCurrentWidth(newWidth)} + updateGrow={(newGrow) => setCurrentGrow(newGrow)} + onTypeEditorChange={(partialInput) => { + setInputToReturn({ ...inputToReturn, ...partialInput }); + }} + onSave={(type) => onSave(type)} + removeControl={() => { + closeFlyout(); + removeControl(); + }} + /> + ); +}; diff --git a/src/plugins/controls/public/control_group/actions/index.ts b/src/plugins/controls/public/control_group/actions/index.ts new file mode 100644 index 0000000000000..2bc869bb8f478 --- /dev/null +++ b/src/plugins/controls/public/control_group/actions/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const ACTION_EDIT_CONTROL = 'editControl'; +export const ACTION_DELETE_CONTROL = 'deleteControl'; diff --git a/src/plugins/controls/public/control_group/component/control_frame_component.tsx b/src/plugins/controls/public/control_group/component/control_frame_component.tsx index ac3df892846bd..2697e941ab234 100644 --- a/src/plugins/controls/public/control_group/component/control_frame_component.tsx +++ b/src/plugins/controls/public/control_group/component/control_frame_component.tsx @@ -10,7 +10,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import classNames from 'classnames'; import { EuiButtonEmpty, - EuiButtonIcon, EuiFormControlLayout, EuiFormLabel, EuiFormRow, @@ -23,11 +22,8 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { Markdown } from '@kbn/kibana-react-plugin/public'; import { useReduxEmbeddableContext, FloatingActions } from '@kbn/presentation-util-plugin/public'; import { ControlGroupReduxState } from '../types'; -import { pluginServices } from '../../services'; -import { EditControlButton } from '../editor/edit_control'; import { ControlGroupStrings } from '../control_group_strings'; import { useChildEmbeddable } from '../../hooks/use_child_embeddable'; -import { TIME_SLIDER_CONTROL } from '../../../common'; import { controlGroupReducers } from '../state/control_group_reducers'; import { ControlGroupContainer } from '..'; @@ -93,12 +89,9 @@ export const ControlFrame = ({ ControlGroupContainer >(); + const viewMode = select((state) => state.explicitInput.viewMode); const controlStyle = select((state) => state.explicitInput.controlStyle); - - // Controls Services Context - const { - overlays: { openConfirm }, - } = pluginServices.getServices(); + const disabledActions = select((state) => state.explicitInput.disabledActions); const embeddable = useChildEmbeddable({ untilEmbeddableLoaded: controlGroup.untilEmbeddableLoaded.bind(controlGroup), @@ -126,36 +119,6 @@ export const ControlFrame = ({ }; }, [embeddable, embeddableRoot]); - const floatingActions = ( - <> - {!fatalError && embeddableType !== TIME_SLIDER_CONTROL && ( - - - - )} - - - openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { - confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), - cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), - title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), - buttonColor: 'danger', - }).then((confirmed) => { - if (confirmed) { - controlGroup.removeEmbeddable(embeddableId); - } - }) - } - iconType="cross" - color="danger" - /> - - - ); - const embeddableParentClassNames = classNames('controlFrame__control', { 'controlFrame--twoLine': controlStyle === 'twoLine', 'controlFrame--oneLine': controlStyle === 'oneLine', @@ -219,7 +182,9 @@ export const ControlFrame = ({ 'controlFrameFloatingActions--twoLine': usingTwoLineLayout, 'controlFrameFloatingActions--oneLine': !usingTwoLineLayout, })} - actions={floatingActions} + viewMode={viewMode} + embeddable={embeddable} + disabledActions={disabledActions} isEnabled={embeddable && enableActions} > ; -} - -export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => { - // Controls Services Context - const { - overlays: { openFlyout, openConfirm }, - controls: { getControlFactory }, - theme: { theme$ }, - } = pluginServices.getServices(); - // Redux embeddable container Context - const reduxContext = useControlGroupContainerContext(); - const { - embeddableInstance: controlGroup, - actions: { setControlWidth, setControlGrow }, - useEmbeddableSelector, - useEmbeddableDispatch, - } = reduxContext; - const dispatch = useEmbeddableDispatch(); - - // current state - const panels = useEmbeddableSelector((state) => state.explicitInput.panels); - - // keep up to date ref of latest panel state for comparison when closing editor. - const latestPanelState = useRef(panels[embeddableId]); - useEffect(() => { - latestPanelState.current = panels[embeddableId]; - }, [panels, embeddableId]); - - const editControl = async () => { - const ControlsServicesProvider = pluginServices.getContextProvider(); - const embeddable = (await controlGroup.untilEmbeddableLoaded( - embeddableId - )) as ControlEmbeddable; - - const initialInputPromise = new Promise((resolve, reject) => { - const panel = panels[embeddableId]; - let factory = getControlFactory(panel.type); - if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); - - let inputToReturn: Partial = {}; - - let removed = false; - const onCancel = (ref: OverlayRef) => { - if ( - removed || - (isEqual(latestPanelState.current.explicitInput, { - ...panel.explicitInput, - ...inputToReturn, - }) && - isEqual(latestPanelState.current.width, panel.width) && - isEqual(latestPanelState.current.grow, panel.grow)) - ) { - reject(); - ref.close(); - return; - } - openConfirm(ControlGroupStrings.management.discardChanges.getSubtitle(), { - confirmButtonText: ControlGroupStrings.management.discardChanges.getConfirm(), - cancelButtonText: ControlGroupStrings.management.discardChanges.getCancel(), - title: ControlGroupStrings.management.discardChanges.getTitle(), - buttonColor: 'danger', - }).then((confirmed) => { - if (confirmed) { - dispatch(setControlWidth({ width: panel.width, embeddableId })); - dispatch(setControlGrow({ grow: panel.grow, embeddableId })); - reject(); - ref.close(); - } - }); - }; - - const onSave = (ref: OverlayRef, type?: string) => { - if (!type) { - reject(); - ref.close(); - return; - } - - // if the control now has a new type, need to replace the old factory with - // one of the correct new type - if (latestPanelState.current.type !== type) { - factory = getControlFactory(type); - if (!factory) throw new EmbeddableFactoryNotFoundError(type); - } - const editableFactory = factory as IEditableControlFactory; - if (editableFactory.presaveTransformFunction) { - inputToReturn = editableFactory.presaveTransformFunction(inputToReturn, embeddable); - } - resolve({ type, controlInput: inputToReturn }); - ref.close(); - }; - - const ReduxWrapper = controlGroup.getReduxEmbeddableTools().Wrapper; - - const flyoutInstance = openFlyout( - toMountPoint( - - - onCancel(flyoutInstance)} - updateTitle={(newTitle) => (inputToReturn.title = newTitle)} - setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)} - updateWidth={(newWidth) => - dispatch(setControlWidth({ width: newWidth, embeddableId })) - } - updateGrow={(grow) => dispatch(setControlGrow({ grow, embeddableId }))} - onTypeEditorChange={(partialInput) => { - inputToReturn = { ...inputToReturn, ...partialInput }; - }} - onSave={(type) => onSave(flyoutInstance, type)} - removeControl={() => { - openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { - confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), - cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), - title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), - buttonColor: 'danger', - }).then((confirmed) => { - if (confirmed) { - controlGroup.removeEmbeddable(embeddableId); - removed = true; - flyoutInstance.close(); - } - }); - }} - /> - - , - { theme$ } - ), - { - 'aria-label': ControlGroupStrings.manageControl.getFlyoutEditTitle(), - outsideClickCloses: false, - onClose: (flyout) => { - onCancel(flyout); - setFlyoutRef(undefined); - }, - } - ); - setFlyoutRef(flyoutInstance); - }); - - initialInputPromise.then( - async (promise) => { - await controlGroup.replaceEmbeddable(embeddable.id, promise.controlInput, promise.type); - }, - () => {} // swallow promise rejection because it can be part of normal flow - ); - }; - - return ( - editControl()} - color="text" - /> - ); -}; diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_helpers.ts b/src/plugins/controls/public/control_group/embeddable/control_group_helpers.ts index 1afcdc539bf87..7318a489feac9 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_helpers.ts +++ b/src/plugins/controls/public/control_group/embeddable/control_group_helpers.ts @@ -6,9 +6,13 @@ * Side Public License, v 1. */ -import { ControlsPanels } from '../types'; -import { pluginServices } from '../../services'; +import { type IEmbeddable } from '@kbn/embeddable-plugin/public'; + import { getDataControlFieldRegistry } from '../editor/data_control_editor_tools'; +import { type ControlGroupContainer } from './control_group_container'; +import { pluginServices } from '../../services'; +import { CONTROL_GROUP_TYPE } from '../types'; +import { ControlsPanels } from '../types'; export const getNextPanelOrder = (panels?: ControlsPanels) => { let nextOrder = 0; @@ -34,3 +38,7 @@ export const getCompatibleControlType = async ({ const field = fieldRegistry[fieldName]; return field.compatibleControlTypes[0]; }; + +export const isControlGroup = (embeddable: IEmbeddable): embeddable is ControlGroupContainer => { + return embeddable.isContainer && embeddable.type === CONTROL_GROUP_TYPE; +}; diff --git a/src/plugins/controls/public/control_group/index.ts b/src/plugins/controls/public/control_group/index.ts index 1967a8074beab..745e41ec474b1 100644 --- a/src/plugins/controls/public/control_group/index.ts +++ b/src/plugins/controls/public/control_group/index.ts @@ -14,6 +14,8 @@ export type { ControlGroupInput, ControlGroupOutput } from './types'; export { CONTROL_GROUP_TYPE } from './types'; export { ControlGroupContainerFactory } from './embeddable/control_group_container_factory'; +export { ACTION_EDIT_CONTROL, ACTION_DELETE_CONTROL } from './actions'; + export { type AddDataControlProps, type AddOptionsListControlProps, diff --git a/src/plugins/controls/public/index.ts b/src/plugins/controls/public/index.ts index ff0f139b27a75..1e9df544bd1c9 100644 --- a/src/plugins/controls/public/index.ts +++ b/src/plugins/controls/public/index.ts @@ -58,6 +58,8 @@ export { LazyControlGroupRenderer, useControlGroupContainerContext, type ControlGroupRendererProps, + ACTION_DELETE_CONTROL, + ACTION_EDIT_CONTROL, } from './control_group'; export function plugin() { diff --git a/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx b/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx index 2292555316b82..8465016ce4402 100644 --- a/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx +++ b/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx @@ -20,8 +20,8 @@ import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL, } from '../../../common/options_list/types'; -import { ControlEmbeddable, DataControlField, IEditableControlFactory } from '../../types'; import { OptionsListEditorOptions } from '../components/options_list_editor_options'; +import { ControlEmbeddable, DataControlField, IEditableControlFactory } from '../../types'; export class OptionsListEmbeddableFactory implements EmbeddableFactoryDefinition, IEditableControlFactory @@ -48,8 +48,11 @@ export class OptionsListEmbeddableFactory ((newInput.fieldName && !deepEqual(newInput.fieldName, embeddable.getInput().fieldName)) || (newInput.dataViewId && !deepEqual(newInput.dataViewId, embeddable.getInput().dataViewId))) ) { - // if the field name or data view id has changed in this editing session, selected options are invalid, so reset them. - newInput.selectedOptions = []; + // if the field name or data view id has changed in this editing session, reset all selections + newInput.selectedOptions = undefined; + newInput.existsSelected = undefined; + newInput.exclude = undefined; + newInput.sort = undefined; } return newInput; }; @@ -67,7 +70,7 @@ export class OptionsListEmbeddableFactory public controlEditorOptionsComponent = OptionsListEditorOptions; - public isEditable = () => Promise.resolve(false); + public isEditable = () => Promise.resolve(true); public getDisplayName = () => i18n.translate('controls.optionsList.displayName', { diff --git a/src/plugins/controls/public/plugin.ts b/src/plugins/controls/public/plugin.ts index 00673b79d1d2a..85a892c9eea83 100644 --- a/src/plugins/controls/public/plugin.ts +++ b/src/plugins/controls/public/plugin.ts @@ -7,7 +7,7 @@ */ import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; -import { EmbeddableFactory } from '@kbn/embeddable-plugin/public'; +import { EmbeddableFactory, PANEL_HOVER_TRIGGER } from '@kbn/embeddable-plugin/public'; import { ControlGroupContainerFactory, @@ -28,7 +28,6 @@ import { IEditableControlFactory, ControlInput, } from './types'; - export class ControlsPlugin implements Plugin< @@ -113,10 +112,21 @@ export class ControlsPlugin } public start(coreStart: CoreStart, startPlugins: ControlsPluginStartDeps): ControlsPluginStart { - this.startControlsKibanaServices(coreStart, startPlugins); + this.startControlsKibanaServices(coreStart, startPlugins).then(async () => { + const { uiActions } = startPlugins; - const { getControlFactory, getControlTypes } = controlsService; + const { DeleteControlAction } = await import('./control_group/actions/delete_control_action'); + const deleteControlAction = new DeleteControlAction(); + uiActions.registerAction(deleteControlAction); + uiActions.attachAction(PANEL_HOVER_TRIGGER, deleteControlAction.id); + const { EditControlAction } = await import('./control_group/actions/edit_control_action'); + const editControlAction = new EditControlAction(deleteControlAction); + uiActions.registerAction(editControlAction); + uiActions.attachAction(PANEL_HOVER_TRIGGER, editControlAction.id); + }); + + const { getControlFactory, getControlTypes } = controlsService; return { getControlFactory, getControlTypes, diff --git a/src/plugins/controls/public/range_slider/embeddable/range_slider_embeddable_factory.tsx b/src/plugins/controls/public/range_slider/embeddable/range_slider_embeddable_factory.tsx index 730b0d91bbe61..2a86d5c186fc2 100644 --- a/src/plugins/controls/public/range_slider/embeddable/range_slider_embeddable_factory.tsx +++ b/src/plugins/controls/public/range_slider/embeddable/range_slider_embeddable_factory.tsx @@ -41,7 +41,7 @@ export class RangeSliderEmbeddableFactory public canCreateNew = () => false; - public isEditable = () => Promise.resolve(false); + public isEditable = () => Promise.resolve(true); public async create(initialInput: RangeSliderEmbeddableInput, parent?: IContainer) { const reduxEmbeddablePackage = await lazyLoadReduxEmbeddablePackage(); diff --git a/src/plugins/controls/public/services/plugin_services.ts b/src/plugins/controls/public/services/plugin_services.ts index 20950d42df516..805382254130a 100644 --- a/src/plugins/controls/public/services/plugin_services.ts +++ b/src/plugins/controls/public/services/plugin_services.ts @@ -34,12 +34,12 @@ export const providers: PluginServiceProviders< controls: new PluginServiceProvider(controlsServiceFactory), data: new PluginServiceProvider(dataServiceFactory), dataViews: new PluginServiceProvider(dataViewsServiceFactory), + embeddable: new PluginServiceProvider(embeddableServiceFactory), http: new PluginServiceProvider(httpServiceFactory), optionsList: new PluginServiceProvider(optionsListServiceFactory, ['data', 'http']), overlays: new PluginServiceProvider(overlaysServiceFactory), settings: new PluginServiceProvider(settingsServiceFactory), theme: new PluginServiceProvider(themeServiceFactory), - embeddable: new PluginServiceProvider(embeddableServiceFactory), unifiedSearch: new PluginServiceProvider(unifiedSearchServiceFactory), }; diff --git a/src/plugins/controls/public/time_slider/embeddable/time_slider_embeddable_factory.tsx b/src/plugins/controls/public/time_slider/embeddable/time_slider_embeddable_factory.tsx index b062bcc4370fe..d3f3418104328 100644 --- a/src/plugins/controls/public/time_slider/embeddable/time_slider_embeddable_factory.tsx +++ b/src/plugins/controls/public/time_slider/embeddable/time_slider_embeddable_factory.tsx @@ -35,6 +35,7 @@ export class TimeSliderEmbeddableFactory public isFieldCompatible = () => false; public isEditable = () => Promise.resolve(false); + public canCreateNew = () => false; public getDisplayName = () => i18n.translate('controls.timeSlider.displayName', { diff --git a/src/plugins/controls/public/types.ts b/src/plugins/controls/public/types.ts index 17608ee7bef8d..6e26440c1410d 100644 --- a/src/plugins/controls/public/types.ts +++ b/src/plugins/controls/public/types.ts @@ -7,6 +7,7 @@ */ import { ReactNode } from 'react'; + import { Filter } from '@kbn/es-query'; import { EmbeddableFactory, @@ -15,9 +16,11 @@ import { EmbeddableStart, IEmbeddable, } from '@kbn/embeddable-plugin/public'; +import { UiActionsStart } from '@kbn/ui-actions-plugin/public'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { DataViewField, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; + import { ControlInput } from '../common/types'; import { ControlsServiceType } from './services/controls/types'; @@ -86,10 +89,11 @@ export interface ControlsPluginSetupDeps { embeddable: EmbeddableSetup; } export interface ControlsPluginStartDeps { - data: DataPublicPluginStart; - unifiedSearch: UnifiedSearchPublicPluginStart; + uiActions: UiActionsStart; embeddable: EmbeddableStart; + data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; + unifiedSearch: UnifiedSearchPublicPluginStart; } // re-export from common diff --git a/src/plugins/controls/tsconfig.json b/src/plugins/controls/tsconfig.json index 5029897be467b..5da6923aaaf5d 100644 --- a/src/plugins/controls/tsconfig.json +++ b/src/plugins/controls/tsconfig.json @@ -34,6 +34,7 @@ "@kbn/storybook", "@kbn/ui-theme", "@kbn/safer-lodash-set", + "@kbn/ui-actions-plugin", ], "exclude": [ "target/**/*", diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/_dashboard_grid.scss b/src/plugins/dashboard/public/dashboard_container/component/grid/_dashboard_grid.scss index 450df53c0990a..940accfa4a545 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/_dashboard_grid.scss +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/_dashboard_grid.scss @@ -54,10 +54,18 @@ width: 100% !important; /* 1 */ top: 0 !important; /* 1 */ left: 0 !important; /* 1 */ + padding: $euiSizeS; // Altered panel styles can be found in ../panel } +// Remove padding in fullscreen mode +.kbnAppWrapper--hiddenChrome { + .dshDashboardGrid__item--expanded { + padding: 0; + } +} + // REACT-GRID .react-grid-item { diff --git a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx index 56555b2406395..86ef728eebd34 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/grid/dashboard_grid.tsx @@ -68,14 +68,13 @@ function ResponsiveGrid({ }); const MARGINS = useMargins ? 8 : 0; - // We can't take advantage of isDraggable or isResizable due to performance concerns: - // https://github.com/STRML/react-grid-layout/issues/240 + return ( { }, {} as { [key: string]: DashboardPanelState } ); + // onLayoutChange gets called by react grid layout a lot more than it should, so only dispatch the updated panels if the layout has actually changed if (!getPanelLayoutsAreEqual(panels, updatedPanels)) { dispatch(setPanels(updatedPanels)); diff --git a/src/plugins/dashboard/public/dashboard_container/component/panel/_dashboard_panel.scss b/src/plugins/dashboard/public/dashboard_container/component/panel/_dashboard_panel.scss index f2d017e54dc7b..f04e5e29d960b 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/panel/_dashboard_panel.scss +++ b/src/plugins/dashboard/public/dashboard_container/component/panel/_dashboard_panel.scss @@ -5,10 +5,8 @@ */ // LAYOUT MODES - // Adjust borders/etc... for non-spaced out and expanded panels -.dshLayout-withoutMargins, -.dshDashboardGrid__item--expanded { +.dshLayout-withoutMargins { .embPanel { box-shadow: none; border-radius: 0; @@ -21,4 +19,9 @@ .embPanel { border-color: transparent; } + + .embPanel--dragHandle:hover { + background-color: unset; + cursor: default; + } } diff --git a/src/plugins/data/server/saved_objects/query.ts b/src/plugins/data/server/saved_objects/query.ts index 0e11bf7a9f2e0..c32a13e85888a 100644 --- a/src/plugins/data/server/saved_objects/query.ts +++ b/src/plugins/data/server/saved_objects/query.ts @@ -33,7 +33,10 @@ export const querySavedObjectType: SavedObjectsType = { title: { type: 'text' }, description: { type: 'text' }, query: { - properties: { language: { type: 'keyword' }, query: { type: 'keyword', index: false } }, + dynamic: false, + properties: { + language: { type: 'keyword' }, + }, }, filters: { dynamic: false, diff --git a/src/plugins/embeddable/public/bootstrap.ts b/src/plugins/embeddable/public/bootstrap.ts index 242fa37b75dff..54bcb4c5f647e 100644 --- a/src/plugins/embeddable/public/bootstrap.ts +++ b/src/plugins/embeddable/public/bootstrap.ts @@ -15,6 +15,7 @@ import { selectRangeTrigger, valueClickTrigger, cellValueTrigger, + panelHoverTrigger, } from './lib'; /** @@ -23,6 +24,7 @@ import { */ export const bootstrap = (uiActions: UiActionsSetup) => { uiActions.registerTrigger(contextMenuTrigger); + uiActions.registerTrigger(panelHoverTrigger); uiActions.registerTrigger(panelBadgeTrigger); uiActions.registerTrigger(panelNotificationTrigger); uiActions.registerTrigger(selectRangeTrigger); diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 18e5484b8fa4d..9d89aab704766 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -89,6 +89,8 @@ export { isFilterableEmbeddable, shouldFetch$, shouldRefreshFilterCompareOptions, + PANEL_HOVER_TRIGGER, + panelHoverTrigger, } from './lib'; export { AttributeService, ATTRIBUTE_SERVICE_KEY } from './lib/attribute_service'; diff --git a/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss b/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss index 80814427c9371..5656bb7566775 100644 --- a/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss +++ b/src/plugins/embeddable/public/lib/panel/_embeddable_panel.scss @@ -136,10 +136,10 @@ // EDITING MODE .embPanel--editing { - border-style: dashed !important; - border-color: $euiColorMediumShade !important; + outline-style: dashed !important; + outline-color: $euiColorMediumShade !important; transition: all $euiAnimSpeedFast $euiAnimSlightResistance; - border-width: $euiBorderWidthThin; + outline-width: $euiBorderWidthThin; &:hover, &:focus { diff --git a/src/plugins/embeddable/public/lib/triggers/triggers.ts b/src/plugins/embeddable/public/lib/triggers/triggers.ts index 529b6bfede65b..94c09c2d79376 100644 --- a/src/plugins/embeddable/public/lib/triggers/triggers.ts +++ b/src/plugins/embeddable/public/lib/triggers/triggers.ts @@ -78,6 +78,17 @@ export const contextMenuTrigger: Trigger = { }), }; +export const PANEL_HOVER_TRIGGER = 'PANEL_HOVER_TRIGGER'; +export const panelHoverTrigger: Trigger = { + id: PANEL_HOVER_TRIGGER, + title: i18n.translate('embeddableApi.panelHoverTrigger.title', { + defaultMessage: 'Panel hover', + }), + description: i18n.translate('embeddableApi.panelHoverTrigger.description', { + defaultMessage: "A new action will be added to the panel's hover menu", + }), +}; + export const PANEL_BADGE_TRIGGER = 'PANEL_BADGE_TRIGGER'; export const panelBadgeTrigger: Trigger = { id: PANEL_BADGE_TRIGGER, diff --git a/src/plugins/presentation_util/kibana.jsonc b/src/plugins/presentation_util/kibana.jsonc index 870c9ff27c62b..1dfa765354cf9 100644 --- a/src/plugins/presentation_util/kibana.jsonc +++ b/src/plugins/presentation_util/kibana.jsonc @@ -12,10 +12,9 @@ "kibanaReact", "embeddable", "expressions", - "dataViews" + "dataViews", + "uiActions" ], - "extraPublicDirs": [ - "common" - ] + "extraPublicDirs": ["common"] } } diff --git a/src/plugins/presentation_util/public/components/floating_actions/floating_actions.scss b/src/plugins/presentation_util/public/components/floating_actions/floating_actions.scss index 8d3df0fa3a0b7..efb3db68db613 100644 --- a/src/plugins/presentation_util/public/components/floating_actions/floating_actions.scss +++ b/src/plugins/presentation_util/public/components/floating_actions/floating_actions.scss @@ -5,7 +5,7 @@ opacity: 0; visibility: hidden; // slower transition on hover leave in case the user accidentally stops hover - transition: visibility .3s, opacity .3s; + transition: visibility $euiAnimSpeedSlow, opacity $euiAnimSpeedSlow; position: absolute; right: $euiSizeXS; @@ -17,7 +17,7 @@ .presentationUtil__floatingActions { opacity: 1; visibility: visible; - transition: visibility .1s, opacity .1s; + transition: visibility $euiAnimSpeedFast, opacity $euiAnimSpeedFast; } } } diff --git a/src/plugins/presentation_util/public/components/floating_actions/floating_actions.tsx b/src/plugins/presentation_util/public/components/floating_actions/floating_actions.tsx index 9f8639cbf04e6..35c92e83035a0 100644 --- a/src/plugins/presentation_util/public/components/floating_actions/floating_actions.tsx +++ b/src/plugins/presentation_util/public/components/floating_actions/floating_actions.tsx @@ -5,29 +5,79 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { FC, ReactElement } from 'react'; - +import React, { FC, ReactElement, useEffect, useState } from 'react'; import classNames from 'classnames'; + +import { IEmbeddable, panelHoverTrigger, PANEL_HOVER_TRIGGER } from '@kbn/embeddable-plugin/public'; +import { Action } from '@kbn/ui-actions-plugin/public'; + +import { pluginServices } from '../../services'; import './floating_actions.scss'; +import { ReduxEmbeddableState } from '../../redux_embeddables'; export interface FloatingActionsProps { - className?: string; - actions?: JSX.Element; children: ReactElement; + + className?: string; isEnabled?: boolean; + embeddable?: IEmbeddable; + viewMode?: ReduxEmbeddableState['explicitInput']['viewMode']; + disabledActions?: ReduxEmbeddableState['explicitInput']['disabledActions']; } export const FloatingActions: FC = ({ - className = '', - actions, - isEnabled, children, + viewMode, + isEnabled, + embeddable, + className = '', + disabledActions, }) => { + const { + uiActions: { getTriggerCompatibleActions }, + } = pluginServices.getServices(); + + const [floatingActions, setFloatingActions] = useState(undefined); + + useEffect(() => { + if (!embeddable) return; + + const getActions = async () => { + const context = { + embeddable, + trigger: panelHoverTrigger, + }; + const actions = (await getTriggerCompatibleActions(PANEL_HOVER_TRIGGER, context)) + .filter((action): action is Action & { MenuItem: React.FC } => { + return action.MenuItem !== undefined && (disabledActions ?? []).indexOf(action.id) === -1; + }) + .sort((a, b) => (a.order || 0) - (b.order || 0)); + if (actions.length > 0) { + setFloatingActions( + <> + {actions.map((action) => + React.createElement(action.MenuItem, { + key: action.id, + context, + }) + )} + + ); + } else { + setFloatingActions(undefined); + } + }; + + getActions(); + }, [embeddable, getTriggerCompatibleActions, viewMode, disabledActions]); + return (
{children} - {isEnabled && ( -
{actions}
+ {isEnabled && floatingActions && ( +
+ {floatingActions} +
)}
); diff --git a/src/plugins/presentation_util/public/mocks.ts b/src/plugins/presentation_util/public/mocks.ts index 8804c21bf6d9c..2a3bdba1a0a7e 100644 --- a/src/plugins/presentation_util/public/mocks.ts +++ b/src/plugins/presentation_util/public/mocks.ts @@ -13,7 +13,9 @@ import { registry } from './services/plugin_services'; import { registerExpressionsLanguage } from '.'; const createStartContract = (coreStart: CoreStart): PresentationUtilPluginStart => { - pluginServices.setRegistry(registry.start({ coreStart, startPlugins: { dataViews: {} } as any })); + pluginServices.setRegistry( + registry.start({ coreStart, startPlugins: { dataViews: {}, uiActions: {} } as any }) + ); const startContract: PresentationUtilPluginStart = { ContextProvider: pluginServices.getContextProvider(), diff --git a/src/plugins/presentation_util/public/services/plugin_services.story.ts b/src/plugins/presentation_util/public/services/plugin_services.story.ts index 8eb38a3dee019..b95b99e1dbca8 100644 --- a/src/plugins/presentation_util/public/services/plugin_services.story.ts +++ b/src/plugins/presentation_util/public/services/plugin_services.story.ts @@ -18,12 +18,14 @@ import { capabilitiesServiceFactory } from './capabilities/capabilities.story'; import { dataViewsServiceFactory } from './data_views/data_views.story'; import { dashboardsServiceFactory } from './dashboards/dashboards.stub'; import { labsServiceFactory } from './labs/labs.story'; +import { uiActionsServiceFactory } from './ui_actions/ui_actions.stub'; export const providers: PluginServiceProviders = { capabilities: new PluginServiceProvider(capabilitiesServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), dataViews: new PluginServiceProvider(dataViewsServiceFactory), dashboards: new PluginServiceProvider(dashboardsServiceFactory), + uiActions: new PluginServiceProvider(uiActionsServiceFactory), }; export const pluginServices = new PluginServices(); diff --git a/src/plugins/presentation_util/public/services/plugin_services.stub.ts b/src/plugins/presentation_util/public/services/plugin_services.stub.ts index 8f69efbcbe0c4..427fbf9a3b6eb 100644 --- a/src/plugins/presentation_util/public/services/plugin_services.stub.ts +++ b/src/plugins/presentation_util/public/services/plugin_services.stub.ts @@ -15,12 +15,14 @@ import { capabilitiesServiceFactory } from './capabilities/capabilities.story'; import { dataViewsServiceFactory } from './data_views/data_views.story'; import { dashboardsServiceFactory } from './dashboards/dashboards.stub'; import { labsServiceFactory } from './labs/labs.story'; +import { uiActionsServiceFactory } from './ui_actions/ui_actions.stub'; export const providers: PluginServiceProviders = { capabilities: new PluginServiceProvider(capabilitiesServiceFactory), labs: new PluginServiceProvider(labsServiceFactory), dataViews: new PluginServiceProvider(dataViewsServiceFactory), dashboards: new PluginServiceProvider(dashboardsServiceFactory), + uiActions: new PluginServiceProvider(uiActionsServiceFactory), }; export const pluginServices = new PluginServices(); diff --git a/src/plugins/presentation_util/public/services/plugin_services.ts b/src/plugins/presentation_util/public/services/plugin_services.ts index 04f36836bd1ff..266912446b63c 100644 --- a/src/plugins/presentation_util/public/services/plugin_services.ts +++ b/src/plugins/presentation_util/public/services/plugin_services.ts @@ -18,6 +18,7 @@ import { PresentationUtilPluginStartDeps } from '../types'; import { capabilitiesServiceFactory } from './capabilities/capabilities_service'; import { dataViewsServiceFactory } from './data_views/data_views_service'; import { dashboardsServiceFactory } from './dashboards/dashboards_service'; +import { uiActionsServiceFactory } from './ui_actions/ui_actions_service'; import { labsServiceFactory } from './labs/labs_service'; import { PresentationUtilServices } from './types'; @@ -29,6 +30,7 @@ export const providers: PluginServiceProviders< labs: new PluginServiceProvider(labsServiceFactory), dataViews: new PluginServiceProvider(dataViewsServiceFactory), dashboards: new PluginServiceProvider(dashboardsServiceFactory), + uiActions: new PluginServiceProvider(uiActionsServiceFactory), }; export const pluginServices = new PluginServices(); diff --git a/src/plugins/presentation_util/public/services/types.ts b/src/plugins/presentation_util/public/services/types.ts index b2d32788b1762..861d4f55068ac 100644 --- a/src/plugins/presentation_util/public/services/types.ts +++ b/src/plugins/presentation_util/public/services/types.ts @@ -10,11 +10,13 @@ import { PresentationLabsService } from './labs/types'; import { PresentationDashboardsService } from './dashboards/types'; import { PresentationCapabilitiesService } from './capabilities/types'; import { PresentationDataViewsService } from './data_views/types'; +import { PresentationUiActionsService } from './ui_actions/types'; export interface PresentationUtilServices { + capabilities: PresentationCapabilitiesService; dashboards: PresentationDashboardsService; dataViews: PresentationDataViewsService; - capabilities: PresentationCapabilitiesService; + uiActions: PresentationUiActionsService; labs: PresentationLabsService; } diff --git a/src/plugins/presentation_util/public/services/ui_actions/types.ts b/src/plugins/presentation_util/public/services/ui_actions/types.ts new file mode 100644 index 0000000000000..141be2dfa20d0 --- /dev/null +++ b/src/plugins/presentation_util/public/services/ui_actions/types.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { UiActionsStart } from '@kbn/ui-actions-plugin/public'; + +export interface PresentationUiActionsService { + getTriggerCompatibleActions: UiActionsStart['getTriggerCompatibleActions']; +} diff --git a/src/plugins/presentation_util/public/services/ui_actions/ui_actions.stub.ts b/src/plugins/presentation_util/public/services/ui_actions/ui_actions.stub.ts new file mode 100644 index 0000000000000..449cd7e8184ce --- /dev/null +++ b/src/plugins/presentation_util/public/services/ui_actions/ui_actions.stub.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks'; + +import { PluginServiceFactory } from '../create'; +import { PresentationUiActionsService } from './types'; + +type CapabilitiesServiceFactory = PluginServiceFactory; + +export const uiActionsServiceFactory: CapabilitiesServiceFactory = () => { + const { getTriggerCompatibleActions } = uiActionsPluginMock.createStartContract(); + return { getTriggerCompatibleActions }; +}; diff --git a/src/plugins/presentation_util/public/services/ui_actions/ui_actions_service.ts b/src/plugins/presentation_util/public/services/ui_actions/ui_actions_service.ts new file mode 100644 index 0000000000000..dc6b16240faba --- /dev/null +++ b/src/plugins/presentation_util/public/services/ui_actions/ui_actions_service.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PresentationUtilPluginStartDeps } from '../../types'; +import { PresentationUiActionsService } from './types'; +import { KibanaPluginServiceFactory } from '../create'; + +export type UiActionsServiceFactory = KibanaPluginServiceFactory< + PresentationUiActionsService, + PresentationUtilPluginStartDeps +>; + +export const uiActionsServiceFactory: UiActionsServiceFactory = ({ startPlugins }) => { + const { + uiActions: { getTriggerCompatibleActions }, + } = startPlugins; + return { + getTriggerCompatibleActions, + }; +}; diff --git a/src/plugins/presentation_util/public/types.ts b/src/plugins/presentation_util/public/types.ts index 277c0960b269e..3b2785da82c0e 100644 --- a/src/plugins/presentation_util/public/types.ts +++ b/src/plugins/presentation_util/public/types.ts @@ -7,6 +7,7 @@ */ import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { UiActionsStart } from '@kbn/ui-actions-plugin/public/plugin'; import { registerExpressionsLanguage } from '.'; import { PresentationLabsService } from './services/labs/types'; @@ -23,4 +24,5 @@ export interface PresentationUtilPluginSetupDeps {} export interface PresentationUtilPluginStartDeps { dataViews: DataViewsPublicPluginStart; + uiActions: UiActionsStart; } diff --git a/src/plugins/presentation_util/tsconfig.json b/src/plugins/presentation_util/tsconfig.json index adc524125392e..394337e477ef9 100644 --- a/src/plugins/presentation_util/tsconfig.json +++ b/src/plugins/presentation_util/tsconfig.json @@ -28,6 +28,7 @@ "@kbn/react-field", "@kbn/config-schema", "@kbn/storybook", + "@kbn/ui-actions-plugin", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/cloud_security_posture/common/constants.ts b/x-pack/plugins/cloud_security_posture/common/constants.ts index a7d5807ed66a1..39a1cbf2fd320 100644 --- a/x-pack/plugins/cloud_security_posture/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/common/constants.ts @@ -26,9 +26,23 @@ export const BENCHMARK_SCORE_INDEX_TEMPLATE_NAME = 'logs-cloud_security_posture. export const BENCHMARK_SCORE_INDEX_PATTERN = 'logs-cloud_security_posture.scores-*'; export const BENCHMARK_SCORE_INDEX_DEFAULT_NS = 'logs-cloud_security_posture.scores-default'; +export const VULNERABILITIES_INDEX_NAME = 'logs-cloud_security_posture.vulnerabilities'; +export const VULNERABILITIES_INDEX_PATTERN = 'logs-cloud_security_posture.vulnerabilities-default*'; +export const VULNERABILITIES_INDEX_DEFAULT_NS = + 'logs-cloud_security_posture.vulnerabilities-default'; + +export const LATEST_VULNERABILITIES_INDEX_TEMPLATE_NAME = + 'logs-cloud_security_posture.vulnerabilities_latest'; +export const LATEST_VULNERABILITIES_INDEX_PATTERN = + 'logs-cloud_security_posture.vulnerabilities_latest-*'; +export const LATEST_VULNERABILITIES_INDEX_DEFAULT_NS = + 'logs-cloud_security_posture.vulnerabilities_latest-default'; + export const CSP_INGEST_TIMESTAMP_PIPELINE = 'cloud_security_posture_add_ingest_timestamp_pipeline'; export const CSP_LATEST_FINDINGS_INGEST_TIMESTAMP_PIPELINE = 'cloud_security_posture_latest_index_add_ingest_timestamp_pipeline'; +export const CSP_LATEST_VULNERABILITIES_INGEST_TIMESTAMP_PIPELINE = + 'cloud_security_posture_latest_vulnerabilities_index_add_ingest_timestamp_pipeline'; export const RULE_PASSED = `passed`; export const RULE_FAILED = `failed`; diff --git a/x-pack/plugins/cloud_security_posture/server/create_indices/create_indices.ts b/x-pack/plugins/cloud_security_posture/server/create_indices/create_indices.ts index ea93ae458eb2b..f040980dc59ad 100644 --- a/x-pack/plugins/cloud_security_posture/server/create_indices/create_indices.ts +++ b/x-pack/plugins/cloud_security_posture/server/create_indices/create_indices.ts @@ -7,32 +7,45 @@ import type { ElasticsearchClient, Logger } from '@kbn/core/server'; import { errors } from '@elastic/elasticsearch'; import type { MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types'; + import { BENCHMARK_SCORE_INDEX_DEFAULT_NS, BENCHMARK_SCORE_INDEX_PATTERN, BENCHMARK_SCORE_INDEX_TEMPLATE_NAME, CLOUD_SECURITY_POSTURE_PACKAGE_NAME, - FINDINGS_INDEX_NAME, - LATEST_FINDINGS_INDEX_DEFAULT_NS, - LATEST_FINDINGS_INDEX_PATTERN, - LATEST_FINDINGS_INDEX_TEMPLATE_NAME, } from '../../common/constants'; import { createPipelineIfNotExists } from './create_processor'; import { benchmarkScoreMapping } from './benchmark_score_mapping'; import { latestFindingsPipelineIngestConfig, scorePipelineIngestConfig } from './ingest_pipelines'; +import { latestIndexConfigs } from './latest_indices'; +import { IndexConfig, IndexTemplateParams } from './types'; // TODO: Add integration tests - export const initializeCspIndices = async (esClient: ElasticsearchClient, logger: Logger) => { - await Promise.all([ + await Promise.allSettled([ createPipelineIfNotExists(esClient, scorePipelineIngestConfig, logger), createPipelineIfNotExists(esClient, latestFindingsPipelineIngestConfig, logger), ]); - return Promise.all([ - createLatestFindingsIndex(esClient, logger), + const [ + createFindingsLatestIndexPromise, + createVulnerabilitiesLatestIndexPromise, + createBenchmarkScoreIndexPromise, + ] = await Promise.allSettled([ + createLatestIndex(esClient, logger, latestIndexConfigs.findings), + createLatestIndex(esClient, logger, latestIndexConfigs.vulnerabilities), createBenchmarkScoreIndex(esClient, logger), ]); + + if (createFindingsLatestIndexPromise.status === 'rejected') { + logger.error(createFindingsLatestIndexPromise.reason); + } + if (createVulnerabilitiesLatestIndexPromise.status === 'rejected') { + logger.error(createVulnerabilitiesLatestIndexPromise.reason); + } + if (createBenchmarkScoreIndexPromise.status === 'rejected') { + logger.error(createBenchmarkScoreIndexPromise.reason); + } }; const createBenchmarkScoreIndex = async (esClient: ElasticsearchClient, logger: Logger) => { @@ -76,67 +89,53 @@ const createBenchmarkScoreIndex = async (esClient: ElasticsearchClient, logger: ); } } catch (e) { - logger.error( + logger.error(e); + throw Error( `Failed to upsert index template [Template: ${BENCHMARK_SCORE_INDEX_TEMPLATE_NAME}]` ); - logger.error(e); } }; -const createLatestFindingsIndex = async (esClient: ElasticsearchClient, logger: Logger) => { +const createLatestIndex = async ( + esClient: ElasticsearchClient, + logger: Logger, + indexConfig: IndexConfig +) => { + const { indexName, indexPattern, indexTemplateName, indexDefaultName } = indexConfig; try { - // Deletes old assets from previous versions as part of upgrade process - const INDEX_TEMPLATE_V830 = 'cloud_security_posture.findings_latest'; - await deleteIndexTemplateSafe(esClient, logger, INDEX_TEMPLATE_V830); - // We want that our latest findings index template would be identical to the findings index template - const findingsIndexTemplateResponse = await esClient.indices.getIndexTemplate({ - name: FINDINGS_INDEX_NAME, + const indexTemplateResponse = await esClient.indices.getIndexTemplate({ + name: indexName, }); + // eslint-disable-next-line @typescript-eslint/naming-convention const { template, composed_of, _meta } = - findingsIndexTemplateResponse.index_templates[0].index_template; + indexTemplateResponse.index_templates[0].index_template; - // We always want to keep the index template updated - await esClient.indices.putIndexTemplate({ - name: LATEST_FINDINGS_INDEX_TEMPLATE_NAME, - index_patterns: LATEST_FINDINGS_INDEX_PATTERN, - priority: 500, - template: { - mappings: template?.mappings, - settings: { - ...template?.settings, - default_pipeline: latestFindingsPipelineIngestConfig.id, - lifecycle: { - name: '', - }, - }, - aliases: template?.aliases, - }, + const indexTemplateParams = { + template, + composedOf: composed_of, _meta, - composed_of, - }); + indexTemplateName, + indexPattern, + }; - const result = await createIndexSafe(esClient, logger, LATEST_FINDINGS_INDEX_DEFAULT_NS); + // We always want to keep the index template updated + await updateIndexTemplate(esClient, logger, indexTemplateParams); + + const result = await createIndexSafe(esClient, logger, indexDefaultName); if (result === 'already-exists') { // Make sure mappings are up-to-date const simulateResponse = await esClient.indices.simulateTemplate({ - name: LATEST_FINDINGS_INDEX_TEMPLATE_NAME, + name: indexTemplateName, }); - await updateIndexSafe( - esClient, - logger, - LATEST_FINDINGS_INDEX_DEFAULT_NS, - simulateResponse.template.mappings - ); + await updateIndexSafe(esClient, logger, indexDefaultName, simulateResponse.template.mappings); } } catch (e) { - logger.error( - `Failed to upsert index template [Template: ${LATEST_FINDINGS_INDEX_TEMPLATE_NAME}]` - ); logger.error(e); + throw Error(`Failed to upsert index template [Template: ${indexTemplateName}]`); } }; @@ -191,6 +190,38 @@ const createIndexSafe = async (esClient: ElasticsearchClient, logger: Logger, in } }; +const updateIndexTemplate = async ( + esClient: ElasticsearchClient, + logger: Logger, + indexTemplateParams: IndexTemplateParams +) => { + const { indexTemplateName, indexPattern, template, composedOf, _meta } = indexTemplateParams; + try { + await esClient.indices.putIndexTemplate({ + name: indexTemplateName, + index_patterns: indexPattern, + priority: 500, + template: { + mappings: template?.mappings, + settings: { + ...template?.settings, + default_pipeline: latestFindingsPipelineIngestConfig.id, + lifecycle: { + name: '', + }, + }, + aliases: template?.aliases, + }, + _meta, + composed_of: composedOf, + }); + logger.info(`Updated index template successfully [Name: ${indexTemplateName}]`); + } catch (e) { + logger.error(`Failed to update index template [Name: ${indexTemplateName}]`); + logger.error(e); + } +}; + const updateIndexSafe = async ( esClient: ElasticsearchClient, logger: Logger, diff --git a/x-pack/plugins/cloud_security_posture/server/create_indices/ingest_pipelines.ts b/x-pack/plugins/cloud_security_posture/server/create_indices/ingest_pipelines.ts index 7948fd9d90578..2073f7b146275 100644 --- a/x-pack/plugins/cloud_security_posture/server/create_indices/ingest_pipelines.ts +++ b/x-pack/plugins/cloud_security_posture/server/create_indices/ingest_pipelines.ts @@ -9,6 +9,7 @@ import type { IngestPutPipelineRequest } from '@elastic/elasticsearch/lib/api/ty import { CSP_INGEST_TIMESTAMP_PIPELINE, CSP_LATEST_FINDINGS_INGEST_TIMESTAMP_PIPELINE, + CSP_LATEST_VULNERABILITIES_INGEST_TIMESTAMP_PIPELINE, } from '../../common/constants'; export const scorePipelineIngestConfig: IngestPutPipelineRequest = { @@ -52,3 +53,24 @@ export const latestFindingsPipelineIngestConfig: IngestPutPipelineRequest = { }, ], }; + +export const latestVulnerabilitiesPipelineIngestConfig: IngestPutPipelineRequest = { + id: CSP_LATEST_VULNERABILITIES_INGEST_TIMESTAMP_PIPELINE, + description: 'Pipeline for cloudbeat latest vulnerabilities index', + processors: [ + { + set: { + field: 'event.ingested', + value: '{{_ingest.timestamp}}', + }, + }, + ], + on_failure: [ + { + set: { + field: 'error.message', + value: '{{ _ingest.on_failure_message }}', + }, + }, + ], +}; diff --git a/x-pack/plugins/cloud_security_posture/server/create_indices/latest_indices.ts b/x-pack/plugins/cloud_security_posture/server/create_indices/latest_indices.ts new file mode 100644 index 0000000000000..5bfcb4a504c37 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/create_indices/latest_indices.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + FINDINGS_INDEX_NAME, + LATEST_FINDINGS_INDEX_PATTERN, + LATEST_FINDINGS_INDEX_TEMPLATE_NAME, + LATEST_FINDINGS_INDEX_DEFAULT_NS, + VULNERABILITIES_INDEX_NAME, + LATEST_VULNERABILITIES_INDEX_PATTERN, + LATEST_VULNERABILITIES_INDEX_TEMPLATE_NAME, + LATEST_VULNERABILITIES_INDEX_DEFAULT_NS, +} from '../../common/constants'; +import { LatestIndexConfig } from './types'; + +export const latestIndexConfigs: LatestIndexConfig = { + findings: { + indexName: FINDINGS_INDEX_NAME, + indexPattern: LATEST_FINDINGS_INDEX_PATTERN, + indexTemplateName: LATEST_FINDINGS_INDEX_TEMPLATE_NAME, + indexDefaultName: LATEST_FINDINGS_INDEX_DEFAULT_NS, + }, + vulnerabilities: { + indexName: VULNERABILITIES_INDEX_NAME, + indexPattern: LATEST_VULNERABILITIES_INDEX_PATTERN, + indexTemplateName: LATEST_VULNERABILITIES_INDEX_TEMPLATE_NAME, + indexDefaultName: LATEST_VULNERABILITIES_INDEX_DEFAULT_NS, + }, +}; diff --git a/x-pack/plugins/cloud_security_posture/server/create_indices/types.ts b/x-pack/plugins/cloud_security_posture/server/create_indices/types.ts new file mode 100644 index 0000000000000..190114b3bf055 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/create_indices/types.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + IndicesIndexTemplateSummary, + Metadata, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + +export interface LatestIndexConfig { + findings: IndexConfig; + vulnerabilities: IndexConfig; +} + +export interface IndexConfig { + indexName: string; + indexPattern: string; + indexTemplateName: string; + indexDefaultName: string; +} + +export interface IndexTemplateParams { + template: IndicesIndexTemplateSummary | undefined; + composedOf: string[]; + _meta: Metadata | undefined; + indexTemplateName: string; + indexPattern: string; +} diff --git a/x-pack/plugins/cloud_security_posture/server/create_transforms/create_transforms.ts b/x-pack/plugins/cloud_security_posture/server/create_transforms/create_transforms.ts index 2f902d03f62dc..83430d1d9f507 100644 --- a/x-pack/plugins/cloud_security_posture/server/create_transforms/create_transforms.ts +++ b/x-pack/plugins/cloud_security_posture/server/create_transforms/create_transforms.ts @@ -9,11 +9,11 @@ import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/typ import type { ElasticsearchClient, Logger } from '@kbn/core/server'; import { errors } from '@elastic/elasticsearch'; import { latestFindingsTransform } from './latest_findings_transform'; +import { latestVulnerabilitiesTransform } from './latest_vulnerabilities_transforms'; -const LATEST_TRANSFORM_V830 = 'cloud_security_posture.findings_latest-default-0.0.1'; -const LATEST_TRANSFORM_V840 = 'cloud_security_posture.findings_latest-default-8.4.0'; - -const PREVIOUS_TRANSFORMS = [LATEST_TRANSFORM_V830, LATEST_TRANSFORM_V840]; +const LATEST_FINDINGS_TRANSFORM_V830 = 'cloud_security_posture.findings_latest-default-0.0.1'; +const LATEST_FINDINGS_TRANSFORM_V840 = 'cloud_security_posture.findings_latest-default-8.4.0'; +const PREVIOUS_TRANSFORMS = [LATEST_FINDINGS_TRANSFORM_V830, LATEST_FINDINGS_TRANSFORM_V840]; // TODO: Move transforms to integration package export const initializeCspTransforms = async ( @@ -23,6 +23,7 @@ export const initializeCspTransforms = async ( // Deletes old assets from previous versions as part of upgrade process await deletePreviousTransformsVersions(esClient, logger); await initializeTransform(esClient, latestFindingsTransform, logger); + await initializeTransform(esClient, latestVulnerabilitiesTransform, logger); }; export const initializeTransform = async ( diff --git a/x-pack/plugins/cloud_security_posture/server/create_transforms/latest_vulnerabilities_transforms.ts b/x-pack/plugins/cloud_security_posture/server/create_transforms/latest_vulnerabilities_transforms.ts new file mode 100644 index 0000000000000..8fff7e910d471 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/create_transforms/latest_vulnerabilities_transforms.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/types'; +import { + CLOUD_SECURITY_POSTURE_PACKAGE_NAME, + LATEST_VULNERABILITIES_INDEX_DEFAULT_NS, + VULNERABILITIES_INDEX_PATTERN, +} from '../../common/constants'; + +export const latestVulnerabilitiesTransform: TransformPutTransformRequest = { + transform_id: 'cloud_security_posture.vulnerabilities_latest-default-8.8.0', + description: + 'Defines vulnerabilities transformation to view only the latest vulnerability per resource', + source: { + index: VULNERABILITIES_INDEX_PATTERN, + }, + dest: { + index: LATEST_VULNERABILITIES_INDEX_DEFAULT_NS, + }, + frequency: '5m', + sync: { + time: { + field: 'event.ingested', + delay: '60s', + }, + }, + retention_policy: { + time: { + field: '@timestamp', + max_age: '50h', + }, + }, + latest: { + sort: '@timestamp', + unique_key: ['vulnerability.id', 'resource.id', 'package.version'], + }, + _meta: { + package: { + name: CLOUD_SECURITY_POSTURE_PACKAGE_NAME, + }, + managed_by: 'cloud_security_posture', + managed: true, + }, +}; diff --git a/x-pack/plugins/enterprise_search/common/types/analytics.ts b/x-pack/plugins/enterprise_search/common/types/analytics.ts index 3c716a545a9c5..ca6629f214615 100644 --- a/x-pack/plugins/enterprise_search/common/types/analytics.ts +++ b/x-pack/plugins/enterprise_search/common/types/analytics.ts @@ -6,14 +6,10 @@ */ export interface AnalyticsCollection { - event_retention_day_length: number; events_datastream: string; - id: string; name: string; } -export type AnalyticsCollectionDocument = Omit; - export interface AnalyticsEventsIndexExists { exists: boolean; } diff --git a/x-pack/plugins/enterprise_search/common/types/engines.ts b/x-pack/plugins/enterprise_search/common/types/engines.ts index 2c59f74e42f02..cedfe85ba0435 100644 --- a/x-pack/plugins/enterprise_search/common/types/engines.ts +++ b/x-pack/plugins/enterprise_search/common/types/engines.ts @@ -13,7 +13,7 @@ export interface EnterpriseSearchEnginesResponse { size: number; total: number; }; - params: { q?: string; from: number; size: number }; + params: { from: number; q?: string; size: number }; results: EnterpriseSearchEngine[]; } @@ -40,6 +40,7 @@ export interface EnterpriseSearchEngineIndex { export interface EnterpriseSearchEngineFieldCapabilities { created: string; field_capabilities: FieldCapsResponse; + fields?: SchemaField[]; name: string; updated: string; } @@ -47,3 +48,15 @@ export interface EnterpriseSearchSchemaField { field_name: string; field_type: string[]; } + +export interface SchemaFieldIndex { + name: string; + type: string; +} + +export interface SchemaField { + fields: SchemaField[]; + indices: SchemaFieldIndex[]; + name: string; + type: string; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/api/delete_analytics_collection/delete_analytics_collection_api_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/api/delete_analytics_collection/delete_analytics_collection_api_logic.test.ts index c34bf01090f1b..1ad6c18fea3bf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/api/delete_analytics_collection/delete_analytics_collection_api_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/api/delete_analytics_collection/delete_analytics_collection_api_logic.test.ts @@ -20,12 +20,12 @@ describe('DeleteAnalyticsCollectionApiLogic', () => { describe('DeleteAnalyticsCollectionsApiLogic', () => { it('calls the analytics collections list api', async () => { const promise = Promise.resolve(); - const id = 'collection'; + const name = 'collection'; http.delete.mockReturnValue(promise); - const result = deleteAnalyticsCollection({ id }); + const result = deleteAnalyticsCollection({ name }); await nextTick(); expect(http.delete).toHaveBeenCalledWith( - `/internal/enterprise_search/analytics/collections/${id}` + `/internal/enterprise_search/analytics/collections/${name}` ); await expect(result).resolves.toEqual(undefined); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/api/delete_analytics_collection/delete_analytics_collection_api_logic.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/api/delete_analytics_collection/delete_analytics_collection_api_logic.tsx index 1cd07433721d5..2cddffd49dd5d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/api/delete_analytics_collection/delete_analytics_collection_api_logic.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/api/delete_analytics_collection/delete_analytics_collection_api_logic.tsx @@ -12,9 +12,9 @@ import { HttpLogic } from '../../../shared/http'; export type DeleteAnalyticsCollectionApiLogicResponse = void; -export const deleteAnalyticsCollection = async ({ id }: { id: string }) => { +export const deleteAnalyticsCollection = async ({ name }: { name: string }) => { const { http } = HttpLogic.values; - const route = `/internal/enterprise_search/analytics/collections/${id}`; + const route = `/internal/enterprise_search/analytics/collections/${name}`; await http.delete(route); return; diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/api/fetch_analytics_collection/fetch_analytics_collection_api_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/api/fetch_analytics_collection/fetch_analytics_collection_api_logic.test.ts index 84018ec7a7b31..ae21c61a8fad2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/api/fetch_analytics_collection/fetch_analytics_collection_api_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/api/fetch_analytics_collection/fetch_analytics_collection_api_logic.test.ts @@ -19,15 +19,15 @@ describe('FetchAnalyticsCollectionApiLogic', () => { describe('FetchAnalyticsCollectionsApiLogic', () => { it('calls the analytics collections list api', async () => { - const promise = Promise.resolve({ id: 'collection', name: 'result' }); - const id = 'collection'; + const promise = Promise.resolve({ name: 'result' }); + const name = 'collection'; http.get.mockReturnValue(promise); - const result = fetchAnalyticsCollection({ id }); + const result = fetchAnalyticsCollection({ name }); await nextTick(); expect(http.get).toHaveBeenCalledWith( - `/internal/enterprise_search/analytics/collections/${id}` + `/internal/enterprise_search/analytics/collections/${name}` ); - await expect(result).resolves.toEqual({ id: 'collection', name: 'result' }); + await expect(result).resolves.toEqual({ name: 'result' }); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/api/fetch_analytics_collection/fetch_analytics_collection_api_logic.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/api/fetch_analytics_collection/fetch_analytics_collection_api_logic.tsx index 6ec8caf00d400..5aafc82c29e0e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/api/fetch_analytics_collection/fetch_analytics_collection_api_logic.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/api/fetch_analytics_collection/fetch_analytics_collection_api_logic.tsx @@ -12,9 +12,9 @@ import { HttpLogic } from '../../../shared/http'; export type FetchAnalyticsCollectionApiLogicResponse = AnalyticsCollection; -export const fetchAnalyticsCollection = async ({ id }: { id: string }) => { +export const fetchAnalyticsCollection = async ({ name }: { name: string }) => { const { http } = HttpLogic.values; - const route = `/internal/enterprise_search/analytics/collections/${id}`; + const route = `/internal/enterprise_search/analytics/collections/${name}`; const response = await http.get(route); return response; diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/api/fetch_analytics_collection_data_view_id/fetch_analytics_collection_data_view_id_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/api/fetch_analytics_collection_data_view_id/fetch_analytics_collection_data_view_id_api_logic.ts index 6b4853f2aa581..6581d551529f5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/api/fetch_analytics_collection_data_view_id/fetch_analytics_collection_data_view_id_api_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/api/fetch_analytics_collection_data_view_id/fetch_analytics_collection_data_view_id_api_logic.ts @@ -10,16 +10,16 @@ import { createApiLogic } from '../../../shared/api_logic/create_api_logic'; import { HttpLogic } from '../../../shared/http'; export interface FetchAnalyticsCollectionDataViewIdAPILogicArgs { - id: string; + name: string; } export type FetchAnalyticsCollectionDataViewIdApiLogicResponse = AnalyticsCollectionDataViewId; export const fetchAnalyticsCollectionDataViewId = async ({ - id, + name, }: FetchAnalyticsCollectionDataViewIdAPILogicArgs): Promise => { const { http } = HttpLogic.values; - const route = `/internal/enterprise_search/analytics/collections/${id}/data_view_id`; + const route = `/internal/enterprise_search/analytics/collections/${name}/data_view_id`; const response = await http.get(route); return response; diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/add_analytics_collections/add_analytics_collection_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/components/add_analytics_collections/add_analytics_collection_logic.test.ts index 16e77b0ae84e1..69135f9c853e1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/add_analytics_collections/add_analytics_collection_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/add_analytics_collections/add_analytics_collection_logic.test.ts @@ -166,9 +166,7 @@ describe('addAnalyticsCollectionLogic', () => { it('updates when apiSuccess listener triggered', () => { AddAnalyticsCollectionLogic.actions.apiSuccess({ - event_retention_day_length: 180, events_datastream: 'logs-elastic_analytics.events-test', - id: 'bla', name: 'test', }); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/add_analytics_collections/add_analytics_collection_logic.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/components/add_analytics_collections/add_analytics_collection_logic.ts index 95614426e2941..51bfddd3b9f20 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/add_analytics_collections/add_analytics_collection_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/add_analytics_collections/add_analytics_collection_logic.ts @@ -92,7 +92,7 @@ export const AddAnalyticsCollectionLogic = kea< actions.setInputError(error?.body?.message || null); } }, - apiSuccess: async ({ name, id }) => { + apiSuccess: async ({ name }) => { flashSuccessToast( i18n.translate('xpack.enterpriseSearch.analytics.collectionsCreate.action.successMessage', { defaultMessage: "Successfully added collection '{name}'", @@ -103,7 +103,7 @@ export const AddAnalyticsCollectionLogic = kea< ); KibanaLogic.values.navigateToUrl( generateEncodedPath(COLLECTION_VIEW_PATH, { - id, + name, section: 'events', }) ); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_data_view_id_logic.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_data_view_id_logic.ts index 147f43c53e03e..ff156c20fe8eb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_data_view_id_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_data_view_id_logic.ts @@ -17,14 +17,12 @@ import { export interface AnalyticsCollectionDataViewIdActions { apiSuccess: Actions<{}, FetchAnalyticsCollectionDataViewIdApiLogicResponse>['apiSuccess']; - fetchAnalyticsCollectionDataViewId(id: string): { id: string }; + fetchAnalyticsCollectionDataViewId(name: string): { name: string }; makeRequest: Actions<{}, FetchAnalyticsCollectionDataViewIdApiLogicResponse>['makeRequest']; } export interface AnalyticsCollectionDataViewIdValues { data: typeof FetchAnalyticsCollectionDataViewIdAPILogic.values.data; - dataViewId: string | null; - status: Status; } @@ -32,7 +30,7 @@ export const AnalyticsCollectionDataViewIdLogic = kea< MakeLogicType >({ actions: { - fetchAnalyticsCollectionDataViewId: (id) => ({ id }), + fetchAnalyticsCollectionDataViewId: (name) => ({ name }), }, connect: { actions: [ @@ -42,8 +40,8 @@ export const AnalyticsCollectionDataViewIdLogic = kea< values: [FetchAnalyticsCollectionDataViewIdAPILogic, ['status', 'data']], }, listeners: ({ actions }) => ({ - fetchAnalyticsCollectionDataViewId: ({ id }) => { - actions.makeRequest({ id }); + fetchAnalyticsCollectionDataViewId: ({ name }) => { + actions.makeRequest({ name }); }, }), path: ['enterprise_search', 'analytics', 'collection_data_view_id'], diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_events.test.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_events.test.tsx index 372043fd96ed8..ebe2c67ea8f9f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_events.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_events.test.tsx @@ -22,9 +22,7 @@ import { AnalyticsCollectionEvents } from './analytics_collection_events'; describe('AnalyticsCollectionEvents', () => { const analyticsCollection: AnalyticsCollection = { - event_retention_day_length: 180, events_datastream: 'logs-elastic_analytics.events-example', - id: '1', name: 'example', }; diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_events.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_events.tsx index e690605a7b5b5..ad220dbb3efe1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_events.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_events.tsx @@ -38,10 +38,10 @@ export const AnalyticsCollectionEvents: React.FC const { navigateToUrl } = useValues(KibanaLogic); useEffect(() => { - analyticsEventsIndexExists(collection.id); + analyticsEventsIndexExists(collection.events_datastream); const interval = setInterval(() => { - analyticsEventsIndexExists(collection.id); + analyticsEventsIndexExists(collection.events_datastream); }, EVENTS_POLLING_INTERVAL); return () => clearInterval(interval); @@ -83,7 +83,7 @@ export const AnalyticsCollectionEvents: React.FC onClick={() => navigateToUrl( generateEncodedPath(COLLECTION_VIEW_PATH, { - id: collection.id, + id: collection.name, section: 'integrate', }) ) diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate.test.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate.test.tsx index 65a4ab2cd4c35..53300b2649a08 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate.test.tsx @@ -18,9 +18,7 @@ import { AnalyticsCollectionIntegrate } from './analytics_collection_integrate'; describe('AnalyticsCollectionIntegrate', () => { const analyticsCollections: AnalyticsCollection = { - event_retention_day_length: 180, events_datastream: 'analytics-events-example', - id: '1', name: 'example', }; @@ -44,7 +42,7 @@ describe('AnalyticsCollectionIntegrate', () => { ); expect(wrapper.find(EuiCodeBlock).at(0).text()).toContain( - 'data-dsn="/api/analytics/collections/1"' + 'data-dsn="/api/analytics/collections/example"' ); expect(wrapper.find(EuiCodeBlock).at(0).text()).toContain('src="/analytics.js"'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate.tsx index ed4aa6801f8e0..dee042584ad8d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_integrate/analytics_collection_integrate.tsx @@ -28,7 +28,7 @@ export type TabKey = 'javascriptEmbed' | 'searchuiEmbed' | 'javascriptClientEmbe export const AnalyticsCollectionIntegrate: React.FC = ({ collection, }) => { - const analyticsDNSUrl = getEnterpriseSearchUrl(`/api/analytics/collections/${collection.id}`); + const analyticsDNSUrl = getEnterpriseSearchUrl(`/api/analytics/collections/${collection.name}`); const webClientSrc = getEnterpriseSearchUrl('/analytics.js'); const [selectedTab, setSelectedTab] = React.useState('javascriptEmbed'); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_settings.test.tsx index 751d570d6ed57..fb31eaa45db7b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_settings.test.tsx @@ -23,9 +23,7 @@ const mockActions = { }; const analyticsCollection: AnalyticsCollection = { - event_retention_day_length: 180, events_datastream: 'analytics-events-foo', - id: 'example', name: 'example', }; diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_settings.tsx index 1038add87afa9..8935573bfd406 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_settings.tsx @@ -92,7 +92,7 @@ export const AnalyticsCollectionSettings: React.FC { - deleteAnalyticsCollection(collection.id); + deleteAnalyticsCollection(collection.name); }} > {i18n.translate( diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_view.test.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_view.test.tsx index 4fe1828378736..a33dd893dae8a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_view.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_view.test.tsx @@ -24,8 +24,6 @@ import { AnalyticsCollectionView } from './analytics_collection_view'; const mockValues = { analyticsCollection: { - event_retention_day_length: 180, - id: '1', name: 'Analytics Collection 1', } as AnalyticsCollection, dataViewId: '1234-1234-1234', diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_view.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_view.tsx index a91bc4a9db079..3bcd477a18393 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_view.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_view.tsx @@ -50,7 +50,7 @@ export const AnalyticsCollectionView: React.FC = () => { const { fetchAnalyticsCollectionDataViewId } = useActions(AnalyticsCollectionDataViewIdLogic); const { analyticsCollection, isLoading } = useValues(FetchAnalyticsCollectionLogic); const { dataViewId } = useValues(AnalyticsCollectionDataViewIdLogic); - const { id, section } = useParams<{ id: string; section: string }>(); + const { name, section } = useParams<{ name: string; section: string }>(); const { navigateToUrl, application } = useValues(KibanaLogic); const collectionViewTabs = [ { @@ -61,7 +61,7 @@ export const AnalyticsCollectionView: React.FC = () => { onClick: () => navigateToUrl( generateEncodedPath(COLLECTION_VIEW_PATH, { - id: analyticsCollection?.id, + name: analyticsCollection.name, section: 'events', }) ), @@ -75,7 +75,7 @@ export const AnalyticsCollectionView: React.FC = () => { onClick: () => navigateToUrl( generateEncodedPath(COLLECTION_VIEW_PATH, { - id: analyticsCollection?.id, + name: analyticsCollection?.name, section: 'integrate', }) ), @@ -89,7 +89,7 @@ export const AnalyticsCollectionView: React.FC = () => { onClick: () => navigateToUrl( generateEncodedPath(COLLECTION_VIEW_PATH, { - id: analyticsCollection?.id, + name: analyticsCollection?.name, section: 'settings', }) ), @@ -98,8 +98,8 @@ export const AnalyticsCollectionView: React.FC = () => { ]; useEffect(() => { - fetchAnalyticsCollection(id); - fetchAnalyticsCollectionDataViewId(id); + fetchAnalyticsCollection(name); + fetchAnalyticsCollectionDataViewId(name); }, []); return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/delete_analytics_collection_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/delete_analytics_collection_logic.test.ts index 7db951a31ea93..81b9387db5f02 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/delete_analytics_collection_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/delete_analytics_collection_logic.test.ts @@ -57,7 +57,7 @@ describe('deleteAnalyticsCollectionLogic', () => { jest.advanceTimersByTime(150); await nextTick(); expect(DeleteAnalyticsCollectionLogic.actions.makeRequest).toHaveBeenCalledWith({ - id: collectionName, + name: collectionName, }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/delete_analytics_collection_logic.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/delete_analytics_collection_logic.ts index f78e8ee76aff4..ea0cf4476ac54 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/delete_analytics_collection_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/delete_analytics_collection_logic.ts @@ -19,7 +19,7 @@ import { ROOT_PATH } from '../../routes'; export interface DeleteAnalyticsCollectionActions { apiSuccess: Actions<{}, DeleteAnalyticsCollectionApiLogicResponse>['apiSuccess']; - deleteAnalyticsCollection(id: string): { id: string }; + deleteAnalyticsCollection(name: string): { name: string }; makeRequest: Actions<{}, DeleteAnalyticsCollectionApiLogicResponse>['makeRequest']; } export interface DeleteAnalyticsCollectionValues { @@ -32,7 +32,7 @@ export const DeleteAnalyticsCollectionLogic = kea< MakeLogicType >({ actions: { - deleteAnalyticsCollection: (id) => ({ id }), + deleteAnalyticsCollection: (name) => ({ name }), }, connect: { actions: [DeleteAnalyticsCollectionAPILogic, ['makeRequest', 'apiSuccess']], @@ -44,8 +44,8 @@ export const DeleteAnalyticsCollectionLogic = kea< await breakpoint(1000); KibanaLogic.values.navigateToUrl(ROOT_PATH); }, - deleteAnalyticsCollection: ({ id }) => { - actions.makeRequest({ id }); + deleteAnalyticsCollection: ({ name }) => { + actions.makeRequest({ name }); }, }), path: ['enterprise_search', 'analytics', 'collections', 'delete'], diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/fetch_analytics_collection_logic.test.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/fetch_analytics_collection_logic.test.tsx index 89c422da5739d..4cbed5726e6f2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/fetch_analytics_collection_logic.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/fetch_analytics_collection_logic.test.tsx @@ -49,12 +49,12 @@ describe('fetchAnalyticsCollectionLogic', () => { }); it('calls makeRequest on fetchAnalyticsCollections', async () => { - const id = 'name'; + const name = 'name'; FetchAnalyticsCollectionLogic.actions.makeRequest = jest.fn(); - FetchAnalyticsCollectionLogic.actions.fetchAnalyticsCollection(id); + FetchAnalyticsCollectionLogic.actions.fetchAnalyticsCollection(name); expect(FetchAnalyticsCollectionLogic.actions.makeRequest).toHaveBeenCalledWith({ - id, + name, }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/fetch_analytics_collection_logic.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/fetch_analytics_collection_logic.ts index 6f9ab441716d4..19d54b5b9dad7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/fetch_analytics_collection_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/fetch_analytics_collection_logic.ts @@ -30,15 +30,15 @@ export const FetchAnalyticsCollectionLogic = kea< MakeLogicType >({ actions: { - fetchAnalyticsCollection: (id) => ({ id }), + fetchAnalyticsCollection: (name) => ({ name }), }, connect: { actions: [FetchAnalyticsCollectionAPILogic, ['makeRequest']], values: [FetchAnalyticsCollectionAPILogic, ['data', 'status']], }, listeners: ({ actions }) => ({ - fetchAnalyticsCollection: ({ id }) => { - actions.makeRequest({ id }); + fetchAnalyticsCollection: ({ name }) => { + actions.makeRequest({ name }); }, }), path: ['enterprise_search', 'analytics', 'collection'], diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_overview/analytics_collection_card/analytics_collection_card.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_overview/analytics_collection_card/analytics_collection_card.tsx index 01d30a53ec871..f71ea67efaa52 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_overview/analytics_collection_card/analytics_collection_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_overview/analytics_collection_card/analytics_collection_card.tsx @@ -103,7 +103,7 @@ export const AnalyticsCollectionCard: React.FC = ( const status = getChartStatus(secondaryMetric); const CARD_THEME = getCardTheme(euiTheme)[status]; const collectionViewUrl = generateEncodedPath(COLLECTION_VIEW_PATH, { - id: collection.id, + name: collection.name, section: 'events', }); const handleCardClick = (event: MouseEvent) => { @@ -195,7 +195,7 @@ export const AnalyticsCollectionCard: React.FC = ( tooltip="none" /> (Component: React. {dataView && attributes && (
{ const analyticsCollections: AnalyticsCollection[] = [ { - event_retention_day_length: 180, events_datastream: 'analytics-events-example', - id: 'example', name: 'example', }, { - event_retention_day_length: 180, events_datastream: 'analytics-events-example2', - id: 'example2', name: 'example2', }, ]; diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_overview/analytics_collection_table.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_overview/analytics_collection_table.tsx index f4d291eb31488..790e5e5c392b5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_overview/analytics_collection_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_overview/analytics_collection_table.tsx @@ -155,7 +155,7 @@ export const AnalyticsCollectionTable: React.FC = {collections.map((collection) => ( { it('updates to false when apiSuccess returns analytics collections array', () => { const collections: AnalyticsCollection[] = [ { - event_retention_day_length: 19, events_datastream: 'collection1-events', - id: 'collection1', name: 'collection1', }, ]; diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_overview/analytics_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_overview/analytics_overview.test.tsx index d6cb7c122dcad..6992769b1d197 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_overview/analytics_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_overview/analytics_overview.test.tsx @@ -22,8 +22,7 @@ import { AnalyticsOverview } from './analytics_overview'; const mockValues = { analyticsCollections: [ { - event_retention_day_length: 180, - id: '1', + events_datastream: 'analytics-events-1', name: 'Analytics Collection 1', }, ] as AnalyticsCollection[], diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/routes.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/routes.ts index b213203c574e1..78c526cd687bc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/routes.ts @@ -7,4 +7,4 @@ export const ROOT_PATH = '/'; export const COLLECTIONS_PATH = '/collections'; -export const COLLECTION_VIEW_PATH = `${COLLECTIONS_PATH}/:id/:section`; +export const COLLECTION_VIEW_PATH = `${COLLECTIONS_PATH}/:name/:section`; diff --git a/x-pack/plugins/enterprise_search/server/index.ts b/x-pack/plugins/enterprise_search/server/index.ts index 40e2c349b9605..19019841976d4 100644 --- a/x-pack/plugins/enterprise_search/server/index.ts +++ b/x-pack/plugins/enterprise_search/server/index.ts @@ -49,5 +49,3 @@ export const CURRENT_CONNECTORS_INDEX = '.elastic-connectors-v1'; export const CONNECTORS_JOBS_INDEX = '.elastic-connectors-sync-jobs'; export const CONNECTORS_VERSION = 1; export const CRAWLERS_INDEX = '.ent-search-actastic-crawler2_configurations_v2'; -export const ANALYTICS_COLLECTIONS_INDEX = '.elastic-analytics-collections'; -export const ANALYTICS_VERSION = '1'; diff --git a/x-pack/plugins/enterprise_search/server/lib/analytics/add_analytics_collection.test.ts b/x-pack/plugins/enterprise_search/server/lib/analytics/add_analytics_collection.test.ts index 242ed2b087106..ed6aa16b26a35 100644 --- a/x-pack/plugins/enterprise_search/server/lib/analytics/add_analytics_collection.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/analytics/add_analytics_collection.test.ts @@ -8,26 +8,18 @@ import { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; import { DataViewsService } from '@kbn/data-views-plugin/common'; -import { ANALYTICS_COLLECTIONS_INDEX } from '../..'; import { ErrorCode } from '../../../common/types/error_codes'; import { addAnalyticsCollection } from './add_analytics_collection'; -import { fetchAnalyticsCollectionById } from './fetch_analytics_collection'; -import { setupAnalyticsCollectionIndex } from './setup_indices'; +import { fetchAnalyticsCollections } from './fetch_analytics_collection'; -jest.mock('./fetch_analytics_collection', () => ({ fetchAnalyticsCollectionById: jest.fn() })); -jest.mock('./setup_indices', () => ({ - setupAnalyticsCollectionIndex: jest.fn(), -})); +jest.mock('./fetch_analytics_collection', () => ({ fetchAnalyticsCollections: jest.fn() })); describe('add analytics collection lib function', () => { const mockClient = { asCurrentUser: { - index: jest.fn(), - indices: { - create: jest.fn(), - exists: jest.fn(), - refresh: jest.fn(), + transport: { + request: jest.fn(), }, }, asInternalUser: {}, @@ -42,114 +34,65 @@ describe('add analytics collection lib function', () => { }); it('should add analytics collection', async () => { - mockClient.asCurrentUser.index.mockImplementation(() => ({ _id: 'example' })); - mockClient.asCurrentUser.indices.exists.mockImplementation(() => false); + mockClient.asCurrentUser.transport.request.mockImplementation(() => ({ + acknowledged: true, + name: `example`, + })); + + (fetchAnalyticsCollections as jest.Mock).mockImplementation(() => [ + { + events_datastream: 'example-datastream', + name: 'example', + }, + ]); await expect( addAnalyticsCollection( mockClient as unknown as IScopedClusterClient, mockDataViewsService as unknown as DataViewsService, - { - name: 'example', - } + 'example' ) ).resolves.toEqual({ - event_retention_day_length: 180, - events_datastream: 'logs-elastic_analytics.events-example', - id: 'example', + events_datastream: 'example-datastream', name: 'example', }); - expect(mockClient.asCurrentUser.index).toHaveBeenCalledWith({ - document: { - event_retention_day_length: 180, - events_datastream: 'logs-elastic_analytics.events-example', - name: 'example', - }, - id: 'example', - index: ANALYTICS_COLLECTIONS_INDEX, + expect(mockClient.asCurrentUser.transport.request).toHaveBeenCalledWith({ + method: 'PUT', + path: '/_application/analytics/example', }); expect(mockDataViewsService.createAndSave).toHaveBeenCalledWith( { allowNoIndex: true, - name: 'elastic_analytics.events-example', - title: 'logs-elastic_analytics.events-example', + name: 'behavioral_analytics.events-example', + title: 'example-datastream', timeFieldName: '@timestamp', }, true ); }); - it('should reject if index already exists', async () => { - mockClient.asCurrentUser.index.mockImplementation(() => ({ _id: 'fakeId' })); - (fetchAnalyticsCollectionById as jest.Mock).mockImplementation(() => true); + it('should reject if analytics collection already exists', async () => { + mockClient.asCurrentUser.transport.request.mockImplementation(() => + Promise.reject({ + meta: { + body: { + error: { + type: 'resource_already_exists_exception', + }, + }, + }, + }) + ); await expect( addAnalyticsCollection( mockClient as unknown as IScopedClusterClient, mockDataViewsService as unknown as DataViewsService, - { - name: 'index_name', - } + 'index_name' ) ).rejects.toEqual(new Error(ErrorCode.ANALYTICS_COLLECTION_ALREADY_EXISTS)); - expect(mockClient.asCurrentUser.index).not.toHaveBeenCalled(); - expect(mockDataViewsService.createAndSave).not.toHaveBeenCalled(); - }); - - it('should create index if no analytics collection index exists', async () => { - mockClient.asCurrentUser.indices.exists.mockImplementation(() => false); - - (fetchAnalyticsCollectionById as jest.Mock).mockImplementation(() => undefined); - - mockClient.asCurrentUser.index.mockImplementation(() => ({ _id: 'example' })); - - await expect( - addAnalyticsCollection( - mockClient as unknown as IScopedClusterClient, - mockDataViewsService as unknown as DataViewsService, - { - name: 'example', - } - ) - ).resolves.toEqual({ - event_retention_day_length: 180, - events_datastream: 'logs-elastic_analytics.events-example', - id: 'example', - name: 'example', - }); - - expect(mockClient.asCurrentUser.index).toHaveBeenCalledWith({ - document: { - event_retention_day_length: 180, - events_datastream: 'logs-elastic_analytics.events-example', - name: 'example', - }, - id: 'example', - index: ANALYTICS_COLLECTIONS_INDEX, - }); - - expect(setupAnalyticsCollectionIndex).toHaveBeenCalledWith(mockClient.asCurrentUser); - }); - - it('should not create index if status code is not 404', async () => { - mockClient.asCurrentUser.index.mockImplementationOnce(() => { - return Promise.reject({ statusCode: 500 }); - }); - mockClient.asCurrentUser.indices.exists.mockImplementation(() => true); - (fetchAnalyticsCollectionById as jest.Mock).mockImplementation(() => false); - await expect( - addAnalyticsCollection( - mockClient as unknown as IScopedClusterClient, - mockDataViewsService as unknown as DataViewsService, - { - name: 'example', - } - ) - ).rejects.toEqual({ statusCode: 500 }); - expect(setupAnalyticsCollectionIndex).not.toHaveBeenCalled(); - expect(mockClient.asCurrentUser.index).toHaveBeenCalledTimes(1); expect(mockDataViewsService.createAndSave).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/enterprise_search/server/lib/analytics/add_analytics_collection.ts b/x-pack/plugins/enterprise_search/server/lib/analytics/add_analytics_collection.ts index fb048c802a681..f85732136d454 100644 --- a/x-pack/plugins/enterprise_search/server/lib/analytics/add_analytics_collection.ts +++ b/x-pack/plugins/enterprise_search/server/lib/analytics/add_analytics_collection.ts @@ -7,50 +7,28 @@ import { IScopedClusterClient } from '@kbn/core/server'; import { DataView, DataViewsService } from '@kbn/data-views-plugin/common'; -import { ANALYTICS_COLLECTIONS_INDEX } from '../..'; - -import { AnalyticsCollectionDocument, AnalyticsCollection } from '../../../common/types/analytics'; +import { AnalyticsCollection } from '../../../common/types/analytics'; import { ErrorCode } from '../../../common/types/error_codes'; -import { toAlphanumeric } from '../../../common/utils/to_alphanumeric'; -import { fetchAnalyticsCollectionById } from './fetch_analytics_collection'; -import { setupAnalyticsCollectionIndex } from './setup_indices'; +import { isResourceAlreadyExistsException } from '../../utils/identify_exceptions'; + +import { fetchAnalyticsCollections } from './fetch_analytics_collection'; -interface AddAnalyticsCollectionRequestBody { +interface CollectionsPutResponse { + acknowledged: boolean; name: string; } const createAnalyticsCollection = async ( client: IScopedClusterClient, - document: AnalyticsCollectionDocument, - id: string -): Promise => { - const analyticsCollection = await fetchAnalyticsCollectionById(client, id); - - if (analyticsCollection) { - throw new Error(ErrorCode.ANALYTICS_COLLECTION_ALREADY_EXISTS); - } - - const result = await client.asCurrentUser.index({ - document, - id, - index: ANALYTICS_COLLECTIONS_INDEX, + name: string +): Promise => { + const response = await client.asCurrentUser.transport.request({ + method: 'PUT', + path: `/_application/analytics/${name}`, }); - await client.asCurrentUser.indices.refresh({ index: ANALYTICS_COLLECTIONS_INDEX }); - - return { - id: result._id, - ...document, - }; -}; - -const getDataViewName = (collectionId: string): string => { - return `elastic_analytics.events-${collectionId}`; -}; - -const getDataStreamName = (collectionId: string): string => { - return `logs-${getDataViewName(collectionId)}`; + return response; }; const createDataView = async ( @@ -60,9 +38,9 @@ const createDataView = async ( return dataViewsService.createAndSave( { allowNoIndex: true, - title: getDataStreamName(analyticsCollection.id), - name: getDataViewName(analyticsCollection.id), + name: `behavioral_analytics.events-${analyticsCollection.name}`, timeFieldName: '@timestamp', + title: analyticsCollection.events_datastream, }, true ); @@ -71,28 +49,18 @@ const createDataView = async ( export const addAnalyticsCollection = async ( client: IScopedClusterClient, dataViewsService: DataViewsService, - { name: collectionName }: AddAnalyticsCollectionRequestBody + name: string ): Promise => { - const id = toAlphanumeric(collectionName); - const eventsDataStreamName = getDataStreamName(id); - - const document: AnalyticsCollectionDocument = { - event_retention_day_length: 180, - events_datastream: eventsDataStreamName, - name: collectionName, - }; - - const analyticsCollectionIndexExists = await client.asCurrentUser.indices.exists({ - index: ANALYTICS_COLLECTIONS_INDEX, - }); - - if (!analyticsCollectionIndexExists) { - await setupAnalyticsCollectionIndex(client.asCurrentUser); + try { + await createAnalyticsCollection(client, name); + } catch (error) { + if (isResourceAlreadyExistsException(error)) { + throw new Error(ErrorCode.ANALYTICS_COLLECTION_ALREADY_EXISTS); + } } + const analyticsCollections = await fetchAnalyticsCollections(client, name); - const analyticsCollection = await createAnalyticsCollection(client, document, id); - + const analyticsCollection = analyticsCollections[0]; await createDataView(dataViewsService, analyticsCollection); - return analyticsCollection; }; diff --git a/x-pack/plugins/enterprise_search/server/lib/analytics/analytics_events_index_exists.test.ts b/x-pack/plugins/enterprise_search/server/lib/analytics/analytics_events_index_exists.test.ts index 5a9a64420b8ff..a851f21eaa151 100644 --- a/x-pack/plugins/enterprise_search/server/lib/analytics/analytics_events_index_exists.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/analytics/analytics_events_index_exists.test.ts @@ -9,15 +9,11 @@ import { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; import { analyticsEventsIndexExists } from './analytics_events_index_exists'; -// jest.mock('./analytics_events_index_exists', () => ({ -// analyticsEventsIndexExists: jest.fn(), -// })); - describe('analytics collection events exists function', () => { const mockClient = { asCurrentUser: { indices: { - exists: jest.fn(), + getDataStream: jest.fn(), }, }, }; @@ -28,13 +24,14 @@ describe('analytics collection events exists function', () => { describe('checking if analytics events index exists', () => { it('should call exists endpoint', async () => { - mockClient.asCurrentUser.indices.exists.mockImplementationOnce(() => Promise.resolve(true)); + mockClient.asCurrentUser.indices.getDataStream.mockImplementationOnce(() => ({ + data_streams: [{ name: 'example' }], + })); await expect( analyticsEventsIndexExists(mockClient as unknown as IScopedClusterClient, 'example') ).resolves.toEqual(true); - expect(mockClient.asCurrentUser.indices.exists).toHaveBeenCalledWith({ - index: '.ds-logs-elastic_analytics.events-example-*', - allow_no_indices: false, + expect(mockClient.asCurrentUser.indices.getDataStream).toHaveBeenCalledWith({ + name: 'example', }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/lib/analytics/analytics_events_index_exists.ts b/x-pack/plugins/enterprise_search/server/lib/analytics/analytics_events_index_exists.ts index 0d5ffad5a291a..d9549c42494f6 100644 --- a/x-pack/plugins/enterprise_search/server/lib/analytics/analytics_events_index_exists.ts +++ b/x-pack/plugins/enterprise_search/server/lib/analytics/analytics_events_index_exists.ts @@ -7,16 +7,21 @@ import { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; -const getFullIndexName = (indexName: string): string => { - return `.ds-logs-elastic_analytics.events-${indexName}-*`; -}; +import { isIndexNotFoundException } from '../../utils/identify_exceptions'; export const analyticsEventsIndexExists = async ( client: IScopedClusterClient, - indexName: string + datastreamName: string ): Promise => { - return await client.asCurrentUser.indices.exists({ - index: getFullIndexName(indexName), - allow_no_indices: false, - }); + try { + const response = await client.asCurrentUser.indices.getDataStream({ + name: datastreamName, + }); + return response.data_streams.length > 0; + } catch (error) { + if (isIndexNotFoundException(error)) { + return false; + } + throw error; + } }; diff --git a/x-pack/plugins/enterprise_search/server/lib/analytics/delete_analytics_collection.test.ts b/x-pack/plugins/enterprise_search/server/lib/analytics/delete_analytics_collection.test.ts index 70fcd3f416431..06a08f1cc8848 100644 --- a/x-pack/plugins/enterprise_search/server/lib/analytics/delete_analytics_collection.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/analytics/delete_analytics_collection.test.ts @@ -7,22 +7,16 @@ import { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; -import { ANALYTICS_COLLECTIONS_INDEX } from '../..'; -import { AnalyticsCollection } from '../../../common/types/analytics'; - import { ErrorCode } from '../../../common/types/error_codes'; import { deleteAnalyticsCollectionById } from './delete_analytics_collection'; -import { fetchAnalyticsCollectionById } from './fetch_analytics_collection'; - -jest.mock('./fetch_analytics_collection', () => ({ - fetchAnalyticsCollectionById: jest.fn(), -})); describe('delete analytics collection lib function', () => { const mockClient = { asCurrentUser: { - delete: jest.fn(), + transport: { + request: jest.fn(), + }, }, asInternalUser: {}, }; @@ -33,34 +27,32 @@ describe('delete analytics collection lib function', () => { describe('deleting analytics collections', () => { it('should delete an analytics collection', async () => { - (fetchAnalyticsCollectionById as jest.Mock).mockImplementationOnce(() => { - return Promise.resolve({ - event_retention_day_length: 180, - id: 'example', - name: 'example', - } as AnalyticsCollection); - }); - await expect( deleteAnalyticsCollectionById(mockClient as unknown as IScopedClusterClient, 'example') ).resolves.toBeUndefined(); - expect(mockClient.asCurrentUser.delete).toHaveBeenCalledWith({ - id: 'example', - index: ANALYTICS_COLLECTIONS_INDEX, + expect(mockClient.asCurrentUser.transport.request).toHaveBeenCalledWith({ + method: 'DELETE', + path: '/_application/analytics/example', }); }); it('should throw an exception when analytics collection does not exist', async () => { - (fetchAnalyticsCollectionById as jest.Mock).mockImplementationOnce(() => - Promise.resolve(undefined) + mockClient.asCurrentUser.transport.request.mockImplementation(() => + Promise.reject({ + meta: { + body: { + error: { + type: 'resource_not_found_exception', + }, + }, + }, + }) ); await expect( deleteAnalyticsCollectionById(mockClient as unknown as IScopedClusterClient, 'example') ).rejects.toEqual(new Error(ErrorCode.ANALYTICS_COLLECTION_NOT_FOUND)); - - expect(mockClient.asCurrentUser.delete).not.toHaveBeenCalled(); }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/lib/analytics/delete_analytics_collection.ts b/x-pack/plugins/enterprise_search/server/lib/analytics/delete_analytics_collection.ts index eeaf7a73d8760..c305d1a4b1031 100644 --- a/x-pack/plugins/enterprise_search/server/lib/analytics/delete_analytics_collection.ts +++ b/x-pack/plugins/enterprise_search/server/lib/analytics/delete_analytics_collection.ts @@ -7,21 +7,23 @@ import { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; -import { ANALYTICS_COLLECTIONS_INDEX } from '../..'; - import { ErrorCode } from '../../../common/types/error_codes'; +import { isResourceNotFoundException } from '../../utils/identify_exceptions'; -import { fetchAnalyticsCollectionById } from './fetch_analytics_collection'; - -export const deleteAnalyticsCollectionById = async (client: IScopedClusterClient, id: string) => { - const analyticsCollection = await fetchAnalyticsCollectionById(client, id); +interface CollectionsDeleteResponse { + acknowledged: boolean; +} - if (!analyticsCollection) { - throw new Error(ErrorCode.ANALYTICS_COLLECTION_NOT_FOUND); +export const deleteAnalyticsCollectionById = async (client: IScopedClusterClient, name: string) => { + try { + await client.asCurrentUser.transport.request({ + method: 'DELETE', + path: `/_application/analytics/${name}`, + }); + } catch (error) { + if (isResourceNotFoundException(error)) { + throw new Error(ErrorCode.ANALYTICS_COLLECTION_NOT_FOUND); + } + throw error; } - - await client.asCurrentUser.delete({ - id: analyticsCollection.id, - index: ANALYTICS_COLLECTIONS_INDEX, - }); }; diff --git a/x-pack/plugins/enterprise_search/server/lib/analytics/fetch_analytics_collection.test.ts b/x-pack/plugins/enterprise_search/server/lib/analytics/fetch_analytics_collection.test.ts index 6b2b612e6f8c2..83ef91599f554 100644 --- a/x-pack/plugins/enterprise_search/server/lib/analytics/fetch_analytics_collection.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/analytics/fetch_analytics_collection.test.ts @@ -7,23 +7,14 @@ import { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; -import { ANALYTICS_COLLECTIONS_INDEX } from '../..'; - -import { - fetchAnalyticsCollectionById, - fetchAnalyticsCollections, -} from './fetch_analytics_collection'; -import { setupAnalyticsCollectionIndex } from './setup_indices'; - -jest.mock('./setup_indices', () => ({ - setupAnalyticsCollectionIndex: jest.fn(), -})); +import { fetchAnalyticsCollections } from './fetch_analytics_collection'; describe('fetch analytics collection lib function', () => { const mockClient = { asCurrentUser: { - get: jest.fn(), - search: jest.fn(), + transport: { + request: jest.fn(), + }, }, asInternalUser: {}, }; @@ -34,131 +25,43 @@ describe('fetch analytics collection lib function', () => { describe('fetch collections', () => { it('should return a list of analytics collections', async () => { - mockClient.asCurrentUser.search.mockImplementationOnce(() => + mockClient.asCurrentUser.transport.request.mockImplementation(() => Promise.resolve({ - hits: { - hits: [ - { _id: '2', _source: { name: 'example' } }, - { _id: '1', _source: { name: 'example2' } }, - ], + example: { + event_data_stream: { + name: 'datastream-example', + }, }, - }) - ); - await expect( - fetchAnalyticsCollections(mockClient as unknown as IScopedClusterClient) - ).resolves.toEqual([ - { id: '2', name: 'example' }, - { id: '1', name: 'example2' }, - ]); - }); - - it('should setup the indexes if none exist and return an empty array', async () => { - mockClient.asCurrentUser.search.mockImplementationOnce(() => - Promise.reject({ - meta: { - body: { - error: { - type: 'index_not_found_exception', - }, + exampleTwo: { + event_data_stream: { + name: 'datastream-exampleTwo', }, }, }) ); - - await expect( - fetchAnalyticsCollections(mockClient as unknown as IScopedClusterClient) - ).resolves.toEqual([]); - - expect(setupAnalyticsCollectionIndex as jest.Mock).toHaveBeenCalledWith( - mockClient.asCurrentUser - ); - }); - - it('should not call setup analytics index on other errors and return error', async () => { - const error = { - meta: { - body: { - error: { - type: 'other error', - }, - }, - }, - }; - mockClient.asCurrentUser.search.mockImplementationOnce(() => Promise.reject(error)); await expect( fetchAnalyticsCollections(mockClient as unknown as IScopedClusterClient) - ).rejects.toMatchObject(error); - - expect(mockClient.asCurrentUser.search).toHaveBeenCalledWith({ - from: 0, - index: ANALYTICS_COLLECTIONS_INDEX, - query: { - match_all: {}, - }, - size: 1000, - }); - expect(setupAnalyticsCollectionIndex as jest.Mock).not.toHaveBeenCalled(); + ).resolves.toEqual([ + { name: 'example', events_datastream: 'datastream-example' }, + { name: 'exampleTwo', events_datastream: 'datastream-exampleTwo' }, + ]); }); }); describe('fetch collection by Id', () => { it('should fetch analytics collection by Id', async () => { - mockClient.asCurrentUser.get.mockImplementationOnce(() => - Promise.resolve({ _id: 'example', _source: { name: 'example' } }) - ); - - await expect( - fetchAnalyticsCollectionById(mockClient as unknown as IScopedClusterClient, 'example') - ).resolves.toEqual({ id: 'example', name: 'example' }); - - expect(mockClient.asCurrentUser.get).toHaveBeenCalledWith({ - id: 'example', - index: ANALYTICS_COLLECTIONS_INDEX, - }); - }); - - it('should call setup analytics collection index on index not found error', async () => { - mockClient.asCurrentUser.get.mockImplementationOnce(() => - Promise.reject({ - meta: { - body: { - error: { type: 'index_not_found_exception' }, + mockClient.asCurrentUser.transport.request.mockImplementation(() => + Promise.resolve({ + example: { + event_data_stream: { + name: 'datastream-example', }, }, }) ); await expect( - fetchAnalyticsCollectionById(mockClient as unknown as IScopedClusterClient, 'example') - ).resolves.toEqual(undefined); - expect(mockClient.asCurrentUser.get).toHaveBeenCalledWith({ - id: 'example', - index: ANALYTICS_COLLECTIONS_INDEX, - }); - expect(setupAnalyticsCollectionIndex as jest.Mock).toHaveBeenCalledWith( - mockClient.asCurrentUser - ); - }); - - it('should not call setup analytics indices on other errors', async () => { - mockClient.asCurrentUser.get.mockImplementationOnce(() => - Promise.reject({ - meta: { - body: { - error: { - type: 'other error', - }, - }, - }, - }) - ); - await expect(fetchAnalyticsCollectionById(mockClient as any, 'example')).resolves.toEqual( - undefined - ); - expect(mockClient.asCurrentUser.get).toHaveBeenCalledWith({ - id: 'example', - index: ANALYTICS_COLLECTIONS_INDEX, - }); - expect(setupAnalyticsCollectionIndex as jest.Mock).not.toHaveBeenCalled(); + fetchAnalyticsCollections(mockClient as unknown as IScopedClusterClient, 'example') + ).resolves.toEqual([{ name: 'example', events_datastream: 'datastream-example' }]); }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/lib/analytics/fetch_analytics_collection.ts b/x-pack/plugins/enterprise_search/server/lib/analytics/fetch_analytics_collection.ts index 9244f2f10d01a..8baff2c85de44 100644 --- a/x-pack/plugins/enterprise_search/server/lib/analytics/fetch_analytics_collection.ts +++ b/x-pack/plugins/enterprise_search/server/lib/analytics/fetch_analytics_collection.ts @@ -5,49 +5,41 @@ * 2.0. */ -import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; -import { ANALYTICS_COLLECTIONS_INDEX } from '../..'; import { AnalyticsCollection } from '../../../common/types/analytics'; +import { ErrorCode } from '../../../common/types/error_codes'; -import { isIndexNotFoundException } from '../../utils/identify_exceptions'; -import { fetchAll } from '../fetch_all'; +import { isResourceNotFoundException } from '../../utils/identify_exceptions'; -import { setupAnalyticsCollectionIndex } from './setup_indices'; +interface CollectionsListResponse { + [name: string]: { + event_data_stream: { + name: string; + }; + }; +} -export const fetchAnalyticsCollectionById = async ( +export const fetchAnalyticsCollections = async ( client: IScopedClusterClient, - id: string -): Promise => { + query: string = '' +): Promise => { try { - const hit = await client.asCurrentUser.get({ - id, - index: ANALYTICS_COLLECTIONS_INDEX, + const collections = await client.asCurrentUser.transport.request({ + method: 'GET', + path: `/_application/analytics/${query}`, }); - const result = hit._source ? { ...hit._source, id: hit._id } : undefined; - - return result; - } catch (error) { - if (isIndexNotFoundException(error)) { - await setupAnalyticsCollectionIndex(client.asCurrentUser); - } - return undefined; - } -}; - -export const fetchAnalyticsCollections = async ( - client: IScopedClusterClient -): Promise => { - const query: QueryDslQueryContainer = { match_all: {} }; - - try { - return await fetchAll(client, ANALYTICS_COLLECTIONS_INDEX, query); + return Object.keys(collections).map((value) => { + const entry = collections[value]; + return { + events_datastream: entry.event_data_stream.name, + name: value, + }; + }); } catch (error) { - if (isIndexNotFoundException(error)) { - await setupAnalyticsCollectionIndex(client.asCurrentUser); - return []; + if (isResourceNotFoundException(error)) { + throw new Error(ErrorCode.ANALYTICS_COLLECTION_NOT_FOUND); } throw error; } diff --git a/x-pack/plugins/enterprise_search/server/lib/analytics/fetch_analytics_collection_data_view_id.test.ts b/x-pack/plugins/enterprise_search/server/lib/analytics/fetch_analytics_collection_data_view_id.test.ts index 1db6ccedfd920..abfecbe742ac5 100644 --- a/x-pack/plugins/enterprise_search/server/lib/analytics/fetch_analytics_collection_data_view_id.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/analytics/fetch_analytics_collection_data_view_id.test.ts @@ -11,15 +11,22 @@ import { DataViewsService } from '@kbn/data-views-plugin/common'; import { ErrorCode } from '../../../common/types/error_codes'; -import { fetchAnalyticsCollectionById } from './fetch_analytics_collection'; +import { fetchAnalyticsCollections } from './fetch_analytics_collection'; import { fetchAnalyticsCollectionDataViewId } from './fetch_analytics_collection_data_view_id'; jest.mock('./fetch_analytics_collection', () => ({ - fetchAnalyticsCollectionById: jest.fn(), + fetchAnalyticsCollections: jest.fn(), })); describe('fetch analytics collection data view id', () => { - const mockClient = {}; + const mockClient = { + asCurrentUser: { + transport: { + request: jest.fn(), + }, + }, + asInternalUser: {}, + }; const dataViewService = { find: jest.fn() }; beforeEach(() => { @@ -29,8 +36,8 @@ describe('fetch analytics collection data view id', () => { it('should return data view id of analytics collection by Id', async () => { const mockCollectionId = 'collectionId'; const mockDataViewId = 'dataViewId'; - const mockCollection = { events_datastream: 'log-collection-data-stream' }; - (fetchAnalyticsCollectionById as jest.Mock).mockImplementationOnce(() => + const mockCollection = [{ name: 'example', events_datastream: 'log-collection-data-stream' }]; + (fetchAnalyticsCollections as jest.Mock).mockImplementationOnce(() => Promise.resolve(mockCollection) ); @@ -43,14 +50,14 @@ describe('fetch analytics collection data view id', () => { mockCollectionId ) ).resolves.toEqual({ data_view_id: mockDataViewId }); - expect(fetchAnalyticsCollectionById).toHaveBeenCalledWith(mockClient, mockCollectionId); - expect(dataViewService.find).toHaveBeenCalledWith(mockCollection.events_datastream, 1); + expect(fetchAnalyticsCollections).toHaveBeenCalledWith(mockClient, mockCollectionId); + expect(dataViewService.find).toHaveBeenCalledWith(mockCollection[0].events_datastream, 1); }); it('should return null when data view not found', async () => { const mockCollectionId = 'collectionId'; - const mockCollection = { events_datastream: 'log-collection-data-stream' }; - (fetchAnalyticsCollectionById as jest.Mock).mockImplementationOnce(() => + const mockCollection = [{ events_datastream: 'log-collection-data-stream' }]; + (fetchAnalyticsCollections as jest.Mock).mockImplementationOnce(() => Promise.resolve(mockCollection) ); @@ -63,13 +70,16 @@ describe('fetch analytics collection data view id', () => { mockCollectionId ) ).resolves.toEqual({ data_view_id: null }); - expect(fetchAnalyticsCollectionById).toHaveBeenCalledWith(mockClient, mockCollectionId); - expect(dataViewService.find).toHaveBeenCalledWith(mockCollection.events_datastream, 1); + expect(fetchAnalyticsCollections).toHaveBeenCalledWith(mockClient, mockCollectionId); + expect(dataViewService.find).toHaveBeenCalledWith(mockCollection[0].events_datastream, 1); }); it('should throw an error when analytics collection not found', async () => { const mockCollectionId = 'collectionId'; - (fetchAnalyticsCollectionById as jest.Mock).mockImplementationOnce(() => Promise.resolve(null)); + + (fetchAnalyticsCollections as jest.Mock).mockImplementation(() => { + throw new Error(ErrorCode.ANALYTICS_COLLECTION_NOT_FOUND); + }); await expect( fetchAnalyticsCollectionDataViewId( @@ -78,7 +88,7 @@ describe('fetch analytics collection data view id', () => { mockCollectionId ) ).rejects.toThrowError(ErrorCode.ANALYTICS_COLLECTION_NOT_FOUND); - expect(fetchAnalyticsCollectionById).toHaveBeenCalledWith(mockClient, mockCollectionId); + expect(fetchAnalyticsCollections).toHaveBeenCalledWith(mockClient, mockCollectionId); expect(dataViewService.find).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/enterprise_search/server/lib/analytics/fetch_analytics_collection_data_view_id.ts b/x-pack/plugins/enterprise_search/server/lib/analytics/fetch_analytics_collection_data_view_id.ts index 0ec07139d7b41..5d6967523ca9e 100644 --- a/x-pack/plugins/enterprise_search/server/lib/analytics/fetch_analytics_collection_data_view_id.ts +++ b/x-pack/plugins/enterprise_search/server/lib/analytics/fetch_analytics_collection_data_view_id.ts @@ -10,22 +10,16 @@ import { DataViewsService } from '@kbn/data-views-plugin/common'; import { AnalyticsCollectionDataViewId } from '../../../common/types/analytics'; -import { ErrorCode } from '../../../common/types/error_codes'; - -import { fetchAnalyticsCollectionById } from './fetch_analytics_collection'; +import { fetchAnalyticsCollections } from './fetch_analytics_collection'; export const fetchAnalyticsCollectionDataViewId = async ( elasticsearchClient: IScopedClusterClient, dataViewsService: DataViewsService, - collectionId: string + collectionName: string ): Promise => { - const collection = await fetchAnalyticsCollectionById(elasticsearchClient, collectionId); - - if (!collection) { - throw new Error(ErrorCode.ANALYTICS_COLLECTION_NOT_FOUND); - } + const collections = await fetchAnalyticsCollections(elasticsearchClient, collectionName); - const collectionDataView = await dataViewsService.find(collection.events_datastream, 1); + const collectionDataView = await dataViewsService.find(collections[0].events_datastream, 1); return { data_view_id: collectionDataView?.[0]?.id || null }; }; diff --git a/x-pack/plugins/enterprise_search/server/lib/analytics/setup_indices.test.ts b/x-pack/plugins/enterprise_search/server/lib/analytics/setup_indices.test.ts deleted file mode 100644 index 0647022f23fa5..0000000000000 --- a/x-pack/plugins/enterprise_search/server/lib/analytics/setup_indices.test.ts +++ /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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ANALYTICS_VERSION } from '../..'; - -import { setupAnalyticsCollectionIndex } from './setup_indices'; - -describe('setup analytics collection index', () => { - const mockClient = { - asCurrentUser: { - indices: { - create: jest.fn(), - updateAliases: jest.fn(), - }, - }, - asInternalUser: {}, - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it("should create the analytics collection index when it doesn't exist", async () => { - const indexName = '.elastic-analytics-collections'; - const analyticCollectionsMappings = { - _meta: { - version: ANALYTICS_VERSION, - }, - properties: { - event_retention_day_length: { - type: 'long', - }, - eventsDatastream: { - type: 'keyword', - }, - name: { - type: 'keyword', - }, - }, - }; - - mockClient.asCurrentUser.indices.create.mockImplementation(() => Promise.resolve()); - mockClient.asCurrentUser.indices.updateAliases.mockImplementation(() => Promise.resolve()); - await expect(setupAnalyticsCollectionIndex(mockClient.asCurrentUser as any)).resolves.toEqual( - undefined - ); - expect(mockClient.asCurrentUser.indices.create).toHaveBeenCalledWith({ - index: `${indexName}-v${1}`, - mappings: analyticCollectionsMappings, - settings: { auto_expand_replicas: '0-3', hidden: true, number_of_replicas: 0 }, - }); - expect(mockClient.asCurrentUser.indices.updateAliases).toHaveBeenCalledWith({ - actions: [ - { - add: { - aliases: [indexName], - index: `${indexName}-v${1}`, - is_hidden: true, - is_write_index: true, - }, - }, - ], - }); - }); - - it('should do nothing if it hits that resource already exists', async () => { - mockClient.asCurrentUser.indices.create.mockImplementation(() => - Promise.reject({ meta: { body: { error: { type: 'resource_already_exists_exception' } } } }) - ); - await expect(setupAnalyticsCollectionIndex(mockClient.asCurrentUser as any)).resolves.toEqual( - undefined - ); - expect(mockClient.asCurrentUser.indices.updateAliases).not.toHaveBeenCalled(); - expect(mockClient.asCurrentUser.indices.create).toHaveBeenCalled(); - }); -}); diff --git a/x-pack/plugins/enterprise_search/server/lib/analytics/setup_indices.ts b/x-pack/plugins/enterprise_search/server/lib/analytics/setup_indices.ts deleted file mode 100644 index e007fdecad458..0000000000000 --- a/x-pack/plugins/enterprise_search/server/lib/analytics/setup_indices.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - IndicesIndexSettings, - MappingProperty, - MappingTypeMapping, -} from '@elastic/elasticsearch/lib/api/types'; -import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; - -import { ANALYTICS_COLLECTIONS_INDEX, ANALYTICS_VERSION } from '../..'; -import { isResourceAlreadyExistsException } from '../../utils/identify_exceptions'; - -const analyticsCollectionMappingsProperties: Record = { - event_retention_day_length: { - type: 'long', - }, - eventsDatastream: { - type: 'keyword', - }, - name: { - type: 'keyword', - }, -}; - -const defaultSettings: IndicesIndexSettings = { - auto_expand_replicas: '0-3', - hidden: true, - number_of_replicas: 0, -}; - -interface IndexDefinition { - aliases: string[]; - mappings: MappingTypeMapping; - name: string; - settings: IndicesIndexSettings; -} - -export const setupAnalyticsCollectionIndex = async (client: ElasticsearchClient) => { - const indexConfiguration: IndexDefinition = { - aliases: [ANALYTICS_COLLECTIONS_INDEX], - mappings: { - _meta: { - version: ANALYTICS_VERSION, - }, - properties: analyticsCollectionMappingsProperties, - }, - name: `${ANALYTICS_COLLECTIONS_INDEX}-v${ANALYTICS_VERSION}`, - settings: defaultSettings, - }; - - try { - const { mappings, aliases, name: index, settings } = indexConfiguration; - await client.indices.create({ - index, - mappings, - settings, - }); - await client.indices.updateAliases({ - actions: [ - { - add: { - aliases, - index, - is_hidden: true, - is_write_index: true, - }, - }, - ], - }); - } catch (error) { - if (isResourceAlreadyExistsException(error)) { - // index already exists, swallow error - return; - } - return error; - } -}; diff --git a/x-pack/plugins/enterprise_search/server/lib/engines/field_capabilities.test.ts b/x-pack/plugins/enterprise_search/server/lib/engines/field_capabilities.test.ts index 3e250e4ec9109..f6c87f641fcd4 100644 --- a/x-pack/plugins/enterprise_search/server/lib/engines/field_capabilities.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/engines/field_capabilities.test.ts @@ -8,9 +8,9 @@ import { FieldCapsResponse } from '@elastic/elasticsearch/lib/api/types'; import { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; -import { EnterpriseSearchEngineDetails } from '../../../common/types/engines'; +import { EnterpriseSearchEngineDetails, SchemaField } from '../../../common/types/engines'; -import { fetchEngineFieldCapabilities } from './field_capabilities'; +import { fetchEngineFieldCapabilities, parseFieldsCapabilities } from './field_capabilities'; describe('engines field_capabilities', () => { const mockClient = { @@ -29,24 +29,1081 @@ describe('engines field_capabilities', () => { jest.clearAllMocks(); }); - it('gets engine alias field capabilities', async () => { - const fieldCapsResponse = {} as FieldCapsResponse; + describe('fetchEngineFieldCapabilities', () => { + it('gets engine alias field capabilities', async () => { + const fieldCapsResponse: FieldCapsResponse = { + fields: { + body: { + text: { + aggregatable: false, + metadata_field: false, + searchable: true, + type: 'text', + }, + }, + }, + indices: ['index-001'], + }; - mockClient.asCurrentUser.fieldCaps.mockResolvedValueOnce(fieldCapsResponse); - await expect( - fetchEngineFieldCapabilities(mockClient as unknown as IScopedClusterClient, mockEngine) - ).resolves.toEqual({ - created: mockEngine.created, - field_capabilities: fieldCapsResponse, - name: mockEngine.name, - updated: mockEngine.updated, + mockClient.asCurrentUser.fieldCaps.mockResolvedValueOnce(fieldCapsResponse); + await expect( + fetchEngineFieldCapabilities(mockClient as unknown as IScopedClusterClient, mockEngine) + ).resolves.toEqual({ + created: mockEngine.created, + field_capabilities: fieldCapsResponse, + fields: [ + { + fields: [], + indices: [ + { + name: 'index-001', + type: 'text', + }, + ], + name: 'body', + type: 'text', + }, + ], + name: mockEngine.name, + updated: mockEngine.updated, + }); + + expect(mockClient.asCurrentUser.fieldCaps).toHaveBeenCalledTimes(1); + expect(mockClient.asCurrentUser.fieldCaps).toHaveBeenCalledWith({ + fields: '*', + include_unmapped: true, + index: 'search-engine-unit-test-engine', + }); }); + }); - expect(mockClient.asCurrentUser.fieldCaps).toHaveBeenCalledTimes(1); - expect(mockClient.asCurrentUser.fieldCaps).toHaveBeenCalledWith({ - fields: '*', - include_unmapped: true, - index: 'search-engine-unit-test-engine', + describe('parseFieldsCapabilities', () => { + it('parse field capabilities to a list of fields', () => { + const fieldCapabilities: FieldCapsResponse = { + fields: { + body: { + text: { + aggregatable: false, + metadata_field: false, + searchable: true, + type: 'text', + }, + }, + views: { + number: { + aggregatable: false, + metadata_field: false, + searchable: false, + type: 'number', + }, + }, + }, + indices: ['index-001'], + }; + const expectedFields: SchemaField[] = [ + { + fields: [], + indices: [ + { + name: 'index-001', + type: 'text', + }, + ], + name: 'body', + type: 'text', + }, + { + fields: [], + indices: [ + { + name: 'index-001', + type: 'number', + }, + ], + name: 'views', + type: 'number', + }, + ]; + expect(parseFieldsCapabilities(fieldCapabilities)).toEqual(expectedFields); + }); + it('handles multi-fields', () => { + const fieldCapabilities: FieldCapsResponse = { + fields: { + body: { + text: { + aggregatable: false, + metadata_field: false, + searchable: true, + type: 'text', + }, + }, + 'body.keyword': { + keyword: { + aggregatable: true, + metadata_field: false, + searchable: true, + type: 'keyword', + }, + }, + }, + indices: ['index-001'], + }; + const expectedFields: SchemaField[] = [ + { + fields: [ + { + fields: [], + indices: [ + { + name: 'index-001', + type: 'keyword', + }, + ], + name: 'keyword', + type: 'keyword', + }, + ], + indices: [ + { + name: 'index-001', + type: 'text', + }, + ], + name: 'body', + type: 'text', + }, + ]; + expect(parseFieldsCapabilities(fieldCapabilities)).toEqual(expectedFields); + }); + it('handles object fields', () => { + const fieldCapabilities: FieldCapsResponse = { + fields: { + name: { + object: { + aggregatable: false, + metadata_field: false, + searchable: false, + type: 'object', + }, + }, + 'name.first': { + text: { + aggregatable: false, + metadata_field: false, + searchable: true, + type: 'text', + }, + }, + 'name.last': { + text: { + aggregatable: false, + metadata_field: false, + searchable: true, + type: 'text', + }, + }, + }, + indices: ['index-001'], + }; + const expectedFields: SchemaField[] = [ + { + fields: [ + { + fields: [], + indices: [ + { + name: 'index-001', + type: 'text', + }, + ], + name: 'first', + type: 'text', + }, + { + fields: [], + indices: [ + { + name: 'index-001', + type: 'text', + }, + ], + name: 'last', + type: 'text', + }, + ], + indices: [ + { + name: 'index-001', + type: 'object', + }, + ], + name: 'name', + type: 'object', + }, + ]; + expect(parseFieldsCapabilities(fieldCapabilities)).toEqual(expectedFields); + }); + it('handles nested fields', () => { + const fieldCapabilities: FieldCapsResponse = { + fields: { + name: { + nested: { + aggregatable: false, + metadata_field: false, + searchable: false, + type: 'nested', + }, + }, + 'name.first': { + text: { + aggregatable: false, + metadata_field: false, + searchable: true, + type: 'text', + }, + }, + 'name.last': { + text: { + aggregatable: false, + metadata_field: false, + searchable: true, + type: 'text', + }, + }, + }, + indices: ['index-001'], + }; + const expectedFields: SchemaField[] = [ + { + fields: [ + { + fields: [], + indices: [ + { + name: 'index-001', + type: 'text', + }, + ], + name: 'first', + type: 'text', + }, + { + fields: [], + indices: [ + { + name: 'index-001', + type: 'text', + }, + ], + name: 'last', + type: 'text', + }, + ], + indices: [ + { + name: 'index-001', + type: 'nested', + }, + ], + name: 'name', + type: 'nested', + }, + ]; + expect(parseFieldsCapabilities(fieldCapabilities)).toEqual(expectedFields); + }); + it('handles unmapped fields', () => { + const fieldCapabilities: FieldCapsResponse = { + fields: { + body: { + text: { + aggregatable: false, + indices: ['index-001'], + metadata_field: false, + searchable: true, + type: 'text', + }, + unmapped: { + aggregatable: false, + indices: ['index-002'], + metadata_field: false, + searchable: true, + type: 'unmapped', + }, + }, + }, + indices: ['index-001', 'index-002'], + }; + const expectedFields: SchemaField[] = [ + { + fields: [], + indices: [ + { + name: 'index-001', + type: 'text', + }, + { + name: 'index-002', + type: 'unmapped', + }, + ], + name: 'body', + type: 'text', + }, + ]; + expect(parseFieldsCapabilities(fieldCapabilities)).toEqual(expectedFields); + }); + it('handles conflicts in top-level fields', () => { + const fieldCapabilities: FieldCapsResponse = { + fields: { + name: { + object: { + aggregatable: false, + indices: ['index-002'], + metadata_field: false, + searchable: false, + type: 'object', + }, + text: { + aggregatable: false, + indices: ['index-001'], + metadata_field: false, + searchable: true, + type: 'text', + }, + }, + 'name.first': { + text: { + aggregatable: false, + indices: ['index-002'], + metadata_field: false, + searchable: true, + type: 'text', + }, + unmapped: { + aggregatable: false, + indices: ['index-001'], + metadata_field: false, + searchable: true, + type: 'unmapped', + }, + }, + 'name.last': { + text: { + aggregatable: false, + indices: ['index-002'], + metadata_field: false, + searchable: true, + type: 'text', + }, + unmapped: { + aggregatable: false, + indices: ['index-001'], + metadata_field: false, + searchable: true, + type: 'unmapped', + }, + }, + }, + indices: ['index-001', 'index-002'], + }; + const expectedFields: SchemaField[] = [ + { + fields: [ + { + fields: [], + indices: [ + { + name: 'index-002', + type: 'text', + }, + { + name: 'index-001', + type: 'unmapped', + }, + ], + name: 'first', + type: 'text', + }, + { + fields: [], + indices: [ + { + name: 'index-002', + type: 'text', + }, + { + name: 'index-001', + type: 'unmapped', + }, + ], + name: 'last', + type: 'text', + }, + ], + indices: [ + { + name: 'index-002', + type: 'object', + }, + { + name: 'index-001', + type: 'text', + }, + ], + name: 'name', + type: 'conflict', + }, + ]; + expect(parseFieldsCapabilities(fieldCapabilities)).toEqual(expectedFields); + }); + it('handles conflicts of more than two indices', () => { + const fieldCapabilities: FieldCapsResponse = { + fields: { + name: { + keyword: { + aggregatable: false, + indices: ['index-003'], + metadata_field: false, + searchable: true, + type: 'keyword', + }, + object: { + aggregatable: false, + indices: ['index-002'], + metadata_field: false, + searchable: false, + type: 'object', + }, + text: { + aggregatable: false, + indices: ['index-001'], + metadata_field: false, + searchable: true, + type: 'text', + }, + }, + 'name.first': { + text: { + aggregatable: false, + indices: ['index-002', 'index-003'], + metadata_field: false, + searchable: true, + type: 'text', + }, + unmapped: { + aggregatable: false, + indices: ['index-001'], + metadata_field: false, + searchable: true, + type: 'unmapped', + }, + }, + 'name.last': { + text: { + aggregatable: false, + indices: ['index-002'], + metadata_field: false, + searchable: true, + type: 'text', + }, + unmapped: { + aggregatable: false, + indices: ['index-001'], + metadata_field: false, + searchable: true, + type: 'unmapped', + }, + }, + }, + indices: ['index-001', 'index-002', 'index-003'], + }; + + const expectedFields: SchemaField[] = [ + { + fields: [ + { + fields: [], + indices: [ + { + name: 'index-002', + type: 'text', + }, + { + name: 'index-003', + type: 'text', + }, + { + name: 'index-001', + type: 'unmapped', + }, + ], + name: 'first', + type: 'text', + }, + { + fields: [], + indices: [ + { + name: 'index-002', + type: 'text', + }, + { + name: 'index-001', + type: 'unmapped', + }, + ], + name: 'last', + type: 'text', + }, + ], + indices: [ + { + name: 'index-003', + type: 'keyword', + }, + { + name: 'index-002', + type: 'object', + }, + { + name: 'index-001', + type: 'text', + }, + ], + name: 'name', + type: 'conflict', + }, + ]; + expect(parseFieldsCapabilities(fieldCapabilities)).toEqual(expectedFields); + }); + it('handles conflicts & unmapped fields together', () => { + const fieldCapabilities: FieldCapsResponse = { + fields: { + body: { + text: { + aggregatable: false, + indices: ['index-003'], + metadata_field: false, + searchable: true, + type: 'text', + }, + unmapped: { + aggregatable: false, + indices: ['index-001', 'index-002'], + metadata_field: false, + searchable: true, + type: 'unmapped', + }, + }, + name: { + object: { + aggregatable: false, + indices: ['index-002'], + metadata_field: false, + searchable: false, + type: 'object', + }, + text: { + aggregatable: false, + indices: ['index-001'], + metadata_field: false, + searchable: true, + type: 'text', + }, + unmapped: { + aggregatable: false, + indices: ['index-003'], + metadata_field: false, + searchable: true, + type: 'unmapped', + }, + }, + 'name.first': { + text: { + aggregatable: false, + indices: ['index-002'], + metadata_field: false, + searchable: true, + type: 'text', + }, + unmapped: { + aggregatable: false, + indices: ['index-001', 'index-003'], + metadata_field: false, + searchable: true, + type: 'unmapped', + }, + }, + 'name.last': { + text: { + aggregatable: false, + indices: ['index-002'], + metadata_field: false, + searchable: true, + type: 'text', + }, + unmapped: { + aggregatable: false, + indices: ['index-001', 'index-003'], + metadata_field: false, + searchable: true, + type: 'unmapped', + }, + }, + }, + indices: ['index-001', 'index-002', 'index-003'], + }; + const expectedFields: SchemaField[] = [ + { + fields: [], + indices: [ + { + name: 'index-003', + type: 'text', + }, + { + name: 'index-001', + type: 'unmapped', + }, + { + name: 'index-002', + type: 'unmapped', + }, + ], + name: 'body', + type: 'text', + }, + { + fields: [ + { + fields: [], + indices: [ + { + name: 'index-002', + type: 'text', + }, + { + name: 'index-001', + type: 'unmapped', + }, + { + name: 'index-003', + type: 'unmapped', + }, + ], + name: 'first', + type: 'text', + }, + { + fields: [], + indices: [ + { + name: 'index-002', + type: 'text', + }, + { + name: 'index-001', + type: 'unmapped', + }, + { + name: 'index-003', + type: 'unmapped', + }, + ], + name: 'last', + type: 'text', + }, + ], + indices: [ + { + name: 'index-002', + type: 'object', + }, + { + name: 'index-001', + type: 'text', + }, + { + name: 'index-003', + type: 'unmapped', + }, + ], + name: 'name', + type: 'conflict', + }, + ]; + expect(parseFieldsCapabilities(fieldCapabilities)).toEqual(expectedFields); + }); + it('handles unmapped sub-fields in object fields', () => { + const fieldCapabilities: FieldCapsResponse = { + fields: { + name: { + object: { + aggregatable: false, + metadata_field: false, + searchable: false, + type: 'object', + }, + }, + 'name.first': { + text: { + aggregatable: false, + metadata_field: false, + searchable: true, + type: 'text', + }, + }, + 'name.last': { + text: { + aggregatable: false, + indices: ['index-001'], + metadata_field: false, + searchable: true, + type: 'text', + }, + unmapped: { + aggregatable: false, + indices: ['index-002'], + metadata_field: false, + searchable: true, + type: 'unmapped', + }, + }, + }, + indices: ['index-001', 'index-002'], + }; + const expectedFields: SchemaField[] = [ + { + fields: [ + { + fields: [], + indices: [ + { + name: 'index-001', + type: 'text', + }, + { + name: 'index-002', + type: 'text', + }, + ], + name: 'first', + type: 'text', + }, + { + fields: [], + indices: [ + { + name: 'index-001', + type: 'text', + }, + { + name: 'index-002', + type: 'unmapped', + }, + ], + name: 'last', + type: 'text', + }, + ], + indices: [ + { + name: 'index-001', + type: 'object', + }, + { + name: 'index-002', + type: 'object', + }, + ], + name: 'name', + type: 'object', + }, + ]; + expect(parseFieldsCapabilities(fieldCapabilities)).toEqual(expectedFields); + }); + it('handles unmapped sub-fields in nested fields', () => { + const fieldCapabilities: FieldCapsResponse = { + fields: { + name: { + nested: { + aggregatable: false, + metadata_field: false, + searchable: false, + type: 'nested', + }, + }, + 'name.first': { + text: { + aggregatable: false, + metadata_field: false, + searchable: true, + type: 'text', + }, + }, + 'name.last': { + text: { + aggregatable: false, + indices: ['index-001'], + metadata_field: false, + searchable: true, + type: 'text', + }, + unmapped: { + aggregatable: false, + indices: ['index-002'], + metadata_field: false, + searchable: true, + type: 'unmapped', + }, + }, + }, + indices: ['index-001', 'index-002'], + }; + const expectedFields: SchemaField[] = [ + { + fields: [ + { + fields: [], + indices: [ + { + name: 'index-001', + type: 'text', + }, + { + name: 'index-002', + type: 'text', + }, + ], + name: 'first', + type: 'text', + }, + { + fields: [], + indices: [ + { + name: 'index-001', + type: 'text', + }, + { + name: 'index-002', + type: 'unmapped', + }, + ], + name: 'last', + type: 'text', + }, + ], + indices: [ + { + name: 'index-001', + type: 'nested', + }, + { + name: 'index-002', + type: 'nested', + }, + ], + name: 'name', + type: 'nested', + }, + ]; + expect(parseFieldsCapabilities(fieldCapabilities)).toEqual(expectedFields); + }); + it('handles unmapped multi fields', () => { + const fieldCapabilities: FieldCapsResponse = { + fields: { + body: { + text: { + aggregatable: false, + metadata_field: false, + searchable: true, + type: 'text', + }, + }, + 'body.keyword': { + keyword: { + aggregatable: true, + indices: ['index-001'], + metadata_field: false, + searchable: true, + type: 'keyword', + }, + unmapped: { + aggregatable: false, + indices: ['index-002'], + metadata_field: false, + searchable: true, + type: 'unmapped', + }, + }, + }, + indices: ['index-001', 'index-002'], + }; + const expectedFields: SchemaField[] = [ + { + fields: [ + { + fields: [], + indices: [ + { + name: 'index-001', + type: 'keyword', + }, + { + name: 'index-002', + type: 'unmapped', + }, + ], + name: 'keyword', + type: 'keyword', + }, + ], + indices: [ + { + name: 'index-001', + type: 'text', + }, + { + name: 'index-002', + type: 'text', + }, + ], + name: 'body', + type: 'text', + }, + ]; + expect(parseFieldsCapabilities(fieldCapabilities)).toEqual(expectedFields); + }); + it('handles conflicts in object fields', () => { + const fieldCapabilities: FieldCapsResponse = { + fields: { + order: { + object: { + aggregatable: false, + metadata_field: false, + searchable: false, + type: 'object', + }, + }, + 'order.id': { + number: { + aggregatable: false, + indices: ['index-002'], + metadata_field: false, + searchable: false, + type: 'number', + }, + text: { + aggregatable: false, + indices: ['index-001'], + metadata_field: false, + searchable: true, + type: 'text', + }, + }, + }, + indices: ['index-001', 'index-002'], + }; + const expectedFields: SchemaField[] = [ + { + fields: [ + { + fields: [], + indices: [ + { + name: 'index-002', + type: 'number', + }, + { + name: 'index-001', + type: 'text', + }, + ], + name: 'id', + type: 'conflict', + }, + ], + indices: [ + { + name: 'index-001', + type: 'object', + }, + { + name: 'index-002', + type: 'object', + }, + ], + name: 'order', + type: 'object', // Should this be 'conflict' too? + }, + ]; + expect(parseFieldsCapabilities(fieldCapabilities)).toEqual(expectedFields); + }); + it('handles conflicts in nested fields', () => { + const fieldCapabilities: FieldCapsResponse = { + fields: { + order: { + nested: { + aggregatable: false, + metadata_field: false, + searchable: false, + type: 'nested', + }, + }, + 'order.id': { + number: { + aggregatable: false, + indices: ['index-002'], + metadata_field: false, + searchable: false, + type: 'number', + }, + text: { + aggregatable: false, + indices: ['index-001'], + metadata_field: false, + searchable: true, + type: 'text', + }, + }, + }, + indices: ['index-001', 'index-002'], + }; + const expectedFields: SchemaField[] = [ + { + fields: [ + { + fields: [], + indices: [ + { + name: 'index-002', + type: 'number', + }, + { + name: 'index-001', + type: 'text', + }, + ], + name: 'id', + type: 'conflict', + }, + ], + indices: [ + { + name: 'index-001', + type: 'nested', + }, + { + name: 'index-002', + type: 'nested', + }, + ], + name: 'order', + type: 'nested', // Should this be 'conflict' too? + }, + ]; + expect(parseFieldsCapabilities(fieldCapabilities)).toEqual(expectedFields); }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/lib/engines/field_capabilities.ts b/x-pack/plugins/enterprise_search/server/lib/engines/field_capabilities.ts index ed42ab744621c..59ccafab8d7d1 100644 --- a/x-pack/plugins/enterprise_search/server/lib/engines/field_capabilities.ts +++ b/x-pack/plugins/enterprise_search/server/lib/engines/field_capabilities.ts @@ -5,11 +5,13 @@ * 2.0. */ +import { FieldCapsResponse, FieldCapsFieldCapability } from '@elastic/elasticsearch/lib/api/types'; import { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; import { EnterpriseSearchEngineDetails, EnterpriseSearchEngineFieldCapabilities, + SchemaField, } from '../../../common/types/engines'; export const fetchEngineFieldCapabilities = async ( @@ -22,13 +24,70 @@ export const fetchEngineFieldCapabilities = async ( include_unmapped: true, index: getEngineIndexAliasName(name), }); + const fields = parseFieldsCapabilities(fieldCapabilities); return { created, field_capabilities: fieldCapabilities, + fields, name, updated, }; }; +const ensureIndices = (indices: string | string[] | undefined): string[] => { + if (!indices) return []; + return Array.isArray(indices) ? indices : [indices]; +}; + +export const parseFieldsCapabilities = ( + fieldCapsResponse: FieldCapsResponse, + prefix: string = '' +): SchemaField[] => { + const { fields, indices: indexOrIndices } = fieldCapsResponse; + const inThisPass: Array<[string, Record]> = Object.entries( + fields + ) + .filter(([key]) => key.startsWith(prefix)) + .map(([key, value]) => [key.replace(prefix, ''), value]); + + const atThisLevel = inThisPass.filter(([key]) => !key.includes('.')); + + return atThisLevel.map(([name, value]) => { + const type = calculateType(Object.keys(value)); + let indices = Object.values(value).flatMap((fieldCaps) => { + return ensureIndices(fieldCaps.indices).map((index) => ({ + name: index, + type: fieldCaps.type, + })); + }); + + indices = + indices.length === 0 + ? ensureIndices(indexOrIndices).map((index) => ({ name: index, type })) + : indices; + + const subFields = parseFieldsCapabilities(fieldCapsResponse, `${prefix}${name}.`); + return { + fields: subFields, + indices, + name, + type, + }; + }); +}; + +const calculateType = (types: string[]): string => { + // If there is only one type, return it + if (types.length === 1) return types[0]; + + // Unmapped types are ignored for the purposes of determining the type + // If all of the mapped types are the same, return that type + const mapped = types.filter((t) => t !== 'unmapped'); + if (new Set(mapped).size === 1) return mapped[0]; + + // Otherwise there is a conflict + return 'conflict'; +}; + // Note: This will likely need to be modified when engines move to es module const getEngineIndexAliasName = (engineName: string): string => `search-engine-${engineName}`; diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 8135997afb79d..9f4c17f3915d8 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -266,7 +266,7 @@ export class EnterpriseSearchPlugin implements Plugin { infra.logViews.defineInternalLogView(ENTERPRISE_SEARCH_ANALYTICS_LOGS_SOURCE_ID, { logIndices: { - indexName: 'logs-elastic_analytics.events-*', + indexName: 'behavioral_analytics-events-*', type: 'index_name', }, name: 'Enterprise Search Behavioral Analytics Logs', diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/analytics.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/analytics.test.ts index f216a23e58e0a..99dec4653e229 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/analytics.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/analytics.test.ts @@ -12,7 +12,7 @@ import { RequestHandlerContext } from '@kbn/core/server'; import { DataPluginStart } from '@kbn/data-plugin/server/plugin'; jest.mock('../../lib/analytics/fetch_analytics_collection', () => ({ - fetchAnalyticsCollectionById: jest.fn(), + fetchAnalyticsCollections: jest.fn(), })); jest.mock('../../lib/analytics/fetch_analytics_collection_data_view_id', () => ({ @@ -24,7 +24,7 @@ import { AnalyticsCollectionDataViewId, } from '../../../common/types/analytics'; import { ErrorCode } from '../../../common/types/error_codes'; -import { fetchAnalyticsCollectionById } from '../../lib/analytics/fetch_analytics_collection'; +import { fetchAnalyticsCollections } from '../../lib/analytics/fetch_analytics_collection'; import { fetchAnalyticsCollectionDataViewId } from '../../lib/analytics/fetch_analytics_collection_data_view_id'; import { registerAnalyticsRoutes } from './analytics'; @@ -42,7 +42,7 @@ describe('Enterprise Search Analytics API', () => { mockRouter = new MockRouter({ context, method: 'get', - path: '/internal/enterprise_search/analytics/collections/{id}', + path: '/internal/enterprise_search/analytics/collections/{name}', }); const mockDataPlugin = { @@ -64,29 +64,29 @@ describe('Enterprise Search Analytics API', () => { }); it('fetches a defined analytics collection name', async () => { - const mockData: AnalyticsCollection = { - event_retention_day_length: 30, - events_datastream: 'logs-elastic_analytics.events-example', - id: '1', - name: 'my_collection', - }; + const mockData: AnalyticsCollection[] = [ + { + events_datastream: 'logs-elastic_analytics.events-example', + name: 'my_collection', + }, + ]; - (fetchAnalyticsCollectionById as jest.Mock).mockImplementationOnce(() => { + (fetchAnalyticsCollections as jest.Mock).mockImplementationOnce(() => { return Promise.resolve(mockData); }); - await mockRouter.callRoute({ params: { id: '1' } }); + await mockRouter.callRoute({ params: { name: '1' } }); expect(mockRouter.response.ok).toHaveBeenCalledWith({ - body: mockData, + body: mockData[0], }); }); it('throws a 404 error if data returns an empty obj', async () => { - (fetchAnalyticsCollectionById as jest.Mock).mockImplementationOnce(() => { - return Promise.resolve(undefined); + (fetchAnalyticsCollections as jest.Mock).mockImplementationOnce(() => { + throw new Error(ErrorCode.ANALYTICS_COLLECTION_NOT_FOUND); }); await mockRouter.callRoute({ - params: { id: 'my_collection' }, + params: { name: 'my_collection' }, }); expect(mockRouter.response.customError).toHaveBeenCalledWith({ @@ -101,7 +101,7 @@ describe('Enterprise Search Analytics API', () => { }); }); - describe('GET /internal/enterprise_search/analytics/collections/{id}/data_view_id', () => { + describe('GET /internal/enterprise_search/analytics/collections/{name}/data_view_id', () => { beforeEach(() => { const context = { core: Promise.resolve({ elasticsearch: { client: mockClient } }), @@ -110,7 +110,7 @@ describe('Enterprise Search Analytics API', () => { mockRouter = new MockRouter({ context, method: 'get', - path: '/internal/enterprise_search/analytics/collections/{id}/data_view_id', + path: '/internal/enterprise_search/analytics/collections/{name}/data_view_id', }); const mockDataPlugin = { @@ -131,7 +131,7 @@ describe('Enterprise Search Analytics API', () => { }); }); - it('fetches a defined data view id by collection id', async () => { + it('fetches a defined data view id by collection name', async () => { const mockData: AnalyticsCollectionDataViewId = { data_view_id: '03fca-1234-5678-9abc-1234', }; @@ -139,19 +139,19 @@ describe('Enterprise Search Analytics API', () => { (fetchAnalyticsCollectionDataViewId as jest.Mock).mockImplementationOnce(() => { return Promise.resolve(mockData); }); - await mockRouter.callRoute({ params: { id: '1' } }); + await mockRouter.callRoute({ params: { name: '1' } }); expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: mockData, }); }); - it('throws a 404 error if collection not found by id', async () => { + it('throws a 404 error if collection not found by name', async () => { (fetchAnalyticsCollectionDataViewId as jest.Mock).mockImplementationOnce(() => { throw new Error(ErrorCode.ANALYTICS_COLLECTION_NOT_FOUND); }); await mockRouter.callRoute({ - params: { id: '1' }, + params: { name: '1' }, }); expect(mockRouter.response.customError).toHaveBeenCalledWith({ diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/analytics.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/analytics.ts index 724e159618df6..73bd29c455da6 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/analytics.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/analytics.ts @@ -16,10 +16,7 @@ import { ErrorCode } from '../../../common/types/error_codes'; import { addAnalyticsCollection } from '../../lib/analytics/add_analytics_collection'; import { analyticsEventsIndexExists } from '../../lib/analytics/analytics_events_index_exists'; import { deleteAnalyticsCollectionById } from '../../lib/analytics/delete_analytics_collection'; -import { - fetchAnalyticsCollectionById, - fetchAnalyticsCollections, -} from '../../lib/analytics/fetch_analytics_collection'; +import { fetchAnalyticsCollections } from '../../lib/analytics/fetch_analytics_collection'; import { fetchAnalyticsCollectionDataViewId } from '../../lib/analytics/fetch_analytics_collection_data_view_id'; import { RouteDependencies } from '../../plugin'; import { createError } from '../../utils/create_error'; @@ -64,10 +61,10 @@ export function registerAnalyticsRoutes({ router.get( { - path: '/internal/enterprise_search/analytics/collections/{id}', + path: '/internal/enterprise_search/analytics/collections/{name}', validate: { params: schema.object({ - id: schema.string(), + name: schema.string(), }), }, }, @@ -75,13 +72,9 @@ export function registerAnalyticsRoutes({ const { client } = (await context.core).elasticsearch; try { - const collection = await fetchAnalyticsCollectionById(client, request.params.id); - - if (!collection) { - throw new Error(ErrorCode.ANALYTICS_COLLECTION_NOT_FOUND); - } + const collections = await fetchAnalyticsCollections(client, request.params.name); - return response.ok({ body: collection }); + return response.ok({ body: collections[0] }); } catch (error) { if ((error as Error).message === ErrorCode.ANALYTICS_COLLECTION_NOT_FOUND) { return createIndexNotFoundError(error, response); @@ -114,7 +107,7 @@ export function registerAnalyticsRoutes({ const body = await addAnalyticsCollection( elasticsearchClient, dataViewsService, - request.body + request.body.name ); return response.ok({ body }); } catch (error) { @@ -139,17 +132,17 @@ export function registerAnalyticsRoutes({ router.delete( { - path: '/internal/enterprise_search/analytics/collections/{id}', + path: '/internal/enterprise_search/analytics/collections/{name}', validate: { params: schema.object({ - id: schema.string(), + name: schema.string(), }), }, }, elasticsearchErrorHandler(log, async (context, request, response) => { const { client } = (await context.core).elasticsearch; try { - await deleteAnalyticsCollectionById(client, request.params.id); + await deleteAnalyticsCollectionById(client, request.params.name); return response.ok(); } catch (error) { if ((error as Error).message === ErrorCode.ANALYTICS_COLLECTION_NOT_FOUND) { @@ -162,17 +155,17 @@ export function registerAnalyticsRoutes({ router.get( { - path: '/internal/enterprise_search/analytics/events/{id}/exists', + path: '/internal/enterprise_search/analytics/events/{name}/exists', validate: { params: schema.object({ - id: schema.string(), + name: schema.string(), }), }, }, elasticsearchErrorHandler(log, async (context, request, response) => { const { client } = (await context.core).elasticsearch; - const eventsIndexExists = await analyticsEventsIndexExists(client, request.params.id); + const eventsIndexExists = await analyticsEventsIndexExists(client, request.params.name); if (!eventsIndexExists) { return response.ok({ body: { exists: false } }); @@ -184,10 +177,10 @@ export function registerAnalyticsRoutes({ router.get( { - path: '/internal/enterprise_search/analytics/collections/{id}/data_view_id', + path: '/internal/enterprise_search/analytics/collections/{name}/data_view_id', validate: { params: schema.object({ - id: schema.string(), + name: schema.string(), }), }, }, @@ -204,7 +197,7 @@ export function registerAnalyticsRoutes({ const dataViewId = await fetchAnalyticsCollectionDataViewId( elasticsearchClient, dataViewsService, - request.params.id + request.params.name ); return response.ok({ body: dataViewId }); diff --git a/x-pack/plugins/fleet/server/services/elastic_agent_manifest.ts b/x-pack/plugins/fleet/server/services/elastic_agent_manifest.ts index 7ae83f9cece5c..1488d81b327cd 100644 --- a/x-pack/plugins/fleet/server/services/elastic_agent_manifest.ts +++ b/x-pack/plugins/fleet/server/services/elastic_agent_manifest.ts @@ -73,6 +73,8 @@ spec: value: "/etc/elastic-agent" securityContext: runAsUser: 0 + # Needed for 'Defend for containers' integration (cloud-defend) + #privileged: true resources: limits: memory: 700Mi @@ -105,6 +107,17 @@ spec: - name: var-lib mountPath: /hostfs/var/lib readOnly: true + # Needed for 'Defend for containers' integration (cloud-defend) + #- name: boot + # mountPath: /boot + # readOnly: true + #- name: sys-kernel-debug + # mountPath: /sys/kernel/debug + #- name: sys-fs-bpf + # mountPath: /sys/fs/bpf + #- name: sys-kernel-security + # mountPath: /sys/kernel/security + # readOnly: true volumes: - name: datastreams configMap: @@ -134,6 +147,19 @@ spec: - name: var-lib hostPath: path: /var/lib + # Needed for 'Defend for containers' integration (cloud-defend) + #- name: boot + # hostPath: + # path: /boot + #- name: sys-kernel-debug + # hostPath: + # path: /sys/kernel/debug + #- name: sys-fs-bpf + # hostPath: + # path: /sys/fs/bpf + #- name: sys-kernel-security + # hostPath: + # path: /sys/kernel/security --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding @@ -353,12 +379,14 @@ spec: fieldPath: metadata.name securityContext: runAsUser: 0 + # Needed for 'Defend for containers' integration (cloud-defend) + #privileged: true resources: limits: - memory: 500Mi + memory: 700Mi requests: cpu: 100m - memory: 200Mi + memory: 400Mi volumeMounts: - name: proc mountPath: /hostfs/proc @@ -381,6 +409,17 @@ spec: - name: etc-mid mountPath: /etc/machine-id readOnly: true + # Needed for 'Defend for containers' integration (cloud-defend) + #- name: boot + # mountPath: /boot + # readOnly: true + #- name: sys-kernel-debug + # mountPath: /sys/kernel/debug + #- name: sys-fs-bpf + # mountPath: /sys/fs/bpf + #- name: sys-kernel-security + # mountPath: /sys/kernel/security + # readOnly: true volumes: - name: proc hostPath: @@ -409,6 +448,19 @@ spec: hostPath: path: /etc/machine-id type: File + # Needed for 'Defend for containers' integration (cloud-defend) + #- name: boot + # hostPath: + # path: /boot + #- name: sys-kernel-debug + # hostPath: + # path: /sys/kernel/debug + #- name: sys-fs-bpf + # hostPath: + # path: /sys/fs/bpf + #- name: sys-kernel-security + # hostPath: + # path: /sys/kernel/security --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/alerts/alerts_tab_content.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/alerts/alerts_tab_content.tsx index f190b983287e6..7fa60cf7216e2 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/alerts/alerts_tab_content.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/alerts/alerts_tab_content.tsx @@ -101,14 +101,14 @@ const MemoAlertSummaryWidget = React.memo( const { charts, triggersActionsUi } = services; const { getAlertSummaryWidget: AlertSummaryWidget } = triggersActionsUi; - const chartThemes = { + const chartProps = { theme: charts.theme.useChartsTheme(), baseTheme: charts.theme.useChartsBaseTheme(), }; return ( { primary: isPrimary, start_time_in_millis: startTimeInMillis, total_time_in_millis: totalTimeInMillis, + start_time: mbStartTime, + total_time: mbTotalTime, source, target, translog, @@ -47,8 +49,8 @@ export const parseProps = (props) => { shard: `${id} / ${isPrimary ? 'Primary' : 'Replica'}`, relocationType: type === 'PRIMARY_RELOCATION' ? 'Primary Relocation' : normalizeString(type), stage: normalizeString(stage), - startTime: formatDateTimeLocal(startTimeInMillis, timezone), - totalTime: formatMetric(Math.floor(totalTimeInMillis / 1000), '00:00:00'), + startTime: formatDateTimeLocal(startTimeInMillis || mbStartTime?.ms, timezone), + totalTime: formatMetric(Math.floor((totalTimeInMillis || mbTotalTime?.ms) / 1000), '00:00:00'), isCopiedFromPrimary: !isPrimary || type === 'PRIMARY_RELOCATION', sourceName: source.name === undefined ? 'n/a' : source.name, targetName: target.name, diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.ts index 1a07565dd74ad..81262ddb3e3f0 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/get_last_recovery.ts @@ -12,6 +12,7 @@ import { ElasticsearchMetric } from '../metrics'; import { ElasticsearchResponse, ElasticsearchIndexRecoveryShard, + ElasticsearchMetricbeatIndexRecoveryShard, ElasticsearchResponseHit, } from '../../../common/types/es'; import { LegacyRequest } from '../../types'; @@ -30,12 +31,22 @@ import { Globals } from '../../static_globals'; * @returns {boolean} true to keep */ export function filterOldShardActivity(startMs: number) { - return (activity?: ElasticsearchIndexRecoveryShard) => { + return ( + activity?: ElasticsearchIndexRecoveryShard | ElasticsearchMetricbeatIndexRecoveryShard + ) => { + if (!activity) { + return false; + } + + let stopTime = null; + if ((activity as ElasticsearchMetricbeatIndexRecoveryShard).stop_time) { + stopTime = (activity as ElasticsearchMetricbeatIndexRecoveryShard).stop_time?.ms; + } else { + stopTime = (activity as ElasticsearchIndexRecoveryShard).stop_time_in_millis; + } + // either it's still going and there is no stop time, or the stop time happened after we started looking for one - return ( - activity && - (!_.isNumber(activity.stop_time_in_millis) || activity.stop_time_in_millis >= startMs) - ); + return !_.isNumber(stopTime) || stopTime >= startMs; }; } @@ -78,9 +89,16 @@ export function handleMbLastRecoveries(resp: ElasticsearchResponse, start: numbe (hit) => hit._source.elasticsearch?.index?.recovery ); const filtered = mapped.filter(filterOldShardActivity(moment.utc(start).valueOf())); - filtered.sort((a, b) => - a && b ? (b.start_time_in_millis ?? 0) - (a.start_time_in_millis ?? 0) : 0 - ); + filtered.sort((a, b) => { + if (!a || !b) { + return 0; + } + + const startTimeA = a.start_time?.ms || 0; + const startTimeB = b.start_time?.ms || 0; + + return startTimeB - startTimeA; + }); return filtered; } diff --git a/x-pack/plugins/observability/public/components/shared/alert_search_bar/containers/use_alert_search_bar_state_container.tsx b/x-pack/plugins/observability/public/components/shared/alert_search_bar/containers/use_alert_search_bar_state_container.tsx index cae32f3a9e304..4a658d3c261b7 100644 --- a/x-pack/plugins/observability/public/components/shared/alert_search_bar/containers/use_alert_search_bar_state_container.tsx +++ b/x-pack/plugins/observability/public/components/shared/alert_search_bar/containers/use_alert_search_bar_state_container.tsx @@ -40,10 +40,13 @@ export const alertSearchBarState = t.partial({ ]), }); -export function useAlertSearchBarStateContainer(urlStorageKey: string) { +export function useAlertSearchBarStateContainer( + urlStorageKey: string, + { replace }: { replace?: boolean } = {} +) { const stateContainer = useContainer(); - useUrlStateSyncEffect(stateContainer, urlStorageKey); + useUrlStateSyncEffect(stateContainer, urlStorageKey, replace); const { setRangeFrom, setRangeTo, setKuery, setStatus } = stateContainer.transitions; const { rangeFrom, rangeTo, kuery, status } = useContainerSelector( @@ -65,7 +68,8 @@ export function useAlertSearchBarStateContainer(urlStorageKey: string) { function useUrlStateSyncEffect( stateContainer: AlertSearchBarStateContainer, - urlStorageKey: string + urlStorageKey: string, + replace: boolean = true ) { const history = useHistory(); const timefilterService = useTimefilterService(); @@ -76,7 +80,12 @@ function useUrlStateSyncEffect( useHash: false, useHashQuery: false, }); - const { start, stop } = setupUrlStateSync(stateContainer, urlStateStorage, urlStorageKey); + const { start, stop } = setupUrlStateSync( + stateContainer, + urlStateStorage, + urlStorageKey, + replace + ); start(); @@ -84,17 +93,19 @@ function useUrlStateSyncEffect( timefilterService, stateContainer, urlStateStorage, - urlStorageKey + urlStorageKey, + replace ); return stop; - }, [stateContainer, history, timefilterService, urlStorageKey]); + }, [stateContainer, history, timefilterService, urlStorageKey, replace]); } function setupUrlStateSync( stateContainer: AlertSearchBarStateContainer, urlStateStorage: IKbnUrlStateStorage, - urlStorageKey: string + urlStorageKey: string, + replace: boolean = true ) { // This handles filling the state when an incomplete URL set is provided const setWithDefaults = (changedState: Partial | null) => { @@ -110,7 +121,7 @@ function setupUrlStateSync( stateStorage: { ...urlStateStorage, set: (key: string, state: AlertSearchBarStateContainer) => - urlStateStorage.set(key, state, { replace: true }), + urlStateStorage.set(key, state, { replace }), }, }); } @@ -119,7 +130,8 @@ function syncUrlStateWithInitialContainerState( timefilterService: TimefilterContract, stateContainer: AlertSearchBarStateContainer, urlStateStorage: IKbnUrlStateStorage, - urlStorageKey: string + urlStorageKey: string, + replace: boolean = true ) { const urlState = alertSearchBarState.decode( urlStateStorage.get>(urlStorageKey) @@ -148,6 +160,6 @@ function syncUrlStateWithInitialContainerState( } urlStateStorage.set(urlStorageKey, stateContainer.get(), { - replace: true, + replace, }); } diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts.tsx index 06218a37ad6b2..0f816ebfffeeb 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts.tsx @@ -6,6 +6,7 @@ */ import React, { useEffect, useMemo, useState } from 'react'; +import { BrushEndListener, XYBrushEvent } from '@elastic/charts'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { BoolQuery } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; @@ -62,11 +63,22 @@ function InternalAlertsPage() { }, } = useKibana().services; const { ObservabilityPageTemplate, observabilityRuleTypeRegistry } = usePluginContext(); - const alertSearchBarStateProps = useAlertSearchBarStateContainer(URL_STORAGE_KEY); + const alertSearchBarStateProps = useAlertSearchBarStateContainer(URL_STORAGE_KEY, { + replace: false, + }); - const chartThemes = { + const onBrushEnd: BrushEndListener = (brushEvent) => { + const { x } = brushEvent as XYBrushEvent; + if (x) { + const [start, end] = x; + alertSearchBarStateProps.onRangeFromChange(new Date(start).toISOString()); + alertSearchBarStateProps.onRangeToChange(new Date(end).toISOString()); + } + }; + const chartProps = { theme: charts.theme.useChartsTheme(), baseTheme: charts.theme.useChartsBaseTheme(), + onBrushEnd, }; const [ruleStatsLoading, setRuleStatsLoading] = useState(false); const [ruleStats, setRuleStats] = useState({ @@ -197,7 +209,7 @@ function InternalAlertsPage() { filter={esQuery} fullSize timeRange={alertSummaryTimeRange} - chartThemes={chartThemes} + chartProps={chartProps} /> diff --git a/x-pack/plugins/observability/public/pages/overview/overview.tsx b/x-pack/plugins/observability/public/pages/overview/overview.tsx index aa447310bf2e8..9eaf72d431c19 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview.tsx @@ -118,7 +118,7 @@ export function OverviewPage() { [bucketSize, relativeEnd, relativeStart] ); - const chartThemes = { + const chartProps = { theme: charts.theme.useChartsTheme(), baseTheme: charts.theme.useChartsBaseTheme(), }; @@ -207,11 +207,11 @@ export function OverviewPage() { features={{ alerts: { sync: false } }} > { fetchMock.restore(); }); -for (const reason of ['AUTHENTICATION_ERROR', 'SESSION_EXPIRED']) { +for (const reason of [ + LogoutReason.AUTHENTICATION_ERROR, + LogoutReason.SESSION_EXPIRED, + LogoutReason.CONCURRENCY_LIMIT, +]) { const headers = - reason === 'SESSION_EXPIRED' ? { [SESSION_ERROR_REASON_HEADER]: reason } : undefined; + reason === LogoutReason.SESSION_EXPIRED || reason === LogoutReason.CONCURRENCY_LIMIT + ? { [SESSION_ERROR_REASON_HEADER]: reason } + : undefined; it(`logs out 401 responses (reason: ${reason})`, async () => { const http = setupHttp('/foo'); diff --git a/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.ts b/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.ts index cac7ec90e7dab..e09a9e5f26c31 100644 --- a/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.ts +++ b/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.ts @@ -43,8 +43,8 @@ export class UnauthorizedResponseHttpInterceptor implements HttpInterceptor { if (response.status === 401) { const reason = response.headers.get(SESSION_ERROR_REASON_HEADER); this.sessionExpired.logout( - reason === LogoutReason.SESSION_EXPIRED - ? LogoutReason.SESSION_EXPIRED + reason === LogoutReason.SESSION_EXPIRED || reason === LogoutReason.CONCURRENCY_LIMIT + ? reason : LogoutReason.AUTHENTICATION_ERROR ); controller.halt(); diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index f743244630412..7edf3ea050590 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -35,6 +35,7 @@ import { ConfigSchema, createConfig } from '../config'; import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock'; import { securityMock } from '../mocks'; import { + SessionConcurrencyLimitError, type SessionError, SessionExpiredError, SessionMissingError, @@ -1356,7 +1357,12 @@ describe('Authenticator', () => { expectAuditEvents({ action: 'user_login', outcome: 'failure' }); }); - for (const FailureClass of [SessionMissingError, SessionExpiredError, SessionUnexpectedError]) { + for (const FailureClass of [ + SessionMissingError, + SessionExpiredError, + SessionConcurrencyLimitError, + SessionUnexpectedError, + ]) { describe(`session.get results in ${FailureClass.name}`, () => { it('fails as expected for redirectable requests', async () => { const request = httpServerMock.createKibanaRequest(); @@ -1455,7 +1461,10 @@ describe('Authenticator', () => { const authenticationResult = await authenticator.authenticate(request); expect(authenticationResult.redirected()).toBe(true); - if (failureReason instanceof SessionExpiredError) { + if ( + failureReason instanceof SessionExpiredError || + failureReason instanceof SessionConcurrencyLimitError + ) { expect(authenticationResult.redirectURL).toBe( redirectUrl + '&msg=' + failureReason.code ); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index d984783df4c02..8599eb287989e 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -27,6 +27,7 @@ import { getErrorStatusCode } from '../errors'; import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; import { type Session, + SessionConcurrencyLimitError, SessionExpiredError, SessionUnexpectedError, type SessionValue, @@ -386,7 +387,8 @@ export class Authenticator { )}` : '' }${ - existingSession.error instanceof SessionExpiredError + existingSession.error instanceof SessionExpiredError || + existingSession.error instanceof SessionConcurrencyLimitError ? `&${LOGOUT_REASON_QUERY_STRING_PARAMETER}=${encodeURIComponent( existingSession.error.code )}` @@ -420,7 +422,8 @@ export class Authenticator { if (requestIsRedirectable) { if ( - existingSession.error instanceof SessionExpiredError && + (existingSession.error instanceof SessionExpiredError || + existingSession.error instanceof SessionConcurrencyLimitError) && authenticationResult.redirectURL?.startsWith( `${this.options.basePath.get(request)}/login?` ) @@ -479,9 +482,9 @@ export class Authenticator { } } } - if ( existingSession.error instanceof SessionExpiredError || + existingSession.error instanceof SessionConcurrencyLimitError || existingSession.error instanceof SessionUnexpectedError ) { const options = requestIsRedirectable diff --git a/x-pack/plugins/security/server/session_management/index.ts b/x-pack/plugins/security/server/session_management/index.ts index 5624c9d6ba0c9..992b9c4b37975 100644 --- a/x-pack/plugins/security/server/session_management/index.ts +++ b/x-pack/plugins/security/server/session_management/index.ts @@ -12,6 +12,7 @@ export { SessionMissingError, SessionExpiredError, SessionUnexpectedError, + SessionConcurrencyLimitError, } from './session_errors'; export type { SessionManagementServiceStart } from './session_management_service'; export { SessionManagementService } from './session_management_service'; diff --git a/x-pack/plugins/security/server/session_management/session.test.ts b/x-pack/plugins/security/server/session_management/session.test.ts index d5ad9ce3b31d4..ca6a669ecd5e4 100644 --- a/x-pack/plugins/security/server/session_management/session.test.ts +++ b/x-pack/plugins/security/server/session_management/session.test.ts @@ -19,7 +19,12 @@ import { ConfigSchema, createConfig } from '../config'; import { sessionCookieMock, sessionIndexMock, sessionMock } from './index.mock'; import { getPrintableSessionId, Session, type SessionValueContentToEncrypt } from './session'; import type { SessionCookie } from './session_cookie'; -import { SessionExpiredError, SessionMissingError, SessionUnexpectedError } from './session_errors'; +import { + SessionConcurrencyLimitError, + SessionExpiredError, + SessionMissingError, + SessionUnexpectedError, +} from './session_errors'; import type { SessionIndex } from './session_index'; describe('Session', () => { @@ -233,7 +238,7 @@ describe('Session', () => { mockSessionIndex.isWithinConcurrentSessionLimit.mockResolvedValue(false); await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toEqual({ - error: expect.any(SessionUnexpectedError), + error: expect.any(SessionConcurrencyLimitError), value: null, }); expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/server/session_management/session.ts b/x-pack/plugins/security/server/session_management/session.ts index 8969cc5939082..228f9b05e5751 100644 --- a/x-pack/plugins/security/server/session_management/session.ts +++ b/x-pack/plugins/security/server/session_management/session.ts @@ -18,7 +18,12 @@ import type { AuthenticationProvider } from '../../common'; import { userSessionConcurrentLimitLogoutEvent } from '../audit'; import type { ConfigType } from '../config'; import type { SessionCookie } from './session_cookie'; -import { SessionExpiredError, SessionMissingError, SessionUnexpectedError } from './session_errors'; +import { + SessionConcurrencyLimitError, + SessionExpiredError, + SessionMissingError, + SessionUnexpectedError, +} from './session_errors'; import type { SessionIndex, SessionIndexValue } from './session_index'; /** @@ -214,7 +219,7 @@ export class Session { 'Session is outside the concurrent session limit and will be invalidated.' ); await this.invalidate(request, { match: 'current' }); - return { error: new SessionUnexpectedError(), value: null }; + return { error: new SessionConcurrencyLimitError(), value: null }; } return { diff --git a/x-pack/plugins/security/server/session_management/session_errors/index.ts b/x-pack/plugins/security/server/session_management/session_errors/index.ts index be1cea2bfd5b7..6f8e0bd605dda 100644 --- a/x-pack/plugins/security/server/session_management/session_errors/index.ts +++ b/x-pack/plugins/security/server/session_management/session_errors/index.ts @@ -8,4 +8,5 @@ export { SessionError } from './session_error'; export { SessionMissingError } from './session_missing_error'; export { SessionExpiredError } from './session_expired_error'; +export { SessionConcurrencyLimitError } from './session_concurrency_limit_error'; export { SessionUnexpectedError } from './session_unexpected_error'; diff --git a/x-pack/plugins/security/server/session_management/session_errors/session_concurrency_limit_error.ts b/x-pack/plugins/security/server/session_management/session_errors/session_concurrency_limit_error.ts new file mode 100644 index 0000000000000..0e13f99458c38 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session_errors/session_concurrency_limit_error.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SessionError, SessionErrorReason } from './session_error'; + +export class SessionConcurrencyLimitError extends SessionError { + constructor() { + super(SessionErrorReason.CONCURRENCY_LIMIT, SessionErrorReason.CONCURRENCY_LIMIT); + } +} diff --git a/x-pack/plugins/security/server/session_management/session_errors/session_error.ts b/x-pack/plugins/security/server/session_management/session_errors/session_error.ts index 9974cc000514e..d720f9c3dcdd4 100644 --- a/x-pack/plugins/security/server/session_management/session_errors/session_error.ts +++ b/x-pack/plugins/security/server/session_management/session_errors/session_error.ts @@ -8,6 +8,7 @@ export enum SessionErrorReason { 'SESSION_MISSING' = 'SESSION_MISSING', 'SESSION_EXPIRED' = 'SESSION_EXPIRED', + 'CONCURRENCY_LIMIT' = 'CONCURRENCY_LIMIT', 'UNEXPECTED_SESSION_ERROR' = 'UNEXPECTED_SESSION_ERROR', } diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_response_actions/schemas/endpoint.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_response_actions/schemas/endpoint.ts new file mode 100644 index 0000000000000..233cc8ed694e3 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_response_actions/schemas/endpoint.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { RESPONSE_ACTION_API_COMMANDS_NAMES } from '../../../endpoint/service/response_actions/constants'; + +// to enable using RESPONSE_ACTION_API_COMMANDS_NAMES as a type +function keyObject(arr: T): { [K in T[number]]: null } { + return Object.fromEntries(arr.map((v) => [v, null])) as never; +} + +export const EndpointParams = t.type({ + command: t.keyof(keyObject(RESPONSE_ACTION_API_COMMANDS_NAMES)), + comment: t.union([t.string, t.undefined]), +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_response_actions/schemas/response_actions.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_response_actions/schemas/response_actions.ts index 973231fccab47..335a5f91ffbc5 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_response_actions/schemas/response_actions.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_response_actions/schemas/response_actions.ts @@ -6,21 +6,35 @@ */ import * as t from 'io-ts'; +import { EndpointParams } from './endpoint'; import { OsqueryParams, OsqueryParamsCamelCase } from './osquery'; export enum RESPONSE_ACTION_TYPES { OSQUERY = '.osquery', + ENDPOINT = '.endpoint', } export const SUPPORTED_RESPONSE_ACTION_TYPES = Object.values(RESPONSE_ACTION_TYPES); // When we create new response action types, create a union of types -const ResponseActionRuleParam = t.exact( - t.type({ - actionTypeId: t.literal(RESPONSE_ACTION_TYPES.OSQUERY), - params: OsqueryParamsCamelCase, - }) -); +export const OsqueryResponseActionRuleParam = t.strict({ + actionTypeId: t.literal(RESPONSE_ACTION_TYPES.OSQUERY), + params: OsqueryParamsCamelCase, +}); + +export type RuleResponseOsqueryAction = t.TypeOf; + +export const EndpointResponseActionRuleParam = t.strict({ + actionTypeId: t.literal(RESPONSE_ACTION_TYPES.ENDPOINT), + params: EndpointParams, +}); + +export type RuleResponseEndpointAction = t.TypeOf; + +const ResponseActionRuleParam = t.union([ + OsqueryResponseActionRuleParam, + EndpointResponseActionRuleParam, +]); export type RuleResponseAction = t.TypeOf; export const ResponseActionRuleParamsOrUndefined = t.union([ @@ -29,12 +43,17 @@ export const ResponseActionRuleParamsOrUndefined = t.union([ ]); // When we create new response action types, create a union of types -const ResponseAction = t.exact( - t.type({ - action_type_id: t.literal(RESPONSE_ACTION_TYPES.OSQUERY), - params: OsqueryParams, - }) -); +const OsqueryResponseAction = t.strict({ + action_type_id: t.literal(RESPONSE_ACTION_TYPES.OSQUERY), + params: OsqueryParams, +}); + +const EndpointResponseAction = t.strict({ + action_type_id: t.literal(RESPONSE_ACTION_TYPES.ENDPOINT), + params: EndpointParams, +}); + +const ResponseAction = t.union([OsqueryResponseAction, EndpointResponseAction]); export const ResponseActionArray = t.array(ResponseAction); diff --git a/x-pack/plugins/security_solution/common/detection_engine/transform_actions.test.ts b/x-pack/plugins/security_solution/common/detection_engine/transform_actions.test.ts index a8901fee7a3de..309dccbf5804a 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/transform_actions.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/transform_actions.test.ts @@ -11,6 +11,7 @@ import { transformRuleToAlertResponseAction, transformAlertToRuleResponseAction, } from './transform_actions'; +import type { ResponseAction, RuleResponseAction } from './rule_response_actions/schemas'; import { RESPONSE_ACTION_TYPES } from './rule_response_actions/schemas'; describe('transform_actions', () => { @@ -48,10 +49,9 @@ describe('transform_actions', () => { }); }); test('it should transform ResponseAction[] to RuleResponseAction[]', () => { - const ruleAction = { + const ruleAction: ResponseAction = { action_type_id: RESPONSE_ACTION_TYPES.OSQUERY, params: { - id: 'test', ecs_mapping: {}, saved_query_id: undefined, pack_id: undefined, @@ -63,7 +63,6 @@ describe('transform_actions', () => { expect(alertAction).toEqual({ actionTypeId: ruleAction.action_type_id, params: { - id: 'test', ecsMapping: {}, savedQueryId: undefined, packId: undefined, @@ -74,10 +73,9 @@ describe('transform_actions', () => { }); test('it should transform RuleResponseAction[] to ResponseAction[]', () => { - const alertAction = { + const alertAction: RuleResponseAction = { actionTypeId: RESPONSE_ACTION_TYPES.OSQUERY, params: { - id: 'test', ecsMapping: {}, savedQueryId: undefined, packId: undefined, @@ -89,7 +87,6 @@ describe('transform_actions', () => { expect(ruleAction).toEqual({ action_type_id: alertAction.actionTypeId, params: { - id: 'test', ecs_mapping: {}, saved_query_id: undefined, pack_id: undefined, diff --git a/x-pack/plugins/security_solution/common/detection_engine/transform_actions.ts b/x-pack/plugins/security_solution/common/detection_engine/transform_actions.ts index 354713e30d7c8..8561226f98744 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/transform_actions.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/transform_actions.ts @@ -7,6 +7,7 @@ import type { RuleAction } from '@kbn/alerting-plugin/common'; import type { ResponseAction, RuleResponseAction } from './rule_response_actions/schemas'; +import { RESPONSE_ACTION_TYPES } from './rule_response_actions/schemas'; import type { RuleAlertAction } from './types'; export const transformRuleToAlertAction = ({ @@ -41,20 +42,26 @@ export const transformRuleToAlertResponseAction = ({ action_type_id: actionTypeId, params, }: ResponseAction): RuleResponseAction => { - const { - saved_query_id: savedQueryId, - ecs_mapping: ecsMapping, - pack_id: packId, - ...rest - } = params; + if (actionTypeId === RESPONSE_ACTION_TYPES.OSQUERY) { + const { + saved_query_id: savedQueryId, + ecs_mapping: ecsMapping, + pack_id: packId, + ...rest + } = params; + return { + params: { + ...rest, + savedQueryId, + ecsMapping, + packId, + }, + actionTypeId, + }; + } return { - params: { - ...rest, - savedQueryId, - ecsMapping, - packId, - }, + params, actionTypeId, }; }; @@ -63,14 +70,20 @@ export const transformAlertToRuleResponseAction = ({ actionTypeId, params, }: RuleResponseAction): ResponseAction => { - const { savedQueryId, ecsMapping, packId, ...rest } = params; + if (actionTypeId === RESPONSE_ACTION_TYPES.OSQUERY) { + const { savedQueryId, ecsMapping, packId, ...rest } = params; + return { + params: { + ...rest, + saved_query_id: savedQueryId, + ecs_mapping: ecsMapping, + pack_id: packId, + }, + action_type_id: actionTypeId, + }; + } return { - params: { - ...rest, - saved_query_id: savedQueryId, - ecs_mapping: ecsMapping, - pack_id: packId, - }, + params, action_type_id: actionTypeId, }; }; diff --git a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts index fec3209d2d688..bc58af2ae011e 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts @@ -22,6 +22,10 @@ export const RESPONSE_ACTION_API_COMMANDS_NAMES = [ export type ResponseActionsApiCommandNames = typeof RESPONSE_ACTION_API_COMMANDS_NAMES[number]; +export const ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS: ResponseActionsApiCommandNames[] = [ + 'isolate', +]; + /** * The list of possible capabilities, reported by the endpoint in the metadata document */ diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 100ff37586c5f..657cde434cb4f 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -64,10 +64,15 @@ export const allowedExperimentalValues = Object.freeze({ socTrendsEnabled: false, /** - * Enables the detection response actions in rule + alerts + * Enables the automated response actions in rule + alerts */ responseActionsEnabled: true, + /** + * Enables the automated endpoint response action in rule + alerts + */ + endpointResponseActionsEnabled: true, + /** * Enables endpoint package level rbac */ diff --git a/x-pack/plugins/security_solution/public/common/components/charts/donutchart.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/donutchart.test.tsx index c652daa786567..9d482e0565e5c 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/donutchart.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/donutchart.test.tsx @@ -111,6 +111,7 @@ describe('DonutChart', () => { outerSizeRatio: 1, }, }, + onElementClick: expect.any(Function), }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/charts/donutchart.tsx b/x-pack/plugins/security_solution/public/common/components/charts/donutchart.tsx index 8b968077f3dcb..3a54f26ae7dde 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/donutchart.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/donutchart.tsx @@ -7,7 +7,7 @@ import type { EuiFlexGroupProps } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip, useEuiTheme } from '@elastic/eui'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import type { Datum, NodeColorAccessor, PartialTheme, ElementClickListener } from '@elastic/charts'; import { @@ -17,8 +17,10 @@ import { PartitionLayout, defaultPartitionValueFormatter, } from '@elastic/charts'; +import { isEmpty } from 'lodash'; import type { FlattenSimpleInterpolation } from 'styled-components'; import styled from 'styled-components'; + import { useTheme } from './common'; import { DraggableLegend } from './draggable_legend'; import type { LegendItem } from './draggable_legend_item'; @@ -48,7 +50,10 @@ export interface DonutChartProps { height?: number; label: React.ReactElement | string; legendItems?: LegendItem[] | null | undefined; - onElementClick?: ElementClickListener; + /** + * provides the section name of a clicked donut ring partition + */ + onPartitionClick?: (level: string) => void; title: React.ReactElement | string | number | null; totalCount: number | null | undefined; } @@ -105,6 +110,7 @@ const DonutChartWrapperComponent: React.FC = ({ [euiTheme.colors.disabled] ); const className = isChartEmbeddablesEnabled ? undefined : 'eui-textTruncate'; + return ( { const theme = useTheme(); + const onElementClicked: ElementClickListener = useCallback( + (event) => { + if (onPartitionClick) { + const flattened = event.flat(2); + const level = + flattened.length > 0 && + 'groupByRollup' in flattened[0] && + flattened[0]?.groupByRollup != null + ? `${flattened[0].groupByRollup}` + : ''; + + if (!isEmpty(level.trim())) { + onPartitionClick(level.toLowerCase()); + } + } + }, + [onPartitionClick] + ); + return ( 0} @@ -170,7 +195,7 @@ export const DonutChart = ({ ) : ( - + void; -}> = ({ children, Component, userName, isButton, onClick, title, userTab }) => { +}> = ({ children, Component, userName, isButton, onClick: onClickParam, title, userTab }) => { const encodedUserName = encodeURIComponent(userName); - const { formatUrl, search } = useFormatUrl(SecurityPageName.users); - const { navigateToApp } = useKibana().services.application; + const { + application: { navigateToApp }, + telemetry, + } = useKibana().services; const goToUsersDetails = useCallback( (ev) => { ev.preventDefault(); @@ -92,22 +94,27 @@ const UserDetailsLinkComponent: React.FC<{ [formatUrl, encodedUserName, userTab] ); + const onClick = useCallback( + (e: SyntheticEvent) => { + telemetry.reportEntityDetailsClicked({ entity: 'user' }); + const callback = onClickParam ?? goToUsersDetails; + callback(e); + }, + [goToUsersDetails, onClickParam, telemetry] + ); + return isButton ? ( {children ? children : userName} ) : ( - + {children ? children : userName} ); @@ -124,9 +131,12 @@ const HostDetailsLinkComponent: React.FC<{ onClick?: (e: SyntheticEvent) => void; hostTab?: HostsTableType; title?: string; -}> = ({ children, Component, hostName, isButton, onClick, title, hostTab }) => { +}> = ({ children, Component, hostName, isButton, onClick: onClickParam, title, hostTab }) => { const { formatUrl, search } = useFormatUrl(SecurityPageName.hosts); - const { navigateToApp } = useKibana().services.application; + const { + application: { navigateToApp }, + telemetry, + } = useKibana().services; const encodedHostName = encodeURIComponent(hostName); @@ -151,23 +161,30 @@ const HostDetailsLinkComponent: React.FC<{ ), [formatUrl, encodedHostName, hostTab] ); + + const onClick = useCallback( + (e: SyntheticEvent) => { + telemetry.reportEntityDetailsClicked({ entity: 'host' }); + + const callback = onClickParam ?? goToHostDetails; + callback(e); + }, + [goToHostDetails, onClickParam, telemetry] + ); + return isButton ? ( {children} ) : ( - + {children ? children : hostName} ); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_navigate_to_alerts_page_with_filters.ts b/x-pack/plugins/security_solution/public/common/hooks/use_navigate_to_alerts_page_with_filters.ts new file mode 100644 index 0000000000000..f96f9c813cc65 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_navigate_to_alerts_page_with_filters.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { encode } from '@kbn/rison'; + +import { SecurityPageName } from '../../../common/constants'; +import { formatPageFilterSearchParam } from '../../../common/utils/format_page_filter_search_param'; +import type { FilterItemObj } from '../components/filter_group/types'; +import { useNavigation } from '../lib/kibana'; +import { URL_PARAM_KEY } from './use_url_state'; + +export const useNavigateToAlertsPageWithFilters = () => { + const { navigateTo } = useNavigation(); + + return (filterItems: FilterItemObj | FilterItemObj[]) => { + const urlFilterParams = encode( + formatPageFilterSearchParam(Array.isArray(filterItems) ? filterItems : [filterItems]) + ); + + navigateTo({ + deepLinkId: SecurityPageName.alerts, + path: `?${URL_PARAM_KEY.pageFilter}=${urlFilterParams}`, + }); + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.mock.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.mock.ts index 7b1c33eac9f0f..a79f9c662ae23 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.mock.ts @@ -11,4 +11,7 @@ export const createTelemetryClientMock = (): jest.Mocked = reportAlertsGroupingChanged: jest.fn(), reportAlertsGroupingToggled: jest.fn(), reportAlertsGroupingTakeAction: jest.fn(), + reportEntityDetailsClicked: jest.fn(), + reportEntityAlertsClicked: jest.fn(), + reportEntityRiskFiltered: jest.fn(), }); diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts index aebe7b5b3aa55..e356b47f29c38 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts @@ -11,6 +11,9 @@ import type { ReportAlertsGroupingChangedParams, ReportAlertsGroupingToggledParams, ReportAlertsTakeActionParams, + ReportEntityDetailsClickedParams, + ReportEntityAlertsClickedParams, + ReportEntityRiskFilteredParams, } from './types'; import { TelemetryEventTypes } from './types'; @@ -58,4 +61,26 @@ export class TelemetryClient implements TelemetryClientStart { groupByField, }); }; + + public reportEntityDetailsClicked = ({ entity }: ReportEntityDetailsClickedParams) => { + this.analytics.reportEvent(TelemetryEventTypes.EntityDetailsClicked, { + entity, + }); + }; + + public reportEntityAlertsClicked = ({ entity }: ReportEntityAlertsClickedParams) => { + this.analytics.reportEvent(TelemetryEventTypes.EntityAlertsClicked, { + entity, + }); + }; + + public reportEntityRiskFiltered = ({ + entity, + selectedSeverity, + }: ReportEntityRiskFilteredParams) => { + this.analytics.reportEvent(TelemetryEventTypes.EntityRiskFiltered, { + entity, + selectedSeverity, + }); + }; } diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_events.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_events.ts index d37957d2508d5..4087d8df7b5d4 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_events.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_events.ts @@ -95,8 +95,57 @@ const alertsGroupingTakeActionEvent: TelemetryEvent = { }, }; +const entityClickedEvent: TelemetryEvent = { + eventType: TelemetryEventTypes.EntityDetailsClicked, + schema: { + entity: { + type: 'keyword', + _meta: { + description: 'Entity name (host|user)', + optional: false, + }, + }, + }, +}; + +const entityAlertsClickedEvent: TelemetryEvent = { + eventType: TelemetryEventTypes.EntityAlertsClicked, + schema: { + entity: { + type: 'keyword', + _meta: { + description: 'Entity name (host|user)', + optional: false, + }, + }, + }, +}; + +const entityRiskFilteredEvent: TelemetryEvent = { + eventType: TelemetryEventTypes.EntityRiskFiltered, + schema: { + entity: { + type: 'keyword', + _meta: { + description: 'Entity name (host|user)', + optional: false, + }, + }, + selectedSeverity: { + type: 'keyword', + _meta: { + description: 'Selected severity (Unknown|Low|Moderate|High|Critical)', + optional: false, + }, + }, + }, +}; + export const telemetryEvents = [ alertsGroupingToggledEvent, alertsGroupingChangedEvent, alertsGroupingTakeActionEvent, + entityClickedEvent, + entityAlertsClickedEvent, + entityRiskFilteredEvent, ]; diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_service.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_service.ts index 58d1c3d7d7418..77bb3036fd9ab 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_service.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_service.ts @@ -17,7 +17,7 @@ import { TelemetryClient } from './telemetry_client'; /** * Service that interacts with the Core's analytics module - * to trigger custom event for the Infra plugin features + * to trigger custom event for Security Solution plugin features */ export class TelemetryService { constructor(private analytics: AnalyticsServiceSetup | null = null) {} diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/types.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/types.ts index 2610dc51c1e41..78ed0ee9d3fd3 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/types.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/types.ts @@ -7,6 +7,7 @@ import type { RootSchema } from '@kbn/analytics-client'; import type { AnalyticsServiceSetup } from '@kbn/core/public'; +import type { RiskSeverity } from '../../../../common/search_strategy'; export interface TelemetryServiceSetupParams { analytics: AnalyticsServiceSetup; @@ -16,6 +17,9 @@ export enum TelemetryEventTypes { AlertsGroupingChanged = 'Alerts Grouping Changed', AlertsGroupingToggled = 'Alerts Grouping Toggled', AlertsGroupingTakeAction = 'Alerts Grouping Take Action', + EntityDetailsClicked = 'Entity Details Clicked', + EntityAlertsClicked = 'Entity Alerts Clicked', + EntityRiskFiltered = 'Entity Risk Filtered', } export interface ReportAlertsGroupingChangedParams { @@ -37,15 +41,32 @@ export interface ReportAlertsTakeActionParams { groupByField: string; } +interface EntityParam { + entity: 'host' | 'user'; +} + +export type ReportEntityDetailsClickedParams = EntityParam; +export type ReportEntityAlertsClickedParams = EntityParam; +export interface ReportEntityRiskFilteredParams extends EntityParam { + selectedSeverity: RiskSeverity; +} + export type TelemetryEventParams = | ReportAlertsGroupingChangedParams | ReportAlertsGroupingToggledParams - | ReportAlertsTakeActionParams; + | ReportAlertsTakeActionParams + | ReportEntityDetailsClickedParams + | ReportEntityAlertsClickedParams + | ReportEntityRiskFilteredParams; export interface TelemetryClientStart { reportAlertsGroupingChanged(params: ReportAlertsGroupingChangedParams): void; reportAlertsGroupingToggled(params: ReportAlertsGroupingToggledParams): void; reportAlertsGroupingTakeAction(params: ReportAlertsTakeActionParams): void; + + reportEntityDetailsClicked(params: ReportEntityDetailsClickedParams): void; + reportEntityAlertsClicked(params: ReportEntityAlertsClickedParams): void; + reportEntityRiskFiltered(params: ReportEntityRiskFilteredParams): void; } export type TelemetryEvent = @@ -60,4 +81,16 @@ export type TelemetryEvent = | { eventType: TelemetryEventTypes.AlertsGroupingTakeAction; schema: RootSchema; + } + | { + eventType: TelemetryEventTypes.EntityDetailsClicked; + schema: RootSchema; + } + | { + eventType: TelemetryEventTypes.EntityAlertsClicked; + schema: RootSchema; + } + | { + eventType: TelemetryEventTypes.EntityRiskFiltered; + schema: RootSchema; }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/constants.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/constants.ts index 05ba707207c44..ee916820c7db5 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/constants.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/constants.ts @@ -11,6 +11,8 @@ export const getActionDetails = (actionTypeId: string) => { switch (actionTypeId) { case RESPONSE_ACTION_TYPES.OSQUERY: return { logo: 'logoOsquery', name: 'Osquery' }; + case RESPONSE_ACTION_TYPES.ENDPOINT: + return { logo: 'logoSecurity', name: 'Endpoint' }; // update when new responseActions are provided default: return { logo: 'logoOsquery', name: 'Osquery' }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/action_type_field.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/action_type_field.tsx new file mode 100644 index 0000000000000..24c3b6551bb53 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/action_type_field.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { map } from 'lodash'; +import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { UseField, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { SuperSelectField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import { getUiCommand } from '../../../management/components/endpoint_response_actions_list/components/hooks'; +import { getRbacControl } from '../../../management/components/endpoint_responder/lib/console_commands_definition'; +import { useUserPrivileges } from '../../../common/components/user_privileges'; +import { ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS } from '../../../../common/endpoint/service/response_actions/constants'; + +interface ActionTypeFieldProps { + basePath: string; + disabled: boolean; + readDefaultValueOnForm: boolean; +} + +const ActionTypeFieldComponent = ({ + basePath, + disabled, + readDefaultValueOnForm, +}: ActionTypeFieldProps) => { + const { endpointPrivileges } = useUserPrivileges(); + const [data] = useFormData(); + + const fieldOptions = useMemo( + () => + ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS.map((name) => { + const isDisabled = + map(data.responseActions, 'params.command').includes(name) || + !getRbacControl({ + commandName: getUiCommand(name), + privileges: endpointPrivileges, + }); + + return { + value: name, + inputDisplay: name, + disabled: isDisabled, + 'data-test-subj': `command-type-${name}`, + }; + }), + [data.responseActions, endpointPrivileges] + ); + + return ( + + ); +}; + +export const ActionTypeField = React.memo(ActionTypeFieldComponent); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/check_permissions.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/check_permissions.ts new file mode 100644 index 0000000000000..ce7376ae88b70 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/check_permissions.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RuleResponseAction } from '../../../../common/detection_engine/rule_response_actions/schemas'; +import { getRbacControl } from '../../../management/components/endpoint_responder/lib/console_commands_definition'; +import { getUiCommand } from '../../../management/components/endpoint_response_actions_list/components/hooks'; +import { useUserPrivileges } from '../../../common/components/user_privileges'; + +export const useCheckEndpointPermissions = (action: RuleResponseAction) => { + const endpointPrivileges = useUserPrivileges().endpointPrivileges; + + if (action?.actionTypeId === '.endpoint' && action?.params?.command) { + return !getRbacControl({ + commandName: getUiCommand(action.params.command), + privileges: endpointPrivileges, + }); + } +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/comment_field.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/comment_field.tsx new file mode 100644 index 0000000000000..b1ee8021a6bc6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/comment_field.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { TextField } from '@kbn/es-ui-shared-plugin/static/forms/components'; + +interface ActionTypeFieldProps { + basePath: string; + disabled: boolean; + readDefaultValueOnForm: boolean; +} + +const CONFIG = { + label: i18n.translate('xpack.securitySolution.responseActions.endpoint.commentLabel', { + defaultMessage: 'Comment (optional)', + }), +}; + +const CommentFieldComponent = ({ + basePath, + disabled, + readDefaultValueOnForm, +}: ActionTypeFieldProps) => ( + +); + +export const CommentField = React.memo(CommentFieldComponent); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/endpoint_response_action.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/endpoint_response_action.tsx new file mode 100644 index 0000000000000..ba90ebec2b3aa --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/endpoint_response_action.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { ArrayItem } from '../../../shared_imports'; +import { CommentField } from './comment_field'; +import { ActionTypeField } from './action_type_field'; + +interface EndpointResponseActionProps { + item: ArrayItem; + editDisabled: boolean; +} + +export const EndpointResponseAction = React.memo((props: EndpointResponseActionProps) => ( + <> + + + +)); +EndpointResponseAction.displayName = 'EndpointResponseAction'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/translations.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/translations.tsx new file mode 100644 index 0000000000000..170c21059e552 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/endpoint/translations.tsx @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const PERMISSION_DENIED = i18n.translate( + 'xpack.securitySolution.endpoint.action.permissionDenied', + { + defaultMessage: 'Permission denied', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/get_supported_response_actions.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/get_supported_response_actions.ts index eed26198e5d43..cf59fd7c23f1d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/get_supported_response_actions.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/get_supported_response_actions.ts @@ -5,30 +5,48 @@ * 2.0. */ +import type { EnabledFeatures } from '@kbn/spaces-plugin/public/management/edit_space/enabled_features'; import { - SUPPORTED_RESPONSE_ACTION_TYPES, RESPONSE_ACTION_TYPES, + SUPPORTED_RESPONSE_ACTION_TYPES, } from '../../../common/detection_engine/rule_response_actions/schemas'; export interface ResponseActionType { id: RESPONSE_ACTION_TYPES; name: string; iconClass: string; + disabled?: boolean; +} + +interface EnabledFeatures { + endpoint: boolean; } export const getSupportedResponseActions = ( - actionTypes: ResponseActionType[] -): ResponseActionType[] => { - return actionTypes.filter((actionType) => { - return SUPPORTED_RESPONSE_ACTION_TYPES.includes(actionType.id); - }); -}; + actionTypes: ResponseActionType[], + enabledFeatures: EnabledFeatures, + userPermissions: EnabledFeatures +): ResponseActionType[] => + actionTypes.reduce((acc: ResponseActionType[], actionType) => { + const isEndpointAction = actionType.id === RESPONSE_ACTION_TYPES.ENDPOINT; + if (!enabledFeatures.endpoint && isEndpointAction) return acc; + if (SUPPORTED_RESPONSE_ACTION_TYPES.includes(actionType.id)) + return [ + ...acc, + { ...actionType, disabled: isEndpointAction ? !userPermissions.endpoint : undefined }, + ]; + return acc; + }, []); export const responseActionTypes = [ { id: RESPONSE_ACTION_TYPES.OSQUERY, - name: 'osquery', + name: 'Osquery', iconClass: 'logoOsquery', }, - // { id: '.endpointSecurity', name: 'endpointSecurity', iconClass: 'logoSecurity' }, + { + id: RESPONSE_ACTION_TYPES.ENDPOINT, + name: 'Endpoint Security', + iconClass: 'logoSecurity', + }, ]; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_action_add_button.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_action_add_button.tsx index 8352d6b150bc2..a8fcccee30eff 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_action_add_button.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_action_add_button.tsx @@ -13,8 +13,10 @@ import { EuiIcon, EuiKeyPadMenuItem, EuiSpacer, + EuiToolTip, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; import { useLicense } from '../../common/hooks/use_license'; import type { ResponseActionType } from './get_supported_response_actions'; import { useFormData } from '../../shared_imports'; @@ -25,6 +27,13 @@ interface IResponseActionsAddButtonProps { updateActionTypeId: (id: string) => void; } +export const PLATINUM_ONLY_TOOLTIP = i18n.translate( + 'xpack.securitySolution.actionForm.platinumOnly', + { + defaultMessage: 'This functionality is available only in Platinum and above.', + } +); + export const ResponseActionAddButton = ({ supportedResponseActionTypes, addActionType, @@ -34,7 +43,7 @@ export const ResponseActionAddButton = ({ const [isAddResponseActionButtonShown, setAddResponseActionButtonShown] = useState( data.responseActions && data.responseActions.length > 0 ); - const isGoldLicense = useLicense().isGoldPlus(); + const isPlatinumPlus = useLicense().isPlatinumPlus(); const handleAddActionType = useCallback( (item) => { @@ -48,7 +57,7 @@ export const ResponseActionAddButton = ({ const renderAddResponseActionButton = useMemo(() => { return ( - + handleAddActionType(item)} > ); + if (!isPlatinumPlus) { + return ( + + + {keyPadItem} + + + ); + } return ( {keyPadItem} @@ -89,13 +108,17 @@ export const ResponseActionAddButton = ({ ); }) ); - }, [handleAddActionType, isGoldLicense, supportedResponseActionTypes]); + }, [handleAddActionType, isPlatinumPlus, supportedResponseActionTypes]); if (!supportedResponseActionTypes?.length) return <>; return ( <> - {isAddResponseActionButtonShown ? renderAddResponseActionButton : renderResponseActionTypes} + {isAddResponseActionButtonShown ? ( + renderAddResponseActionButton + ) : ( + {renderResponseActionTypes} + )} ); }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_action_type_form.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_action_type_form.tsx index 3074f40b38fc9..0cc03b4c234ef 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_action_type_form.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_action_type_form.tsx @@ -18,6 +18,9 @@ import { i18n } from '@kbn/i18n'; import { get } from 'lodash'; import styled from 'styled-components'; +import { useCheckEndpointPermissions } from './endpoint/check_permissions'; +import { EndpointResponseAction } from './endpoint/endpoint_response_action'; +import type { RuleResponseAction } from '../../../common/detection_engine/rule_response_actions/schemas'; import { RESPONSE_ACTION_TYPES } from '../../../common/detection_engine/rule_response_actions/schemas'; import { OsqueryResponseAction } from './osquery/osquery_response_action'; import { getActionDetails } from './constants'; @@ -41,15 +44,19 @@ const ResponseActionTypeFormComponent = ({ item, onDeleteAction }: ResponseActio const [_isOpen, setIsOpen] = useState(true); const [data] = useFormData(); - const action = get(data, item.path); + const action: RuleResponseAction = get(data, item.path); + const editDisabled = useCheckEndpointPermissions(action) ?? false; - const getResponseActionTypeForm = useCallback(() => { + const getResponseActionTypeForm = useMemo(() => { if (action?.actionTypeId === RESPONSE_ACTION_TYPES.OSQUERY) { return ; } + if (action?.actionTypeId === RESPONSE_ACTION_TYPES.ENDPOINT) { + return ; + } // Place for other ResponseActionTypes return null; - }, [action?.actionTypeId, item]); + }, [action?.actionTypeId, editDisabled, item]); const handleDelete = useCallback(() => { onDeleteAction(item.id); @@ -82,10 +89,11 @@ const ResponseActionTypeFormComponent = ({ item, onDeleteAction }: ResponseActio defaultMessage: 'Delete', } )} + disabled={editDisabled} onClick={handleDelete} /> ); - }, [handleDelete]); + }, [editDisabled, handleDelete]); return ( - {getResponseActionTypeForm()} + {getResponseActionTypeForm} ); }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_form.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_form.test.tsx index 05c886564e878..fe78862e6d56c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_form.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/response_actions_form.test.tsx @@ -14,6 +14,7 @@ import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import type { ArrayItem } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { getMockTheme } from '../../common/lib/kibana/kibana_react.mock'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts @@ -34,10 +35,17 @@ jest.mock('../../common/lib/kibana', () => { }; }); +jest.mock('../../common/hooks/use_experimental_features', () => ({ + useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), +})); + import * as rules from '../rule_management/logic/use_rule'; // @ts-expect-error we don't really care about thr useRule return value jest.spyOn(rules, 'useRule').mockReturnValue({}); +const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; +useIsExperimentalFeatureEnabledMock.mockReturnValue(true); + const renderWithContext = (Element: React.ReactElement) => { const mockTheme = getMockTheme({ eui: { euiColorLightestShade: '#F5F7FA' } }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/use_supported_response_action_types.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/use_supported_response_action_types.tsx index 4b969aa7dee8d..1d1346f9090c2 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/use_supported_response_action_types.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_response_actions/use_supported_response_action_types.tsx @@ -5,22 +5,42 @@ * 2.0. */ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { useUserPrivileges } from '../../common/components/user_privileges'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; import type { ResponseActionType } from './get_supported_response_actions'; import { getSupportedResponseActions, responseActionTypes } from './get_supported_response_actions'; -import { useOsqueryEnabled } from './use_osquery_enabled'; export const useSupportedResponseActionTypes = () => { const [supportedResponseActionTypes, setSupportedResponseActionTypes] = useState< ResponseActionType[] | undefined >(); - const isOsqueryEnabled = useOsqueryEnabled(); + const isEndpointEnabled = useIsExperimentalFeatureEnabled('endpointResponseActionsEnabled'); + const { canIsolateHost } = useUserPrivileges().endpointPrivileges; + + const enabledFeatures = useMemo( + () => ({ + endpoint: isEndpointEnabled, + }), + [isEndpointEnabled] + ); + + const userHasPermissionsToExecute = useMemo( + () => ({ + endpoint: canIsolateHost, + }), + [canIsolateHost] + ); useEffect(() => { - const supportedTypes = getSupportedResponseActions(responseActionTypes); + const supportedTypes = getSupportedResponseActions( + responseActionTypes, + enabledFeatures, + userHasPermissionsToExecute + ); setSupportedResponseActionTypes(supportedTypes); - }, [isOsqueryEnabled]); + }, [isEndpointEnabled, enabledFeatures, userHasPermissionsToExecute]); return supportedResponseActionTypes; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/severity_level_chart.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/severity_level_chart.tsx index 395357ff7b1b0..5b2599a3a4607 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/severity_level_chart.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/severity_level_chart.tsx @@ -5,12 +5,11 @@ * 2.0. */ import React, { useCallback, useMemo } from 'react'; -import { isEmpty } from 'lodash/fp'; import { ALERT_SEVERITY } from '@kbn/rule-data-utils'; import styled from 'styled-components'; import { EuiFlexGroup, EuiFlexItem, EuiInMemoryTable, EuiLoadingSpinner } from '@elastic/eui'; import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import type { ShapeTreeNode, ElementClickListener } from '@elastic/charts'; +import type { ShapeTreeNode } from '@elastic/charts'; import type { SeverityBuckets as SeverityData } from '../../../../overview/components/detection_response/alerts_by_status/types'; import type { FillColor } from '../../../../common/components/charts/donutchart'; import { DonutChart } from '../../../../common/components/charts/donutchart'; @@ -56,17 +55,9 @@ export const SeverityLevelChart: React.FC = ({ }, }; - const onElementClick: ElementClickListener = useCallback( - (event) => { - const flattened = event.flat(2); - const level = - flattened.length > 0 && - 'groupByRollup' in flattened[0] && - flattened[0].groupByRollup != null - ? `${flattened[0].groupByRollup}` - : ''; - - if (addFilter != null && !isEmpty(level.trim())) { + const onDonutPartitionClicked = useCallback( + (level: string) => { + if (addFilter) { addFilter({ field: ALERT_SEVERITY, value: level.toLowerCase() }); } }, @@ -95,7 +86,7 @@ export const SeverityLevelChart: React.FC = ({ label={TOTAL_COUNT_OF_ALERTS} title={} totalCount={count} - onElementClick={onElementClick} + onPartitionClick={onDonutPartitionClicked} /> )} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/get_schema.ts b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/get_schema.ts index 4bc6896c10467..858578f8a5d38 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/get_schema.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/get_schema.ts @@ -27,6 +27,7 @@ export const getSchema = ({ }, ], }, + responseActions: {}, enabled: {}, kibanaSiemAppUrl: {}, throttle: { diff --git a/x-pack/plugins/security_solution/public/explore/components/risk_score/severity/severity_filter_group.test.tsx b/x-pack/plugins/security_solution/public/explore/components/risk_score/severity/severity_filter_group.test.tsx index 186d6336f8ca4..07297f9af4042 100644 --- a/x-pack/plugins/security_solution/public/explore/components/risk_score/severity/severity_filter_group.test.tsx +++ b/x-pack/plugins/security_solution/public/explore/components/risk_score/severity/severity_filter_group.test.tsx @@ -7,10 +7,26 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; import { SeverityFilterGroup } from './severity_filter_group'; -import { RiskSeverity } from '../../../../../common/search_strategy'; +import { RiskScoreEntity, RiskSeverity } from '../../../../../common/search_strategy'; import { TestProviders } from '../../../../common/mock'; +import { createTelemetryServiceMock } from '../../../../common/lib/telemetry/telemetry_service.mock'; + +const mockedTelemetry = createTelemetryServiceMock(); +jest.mock('../../../../common/lib/kibana', () => { + return { + useKibana: () => ({ + services: { + telemetry: mockedTelemetry, + }, + }), + }; +}); describe('SeverityFilterGroup', () => { + beforeEach(() => { + mockedTelemetry.reportEntityRiskFiltered.mockClear(); + }); + it('preserves sort order when severityCount is out of order', () => { const { getByTestId, getAllByTestId } = render( @@ -23,8 +39,8 @@ describe('SeverityFilterGroup', () => { [RiskSeverity.moderate]: 0, [RiskSeverity.unknown]: 0, }} - title={'test title'} onSelect={jest.fn()} + riskEntity={RiskScoreEntity.user} /> ); @@ -39,4 +55,58 @@ describe('SeverityFilterGroup', () => { 'Critical', ]); }); + + it('sends telemetry when selecting a classification', () => { + const { getByTestId, getAllByTestId } = render( + + + + ); + + fireEvent.click(getByTestId('risk-filter-button')); + + fireEvent.click(getAllByTestId('risk-score').at(0)!); + expect(mockedTelemetry.reportEntityRiskFiltered).toHaveBeenCalledTimes(1); + }); + + it('does not send telemetry when deselecting a classification', () => { + const { getByTestId, getAllByTestId } = render( + + + + ); + + fireEvent.click(getByTestId('risk-filter-button')); + + fireEvent.click(getAllByTestId('risk-score').at(0)!); + expect(mockedTelemetry.reportEntityRiskFiltered).toHaveBeenCalledTimes(0); + }); }); diff --git a/x-pack/plugins/security_solution/public/explore/components/risk_score/severity/severity_filter_group.tsx b/x-pack/plugins/security_solution/public/explore/components/risk_score/severity/severity_filter_group.tsx index b736423a28d4f..02ea5fa2042b4 100644 --- a/x-pack/plugins/security_solution/public/explore/components/risk_score/severity/severity_filter_group.tsx +++ b/x-pack/plugins/security_solution/public/explore/components/risk_score/severity/severity_filter_group.tsx @@ -15,10 +15,12 @@ import { useGeneratedHtmlId, } from '@elastic/eui'; -import type { RiskSeverity } from '../../../../../common/search_strategy'; +import type { RiskScoreEntity, RiskSeverity } from '../../../../../common/search_strategy'; import { SEVERITY_UI_SORT_ORDER } from '../../../../../common/search_strategy'; import type { SeverityCount } from './types'; import { RiskScore } from './common'; +import { ENTITY_RISK_CLASSIFICATION } from '../translations'; +import { useKibana } from '../../../../common/lib/kibana'; interface SeverityItems { risk: RiskSeverity; @@ -29,8 +31,9 @@ export const SeverityFilterGroup: React.FC<{ severityCount: SeverityCount; selectedSeverities: RiskSeverity[]; onSelect: (newSelection: RiskSeverity[]) => void; - title: string; -}> = ({ severityCount, selectedSeverities, onSelect, title }) => { + riskEntity: RiskScoreEntity; +}> = ({ severityCount, selectedSeverities, onSelect, riskEntity }) => { + const { telemetry } = useKibana().services; const [isPopoverOpen, setIsPopoverOpen] = useState(false); const onButtonClick = useCallback(() => { @@ -57,13 +60,19 @@ export const SeverityFilterGroup: React.FC<{ const updateSeverityFilter = useCallback( (selectedSeverity: RiskSeverity) => { const currentSelection = selectedSeverities ?? []; - const newSelection = currentSelection.includes(selectedSeverity) - ? currentSelection.filter((s) => s !== selectedSeverity) - : [...currentSelection, selectedSeverity]; + const isAddingSeverity = !currentSelection.includes(selectedSeverity); + + const newSelection = isAddingSeverity + ? [...currentSelection, selectedSeverity] + : currentSelection.filter((s) => s !== selectedSeverity); + + if (isAddingSeverity) { + telemetry.reportEntityRiskFiltered({ entity: riskEntity, selectedSeverity }); + } onSelect(newSelection); }, - [selectedSeverities, onSelect] + [selectedSeverities, onSelect, telemetry, riskEntity] ); const totalActiveItem = useMemo( @@ -81,10 +90,10 @@ export const SeverityFilterGroup: React.FC<{ numActiveFilters={totalActiveItem} onClick={onButtonClick} > - {title} + {ENTITY_RISK_CLASSIFICATION(riskEntity)} ), - [isPopoverOpen, items, onButtonClick, totalActiveItem, title] + [isPopoverOpen, items, onButtonClick, totalActiveItem, riskEntity] ); return ( diff --git a/x-pack/plugins/security_solution/public/explore/components/risk_score/translations.ts b/x-pack/plugins/security_solution/public/explore/components/risk_score/translations.ts index 5f9a33f66eac1..a2a04f346de09 100644 --- a/x-pack/plugins/security_solution/public/explore/components/risk_score/translations.ts +++ b/x-pack/plugins/security_solution/public/explore/components/risk_score/translations.ts @@ -32,6 +32,14 @@ export const RISK_SCORE_TITLE = (riskEntity: RiskScoreEntity) => }, }); +export const ENTITY_RISK_CLASSIFICATION = (riskEntity: RiskScoreEntity) => + i18n.translate('xpack.securitySolution.entityAnalytics.riskDashboard.riskClassificationTitle', { + defaultMessage: '{riskEntity} risk classification', + values: { + riskEntity: getRiskEntityTranslation(riskEntity), + }, + }); + export const getRiskEntityTranslation = ( riskEntity: RiskScoreEntity, lowercase = false, diff --git a/x-pack/plugins/security_solution/public/explore/hosts/components/host_risk_score_table/columns.tsx b/x-pack/plugins/security_solution/public/explore/hosts/components/host_risk_score_table/columns.tsx index 557dcff247a6b..f1a489ed83b73 100644 --- a/x-pack/plugins/security_solution/public/explore/hosts/components/host_risk_score_table/columns.tsx +++ b/x-pack/plugins/security_solution/public/explore/hosts/components/host_risk_score_table/columns.tsx @@ -18,8 +18,9 @@ import type { HostRiskScoreColumns } from '.'; import * as i18n from './translations'; import { HostsTableType } from '../../store/model'; import type { RiskSeverity } from '../../../../../common/search_strategy'; -import { RiskScoreFields } from '../../../../../common/search_strategy'; +import { RiskScoreFields, RiskScoreEntity } from '../../../../../common/search_strategy'; import { RiskScore } from '../../../components/risk_score/severity/common'; +import { ENTITY_RISK_CLASSIFICATION } from '../../../components/risk_score/translations'; export const getHostRiskScoreColumns = ({ dispatchSeverityUpdate, @@ -76,7 +77,8 @@ export const getHostRiskScoreColumns = ({ name: ( <> - {i18n.HOST_RISK} + {ENTITY_RISK_CLASSIFICATION(RiskScoreEntity.host)}{' '} + ), diff --git a/x-pack/plugins/security_solution/public/explore/hosts/components/host_risk_score_table/index.tsx b/x-pack/plugins/security_solution/public/explore/hosts/components/host_risk_score_table/index.tsx index a6c7f3cd69ded..f4cec6357c328 100644 --- a/x-pack/plugins/security_solution/public/explore/hosts/components/host_risk_score_table/index.tsx +++ b/x-pack/plugins/security_solution/public/explore/hosts/components/host_risk_score_table/index.tsx @@ -21,6 +21,7 @@ import type { RiskSeverity, RiskScoreFields, } from '../../../../../common/search_strategy'; +import { RiskScoreEntity } from '../../../../../common/search_strategy'; import type { State } from '../../../../common/store'; import * as i18n from '../hosts_table/translations'; import * as i18nHosts from './translations'; @@ -175,8 +176,8 @@ const HostRiskScoreTableComponent: React.FC = ({ } headerSupplement={risk} diff --git a/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/columns.tsx b/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/columns.tsx index 9078a103b389d..2c8eba08178d0 100644 --- a/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/columns.tsx +++ b/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/columns.tsx @@ -18,8 +18,10 @@ import { FormattedRelativePreferenceDate } from '../../../../common/components/f import type { HostsTableColumns } from '.'; import * as i18n from './translations'; import type { Maybe, RiskSeverity } from '../../../../../common/search_strategy'; +import { RiskScoreEntity } from '../../../../../common/search_strategy'; import { VIEW_HOSTS_BY_SEVERITY } from '../host_risk_score_table/translations'; import { RiskScore } from '../../../components/risk_score/severity/common'; +import { ENTITY_RISK_CLASSIFICATION } from '../../../components/risk_score/translations'; export const getHostsColumns = ( showRiskColumn: boolean, @@ -146,7 +148,8 @@ export const getHostsColumns = ( name: ( <> - {i18n.HOST_RISK} + {ENTITY_RISK_CLASSIFICATION(RiskScoreEntity.host)}{' '} + ), diff --git a/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/translations.ts b/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/translations.ts index 49c904f04b57b..55960cca0faf2 100644 --- a/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/explore/hosts/components/hosts_table/translations.ts @@ -59,10 +59,6 @@ export const VERSION = i18n.translate('xpack.securitySolution.hostsTable.version defaultMessage: 'Version', }); -export const HOST_RISK = i18n.translate('xpack.securitySolution.hostsTable.hostRiskTitle', { - defaultMessage: 'Host risk classification', -}); - export const ROWS_5 = i18n.translate('xpack.securitySolution.hostsTable.rows', { values: { numRows: 5 }, defaultMessage: '{numRows} {numRows, plural, =0 {rows} =1 {row} other {rows}}', diff --git a/x-pack/plugins/security_solution/public/explore/users/components/user_risk_score_table/columns.tsx b/x-pack/plugins/security_solution/public/explore/users/components/user_risk_score_table/columns.tsx index 86a57109ac431..cf122adc0c397 100644 --- a/x-pack/plugins/security_solution/public/explore/users/components/user_risk_score_table/columns.tsx +++ b/x-pack/plugins/security_solution/public/explore/users/components/user_risk_score_table/columns.tsx @@ -18,9 +18,10 @@ import type { UserRiskScoreColumns } from '.'; import * as i18n from './translations'; import { RiskScore } from '../../../components/risk_score/severity/common'; import type { RiskSeverity } from '../../../../../common/search_strategy'; -import { RiskScoreFields } from '../../../../../common/search_strategy'; +import { RiskScoreEntity, RiskScoreFields } from '../../../../../common/search_strategy'; import { UserDetailsLink } from '../../../../common/components/links'; import { UsersTableType } from '../../store/model'; +import { ENTITY_RISK_CLASSIFICATION } from '../../../components/risk_score/translations'; export const getUserRiskScoreColumns = ({ dispatchSeverityUpdate, @@ -79,7 +80,8 @@ export const getUserRiskScoreColumns = ({ name: ( <> - {i18n.USER_RISK} + {ENTITY_RISK_CLASSIFICATION(RiskScoreEntity.user)}{' '} + ), diff --git a/x-pack/plugins/security_solution/public/explore/users/components/user_risk_score_table/index.tsx b/x-pack/plugins/security_solution/public/explore/users/components/user_risk_score_table/index.tsx index a08e4fc61468a..378705bd6932e 100644 --- a/x-pack/plugins/security_solution/public/explore/users/components/user_risk_score_table/index.tsx +++ b/x-pack/plugins/security_solution/public/explore/users/components/user_risk_score_table/index.tsx @@ -30,6 +30,7 @@ import type { RiskSeverity, UserRiskScore, } from '../../../../../common/search_strategy'; +import { RiskScoreEntity } from '../../../../../common/search_strategy'; export const rowItems: ItemsPerRow[] = [ { @@ -176,8 +177,8 @@ const UserRiskScoreTableComponent: React.FC = ({ } headerSupplement={risk} diff --git a/x-pack/plugins/security_solution/public/explore/users/components/user_risk_score_table/translations.ts b/x-pack/plugins/security_solution/public/explore/users/components/user_risk_score_table/translations.ts index cd578bbc30c2f..c1f5f2f9da7e8 100644 --- a/x-pack/plugins/security_solution/public/explore/users/components/user_risk_score_table/translations.ts +++ b/x-pack/plugins/security_solution/public/explore/users/components/user_risk_score_table/translations.ts @@ -25,10 +25,6 @@ export const USER_RISK_TOOLTIP = i18n.translate( } ); -export const USER_RISK = i18n.translate('xpack.securitySolution.usersRiskTable.riskTitle', { - defaultMessage: 'User risk classification', -}); - export const VIEW_USERS_BY_SEVERITY = (severity: string) => i18n.translate('xpack.securitySolution.usersRiskTable.filteredUsersTitle', { values: { severity }, diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts index 113abdb2f2831..43065459f555c 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts @@ -119,7 +119,7 @@ const commandToCapabilitiesPrivilegesMap = new Map< ], ]); -const getRbacControl = ({ +export const getRbacControl = ({ commandName, privileges, }: { diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/response_actions.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/response_actions.cy.ts new file mode 100644 index 0000000000000..fef7020ca4973 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/mocked_data/response_actions.cy.ts @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + addEndpointResponseAction, + fillUpNewRule, + focusAndOpenCommandDropdown, + tryAddingDisabledResponseAction, + validateAvailableCommands, + visitRuleActions, +} from '../../tasks/response_actions'; +import { cleanupRule, generateRandomStringName, loadRule } from '../../tasks/api_fixtures'; +import { RESPONSE_ACTION_TYPES } from '../../../../../common/detection_engine/rule_response_actions/schemas'; +import { loginWithRole, ROLE } from '../../tasks/login'; + +describe('Response actions', () => { + describe('User with no access can not create an endpoint response action', () => { + before(() => { + loginWithRole(ROLE.endpoint_response_actions_no_access); + }); + + it('no endpoint response action option during rule creation', () => { + fillUpNewRule(); + tryAddingDisabledResponseAction(); + }); + }); + + describe('User with access can create and save an endpoint response action', () => { + const testedCommand = 'isolate'; + let ruleId: string; + const [ruleName, ruleDescription] = generateRandomStringName(2); + + before(() => { + loginWithRole(ROLE.endpoint_response_actions_access); + }); + after(() => { + cleanupRule(ruleId); + }); + + it('create and save endpoint response action inside of a rule', () => { + fillUpNewRule(ruleName, ruleDescription); + addEndpointResponseAction(); + focusAndOpenCommandDropdown(); + validateAvailableCommands(); + cy.getByTestSubj(`command-type-${testedCommand}`).click(); + addEndpointResponseAction(); + focusAndOpenCommandDropdown(1); + validateAvailableCommands(); + // tested command selected in previous action, should be disabled. + cy.getByTestSubj(`command-type-${testedCommand}`).should('have.attr', 'disabled'); + // Remove first response action, this should unlock tested command as an option + cy.getByTestSubj(`response-actions-list-item-0`).within(() => { + cy.getByTestSubj('remove-response-action').click(); + }); + cy.getByTestSubj(`response-actions-list-item-0`).within(() => { + cy.getByTestSubj('commandTypeField').click(); + }); + cy.getByTestSubj(`command-type-${testedCommand}`).should('not.have.attr', 'disabled'); + cy.getByTestSubj(`command-type-${testedCommand}`).click(); + cy.intercept('POST', '/api/detection_engine/rules', (request) => { + const result = { + action_type_id: RESPONSE_ACTION_TYPES.ENDPOINT, + params: { + command: testedCommand, + comment: 'example1', + }, + }; + expect(request.body.response_actions[0]).to.deep.equal(result); + request.continue((response) => { + ruleId = response.body.id; + response.send(response.body); + }); + }); + cy.getByTestSubj('create-enabled-false').click(); + cy.contains(`${ruleName} was created`); + }); + }); + + describe('User with access can edit and delete an endpoint response action', () => { + let ruleId: string; + let ruleName: string; + const testedCommand = 'isolate'; + const newDescription = 'Example isolate host description'; + + before(() => { + loadRule().then((res) => { + ruleId = res.id; + ruleName = res.name; + }); + }); + beforeEach(() => { + loginWithRole(ROLE.endpoint_response_actions_access); + }); + after(() => { + cleanupRule(ruleId); + }); + + it('edit response action inside of a rule', () => { + visitRuleActions(ruleId); + cy.getByTestSubj(`response-actions-list-item-0`).within(() => { + cy.getByTestSubj('input').should('have.value', 'Isolate host'); + cy.getByTestSubj('input').should('have.value', 'Isolate host'); + cy.getByTestSubj('input').type(`{selectall}{backspace}${newDescription}`); + cy.getByTestSubj('commandTypeField').click(); + }); + validateAvailableCommands(); + cy.intercept('PUT', '/api/detection_engine/rules').as('updateResponseAction'); + cy.getByTestSubj('ruleEditSubmitButton').click(); + cy.wait('@updateResponseAction').should(({ request }) => { + const query = { + action_type_id: RESPONSE_ACTION_TYPES.ENDPOINT, + params: { + command: testedCommand, + comment: newDescription, + }, + }; + expect(request.body.response_actions[0]).to.deep.equal(query); + }); + cy.contains(`${ruleName} was saved`).should('exist'); + }); + + it('delete response action inside of a rule', () => { + visitRuleActions(ruleId); + cy.getByTestSubj(`response-actions-list-item-0`).within(() => { + cy.getByTestSubj('remove-response-action').click(); + }); + cy.intercept('PUT', '/api/detection_engine/rules').as('deleteResponseAction'); + cy.getByTestSubj('ruleEditSubmitButton').click(); + cy.wait('@deleteResponseAction').should(({ request }) => { + expect(request.body.response_actions).to.be.equal(undefined); + }); + cy.contains(`${ruleName} was saved`).should('exist'); + }); + }); + + describe('User without access can not edit, add nor delete an endpoint response action', () => { + let ruleId: string; + + before(() => { + loadRule().then((res) => { + ruleId = res.id; + }); + loginWithRole(ROLE.endpoint_response_actions_no_access); + }); + after(() => { + cleanupRule(ruleId); + }); + + it('All response action controls are disabled', () => { + visitRuleActions(ruleId); + cy.getByTestSubj('response-actions-wrapper').within(() => { + cy.getByTestSubj('Endpoint Security-response-action-type-selection-option').should( + 'be.disabled' + ); + }); + cy.getByTestSubj(`response-actions-list-item-0`).within(() => { + cy.getByTestSubj('commandTypeField').should('have.text', 'isolate').and('be.disabled'); + cy.getByTestSubj('input').should('have.value', 'Isolate host').and('be.disabled'); + cy.getByTestSubj('remove-response-action').should('be.disabled'); + // Try removing action + cy.getByTestSubj('remove-response-action').click({ force: true }); + }); + cy.getByTestSubj(`response-actions-list-item-0`).should('exist'); + tryAddingDisabledResponseAction(1); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/api_fixtures.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/api_fixtures.ts new file mode 100644 index 0000000000000..8d8df6318d215 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/api_fixtures.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + RuleCreateProps, + RuleResponse, +} from '../../../../common/detection_engine/rule_schema'; +import { request } from './common'; + +export const generateRandomStringName = (length: number) => + Array.from({ length }, () => Math.random().toString(36).substring(2)); + +export const cleanupRule = (id: string) => { + request({ method: 'DELETE', url: `/api/detection_engine/rules?id=${id}` }); +}; + +export const loadRule = () => + request({ + method: 'POST', + body: { + type: 'query', + index: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'traces-apm*', + 'winlogbeat-*', + '-*elastic-cloud-logs-*', + ], + filters: [], + language: 'kuery', + query: '_id:*', + author: [], + false_positives: [], + references: [], + risk_score: 21, + risk_score_mapping: [], + severity: 'low', + severity_mapping: [], + threat: [], + name: `Test rule ${generateRandomStringName(1)[0]}`, + description: 'Test rule', + tags: [], + license: '', + interval: '1m', + from: 'now-120s', + to: 'now', + meta: { from: '1m', kibana_siem_app_url: 'http://localhost:5620/app/security' }, + actions: [], + enabled: true, + throttle: 'no_actions', + response_actions: [ + { params: { command: 'isolate', comment: 'Isolate host' }, action_type_id: '.endpoint' }, + ], + } as RuleCreateProps, + url: `/api/detection_engine/rules`, + }).then((response) => response.body); diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/login.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/login.ts index 3ac1cfb1b02e4..9b2dfca805a99 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/tasks/login.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/login.ts @@ -11,6 +11,8 @@ import * as yaml from 'js-yaml'; import type { UrlObject } from 'url'; import Url from 'url'; import type { Role } from '@kbn/security-plugin/common'; +import { getWithResponseActionsRole } from '../../../../scripts/endpoint/common/roles_users/with_response_actions_role'; +import { getNoResponseActionsRole } from '../../../../scripts/endpoint/common/roles_users/without_response_actions_role'; import { request } from './common'; import { getT1Analyst } from '../../../../scripts/endpoint/common/roles_users/t1_analyst'; import { getT2Analyst } from '../../../../scripts/endpoint/common/roles_users/t2_analyst'; @@ -32,6 +34,8 @@ export enum ROLE { platform_engineer = 'platformEngineer', endpoint_operations_analyst = 'endpointOperationsAnalyst', endpoint_security_policy_manager = 'endpointSecurityPolicyManager', + endpoint_response_actions_access = 'endpointResponseActionsAccess', + endpoint_response_actions_no_access = 'endpointResponseActionsNoAccess', } export const rolesMapping: { [key in ROLE]: Omit } = { @@ -44,6 +48,8 @@ export const rolesMapping: { [key in ROLE]: Omit } = { endpointOperationsAnalyst: getEndpointOperationsAnalyst(), endpointSecurityPolicyManager: getEndpointSecurityPolicyManager(), detectionsEngineer: getDetectionsEngineer(), + endpointResponseActionsAccess: getWithResponseActionsRole(), + endpointResponseActionsNoAccess: getNoResponseActionsRole(), }; /** * Credentials in the `kibana.dev.yml` config file will be used to authenticate diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts new file mode 100644 index 0000000000000..c888e7dce1254 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS } from '../../../../common/endpoint/service/response_actions/constants'; + +export const validateAvailableCommands = () => { + cy.get('[data-test-subj^="command-type"]').should( + 'have.length', + ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS.length + ); + ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS.forEach((command) => { + cy.getByTestSubj(`command-type-${command}`); + }); +}; +export const addEndpointResponseAction = () => { + cy.getByTestSubj('response-actions-wrapper').within(() => { + cy.getByTestSubj('Endpoint Security-response-action-type-selection-option').click(); + }); +}; +export const focusAndOpenCommandDropdown = (number = 0) => { + cy.getByTestSubj(`response-actions-list-item-${number}`).within(() => { + cy.getByTestSubj('input').type(`example${number}`); + cy.getByTestSubj('commandTypeField').click(); + }); +}; +export const fillUpNewRule = (name = 'Test', description = 'Test') => { + cy.visit('app/security/rules/management'); + cy.getByTestSubj('create-new-rule').click(); + cy.getByTestSubj('stepDefineRule').within(() => { + cy.getByTestSubj('queryInput').first().type('_id:*{enter}'); + }); + cy.getByTestSubj('define-continue').click(); + cy.getByTestSubj('detectionEngineStepAboutRuleName').within(() => { + cy.getByTestSubj('input').type(name); + }); + cy.getByTestSubj('detectionEngineStepAboutRuleDescription').within(() => { + cy.getByTestSubj('input').type(description); + }); + cy.getByTestSubj('about-continue').click(); + cy.getByTestSubj('schedule-continue').click(); +}; +export const visitRuleActions = (ruleId: string) => { + cy.visit(`app/security/rules/id/${ruleId}/edit`); + cy.getByTestSubj('edit-rule-actions-tab').wait(500).click(); +}; +export const tryAddingDisabledResponseAction = (itemNumber = 0) => { + cy.getByTestSubj('response-actions-wrapper').within(() => { + cy.getByTestSubj('Endpoint Security-response-action-type-selection-option').should( + 'be.disabled' + ); + }); + // Try adding new action, should not add list item. + cy.getByTestSubj('Endpoint Security-response-action-type-selection-option').click({ + force: true, + }); + cy.getByTestSubj(`response-actions-list-item-${itemNumber}`).should('not.exist'); +}; diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/alerts_by_status.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/alerts_by_status.tsx index f785ec136db56..0c67de7a2b079 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/alerts_by_status.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/alerts_by_status.tsx @@ -20,6 +20,9 @@ import type { ShapeTreeNode } from '@elastic/charts'; import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; import styled from 'styled-components'; +import { ALERT_WORKFLOW_STATUS, ALERT_SEVERITY } from '@kbn/rule-data-utils'; +import { FILTER_OPEN, FILTER_ACKNOWLEDGED, FILTER_CLOSED } from '../../../../../common/types'; +import { useNavigateToAlertsPageWithFilters } from '../../../../common/hooks/use_navigate_to_alerts_page_with_filters'; import type { ESBoolQuery } from '../../../../../common/typed_json'; import type { FillColor } from '../../../../common/components/charts/donutchart'; import { DonutChart } from '../../../../common/components/charts/donutchart'; @@ -35,6 +38,8 @@ import { ALERTS_TEXT, ALERTS_BY_SEVERITY_TEXT, INVESTIGATE_IN_TIMELINE, + OPEN_IN_ALERTS_TITLE_SEVERITY, + OPEN_IN_ALERTS_TITLE_STATUS, STATUS_ACKNOWLEDGED, STATUS_CLOSED, STATUS_CRITICAL_LABEL, @@ -80,7 +85,6 @@ interface AlertsByStatusProps { signalIndexName: string | null; } -const legendField = 'kibana.alert.severity'; const chartConfigs: Array<{ key: Severity; label: string; color: string }> = [ { key: 'critical', label: STATUS_CRITICAL_LABEL, color: SEVERITY_COLOR.critical }, { key: 'high', label: STATUS_HIGH_LABEL, color: SEVERITY_COLOR.high }, @@ -105,6 +109,7 @@ export const AlertsByStatus = ({ }: AlertsByStatusProps) => { const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTION_RESPONSE_ALERTS_BY_STATUS_ID); const { openTimelineWithFilters } = useNavigateToTimeline(); + const navigateToAlerts = useNavigateToAlertsPageWithFilters(); const { onClick: goToAlerts, href } = useGetSecuritySolutionLinkProps()({ deepLinkId: SecurityPageName.alerts, }); @@ -147,12 +152,56 @@ export const AlertsByStatus = ({ () => chartConfigs.map((d) => ({ color: d.color, - field: legendField, + field: ALERT_SEVERITY, value: d.label, })), [] ); + const navigateToAlertsWithStatus = useCallback( + (status: Status, level?: string) => + navigateToAlerts([ + { + title: OPEN_IN_ALERTS_TITLE_STATUS, + selectedOptions: [status], + fieldName: ALERT_WORKFLOW_STATUS, + }, + ...(level + ? [ + { + title: OPEN_IN_ALERTS_TITLE_SEVERITY, + selectedOptions: [level], + fieldName: ALERT_SEVERITY, + }, + ] + : []), + ...(entityFilter + ? [ + { + selectedOptions: [entityFilter.value], + fieldName: entityFilter.field, + }, + ] + : []), + ]), + [entityFilter, navigateToAlerts] + ); + + const navigateToAlertsWithStatusOpen = useCallback( + (level?: string) => navigateToAlertsWithStatus(FILTER_OPEN, level), + [navigateToAlertsWithStatus] + ); + + const navigateToAlertsWithStatusAcknowledged = useCallback( + (level?: string) => navigateToAlertsWithStatus(FILTER_ACKNOWLEDGED, level), + [navigateToAlertsWithStatus] + ); + + const navigateToAlertsWithStatusClosed = useCallback( + (level?: string) => navigateToAlertsWithStatus(FILTER_CLOSED, level), + [navigateToAlertsWithStatus] + ); + const openCount = donutData?.open?.total ?? 0; const acknowledgedCount = donutData?.acknowledged?.total ?? 0; const closedCount = donutData?.closed?.total ?? 0; @@ -232,17 +281,23 @@ export const AlertsByStatus = ({ isDonut={true} label={STATUS_OPEN} scopeId={SourcererScopeName.detections} - stackByField="kibana.alert.workflow_status" + stackByField={ALERT_WORKFLOW_STATUS} timerange={timerange} width={ChartSize} /> ) : ( } + title={ + + } totalCount={openCount} /> )} @@ -261,7 +316,7 @@ export const AlertsByStatus = ({ isDonut={true} label={STATUS_ACKNOWLEDGED} scopeId={SourcererScopeName.detections} - stackByField="kibana.alert.workflow_status" + stackByField={ALERT_WORKFLOW_STATUS} timerange={timerange} width={ChartSize} /> @@ -271,7 +326,13 @@ export const AlertsByStatus = ({ fillColor={fillColor} height={donutHeight} label={STATUS_ACKNOWLEDGED} - title={} + onPartitionClick={navigateToAlertsWithStatusAcknowledged} + title={ + + } totalCount={acknowledgedCount} /> )} @@ -290,7 +351,7 @@ export const AlertsByStatus = ({ isDonut={true} label={STATUS_CLOSED} scopeId={SourcererScopeName.detections} - stackByField="kibana.alert.workflow_status" + stackByField={ALERT_WORKFLOW_STATUS} timerange={timerange} width={ChartSize} /> @@ -300,7 +361,13 @@ export const AlertsByStatus = ({ fillColor={fillColor} height={donutHeight} label={STATUS_CLOSED} - title={} + onPartitionClick={navigateToAlertsWithStatusClosed} + title={ + + } totalCount={closedCount} /> )} diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/chart_label.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/chart_label.tsx index 684c1db54670c..12dce8053bf59 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/chart_label.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/chart_label.tsx @@ -4,26 +4,37 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React from 'react'; +import { EuiLink } from '@elastic/eui'; +import React, { useCallback } from 'react'; import styled from 'styled-components'; import { FormattedCount } from '../../../../common/components/formatted_number'; interface ChartLabelProps { count: number | null | undefined; + onClick?: () => void; } const PlaceHolder = styled.div` padding: ${(props) => props.theme.eui.euiSizeS}; `; -const ChartLabelComponent: React.FC = ({ count }) => { - return count != null ? ( - - - - ) : ( - - ); +const ChartLabelComponent: React.FC = ({ count, onClick }) => { + const onLabelClick = useCallback(() => onClick && onClick(), [onClick]); + + if (count) { + return onClick ? ( + + + + + + ) : ( + + + + ); + } + return ; }; ChartLabelComponent.displayName = 'ChartLabelComponent'; diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.test.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.test.tsx index cbd4e0c59b224..f0e532f9c97f4 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.test.tsx @@ -13,25 +13,11 @@ import { TestProviders } from '../../../../common/mock'; import { parsedVulnerableHostsAlertsResult } from './mock_data'; import type { UseHostAlertsItems } from './use_host_alerts_items'; import { HostAlertsTable } from './host_alerts_table'; -import { openAlertsFilter } from '../utils'; -const mockGetAppUrl = jest.fn(); -jest.mock('../../../../common/lib/kibana/hooks', () => { - const original = jest.requireActual('../../../../common/lib/kibana/hooks'); +const mockNavigateToAlertsPageWithFilters = jest.fn(); +jest.mock('../../../../common/hooks/use_navigate_to_alerts_page_with_filters', () => { return { - ...original, - useNavigation: () => ({ - getAppUrl: mockGetAppUrl, - }), - }; -}); - -const mockOpenTimelineWithFilters = jest.fn(); -jest.mock('../hooks/use_navigate_to_timeline', () => { - return { - useNavigateToTimeline: () => ({ - openTimelineWithFilters: mockOpenTimelineWithFilters, - }), + useNavigateToAlertsPageWithFilters: () => mockNavigateToAlertsPageWithFilters, }; }); @@ -141,14 +127,8 @@ describe('HostAlertsTable', () => { fireEvent.click(getByTestId('hostSeverityAlertsTable-totalAlertsLink')); - expect(mockOpenTimelineWithFilters).toHaveBeenCalledWith([ - [ - { - field: 'host.name', - value: 'Host-342m5gl1g2', - }, - openAlertsFilter, - ], + expect(mockNavigateToAlertsPageWithFilters).toHaveBeenCalledWith([ + { fieldName: 'host.name', selectedOptions: ['Host-342m5gl1g2'], title: 'Host name' }, ]); }); @@ -158,18 +138,17 @@ describe('HostAlertsTable', () => { fireEvent.click(getByTestId('hostSeverityAlertsTable-criticalLink')); - expect(mockOpenTimelineWithFilters).toHaveBeenCalledWith([ - [ - { - field: 'host.name', - value: 'Host-342m5gl1g2', - }, - openAlertsFilter, - { - field: 'kibana.alert.severity', - value: 'critical', - }, - ], + expect(mockNavigateToAlertsPageWithFilters).toHaveBeenCalledWith([ + { + fieldName: 'host.name', + selectedOptions: ['Host-342m5gl1g2'], + title: 'Host name', + }, + { + fieldName: 'kibana.alert.severity', + selectedOptions: ['critical'], + title: 'Severity', + }, ]); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.tsx index c090277fc1786..26e3d866ff343 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/host_alerts_table/host_alerts_table.tsx @@ -19,6 +19,8 @@ import { EuiToolTip, } from '@elastic/eui'; +import { ALERT_SEVERITY } from '@kbn/rule-data-utils'; +import { useNavigateToAlertsPageWithFilters } from '../../../../common/hooks/use_navigate_to_alerts_page_with_filters'; import { FormattedCount } from '../../../../common/components/formatted_number'; import { HeaderSection } from '../../../../common/components/header_section'; import { HoverVisibilityContainer } from '../../../../common/components/hover_visibility_container'; @@ -26,9 +28,9 @@ import { BUTTON_CLASS as INPECT_BUTTON_CLASS } from '../../../../common/componen import { LastUpdatedAt } from '../../../../common/components/last_updated_at'; import { HostDetailsLink } from '../../../../common/components/links'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; -import { useNavigateToTimeline } from '../hooks/use_navigate_to_timeline'; + import * as i18n from '../translations'; -import { ITEMS_PER_PAGE, openAlertsFilter, SEVERITY_COLOR } from '../utils'; +import { ITEMS_PER_PAGE, SEVERITY_COLOR } from '../utils'; import type { HostAlertsItem } from './use_host_alerts_items'; import { useHostAlertsItems } from './use_host_alerts_items'; @@ -43,22 +45,28 @@ type GetTableColumns = ( const DETECTION_RESPONSE_HOST_SEVERITY_QUERY_ID = 'vulnerableHostsBySeverityQuery'; export const HostAlertsTable = React.memo(({ signalIndexName }: HostAlertsTableProps) => { - const { openTimelineWithFilters } = useNavigateToTimeline(); + const openAlertsPageWithFilters = useNavigateToAlertsPageWithFilters(); - const openHostInTimeline = useCallback( - ({ hostName, severity }: { hostName: string; severity?: string }) => { - const hostNameFilter = { field: 'host.name', value: hostName }; - const severityFilter = severity - ? { field: 'kibana.alert.severity', value: severity } - : undefined; + const openHostInAlerts = useCallback( + ({ hostName, severity }: { hostName: string; severity?: string }) => + openAlertsPageWithFilters([ + { + title: i18n.OPEN_IN_ALERTS_TITLE_HOSTNAME, + selectedOptions: [hostName], + fieldName: 'host.name', + }, - openTimelineWithFilters( - severityFilter - ? [[hostNameFilter, openAlertsFilter, severityFilter]] - : [[hostNameFilter, openAlertsFilter]] - ); - }, - [openTimelineWithFilters] + ...(severity + ? [ + { + title: i18n.OPEN_IN_ALERTS_TITLE_SEVERITY, + selectedOptions: [severity], + fieldName: ALERT_SEVERITY, + }, + ] + : []), + ]), + [openAlertsPageWithFilters] ); const { toggleStatus, setToggleStatus } = useQueryToggle( @@ -70,7 +78,7 @@ export const HostAlertsTable = React.memo(({ signalIndexName }: HostAlertsTableP signalIndexName, }); - const columns = useMemo(() => getTableColumns(openHostInTimeline), [openHostInTimeline]); + const columns = useMemo(() => getTableColumns(openHostInAlerts), [openHostInAlerts]); return ( diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.test.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.test.tsx index fca9b4cd8e479..9750b8d34b9de 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.test.tsx @@ -15,7 +15,6 @@ import { TestProviders } from '../../../../common/mock'; import type { RuleAlertsTableProps } from './rule_alerts_table'; import { RuleAlertsTable } from './rule_alerts_table'; import type { RuleAlertsItem, UseRuleAlertsItems } from './use_rule_alerts_items'; -import { openAlertsFilter } from '../utils'; const mockGetAppUrl = jest.fn(); jest.mock('../../../../common/lib/kibana/hooks', () => { @@ -28,12 +27,10 @@ jest.mock('../../../../common/lib/kibana/hooks', () => { }; }); -const mockOpenTimelineWithFilters = jest.fn(); -jest.mock('../hooks/use_navigate_to_timeline', () => { +const mockNavigateToAlertsPageWithFilters = jest.fn(); +jest.mock('../../../../common/hooks/use_navigate_to_alerts_page_with_filters', () => { return { - useNavigateToTimeline: () => ({ - openTimelineWithFilters: mockOpenTimelineWithFilters, - }), + useNavigateToAlertsPageWithFilters: () => mockNavigateToAlertsPageWithFilters, }; }); @@ -166,14 +163,10 @@ describe('RuleAlertsTable', () => { fireEvent.click(getByTestId('severityRuleAlertsTable-alertCountLink')); - expect(mockOpenTimelineWithFilters).toHaveBeenCalledWith([ - [ - { - field: 'kibana.alert.rule.name', - value: ruleName, - }, - openAlertsFilter, - ], - ]); + expect(mockNavigateToAlertsPageWithFilters).toHaveBeenCalledWith({ + fieldName: 'kibana.alert.rule.name', + selectedOptions: ['ruleName'], + title: 'Rule name', + }); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.tsx index 82ec2837e77b7..8b0f38d0e479f 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.tsx @@ -20,9 +20,11 @@ import { } from '@elastic/eui'; import { FormattedRelative } from '@kbn/i18n-react'; import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; +import { ALERT_RULE_NAME } from '@kbn/rule-data-utils'; +import { useNavigateToAlertsPageWithFilters } from '../../../../common/hooks/use_navigate_to_alerts_page_with_filters'; import { HeaderSection } from '../../../../common/components/header_section'; -import { openAlertsFilter, SEVERITY_COLOR } from '../utils'; +import { SEVERITY_COLOR } from '../utils'; import * as i18n from '../translations'; import type { RuleAlertsItem } from './use_rule_alerts_items'; import { useRuleAlertsItems } from './use_rule_alerts_items'; @@ -34,7 +36,6 @@ import { HoverVisibilityContainer } from '../../../../common/components/hover_vi import { BUTTON_CLASS as INSPECT_BUTTON_CLASS } from '../../../../common/components/inspect'; import { LastUpdatedAt } from '../../../../common/components/last_updated_at'; import { FormattedCount } from '../../../../common/components/formatted_number'; -import { useNavigateToTimeline } from '../hooks/use_navigate_to_timeline'; export interface RuleAlertsTableProps { signalIndexName: string | null; @@ -43,13 +44,17 @@ export interface RuleAlertsTableProps { export type GetTableColumns = (params: { getAppUrl: GetAppUrl; navigateTo: NavigateTo; - openRuleInTimeline: (ruleName: string) => void; + openRuleInAlertsPage: (ruleName: string) => void; }) => Array>; const DETECTION_RESPONSE_RULE_ALERTS_QUERY_ID = 'detection-response-rule-alerts-severity-table' as const; -export const getTableColumns: GetTableColumns = ({ getAppUrl, navigateTo, openRuleInTimeline }) => [ +export const getTableColumns: GetTableColumns = ({ + getAppUrl, + navigateTo, + openRuleInAlertsPage, +}) => [ { field: 'name', name: i18n.RULE_ALERTS_COLUMN_RULE_NAME, @@ -93,7 +98,7 @@ export const getTableColumns: GetTableColumns = ({ getAppUrl, navigateTo, openRu openRuleInTimeline(name)} + onClick={() => openRuleInAlertsPage(name)} > @@ -118,15 +123,16 @@ export const RuleAlertsTable = React.memo(({ signalIndexNa skip: !toggleStatus, }); - const { openTimelineWithFilters } = useNavigateToTimeline(); + const openAlertsPageWithFilter = useNavigateToAlertsPageWithFilters(); - const openRuleInTimeline = useCallback( - (ruleName: string) => { - openTimelineWithFilters([ - [{ field: 'kibana.alert.rule.name', value: ruleName }, openAlertsFilter], - ]); - }, - [openTimelineWithFilters] + const openRuleInAlertsPage = useCallback( + (ruleName: string) => + openAlertsPageWithFilter({ + title: i18n.OPEN_IN_ALERTS_TITLE_RULENAME, + selectedOptions: [ruleName], + fieldName: ALERT_RULE_NAME, + }), + [openAlertsPageWithFilter] ); const navigateToAlerts = useCallback(() => { @@ -134,8 +140,8 @@ export const RuleAlertsTable = React.memo(({ signalIndexNa }, [navigateTo]); const columns = useMemo( - () => getTableColumns({ getAppUrl, navigateTo, openRuleInTimeline }), - [getAppUrl, navigateTo, openRuleInTimeline] + () => getTableColumns({ getAppUrl, navigateTo, openRuleInAlertsPage }), + [getAppUrl, navigateTo, openRuleInAlertsPage] ); return ( diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/translations.ts b/x-pack/plugins/security_solution/public/overview/components/detection_response/translations.ts index a896d4b948cc5..a1c7020f222ee 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/translations.ts +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/translations.ts @@ -255,9 +255,45 @@ export const USER_TOOLTIP = i18n.translate( defaultMessage: 'Maximum of 100 users. Please consult Alerts page for further information.', } ); + export const INVESTIGATE_IN_TIMELINE = i18n.translate( 'xpack.securitySolution.detectionResponse.investigateInTimeline', { defaultMessage: 'Investigate in Timeline', } ); + +export const OPEN_IN_ALERTS_TITLE_SEVERITY = i18n.translate( + 'xpack.securitySolution.detectionResponse.openInAlertsSeverity', + { + defaultMessage: 'Severity', + } +); + +export const OPEN_IN_ALERTS_TITLE_STATUS = i18n.translate( + 'xpack.securitySolution.detectionResponse.openInAlertsStatus', + { + defaultMessage: 'Status', + } +); + +export const OPEN_IN_ALERTS_TITLE_RULENAME = i18n.translate( + 'xpack.securitySolution.detectionResponse.openInAlertsRuleName', + { + defaultMessage: 'Rule name', + } +); + +export const OPEN_IN_ALERTS_TITLE_HOSTNAME = i18n.translate( + 'xpack.securitySolution.detectionResponse.openInAlertsHostName', + { + defaultMessage: 'Host name', + } +); + +export const OPEN_IN_ALERTS_TITLE_USERNAME = i18n.translate( + 'xpack.securitySolution.detectionResponse.openInAlertsUserName', + { + defaultMessage: 'Username', + } +); diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.test.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.test.tsx index 1d7cb38b864ac..3942f7c41c909 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.test.tsx @@ -13,7 +13,6 @@ import { TestProviders } from '../../../../common/mock'; import { parsedVulnerableUserAlertsResult } from './mock_data'; import type { UseUserAlertsItems } from './use_user_alerts_items'; import { UserAlertsTable } from './user_alerts_table'; -import { openAlertsFilter } from '../utils'; const userName = 'crffn20qcs'; const mockGetAppUrl = jest.fn(); @@ -27,12 +26,10 @@ jest.mock('../../../../common/lib/kibana/hooks', () => { }; }); -const mockOpenTimelineWithFilters = jest.fn(); -jest.mock('../hooks/use_navigate_to_timeline', () => { +const mockNavigateToAlertsPageWithFilters = jest.fn(); +jest.mock('../../../../common/hooks/use_navigate_to_alerts_page_with_filters', () => { return { - useNavigateToTimeline: () => ({ - openTimelineWithFilters: mockOpenTimelineWithFilters, - }), + useNavigateToAlertsPageWithFilters: () => mockNavigateToAlertsPageWithFilters, }; }); @@ -142,14 +139,8 @@ describe('UserAlertsTable', () => { fireEvent.click(getByTestId('userSeverityAlertsTable-totalAlertsLink')); - expect(mockOpenTimelineWithFilters).toHaveBeenCalledWith([ - [ - { - field: 'user.name', - value: userName, - }, - openAlertsFilter, - ], + expect(mockNavigateToAlertsPageWithFilters).toHaveBeenCalledWith([ + { fieldName: 'user.name', selectedOptions: ['crffn20qcs'], title: 'Username' }, ]); }); @@ -159,18 +150,13 @@ describe('UserAlertsTable', () => { fireEvent.click(getByTestId('userSeverityAlertsTable-criticalLink')); - expect(mockOpenTimelineWithFilters).toHaveBeenCalledWith([ - [ - { - field: 'user.name', - value: userName, - }, - openAlertsFilter, - { - field: 'kibana.alert.severity', - value: 'critical', - }, - ], + expect(mockNavigateToAlertsPageWithFilters).toHaveBeenCalledWith([ + { fieldName: 'user.name', selectedOptions: ['crffn20qcs'], title: 'Username' }, + { + fieldName: 'kibana.alert.severity', + selectedOptions: ['critical'], + title: 'Severity', + }, ]); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.tsx index e4a2c29caa475..ddaf75cba0f8a 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/user_alerts_table/user_alerts_table.tsx @@ -19,6 +19,8 @@ import { EuiToolTip, } from '@elastic/eui'; +import { ALERT_SEVERITY } from '@kbn/rule-data-utils'; +import { useNavigateToAlertsPageWithFilters } from '../../../../common/hooks/use_navigate_to_alerts_page_with_filters'; import { FormattedCount } from '../../../../common/components/formatted_number'; import { HeaderSection } from '../../../../common/components/header_section'; import { HoverVisibilityContainer } from '../../../../common/components/hover_visibility_container'; @@ -26,9 +28,8 @@ import { BUTTON_CLASS as INPECT_BUTTON_CLASS } from '../../../../common/componen import { LastUpdatedAt } from '../../../../common/components/last_updated_at'; import { UserDetailsLink } from '../../../../common/components/links'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; -import { useNavigateToTimeline } from '../hooks/use_navigate_to_timeline'; import * as i18n from '../translations'; -import { ITEMS_PER_PAGE, openAlertsFilter, SEVERITY_COLOR } from '../utils'; +import { ITEMS_PER_PAGE, SEVERITY_COLOR } from '../utils'; import type { UserAlertsItem } from './use_user_alerts_items'; import { useUserAlertsItems } from './use_user_alerts_items'; @@ -43,23 +44,30 @@ type GetTableColumns = ( const DETECTION_RESPONSE_USER_SEVERITY_QUERY_ID = 'vulnerableUsersBySeverityQuery'; export const UserAlertsTable = React.memo(({ signalIndexName }: UserAlertsTableProps) => { - const { openTimelineWithFilters } = useNavigateToTimeline(); + const openAlertsPageWithFilters = useNavigateToAlertsPageWithFilters(); - const openUserInTimeline = useCallback( - ({ userName, severity }: { userName: string; severity?: string }) => { - const userNameFilter = { field: 'user.name', value: userName }; - const severityFilter = severity - ? { field: 'kibana.alert.severity', value: severity } - : undefined; + const openUserInAlerts = useCallback( + ({ userName, severity }: { userName: string; severity?: string }) => + openAlertsPageWithFilters([ + { + title: i18n.OPEN_IN_ALERTS_TITLE_USERNAME, + selectedOptions: [userName], + fieldName: 'user.name', + }, - openTimelineWithFilters( - severityFilter - ? [[userNameFilter, openAlertsFilter, severityFilter]] - : [[userNameFilter, openAlertsFilter]] - ); - }, - [openTimelineWithFilters] + ...(severity + ? [ + { + title: i18n.OPEN_IN_ALERTS_TITLE_SEVERITY, + selectedOptions: [severity], + fieldName: ALERT_SEVERITY, + }, + ] + : []), + ]), + [openAlertsPageWithFilters] ); + const { toggleStatus, setToggleStatus } = useQueryToggle( DETECTION_RESPONSE_USER_SEVERITY_QUERY_ID ); @@ -69,7 +77,7 @@ export const UserAlertsTable = React.memo(({ signalIndexName }: UserAlertsTableP signalIndexName, }); - const columns = useMemo(() => getTableColumns(openUserInTimeline), [openUserInTimeline]); + const columns = useMemo(() => getTableColumns(openUserInAlerts), [openUserInAlerts]); return ( diff --git a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/columns.tsx b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/columns.tsx index 141ce39008928..5909bba78283f 100644 --- a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/columns.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/columns.tsx @@ -8,8 +8,8 @@ import React from 'react'; import type { EuiBasicTableColumn } from '@elastic/eui'; import { EuiLink, EuiIcon, EuiToolTip } from '@elastic/eui'; -import { get } from 'lodash/fp'; import styled from 'styled-components'; +import { get } from 'lodash/fp'; import { UsersTableType } from '../../../../explore/users/store/model'; import { getEmptyTagValue } from '../../../../common/components/empty_value'; import { HostDetailsLink, UserDetailsLink } from '../../../../common/components/links'; @@ -29,6 +29,7 @@ import { SecurityCellActionsTrigger, SecurityCellActionType, } from '../../../../common/components/cell_actions'; +import { useKibana } from '../../../../common/lib/kibana'; type HostRiskScoreColumns = Array>; @@ -36,9 +37,11 @@ const StyledCellActions = styled(SecurityCellActions)` padding-left: ${({ theme }) => theme.eui.euiSizeS}; `; +type OpenEntityInTimeline = (entityName: string, oldestAlertTimestamp?: string) => void; + export const getRiskScoreColumns = ( riskEntity: RiskScoreEntity, - openEntityInTimeline: (entityName: string, oldestAlertTimestamp?: string) => void + openEntityInTimeline: OpenEntityInTimeline ): HostRiskScoreColumns => [ { field: riskEntity === RiskScoreEntity.host ? 'host.name' : 'user.name', @@ -114,7 +117,7 @@ export const getRiskScoreColumns = ( name: ( <> - {i18n.ENTITY_RISK(riskEntity)} + {i18n.ENTITY_RISK_CLASSIFICATION(riskEntity)} @@ -135,18 +138,45 @@ export const getRiskScoreColumns = ( truncateText: false, mobileOptions: { show: true }, render: (alertCount: number, risk) => ( - - openEntityInTimeline( - get('host.name', risk) ?? get('user.name', risk), - risk.oldestAlertTimestamp - ) - } - > - - + ), }, ]; + +interface AlertsCountColumnParams { + riskEntity: RiskScoreEntity; + openEntityInTimeline: OpenEntityInTimeline; + alertCount: number; + risk: HostRiskScore & UserRiskScore; +} + +const AlertsCountColumn = ({ + riskEntity, + openEntityInTimeline, + alertCount, + risk, +}: AlertsCountColumnParams) => { + const { telemetry } = useKibana().services; + + return ( + { + telemetry.reportEntityAlertsClicked({ entity: 'host' }); + + openEntityInTimeline( + get('host.name', risk) ?? get('user.name', risk), + risk.oldestAlertTimestamp + ); + }} + > + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/header_content.tsx b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/header_content.tsx index 3ddd737486a2f..d4a1ff1887285 100644 --- a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/header_content.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/header_content.tsx @@ -56,7 +56,7 @@ const RiskScoreHeaderContentComponent = ({ diff --git a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/index.test.tsx index 054eb87daf992..08faeefcb8418 100644 --- a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/index.test.tsx @@ -14,6 +14,25 @@ import { RiskScoreEntity, RiskSeverity } from '../../../../../common/search_stra import type { SeverityCount } from '../../../../explore/components/risk_score/severity/types'; import { useRiskScore, useRiskScoreKpi } from '../../../../explore/containers/risk_score'; import { openAlertsFilter } from '../../detection_response/utils'; +import { useKibana as mockUseKibana } from '../../../../common/lib/kibana/__mocks__'; +import { createTelemetryServiceMock } from '../../../../common/lib/telemetry/telemetry_service.mock'; + +const mockedTelemetry = createTelemetryServiceMock(); +const mockedUseKibana = mockUseKibana(); +jest.mock('../../../../common/lib/kibana', () => { + const original = jest.requireActual('../../../../common/lib/kibana'); + + return { + ...original, + useKibana: () => ({ + ...mockedUseKibana, + services: { + ...mockedUseKibana.services, + telemetry: mockedTelemetry, + }, + }), + }; +}); const mockSeverityCount: SeverityCount = { [RiskSeverity.low]: 1, diff --git a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/translations.ts b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/translations.ts index c4f9ea993bb5b..3d8332c771bd9 100644 --- a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/translations.ts +++ b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/risk_score/translations.ts @@ -20,14 +20,6 @@ export const ENTITY_RISK_TOOLTIP = (riskEntity: RiskScoreEntity) => }, }); -export const ENTITY_RISK = (riskEntity: RiskScoreEntity) => - i18n.translate('xpack.securitySolution.entityAnalytics.riskDashboard.riskClassificationTitle', { - defaultMessage: '{riskEntity} risk classification', - values: { - riskEntity: getRiskEntityTranslation(riskEntity), - }, - }); - export const ENTITY_NAME = (riskEntity: RiskScoreEntity) => i18n.translate('xpack.securitySolution.entityAnalytics.riskDashboard.nameTitle', { defaultMessage: '{riskEntity} Name', diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.test.tsx index 86ef72ebb6e60..b793dfa370760 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.test.tsx @@ -14,6 +14,9 @@ import { TimelineId, TimelineTabs } from '../../../../../../common/types'; import { timelineActions } from '../../../../store/timeline'; import { activeTimeline } from '../../../../containers/active_timeline_context'; import { StatefulEventContext } from '../../../../../common/components/events_viewer/stateful_event_context'; +import { createTelemetryServiceMock } from '../../../../../common/lib/telemetry/telemetry_service.mock'; + +const mockedTelemetry = createTelemetryServiceMock(); jest.mock('react-redux', () => { const origin = jest.requireActual('react-redux'); @@ -25,12 +28,13 @@ jest.mock('react-redux', () => { jest.mock('../../../../../common/lib/kibana/kibana_react', () => { return { - useKibana: jest.fn().mockReturnValue({ + useKibana: () => ({ services: { application: { getUrlForApp: jest.fn(), navigateToApp: jest.fn(), }, + telemetry: mockedTelemetry, }, }), }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.test.tsx index 831bddd55f964..16a721c2f14cd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_name.test.tsx @@ -14,6 +14,9 @@ import { timelineActions } from '../../../../store/timeline'; import { activeTimeline } from '../../../../containers/active_timeline_context'; import { UserName } from './user_name'; import { StatefulEventContext } from '../../../../../common/components/events_viewer/stateful_event_context'; +import { createTelemetryServiceMock } from '../../../../../common/lib/telemetry/telemetry_service.mock'; + +const mockedTelemetry = createTelemetryServiceMock(); jest.mock('react-redux', () => { const origin = jest.requireActual('react-redux'); @@ -25,12 +28,13 @@ jest.mock('react-redux', () => { jest.mock('../../../../../common/lib/kibana/kibana_react', () => { return { - useKibana: jest.fn().mockReturnValue({ + useKibana: () => ({ services: { application: { getUrlForApp: jest.fn(), navigateToApp: jest.fn(), }, + telemetry: mockedTelemetry, }, }), }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/schedule_notification_response_actions.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/schedule_notification_response_actions.test.ts index b848c26b9e1e6..ce0dad15edb1d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/schedule_notification_response_actions.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions/schedule_notification_response_actions.test.ts @@ -6,6 +6,7 @@ */ import { scheduleNotificationResponseActions } from './schedule_notification_response_actions'; +import type { RuleResponseAction } from '../../../../common/detection_engine/rule_response_actions/schemas'; import { RESPONSE_ACTION_TYPES } from '../../../../common/detection_engine/rule_response_actions/schemas'; describe('ScheduleNotificationResponseActions', () => { @@ -57,7 +58,7 @@ describe('ScheduleNotificationResponseActions', () => { it('should handle osquery response actions with query', async () => { const osqueryActionMock = jest.fn(); - const responseActions = [ + const responseActions: RuleResponseAction[] = [ { actionTypeId: RESPONSE_ACTION_TYPES.OSQUERY, params: { @@ -77,7 +78,7 @@ describe('ScheduleNotificationResponseActions', () => { it('should handle osquery response actions with packs', async () => { const osqueryActionMock = jest.fn(); - const responseActions = [ + const responseActions: RuleResponseAction[] = [ { actionTypeId: RESPONSE_ACTION_TYPES.OSQUERY, params: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/query.ts index 52157c2f64a94..32099bf836991 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/query.ts @@ -64,7 +64,6 @@ export const queryExecutor = async ({ const license = await firstValueFrom(licensing.license$); const hasPlatinumLicense = license.hasAtLeast('platinum'); - const hasGoldLicense = license.hasAtLeast('gold'); const result = ruleParams.alertSuppression?.groupBy != null && hasPlatinumLicense @@ -98,7 +97,7 @@ export const queryExecutor = async ({ state: {}, }; - if (hasGoldLicense) { + if (hasPlatinumLicense) { if (completeRule.ruleParams.responseActions?.length && result.createdSignalsCount) { scheduleNotificationResponseActions( { diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 104b7741683d2..c13377b026aab 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -31503,7 +31503,6 @@ "xpack.securitySolution.hostsRiskTable.tableTitle": "Risque de l'hôte", "xpack.securitySolution.hostsRiskTable.usersTableTooltip": "Le tableau des risques de l'utilisateur n'est pas affecté par la plage temporelle KQL. Ce tableau montre le dernier score de risque enregistré pour chaque utilisateur.", "xpack.securitySolution.hostsTable.firstLastSeenToolTip": "Par rapport à la plage de dates sélectionnée", - "xpack.securitySolution.hostsTable.hostRiskTitle": "Classification de risque de l'hôte", "xpack.securitySolution.hostsTable.hostRiskToolTip": "La classification des risques de l'hôte est déterminée par score de risque de l'hôte. Les hôtes classés comme étant Critique ou Élevé sont indiqués comme étant \"à risque\".", "xpack.securitySolution.hostsTable.hostsTitle": "Tous les hôtes", "xpack.securitySolution.hostsTable.lastSeenTitle": "Vu en dernier", @@ -32598,7 +32597,6 @@ "xpack.securitySolution.users.pageTitle": "Utilisateurs", "xpack.securitySolution.usersKpiAuthentications.errorSearchDescription": "Une erreur s'est produite lors de la recherche d'authentifications du KPI des utilisateurs", "xpack.securitySolution.usersKpiAuthentications.failSearchDescription": "Impossible de lancer une recherche sur les authentifications du KPI des utilisateurs", - "xpack.securitySolution.usersRiskTable.riskTitle": "Classification de risque de l'utilisateur", "xpack.securitySolution.usersRiskTable.userNameTitle": "Nom d'utilisateur", "xpack.securitySolution.usersRiskTable.userRiskScoreTitle": "Score de risque de l'utilisateur", "xpack.securitySolution.usersRiskTable.userRiskToolTip": "La classification des risques de l'utilisateur est déterminée par score de risque de l'utilisateur. Les utilisateurs classés comme étant Critique ou Élevé sont indiqués comme étant \"à risque\".", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 3dc5985e3525c..b7a7a626158fe 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -31482,7 +31482,6 @@ "xpack.securitySolution.hostsRiskTable.tableTitle": "ホストリスク", "xpack.securitySolution.hostsRiskTable.usersTableTooltip": "ユーザーリスク表はKQL時間範囲の影響を受けません。この表は、各ユーザーの最後に記録されたリスクスコアを示します。", "xpack.securitySolution.hostsTable.firstLastSeenToolTip": "選択された日付範囲との相関付けです", - "xpack.securitySolution.hostsTable.hostRiskTitle": "ホストリスク分類", "xpack.securitySolution.hostsTable.hostRiskToolTip": "ホストリスク分類はホストリスクスコアで決まります。「重大」または「高」に分類されたホストはリスクが高いことが示されます。", "xpack.securitySolution.hostsTable.hostsTitle": "すべてのホスト", "xpack.securitySolution.hostsTable.lastSeenTitle": "前回の認識", @@ -32577,7 +32576,6 @@ "xpack.securitySolution.users.pageTitle": "ユーザー", "xpack.securitySolution.usersKpiAuthentications.errorSearchDescription": "ユーザーKPI認証検索でエラーが発生しました", "xpack.securitySolution.usersKpiAuthentications.failSearchDescription": "ユーザーKPI認証で検索を実行できませんでした", - "xpack.securitySolution.usersRiskTable.riskTitle": "ユーザーリスク分類", "xpack.securitySolution.usersRiskTable.userNameTitle": "ユーザー名", "xpack.securitySolution.usersRiskTable.userRiskScoreTitle": "ユーザーリスクスコア", "xpack.securitySolution.usersRiskTable.userRiskToolTip": "ユーザーリスク分類はユーザーリスクスコアで決まります。「重大」または「高」に分類されたユーザーはリスクが高いことが示されます。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7334cf34e0e4b..bc56d71d3e1f7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -31497,7 +31497,6 @@ "xpack.securitySolution.hostsRiskTable.tableTitle": "主机风险", "xpack.securitySolution.hostsRiskTable.usersTableTooltip": "用户风险表不受 KQL 时间范围影响。本表显示每个用户最新记录的风险分数。", "xpack.securitySolution.hostsTable.firstLastSeenToolTip": "相对于选定日期范围", - "xpack.securitySolution.hostsTable.hostRiskTitle": "主机风险分类", "xpack.securitySolution.hostsTable.hostRiskToolTip": "主机风险分类由主机风险分数决定。分类为紧急或高的主机即表示存在风险。", "xpack.securitySolution.hostsTable.hostsTitle": "所有主机", "xpack.securitySolution.hostsTable.lastSeenTitle": "最后看到时间", @@ -32592,7 +32591,6 @@ "xpack.securitySolution.users.pageTitle": "用户", "xpack.securitySolution.usersKpiAuthentications.errorSearchDescription": "搜索用户 KPI 身份验证时发生错误", "xpack.securitySolution.usersKpiAuthentications.failSearchDescription": "无法对用户 KPI 身份验证执行搜索", - "xpack.securitySolution.usersRiskTable.riskTitle": "用户风险分类", "xpack.securitySolution.usersRiskTable.userNameTitle": "用户名", "xpack.securitySolution.usersRiskTable.userRiskScoreTitle": "用户风险分数", "xpack.securitySolution.usersRiskTable.userRiskToolTip": "用户风险分类由用户风险分数决定。分类为紧急或高的用户即表示存在风险。", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/mock/alert_summary_widget/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/mock/alert_summary_widget/index.ts index c6f31f36d2f2e..b155e08a77f99 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/mock/alert_summary_widget/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/mock/alert_summary_widget/index.ts @@ -7,7 +7,7 @@ import { LIGHT_THEME } from '@elastic/charts'; import { EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme'; -import { AlertSummaryTimeRange, ChartThemes } from '../../sections/alert_summary_widget/types'; +import { AlertSummaryTimeRange, ChartProps } from '../../sections/alert_summary_widget/types'; export const mockedAlertSummaryResponse = { activeAlertCount: 2, @@ -39,7 +39,7 @@ export const mockedAlertSummaryTimeRange: AlertSummaryTimeRange = { title: 'mockedTitle', }; -export const mockedChartThemes: ChartThemes = { +export const mockedChartProps: ChartProps = { theme: EUI_CHARTS_THEME_LIGHT.theme, baseTheme: LIGHT_THEME, }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/alert_summary_widget.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/alert_summary_widget.test.tsx index cc60a87d87daa..17eb584a982c1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/alert_summary_widget.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/alert_summary_widget.test.tsx @@ -10,7 +10,7 @@ import { render } from '@testing-library/react'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { AlertSummaryWidget } from './alert_summary_widget'; import { AlertSummaryWidgetProps } from './types'; -import { mockedAlertSummaryTimeRange, mockedChartThemes } from '../../mock/alert_summary_widget'; +import { mockedAlertSummaryTimeRange, mockedChartProps } from '../../mock/alert_summary_widget'; import { useLoadAlertSummary } from '../../hooks/use_load_alert_summary'; jest.mock('@kbn/kibana-react-plugin/public/ui_settings/use_ui_setting', () => ({ @@ -43,7 +43,7 @@ describe('AlertSummaryWidget', () => { render( {}, timeRange, - chartThemes, }: AlertSummaryWidgetProps) => { const { alertSummary: { activeAlertCount, activeAlerts, recoveredAlertCount }, @@ -43,19 +43,19 @@ export const AlertSummaryWidget = ({ ) : null ) : ( ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_compact.stories.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_compact.stories.tsx index b5a95447a03aa..9d8eb92fad8f4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_compact.stories.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_compact.stories.tsx @@ -7,7 +7,7 @@ import { action } from '@storybook/addon-actions'; import { AlertSummaryWidgetCompact as Component } from './alert_summary_widget_compact'; -import { mockedAlertSummaryResponse, mockedChartThemes } from '../../../mock/alert_summary_widget'; +import { mockedAlertSummaryResponse, mockedChartProps } from '../../../mock/alert_summary_widget'; export default { component: Component, @@ -17,7 +17,7 @@ export default { export const Compact = { args: { ...mockedAlertSummaryResponse, - chartThemes: mockedChartThemes, + chartProps: mockedChartProps, timeRangeTitle: 'Last 30 days', onClick: action('clicked'), }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_compact.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_compact.test.tsx index 1a0c41e331a42..2dee4c3dcb705 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_compact.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_compact.test.tsx @@ -12,14 +12,14 @@ import { AlertSummaryWidgetCompactProps, } from './alert_summary_widget_compact'; import { render } from '@testing-library/react'; -import { mockedAlertSummaryResponse, mockedChartThemes } from '../../../mock/alert_summary_widget'; +import { mockedAlertSummaryResponse, mockedChartProps } from '../../../mock/alert_summary_widget'; describe('AlertSummaryWidgetCompact', () => { const renderComponent = (props: Partial = {}) => render( void; @@ -26,7 +26,7 @@ export interface AlertSummaryWidgetCompactProps { export const AlertSummaryWidgetCompact = ({ activeAlertCount, activeAlerts, - chartThemes: { theme, baseTheme }, + chartProps: { theme, baseTheme }, recoveredAlertCount, timeRangeTitle, onClick, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.stories.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.stories.tsx index dfdc2d4a26361..66c38a336f6f4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.stories.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.stories.tsx @@ -5,8 +5,9 @@ * 2.0. */ +import { action } from '@storybook/addon-actions'; import { AlertSummaryWidgetFullSize as Component } from './alert_summary_widget_full_size'; -import { mockedAlertSummaryResponse, mockedChartThemes } from '../../../mock/alert_summary_widget'; +import { mockedAlertSummaryResponse, mockedChartProps } from '../../../mock/alert_summary_widget'; export default { component: Component, @@ -16,6 +17,9 @@ export default { export const FullSize = { args: { ...mockedAlertSummaryResponse, - chartThemes: mockedChartThemes, + chartProps: { + ...mockedChartProps, + onBrushEnd: action('brushEvent'), + }, }, }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.test.tsx index 544cd9c9e1603..3dca2312274e3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.test.tsx @@ -12,14 +12,14 @@ import { AlertSummaryWidgetFullSizeProps, } from './alert_summary_widget_full_size'; import { render } from '@testing-library/react'; -import { mockedAlertSummaryResponse, mockedChartThemes } from '../../../mock/alert_summary_widget'; +import { mockedAlertSummaryResponse, mockedChartProps } from '../../../mock/alert_summary_widget'; describe('AlertSummaryWidgetFullSize', () => { const renderComponent = (props: Partial = {}) => render( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.tsx index 7ae36d4b8a098..c9d43e5c008c3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_summary_widget/components/alert_summary_widget_full_size.tsx @@ -11,12 +11,12 @@ import { Axis, Chart, CurveType, LineSeries, Position, ScaleType, Settings } fro import { EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui'; import { AlertCounts } from './alert_counts'; import { ALL_ALERT_COLOR, TOOLTIP_DATE_FORMAT } from './constants'; -import { Alert, ChartThemes } from '../types'; +import { Alert, ChartProps } from '../types'; export interface AlertSummaryWidgetFullSizeProps { activeAlertCount: number; activeAlerts: Alert[]; - chartThemes: ChartThemes; + chartProps: ChartProps; recoveredAlertCount: number; dateFormat?: string; } @@ -24,7 +24,7 @@ export interface AlertSummaryWidgetFullSizeProps { export const AlertSummaryWidgetFullSize = ({ activeAlertCount, activeAlerts, - chartThemes: { theme, baseTheme }, + chartProps: { theme, baseTheme, onBrushEnd }, dateFormat, recoveredAlertCount, }: AlertSummaryWidgetFullSizeProps) => { @@ -60,6 +60,7 @@ export const AlertSummaryWidgetFullSize = ({ headerFormatter: (tooltip) => moment(tooltip.value).format(dateFormat || TOOLTIP_DATE_FORMAT), }} + onBrushEnd={onBrushEnd} /> void; timeRange: AlertSummaryTimeRange; - chartThemes: ChartThemes; + chartProps: ChartProps; } diff --git a/x-pack/test/defend_workflows_cypress/config.ts b/x-pack/test/defend_workflows_cypress/config.ts index a86916951c1fd..6b68a0de87ee2 100644 --- a/x-pack/test/defend_workflows_cypress/config.ts +++ b/x-pack/test/defend_workflows_cypress/config.ts @@ -51,7 +51,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { // always install Endpoint package by default when Fleet sets up `--xpack.fleet.packages.0.name=endpoint`, `--xpack.fleet.packages.0.version=latest`, - `--xpack.securitySolution.enableExperimental=${JSON.stringify(['endpointRbacEnabled'])}`, + `--xpack.securitySolution.enableExperimental=${JSON.stringify([ + 'endpointRbacEnabled', + 'endpointResponseActionsEnabled', + ])}`, ], }, }; diff --git a/x-pack/test/monitoring_api_integration/fixtures/elasticsearch/overview.json b/x-pack/test/monitoring_api_integration/fixtures/elasticsearch/overview.json index 7eeb94537ad7a..518755f5a830c 100644 --- a/x-pack/test/monitoring_api_integration/fixtures/elasticsearch/overview.json +++ b/x-pack/test/monitoring_api_integration/fixtures/elasticsearch/overview.json @@ -1 +1 @@ -{"clusterStatus":{"status":"yellow","indicesCount":47,"documentCount":39688,"dataSize":33007934,"nodesCount":1,"upTime":779257,"version":["8.7.0"],"memUsed":586378752,"memMax":1073741824,"unassignedShards":28,"totalShards":75},"metrics":{"cluster_search_request_rate":[{"bucket_size":"10 seconds","timeRange":{"min":1674575130000,"max":1674575490000},"metric":{"app":"elasticsearch","field":"indices_stats._all.total.search.query_total","metricAgg":"max","label":"Total Shards","title":"Search Rate","description":"Number of search requests being executed across primary and replica shards. A single search can run against multiple shards!","units":"/s","format":"0,0.[00]","hasCalculation":false,"isDerivative":true},"data":[[1674575130000,4.5],[1674575140000,4.5],[1674575150000,4.5],[1674575160000,4.5],[1674575170000,4.5],[1674575180000,4.5],[1674575190000,0.3],[1674575200000,4.2],[1674575210000,4.5],[1674575220000,4.5],[1674575230000,4.5],[1674575240000,4.5],[1674575250000,4.5],[1674575260000,4.5],[1674575270000,4.5],[1674575280000,4.5],[1674575290000,4.5],[1674575300000,4.5],[1674575310000,4.5],[1674575320000,4.5],[1674575330000,4.5],[1674575340000,4.5],[1674575350000,4.5],[1674575360000,4.5],[1674575370000,4.5],[1674575380000,4.5],[1674575390000,4.5],[1674575400000,4.5],[1674575410000,4.5],[1674575420000,4.5],[1674575430000,4.5],[1674575440000,4.5],[1674575450000,4.5],[1674575460000,4.5],[1674575470000,4.5],[1674575480000,4.5]]}],"cluster_query_latency":[{"bucket_size":"10 seconds","timeRange":{"min":1674575130000,"max":1674575490000},"metric":{"app":"elasticsearch","field":"indices_stats._all.total.search.query_total","metricAgg":"sum","label":"Search Latency","description":"Average latency for searching, which is time it takes to execute searches divided by number of searches submitted. This considers primary and replica shards.","units":"ms","format":"0,0.[00]","hasCalculation":true,"isDerivative":false},"data":[[1674575130000,null],[1674575140000,0.7555555555555555],[1674575150000,0.6000000000000001],[1674575160000,0.5555555555555556],[1674575170000,2.511111111111111],[1674575180000,2.3777777777777778],[1674575190000,0.33333333333333337],[1674575200000,0.5952380952380952],[1674575210000,0.4666666666666667],[1674575220000,0.5333333333333333],[1674575230000,0.5333333333333333],[1674575240000,0.48888888888888893],[1674575250000,0.4444444444444444],[1674575260000,0.4666666666666667],[1674575270000,1.6444444444444446],[1674575280000,0.4666666666666667],[1674575290000,0.5555555555555556],[1674575300000,0.4222222222222222],[1674575310000,0.4444444444444444],[1674575320000,0.4222222222222222],[1674575330000,0.35555555555555557],[1674575340000,0.5333333333333333],[1674575350000,1.1777777777777778],[1674575360000,0.5555555555555556],[1674575370000,0.35555555555555557],[1674575380000,0.7111111111111111],[1674575390000,0.6222222222222222],[1674575400000,0.35555555555555557],[1674575410000,0.37777777777777777],[1674575420000,0.3333333333333333],[1674575430000,0.4222222222222222],[1674575440000,0.4222222222222222],[1674575450000,0.35555555555555557],[1674575460000,0.4444444444444444],[1674575470000,0.5777777777777778],[1674575480000,0.48888888888888893]]}],"cluster_index_request_rate":[{"bucket_size":"10 seconds","timeRange":{"min":1674575130000,"max":1674575490000},"metric":{"app":"elasticsearch","field":"indices_stats._all.total.indexing.index_total","metricAgg":"max","label":"Total Shards","title":"Indexing Rate","description":"Number of documents being indexed for primary and replica shards.","units":"/s","format":"0,0.[00]","hasCalculation":false,"isDerivative":true},"data":[[1674575130000,19],[1674575140000,11.7],[1674575150000,12.5],[1674575160000,11.8],[1674575170000,11.7],[1674575180000,12.7],[1674575190000,11.6],[1674575200000,11.8],[1674575210000,12.4],[1674575220000,11.8],[1674575230000,11.8],[1674575240000,12.7],[1674575250000,11.6],[1674575260000,11.8],[1674575270000,12.5],[1674575280000,11.6],[1674575290000,11.7],[1674575300000,12.7],[1674575310000,11.7],[1674575320000,11.8],[1674575330000,12.4],[1674575340000,19.1],[1674575350000,11.7],[1674575360000,12.7],[1674575370000,11.6],[1674575380000,11.8],[1674575390000,12.5],[1674575400000,11.7],[1674575410000,11.7],[1674575420000,12.8],[1674575430000,11.6],[1674575440000,11.8],[1674575450000,12.4],[1674575460000,11.6],[1674575470000,11.8],[1674575480000,12.7]]},{"bucket_size":"10 seconds","timeRange":{"min":1674575130000,"max":1674575490000},"metric":{"app":"elasticsearch","field":"indices_stats._all.primaries.indexing.index_total","metricAgg":"max","label":"Primary Shards","title":"Indexing Rate","description":"Number of documents being indexed for primary shards.","units":"/s","format":"0,0.[00]","hasCalculation":false,"isDerivative":true},"data":[[1674575130000,19],[1674575140000,11.7],[1674575150000,12.5],[1674575160000,11.8],[1674575170000,11.7],[1674575180000,12.7],[1674575190000,11.6],[1674575200000,11.8],[1674575210000,12.4],[1674575220000,11.8],[1674575230000,11.8],[1674575240000,12.7],[1674575250000,11.6],[1674575260000,11.8],[1674575270000,12.5],[1674575280000,11.6],[1674575290000,11.7],[1674575300000,12.7],[1674575310000,11.7],[1674575320000,11.8],[1674575330000,12.4],[1674575340000,19.1],[1674575350000,11.7],[1674575360000,12.7],[1674575370000,11.6],[1674575380000,11.8],[1674575390000,12.5],[1674575400000,11.7],[1674575410000,11.7],[1674575420000,12.8],[1674575430000,11.6],[1674575440000,11.8],[1674575450000,12.4],[1674575460000,11.6],[1674575470000,11.8],[1674575480000,12.7]]}],"cluster_index_latency":[{"bucket_size":"10 seconds","timeRange":{"min":1674575130000,"max":1674575490000},"metric":{"app":"elasticsearch","field":"indices_stats._all.primaries.indexing.index_total","metricAgg":"sum","label":"Indexing Latency","description":"Average latency for indexing documents, which is time it takes to index documents divided by number that were indexed. This only considers primary shards.","units":"ms","format":"0,0.[00]","hasCalculation":true,"isDerivative":false},"data":[[1674575130000,null],[1674575140000,0.11965811965811966],[1674575150000,0.17600000000000002],[1674575160000,0.14406779661016947],[1674575170000,0.12820512820512822],[1674575180000,0.14173228346456693],[1674575190000,0.13793103448275862],[1674575200000,0.14406779661016947],[1674575210000,0.16129032258064516],[1674575220000,0.16101694915254236],[1674575230000,0.11016949152542373],[1674575240000,0.12598425196850396],[1674575250000,0.15517241379310345],[1674575260000,0.11016949152542373],[1674575270000,0.552],[1674575280000,0.14655172413793102],[1674575290000,0.11111111111111112],[1674575300000,0.13385826771653545],[1674575310000,0.1452991452991453],[1674575320000,0.2966101694915254],[1674575330000,0.14516129032258066],[1674575340000,0.09947643979057591],[1674575350000,0.1452991452991453],[1674575360000,0.14173228346456693],[1674575370000,0.18965517241379312],[1674575380000,0.13559322033898305],[1674575390000,0.136],[1674575400000,0.13675213675213677],[1674575410000,0.15384615384615385],[1674575420000,0.140625],[1674575430000,0.11206896551724138],[1674575440000,0.16101694915254236],[1674575450000,0.1854838709677419],[1674575460000,0.09482758620689656],[1674575470000,0.11864406779661016],[1674575480000,0.1811023622047244]]}]},"logs":{"enabled":false,"logs":[],"reason":{"indexPatternExists":false,"indexPatternInTimeRangeExists":false,"typeExistsAtAnyTime":false,"typeExists":false,"usingStructuredLogs":false,"clusterExists":false,"nodeExists":null,"indexExists":null},"limit":10},"shardActivity":[{"start_time":{"ms":1674574784767},"stop_time":{"ms":1674574784836},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-elastic_agent.fleet_server-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574786227},"stop_time":{"ms":1674574786257},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-system.diskio-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574786142},"stop_time":{"ms":1674574786168},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-elastic_agent.metricbeat-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674575114717},"stop_time":{"ms":1674575114748},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-elasticsearch.stack_monitoring.shard-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574786426},"stop_time":{"ms":1674574786453},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-system.process.summary-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574781779},"stop_time":{"ms":1674574781836},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-logs-elastic_agent.fleet_server-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574786452},"stop_time":{"ms":1674574786479},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-system.network-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574786240},"stop_time":{"ms":1674574786270},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-system.load-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574786624},"stop_time":{"ms":1674574786648},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-system.uptime-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574786443},"stop_time":{"ms":1674574786472},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-system.process-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574781827},"stop_time":{"ms":1674574781928},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-logs-elastic_agent.metricbeat-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574781733},"stop_time":{"ms":1674574781763},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-logs-elastic_agent-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574781818},"stop_time":{"ms":1674574781917},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-logs-elastic_agent.filebeat-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574784997},"stop_time":{"ms":1674574785017},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-elastic_agent.elastic_agent-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674575114496},"stop_time":{"ms":1674575114526},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-elasticsearch.stack_monitoring.enrich-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574787065},"stop_time":{"ms":1674574787118},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-elastic_agent.filebeat-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574786217},"stop_time":{"ms":1674574786251},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-system.socket_summary-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574899413},"stop_time":{"ms":1674574899436},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":"kibana_sample_data_flights","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674575114701},"stop_time":{"ms":1674575114734},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-elasticsearch.stack_monitoring.node_stats-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674575114513},"stop_time":{"ms":1674575114541},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-elasticsearch.stack_monitoring.node-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574786432},"stop_time":{"ms":1674574786461},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-system.fsstat-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674575114503},"stop_time":{"ms":1674575114535},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-elasticsearch.stack_monitoring.index-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574901448},"stop_time":{"ms":1674574901471},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-kibana_sample_data_logs-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674575114709},"stop_time":{"ms":1674575114738},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-elasticsearch.stack_monitoring.cluster_stats-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574786644},"stop_time":{"ms":1674574786673},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-system.memory-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574786633},"stop_time":{"ms":1674574786661},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-system.cpu-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574786656},"stop_time":{"ms":1674574786690},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-system.filesystem-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674575114694},"stop_time":{"ms":1674575114719},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-elasticsearch.stack_monitoring.index_summary-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674575114488},"stop_time":{"ms":1674575114517},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":".ds-metrics-elasticsearch.stack_monitoring.index_recovery-default-2023.01.24-000001","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}},{"start_time":{"ms":1674574823950},"stop_time":{"ms":1674574823970},"stage":"DONE","translog":{"total_on_start":0,"total":0,"percent":"100.0%"},"name":"kibana_sample_data_ecommerce","index":{"size":{"total_in_bytes":0,"reused_in_bytes":0,"recovered_in_bytes":0},"files":{"recovered":0,"total":0,"percent":"0.0%","reused":0}},"id":0,"source":{},"type":"EMPTY_STORE","primary":true,"target":{"transport_address":"127.0.0.1:9300","host":"127.0.0.1","name":"712a47f7f7df","id":"1ksGajLvSW-OwnAYH-X3Sg"}}]} +{"clusterStatus":{"status":"yellow","indicesCount":47,"documentCount":39688,"dataSize":33007934,"nodesCount":1,"upTime":779257,"version":["8.7.0"],"memUsed":586378752,"memMax":1073741824,"unassignedShards":28,"totalShards":75},"metrics":{"cluster_search_request_rate":[{"bucket_size":"10 seconds","timeRange":{"min":1674575130000,"max":1674575490000},"metric":{"app":"elasticsearch","field":"indices_stats._all.total.search.query_total","metricAgg":"max","label":"Total Shards","title":"Search Rate","description":"Number of search requests being executed across primary and replica shards. A single search can run against multiple shards!","units":"/s","format":"0,0.[00]","hasCalculation":false,"isDerivative":true},"data":[[1674575130000,4.5],[1674575140000,4.5],[1674575150000,4.5],[1674575160000,4.5],[1674575170000,4.5],[1674575180000,4.5],[1674575190000,0.3],[1674575200000,4.2],[1674575210000,4.5],[1674575220000,4.5],[1674575230000,4.5],[1674575240000,4.5],[1674575250000,4.5],[1674575260000,4.5],[1674575270000,4.5],[1674575280000,4.5],[1674575290000,4.5],[1674575300000,4.5],[1674575310000,4.5],[1674575320000,4.5],[1674575330000,4.5],[1674575340000,4.5],[1674575350000,4.5],[1674575360000,4.5],[1674575370000,4.5],[1674575380000,4.5],[1674575390000,4.5],[1674575400000,4.5],[1674575410000,4.5],[1674575420000,4.5],[1674575430000,4.5],[1674575440000,4.5],[1674575450000,4.5],[1674575460000,4.5],[1674575470000,4.5],[1674575480000,4.5]]}],"cluster_query_latency":[{"bucket_size":"10 seconds","timeRange":{"min":1674575130000,"max":1674575490000},"metric":{"app":"elasticsearch","field":"indices_stats._all.total.search.query_total","metricAgg":"sum","label":"Search Latency","description":"Average latency for searching, which is time it takes to execute searches divided by number of searches submitted. This considers primary and replica shards.","units":"ms","format":"0,0.[00]","hasCalculation":true,"isDerivative":false},"data":[[1674575130000,null],[1674575140000,0.7555555555555555],[1674575150000,0.6000000000000001],[1674575160000,0.5555555555555556],[1674575170000,2.511111111111111],[1674575180000,2.3777777777777778],[1674575190000,0.33333333333333337],[1674575200000,0.5952380952380952],[1674575210000,0.4666666666666667],[1674575220000,0.5333333333333333],[1674575230000,0.5333333333333333],[1674575240000,0.48888888888888893],[1674575250000,0.4444444444444444],[1674575260000,0.4666666666666667],[1674575270000,1.6444444444444446],[1674575280000,0.4666666666666667],[1674575290000,0.5555555555555556],[1674575300000,0.4222222222222222],[1674575310000,0.4444444444444444],[1674575320000,0.4222222222222222],[1674575330000,0.35555555555555557],[1674575340000,0.5333333333333333],[1674575350000,1.1777777777777778],[1674575360000,0.5555555555555556],[1674575370000,0.35555555555555557],[1674575380000,0.7111111111111111],[1674575390000,0.6222222222222222],[1674575400000,0.35555555555555557],[1674575410000,0.37777777777777777],[1674575420000,0.3333333333333333],[1674575430000,0.4222222222222222],[1674575440000,0.4222222222222222],[1674575450000,0.35555555555555557],[1674575460000,0.4444444444444444],[1674575470000,0.5777777777777778],[1674575480000,0.48888888888888893]]}],"cluster_index_request_rate":[{"bucket_size":"10 seconds","timeRange":{"min":1674575130000,"max":1674575490000},"metric":{"app":"elasticsearch","field":"indices_stats._all.total.indexing.index_total","metricAgg":"max","label":"Total Shards","title":"Indexing Rate","description":"Number of documents being indexed for primary and replica shards.","units":"/s","format":"0,0.[00]","hasCalculation":false,"isDerivative":true},"data":[[1674575130000,19],[1674575140000,11.7],[1674575150000,12.5],[1674575160000,11.8],[1674575170000,11.7],[1674575180000,12.7],[1674575190000,11.6],[1674575200000,11.8],[1674575210000,12.4],[1674575220000,11.8],[1674575230000,11.8],[1674575240000,12.7],[1674575250000,11.6],[1674575260000,11.8],[1674575270000,12.5],[1674575280000,11.6],[1674575290000,11.7],[1674575300000,12.7],[1674575310000,11.7],[1674575320000,11.8],[1674575330000,12.4],[1674575340000,19.1],[1674575350000,11.7],[1674575360000,12.7],[1674575370000,11.6],[1674575380000,11.8],[1674575390000,12.5],[1674575400000,11.7],[1674575410000,11.7],[1674575420000,12.8],[1674575430000,11.6],[1674575440000,11.8],[1674575450000,12.4],[1674575460000,11.6],[1674575470000,11.8],[1674575480000,12.7]]},{"bucket_size":"10 seconds","timeRange":{"min":1674575130000,"max":1674575490000},"metric":{"app":"elasticsearch","field":"indices_stats._all.primaries.indexing.index_total","metricAgg":"max","label":"Primary Shards","title":"Indexing Rate","description":"Number of documents being indexed for primary shards.","units":"/s","format":"0,0.[00]","hasCalculation":false,"isDerivative":true},"data":[[1674575130000,19],[1674575140000,11.7],[1674575150000,12.5],[1674575160000,11.8],[1674575170000,11.7],[1674575180000,12.7],[1674575190000,11.6],[1674575200000,11.8],[1674575210000,12.4],[1674575220000,11.8],[1674575230000,11.8],[1674575240000,12.7],[1674575250000,11.6],[1674575260000,11.8],[1674575270000,12.5],[1674575280000,11.6],[1674575290000,11.7],[1674575300000,12.7],[1674575310000,11.7],[1674575320000,11.8],[1674575330000,12.4],[1674575340000,19.1],[1674575350000,11.7],[1674575360000,12.7],[1674575370000,11.6],[1674575380000,11.8],[1674575390000,12.5],[1674575400000,11.7],[1674575410000,11.7],[1674575420000,12.8],[1674575430000,11.6],[1674575440000,11.8],[1674575450000,12.4],[1674575460000,11.6],[1674575470000,11.8],[1674575480000,12.7]]}],"cluster_index_latency":[{"bucket_size":"10 seconds","timeRange":{"min":1674575130000,"max":1674575490000},"metric":{"app":"elasticsearch","field":"indices_stats._all.primaries.indexing.index_total","metricAgg":"sum","label":"Indexing Latency","description":"Average latency for indexing documents, which is time it takes to index documents divided by number that were indexed. This only considers primary shards.","units":"ms","format":"0,0.[00]","hasCalculation":true,"isDerivative":false},"data":[[1674575130000,null],[1674575140000,0.11965811965811966],[1674575150000,0.17600000000000002],[1674575160000,0.14406779661016947],[1674575170000,0.12820512820512822],[1674575180000,0.14173228346456693],[1674575190000,0.13793103448275862],[1674575200000,0.14406779661016947],[1674575210000,0.16129032258064516],[1674575220000,0.16101694915254236],[1674575230000,0.11016949152542373],[1674575240000,0.12598425196850396],[1674575250000,0.15517241379310345],[1674575260000,0.11016949152542373],[1674575270000,0.552],[1674575280000,0.14655172413793102],[1674575290000,0.11111111111111112],[1674575300000,0.13385826771653545],[1674575310000,0.1452991452991453],[1674575320000,0.2966101694915254],[1674575330000,0.14516129032258066],[1674575340000,0.09947643979057591],[1674575350000,0.1452991452991453],[1674575360000,0.14173228346456693],[1674575370000,0.18965517241379312],[1674575380000,0.13559322033898305],[1674575390000,0.136],[1674575400000,0.13675213675213677],[1674575410000,0.15384615384615385],[1674575420000,0.140625],[1674575430000,0.11206896551724138],[1674575440000,0.16101694915254236],[1674575450000,0.1854838709677419],[1674575460000,0.09482758620689656],[1674575470000,0.11864406779661016],[1674575480000,0.1811023622047244]]}]},"logs":{"enabled":false,"logs":[],"reason":{"indexPatternExists":false,"indexPatternInTimeRangeExists":false,"typeExistsAtAnyTime":false,"typeExists":false,"usingStructuredLogs":false,"clusterExists":false,"nodeExists":null,"indexExists":null},"limit":10},"shardActivity":[]} \ No newline at end of file