diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 2c7910166b196..0402ed1a09923 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -21,7 +21,6 @@ export const allowedExperimentalValues = Object.freeze({ kubernetesEnabled: true, chartEmbeddablesEnabled: true, donutChartEmbeddablesEnabled: false, // Depends on https://github.com/elastic/kibana/issues/136409 item 2 - 6 - alertsPreviewChartEmbeddablesEnabled: false, // Depends on https://github.com/elastic/kibana/issues/136409 item 9 /** * This is used for enabling the end-to-end tests for the security_solution telemetry. * We disable the telemetry since we don't have specific roles or permissions around it and diff --git a/x-pack/plugins/security_solution/public/common/components/inspect/use_inspect.tsx b/x-pack/plugins/security_solution/public/common/components/inspect/use_inspect.tsx index e1d60be58acc4..23a81288908c1 100644 --- a/x-pack/plugins/security_solution/public/common/components/inspect/use_inspect.tsx +++ b/x-pack/plugins/security_solution/public/common/components/inspect/use_inspect.tsx @@ -35,11 +35,12 @@ export const useInspect = ({ const getGlobalQuery = inputsSelectors.globalQueryByIdSelector(); const getTimelineQuery = inputsSelectors.timelineQueryByIdSelector(); - const { loading, inspect, selectedInspectIndex, isInspected } = useDeepEqualSelector((state) => - inputId === InputsModelId.global - ? getGlobalQuery(state, queryId) - : getTimelineQuery(state, queryId) - ); + const { loading, inspect, selectedInspectIndex, isInspected, searchSessionId } = + useDeepEqualSelector((state) => + inputId === InputsModelId.global + ? getGlobalQuery(state, queryId) + : getTimelineQuery(state, queryId) + ); const handleClick = useCallback(() => { if (onClick) { @@ -51,9 +52,10 @@ export const useInspect = ({ inputId, isInspected: true, selectedInspectIndex: inspectIndex, + searchSessionId, }) ); - }, [onClick, dispatch, queryId, inputId, inspectIndex]); + }, [onClick, dispatch, queryId, inputId, inspectIndex, searchSessionId]); const handleCloseModal = useCallback(() => { if (onCloseInspect != null) { @@ -65,9 +67,10 @@ export const useInspect = ({ inputId, isInspected: false, selectedInspectIndex: inspectIndex, + searchSessionId, }) ); - }, [onCloseInspect, dispatch, queryId, inputId, inspectIndex]); + }, [onCloseInspect, dispatch, queryId, inputId, inspectIndex, searchSessionId]); let request: string | null = null; let additionalRequests: string[] | null = null; diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx index 0320daa2ec338..ac254714adbb7 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx @@ -45,9 +45,9 @@ jest.mock('./utils', () => ({ getCustomChartData: jest.fn().mockReturnValue(true), })); -const mockUseVisualizationResponse = jest.fn(() => [ - { aggregations: [{ buckets: [{ key: '1234' }] }], hits: { total: 999 } }, -]); +const mockUseVisualizationResponse = jest.fn(() => ({ + responses: [{ aggregations: [{ buckets: [{ key: '1234' }] }], hits: { total: 999 } }], +})); jest.mock('../visualization_actions/use_visualization_response', () => ({ useVisualizationResponse: () => mockUseVisualizationResponse(), })); @@ -345,9 +345,9 @@ describe('Matrix Histogram Component', () => { }); test('it should render 0 as subtitle when buckets are empty', () => { - mockUseVisualizationResponse.mockReturnValue([ - { aggregations: [{ buckets: [] }], hits: { total: 999 } }, - ]); + mockUseVisualizationResponse.mockReturnValue({ + responses: [{ aggregations: [{ buckets: [] }], hits: { total: 999 } }], + }); mockUseMatrix.mockReturnValue([ false, { diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx index 761a3e597cadd..58f1736e13792 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx @@ -82,10 +82,14 @@ const HistogramPanel = styled(Panel)<{ height?: number }>` const CHART_HEIGHT = 150; -const visualizationResponseHasData = (response: VisualizationResponse): boolean => - Object.values>(response.aggregations ?? {}).some( - ({ buckets }) => buckets.length > 0 - ); +const visualizationResponseHasData = (response: VisualizationResponse[]): boolean => { + if (response.length === 0) { + return false; + } + return Object.values>( + response[0].aggregations ?? {} + ).some(({ buckets }) => buckets.length > 0); +}; export const MatrixHistogramComponent: React.FC = ({ chartHeight, @@ -209,7 +213,7 @@ export const MatrixHistogramComponent: React.FC = () => (title != null && typeof title === 'function' ? title(selectedStackByOption) : title), [title, selectedStackByOption] ); - const visualizationResponse = useVisualizationResponse({ visualizationId }); + const { responses } = useVisualizationResponse({ visualizationId }); const subtitleWithCounts = useMemo(() => { if (isInitialLoading) { return null; @@ -217,10 +221,10 @@ export const MatrixHistogramComponent: React.FC = if (typeof subtitle === 'function') { if (isChartEmbeddablesEnabled) { - if (!visualizationResponse || !visualizationResponseHasData(visualizationResponse[0])) { + if (!responses || !visualizationResponseHasData(responses)) { return subtitle(0); } - const visualizationCount = visualizationResponse[0].hits.total; + const visualizationCount = responses[0].hits.total; return visualizationCount >= 0 ? subtitle(visualizationCount) : null; } else { return totalCount >= 0 ? subtitle(totalCount) : null; @@ -228,7 +232,7 @@ export const MatrixHistogramComponent: React.FC = } return subtitle; - }, [isChartEmbeddablesEnabled, isInitialLoading, subtitle, totalCount, visualizationResponse]); + }, [isChartEmbeddablesEnabled, isInitialLoading, responses, subtitle, totalCount]); const hideHistogram = useMemo( () => (totalCount <= 0 && hideHistogramIfEmpty ? true : false), diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/__mocks__/use_actions.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/__mocks__/use_actions.ts new file mode 100644 index 0000000000000..9f5f46fb67f68 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/__mocks__/use_actions.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export const VISUALIZATION_CONTEXT_MENU_TRIGGER = 'VISUALIZATION_CONTEXT_MENU_TRIGGER'; +export const DEFAULT_ACTIONS = [ + 'inspect', + 'addToNewCase', + 'addToExistingCase', + 'saveToLibrary', + 'openInLens', +]; +export const MOCK_ACTIONS = [ + { + id: 'inspect', + getDisplayName: () => 'Inspect', + getIconType: () => 'inspect', + type: 'actionButton', + order: 4, + isCompatible: () => true, + execute: jest.fn(), + }, + { + id: 'addToNewCase', + getDisplayName: () => 'Add to new case', + getIconType: () => 'casesApp', + type: 'actionButton', + order: 3, + isCompatible: () => true, + execute: jest.fn(), + }, + { + id: 'addToExistingCase', + getDisplayName: () => 'Add to existing case', + getIconType: () => 'casesApp', + type: 'actionButton', + order: 2, + isCompatible: () => true, + execute: jest.fn(), + }, + { + id: 'saveToLibrary', + getDisplayName: () => 'Added to library', + getIconType: () => 'save', + type: 'actionButton', + order: 1, + isCompatible: () => true, + execute: jest.fn(), + }, + { + id: 'openInLens', + getDisplayName: () => 'Open in Lens', + getIconType: () => 'visArea', + type: 'actionButton', + order: 0, + isCompatible: () => true, + execute: jest.fn(), + }, +]; +export const useActions = jest.fn().mockReturnValue(MOCK_ACTIONS); diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/actions.test.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/actions.test.tsx index 924b1158593a7..b3fd18989991c 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/actions.test.tsx @@ -5,66 +5,36 @@ * 2.0. */ import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; -import type { Action } from '@kbn/ui-actions-plugin/public'; +import { EuiContextMenu } from '@elastic/eui'; + +import { fireEvent, render, waitFor } from '@testing-library/react'; import { getDnsTopDomainsLensAttributes } from './lens_attributes/network/dns_top_domains'; import { VisualizationActions } from './actions'; -import { - createSecuritySolutionStorageMock, - kibanaObservable, - mockGlobalState, - SUB_PLUGINS_REDUCER, - TestProviders, -} from '../../mock'; -import type { State } from '../../store'; -import { createStore } from '../../store'; -import type { UpdateQueryParams } from '../../store/inputs/helpers'; -import { upsertQuery } from '../../store/inputs/helpers'; -import { cloneDeep } from 'lodash'; -import { useKibana } from '../../lib/kibana/kibana_react'; -import { CASES_FEATURE_ID } from '../../../../common/constants'; -import { mockCasesContract } from '@kbn/cases-plugin/public/mocks'; -import { allCasesCapabilities, allCasesPermissions } from '../../../cases_test_utils'; -import { InputsModelId } from '../../store/inputs/constants'; +import { TestProviders } from '../../mock'; + import type { VisualizationActionsProps } from './types'; import * as useLensAttributesModule from './use_lens_attributes'; import { SourcererScopeName } from '../../store/sourcerer/model'; -jest.mock('react-router-dom', () => { - const actual = jest.requireActual('react-router-dom'); +jest.mock('./use_actions'); + +jest.mock('../inspect/use_inspect', () => { return { - ...actual, - useLocation: jest.fn(() => { - return { pathname: 'network' }; - }), + useInspect: jest.fn().mockReturnValue({}), }; }); -jest.mock('../../lib/kibana/kibana_react'); -jest.mock('../../utils/route/use_route_spy', () => { + +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); return { - useRouteSpy: jest.fn(() => [{ pageName: 'network', detailName: '', tabName: 'dns' }]), + ...original, + EuiContextMenu: jest.fn().mockReturnValue(
), }; }); describe('VisualizationActions', () => { - const refetch = jest.fn(); - const state: State = mockGlobalState; - const { storage } = createSecuritySolutionStorageMock(); - const newQuery: UpdateQueryParams = { - inputId: InputsModelId.global, - id: 'networkDnsHistogramQuery', - inspect: { - dsl: ['mockDsl'], - response: ['mockResponse'], - }, - loading: false, - refetch, - state: state.inputs, - }; const spyUseLensAttributes = jest.spyOn(useLensAttributesModule, 'useLensAttributes'); - - let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); const props: VisualizationActionsProps = { getLensAttributes: getDnsTopDomainsLensAttributes, queryId: 'networkDnsHistogramQuery', @@ -76,64 +46,15 @@ describe('VisualizationActions', () => { extraOptions: { dnsIsPtrIncluded: true }, stackByField: 'dns.question.registered_domain', }; - const mockNavigateToPrefilledEditor = jest.fn(); - const mockGetCreateCaseFlyoutOpen = jest.fn(); - const mockGetAllCasesSelectorModalOpen = jest.fn(); + const mockContextMenu = EuiContextMenu as unknown as jest.Mock; beforeEach(() => { jest.clearAllMocks(); - const cases = mockCasesContract(); - cases.helpers.getUICapabilities.mockReturnValue(allCasesPermissions()); - - (useKibana as jest.Mock).mockReturnValue({ - services: { - lens: { - canUseEditor: jest.fn(() => true), - navigateToPrefilledEditor: mockNavigateToPrefilledEditor, - }, - cases: { - ...mockCasesContract(), - hooks: { - useCasesAddToExistingCaseModal: jest - .fn() - .mockReturnValue({ open: mockGetAllCasesSelectorModalOpen }), - useCasesAddToNewCaseFlyout: jest - .fn() - .mockReturnValue({ open: mockGetCreateCaseFlyoutOpen }), - }, - helpers: { canUseCases: jest.fn().mockReturnValue(allCasesPermissions()) }, - }, - application: { - capabilities: { [CASES_FEATURE_ID]: allCasesCapabilities() }, - getUrlForApp: jest.fn(), - navigateToApp: jest.fn(), - }, - notifications: { - toasts: { - addError: jest.fn(), - addSuccess: jest.fn(), - addWarning: jest.fn(), - remove: jest.fn(), - }, - }, - http: jest.fn(), - data: { - search: jest.fn(), - }, - storage: { - set: jest.fn(), - }, - theme: {}, - }, - }); - const myState = cloneDeep(state); - myState.inputs = upsertQuery(newQuery); - store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); }); test('Should generate attributes', () => { render( - + ); @@ -150,161 +71,38 @@ describe('VisualizationActions', () => { ); }); - test('Should render VisualizationActions button', () => { - const { container } = render( - - - - ); - expect( - container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`) - ).toBeInTheDocument(); - }); - - test('Should render Open in Lens button', () => { - const { container } = render( - - - - ); - fireEvent.click(container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`)!); - - expect(screen.getByText('Open in Lens')).toBeInTheDocument(); - expect(screen.getByText('Open in Lens')).not.toBeDisabled(); - }); - - test('Should call NavigateToPrefilledEditor when Open in Lens', () => { - const { container } = render( - - - - ); - fireEvent.click(container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`)!); - - fireEvent.click(screen.getByText('Open in Lens')); - expect(mockNavigateToPrefilledEditor.mock.calls[0][0].timeRange).toEqual(props.timerange); - expect(mockNavigateToPrefilledEditor.mock.calls[0][0].attributes.title).toEqual(''); - expect(mockNavigateToPrefilledEditor.mock.calls[0][0].attributes.references).toEqual([ - { - id: 'security-solution', - name: 'indexpattern-datasource-layer-b1c3efc6-c886-4fba-978f-3b6bb5e7948a', - type: 'index-pattern', - }, - ]); - expect(mockNavigateToPrefilledEditor.mock.calls[0][1].openInNewTab).toEqual(true); - }); - - test('Should render Inspect button', () => { - const { container } = render( - - - - ); - fireEvent.click(container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`)!); - - expect(screen.getByText('Inspect')).toBeInTheDocument(); - expect(screen.getByText('Inspect')).not.toBeDisabled(); - }); - - test('Should render Inspect Modal after clicking the inspect button', () => { - const { baseElement, container } = render( - - - - ); - fireEvent.click(container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`)!); - - expect(screen.getByText('Inspect')).toBeInTheDocument(); - fireEvent.click(screen.getByText('Inspect')); - expect( - baseElement.querySelector('[data-test-subj="modal-inspect-euiModal"]') - ).toBeInTheDocument(); - }); - - test('Should render Add to new case button', () => { - const { container } = render( - - - - ); - fireEvent.click(container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`)!); - - expect(screen.getByText('Add to new case')).toBeInTheDocument(); - expect(screen.getByText('Add to new case')).not.toBeDisabled(); - }); - - test('Should render Add to new case modal after clicking on Add to new case button', () => { - const { container } = render( - - - - ); - fireEvent.click(container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`)!); - fireEvent.click(screen.getByText('Add to new case')); - - expect(mockGetCreateCaseFlyoutOpen).toBeCalled(); - }); - - test('Should render Add to existing case button', () => { - const { container } = render( - + test('Should render VisualizationActions button', async () => { + const { queryByTestId } = render( + ); - fireEvent.click(container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`)!); - expect(screen.getByText('Add to existing case')).toBeInTheDocument(); - expect(screen.getByText('Add to existing case')).not.toBeDisabled(); + await waitFor(() => { + expect(queryByTestId(`stat-networkDnsHistogramQuery`)).toBeInTheDocument(); + }); }); - test('Should render Add to existing case modal after clicking on Add to existing case button', () => { - const { container } = render( - + test('renders context menu', async () => { + const { getByTestId } = render( + ); - fireEvent.click(container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`)!); - fireEvent.click(screen.getByText('Add to existing case')); - - expect(mockGetAllCasesSelectorModalOpen).toBeCalled(); - }); - test('Should not render default actions when withDefaultActions = false', () => { - const testProps = { ...props, withDefaultActions: false }; - render( - - - - ); + await waitFor(() => { + expect(getByTestId(`stat-networkDnsHistogramQuery`)).toBeInTheDocument(); + }); - expect( - screen.queryByTestId(`[data-test-subj="stat-networkDnsHistogramQuery"]`) - ).not.toBeInTheDocument(); - expect(screen.queryByText('Inspect')).not.toBeInTheDocument(); - expect(screen.queryByText('Add to new case')).not.toBeInTheDocument(); - expect(screen.queryByText('Add to existing case')).not.toBeInTheDocument(); - expect(screen.queryByText('Open in Lens')).not.toBeInTheDocument(); - }); + fireEvent.click(getByTestId(`stat-networkDnsHistogramQuery`)); - test('Should render extra actions when extraAction is provided', () => { - const testProps = { - ...props, - extraActions: [ - { - getIconType: () => 'reset', - id: 'resetField', - execute: jest.fn(), - getDisplayName: () => 'Reset Field', - } as unknown as Action, - ], - }; - const { container } = render( - - - + expect(getByTestId('viz-actions-menu')).toBeInTheDocument(); + expect(mockContextMenu.mock.calls[0][0].panels[0].items[0].name).toEqual('Inspect'); + expect(mockContextMenu.mock.calls[0][0].panels[0].items[1].name).toEqual('Add to new case'); + expect(mockContextMenu.mock.calls[0][0].panels[0].items[2].name).toEqual( + 'Add to existing case' ); - - fireEvent.click(container.querySelector(`[data-test-subj="stat-networkDnsHistogramQuery"]`)!); - expect(screen.getByText('Reset Field')).toBeInTheDocument(); + expect(mockContextMenu.mock.calls[0][0].panels[1].items[0].name).toEqual('Added to library'); + expect(mockContextMenu.mock.calls[0][0].panels[1].items[1].name).toEqual('Open in Lens'); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/actions.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/actions.tsx index 5527e0eca44d6..930e510ff07fa 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/actions.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/actions.tsx @@ -4,34 +4,24 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; -import type { Action, ActionExecutionContext } from '@kbn/ui-actions-plugin/public'; +import { EuiButtonIcon, EuiContextMenu, EuiPopover } from '@elastic/eui'; +import { buildContextMenuForActions } from '@kbn/ui-actions-plugin/public'; import React, { useCallback, useMemo, useState } from 'react'; import styled from 'styled-components'; -import type { LensEmbeddableInput } from '@kbn/lens-plugin/public'; +import { useAsync } from 'react-use'; import { InputsModelId } from '../../store/inputs/constants'; -import { useKibana } from '../../lib/kibana/kibana_react'; import { ModalInspectQuery } from '../inspect/modal'; import { useInspect } from '../inspect/use_inspect'; import { useLensAttributes } from './use_lens_attributes'; -import { useAddToExistingCase } from './use_add_to_existing_case'; -import { useAddToNewCase } from './use_add_to_new_case'; -import { useSaveToLibrary } from './use_save_to_library'; + import type { VisualizationActionsProps } from './types'; -import { - ADD_TO_EXISTING_CASE, - ADD_TO_NEW_CASE, - INSPECT, - MORE_ACTIONS, - OPEN_IN_LENS, - ADDED_TO_LIBRARY, -} from './translations'; +import { MORE_ACTIONS } from './translations'; import { VISUALIZATION_ACTIONS_BUTTON_CLASS } from './utils'; +import { DEFAULT_ACTIONS, useActions, VISUALIZATION_CONTEXT_MENU_TRIGGER } from './use_actions'; import { SourcererScopeName } from '../../store/sourcerer/model'; -import { useAppToasts } from '../../hooks/use_app_toasts'; const Wrapper = styled.div` &.viz-actions { @@ -62,23 +52,10 @@ const VisualizationActionsComponent: React.FC = ({ title: inspectTitle, scopeId = SourcererScopeName.default, stackByField, - withDefaultActions = true, + withActions = DEFAULT_ACTIONS, }) => { - const { lens } = useKibana().services; - - const { canUseEditor, navigateToPrefilledEditor, SaveModalComponent } = lens; const [isPopoverOpen, setPopover] = useState(false); const [isInspectModalOpen, setIsInspectModalOpen] = useState(false); - const [isSaveModalVisible, setIsSaveModalVisible] = useState(false); - const { addSuccess } = useAppToasts(); - const onSave = useCallback(() => { - setIsSaveModalVisible(false); - addSuccess(ADDED_TO_LIBRARY); - }, [addSuccess]); - const onClose = useCallback(() => { - setIsSaveModalVisible(false); - }, []); - const hasPermission = canUseEditor(); const onButtonClick = useCallback(() => { setPopover(!isPopoverOpen); @@ -100,40 +77,6 @@ const VisualizationActionsComponent: React.FC = ({ const dataTestSubj = `stat-${queryId}`; - const { disabled: isAddToExistingCaseDisabled, onAddToExistingCaseClicked } = - useAddToExistingCase({ - onAddToCaseClicked: closePopover, - lensAttributes: attributes, - timeRange: timerange, - }); - - const { onAddToNewCaseClicked, disabled: isAddToNewCaseDisabled } = useAddToNewCase({ - onClick: closePopover, - timeRange: timerange, - lensAttributes: attributes, - }); - - const onOpenInLens = useCallback(() => { - closePopover(); - if (!timerange || !attributes) { - return; - } - navigateToPrefilledEditor( - { - id: '', - timeRange: timerange, - attributes, - }, - { - openInNewTab: true, - } - ); - }, [attributes, navigateToPrefilledEditor, timerange]); - - const { openSaveVisualizationFlyout, disableVisualizations } = useSaveToLibrary({ - attributes, - }); - const onOpenInspectModal = useCallback(() => { closePopover(); setIsInspectModalOpen(true); @@ -164,91 +107,33 @@ const VisualizationActionsComponent: React.FC = ({ queryId, }); - const items = useMemo(() => { - const context = {} as ActionExecutionContext; - const extraActionsItems = - extraActions?.map((item: Action) => { - return ( - item.execute(context)} - data-test-subj={`viz-actions-${item.id}`} - > - {item.getDisplayName(context)} - - ); - }) ?? []; - return [ - ...(extraActionsItems ? extraActionsItems : []), - ...(withDefaultActions - ? [ - - {INSPECT} - , - - {ADD_TO_NEW_CASE} - , - - {ADD_TO_EXISTING_CASE} - , - ...(hasPermission - ? [ - - {ADDED_TO_LIBRARY} - , - ] - : []), - - {OPEN_IN_LENS} - , - ] - : []), - ]; - }, [ - hasPermission, - disableInspectButton, - disableVisualizations, + const inspectActionProps = useMemo( + () => ({ + handleInspectClick: handleInspectButtonClick, + isInspectButtonDisabled: disableInspectButton, + }), + [disableInspectButton, handleInspectButtonClick] + ); + + const contextMenuActions = useActions({ + attributes, extraActions, - handleInspectButtonClick, - isAddToExistingCaseDisabled, - isAddToNewCaseDisabled, - onAddToExistingCaseClicked, - onAddToNewCaseClicked, - onOpenInLens, - openSaveVisualizationFlyout, - withDefaultActions, - ]); + inspectActionProps, + timeRange: timerange, + withActions, + }); + + const panels = useAsync( + () => + buildContextMenuForActions({ + actions: contextMenuActions.map((action) => ({ + action, + context: {}, + trigger: VISUALIZATION_CONTEXT_MENU_TRIGGER, + })), + }), + [contextMenuActions] + ); const button = useMemo( () => ( @@ -265,7 +150,7 @@ const VisualizationActionsComponent: React.FC = ({ return ( - {items.length > 0 && ( + {panels.value && panels.value.length > 0 && ( = ({ panelClassName="withHoverActions__popover" data-test-subj="viz-actions-popover" > - + )} {isInspectModalOpen && request !== null && response !== null && ( @@ -289,13 +174,6 @@ const VisualizationActionsComponent: React.FC = ({ title={inspectTitle} /> )} - {isSaveModalVisible && hasPermission && ( - - )} ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/alerts/rule_preview.test.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/alerts/rule_preview.test.ts index 85b4a11bbc7f9..0e31ae006ddb5 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/alerts/rule_preview.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/alerts/rule_preview.test.ts @@ -41,6 +41,7 @@ describe('getRulePreviewLensAttributes', () => { const { result } = renderHook( () => useLensAttributes({ + extraOptions: { showLegend: false }, getLensAttributes: getRulePreviewLensAttributes, stackByField: 'event.category', }), diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/alerts/rule_preview.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/alerts/rule_preview.ts index 2c4c3ec036034..79d791a15d7e8 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/alerts/rule_preview.ts +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/alerts/rule_preview.ts @@ -22,7 +22,7 @@ export const getRulePreviewLensAttributes: GetLensAttributes = ( visualization: { title: 'Empty XY chart', legend: { - isVisible: false, + isVisible: extraOptions?.showLegend, position: 'right', }, valueLabels: 'hide', diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_embeddable.test.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_embeddable.test.tsx index 92f394006d8e9..c87e941b18c9d 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_embeddable.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_embeddable.test.tsx @@ -66,7 +66,7 @@ describe('LensEmbeddable', () => { queries: [ { id: 'testId', - inspect: { dsl: [], response: [] }, + inspect: { dsl: [], response: ['{"mockResponse": "mockResponse"}'] }, isInspected: false, loading: false, selectedInspectIndex: 0, diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_embeddable.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_embeddable.tsx index 69d171467f996..4883757132bc0 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_embeddable.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_embeddable.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -14,19 +14,24 @@ import styled from 'styled-components'; import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import type { RangeFilterParams } from '@kbn/es-query'; import type { ClickTriggerEvent, MultiClickTriggerEvent } from '@kbn/charts-plugin/public'; -import type { EmbeddableComponentProps, XYState } from '@kbn/lens-plugin/public'; +import type { + EmbeddableComponentProps, + TypedLensByValueInput, + XYState, +} from '@kbn/lens-plugin/public'; import { setAbsoluteRangeDatePicker } from '../../store/inputs/actions'; import { useKibana } from '../../lib/kibana'; import { useLensAttributes } from './use_lens_attributes'; import type { LensEmbeddableComponentProps } from './types'; -import { useActions } from './use_actions'; -import { inputsSelectors } from '../../store'; -import { useDeepEqualSelector } from '../../hooks/use_selector'; +import { DEFAULT_ACTIONS, useActions } from './use_actions'; + import { ModalInspectQuery } from '../inspect/modal'; import { InputsModelId } from '../../store/inputs/constants'; -import { getRequestsAndResponses } from './utils'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { VisualizationActions } from './actions'; +import { useEmbeddableInspect } from './use_embeddable_inspect'; +import { useVisualizationResponse } from './use_visualization_response'; +import { useInspect } from '../inspect/use_inspect'; const HOVER_ACTIONS_PADDING = 24; const DISABLED_ACTIONS = [ACTION_CUSTOMIZE_PANEL]; @@ -56,16 +61,6 @@ const LensComponentWrapper = styled.div<{ } `; -const initVisualizationData: { - requests: string[] | undefined; - responses: string[] | undefined; - isLoading: boolean; -} = { - requests: undefined, - responses: undefined, - isLoading: true, -}; - const LensEmbeddableComponent: React.FC = ({ applyGlobalQueriesAndFilters = true, extraActions, @@ -78,10 +73,11 @@ const LensEmbeddableComponent: React.FC = ({ lensAttributes, onLoad, scopeId = SourcererScopeName.default, + enableLegendActions = true, stackByField, timerange, width: wrapperWidth, - withActions = true, + withActions = DEFAULT_ACTIONS, disableOnClickFilter = false, }) => { const style = useMemo( @@ -99,10 +95,7 @@ const LensEmbeddableComponent: React.FC = ({ }, } = useKibana().services; const dispatch = useDispatch(); - const [isShowingModal, setIsShowingModal] = useState(false); - const [visualizationData, setVisualizationData] = useState(initVisualizationData); - const getGlobalQuery = inputsSelectors.globalQueryByIdSelector(); - const { searchSessionId } = useDeepEqualSelector((state) => getGlobalQuery(state, id)); + const { searchSessionId } = useVisualizationResponse({ visualizationId: id }); const attributes = useLensAttributes({ applyGlobalQueriesAndFilters, extraOptions, @@ -118,14 +111,39 @@ const LensEmbeddableComponent: React.FC = ({ attributes?.visualizationType !== 'lnsLegacyMetric' && attributes?.visualizationType !== 'lnsPie'; const LensComponent = lens.EmbeddableComponent; + + const overrides: TypedLensByValueInput['overrides'] = useMemo( + () => + enableLegendActions + ? undefined + : { settings: { legendAction: 'ignore', onBrushEnd: 'ignore' } }, + [enableLegendActions] + ); + const { setInspectData } = useEmbeddableInspect(onLoad); + const { responses, loading } = useVisualizationResponse({ visualizationId: id }); + + const { + additionalRequests, + additionalResponses, + handleClick: handleInspectClick, + handleCloseModal, + isButtonDisabled: isInspectButtonDisabled, + isShowingModal, + request, + response, + } = useInspect({ + inputId: inputsModelId, + isDisabled: loading, + multiple: responses != null && responses.length > 1, + queryId: id, + }); + const inspectActionProps = useMemo( () => ({ - onInspectActionClicked: () => { - setIsShowingModal(true); - }, - isDisabled: visualizationData.isLoading, + handleInspectClick, + isInspectButtonDisabled, }), - [visualizationData.isLoading] + [handleInspectClick, isInspectButtonDisabled] ); const actions = useActions({ @@ -136,10 +154,6 @@ const LensEmbeddableComponent: React.FC = ({ withActions, }); - const handleCloseModal = useCallback(() => { - setIsShowingModal(false); - }, []); - const updateDateRange = useCallback( ({ range }) => { const [min, max] = range; @@ -154,65 +168,34 @@ const LensEmbeddableComponent: React.FC = ({ [dispatch, inputsModelId] ); - const requests = useMemo(() => { - const [request, ...additionalRequests] = visualizationData.requests ?? []; - return { request, additionalRequests }; - }, [visualizationData.requests]); - - const responses = useMemo(() => { - const [response, ...additionalResponses] = visualizationData.responses ?? []; - return { response, additionalResponses }; - }, [visualizationData.responses]); - - const onLoadCallback = useCallback( - (isLoading, adapters) => { - if (!adapters) { + const onFilterCallback = useCallback( + (event) => { + if (disableOnClickFilter) { + event.preventDefault(); return; } - const data = getRequestsAndResponses(adapters?.requests?.getRequests()); - setVisualizationData({ - requests: data.requests, - responses: data.responses, - isLoading, - }); - - if (onLoad != null) { - onLoad({ - requests: data.requests, - responses: data.responses, - isLoading, + const callback: EmbeddableComponentProps['onFilter'] = async (e) => { + if (!isClickTriggerEvent(e) || preferredSeriesType !== 'area') { + e.preventDefault(); + return; + } + // Update timerange when clicking on a dot in an area chart + const [{ query }] = await createFiltersFromValueClickAction({ + data: e.data, + negate: e.negate, }); - } + const rangeFilter: RangeFilterParams = query?.range['@timestamp']; + if (rangeFilter?.gte && rangeFilter?.lt) { + updateDateRange({ + range: [rangeFilter.gte, rangeFilter.lt], + }); + } + }; + return callback; }, - [onLoad] + [createFiltersFromValueClickAction, updateDateRange, preferredSeriesType, disableOnClickFilter] ); - const onFilterCallback = useCallback(() => { - const callback: EmbeddableComponentProps['onFilter'] = async (e) => { - if (!isClickTriggerEvent(e) || preferredSeriesType !== 'area' || disableOnClickFilter) { - e.preventDefault(); - return; - } - // Update timerange when clicking on a dot in an area chart - const [{ query }] = await createFiltersFromValueClickAction({ - data: e.data, - negate: e.negate, - }); - const rangeFilter: RangeFilterParams = query?.range['@timestamp']; - if (rangeFilter?.gte && rangeFilter?.lt) { - updateDateRange({ - range: [rangeFilter.gte, rangeFilter.lt], - }); - } - }; - return callback; - }, [ - createFiltersFromValueClickAction, - updateDateRange, - preferredSeriesType, - disableOnClickFilter, - ]); - const adHocDataViews = useMemo( () => attributes?.state?.adHocDataViews != null @@ -230,10 +213,7 @@ const LensEmbeddableComponent: React.FC = ({ return null; } - if ( - !attributes || - (visualizationData?.responses != null && visualizationData?.responses?.length === 0) - ) { + if (!attributes || (responses != null && responses.length === 0)) { return ( @@ -259,7 +239,7 @@ const LensEmbeddableComponent: React.FC = ({ stackByField={stackByField} timerange={timerange} title={inspectTitle} - withDefaultActions={false} + withActions={withActions} /> @@ -275,34 +255,35 @@ const LensEmbeddableComponent: React.FC = ({ $addHoverActionsPadding={addHoverActionsPadding} > )} - {isShowingModal && requests.request != null && responses.response != null && ( + {isShowingModal && request != null && response != null && ( )} diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/types.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/types.ts index 6f513e445660e..b09e1fe2cc46c 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/types.ts @@ -35,6 +35,14 @@ export interface UseLensAttributesProps { title?: string; } +export enum VisualizationContextMenuActions { + addToExistingCase = 'addToExistingCase', + addToNewCase = 'addToNewCase', + inspect = 'inspect', + openInLens = 'openInLens', + saveToLibrary = 'saveToLibrary', +} + export interface VisualizationActionsProps { applyGlobalQueriesAndFilters?: boolean; className?: string; @@ -52,7 +60,7 @@ export interface VisualizationActionsProps { stackByField?: string; timerange: { from: string; to: string }; title: React.ReactNode; - withDefaultActions?: boolean; + withActions?: VisualizationContextMenuActions[]; } export interface EmbeddableData { @@ -63,6 +71,14 @@ export interface EmbeddableData { export type OnEmbeddableLoaded = (data: EmbeddableData) => void; +export enum VisualizationContextMenuDefaultActionName { + addToExistingCase = 'addToExistingCase', + addToNewCase = 'addToNewCase', + inspect = 'inspect', + openInLens = 'openInLens', + saveToLibrary = 'saveToLibrary', +} + export interface LensEmbeddableComponentProps { applyGlobalQueriesAndFilters?: boolean; extraActions?: Action[]; @@ -74,11 +90,12 @@ export interface LensEmbeddableComponentProps { inspectTitle?: React.ReactNode; lensAttributes?: LensAttributes; onLoad?: OnEmbeddableLoaded; + enableLegendActions?: boolean; scopeId?: SourcererScopeName; stackByField?: string; timerange: { from: string; to: string }; width?: string | number; - withActions?: boolean; + withActions?: VisualizationContextMenuActions[]; /** * Disable the on click filter for the visualization. */ @@ -125,11 +142,12 @@ export interface Response { export interface ExtraOptions { breakdownField?: string; + dnsIsPtrIncluded?: boolean; filters?: Filter[]; ruleId?: string; + showLegend?: boolean; spaceId?: string; status?: Status; - dnsIsPtrIncluded?: boolean; } export interface VisualizationEmbeddableProps extends LensEmbeddableComponentProps { diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_actions.test.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_actions.test.tsx index 273e4d89d1d7a..1582a0b382c75 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_actions.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_actions.test.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { NavigationProvider } from '@kbn/security-solution-navigation'; import { useKibana } from '../../lib/kibana/kibana_react'; import { mockAttributes } from './mocks'; -import { useActions } from './use_actions'; +import { DEFAULT_ACTIONS, useActions } from './use_actions'; import { coreMock } from '@kbn/core/public/mocks'; import { TestProviders } from '../../mock'; @@ -71,15 +71,15 @@ describe(`useActions`, () => { const { result } = renderHook( () => useActions({ - withActions: true, + withActions: DEFAULT_ACTIONS, attributes: mockAttributes, timeRange: { from: '2022-10-26T23:00:00.000Z', to: '2022-11-03T15:16:50.053Z', }, inspectActionProps: { - onInspectActionClicked: jest.fn(), - isDisabled: false, + handleInspectClick: jest.fn(), + isInspectButtonDisabled: false, }, }), { @@ -119,15 +119,15 @@ describe(`useActions`, () => { const { result } = renderHook( () => useActions({ - withActions: true, + withActions: DEFAULT_ACTIONS, attributes: mockAttributes, timeRange: { from: '2022-10-26T23:00:00.000Z', to: '2022-11-03T15:16:50.053Z', }, inspectActionProps: { - onInspectActionClicked: jest.fn(), - isDisabled: false, + handleInspectClick: jest.fn(), + isInspectButtonDisabled: false, }, extraActions: mockExtraAction, }), diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_actions.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_actions.ts index 760d9e396584e..8085097838307 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_actions.ts +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_actions.ts @@ -5,52 +5,103 @@ * 2.0. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import type { Action, ActionExecutionContext } from '@kbn/ui-actions-plugin/public'; +import { useCallback, useMemo } from 'react'; +import type { Action, Trigger } from '@kbn/ui-actions-plugin/public'; + +import { createAction } from '@kbn/ui-actions-plugin/public'; +import type { ActionDefinition } from '@kbn/ui-actions-plugin/public/actions'; import { useKibana } from '../../lib/kibana/kibana_react'; import { useAddToExistingCase } from './use_add_to_existing_case'; import { useAddToNewCase } from './use_add_to_new_case'; import { useSaveToLibrary } from './use_save_to_library'; +import { VisualizationContextMenuActions } from './types'; +import type { LensAttributes } from './types'; import { ADDED_TO_LIBRARY, ADD_TO_EXISTING_CASE, ADD_TO_NEW_CASE, + INSPECT, OPEN_IN_LENS, } from './translations'; -import type { LensAttributes } from './types'; -import { INSPECT } from '../inspect/translations'; -export type ActionTypes = 'addToExistingCase' | 'addToNewCase' | 'openInLens'; +export const DEFAULT_ACTIONS: VisualizationContextMenuActions[] = [ + VisualizationContextMenuActions.inspect, + VisualizationContextMenuActions.addToNewCase, + VisualizationContextMenuActions.addToExistingCase, + VisualizationContextMenuActions.saveToLibrary, + VisualizationContextMenuActions.openInLens, +]; + +export const INSPECT_ACTION: VisualizationContextMenuActions[] = [ + VisualizationContextMenuActions.inspect, +]; + +export const VISUALIZATION_CONTEXT_MENU_TRIGGER: Trigger = { + id: 'VISUALIZATION_CONTEXT_MENU_TRIGGER', +}; + +const ACTION_DEFINITION: Record< + VisualizationContextMenuActions, + Omit +> = { + [VisualizationContextMenuActions.inspect]: { + id: VisualizationContextMenuActions.inspect, + getDisplayName: () => INSPECT, + getIconType: () => 'inspect', + type: 'actionButton', + order: 4, + }, + [VisualizationContextMenuActions.addToNewCase]: { + id: VisualizationContextMenuActions.addToNewCase, + getDisplayName: () => ADD_TO_NEW_CASE, + getIconType: () => 'casesApp', + type: 'actionButton', + order: 3, + }, + [VisualizationContextMenuActions.addToExistingCase]: { + id: VisualizationContextMenuActions.addToExistingCase, + getDisplayName: () => ADD_TO_EXISTING_CASE, + getIconType: () => 'casesApp', + type: 'actionButton', + order: 2, + }, + [VisualizationContextMenuActions.saveToLibrary]: { + id: VisualizationContextMenuActions.saveToLibrary, + getDisplayName: () => ADDED_TO_LIBRARY, + getIconType: () => 'save', + type: 'actionButton', + order: 1, + }, + [VisualizationContextMenuActions.openInLens]: { + id: VisualizationContextMenuActions.openInLens, + getDisplayName: () => OPEN_IN_LENS, + getIconType: () => 'visArea', + type: 'actionButton', + order: 0, + }, +}; export const useActions = ({ attributes, - extraActions, + extraActions = [], inspectActionProps, timeRange, - withActions, + withActions = DEFAULT_ACTIONS, }: { attributes: LensAttributes | null; extraActions?: Action[]; - inspectActionProps?: { onInspectActionClicked: () => void; isDisabled: boolean }; + inspectActionProps: { + handleInspectClick: () => void; + isInspectButtonDisabled: boolean; + }; timeRange: { from: string; to: string }; - withActions?: boolean; + withActions?: VisualizationContextMenuActions[]; }) => { - const { lens } = useKibana().services; - const { navigateToPrefilledEditor } = lens; - const [defaultActions, setDefaultActions] = useState([ - 'inspect', - 'addToNewCase', - 'addToExistingCase', - 'saveToLibrary', - 'openInLens', - ]); - - useEffect(() => { - if (withActions === false) { - setDefaultActions([]); - } - }, [withActions]); + const { services } = useKibana(); + const { + lens: { navigateToPrefilledEditor, canUseEditor }, + } = services; const onOpenInLens = useCallback(() => { if (!timeRange || !attributes) { @@ -80,201 +131,78 @@ export const useActions = ({ }); const { openSaveVisualizationFlyout, disableVisualizations } = useSaveToLibrary({ attributes }); - const actions = useMemo( - () => - defaultActions?.reduce((acc, action) => { - if (action === 'inspect' && inspectActionProps != null) { - return [ - ...acc, - getInspectAction({ - callback: inspectActionProps?.onInspectActionClicked, - disabled: inspectActionProps?.isDisabled, - }), - ]; - } - if (action === 'addToExistingCase') { - return [ - ...acc, - getAddToExistingCaseAction({ - callback: onAddToExistingCaseClicked, - disabled: isAddToExistingCaseDisabled, - }), - ]; - } - if (action === 'addToNewCase') { - return [ - ...acc, - getAddToNewCaseAction({ - callback: onAddToNewCaseClicked, - disabled: isAddToNewCaseDisabled, - }), - ]; - } - if (action === 'openInLens') { - return [...acc, getOpenInLensAction({ callback: onOpenInLens })]; - } - if (action === 'saveToLibrary') { - return [ - ...acc, - getSaveToLibraryAction({ - callback: openSaveVisualizationFlyout, - disabled: disableVisualizations, - }), - ]; - } - - return acc; - }, []), + const allActions: Action[] = useMemo( + () => + [ + createAction({ + ...ACTION_DEFINITION[VisualizationContextMenuActions.inspect], + execute: async () => { + inspectActionProps.handleInspectClick(); + }, + disabled: inspectActionProps.isInspectButtonDisabled, + isCompatible: async () => withActions.includes(VisualizationContextMenuActions.inspect), + }), + createAction({ + ...ACTION_DEFINITION[VisualizationContextMenuActions.addToNewCase], + execute: async () => { + onAddToNewCaseClicked(); + }, + disabled: isAddToNewCaseDisabled, + isCompatible: async () => + withActions.includes(VisualizationContextMenuActions.addToNewCase), + }), + createAction({ + ...ACTION_DEFINITION[VisualizationContextMenuActions.addToExistingCase], + execute: async () => { + onAddToExistingCaseClicked(); + }, + disabled: isAddToExistingCaseDisabled, + isCompatible: async () => + withActions.includes(VisualizationContextMenuActions.addToExistingCase), + order: 2, + }), + createAction({ + ...ACTION_DEFINITION[VisualizationContextMenuActions.saveToLibrary], + execute: async () => { + openSaveVisualizationFlyout(); + }, + disabled: disableVisualizations, + isCompatible: async () => + withActions.includes(VisualizationContextMenuActions.saveToLibrary), + order: 1, + }), + createAction({ + ...ACTION_DEFINITION[VisualizationContextMenuActions.openInLens], + execute: async () => { + onOpenInLens(); + }, + isCompatible: async () => + canUseEditor() && withActions.includes(VisualizationContextMenuActions.openInLens), + order: 0, + }), + ...extraActions, + ].map((a, i, totalActions) => { + const order = Math.max(totalActions.length - (1 + i), 0); + return { + ...a, + order, + }; + }), [ - defaultActions, + canUseEditor, + disableVisualizations, + extraActions, inspectActionProps, - onAddToExistingCaseClicked, isAddToExistingCaseDisabled, - onAddToNewCaseClicked, isAddToNewCaseDisabled, + onAddToExistingCaseClicked, + onAddToNewCaseClicked, onOpenInLens, openSaveVisualizationFlyout, - disableVisualizations, + withActions, ] ); - const withExtraActions = actions.concat(extraActions ?? []).map((a, i, totalActions) => { - const order = Math.max(totalActions.length - (1 + i), 0); - return { - ...a, - order, - }; - }); - - return withExtraActions; -}; - -const getOpenInLensAction = ({ callback }: { callback: () => void }): Action => { - return { - id: 'openInLens', - - getDisplayName(context: ActionExecutionContext): string { - return OPEN_IN_LENS; - }, - getIconType(context: ActionExecutionContext): string | undefined { - return 'visArea'; - }, - type: 'actionButton', - async isCompatible(context: ActionExecutionContext): Promise { - return true; - }, - async execute(context: ActionExecutionContext): Promise { - callback(); - }, - order: 0, - }; -}; - -const getSaveToLibraryAction = ({ - callback, - disabled, -}: { - callback: () => void; - disabled?: boolean; -}): Action => { - return { - id: 'saveToLibrary', - getDisplayName(context: ActionExecutionContext): string { - return ADDED_TO_LIBRARY; - }, - getIconType(context: ActionExecutionContext): string | undefined { - return 'save'; - }, - type: 'actionButton', - async isCompatible(context: ActionExecutionContext): Promise { - return true; - }, - async execute(context: ActionExecutionContext): Promise { - callback(); - }, - disabled, - order: 1, - }; -}; - -const getAddToExistingCaseAction = ({ - callback, - disabled, -}: { - callback: () => void; - disabled?: boolean; -}): Action => { - return { - id: 'addToExistingCase', - getDisplayName(context: ActionExecutionContext): string { - return ADD_TO_EXISTING_CASE; - }, - getIconType(context: ActionExecutionContext): string | undefined { - return 'casesApp'; - }, - type: 'actionButton', - async isCompatible(context: ActionExecutionContext): Promise { - return true; - }, - async execute(context: ActionExecutionContext): Promise { - callback(); - }, - disabled, - order: 2, - }; -}; - -const getAddToNewCaseAction = ({ - callback, - disabled, -}: { - callback: () => void; - disabled?: boolean; -}): Action => { - return { - id: 'addToNewCase', - getDisplayName(context: ActionExecutionContext): string { - return ADD_TO_NEW_CASE; - }, - getIconType(context: ActionExecutionContext): string | undefined { - return 'casesApp'; - }, - type: 'actionButton', - async isCompatible(context: ActionExecutionContext): Promise { - return true; - }, - async execute(context: ActionExecutionContext): Promise { - callback(); - }, - disabled, - order: 3, - }; -}; - -const getInspectAction = ({ - callback, - disabled, -}: { - callback: () => void; - disabled?: boolean; -}): Action => { - return { - id: 'inspect', - getDisplayName(context: ActionExecutionContext): string { - return INSPECT; - }, - getIconType(context: ActionExecutionContext): string | undefined { - return 'inspect'; - }, - type: 'actionButton', - async isCompatible(context: ActionExecutionContext): Promise { - return true; - }, - async execute(context: ActionExecutionContext): Promise { - callback(); - }, - disabled, - order: 4, - }; + return allActions; }; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_embeddable_inspect.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_embeddable_inspect.tsx new file mode 100644 index 0000000000000..ca80999a81062 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_embeddable_inspect.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import type { OnEmbeddableLoaded } from './types'; + +import { getRequestsAndResponses } from './utils'; + +export const useEmbeddableInspect = (onEmbeddableLoad?: OnEmbeddableLoaded) => { + const setInspectData = useCallback( + (isLoading, adapters) => { + if (!adapters) { + return; + } + const data = getRequestsAndResponses(adapters?.requests?.getRequests()); + + onEmbeddableLoad?.({ + requests: data.requests, + responses: data.responses, + isLoading, + }); + }, + [onEmbeddableLoad] + ); + + return { setInspectData }; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_visualization_response.test.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_visualization_response.test.tsx index 36d83e7793e59..68adb1dd8f20a 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_visualization_response.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_visualization_response.test.tsx @@ -55,7 +55,7 @@ describe('useVisualizationResponse', () => { const { result } = renderHook(() => useVisualizationResponse({ visualizationId }), { wrapper: ({ children }) => {children}, }); - expect(result.current).toEqual( + expect(result.current.responses).toEqual( parseVisualizationData(mockState.inputs.global.queries[0].inspect.response) ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_visualization_response.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_visualization_response.tsx index 39e822744922c..601059cab2c2a 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_visualization_response.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_visualization_response.tsx @@ -14,10 +14,17 @@ import type { VisualizationResponse } from './types'; export const useVisualizationResponse = ({ visualizationId }: { visualizationId: string }) => { const getGlobalQuery = inputsSelectors.globalQueryByIdSelector(); - const { inspect } = useDeepEqualSelector((state) => getGlobalQuery(state, visualizationId)); + const { inspect, loading, searchSessionId } = useDeepEqualSelector((state) => + getGlobalQuery(state, visualizationId) + ); const response = useMemo( - () => (inspect ? parseVisualizationData(inspect?.response) : null), - [inspect] + () => ({ + requests: inspect ? parseVisualizationData(inspect?.dsl) : null, + responses: inspect ? parseVisualizationData(inspect?.response) : null, + loading, + searchSessionId, + }), + [inspect, loading, searchSessionId] ); return response; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/visualization_embeddable.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/visualization_embeddable.tsx index 580aad868d5c7..a5845b9bb0fc8 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/visualization_embeddable.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/visualization_embeddable.tsx @@ -40,7 +40,7 @@ const VisualizationEmbeddableComponent: React.FC = const memorizedTimerange = useRef(lensProps.timerange); const getGlobalQuery = inputsSelectors.globalQueryByIdSelector(); const { searchSessionId } = useDeepEqualSelector((state) => getGlobalQuery(state, id)); - const visualizationData = useVisualizationResponse({ visualizationId: id }); + const { responses: visualizationData } = useVisualizationResponse({ visualizationId: id }); const dataExists = visualizationData != null && visualizationData[0]?.hits?.total !== 0; const donutTextWrapperStyles = dataExists ? css` @@ -125,7 +125,7 @@ const VisualizationEmbeddableComponent: React.FC = isChartEmbeddablesEnabled={true} dataExists={dataExists} label={label} - title={dataExists ? : null} + title={visualizationData ? : null} donutTextWrapperClassName={donutTextWrapperClassName} donutTextWrapperStyles={donutTextWrapperStyles} > diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/helpers.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/helpers.ts index 3b072c2f91e2a..f1fbe5a06c1d1 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/helpers.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/helpers.ts @@ -6,12 +6,10 @@ */ import { isEmpty } from 'lodash'; -import { Position, ScaleType } from '@elastic/charts'; import type { EuiSelectOption } from '@elastic/eui'; import type { Type, ThreatMapping } from '@kbn/securitysolution-io-ts-alerting-types'; import * as i18n from './translations'; -import { histogramDateTimeFormatter } from '../../../../common/components/utils'; -import type { ChartSeriesConfigs } from '../../../../common/components/charts/common'; + import type { FieldValueQueryBar } from '../query_bar'; import type { TimeframePreviewOptions } from '../../../../detections/pages/detection_engine/rules/types'; import { DataSourceType } from '../../../../detections/pages/detection_engine/rules/types'; @@ -61,54 +59,6 @@ export const getTimeframeOptions = (ruleType: Type): EuiSelectOption[] => { } }; -/** - * Config passed into elastic-charts settings. - * @param to - * @param from - */ -export const getHistogramConfig = ( - to: string, - from: string, - showLegend = false -): ChartSeriesConfigs => { - return { - series: { - xScaleType: ScaleType.Time, - yScaleType: ScaleType.Linear, - stackAccessors: ['g'], - }, - axis: { - xTickFormatter: histogramDateTimeFormatter([to, from]), - yTickFormatter: (value: string | number): string => value.toLocaleString(), - tickSize: 8, - }, - yAxisTitle: i18n.QUERY_GRAPH_COUNT, - settings: { - legendPosition: Position.Right, - showLegend, - showLegendExtra: showLegend, - theme: { - scales: { - barsPadding: 0.08, - }, - chartMargins: { - left: 0, - right: 0, - top: 0, - bottom: 0, - }, - chartPaddings: { - left: 0, - right: 0, - top: 0, - bottom: 0, - }, - }, - }, - customHeight: 200, - }; -}; - const isNewTermsPreviewDisabled = (newTermsFields: string[]): boolean => { return newTermsFields.length === 0 || newTermsFields.length > MAX_NUMBER_OF_NEW_TERMS_FIELDS; }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.test.tsx index 9d39b68626907..69eebec3452d5 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.test.tsx @@ -15,7 +15,6 @@ import { TestProviders } from '../../../../common/mock'; import type { RulePreviewProps } from '.'; import { RulePreview, REASONABLE_INVOCATION_COUNT } from '.'; import { usePreviewRoute } from './use_preview_route'; -import { usePreviewHistogram } from './use_preview_histogram'; import { DataSourceType } from '../../../../detections/pages/detection_engine/rules/types'; import { getStepScheduleDefaultValue, @@ -26,7 +25,6 @@ import { usePreviewInvocationCount } from './use_preview_invocation_count'; jest.mock('../../../../common/lib/kibana'); jest.mock('./use_preview_route'); -jest.mock('./use_preview_histogram'); jest.mock('../../../../common/containers/use_global_time', () => ({ useGlobalTime: jest.fn().mockReturnValue({ from: '2020-07-07T08:20:18.966Z', @@ -88,17 +86,6 @@ const defaultProps: RulePreviewProps = { describe('PreviewQuery', () => { beforeEach(() => { - (usePreviewHistogram as jest.Mock).mockReturnValue([ - false, - { - inspect: { dsl: [], response: [] }, - totalCount: 1, - refetch: jest.fn(), - data: [], - buckets: [], - }, - ]); - (usePreviewRoute as jest.Mock).mockReturnValue({ hasNoiseWarning: false, addNoiseWarning: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/preview_histogram.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/preview_histogram.test.tsx index 9a490bec1ce25..80e98a8f288d8 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/preview_histogram.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/preview_histogram.test.tsx @@ -10,6 +10,7 @@ import moment from 'moment'; import type { DataViewBase } from '@kbn/es-query'; import { fields } from '@kbn/data-plugin/common/mocks'; +import { render } from '@testing-library/react'; import { useGlobalTime } from '../../../../common/containers/use_global_time'; import { @@ -19,21 +20,17 @@ import { SUB_PLUGINS_REDUCER, TestProviders, } from '../../../../common/mock'; -import { usePreviewHistogram } from './use_preview_histogram'; +import { VisualizationEmbeddable } from '../../../../common/components/visualization_actions/visualization_embeddable'; +import { useVisualizationResponse } from '../../../../common/components/visualization_actions/use_visualization_response'; import { PreviewHistogram } from './preview_histogram'; -import { ALL_VALUES_ZEROS_TITLE } from '../../../../common/components/charts/translation'; import { useTimelineEvents } from '../../../../common/components/events_viewer/use_timelines_events'; import { TableId } from '@kbn/securitysolution-data-table'; import { createStore } from '../../../../common/store'; import { mockEventViewerResponse } from '../../../../common/components/events_viewer/mock'; -import type { ReactWrapper } from 'enzyme'; -import { mount } from 'enzyme'; import type { UseFieldBrowserOptionsProps } from '../../../../timelines/components/fields_browser'; import type { TransformColumnsProps } from '../../../../common/components/control_columns'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; -import type { ExperimentalFeatures } from '../../../../../common/experimental_features'; -import { allowedExperimentalValues } from '../../../../../common/experimental_features'; +import { INSPECT_ACTION } from '../../../../common/components/visualization_actions/use_actions'; jest.mock('../../../../common/components/control_columns', () => ({ transformControlColumns: (props: TransformColumnsProps) => [], @@ -46,17 +43,17 @@ jest.mock('../../../../common/components/control_columns', () => ({ })); jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../common/containers/use_global_time'); -jest.mock('./use_preview_histogram'); jest.mock('../../../../common/utils/normalize_time_range'); jest.mock('../../../../common/components/events_viewer/use_timelines_events'); jest.mock('../../../../common/components/visualization_actions/visualization_embeddable'); +jest.mock('../../../../common/components/visualization_actions/use_visualization_response', () => ({ + useVisualizationResponse: jest.fn(), +})); + jest.mock('../../../../common/hooks/use_experimental_features', () => ({ useIsExperimentalFeatureEnabled: jest.fn(), })); -const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; -const getMockUseIsExperimentalFeatureEnabled = - (mockMapping?: Partial) => (flag: keyof typeof allowedExperimentalValues) => - mockMapping ? mockMapping?.[flag] : allowedExperimentalValues?.[flag]; +const mockVisualizationEmbeddable = VisualizationEmbeddable as unknown as jest.Mock; const mockUseFieldBrowserOptions = jest.fn(); jest.mock('../../../../timelines/components/fields_browser', () => ({ @@ -82,9 +79,6 @@ describe('PreviewHistogram', () => { const mockSetQuery = jest.fn(); beforeEach(() => { - mockUseIsExperimentalFeatureEnabled.mockImplementation( - getMockUseIsExperimentalFeatureEnabled({ alertsPreviewChartEmbeddablesEnabled: false }) - ); (useGlobalTime as jest.Mock).mockReturnValue({ from: '2020-07-07T08:20:18.966Z', isInitializing: false, @@ -116,27 +110,15 @@ describe('PreviewHistogram', () => { jest.clearAllMocks(); }); - describe('when there is no data', () => { - (usePreviewHistogram as jest.Mock).mockReturnValue([ - false, - { - inspect: { dsl: [], response: [] }, - totalCount: 1, - refetch: jest.fn(), - data: [], - buckets: [], - }, - ]); + describe('PreviewHistogram', () => { + test('should render Lens embeddable', () => { + (useVisualizationResponse as jest.Mock).mockReturnValue({ + loading: false, + requests: [], + responses: [{ hits: { total: 1 } }], + }); - test('it renders an empty histogram and table', async () => { - (useTimelineEvents as jest.Mock).mockReturnValue([ - false, - { - ...mockEventViewerResponse, - totalCount: 1, - }, - ]); - const wrapper = mount( + const { getByTestId } = render( { /> ); - expect(wrapper.findWhere((node) => node.text() === '1 alert').exists()).toBeTruthy(); - expect( - wrapper.findWhere((node) => node.text() === ALL_VALUES_ZEROS_TITLE).exists() - ).toBeTruthy(); + + expect(getByTestId('visualization-embeddable')).toBeInTheDocument(); }); - }); - describe('when there is data', () => { - test('it renders loader when isLoading is true', () => { - (usePreviewHistogram as jest.Mock).mockReturnValue([ - true, - { - inspect: { dsl: [], response: [] }, - totalCount: 1, - refetch: jest.fn(), - data: [], - buckets: [], - }, - ]); + test('should render inspect action', () => { + (useVisualizationResponse as jest.Mock).mockReturnValue({ + loading: false, + requests: [], + responses: [{ hits: { total: 1 } }], + }); - const wrapper = mount( + render( { ); - expect(wrapper.find(`[data-test-subj="preview-histogram-loading"]`).exists()).toBeTruthy(); + expect(mockVisualizationEmbeddable.mock.calls[0][0].withActions).toEqual(INSPECT_ACTION); }); - }); - describe('when advanced options passed', () => { - test('it uses timeframeStart and timeframeEnd to specify the time range of the preview', () => { - const format = 'YYYY-MM-DD HH:mm:ss'; - const start = '2015-03-12 05:17:10'; - const end = '2020-03-12 05:17:10'; - (useTimelineEvents as jest.Mock).mockReturnValue([ - false, - { - ...mockEventViewerResponse, - totalCount: 0, - }, - ]); - const usePreviewHistogramMock = usePreviewHistogram as jest.Mock; - usePreviewHistogramMock.mockReturnValue([ - true, - { - inspect: { dsl: [], response: [] }, - totalCount: 1, - refetch: jest.fn(), - data: [], - buckets: [], - }, - ]); + test('should disable filter when clicking on the chart', () => { + (useVisualizationResponse as jest.Mock).mockReturnValue({ + loading: false, + requests: [], + responses: [{ hits: { total: 1 } }], + }); - usePreviewHistogramMock.mockImplementation( - ({ startDate, endDate }: { startDate: string; endDate: string }) => { - expect(startDate).toEqual('2015-03-12T09:17:10.000Z'); - expect(endDate).toEqual('2020-03-12T09:17:10.000Z'); - return [ - true, - { - inspect: { dsl: [], response: [] }, - totalCount: 1, - refetch: jest.fn(), - data: [], - buckets: [], - }, - ]; - } + render( + + + ); - const wrapper = mount( + expect(mockVisualizationEmbeddable.mock.calls[0][0].disableOnClickFilter).toBeTruthy(); + }); + + test('should show chart legend when if it is not EQL rule', () => { + (useVisualizationResponse as jest.Mock).mockReturnValue({ + loading: false, + requests: [], + responses: [{ hits: { total: 1 } }], + }); + + render( ); - expect(wrapper.find(`[data-test-subj="preview-histogram-loading"]`).exists()).toBeTruthy(); + expect(mockVisualizationEmbeddable.mock.calls[0][0].extraOptions.showLegend).toBeTruthy(); }); }); - describe('when the alertsPreviewChartEmbeddablesEnabled experimental feature flag is enabled', () => { - let wrapper: ReactWrapper; - beforeEach(() => { - mockUseIsExperimentalFeatureEnabled.mockImplementation( - getMockUseIsExperimentalFeatureEnabled({ - alertsPreviewChartEmbeddablesEnabled: true, - }) - ); - - (usePreviewHistogram as jest.Mock).mockReturnValue([ + describe('when advanced options passed', () => { + test('it uses timeframeStart and timeframeEnd to specify the time range of the preview', () => { + const format = 'YYYY-MM-DD HH:mm:ss'; + const start = '2015-03-12 05:17:10'; + const end = '2020-03-12 05:17:10'; + (useTimelineEvents as jest.Mock).mockReturnValue([ false, { - inspect: { dsl: [], response: [] }, - totalCount: 1, - refetch: jest.fn(), - data: [], - buckets: [], + ...mockEventViewerResponse, + totalCount: 0, }, ]); - wrapper = mount( + (useVisualizationResponse as jest.Mock).mockReturnValue({ + loading: false, + requests: [], + responses: [{ hits: { total: 0 } }], + }); + + render( { spaceId={'default'} ruleType={'query'} indexPattern={getMockIndexPattern()} - timeframeOptions={getLastMonthTimeframe()} + timeframeOptions={{ + timeframeStart: moment(start, format), + timeframeEnd: moment(end, format), + interval: '5m', + lookback: '1m', + }} /> ); - }); - - test('should not fetch preview data', () => { - expect((usePreviewHistogram as jest.Mock).mock.calls[0][0].skip).toEqual(true); - }); - test('should render Lens embeddable', () => { - expect(wrapper.find('[data-test-subj="visualization-embeddable"]').exists()).toBeTruthy(); + expect(mockVisualizationEmbeddable.mock.calls[0][0].timerange).toEqual({ + from: moment(start, format).toISOString(), + to: moment(end, format).toISOString(), + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/preview_histogram.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/preview_histogram.tsx index 7de2f70aa381a..487fc3a4a4e29 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/preview_histogram.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/preview_histogram.tsx @@ -7,7 +7,7 @@ import React, { useEffect, useMemo } from 'react'; import usePrevious from 'react-use/lib/usePrevious'; -import { EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer, EuiLoadingChart } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer } from '@elastic/eui'; import styled from 'styled-components'; import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; import { getEsQueryConfig } from '@kbn/data-plugin/common'; @@ -17,16 +17,10 @@ import { TableId } from '@kbn/securitysolution-data-table'; import { StatefulEventsViewer } from '../../../../common/components/events_viewer'; import { defaultRowRenderers } from '../../../../timelines/components/timeline/body/renderers'; import * as i18n from './translations'; -import { useGlobalTime } from '../../../../common/containers/use_global_time'; -import { getHistogramConfig, isNoisy } from './helpers'; -import type { - ChartSeriesConfigs, - ChartSeriesData, -} from '../../../../common/components/charts/common'; +import { isNoisy } from './helpers'; import { Panel } from '../../../../common/components/panel'; import { HeaderSection } from '../../../../common/components/header_section'; -import { BarChart } from '../../../../common/components/charts/barchart'; -import { usePreviewHistogram } from './use_preview_histogram'; + import { getAlertsPreviewDefaultModel } from '../../../../detections/components/alerts_table/default_config'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { DEFAULT_PREVIEW_INDEX } from '../../../../../common/constants'; @@ -38,14 +32,10 @@ import { useGlobalFullScreen } from '../../../../common/containers/use_full_scre import type { TimeframePreviewOptions } from '../../../../detections/pages/detection_engine/rules/types'; import { useLicense } from '../../../../common/hooks/use_license'; import { useKibana } from '../../../../common/lib/kibana'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { getRulePreviewLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/common/alerts/rule_preview'; import { VisualizationEmbeddable } from '../../../../common/components/visualization_actions/visualization_embeddable'; - -const LoadingChart = styled(EuiLoadingChart)` - display: block; - margin: 0 auto; -`; +import { useVisualizationResponse } from '../../../../common/components/visualization_actions/use_visualization_response'; +import { INSPECT_ACTION } from '../../../../common/components/visualization_actions/use_actions'; const FullScreenContainer = styled.div<{ $isFullScreen: boolean }>` height: ${({ $isFullScreen }) => ($isFullScreen ? '100%' : undefined)}; @@ -78,7 +68,6 @@ const PreviewHistogramComponent = ({ timeframeOptions, }: PreviewHistogramProps) => { const { uiSettings } = useKibana().services; - const { setQuery, isInitializing } = useGlobalTime(); const startDate = useMemo( () => timeframeOptions.timeframeStart.toISOString(), [timeframeOptions] @@ -94,34 +83,29 @@ const PreviewHistogramComponent = ({ const isEqlRule = useMemo(() => ruleType === 'eql', [ruleType]); const isMlRule = useMemo(() => ruleType === 'machine_learning', [ruleType]); - const isAlertsPreviewChartEmbeddablesEnabled = useIsExperimentalFeatureEnabled( - 'alertsPreviewChartEmbeddablesEnabled' - ); const timerange = useMemo(() => ({ from: startDate, to: endDate }), [startDate, endDate]); const extraVisualizationOptions = useMemo( () => ({ ruleId: previewId, spaceId, + showLegend: !isEqlRule, }), - [previewId, spaceId] + [isEqlRule, previewId, spaceId] ); - const [isLoading, { data, inspect, totalCount, refetch }] = usePreviewHistogram({ - previewId, - startDate, - endDate, - spaceId, - indexPattern, - ruleType, - skip: isAlertsPreviewChartEmbeddablesEnabled, - }); const license = useLicense(); const { browserFields, runtimeMappings } = useSourcererDataView(SourcererScopeName.detections); const { globalFullScreen } = useGlobalFullScreen(); const previousPreviewId = usePrevious(previewId); const previewQueryId = `${ID}-${previewId}`; + const previewEmbeddableId = `${previewQueryId}-embeddable`; + const { responses: visualizationResponse } = useVisualizationResponse({ + visualizationId: previewEmbeddableId, + }); + + const totalCount = visualizationResponse?.[0]?.hits?.total ?? 0; useEffect(() => { if (previousPreviewId !== previewId && totalCount > 0) { @@ -129,34 +113,8 @@ const PreviewHistogramComponent = ({ addNoiseWarning(); } } - }, [totalCount, addNoiseWarning, previousPreviewId, previewId, timeframeOptions]); - - useEffect((): void => { - if (!isLoading && !isInitializing) { - setQuery({ - id: previewQueryId, - inspect, - loading: isLoading, - refetch, - }); - } - }, [ - setQuery, - inspect, - isLoading, - isInitializing, - refetch, - previewId, - isAlertsPreviewChartEmbeddablesEnabled, - previewQueryId, - ]); + }, [addNoiseWarning, previewId, previousPreviewId, timeframeOptions, totalCount]); - const barConfig = useMemo( - (): ChartSeriesConfigs => getHistogramConfig(endDate, startDate, !isEqlRule), - [endDate, startDate, isEqlRule] - ); - - const chartData = useMemo((): ChartSeriesData[] => [{ key: 'hits', value: data }], [data]); const config = getEsQueryConfig(uiSettings); const pageFilters = useMemo(() => { const filterQuery = buildEsQuery( @@ -195,32 +153,24 @@ const PreviewHistogramComponent = ({ id={previewQueryId} title={i18n.QUERY_GRAPH_HITS_TITLE} titleSize="xs" - showInspectButton={!isAlertsPreviewChartEmbeddablesEnabled} + showInspectButton={false} /> - {isLoading ? ( - - ) : isAlertsPreviewChartEmbeddablesEnabled ? ( - - ) : ( - - )} + <> diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/use_preview_histogram.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/use_preview_histogram.tsx deleted file mode 100644 index 89600fa014099..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/use_preview_histogram.tsx +++ /dev/null @@ -1,65 +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 { useMemo } from 'react'; -import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; -import { getEsQueryConfig } from '@kbn/data-plugin/common'; -import type { DataViewBase } from '@kbn/es-query'; -import { useMatrixHistogramCombined } from '../../../../common/containers/matrix_histogram'; -import { MatrixHistogramType } from '../../../../../common/search_strategy'; -import { convertToBuildEsQuery } from '../../../../common/lib/kuery'; -import { useKibana } from '../../../../common/lib/kibana'; -import { QUERY_PREVIEW_ERROR } from './translations'; -import { DEFAULT_PREVIEW_INDEX } from '../../../../../common/constants'; - -interface PreviewHistogramParams { - previewId: string | undefined; - endDate: string; - startDate: string; - spaceId: string; - ruleType: Type; - indexPattern: DataViewBase | undefined; - skip?: boolean; -} - -export const usePreviewHistogram = ({ - previewId, - startDate, - endDate, - spaceId, - ruleType, - indexPattern, - skip, -}: PreviewHistogramParams) => { - const { uiSettings } = useKibana().services; - - const [filterQuery, error] = convertToBuildEsQuery({ - config: getEsQueryConfig(uiSettings), - indexPattern, - queries: [{ query: `kibana.alert.rule.uuid:${previewId}`, language: 'kuery' }], - filters: [], - }); - - const stackByField = useMemo(() => { - return ruleType === 'machine_learning' ? 'host.name' : 'event.category'; - }, [ruleType]); - - const matrixHistogramRequest = useMemo(() => { - return { - endDate, - errorMessage: QUERY_PREVIEW_ERROR, - filterQuery, - histogramType: MatrixHistogramType.preview, - indexNames: [`${DEFAULT_PREVIEW_INDEX}-${spaceId}`], - stackByField, - startDate, - includeMissingData: false, - skip: skip || error != null, - }; - }, [endDate, filterQuery, spaceId, stackByField, startDate, skip, error]); - - return useMatrixHistogramCombined(matrixHistogramRequest); -}; diff --git a/x-pack/plugins/security_solution/public/detections/hooks/alerts_visualization/use_alert_histogram_count.test.tsx b/x-pack/plugins/security_solution/public/detections/hooks/alerts_visualization/use_alert_histogram_count.test.tsx index 76699742e6b8a..d86b9b37c568f 100644 --- a/x-pack/plugins/security_solution/public/detections/hooks/alerts_visualization/use_alert_histogram_count.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/hooks/alerts_visualization/use_alert_histogram_count.test.tsx @@ -10,7 +10,7 @@ import { TestProviders } from '../../../common/mock'; import { useAlertHistogramCount } from './use_alert_histogram_count'; jest.mock('../../../common/components/visualization_actions/use_visualization_response', () => ({ - useVisualizationResponse: jest.fn().mockReturnValue([{ hits: { total: 100 } }]), + useVisualizationResponse: jest.fn().mockReturnValue({ responses: [{ hits: { total: 100 } }] }), })); describe('useAlertHistogramCount', () => { diff --git a/x-pack/plugins/security_solution/public/detections/hooks/alerts_visualization/use_alert_histogram_count.ts b/x-pack/plugins/security_solution/public/detections/hooks/alerts_visualization/use_alert_histogram_count.ts index b16ff08c6e919..39365401a68df 100644 --- a/x-pack/plugins/security_solution/public/detections/hooks/alerts_visualization/use_alert_histogram_count.ts +++ b/x-pack/plugins/security_solution/public/detections/hooks/alerts_visualization/use_alert_histogram_count.ts @@ -24,7 +24,7 @@ export const useAlertHistogramCount = ({ isChartEmbeddablesEnabled: boolean; }): string => { const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); - const visualizationResponse = useVisualizationResponse({ visualizationId }); + const { responses: visualizationResponse } = useVisualizationResponse({ visualizationId }); const totalAlerts = useMemo( () => diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/use_alerts_by_status_visualization_data.test.ts b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/use_alerts_by_status_visualization_data.test.ts index 937dae7024ba4..44ad581103393 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/use_alerts_by_status_visualization_data.test.ts +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/use_alerts_by_status_visualization_data.test.ts @@ -23,9 +23,9 @@ describe('useAlertsByStatusVisualizationData', () => { (useVisualizationResponse as jest.Mock).mockImplementation( ({ visualizationId }: { visualizationId: string }) => { const mockCount = { - [openAlertsVisualizationId]: [{ hits: { total: 10 } }], - [acknowledgedAlertsVisualizationId]: [{ hits: { total: 20 } }], - [closedAlertsVisualizationId]: [{ hits: { total: 30 } }], + [openAlertsVisualizationId]: { responses: [{ hits: { total: 10 } }] }, + [acknowledgedAlertsVisualizationId]: { responses: [{ hits: { total: 20 } }] }, + [closedAlertsVisualizationId]: { responses: [{ hits: { total: 30 } }] }, }; return mockCount[visualizationId]; } diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/use_alerts_by_status_visualization_data.ts b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/use_alerts_by_status_visualization_data.ts index 31ed355c1b475..218d69b4183a7 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/use_alerts_by_status_visualization_data.ts +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/use_alerts_by_status_visualization_data.ts @@ -13,15 +13,15 @@ export const acknowledgedAlertsVisualizationId = `${DETECTION_RESPONSE_ALERTS_BY export const closedAlertsVisualizationId = `${DETECTION_RESPONSE_ALERTS_BY_STATUS_ID}-closed`; export const useAlertsByStatusVisualizationData = () => { - const openAlertsResponse = useVisualizationResponse({ + const { responses: openAlertsResponse } = useVisualizationResponse({ visualizationId: openAlertsVisualizationId, }); - const acknowledgedAlertsResponse = useVisualizationResponse({ + const { responses: acknowledgedAlertsResponse } = useVisualizationResponse({ visualizationId: acknowledgedAlertsVisualizationId, }); - const closedAlertsResponse = useVisualizationResponse({ + const { responses: closedAlertsResponse } = useVisualizationResponse({ visualizationId: closedAlertsVisualizationId, });