({ + isCollapsed: true, + toggle: () => {}, + }) + } + isChartAvailable={undefined} + renderedFor="root" + /> + ), }; const component = mountWithIntl( @@ -128,15 +151,36 @@ describe('Discover main content component', () => { expect(component.find(DiscoverDocuments).prop('viewModeToggle')).toBeDefined(); }); - it('should not show DocumentViewModeToggle when isPlainRecord is true', async () => { + it('should include DocumentViewModeToggle when isPlainRecord is true', async () => { const component = await mountComponent({ isPlainRecord: true }); - expect(component.find(DiscoverDocuments).prop('viewModeToggle')).toBeUndefined(); + expect(component.find(DiscoverDocuments).prop('viewModeToggle')).toBeDefined(); }); it('should show DocumentViewModeToggle for Field Statistics', async () => { const component = await mountComponent({ viewMode: VIEW_MODE.AGGREGATED_LEVEL }); expect(component.find(DocumentViewModeToggle).exists()).toBe(true); }); + + it('should include PanelsToggle when chart is available', async () => { + const component = await mountComponent({ isChartAvailable: true }); + expect(component.find(PanelsToggle).prop('isChartAvailable')).toBe(true); + expect(component.find(PanelsToggle).prop('renderedFor')).toBe('tabs'); + expect(component.find(EuiHorizontalRule).exists()).toBe(true); + }); + + it('should include PanelsToggle when chart is available and hidden', async () => { + const component = await mountComponent({ isChartAvailable: true, hideChart: true }); + expect(component.find(PanelsToggle).prop('isChartAvailable')).toBe(true); + expect(component.find(PanelsToggle).prop('renderedFor')).toBe('tabs'); + expect(component.find(EuiHorizontalRule).exists()).toBe(false); + }); + + it('should include PanelsToggle when chart is not available', async () => { + const component = await mountComponent({ isChartAvailable: false }); + expect(component.find(PanelsToggle).prop('isChartAvailable')).toBe(false); + expect(component.find(PanelsToggle).prop('renderedFor')).toBe('tabs'); + expect(component.find(EuiHorizontalRule).exists()).toBe(false); + }); }); describe('Document view', () => { diff --git a/src/plugins/discover/public/application/main/components/layout/discover_main_content.tsx b/src/plugins/discover/public/application/main/components/layout/discover_main_content.tsx index 8b6ff5880d3dc..07a37e3ba1bc3 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_main_content.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_main_content.tsx @@ -8,7 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; import { DragDrop, type DropType, DropOverlayWrapper } from '@kbn/dom-drag-drop'; -import React, { useCallback, useMemo } from 'react'; +import React, { ReactElement, useCallback, useMemo } from 'react'; import { DataView } from '@kbn/data-views-plugin/common'; import { METRIC_TYPE } from '@kbn/analytics'; import { i18n } from '@kbn/i18n'; @@ -21,6 +21,7 @@ import { FieldStatisticsTab } from '../field_stats_table'; import { DiscoverDocuments } from './discover_documents'; import { DOCUMENTS_VIEW_CLICK, FIELD_STATISTICS_VIEW_CLICK } from '../field_stats_table/constants'; import { useAppStateSelector } from '../../services/discover_app_state_container'; +import type { PanelsToggleProps } from '../../../../components/panels_toggle'; const DROP_PROPS = { value: { @@ -44,6 +45,8 @@ export interface DiscoverMainContentProps { onFieldEdited: () => Promise; onDropFieldToTable?: () => void; columns: string[]; + panelsToggle: ReactElement; + isChartAvailable?: boolean; // it will be injected by UnifiedHistogram } export const DiscoverMainContent = ({ @@ -55,6 +58,8 @@ export const DiscoverMainContent = ({ columns, stateContainer, onDropFieldToTable, + panelsToggle, + isChartAvailable, }: DiscoverMainContentProps) => { const { trackUiMetric } = useDiscoverServices(); @@ -76,10 +81,27 @@ export const DiscoverMainContent = ({ const isDropAllowed = Boolean(onDropFieldToTable); const viewModeToggle = useMemo(() => { - return !isPlainRecord ? ( - - ) : undefined; - }, [viewMode, setDiscoverViewMode, isPlainRecord]); + return ( + + ); + }, [ + viewMode, + setDiscoverViewMode, + isPlainRecord, + stateContainer, + panelsToggle, + isChartAvailable, + ]); const showChart = useAppStateSelector((state) => !state.hideChart); @@ -99,7 +121,7 @@ export const DiscoverMainContent = ({ responsive={false} data-test-subj="dscMainContent" > - {showChart && } + {showChart && isChartAvailable && } {viewMode === VIEW_MODE.DOCUMENT_LEVEL ? ( { const wrapper = mount( ({ + isCollapsed: true, + toggle: () => {}, + }) + } sidebarPanel={
} mainPanel={
} /> @@ -69,7 +74,12 @@ describe('DiscoverResizableLayout', () => { const wrapper = mount( ({ + isCollapsed: true, + toggle: () => {}, + }) + } sidebarPanel={
} mainPanel={
} /> @@ -82,7 +92,12 @@ describe('DiscoverResizableLayout', () => { const wrapper = mount( ({ + isCollapsed: true, + toggle: () => {}, + }) + } sidebarPanel={
} mainPanel={
} /> @@ -95,8 +110,11 @@ describe('DiscoverResizableLayout', () => { const wrapper = mount( ({ + isCollapsed: false, + toggle: () => {}, + }) } sidebarPanel={
} mainPanel={
} @@ -110,8 +128,11 @@ describe('DiscoverResizableLayout', () => { const wrapper = mount( ({ + isCollapsed: false, + toggle: () => {}, + }) } sidebarPanel={
} mainPanel={
} @@ -125,8 +146,11 @@ describe('DiscoverResizableLayout', () => { const wrapper = mount( ({ + isCollapsed: true, + toggle: () => {}, + }) } sidebarPanel={
} mainPanel={
} @@ -140,8 +164,11 @@ describe('DiscoverResizableLayout', () => { const wrapper = mount( ({ + isCollapsed: false, + toggle: () => {}, + }) } sidebarPanel={
} mainPanel={
} @@ -157,8 +184,11 @@ describe('DiscoverResizableLayout', () => { const wrapper = mount( ({ + isCollapsed: false, + toggle: () => {}, + }) } sidebarPanel={
} mainPanel={
} diff --git a/src/plugins/discover/public/application/main/components/layout/discover_resizable_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_resizable_layout.tsx index e0859617f0057..179914b9fb68a 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_resizable_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_resizable_layout.tsx @@ -12,23 +12,23 @@ import { ResizableLayoutDirection, ResizableLayoutMode, } from '@kbn/resizable-layout'; -import type { UnifiedFieldListSidebarContainerApi } from '@kbn/unified-field-list'; import React, { ReactNode, useState } from 'react'; import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; import useLocalStorage from 'react-use/lib/useLocalStorage'; import useObservable from 'react-use/lib/useObservable'; -import { of } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; +import { SidebarToggleState } from '../../../types'; export const SIDEBAR_WIDTH_KEY = 'discover:sidebarWidth'; export const DiscoverResizableLayout = ({ container, - unifiedFieldListSidebarContainerApi, + sidebarToggleState$, sidebarPanel, mainPanel, }: { container: HTMLElement | null; - unifiedFieldListSidebarContainerApi: UnifiedFieldListSidebarContainerApi | null; + sidebarToggleState$: BehaviorSubject; sidebarPanel: ReactNode; mainPanel: ReactNode; }) => { @@ -45,10 +45,9 @@ export const DiscoverResizableLayout = ({ const minMainPanelWidth = euiTheme.base * 30; const [sidebarWidth, setSidebarWidth] = useLocalStorage(SIDEBAR_WIDTH_KEY, defaultSidebarWidth); - const isSidebarCollapsed = useObservable( - unifiedFieldListSidebarContainerApi?.isSidebarCollapsed$ ?? of(true), - true - ); + + const sidebarToggleState = useObservable(sidebarToggleState$); + const isSidebarCollapsed = sidebarToggleState?.isCollapsed ?? false; const isMobile = useIsWithinBreakpoints(['xs', 's']); const layoutMode = diff --git a/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.test.tsx b/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.test.tsx index ae73126afde88..068f21863de6c 100644 --- a/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.test.tsx +++ b/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.test.tsx @@ -261,20 +261,14 @@ describe('useDiscoverHistogram', () => { hook.result.current.ref(api); }); stateContainer.appState.update({ hideChart: true, interval: '1m', breakdownField: 'test' }); - expect(api.setTotalHits).toHaveBeenCalled(); + expect(api.setTotalHits).not.toHaveBeenCalled(); expect(api.setChartHidden).toHaveBeenCalled(); expect(api.setTimeInterval).toHaveBeenCalled(); expect(api.setBreakdownField).toHaveBeenCalled(); - expect(Object.keys(params ?? {})).toEqual([ - 'totalHitsStatus', - 'totalHitsResult', - 'breakdownField', - 'timeInterval', - 'chartHidden', - ]); + expect(Object.keys(params ?? {})).toEqual(['breakdownField', 'timeInterval', 'chartHidden']); }); - it('should exclude totalHitsStatus and totalHitsResult from Unified Histogram state updates after the first load', async () => { + it('should exclude totalHitsStatus and totalHitsResult from Unified Histogram state updates', async () => { const stateContainer = getStateContainer(); const { hook } = await renderUseDiscoverHistogram({ stateContainer }); const containerState = stateContainer.appState.getState(); @@ -290,20 +284,13 @@ describe('useDiscoverHistogram', () => { api.setChartHidden = jest.fn((chartHidden) => { params = { ...params, chartHidden }; }); - api.setTotalHits = jest.fn((p) => { - params = { ...params, ...p }; - }); const subject$ = new BehaviorSubject(state); api.state$ = subject$; act(() => { hook.result.current.ref(api); }); stateContainer.appState.update({ hideChart: true }); - expect(Object.keys(params ?? {})).toEqual([ - 'totalHitsStatus', - 'totalHitsResult', - 'chartHidden', - ]); + expect(Object.keys(params ?? {})).toEqual(['chartHidden']); params = {}; stateContainer.appState.update({ hideChart: false }); act(() => { @@ -434,14 +421,14 @@ describe('useDiscoverHistogram', () => { act(() => { hook.result.current.ref(api); }); - expect(api.refetch).not.toHaveBeenCalled(); + expect(api.refetch).toHaveBeenCalled(); act(() => { savedSearchFetch$.next({ options: { reset: false, fetchMore: false }, searchSessionId: '1234', }); }); - expect(api.refetch).toHaveBeenCalled(); + expect(api.refetch).toHaveBeenCalledTimes(2); }); it('should skip the next refetch when hideChart changes from true to false', async () => { @@ -459,6 +446,7 @@ describe('useDiscoverHistogram', () => { act(() => { hook.result.current.ref(api); }); + expect(api.refetch).toHaveBeenCalled(); act(() => { hook.rerender({ ...initialProps, hideChart: true }); }); @@ -471,7 +459,7 @@ describe('useDiscoverHistogram', () => { searchSessionId: '1234', }); }); - expect(api.refetch).not.toHaveBeenCalled(); + expect(api.refetch).toHaveBeenCalledTimes(1); }); it('should skip the next refetch when fetching more', async () => { @@ -489,13 +477,14 @@ describe('useDiscoverHistogram', () => { act(() => { hook.result.current.ref(api); }); + expect(api.refetch).toHaveBeenCalledTimes(1); act(() => { savedSearchFetch$.next({ options: { reset: false, fetchMore: true }, searchSessionId: '1234', }); }); - expect(api.refetch).not.toHaveBeenCalled(); + expect(api.refetch).toHaveBeenCalledTimes(1); act(() => { savedSearchFetch$.next({ @@ -503,7 +492,7 @@ describe('useDiscoverHistogram', () => { searchSessionId: '1234', }); }); - expect(api.refetch).toHaveBeenCalled(); + expect(api.refetch).toHaveBeenCalledTimes(2); }); }); diff --git a/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts b/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts index 764145d72aac1..871edb89d15aa 100644 --- a/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts +++ b/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts @@ -30,7 +30,6 @@ import { useDiscoverCustomization } from '../../../../customizations'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { getUiActions } from '../../../../kibana_services'; import { FetchStatus } from '../../../types'; -import { useDataState } from '../../hooks/use_data_state'; import type { InspectorAdapters } from '../../hooks/use_inspector'; import { checkHitCount, sendErrorTo } from '../../hooks/use_saved_search_messages'; import type { DiscoverStateContainer } from '../../services/discover_state'; @@ -68,9 +67,6 @@ export const useDiscoverHistogram = ({ breakdownField, } = stateContainer.appState.getState(); - const { fetchStatus: totalHitsStatus, result: totalHitsResult } = - savedSearchData$.totalHits$.getValue(); - return { localStorageKeyPrefix: 'discover', disableAutoFetching: true, @@ -78,11 +74,11 @@ export const useDiscoverHistogram = ({ chartHidden, timeInterval, breakdownField, - totalHitsStatus: totalHitsStatus.toString() as UnifiedHistogramFetchStatus, - totalHitsResult, + totalHitsStatus: UnifiedHistogramFetchStatus.loading, + totalHitsResult: undefined, }, }; - }, [savedSearchData$.totalHits$, stateContainer.appState]); + }, [stateContainer.appState]); /** * Sync Unified Histogram state with Discover state @@ -115,28 +111,6 @@ export const useDiscoverHistogram = ({ }; }, [inspectorAdapters, stateContainer.appState, unifiedHistogram?.state$]); - /** - * Override Unified Histgoram total hits with Discover partial results - */ - - const firstLoadComplete = useRef(false); - - const { fetchStatus: totalHitsStatus, result: totalHitsResult } = useDataState( - savedSearchData$.totalHits$ - ); - - useEffect(() => { - // We only want to show the partial results on the first load, - // or there will be a flickering effect as the loading spinner - // is quickly shown and hidden again on fetches - if (!firstLoadComplete.current) { - unifiedHistogram?.setTotalHits({ - totalHitsStatus: totalHitsStatus.toString() as UnifiedHistogramFetchStatus, - totalHitsResult, - }); - } - }, [totalHitsResult, totalHitsStatus, unifiedHistogram]); - /** * Sync URL query params with Unified Histogram */ @@ -181,7 +155,17 @@ export const useDiscoverHistogram = ({ return; } - const { recordRawType } = savedSearchData$.totalHits$.getValue(); + const { recordRawType, result: totalHitsResult } = savedSearchData$.totalHits$.getValue(); + + if ( + (status === UnifiedHistogramFetchStatus.loading || + status === UnifiedHistogramFetchStatus.uninitialized) && + totalHitsResult && + typeof result !== 'number' + ) { + // ignore the histogram initial loading state if discover state already has a total hits value + return; + } // Sync the totalHits$ observable with the unified histogram state savedSearchData$.totalHits$.next({ @@ -196,10 +180,6 @@ export const useDiscoverHistogram = ({ // Check the hits count to set a partial or no results state checkHitCount(savedSearchData$.main$, result); - - // Indicate the first load has completed so we don't show - // partial results on subsequent fetches - firstLoadComplete.current = true; } ); @@ -317,6 +297,11 @@ export const useDiscoverHistogram = ({ skipRefetch.current = false; }); + // triggering the initial request for total hits hook + if (!isPlainRecord && !skipRefetch.current) { + unifiedHistogram.refetch(); + } + return () => { subscription.unsubscribe(); }; @@ -326,14 +311,24 @@ export const useDiscoverHistogram = ({ const histogramCustomization = useDiscoverCustomization('unified_histogram'); + const servicesMemoized = useMemo(() => ({ ...services, uiActions: getUiActions() }), [services]); + + const filtersMemoized = useMemo( + () => [...(filters ?? []), ...customFilters], + [filters, customFilters] + ); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const timeRangeMemoized = useMemo(() => timeRange, [timeRange?.from, timeRange?.to]); + return { ref, getCreationOptions, - services: { ...services, uiActions: getUiActions() }, + services: servicesMemoized, dataView: isPlainRecord ? textBasedDataView : dataView, query: isPlainRecord ? textBasedQuery : query, - filters: [...(filters ?? []), ...customFilters], - timeRange, + filters: filtersMemoized, + timeRange: timeRangeMemoized, relativeTimeRange, columns, onFilter: histogramCustomization?.onFilter, diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx index c4558f4590c5b..0e5e9838f420b 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx @@ -13,13 +13,13 @@ import { EuiProgress } from '@elastic/eui'; import { getDataTableRecords, realHits } from '../../../../__fixtures__/real_hits'; import { act } from 'react-dom/test-utils'; import { mountWithIntl } from '@kbn/test-jest-helpers'; -import React, { useState } from 'react'; +import React from 'react'; import { DiscoverSidebarResponsive, DiscoverSidebarResponsiveProps, } from './discover_sidebar_responsive'; import { DiscoverServices } from '../../../../build_services'; -import { FetchStatus } from '../../../types'; +import { FetchStatus, SidebarToggleState } from '../../../types'; import { AvailableFields$, DataDocuments$, @@ -37,7 +37,6 @@ import { buildDataTableRecord } from '@kbn/discover-utils'; import type { DataTableRecord } from '@kbn/discover-utils/types'; import type { DiscoverCustomizationId } from '../../../../customizations/customization_service'; import type { SearchBarCustomization } from '../../../../customizations'; -import type { UnifiedFieldListSidebarContainerApi } from '@kbn/unified-field-list'; const mockSearchBarCustomization: SearchBarCustomization = { id: 'search_bar', @@ -169,8 +168,10 @@ function getCompProps(options?: { hits?: DataTableRecord[] }): DiscoverSidebarRe trackUiMetric: jest.fn(), onFieldEdited: jest.fn(), onDataViewCreated: jest.fn(), - unifiedFieldListSidebarContainerApi: null, - setUnifiedFieldListSidebarContainerApi: jest.fn(), + sidebarToggleState$: new BehaviorSubject({ + isCollapsed: false, + toggle: () => {}, + }), }; } @@ -202,21 +203,10 @@ async function mountComponent( mockedServices.data.query.getState = jest.fn().mockImplementation(() => appState.getState()); await act(async () => { - const SidebarWrapper = () => { - const [api, setApi] = useState(null); - return ( - - ); - }; - comp = mountWithIntl( - + ); diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx index 3177adefdf49b..b820b63b461b3 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx @@ -6,9 +6,13 @@ * Side Public License, v 1. */ -import React, { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import { UiCounterMetricType } from '@kbn/analytics'; import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import { EuiFlexGroup, EuiFlexItem, EuiHideFor, useEuiTheme } from '@elastic/eui'; +import useObservable from 'react-use/lib/useObservable'; +import { BehaviorSubject, of } from 'rxjs'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; import { DataViewPicker } from '@kbn/unified-search-plugin/public'; import { @@ -25,7 +29,7 @@ import { RecordRawType, } from '../../services/discover_data_state_container'; import { calcFieldCounts } from '../../utils/calc_field_counts'; -import { FetchStatus } from '../../../types'; +import { FetchStatus, SidebarToggleState } from '../../../types'; import { DISCOVER_TOUR_STEP_ANCHOR_IDS } from '../../../../components/discover_tour'; import { getUiActions } from '../../../../kibana_services'; import { @@ -134,8 +138,7 @@ export interface DiscoverSidebarResponsiveProps { */ fieldListVariant?: UnifiedFieldListSidebarContainerProps['variant']; - unifiedFieldListSidebarContainerApi: UnifiedFieldListSidebarContainerApi | null; - setUnifiedFieldListSidebarContainerApi: (api: UnifiedFieldListSidebarContainerApi) => void; + sidebarToggleState$: BehaviorSubject; } /** @@ -144,6 +147,9 @@ export interface DiscoverSidebarResponsiveProps { * Mobile: Data view selector is visible and a button to trigger a flyout with all elements */ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) { + const [unifiedFieldListSidebarContainerApi, setUnifiedFieldListSidebarContainerApi] = + useState(null); + const { euiTheme } = useEuiTheme(); const services = useDiscoverServices(); const { fieldListVariant, @@ -156,8 +162,7 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) onChangeDataView, onAddField, onRemoveField, - unifiedFieldListSidebarContainerApi, - setUnifiedFieldListSidebarContainerApi, + sidebarToggleState$, } = props; const [sidebarState, dispatchSidebarStateAction] = useReducer( discoverSidebarReducer, @@ -373,27 +378,55 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) [onRemoveField] ); - if (!selectedDataView) { - return null; - } + const isSidebarCollapsed = useObservable( + unifiedFieldListSidebarContainerApi?.sidebarVisibility.isCollapsed$ ?? of(false), + false + ); + + useEffect(() => { + sidebarToggleState$.next({ + isCollapsed: isSidebarCollapsed, + toggle: unifiedFieldListSidebarContainerApi?.sidebarVisibility.toggle, + }); + }, [isSidebarCollapsed, unifiedFieldListSidebarContainerApi, sidebarToggleState$]); return ( - + + + {selectedDataView ? ( + + ) : null} + + + + + ); } diff --git a/src/plugins/discover/public/application/main/services/discover_saved_search_container.ts b/src/plugins/discover/public/application/main/services/discover_saved_search_container.ts index 422fa787da849..ef88aba74d7db 100644 --- a/src/plugins/discover/public/application/main/services/discover_saved_search_container.ts +++ b/src/plugins/discover/public/application/main/services/discover_saved_search_container.ts @@ -318,7 +318,9 @@ function getSearchSourceFieldValueForComparison( searchSourceFieldName: keyof SearchSourceFields ) { if (searchSourceFieldName === 'index') { - return searchSource.getField('index')?.id; + const query = searchSource.getField('query'); + // ad-hoc data view id can change, so we rather compare the ES|QL query itself here + return query && 'esql' in query ? query.esql : searchSource.getField('index')?.id; } if (searchSourceFieldName === 'filter') { diff --git a/src/plugins/discover/public/application/main/utils/fetch_all.ts b/src/plugins/discover/public/application/main/utils/fetch_all.ts index 289ad9e336b04..16f2a1c50de56 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_all.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_all.ts @@ -89,7 +89,7 @@ export function fetchAll( // Mark all subjects as loading sendLoadingMsg(dataSubjects.main$, { recordRawType }); sendLoadingMsg(dataSubjects.documents$, { recordRawType, query }); - sendLoadingMsg(dataSubjects.totalHits$, { recordRawType }); + // histogram will send `loading` for totalHits$ // Start fetching all required requests const response = @@ -116,9 +116,12 @@ export function fetchAll( meta: { fetchType }, }); } + + const currentTotalHits = dataSubjects.totalHits$.getValue(); // If the total hits (or chart) query is still loading, emit a partial // hit count that's at least our retrieved document count - if (dataSubjects.totalHits$.getValue().fetchStatus === FetchStatus.LOADING) { + if (currentTotalHits.fetchStatus === FetchStatus.LOADING && !currentTotalHits.result) { + // trigger `partial` only for the first request (if no total hits value yet) dataSubjects.totalHits$.next({ fetchStatus: FetchStatus.PARTIAL, result: records.length, diff --git a/src/plugins/discover/public/application/types.ts b/src/plugins/discover/public/application/types.ts index 70773d2db521f..d3f8ccd8f990d 100644 --- a/src/plugins/discover/public/application/types.ts +++ b/src/plugins/discover/public/application/types.ts @@ -38,3 +38,8 @@ export interface RecordsFetchResponse { textBasedHeaderWarning?: string; interceptedWarnings?: SearchResponseWarning[]; } + +export interface SidebarToggleState { + isCollapsed: boolean; + toggle: undefined | ((isCollapsed: boolean) => void); +} diff --git a/src/plugins/discover/public/components/hits_counter/hits_counter.test.tsx b/src/plugins/discover/public/components/hits_counter/hits_counter.test.tsx new file mode 100644 index 0000000000000..8d84cdcef5a0c --- /dev/null +++ b/src/plugins/discover/public/components/hits_counter/hits_counter.test.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { mountWithIntl } from '@kbn/test-jest-helpers'; +import { HitsCounter, HitsCounterMode } from './hits_counter'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { BehaviorSubject } from 'rxjs'; +import { getDiscoverStateMock } from '../../__mocks__/discover_state.mock'; +import { DataTotalHits$ } from '../../application/main/services/discover_data_state_container'; +import { FetchStatus } from '../../application/types'; + +describe('hits counter', function () { + it('expect to render the number of hits', function () { + const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({ + fetchStatus: FetchStatus.COMPLETE, + result: 1, + }) as DataTotalHits$; + const component1 = mountWithIntl( + + ); + expect(findTestSubject(component1, 'discoverQueryHits').text()).toBe('1'); + expect(findTestSubject(component1, 'discoverQueryTotalHits').text()).toBe('1'); + expect(component1.find('[data-test-subj="discoverQueryHits"]').length).toBe(1); + + const component2 = mountWithIntl( + + ); + expect(findTestSubject(component2, 'discoverQueryHits').text()).toBe('1'); + expect(findTestSubject(component2, 'discoverQueryTotalHits').text()).toBe('1 result'); + expect(component2.find('[data-test-subj="discoverQueryHits"]').length).toBe(1); + }); + + it('expect to render 1,899 hits if 1899 hits given', function () { + const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({ + fetchStatus: FetchStatus.COMPLETE, + result: 1899, + }) as DataTotalHits$; + const component1 = mountWithIntl( + + ); + expect(findTestSubject(component1, 'discoverQueryHits').text()).toBe('1,899'); + expect(findTestSubject(component1, 'discoverQueryTotalHits').text()).toBe('1,899'); + + const component2 = mountWithIntl( + + ); + expect(findTestSubject(component2, 'discoverQueryHits').text()).toBe('1,899'); + expect(findTestSubject(component2, 'discoverQueryTotalHits').text()).toBe('1,899 results'); + }); + + it('should render a EuiLoadingSpinner when status is partial', () => { + const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({ + fetchStatus: FetchStatus.PARTIAL, + result: 2, + }) as DataTotalHits$; + const component = mountWithIntl( + + ); + expect(component.find(EuiLoadingSpinner).length).toBe(1); + }); + + it('should render discoverQueryHitsPartial when status is partial', () => { + const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({ + fetchStatus: FetchStatus.PARTIAL, + result: 2, + }) as DataTotalHits$; + const component = mountWithIntl( + + ); + expect(component.find('[data-test-subj="discoverQueryHitsPartial"]').length).toBe(1); + expect(findTestSubject(component, 'discoverQueryTotalHits').text()).toBe('≥2 results'); + }); + + it('should not render if loading', () => { + const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({ + fetchStatus: FetchStatus.LOADING, + result: undefined, + }) as DataTotalHits$; + const component = mountWithIntl( + + ); + expect(component.isEmptyRender()).toBe(true); + }); +}); diff --git a/src/plugins/discover/public/components/hits_counter/hits_counter.tsx b/src/plugins/discover/public/components/hits_counter/hits_counter.tsx new file mode 100644 index 0000000000000..be3e819a5e073 --- /dev/null +++ b/src/plugins/discover/public/components/hits_counter/hits_counter.tsx @@ -0,0 +1,117 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiText, EuiLoadingSpinner } from '@elastic/eui'; +import { FormattedMessage, FormattedNumber } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import type { DiscoverStateContainer } from '../../application/main/services/discover_state'; +import { FetchStatus } from '../../application/types'; +import { useDataState } from '../../application/main/hooks/use_data_state'; + +export enum HitsCounterMode { + standalone = 'standalone', + appended = 'appended', +} + +export interface HitsCounterProps { + mode: HitsCounterMode; + stateContainer: DiscoverStateContainer; +} + +export const HitsCounter: React.FC = ({ mode, stateContainer }) => { + const totalHits$ = stateContainer.dataState.data$.totalHits$; + const totalHitsState = useDataState(totalHits$); + const hitsTotal = totalHitsState.result; + const hitsStatus = totalHitsState.fetchStatus; + + if (!hitsTotal && hitsStatus === FetchStatus.LOADING) { + return null; + } + + const formattedHits = ( + + + + ); + + const hitsCounterCss = css` + display: inline-flex; + `; + const hitsCounterTextCss = css` + overflow: hidden; + `; + + const element = ( + + + + + {hitsStatus === FetchStatus.PARTIAL && + (mode === HitsCounterMode.standalone ? ( + + ) : ( + + ))} + {hitsStatus !== FetchStatus.PARTIAL && + (mode === HitsCounterMode.standalone ? ( + + ) : ( + formattedHits + ))} + + + + {hitsStatus === FetchStatus.PARTIAL && ( + + + + )} + + ); + + return mode === HitsCounterMode.appended ? ( + <> + {' ('} + {element} + {')'} + + ) : ( + element + ); +}; diff --git a/src/plugins/discover/public/components/hits_counter/index.ts b/src/plugins/discover/public/components/hits_counter/index.ts new file mode 100644 index 0000000000000..8d7f69c3af275 --- /dev/null +++ b/src/plugins/discover/public/components/hits_counter/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { HitsCounter, HitsCounterMode } from './hits_counter'; diff --git a/src/plugins/discover/public/components/panels_toggle/index.ts b/src/plugins/discover/public/components/panels_toggle/index.ts new file mode 100644 index 0000000000000..7586567d3665c --- /dev/null +++ b/src/plugins/discover/public/components/panels_toggle/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { PanelsToggle, type PanelsToggleProps } from './panels_toggle'; diff --git a/src/plugins/discover/public/components/panels_toggle/panels_toggle.test.tsx b/src/plugins/discover/public/components/panels_toggle/panels_toggle.test.tsx new file mode 100644 index 0000000000000..54a41fbb9255b --- /dev/null +++ b/src/plugins/discover/public/components/panels_toggle/panels_toggle.test.tsx @@ -0,0 +1,206 @@ +/* + * 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 { mountWithIntl } from '@kbn/test-jest-helpers'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { BehaviorSubject } from 'rxjs'; +import { getDiscoverStateMock } from '../../__mocks__/discover_state.mock'; +import { PanelsToggle, type PanelsToggleProps } from './panels_toggle'; +import { DiscoverAppStateProvider } from '../../application/main/services/discover_app_state_container'; +import { SidebarToggleState } from '../../application/types'; + +describe('Panels toggle component', () => { + const mountComponent = ({ + sidebarToggleState$, + isChartAvailable, + renderedFor, + hideChart, + }: Omit & { hideChart: boolean }) => { + const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + const appStateContainer = stateContainer.appState; + appStateContainer.set({ + hideChart, + }); + + return mountWithIntl( + + + + ); + }; + + describe('inside histogram toolbar', function () { + it('should render correctly when sidebar is visible and histogram is visible', () => { + const sidebarToggleState$ = new BehaviorSubject({ + isCollapsed: false, + toggle: jest.fn(), + }); + const component = mountComponent({ + hideChart: false, + isChartAvailable: undefined, + renderedFor: 'histogram', + sidebarToggleState$, + }); + expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(false); + expect(findTestSubject(component, 'dscHideHistogramButton').exists()).toBe(true); + }); + + it('should render correctly when sidebar is collapsed and histogram is visible', () => { + const sidebarToggleState$ = new BehaviorSubject({ + isCollapsed: true, + toggle: jest.fn(), + }); + const component = mountComponent({ + hideChart: false, + isChartAvailable: undefined, + renderedFor: 'histogram', + sidebarToggleState$, + }); + expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(true); + expect(findTestSubject(component, 'dscHideHistogramButton').exists()).toBe(true); + + findTestSubject(component, 'dscShowSidebarButton').simulate('click'); + + expect(sidebarToggleState$.getValue().toggle).toHaveBeenCalledWith(false); + }); + }); + + describe('inside view mode tabs', function () { + it('should render correctly when sidebar is visible and histogram is visible', () => { + const sidebarToggleState$ = new BehaviorSubject({ + isCollapsed: false, + toggle: jest.fn(), + }); + const component = mountComponent({ + hideChart: false, + isChartAvailable: true, + renderedFor: 'tabs', + sidebarToggleState$, + }); + expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(false); + expect(findTestSubject(component, 'dscHideHistogramButton').exists()).toBe(false); + expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(false); + }); + + it('should render correctly when sidebar is visible and histogram is visible but chart is not available', () => { + const sidebarToggleState$ = new BehaviorSubject({ + isCollapsed: false, + toggle: jest.fn(), + }); + const component = mountComponent({ + hideChart: false, + isChartAvailable: false, + renderedFor: 'tabs', + sidebarToggleState$, + }); + expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(false); + expect(findTestSubject(component, 'dscHideHistogramButton').exists()).toBe(false); + expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(false); + }); + + it('should render correctly when sidebar is hidden and histogram is visible', () => { + const sidebarToggleState$ = new BehaviorSubject({ + isCollapsed: true, + toggle: jest.fn(), + }); + const component = mountComponent({ + hideChart: false, + isChartAvailable: true, + renderedFor: 'tabs', + sidebarToggleState$, + }); + expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(false); + expect(findTestSubject(component, 'dscHideHistogramButton').exists()).toBe(false); + expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(false); + }); + + it('should render correctly when sidebar is hidden and histogram is visible but chart is not available', () => { + const sidebarToggleState$ = new BehaviorSubject({ + isCollapsed: true, + toggle: jest.fn(), + }); + const component = mountComponent({ + hideChart: false, + isChartAvailable: false, + renderedFor: 'tabs', + sidebarToggleState$, + }); + expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(true); + expect(findTestSubject(component, 'dscHideHistogramButton').exists()).toBe(false); + expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(false); + }); + + it('should render correctly when sidebar is visible and histogram is hidden', () => { + const sidebarToggleState$ = new BehaviorSubject({ + isCollapsed: false, + toggle: jest.fn(), + }); + const component = mountComponent({ + hideChart: true, + isChartAvailable: true, + renderedFor: 'tabs', + sidebarToggleState$, + }); + expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(false); + expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(true); + }); + + it('should render correctly when sidebar is visible and histogram is hidden but chart is not available', () => { + const sidebarToggleState$ = new BehaviorSubject({ + isCollapsed: false, + toggle: jest.fn(), + }); + const component = mountComponent({ + hideChart: true, + isChartAvailable: false, + renderedFor: 'tabs', + sidebarToggleState$, + }); + expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(false); + expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(false); + expect(findTestSubject(component, 'dscHideHistogramButton').exists()).toBe(false); + }); + + it('should render correctly when sidebar is hidden and histogram is hidden', () => { + const sidebarToggleState$ = new BehaviorSubject({ + isCollapsed: true, + toggle: jest.fn(), + }); + const component = mountComponent({ + hideChart: true, + isChartAvailable: true, + renderedFor: 'tabs', + sidebarToggleState$, + }); + expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(true); + expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(true); + }); + + it('should render correctly when sidebar is hidden and histogram is hidden but chart is not available', () => { + const sidebarToggleState$ = new BehaviorSubject({ + isCollapsed: true, + toggle: jest.fn(), + }); + const component = mountComponent({ + hideChart: true, + isChartAvailable: false, + renderedFor: 'tabs', + sidebarToggleState$, + }); + expect(findTestSubject(component, 'dscShowSidebarButton').exists()).toBe(true); + expect(findTestSubject(component, 'dscShowHistogramButton').exists()).toBe(false); + expect(findTestSubject(component, 'dscHideHistogramButton').exists()).toBe(false); + }); + }); +}); diff --git a/src/plugins/discover/public/components/panels_toggle/panels_toggle.tsx b/src/plugins/discover/public/components/panels_toggle/panels_toggle.tsx new file mode 100644 index 0000000000000..bd04823affd80 --- /dev/null +++ b/src/plugins/discover/public/components/panels_toggle/panels_toggle.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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, { useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import useObservable from 'react-use/lib/useObservable'; +import { BehaviorSubject } from 'rxjs'; +import { IconButtonGroup } from '@kbn/shared-ux-button-toolbar'; +import { useAppStateSelector } from '../../application/main/services/discover_app_state_container'; +import { DiscoverStateContainer } from '../../application/main/services/discover_state'; +import { SidebarToggleState } from '../../application/types'; + +export interface PanelsToggleProps { + stateContainer: DiscoverStateContainer; + sidebarToggleState$: BehaviorSubject; + renderedFor: 'histogram' | 'prompt' | 'tabs' | 'root'; + isChartAvailable: boolean | undefined; // it will be injected in `DiscoverMainContent` when rendering View mode tabs or in `DiscoverLayout` when rendering No results or Error prompt +} + +/** + * An element of this component is created in DiscoverLayout + * @param stateContainer + * @param sidebarToggleState$ + * @param renderedIn + * @param isChartAvailable + * @constructor + */ +export const PanelsToggle: React.FC = ({ + stateContainer, + sidebarToggleState$, + renderedFor, + isChartAvailable, +}) => { + const isChartHidden = useAppStateSelector((state) => Boolean(state.hideChart)); + + const onToggleChart = useCallback(() => { + stateContainer.appState.update({ hideChart: !isChartHidden }); + }, [stateContainer, isChartHidden]); + + const sidebarToggleState = useObservable(sidebarToggleState$); + const isSidebarCollapsed = sidebarToggleState?.isCollapsed ?? false; + + const isInsideHistogram = renderedFor === 'histogram'; + const isInsideDiscoverContent = !isInsideHistogram; + + const buttons = [ + ...((isInsideHistogram && isSidebarCollapsed) || + (isInsideDiscoverContent && isSidebarCollapsed && (isChartHidden || !isChartAvailable)) + ? [ + { + label: i18n.translate('discover.panelsToggle.showSidebarButton', { + defaultMessage: 'Show sidebar', + }), + iconType: 'transitionLeftIn', + 'data-test-subj': 'dscShowSidebarButton', + 'aria-expanded': !isSidebarCollapsed, + 'aria-controls': 'discover-sidebar', + onClick: () => sidebarToggleState?.toggle?.(false), + }, + ] + : []), + ...(isInsideHistogram || (isInsideDiscoverContent && isChartAvailable && isChartHidden) + ? [ + { + label: isChartHidden + ? i18n.translate('discover.panelsToggle.showChartButton', { + defaultMessage: 'Show chart', + }) + : i18n.translate('discover.panelsToggle.hideChartButton', { + defaultMessage: 'Hide chart', + }), + iconType: isChartHidden ? 'transitionTopIn' : 'transitionTopOut', + 'data-test-subj': isChartHidden ? 'dscShowHistogramButton' : 'dscHideHistogramButton', + 'aria-expanded': !isChartHidden, + 'aria-controls': 'unifiedHistogramCollapsablePanel', + onClick: onToggleChart, + }, + ] + : []), + ]; + + if (!buttons.length) { + return null; + } + + return ( + + ); +}; diff --git a/src/plugins/discover/public/components/view_mode_toggle/view_mode_toggle.test.tsx b/src/plugins/discover/public/components/view_mode_toggle/view_mode_toggle.test.tsx index 7c17e5e1a31ef..e1788389d3caf 100644 --- a/src/plugins/discover/public/components/view_mode_toggle/view_mode_toggle.test.tsx +++ b/src/plugins/discover/public/components/view_mode_toggle/view_mode_toggle.test.tsx @@ -11,12 +11,18 @@ import { VIEW_MODE } from '../../../common/constants'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import React from 'react'; +import { findTestSubject } from '@elastic/eui/lib/test'; import { DocumentViewModeToggle } from './view_mode_toggle'; +import { BehaviorSubject } from 'rxjs'; +import { getDiscoverStateMock } from '../../__mocks__/discover_state.mock'; +import { DataTotalHits$ } from '../../application/main/services/discover_data_state_container'; +import { FetchStatus } from '../../application/types'; describe('Document view mode toggle component', () => { const mountComponent = ({ showFieldStatistics = true, viewMode = VIEW_MODE.DOCUMENT_LEVEL, + isTextBasedQuery = false, setDiscoverViewMode = jest.fn(), } = {}) => { const serivces = { @@ -25,21 +31,40 @@ describe('Document view mode toggle component', () => { }, }; + const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({ + fetchStatus: FetchStatus.COMPLETE, + result: 10, + }) as DataTotalHits$; + return mountWithIntl( - + ); }; it('should render if SHOW_FIELD_STATISTICS is true', () => { const component = mountComponent(); - expect(component.isEmptyRender()).toBe(false); + expect(findTestSubject(component, 'dscViewModeToggle').exists()).toBe(true); + expect(findTestSubject(component, 'discoverQueryTotalHits').exists()).toBe(true); }); it('should not render if SHOW_FIELD_STATISTICS is false', () => { const component = mountComponent({ showFieldStatistics: false }); - expect(component.isEmptyRender()).toBe(true); + expect(findTestSubject(component, 'dscViewModeToggle').exists()).toBe(false); + expect(findTestSubject(component, 'discoverQueryTotalHits').exists()).toBe(true); + }); + + it('should not render if text-based', () => { + const component = mountComponent({ isTextBasedQuery: true }); + expect(findTestSubject(component, 'dscViewModeToggle').exists()).toBe(false); + expect(findTestSubject(component, 'discoverQueryTotalHits').exists()).toBe(true); }); it('should set the view mode to VIEW_MODE.DOCUMENT_LEVEL when dscViewModeDocumentButton is clicked', () => { diff --git a/src/plugins/discover/public/components/view_mode_toggle/view_mode_toggle.tsx b/src/plugins/discover/public/components/view_mode_toggle/view_mode_toggle.tsx index 79c9213e76395..147486ac6dc6e 100644 --- a/src/plugins/discover/public/components/view_mode_toggle/view_mode_toggle.tsx +++ b/src/plugins/discover/public/components/view_mode_toggle/view_mode_toggle.tsx @@ -6,19 +6,27 @@ * Side Public License, v 1. */ -import React, { useMemo } from 'react'; -import { EuiTab, EuiTabs, useEuiTheme } from '@elastic/eui'; +import React, { useMemo, ReactElement } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiTab, EuiTabs, useEuiTheme } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/react'; import { DOC_TABLE_LEGACY, SHOW_FIELD_STATISTICS } from '@kbn/discover-utils'; import { VIEW_MODE } from '../../../common/constants'; import { useDiscoverServices } from '../../hooks/use_discover_services'; +import { DiscoverStateContainer } from '../../application/main/services/discover_state'; +import { HitsCounter, HitsCounterMode } from '../hits_counter'; export const DocumentViewModeToggle = ({ viewMode, + isTextBasedQuery, + prepend, + stateContainer, setDiscoverViewMode, }: { viewMode: VIEW_MODE; + isTextBasedQuery: boolean; + prepend?: ReactElement; + stateContainer: DiscoverStateContainer; setDiscoverViewMode: (viewMode: VIEW_MODE) => void; }) => { const { euiTheme } = useEuiTheme(); @@ -26,10 +34,12 @@ export const DocumentViewModeToggle = ({ const isLegacy = useMemo(() => uiSettings.get(DOC_TABLE_LEGACY), [uiSettings]); const includesNormalTabsStyle = viewMode === VIEW_MODE.AGGREGATED_LEVEL || isLegacy; - const tabsPadding = includesNormalTabsStyle ? euiTheme.size.s : 0; - const tabsCss = css` - padding: ${tabsPadding} ${tabsPadding} 0 ${tabsPadding}; + const containerPadding = includesNormalTabsStyle ? euiTheme.size.s : 0; + const containerCss = css` + padding: ${containerPadding} ${containerPadding} 0 ${containerPadding}; + `; + const tabsCss = css` .euiTab__content { line-height: ${euiTheme.size.xl}; } @@ -37,29 +47,52 @@ export const DocumentViewModeToggle = ({ const showViewModeToggle = uiSettings.get(SHOW_FIELD_STATISTICS) ?? false; - if (!showViewModeToggle) { - return null; - } - return ( - - setDiscoverViewMode(VIEW_MODE.DOCUMENT_LEVEL)} - data-test-subj="dscViewModeDocumentButton" - > - - - setDiscoverViewMode(VIEW_MODE.AGGREGATED_LEVEL)} - data-test-subj="dscViewModeFieldStatsButton" - > - - - + + {prepend && ( + + {prepend} + + )} + + {isTextBasedQuery || !showViewModeToggle ? ( + + ) : ( + + setDiscoverViewMode(VIEW_MODE.DOCUMENT_LEVEL)} + data-test-subj="dscViewModeDocumentButton" + > + + + + setDiscoverViewMode(VIEW_MODE.AGGREGATED_LEVEL)} + data-test-subj="dscViewModeFieldStatsButton" + > + + + + )} + + ); }; diff --git a/src/plugins/discover/tsconfig.json b/src/plugins/discover/tsconfig.json index fe06c93232460..b75f27c9266f8 100644 --- a/src/plugins/discover/tsconfig.json +++ b/src/plugins/discover/tsconfig.json @@ -78,6 +78,7 @@ "@kbn/rule-data-utils", "@kbn/core-chrome-browser", "@kbn/core-plugins-server", + "@kbn/shared-ux-button-toolbar", "@kbn/serverless", "@kbn/deeplinks-observability" ], diff --git a/src/plugins/expressions/common/execution/execution.ts b/src/plugins/expressions/common/execution/execution.ts index 593e437fd9dc7..03a0923b4b313 100644 --- a/src/plugins/expressions/common/execution/execution.ts +++ b/src/plugins/expressions/common/execution/execution.ts @@ -287,6 +287,7 @@ export class Execution< isSyncColorsEnabled: () => execution.params.syncColors!, isSyncCursorEnabled: () => execution.params.syncCursor!, isSyncTooltipsEnabled: () => execution.params.syncTooltips!, + shouldUseSizeTransitionVeil: () => execution.params.shouldUseSizeTransitionVeil!, ...execution.executor.context, getExecutionContext: () => execution.params.executionContext, }; diff --git a/src/plugins/expressions/common/execution/types.ts b/src/plugins/expressions/common/execution/types.ts index 03dbcc8a6ff13..ac216515a3f1b 100644 --- a/src/plugins/expressions/common/execution/types.ts +++ b/src/plugins/expressions/common/execution/types.ts @@ -72,6 +72,11 @@ export interface ExecutionContext */ isSyncTooltipsEnabled?: () => boolean; + /** + * Returns whether or not to use the size transition veil when resizing visualizations. + */ + shouldUseSizeTransitionVeil?: () => boolean; + /** * Contains the meta-data about the source of the expression. */ diff --git a/src/plugins/expressions/common/expression_renderers/types.ts b/src/plugins/expressions/common/expression_renderers/types.ts index 7dae307aa6c01..46908e8b38e6e 100644 --- a/src/plugins/expressions/common/expression_renderers/types.ts +++ b/src/plugins/expressions/common/expression_renderers/types.ts @@ -97,6 +97,9 @@ export interface IInterpreterRenderHandlers { isSyncCursorEnabled(): boolean; isSyncTooltipsEnabled(): boolean; + + shouldUseSizeTransitionVeil(): boolean; + /** * This uiState interface is actually `PersistedState` from the visualizations plugin, * but expressions cannot know about vis or it creates a mess of circular dependencies. diff --git a/src/plugins/expressions/common/service/expressions_services.ts b/src/plugins/expressions/common/service/expressions_services.ts index e73e07a387c46..2683921bc038b 100644 --- a/src/plugins/expressions/common/service/expressions_services.ts +++ b/src/plugins/expressions/common/service/expressions_services.ts @@ -156,6 +156,11 @@ export interface ExpressionExecutionParams { syncTooltips?: boolean; + // if this is set to true, a veil will be shown when resizing visualizations in response + // to a chart resize event (see src/plugins/chart_expressions/common/chart_size_transition_veil.tsx). + // This should be only set to true if the client will be responding to the resize events + shouldUseSizeTransitionVeil?: boolean; + inspectorAdapters?: Adapters; executionContext?: KibanaExecutionContext; diff --git a/src/plugins/expressions/public/loader.ts b/src/plugins/expressions/public/loader.ts index f10b8db1f1287..0a3c0e0990645 100644 --- a/src/plugins/expressions/public/loader.ts +++ b/src/plugins/expressions/public/loader.ts @@ -60,6 +60,7 @@ export class ExpressionLoader { syncColors: params?.syncColors, syncTooltips: params?.syncTooltips, syncCursor: params?.syncCursor, + shouldUseSizeTransitionVeil: params?.shouldUseSizeTransitionVeil, hasCompatibleActions: params?.hasCompatibleActions, getCompatibleCellValueActions: params?.getCompatibleCellValueActions, executionContext: params?.executionContext, @@ -148,6 +149,7 @@ export class ExpressionLoader { syncColors: params.syncColors, syncCursor: params?.syncCursor, syncTooltips: params.syncTooltips, + shouldUseSizeTransitionVeil: params.shouldUseSizeTransitionVeil, executionContext: params.executionContext, partial: params.partial, throttle: params.throttle, diff --git a/src/plugins/expressions/public/render.ts b/src/plugins/expressions/public/render.ts index a7b919625b8d6..0b494f30b2e69 100644 --- a/src/plugins/expressions/public/render.ts +++ b/src/plugins/expressions/public/render.ts @@ -33,6 +33,7 @@ export interface ExpressionRenderHandlerParams { syncCursor?: boolean; syncTooltips?: boolean; interactive?: boolean; + shouldUseSizeTransitionVeil?: boolean; hasCompatibleActions?: (event: ExpressionRendererEvent) => Promise; getCompatibleCellValueActions?: (data: object[]) => Promise; executionContext?: KibanaExecutionContext; @@ -62,6 +63,7 @@ export class ExpressionRenderHandler { syncColors, syncTooltips, syncCursor, + shouldUseSizeTransitionVeil, interactive, hasCompatibleActions = async () => false, getCompatibleCellValueActions = async () => [], @@ -113,6 +115,9 @@ export class ExpressionRenderHandler { isSyncCursorEnabled: () => { return syncCursor || true; }, + shouldUseSizeTransitionVeil: () => { + return Boolean(shouldUseSizeTransitionVeil); + }, isInteractive: () => { return interactive ?? true; }, diff --git a/src/plugins/expressions/public/types/index.ts b/src/plugins/expressions/public/types/index.ts index 7bbb486fde390..27090f36fdc7c 100644 --- a/src/plugins/expressions/public/types/index.ts +++ b/src/plugins/expressions/public/types/index.ts @@ -52,6 +52,10 @@ export interface IExpressionLoaderParams { syncColors?: boolean; syncCursor?: boolean; syncTooltips?: boolean; + // if this is set to true, a veil will be shown when resizing visualizations in response + // to a chart resize event (see src/plugins/chart_expressions/common/chart_size_transition_veil.tsx). + // This should be only set to true if the client will be responding to the resize events + shouldUseSizeTransitionVeil?: boolean; hasCompatibleActions?: ExpressionRenderHandlerParams['hasCompatibleActions']; getCompatibleCellValueActions?: ExpressionRenderHandlerParams['getCompatibleCellValueActions']; executionContext?: KibanaExecutionContext; diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index b2e2c5ec3f748..5e49b09b01d24 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -449,6 +449,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'observability:apmEnableTableSearchBar': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'observability:apmAWSLambdaPriceFactor': { type: 'text', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index e1de9aa7842d5..a1125a6d118b8 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -48,6 +48,7 @@ export interface UsageStats { 'observability:enableInfrastructureHostsView': boolean; 'observability:enableInfrastructureProfilingIntegration': boolean; 'observability:apmAgentExplorerView': boolean; + 'observability:apmEnableTableSearchBar': boolean; 'visualization:heatmap:maxBuckets': number; 'visualization:colorMapping': string; 'visualization:useLegacyTimeAxis': boolean; diff --git a/src/plugins/presentation_util/public/__stories__/render.tsx b/src/plugins/presentation_util/public/__stories__/render.tsx index ca9f968842270..e02f1c803d332 100644 --- a/src/plugins/presentation_util/public/__stories__/render.tsx +++ b/src/plugins/presentation_util/public/__stories__/render.tsx @@ -18,6 +18,7 @@ export const defaultHandlers: IInterpreterRenderHandlers = { isSyncColorsEnabled: () => false, isSyncCursorEnabled: () => true, isSyncTooltipsEnabled: () => false, + shouldUseSizeTransitionVeil: () => false, isInteractive: () => true, getExecutionContext: () => undefined, done: action('done'), diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index e04e83dc46feb..7e5a19d41d3d3 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -9863,6 +9863,12 @@ "description": "Non-default value of setting." } }, + "observability:apmEnableTableSearchBar": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "observability:apmAWSLambdaPriceFactor": { "type": "text", "_meta": { diff --git a/src/plugins/unified_histogram/README.md b/src/plugins/unified_histogram/README.md index 229af7851d8a3..4509f28a7a61e 100755 --- a/src/plugins/unified_histogram/README.md +++ b/src/plugins/unified_histogram/README.md @@ -49,9 +49,6 @@ return ( // Pass a ref to the containing element to // handle top panel resize functionality resizeRef={resizeRef} - // Optionally append an element after the - // hits counter display - appendHitsCounter={} > @@ -165,7 +162,6 @@ return ( searchSessionId={searchSessionId} requestAdapter={requestAdapter} resizeRef={resizeRef} - appendHitsCounter={} > diff --git a/src/plugins/unified_histogram/public/chart/breakdown_field_selector.test.tsx b/src/plugins/unified_histogram/public/chart/breakdown_field_selector.test.tsx index c0c20a1e1a80e..86ec06ba48e67 100644 --- a/src/plugins/unified_histogram/public/chart/breakdown_field_selector.test.tsx +++ b/src/plugins/unified_histogram/public/chart/breakdown_field_selector.test.tsx @@ -6,68 +6,132 @@ * Side Public License, v 1. */ -import { EuiComboBox } from '@elastic/eui'; -import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { render, act, screen } from '@testing-library/react'; import React from 'react'; import { UnifiedHistogramBreakdownContext } from '../types'; import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; import { BreakdownFieldSelector } from './breakdown_field_selector'; -import { fieldSupportsBreakdown } from './utils/field_supports_breakdown'; describe('BreakdownFieldSelector', () => { - it('should pass fields that support breakdown as options to the EuiComboBox', () => { + it('should render correctly', () => { const onBreakdownFieldChange = jest.fn(); const breakdown: UnifiedHistogramBreakdownContext = { field: undefined, }; - const wrapper = mountWithIntl( + + render( ); - const comboBox = wrapper.find(EuiComboBox); - expect(comboBox.prop('options')).toEqual( - dataViewWithTimefieldMock.fields - .filter(fieldSupportsBreakdown) - .map((field) => ({ label: field.displayName, value: field.name })) - .sort((a, b) => a.label.toLowerCase().localeCompare(b.label.toLowerCase())) - ); + + const button = screen.getByTestId('unifiedHistogramBreakdownSelectorButton'); + expect(button.getAttribute('data-selected-value')).toBe(null); + + act(() => { + button.click(); + }); + + const options = screen.getAllByRole('option'); + expect( + options.map((option) => ({ + label: option.getAttribute('title'), + value: option.getAttribute('value'), + checked: option.getAttribute('aria-checked'), + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "checked": "true", + "label": "No breakdown", + "value": "__EMPTY_SELECTOR_OPTION__", + }, + Object { + "checked": "false", + "label": "bytes", + "value": "bytes", + }, + Object { + "checked": "false", + "label": "extension", + "value": "extension", + }, + ] + `); }); - it('should pass selectedOptions to the EuiComboBox if breakdown.field is defined', () => { + it('should mark the option as checked if breakdown.field is defined', () => { const onBreakdownFieldChange = jest.fn(); const field = dataViewWithTimefieldMock.fields.find((f) => f.name === 'extension')!; const breakdown: UnifiedHistogramBreakdownContext = { field }; - const wrapper = mountWithIntl( + + render( ); - const comboBox = wrapper.find(EuiComboBox); - expect(comboBox.prop('selectedOptions')).toEqual([ - { label: field.displayName, value: field.name }, - ]); + + const button = screen.getByTestId('unifiedHistogramBreakdownSelectorButton'); + expect(button.getAttribute('data-selected-value')).toBe('extension'); + + act(() => { + button.click(); + }); + + const options = screen.getAllByRole('option'); + expect( + options.map((option) => ({ + label: option.getAttribute('title'), + value: option.getAttribute('value'), + checked: option.getAttribute('aria-checked'), + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "checked": "false", + "label": "No breakdown", + "value": "__EMPTY_SELECTOR_OPTION__", + }, + Object { + "checked": "false", + "label": "bytes", + "value": "bytes", + }, + Object { + "checked": "true", + "label": "extension", + "value": "extension", + }, + ] + `); }); it('should call onBreakdownFieldChange with the selected field when the user selects a field', () => { const onBreakdownFieldChange = jest.fn(); + const selectedField = dataViewWithTimefieldMock.fields.find((f) => f.name === 'bytes')!; const breakdown: UnifiedHistogramBreakdownContext = { field: undefined, }; - const wrapper = mountWithIntl( + render( ); - const comboBox = wrapper.find(EuiComboBox); - const selectedField = dataViewWithTimefieldMock.fields.find((f) => f.name === 'extension')!; - comboBox.prop('onChange')!([{ label: selectedField.displayName, value: selectedField.name }]); + + act(() => { + screen.getByTestId('unifiedHistogramBreakdownSelectorButton').click(); + }); + + act(() => { + screen.getByTitle('bytes').click(); + }); + expect(onBreakdownFieldChange).toHaveBeenCalledWith(selectedField); }); }); diff --git a/src/plugins/unified_histogram/public/chart/breakdown_field_selector.tsx b/src/plugins/unified_histogram/public/chart/breakdown_field_selector.tsx index 77e00e157d62b..78df66f50873e 100644 --- a/src/plugins/unified_histogram/public/chart/breakdown_field_selector.tsx +++ b/src/plugins/unified_histogram/public/chart/breakdown_field_selector.tsx @@ -6,14 +6,20 @@ * Side Public License, v 1. */ -import { EuiComboBox, EuiComboBoxOptionOption, EuiToolTip, useEuiTheme } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { EuiSelectableOption } from '@elastic/eui'; +import { FieldIcon, getFieldIconProps } from '@kbn/field-utils'; import { css } from '@emotion/react'; -import { DataView, DataViewField } from '@kbn/data-views-plugin/common'; -import { calculateWidthFromEntries } from '@kbn/calculate-width-from-char-count'; +import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; import { i18n } from '@kbn/i18n'; -import React, { useCallback, useState } from 'react'; import { UnifiedHistogramBreakdownContext } from '../types'; import { fieldSupportsBreakdown } from './utils/field_supports_breakdown'; +import { + ToolbarSelector, + ToolbarSelectorProps, + EMPTY_OPTION, + SelectableEntry, +} from './toolbar_selector'; export interface BreakdownFieldSelectorProps { dataView: DataView; @@ -21,77 +27,83 @@ export interface BreakdownFieldSelectorProps { onBreakdownFieldChange?: (breakdownField: DataViewField | undefined) => void; } -const TRUNCATION_PROPS = { truncation: 'middle' as const }; -const SINGLE_SELECTION = { asPlainText: true }; - export const BreakdownFieldSelector = ({ dataView, breakdown, onBreakdownFieldChange, }: BreakdownFieldSelectorProps) => { - const fieldOptions = dataView.fields - .filter(fieldSupportsBreakdown) - .map((field) => ({ label: field.displayName, value: field.name })) - .sort((a, b) => a.label.toLowerCase().localeCompare(b.label.toLowerCase())); + const fieldOptions: SelectableEntry[] = useMemo(() => { + const options: SelectableEntry[] = dataView.fields + .filter(fieldSupportsBreakdown) + .map((field) => ({ + key: field.name, + label: field.displayName, + value: field.name, + checked: + breakdown?.field?.name === field.name + ? ('on' as EuiSelectableOption['checked']) + : undefined, + prepend: ( + + + + ), + })) + .sort((a, b) => a.label.toLowerCase().localeCompare(b.label.toLowerCase())); - const selectedFields = breakdown.field - ? [{ label: breakdown.field.displayName, value: breakdown.field.name }] - : []; + options.unshift({ + key: EMPTY_OPTION, + value: EMPTY_OPTION, + label: i18n.translate('unifiedHistogram.breakdownFieldSelector.noBreakdownButtonLabel', { + defaultMessage: 'No breakdown', + }), + checked: !breakdown?.field ? ('on' as EuiSelectableOption['checked']) : undefined, + }); - const onFieldChange = useCallback( - (newOptions: EuiComboBoxOptionOption[]) => { - const field = newOptions.length - ? dataView.fields.find((currentField) => currentField.name === newOptions[0].value) - : undefined; + return options; + }, [dataView, breakdown.field]); + const onChange: ToolbarSelectorProps['onChange'] = useCallback( + (chosenOption) => { + const field = chosenOption?.value + ? dataView.fields.find((currentField) => currentField.name === chosenOption.value) + : undefined; onBreakdownFieldChange?.(field); }, [dataView.fields, onBreakdownFieldChange] ); - const [fieldPopoverDisabled, setFieldPopoverDisabled] = useState(false); - const disableFieldPopover = useCallback(() => setFieldPopoverDisabled(true), []); - const enableFieldPopover = useCallback( - () => setTimeout(() => setFieldPopoverDisabled(false)), - [] - ); - - const { euiTheme } = useEuiTheme(); - const breakdownCss = css` - width: 100%; - max-width: ${euiTheme.base * 22}px; - `; - - const panelMinWidth = calculateWidthFromEntries(fieldOptions, ['label']); - return ( - - - + ); }; diff --git a/src/plugins/unified_histogram/public/chart/chart.test.tsx b/src/plugins/unified_histogram/public/chart/chart.test.tsx index d561a3310ceae..474da6bce5bf7 100644 --- a/src/plugins/unified_histogram/public/chart/chart.test.tsx +++ b/src/plugins/unified_histogram/public/chart/chart.test.tsx @@ -18,11 +18,11 @@ import type { ReactWrapper } from 'enzyme'; import { unifiedHistogramServicesMock } from '../__mocks__/services'; import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_source/mocks'; import { of } from 'rxjs'; -import { HitsCounter } from '../hits_counter'; import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; import { dataViewMock } from '../__mocks__/data_view'; import { BreakdownFieldSelector } from './breakdown_field_selector'; import { SuggestionSelector } from './suggestion_selector'; +import { checkChartAvailability } from './check_chart_availability'; import { currentSuggestionMock, allSuggestionsMock } from '../__mocks__/suggestions'; @@ -33,6 +33,7 @@ jest.mock('./hooks/use_edit_visualization', () => ({ })); async function mountComponent({ + customToggle, noChart, noHits, noBreakdown, @@ -45,6 +46,7 @@ async function mountComponent({ hasDashboardPermissions, isChartLoading, }: { + customToggle?: ReactElement; noChart?: boolean; noHits?: boolean; noBreakdown?: boolean; @@ -70,6 +72,19 @@ async function mountComponent({ } as unknown as Capabilities, }; + const chart = noChart + ? undefined + : { + status: 'complete' as UnifiedHistogramFetchStatus, + hidden: chartHidden, + timeInterval: 'auto', + bucketInterval: { + scaled: true, + description: 'test', + scale: 2, + }, + }; + const props = { dataView, query: { @@ -85,28 +100,18 @@ async function mountComponent({ status: 'complete' as UnifiedHistogramFetchStatus, number: 2, }, - chart: noChart - ? undefined - : { - status: 'complete' as UnifiedHistogramFetchStatus, - hidden: chartHidden, - timeInterval: 'auto', - bucketInterval: { - scaled: true, - description: 'test', - scale: 2, - }, - }, + chart, breakdown: noBreakdown ? undefined : { field: undefined }, currentSuggestion, allSuggestions, isChartLoading: Boolean(isChartLoading), isPlainRecord, appendHistogram, - onResetChartHeight: jest.fn(), onChartHiddenChange: jest.fn(), onTimeIntervalChange: jest.fn(), withDefaultActions: undefined, + isChartAvailable: checkChartAvailability({ chart, dataView, isPlainRecord }), + renderCustomChartToggleActions: customToggle ? () => customToggle : undefined, }; let instance: ReactWrapper = {} as ReactWrapper; @@ -126,16 +131,33 @@ describe('Chart', () => { test('render when chart is undefined', async () => { const component = await mountComponent({ noChart: true }); - expect( - component.find('[data-test-subj="unifiedHistogramChartOptionsToggle"]').exists() - ).toBeFalsy(); + expect(component.find('[data-test-subj="unifiedHistogramToggleChartButton"]').exists()).toBe( + true + ); + }); + + test('should render a custom toggle when provided', async () => { + const component = await mountComponent({ + customToggle: , + }); + expect(component.find('[data-test-subj="custom-toggle"]').exists()).toBe(true); + expect(component.find('[data-test-subj="unifiedHistogramToggleChartButton"]').exists()).toBe( + false + ); + }); + + test('should not render when custom toggle is provided and chart is hidden', async () => { + const component = await mountComponent({ customToggle: , chartHidden: true }); + expect(component.find('[data-test-subj="unifiedHistogramChartPanelHidden"]').exists()).toBe( + true + ); }); test('render when chart is defined and onEditVisualization is undefined', async () => { mockUseEditVisualization = undefined; const component = await mountComponent(); expect( - component.find('[data-test-subj="unifiedHistogramChartOptionsToggle"]').exists() + component.find('[data-test-subj="unifiedHistogramToggleChartButton"]').exists() ).toBeTruthy(); expect( component.find('[data-test-subj="unifiedHistogramEditVisualization"]').exists() @@ -145,7 +167,7 @@ describe('Chart', () => { test('render when chart is defined and onEditVisualization is defined', async () => { const component = await mountComponent(); expect( - component.find('[data-test-subj="unifiedHistogramChartOptionsToggle"]').exists() + component.find('[data-test-subj="unifiedHistogramToggleChartButton"]').exists() ).toBeTruthy(); expect( component.find('[data-test-subj="unifiedHistogramEditVisualization"]').exists() @@ -155,7 +177,7 @@ describe('Chart', () => { test('render when chart.hidden is true', async () => { const component = await mountComponent({ chartHidden: true }); expect( - component.find('[data-test-subj="unifiedHistogramChartOptionsToggle"]').exists() + component.find('[data-test-subj="unifiedHistogramToggleChartButton"]').exists() ).toBeTruthy(); expect(component.find('[data-test-subj="unifiedHistogramChart"]').exists()).toBeFalsy(); }); @@ -163,7 +185,7 @@ describe('Chart', () => { test('render when chart.hidden is false', async () => { const component = await mountComponent({ chartHidden: false }); expect( - component.find('[data-test-subj="unifiedHistogramChartOptionsToggle"]').exists() + component.find('[data-test-subj="unifiedHistogramToggleChartButton"]').exists() ).toBeTruthy(); expect(component.find('[data-test-subj="unifiedHistogramChart"]').exists()).toBeTruthy(); }); @@ -171,7 +193,7 @@ describe('Chart', () => { test('render when is text based and not timebased', async () => { const component = await mountComponent({ isPlainRecord: true, dataView: dataViewMock }); expect( - component.find('[data-test-subj="unifiedHistogramChartOptionsToggle"]').exists() + component.find('[data-test-subj="unifiedHistogramToggleChartButton"]').exists() ).toBeTruthy(); expect(component.find('[data-test-subj="unifiedHistogramChart"]').exists()).toBeTruthy(); }); @@ -187,22 +209,12 @@ describe('Chart', () => { await act(async () => { component .find('[data-test-subj="unifiedHistogramEditVisualization"]') - .first() + .last() .simulate('click'); }); expect(mockUseEditVisualization).toHaveBeenCalled(); }); - it('should render HitsCounter when hits is defined', async () => { - const component = await mountComponent(); - expect(component.find(HitsCounter).exists()).toBeTruthy(); - }); - - it('should not render HitsCounter when hits is undefined', async () => { - const component = await mountComponent({ noHits: true }); - expect(component.find(HitsCounter).exists()).toBeFalsy(); - }); - it('should render the element passed to appendHistogram', async () => { const appendHistogram =
; const component = await mountComponent({ appendHistogram }); diff --git a/src/plugins/unified_histogram/public/chart/chart.tsx b/src/plugins/unified_histogram/public/chart/chart.tsx index a64944d73b5fe..657f27a72c070 100644 --- a/src/plugins/unified_histogram/public/chart/chart.tsx +++ b/src/plugins/unified_histogram/public/chart/chart.tsx @@ -8,28 +8,19 @@ import React, { ReactElement, useMemo, useState, useEffect, useCallback, memo } from 'react'; import type { Observable } from 'rxjs'; -import { - EuiButtonIcon, - EuiContextMenu, - EuiFlexGroup, - EuiFlexItem, - EuiPopover, - EuiToolTip, - EuiProgress, -} from '@elastic/eui'; +import { IconButtonGroup, type IconButtonGroupProps } from '@kbn/shared-ux-button-toolbar'; +import { EuiFlexGroup, EuiFlexItem, EuiProgress } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { EmbeddableComponentProps, Suggestion, LensEmbeddableOutput, } from '@kbn/lens-plugin/public'; -import { DataView, DataViewField, DataViewType } from '@kbn/data-views-plugin/public'; +import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; import type { LensEmbeddableInput } from '@kbn/lens-plugin/public'; import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; import { Subject } from 'rxjs'; -import { HitsCounter } from '../hits_counter'; import { Histogram } from './histogram'; -import { useChartPanels } from './hooks/use_chart_panels'; import type { UnifiedHistogramBreakdownContext, UnifiedHistogramChartContext, @@ -43,6 +34,7 @@ import type { } from '../types'; import { BreakdownFieldSelector } from './breakdown_field_selector'; import { SuggestionSelector } from './suggestion_selector'; +import { TimeIntervalSelector } from './time_interval_selector'; import { useTotalHits } from './hooks/use_total_hits'; import { useRequestParams } from './hooks/use_request_params'; import { useChartStyles } from './hooks/use_chart_styles'; @@ -53,6 +45,8 @@ import { useRefetch } from './hooks/use_refetch'; import { useEditVisualization } from './hooks/use_edit_visualization'; export interface ChartProps { + isChartAvailable: boolean; + hiddenPanel?: boolean; className?: string; services: UnifiedHistogramServices; dataView: DataView; @@ -67,7 +61,7 @@ export interface ChartProps { hits?: UnifiedHistogramHitsContext; chart?: UnifiedHistogramChartContext; breakdown?: UnifiedHistogramBreakdownContext; - appendHitsCounter?: ReactElement; + renderCustomChartToggleActions?: () => ReactElement | undefined; appendHistogram?: ReactElement; disableAutoFetching?: boolean; disableTriggers?: LensEmbeddableInput['disableTriggers']; @@ -78,7 +72,6 @@ export interface ChartProps { isOnHistogramMode?: boolean; histogramQuery?: AggregateQuery; isChartLoading?: boolean; - onResetChartHeight?: () => void; onChartHiddenChange?: (chartHidden: boolean) => void; onTimeIntervalChange?: (timeInterval: string) => void; onBreakdownFieldChange?: (breakdownField: DataViewField | undefined) => void; @@ -93,6 +86,7 @@ export interface ChartProps { const HistogramMemoized = memo(Histogram); export function Chart({ + isChartAvailable, className, services, dataView, @@ -107,7 +101,7 @@ export function Chart({ currentSuggestion, allSuggestions, isPlainRecord, - appendHitsCounter, + renderCustomChartToggleActions, appendHistogram, disableAutoFetching, disableTriggers, @@ -118,7 +112,6 @@ export function Chart({ isOnHistogramMode, histogramQuery, isChartLoading, - onResetChartHeight, onChartHiddenChange, onTimeIntervalChange, onSuggestionChange, @@ -131,33 +124,12 @@ export function Chart({ }: ChartProps) { const [isSaveModalVisible, setIsSaveModalVisible] = useState(false); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); - const { - showChartOptionsPopover, - chartRef, - toggleChartOptions, - closeChartOptions, - toggleHideChart, - } = useChartActions({ + const { chartRef, toggleHideChart } = useChartActions({ chart, onChartHiddenChange, }); - const panels = useChartPanels({ - chart, - toggleHideChart, - onTimeIntervalChange, - closePopover: closeChartOptions, - onResetChartHeight, - isPlainRecord, - }); - - const chartVisible = !!( - chart && - !chart.hidden && - dataView.id && - dataView.type !== DataViewType.ROLLUP && - (isPlainRecord || (!isPlainRecord && dataView.isTimeBased())) - ); + const chartVisible = isChartAvailable && !!chart && !chart.hidden; const input$ = useMemo( () => originalInput$ ?? new Subject(), @@ -201,17 +173,7 @@ export function Chart({ isPlainRecord, }); - const { - resultCountCss, - resultCountInnerCss, - resultCountTitleCss, - resultCountToggleCss, - histogramCss, - breakdownFieldSelectorGroupCss, - breakdownFieldSelectorItemCss, - suggestionsSelectorItemCss, - chartToolButtonCss, - } = useChartStyles(chartVisible); + const { chartToolbarCss, histogramCss } = useChartStyles(chartVisible); const lensAttributesContext = useMemo( () => @@ -258,162 +220,135 @@ export function Chart({ lensAttributes: lensAttributesContext.attributes, isPlainRecord, }); + + const a11yCommonProps = { + id: 'unifiedHistogramCollapsablePanel', + }; + + if (Boolean(renderCustomChartToggleActions) && !chartVisible) { + return
; + } + const LensSaveModalComponent = services.lens.SaveModalComponent; const canSaveVisualization = chartVisible && currentSuggestion && services.capabilities.dashboard?.showWriteControls; + const canEditVisualizationOnTheFly = currentSuggestion && chartVisible; - const renderEditButton = useMemo( - () => ( - setIsFlyoutVisible(true)} - data-test-subj="unifiedHistogramEditFlyoutVisualization" - aria-label={i18n.translate('unifiedHistogram.editVisualizationButton', { - defaultMessage: 'Edit visualization', - })} - disabled={isFlyoutVisible} - /> - ), - [isFlyoutVisible] - ); + const actions: IconButtonGroupProps['buttons'] = []; - const canEditVisualizationOnTheFly = currentSuggestion && chartVisible; + if (canEditVisualizationOnTheFly) { + actions.push({ + label: i18n.translate('unifiedHistogram.editVisualizationButton', { + defaultMessage: 'Edit visualization', + }), + iconType: 'pencil', + isDisabled: isFlyoutVisible, + 'data-test-subj': 'unifiedHistogramEditFlyoutVisualization', + onClick: () => setIsFlyoutVisible(true), + }); + } else if (onEditVisualization) { + actions.push({ + label: i18n.translate('unifiedHistogram.editVisualizationButton', { + defaultMessage: 'Edit visualization', + }), + iconType: 'lensApp', + 'data-test-subj': 'unifiedHistogramEditVisualization', + onClick: onEditVisualization, + }); + } + if (canSaveVisualization) { + actions.push({ + label: i18n.translate('unifiedHistogram.saveVisualizationButton', { + defaultMessage: 'Save visualization', + }), + iconType: 'save', + 'data-test-subj': 'unifiedHistogramSaveVisualization', + onClick: () => setIsSaveModalVisible(true), + }); + } return ( - + - - {hits && } - - {chart && ( - - - {chartVisible && breakdown && ( - + + + + {renderCustomChartToggleActions ? ( + renderCustomChartToggleActions() + ) : ( + + )} + + {chartVisible && !isPlainRecord && !!onTimeIntervalChange && ( + + + + )} + +
+ {chartVisible && breakdown && ( - - )} - {chartVisible && currentSuggestion && allSuggestions && allSuggestions?.length > 1 && ( - - - - )} - {canSaveVisualization && ( - <> - - - setIsSaveModalVisible(true)} - data-test-subj="unifiedHistogramSaveVisualization" - aria-label={i18n.translate('unifiedHistogram.saveVisualizationButton', { - defaultMessage: 'Save visualization', - })} - /> - - - - )} - {canEditVisualizationOnTheFly && ( - - {!isFlyoutVisible ? ( - - {renderEditButton} - - ) : ( - renderEditButton - )} - - )} - {onEditVisualization && ( - - - 1 && ( + - - - )} - - - - - } - isOpen={showChartOptionsPopover} - closePopover={closeChartOptions} - panelPaddingSize="none" - anchorPosition="downLeft" - > - - - - + )} +
+
+
+
+ {chartVisible && actions.length > 0 && ( + + )}
@@ -427,6 +362,7 @@ export function Chart({ defaultMessage: 'Histogram of found documents', })} css={histogramCss} + data-test-subj="unifiedHistogramRendered" > {isChartLoading && ( { meta: { type: 'es_ql' }, columns: [ { - id: 'rows', - name: 'rows', + id: 'results', + name: 'results', meta: { type: 'number', dimensionName: 'Vertical axis', @@ -260,10 +260,10 @@ describe('Histogram', () => { ], rows: [ { - rows: 16, + results: 16, }, { - rows: 4, + results: 4, }, ], } as any; diff --git a/src/plugins/unified_histogram/public/chart/histogram.tsx b/src/plugins/unified_histogram/public/chart/histogram.tsx index 956f4ef86f2a5..29940af44193c 100644 --- a/src/plugins/unified_histogram/public/chart/histogram.tsx +++ b/src/plugins/unified_histogram/public/chart/histogram.tsx @@ -70,9 +70,13 @@ const computeTotalHits = ( return Object.values(adapterTables ?? {})?.[0]?.rows?.length; } else if (isPlainRecord && !hasLensSuggestions) { // ES|QL histogram case + const rows = Object.values(adapterTables ?? {})?.[0]?.rows; + if (!rows) { + return undefined; + } let rowsCount = 0; - Object.values(adapterTables ?? {})?.[0]?.rows.forEach((r) => { - rowsCount += r.rows; + rows.forEach((r) => { + rowsCount += r.results; }); return rowsCount; } else { @@ -177,6 +181,8 @@ export function Histogram({ }); const { euiTheme } = useEuiTheme(); + const boxShadow = `0 2px 2px -1px ${euiTheme.colors.mediumShade}, + 0 1px 5px -2px ${euiTheme.colors.mediumShade}`; const chartCss = css` position: relative; flex-grow: 1; @@ -191,6 +197,7 @@ export function Histogram({ & .lnsExpressionRenderer { width: ${chartSize}; margin: auto; + box-shadow: ${attributes.visualizationType === 'lnsMetric' ? boxShadow : 'none'}; } & .echLegend .echLegendList { diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_chart_actions.test.ts b/src/plugins/unified_histogram/public/chart/hooks/use_chart_actions.test.ts index 120dc0b3d0884..7696f0f9782b7 100644 --- a/src/plugins/unified_histogram/public/chart/hooks/use_chart_actions.test.ts +++ b/src/plugins/unified_histogram/public/chart/hooks/use_chart_actions.test.ts @@ -27,31 +27,6 @@ describe('useChartActions', () => { }; }; - it('should toggle chart options', () => { - const { hook } = render(); - expect(hook.result.current.showChartOptionsPopover).toBe(false); - act(() => { - hook.result.current.toggleChartOptions(); - }); - expect(hook.result.current.showChartOptionsPopover).toBe(true); - act(() => { - hook.result.current.toggleChartOptions(); - }); - expect(hook.result.current.showChartOptionsPopover).toBe(false); - }); - - it('should close chart options', () => { - const { hook } = render(); - act(() => { - hook.result.current.toggleChartOptions(); - }); - expect(hook.result.current.showChartOptionsPopover).toBe(true); - act(() => { - hook.result.current.closeChartOptions(); - }); - expect(hook.result.current.showChartOptionsPopover).toBe(false); - }); - it('should toggle hide chart', () => { const { chart, onChartHiddenChange, hook } = render(); act(() => { diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_chart_actions.ts b/src/plugins/unified_histogram/public/chart/hooks/use_chart_actions.ts index 168db2ca0c4d9..3c4bd2434e3dd 100644 --- a/src/plugins/unified_histogram/public/chart/hooks/use_chart_actions.ts +++ b/src/plugins/unified_histogram/public/chart/hooks/use_chart_actions.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import type { UnifiedHistogramChartContext } from '../../types'; export const useChartActions = ({ @@ -16,16 +16,6 @@ export const useChartActions = ({ chart: UnifiedHistogramChartContext | undefined; onChartHiddenChange?: (chartHidden: boolean) => void; }) => { - const [showChartOptionsPopover, setShowChartOptionsPopover] = useState(false); - - const toggleChartOptions = useCallback(() => { - setShowChartOptionsPopover(!showChartOptionsPopover); - }, [showChartOptionsPopover]); - - const closeChartOptions = useCallback(() => { - setShowChartOptionsPopover(false); - }, [setShowChartOptionsPopover]); - const chartRef = useRef<{ element: HTMLElement | null; moveFocus: boolean }>({ element: null, moveFocus: false, @@ -44,10 +34,7 @@ export const useChartActions = ({ }, [chart?.hidden, onChartHiddenChange]); return { - showChartOptionsPopover, chartRef, - toggleChartOptions, - closeChartOptions, toggleHideChart, }; }; diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_chart_panels.test.ts b/src/plugins/unified_histogram/public/chart/hooks/use_chart_panels.test.ts deleted file mode 100644 index e5ee2b2c55cd9..0000000000000 --- a/src/plugins/unified_histogram/public/chart/hooks/use_chart_panels.test.ts +++ /dev/null @@ -1,108 +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 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 { renderHook } from '@testing-library/react-hooks'; -import { useChartPanels } from './use_chart_panels'; -import { EuiContextMenuPanelDescriptor } from '@elastic/eui'; - -describe('test useChartPanels', () => { - test('useChartsPanel when hideChart is true', async () => { - const { result } = renderHook(() => { - return useChartPanels({ - toggleHideChart: jest.fn(), - onTimeIntervalChange: jest.fn(), - closePopover: jest.fn(), - onResetChartHeight: jest.fn(), - chart: { - hidden: true, - timeInterval: 'auto', - }, - }); - }); - const panels: EuiContextMenuPanelDescriptor[] = result.current; - const panel0: EuiContextMenuPanelDescriptor = result.current[0]; - expect(panels.length).toBe(1); - expect(panel0!.items).toHaveLength(1); - expect(panel0!.items![0].icon).toBe('eye'); - }); - test('useChartsPanel when hideChart is false', async () => { - const { result } = renderHook(() => { - return useChartPanels({ - toggleHideChart: jest.fn(), - onTimeIntervalChange: jest.fn(), - closePopover: jest.fn(), - onResetChartHeight: jest.fn(), - chart: { - hidden: false, - timeInterval: 'auto', - }, - }); - }); - const panels: EuiContextMenuPanelDescriptor[] = result.current; - const panel0: EuiContextMenuPanelDescriptor = result.current[0]; - expect(panels.length).toBe(2); - expect(panel0!.items).toHaveLength(3); - expect(panel0!.items![0].icon).toBe('eyeClosed'); - expect(panel0!.items![1].icon).toBe('refresh'); - }); - test('should not show reset chart height when onResetChartHeight is undefined', async () => { - const { result } = renderHook(() => { - return useChartPanels({ - toggleHideChart: jest.fn(), - onTimeIntervalChange: jest.fn(), - closePopover: jest.fn(), - chart: { - hidden: false, - timeInterval: 'auto', - }, - }); - }); - const panel0: EuiContextMenuPanelDescriptor = result.current[0]; - expect(panel0!.items).toHaveLength(2); - expect(panel0!.items![0].icon).toBe('eyeClosed'); - }); - test('onResetChartHeight is called when the reset chart height button is clicked', async () => { - const onResetChartHeight = jest.fn(); - const { result } = renderHook(() => { - return useChartPanels({ - toggleHideChart: jest.fn(), - onTimeIntervalChange: jest.fn(), - closePopover: jest.fn(), - onResetChartHeight, - chart: { - hidden: false, - timeInterval: 'auto', - }, - }); - }); - const panel0: EuiContextMenuPanelDescriptor = result.current[0]; - const resetChartHeightButton = panel0!.items![1]; - (resetChartHeightButton.onClick as Function)(); - expect(onResetChartHeight).toBeCalled(); - }); - test('useChartsPanel when isPlainRecord', async () => { - const { result } = renderHook(() => { - return useChartPanels({ - toggleHideChart: jest.fn(), - onTimeIntervalChange: jest.fn(), - closePopover: jest.fn(), - onResetChartHeight: jest.fn(), - isPlainRecord: true, - chart: { - hidden: true, - timeInterval: 'auto', - }, - }); - }); - const panels: EuiContextMenuPanelDescriptor[] = result.current; - const panel0: EuiContextMenuPanelDescriptor = result.current[0]; - expect(panels.length).toBe(1); - expect(panel0!.items).toHaveLength(1); - expect(panel0!.items![0].icon).toBe('eye'); - }); -}); diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_chart_panels.ts b/src/plugins/unified_histogram/public/chart/hooks/use_chart_panels.ts deleted file mode 100644 index bf1bf4d6b95cd..0000000000000 --- a/src/plugins/unified_histogram/public/chart/hooks/use_chart_panels.ts +++ /dev/null @@ -1,124 +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 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 { i18n } from '@kbn/i18n'; -import type { - EuiContextMenuPanelItemDescriptor, - EuiContextMenuPanelDescriptor, -} from '@elastic/eui'; -import { search } from '@kbn/data-plugin/public'; -import type { UnifiedHistogramChartContext } from '../../types'; - -export function useChartPanels({ - chart, - toggleHideChart, - onTimeIntervalChange, - closePopover, - onResetChartHeight, - isPlainRecord, -}: { - chart?: UnifiedHistogramChartContext; - toggleHideChart: () => void; - onTimeIntervalChange?: (timeInterval: string) => void; - closePopover: () => void; - onResetChartHeight?: () => void; - isPlainRecord?: boolean; -}) { - if (!chart) { - return []; - } - - const selectedOptionIdx = search.aggs.intervalOptions.findIndex( - (opt) => opt.val === chart.timeInterval - ); - const intervalDisplay = - selectedOptionIdx > -1 - ? search.aggs.intervalOptions[selectedOptionIdx].display - : search.aggs.intervalOptions[0].display; - - const mainPanelItems: EuiContextMenuPanelItemDescriptor[] = [ - { - name: !chart.hidden - ? i18n.translate('unifiedHistogram.hideChart', { - defaultMessage: 'Hide chart', - }) - : i18n.translate('unifiedHistogram.showChart', { - defaultMessage: 'Show chart', - }), - icon: !chart.hidden ? 'eyeClosed' : 'eye', - onClick: () => { - toggleHideChart(); - closePopover(); - }, - 'data-test-subj': 'unifiedHistogramChartToggle', - }, - ]; - if (!chart.hidden) { - if (onResetChartHeight) { - mainPanelItems.push({ - name: i18n.translate('unifiedHistogram.resetChartHeight', { - defaultMessage: 'Reset to default height', - }), - icon: 'refresh', - onClick: () => { - onResetChartHeight(); - closePopover(); - }, - 'data-test-subj': 'unifiedHistogramChartResetHeight', - }); - } - - if (!isPlainRecord) { - mainPanelItems.push({ - name: i18n.translate('unifiedHistogram.timeIntervalWithValue', { - defaultMessage: 'Time interval: {timeInterval}', - values: { - timeInterval: intervalDisplay, - }, - }), - panel: 1, - 'data-test-subj': 'unifiedHistogramTimeIntervalPanel', - }); - } - } - - const panels: EuiContextMenuPanelDescriptor[] = [ - { - id: 0, - title: i18n.translate('unifiedHistogram.chartOptions', { - defaultMessage: 'Chart options', - }), - items: mainPanelItems, - }, - ]; - if (!chart.hidden && !isPlainRecord) { - panels.push({ - id: 1, - initialFocusedItemIndex: selectedOptionIdx > -1 ? selectedOptionIdx : 0, - title: i18n.translate('unifiedHistogram.timeIntervals', { - defaultMessage: 'Time intervals', - }), - items: search.aggs.intervalOptions - .filter(({ val }) => val !== 'custom') - .map(({ display, val }) => { - return { - name: display, - label: display, - icon: val === chart.timeInterval ? 'check' : 'empty', - onClick: () => { - onTimeIntervalChange?.(val); - closePopover(); - }, - 'data-test-subj': `unifiedHistogramTimeInterval-${display}`, - className: val === chart.timeInterval ? 'unifiedHistogramIntervalSelected' : '', - }; - }), - }); - } - return panels; -} diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_chart_styles.tsx b/src/plugins/unified_histogram/public/chart/hooks/use_chart_styles.tsx index 13b527be702c1..5a5bf41ca395d 100644 --- a/src/plugins/unified_histogram/public/chart/hooks/use_chart_styles.tsx +++ b/src/plugins/unified_histogram/public/chart/hooks/use_chart_styles.tsx @@ -6,36 +6,18 @@ * Side Public License, v 1. */ -import { useEuiBreakpoint, useEuiTheme } from '@elastic/eui'; +import { useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; export const useChartStyles = (chartVisible: boolean) => { const { euiTheme } = useEuiTheme(); - const resultCountCss = css` + + const chartToolbarCss = css` padding: ${euiTheme.size.s} ${euiTheme.size.s} ${chartVisible ? 0 : euiTheme.size.s} ${euiTheme.size.s}; min-height: ${euiTheme.base * 2.5}px; `; - const resultCountInnerCss = css` - ${useEuiBreakpoint(['xs', 's'])} { - align-items: center; - } - `; - const resultCountTitleCss = css` - flex-basis: auto; - ${useEuiBreakpoint(['xs', 's'])} { - margin-bottom: 0 !important; - } - `; - const resultCountToggleCss = css` - flex-basis: auto; - min-width: 0; - - ${useEuiBreakpoint(['xs', 's'])} { - align-items: flex-end; - } - `; const histogramCss = css` flex-grow: 1; display: flex; @@ -48,34 +30,9 @@ export const useChartStyles = (chartVisible: boolean) => { stroke-width: 1; } `; - const breakdownFieldSelectorGroupCss = css` - width: 100%; - `; - const breakdownFieldSelectorItemCss = css` - min-width: 0; - align-items: flex-end; - padding-left: ${euiTheme.size.s}; - `; - const suggestionsSelectorItemCss = css` - min-width: 0; - align-items: flex-start; - padding-left: ${euiTheme.size.s}; - `; - const chartToolButtonCss = css` - display: flex; - justify-content: center; - padding-left: ${euiTheme.size.s}; - `; return { - resultCountCss, - resultCountInnerCss, - resultCountTitleCss, - resultCountToggleCss, + chartToolbarCss, histogramCss, - breakdownFieldSelectorGroupCss, - breakdownFieldSelectorItemCss, - suggestionsSelectorItemCss, - chartToolButtonCss, }; }; diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_total_hits.test.ts b/src/plugins/unified_histogram/public/chart/hooks/use_total_hits.test.ts index 3135f3c86f465..b6250f8fa82b7 100644 --- a/src/plugins/unified_histogram/public/chart/hooks/use_total_hits.test.ts +++ b/src/plugins/unified_histogram/public/chart/hooks/use_total_hits.test.ts @@ -85,7 +85,7 @@ describe('useTotalHits', () => { const query = { query: 'test query', language: 'kuery' }; const filters: Filter[] = [{ meta: { index: 'test' }, query: { match_all: {} } }]; const adapter = new RequestAdapter(); - renderHook(() => + const { rerender } = renderHook(() => useTotalHits({ ...getDeps(), services: { data } as any, @@ -99,6 +99,8 @@ describe('useTotalHits', () => { onTotalHitsChange, }) ); + refetch$.next({ type: 'refetch' }); + rerender(); expect(onTotalHitsChange).toBeCalledTimes(1); expect(onTotalHitsChange).toBeCalledWith(UnifiedHistogramFetchStatus.loading, undefined); expect(setFieldSpy).toHaveBeenCalledWith('index', dataViewWithTimefieldMock); @@ -125,7 +127,9 @@ describe('useTotalHits', () => { onTotalHitsChange, query: { esql: 'from test' }, }; - renderHook(() => useTotalHits(deps)); + const { rerender } = renderHook(() => useTotalHits(deps)); + refetch$.next({ type: 'refetch' }); + rerender(); expect(onTotalHitsChange).toBeCalledTimes(1); await waitFor(() => { expect(deps.services.expressions.run).toBeCalledTimes(1); @@ -153,22 +157,16 @@ describe('useTotalHits', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); - it('should not fetch a second time if refetch$ is not triggered', async () => { + it('should not fetch if refetch$ is not triggered', async () => { const onTotalHitsChange = jest.fn(); const fetchSpy = jest.spyOn(searchSourceInstanceMock, 'fetch$').mockClear(); const setFieldSpy = jest.spyOn(searchSourceInstanceMock, 'setField').mockClear(); const options = { ...getDeps(), onTotalHitsChange }; const { rerender } = renderHook(() => useTotalHits(options)); - expect(onTotalHitsChange).toBeCalledTimes(1); - expect(setFieldSpy).toHaveBeenCalled(); - expect(fetchSpy).toHaveBeenCalled(); - await waitFor(() => { - expect(onTotalHitsChange).toBeCalledTimes(2); - }); rerender(); - expect(onTotalHitsChange).toBeCalledTimes(2); - expect(setFieldSpy).toHaveBeenCalledTimes(5); - expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(onTotalHitsChange).toBeCalledTimes(0); + expect(setFieldSpy).toHaveBeenCalledTimes(0); + expect(fetchSpy).toHaveBeenCalledTimes(0); }); it('should fetch a second time if refetch$ is triggered', async () => { @@ -178,6 +176,8 @@ describe('useTotalHits', () => { const setFieldSpy = jest.spyOn(searchSourceInstanceMock, 'setField').mockClear(); const options = { ...getDeps(), onTotalHitsChange }; const { rerender } = renderHook(() => useTotalHits(options)); + refetch$.next({ type: 'refetch' }); + rerender(); expect(onTotalHitsChange).toBeCalledTimes(1); expect(setFieldSpy).toHaveBeenCalled(); expect(fetchSpy).toHaveBeenCalled(); @@ -202,7 +202,9 @@ describe('useTotalHits', () => { .spyOn(searchSourceInstanceMock, 'fetch$') .mockClear() .mockReturnValue(throwError(() => error)); - renderHook(() => useTotalHits({ ...getDeps(), onTotalHitsChange })); + const { rerender } = renderHook(() => useTotalHits({ ...getDeps(), onTotalHitsChange })); + refetch$.next({ type: 'refetch' }); + rerender(); await waitFor(() => { expect(onTotalHitsChange).toBeCalledTimes(2); expect(onTotalHitsChange).toBeCalledWith(UnifiedHistogramFetchStatus.error, error); @@ -220,7 +222,7 @@ describe('useTotalHits', () => { .mockClear() .mockReturnValue(timeRange as any); const filters: Filter[] = [{ meta: { index: 'test' }, query: { match_all: {} } }]; - renderHook(() => + const { rerender } = renderHook(() => useTotalHits({ ...getDeps(), dataView: { @@ -230,6 +232,8 @@ describe('useTotalHits', () => { filters, }) ); + refetch$.next({ type: 'refetch' }); + rerender(); expect(setOverwriteDataViewTypeSpy).toHaveBeenCalledWith(undefined); expect(setFieldSpy).toHaveBeenCalledWith('filter', filters); }); diff --git a/src/plugins/unified_histogram/public/chart/hooks/use_total_hits.ts b/src/plugins/unified_histogram/public/chart/hooks/use_total_hits.ts index 5bb927747e669..c16bb2335be24 100644 --- a/src/plugins/unified_histogram/public/chart/hooks/use_total_hits.ts +++ b/src/plugins/unified_histogram/public/chart/hooks/use_total_hits.ts @@ -13,7 +13,6 @@ import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; import { Datatable, isExpressionValueError } from '@kbn/expressions-plugin/common'; import { i18n } from '@kbn/i18n'; import { MutableRefObject, useEffect, useRef } from 'react'; -import useEffectOnce from 'react-use/lib/useEffectOnce'; import { catchError, filter, lastValueFrom, map, Observable, of, pluck } from 'rxjs'; import { UnifiedHistogramFetchStatus, @@ -66,8 +65,6 @@ export const useTotalHits = ({ }); }); - useEffectOnce(fetch); - useEffect(() => { const subscription = refetch$.subscribe(fetch); return () => subscription.unsubscribe(); @@ -102,13 +99,11 @@ const fetchTotalHits = async ({ abortController.current?.abort(); abortController.current = undefined; - // Either the chart is visible, in which case Lens will make the request, - // or there is no hits context, which means the total hits should be hidden - if (chartVisible || !hits) { + if (chartVisible) { return; } - onTotalHitsChange?.(UnifiedHistogramFetchStatus.loading, hits.total); + onTotalHitsChange?.(UnifiedHistogramFetchStatus.loading, hits?.total); const newAbortController = new AbortController(); diff --git a/src/plugins/unified_histogram/public/chart/index.ts b/src/plugins/unified_histogram/public/chart/index.ts index 6a6d2d65f6f92..4a8b758f7d86e 100644 --- a/src/plugins/unified_histogram/public/chart/index.ts +++ b/src/plugins/unified_histogram/public/chart/index.ts @@ -7,3 +7,4 @@ */ export { Chart } from './chart'; +export { checkChartAvailability } from './check_chart_availability'; diff --git a/src/plugins/unified_histogram/public/chart/suggestion_selector.tsx b/src/plugins/unified_histogram/public/chart/suggestion_selector.tsx index 0196387633396..cad20279bfdf0 100644 --- a/src/plugins/unified_histogram/public/chart/suggestion_selector.tsx +++ b/src/plugins/unified_histogram/public/chart/suggestion_selector.tsx @@ -75,6 +75,8 @@ export const SuggestionSelector = ({ position="top" content={suggestionsPopoverDisabled ? undefined : activeSuggestion?.title} anchorProps={{ css: suggestionComboCss }} + display="block" + delay="long" > { + it('should render correctly', () => { + const onTimeIntervalChange = jest.fn(); + + render( + + ); + + const button = screen.getByTestId('unifiedHistogramTimeIntervalSelectorButton'); + expect(button.getAttribute('data-selected-value')).toBe('auto'); + + act(() => { + button.click(); + }); + + const options = screen.getAllByRole('option'); + expect( + options.map((option) => ({ + label: option.getAttribute('title'), + value: option.getAttribute('value'), + checked: option.getAttribute('aria-checked'), + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "checked": "true", + "label": "Auto", + "value": "auto", + }, + Object { + "checked": "false", + "label": "Millisecond", + "value": "ms", + }, + Object { + "checked": "false", + "label": "Second", + "value": "s", + }, + Object { + "checked": "false", + "label": "Minute", + "value": "m", + }, + Object { + "checked": "false", + "label": "Hour", + "value": "h", + }, + Object { + "checked": "false", + "label": "Day", + "value": "d", + }, + Object { + "checked": "false", + "label": "Week", + "value": "w", + }, + Object { + "checked": "false", + "label": "Month", + "value": "M", + }, + Object { + "checked": "false", + "label": "Year", + "value": "y", + }, + ] + `); + }); + + it('should mark the selected option as checked', () => { + const onTimeIntervalChange = jest.fn(); + + render( + + ); + + const button = screen.getByTestId('unifiedHistogramTimeIntervalSelectorButton'); + expect(button.getAttribute('data-selected-value')).toBe('y'); + + act(() => { + button.click(); + }); + + const options = screen.getAllByRole('option'); + expect( + options.map((option) => ({ + label: option.getAttribute('title'), + value: option.getAttribute('value'), + checked: option.getAttribute('aria-checked'), + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "checked": "false", + "label": "Auto", + "value": "auto", + }, + Object { + "checked": "false", + "label": "Millisecond", + "value": "ms", + }, + Object { + "checked": "false", + "label": "Second", + "value": "s", + }, + Object { + "checked": "false", + "label": "Minute", + "value": "m", + }, + Object { + "checked": "false", + "label": "Hour", + "value": "h", + }, + Object { + "checked": "false", + "label": "Day", + "value": "d", + }, + Object { + "checked": "false", + "label": "Week", + "value": "w", + }, + Object { + "checked": "false", + "label": "Month", + "value": "M", + }, + Object { + "checked": "true", + "label": "Year", + "value": "y", + }, + ] + `); + }); + + it('should call onTimeIntervalChange with the selected option when the user selects an interval', () => { + const onTimeIntervalChange = jest.fn(); + + render( + + ); + + act(() => { + screen.getByTestId('unifiedHistogramTimeIntervalSelectorButton').click(); + }); + + act(() => { + screen.getByTitle('Week').click(); + }); + + expect(onTimeIntervalChange).toHaveBeenCalledWith('w'); + }); +}); diff --git a/src/plugins/unified_histogram/public/chart/time_interval_selector.tsx b/src/plugins/unified_histogram/public/chart/time_interval_selector.tsx new file mode 100644 index 0000000000000..86c17fdc79172 --- /dev/null +++ b/src/plugins/unified_histogram/public/chart/time_interval_selector.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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, { useCallback } from 'react'; +import { EuiSelectableOption } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { search } from '@kbn/data-plugin/public'; +import type { UnifiedHistogramChartContext } from '../types'; +import { ToolbarSelector, ToolbarSelectorProps, SelectableEntry } from './toolbar_selector'; + +export interface TimeIntervalSelectorProps { + chart: UnifiedHistogramChartContext; + onTimeIntervalChange: (timeInterval: string) => void; +} + +export const TimeIntervalSelector: React.FC = ({ + chart, + onTimeIntervalChange, +}) => { + const onChange: ToolbarSelectorProps['onChange'] = useCallback( + (chosenOption) => { + const selectedOption = chosenOption?.value; + if (selectedOption) { + onTimeIntervalChange(selectedOption); + } + }, + [onTimeIntervalChange] + ); + + const selectedOptionIdx = search.aggs.intervalOptions.findIndex( + (opt) => opt.val === chart.timeInterval + ); + const intervalDisplay = + selectedOptionIdx > -1 + ? search.aggs.intervalOptions[selectedOptionIdx].display + : search.aggs.intervalOptions[0].display; + + const options: SelectableEntry[] = search.aggs.intervalOptions + .filter(({ val }) => val !== 'custom') + .map(({ display, val }) => { + return { + key: val, + value: val, + label: display, + checked: val === chart.timeInterval ? ('on' as EuiSelectableOption['checked']) : undefined, + }; + }); + + return ( + + ); +}; diff --git a/src/plugins/unified_histogram/public/chart/toolbar_selector.tsx b/src/plugins/unified_histogram/public/chart/toolbar_selector.tsx new file mode 100644 index 0000000000000..1a83a736bb534 --- /dev/null +++ b/src/plugins/unified_histogram/public/chart/toolbar_selector.tsx @@ -0,0 +1,178 @@ +/* + * 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, { useCallback, ReactElement, useState, useMemo } from 'react'; +import { + EuiPopover, + EuiPopoverTitle, + EuiSelectable, + EuiSelectableProps, + EuiSelectableOption, + useEuiTheme, + EuiPanel, + EuiToolTip, +} from '@elastic/eui'; +import { ToolbarButton } from '@kbn/shared-ux-button-toolbar'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { css } from '@emotion/react'; +import { calculateWidthFromEntries } from '@kbn/calculate-width-from-char-count'; +import { i18n } from '@kbn/i18n'; + +export const EMPTY_OPTION = '__EMPTY_SELECTOR_OPTION__'; + +export type SelectableEntry = EuiSelectableOption<{ value: string }>; + +export interface ToolbarSelectorProps { + 'data-test-subj': string; + 'data-selected-value'?: string; // currently selected value + buttonLabel: ReactElement | string; + popoverTitle: string; + options: SelectableEntry[]; + searchable: boolean; + onChange?: (chosenOption: SelectableEntry | undefined) => void; +} + +export const ToolbarSelector: React.FC = ({ + 'data-test-subj': dataTestSubj, + 'data-selected-value': dataSelectedValue, + buttonLabel, + popoverTitle, + options, + searchable, + onChange, +}) => { + const { euiTheme } = useEuiTheme(); + const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(); + const [labelPopoverDisabled, setLabelPopoverDisabled] = useState(false); + + const disableLabelPopover = useCallback(() => setLabelPopoverDisabled(true), []); + + const enableLabelPopover = useCallback( + () => setTimeout(() => setLabelPopoverDisabled(false)), + [] + ); + + const onSelectionChange = useCallback( + (newOptions) => { + const chosenOption = newOptions.find(({ checked }: SelectableEntry) => checked === 'on'); + + onChange?.( + chosenOption?.value && chosenOption?.value !== EMPTY_OPTION ? chosenOption : undefined + ); + setIsOpen(false); + disableLabelPopover(); + }, + [disableLabelPopover, onChange] + ); + + const searchProps: EuiSelectableProps['searchProps'] = useMemo( + () => + searchable + ? { + id: `${dataTestSubj}SelectableInput`, + 'data-test-subj': `${dataTestSubj}SelectorSearch`, + compressed: true, + placeholder: i18n.translate( + 'unifiedHistogram.toolbarSelectorPopover.searchPlaceholder', + { + defaultMessage: 'Search', + } + ), + onChange: (value) => setSearchTerm(value), + } + : undefined, + [dataTestSubj, searchable, setSearchTerm] + ); + + const panelMinWidth = calculateWidthFromEntries(options, ['label']) + 2 * euiTheme.base; // plus extra width for the right Enter button + + return ( + + setIsOpen(!isOpen)} + onBlur={enableLabelPopover} + /> + + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + anchorPosition="downLeft" + > + {popoverTitle} + + {searchTerm}, + }} + /> +

+ ), + } + : {})} + > + {(list, search) => ( + <> + {search && ( + + {search} + + )} + {list} + + )} +
+
+ ); +}; diff --git a/src/plugins/unified_histogram/public/container/container.tsx b/src/plugins/unified_histogram/public/container/container.tsx index c65f1e2b43c04..fb152a1921e23 100644 --- a/src/plugins/unified_histogram/public/container/container.tsx +++ b/src/plugins/unified_histogram/public/container/container.tsx @@ -54,7 +54,7 @@ export type UnifiedHistogramContainerProps = { | 'relativeTimeRange' | 'columns' | 'container' - | 'appendHitsCounter' + | 'renderCustomChartToggleActions' | 'children' | 'onBrushEnd' | 'onFilter' diff --git a/src/plugins/unified_histogram/public/container/services/state_service.test.ts b/src/plugins/unified_histogram/public/container/services/state_service.test.ts index 73a493e167c19..40304a967243a 100644 --- a/src/plugins/unified_histogram/public/container/services/state_service.test.ts +++ b/src/plugins/unified_histogram/public/container/services/state_service.test.ts @@ -211,28 +211,4 @@ describe('UnifiedHistogramStateService', () => { expect(setTopPanelHeight as jest.Mock).not.toHaveBeenCalled(); expect(setBreakdownField as jest.Mock).not.toHaveBeenCalled(); }); - - it('should not update total hits to loading when the current status is partial', () => { - const stateService = createStateService({ - services: unifiedHistogramServicesMock, - initialState: { - ...initialState, - totalHitsStatus: UnifiedHistogramFetchStatus.partial, - }, - }); - let state: UnifiedHistogramState | undefined; - stateService.state$.subscribe((s) => (state = s)); - expect(state).toEqual({ - ...initialState, - totalHitsStatus: UnifiedHistogramFetchStatus.partial, - }); - stateService.setTotalHits({ - totalHitsStatus: UnifiedHistogramFetchStatus.loading, - totalHitsResult: 100, - }); - expect(state).toEqual({ - ...initialState, - totalHitsStatus: UnifiedHistogramFetchStatus.partial, - }); - }); }); diff --git a/src/plugins/unified_histogram/public/container/services/state_service.ts b/src/plugins/unified_histogram/public/container/services/state_service.ts index f96a4b5b7b033..1a79389e2bc6f 100644 --- a/src/plugins/unified_histogram/public/container/services/state_service.ts +++ b/src/plugins/unified_histogram/public/container/services/state_service.ts @@ -217,15 +217,6 @@ export const createStateService = ( totalHitsStatus: UnifiedHistogramFetchStatus; totalHitsResult: number | Error | undefined; }) => { - // If we have a partial result already, we don't - // want to update the total hits back to loading - if ( - state$.getValue().totalHitsStatus === UnifiedHistogramFetchStatus.partial && - totalHits.totalHitsStatus === UnifiedHistogramFetchStatus.loading - ) { - return; - } - updateState(totalHits); }, }; diff --git a/src/plugins/unified_histogram/public/hits_counter/hits_counter.test.tsx b/src/plugins/unified_histogram/public/hits_counter/hits_counter.test.tsx deleted file mode 100644 index 03b350448e9c2..0000000000000 --- a/src/plugins/unified_histogram/public/hits_counter/hits_counter.test.tsx +++ /dev/null @@ -1,72 +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 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 { mountWithIntl } from '@kbn/test-jest-helpers'; -import type { ReactWrapper } from 'enzyme'; -import type { HitsCounterProps } from './hits_counter'; -import { HitsCounter } from './hits_counter'; -import { findTestSubject } from '@elastic/eui/lib/test'; -import { EuiLoadingSpinner } from '@elastic/eui'; -import { UnifiedHistogramFetchStatus } from '../types'; - -describe('hits counter', function () { - let props: HitsCounterProps; - let component: ReactWrapper; - - beforeAll(() => { - props = { - hits: { - status: UnifiedHistogramFetchStatus.complete, - total: 2, - }, - }; - }); - - it('expect to render the number of hits', function () { - component = mountWithIntl(); - const hits = findTestSubject(component, 'unifiedHistogramQueryHits'); - expect(hits.text()).toBe('2'); - }); - - it('expect to render 1,899 hits if 1899 hits given', function () { - component = mountWithIntl( - - ); - const hits = findTestSubject(component, 'unifiedHistogramQueryHits'); - expect(hits.text()).toBe('1,899'); - }); - - it('should render the element passed to the append prop', () => { - const appendHitsCounter =
appendHitsCounter
; - component = mountWithIntl(); - expect(findTestSubject(component, 'appendHitsCounter').length).toBe(1); - }); - - it('should render a EuiLoadingSpinner when status is partial', () => { - component = mountWithIntl( - - ); - expect(component.find(EuiLoadingSpinner).length).toBe(1); - }); - - it('should render unifiedHistogramQueryHitsPartial when status is partial', () => { - component = mountWithIntl( - - ); - expect(component.find('[data-test-subj="unifiedHistogramQueryHitsPartial"]').length).toBe(1); - }); - - it('should render unifiedHistogramQueryHits when status is complete', () => { - component = mountWithIntl(); - expect(component.find('[data-test-subj="unifiedHistogramQueryHits"]').length).toBe(1); - }); -}); diff --git a/src/plugins/unified_histogram/public/hits_counter/hits_counter.tsx b/src/plugins/unified_histogram/public/hits_counter/hits_counter.tsx deleted file mode 100644 index b6f1212bfeaed..0000000000000 --- a/src/plugins/unified_histogram/public/hits_counter/hits_counter.tsx +++ /dev/null @@ -1,83 +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 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 type { ReactElement } from 'react'; -import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiText, EuiLoadingSpinner } from '@elastic/eui'; -import { FormattedMessage, FormattedNumber } from '@kbn/i18n-react'; -import { i18n } from '@kbn/i18n'; -import { css } from '@emotion/react'; -import type { UnifiedHistogramHitsContext } from '../types'; - -export interface HitsCounterProps { - hits: UnifiedHistogramHitsContext; - append?: ReactElement; -} - -export function HitsCounter({ hits, append }: HitsCounterProps) { - if (!hits.total && hits.status === 'loading') { - return null; - } - - const formattedHits = ( - - - - ); - - const hitsCounterCss = css` - flex-grow: 0; - `; - const hitsCounterTextCss = css` - overflow: hidden; - `; - - return ( - - - - {hits.status === 'partial' && ( - - )} - {hits.status !== 'partial' && ( - - )} - - - {hits.status === 'partial' && ( - - - - )} - {append} - - ); -} diff --git a/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.test.ts b/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.test.ts index 119356af6f63f..f74cc8a3c5925 100644 --- a/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.test.ts +++ b/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.test.ts @@ -137,7 +137,7 @@ describe('useLensSuggestions', () => { currentSuggestion: allSuggestionsMock[0], isOnHistogramMode: true, histogramQuery: { - esql: 'from the-data-view | limit 100 | EVAL timestamp=DATE_TRUNC(30 minute, @timestamp) | stats rows = count(*) by timestamp | rename timestamp as `@timestamp every 30 minute`', + esql: 'from the-data-view | limit 100 | EVAL timestamp=DATE_TRUNC(30 minute, @timestamp) | stats results = count(*) by timestamp | rename timestamp as `@timestamp every 30 minute`', }, suggestionUnsupported: false, }); diff --git a/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.ts b/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.ts index 063e1b7ef89a2..ac1053fd7fa3f 100644 --- a/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.ts +++ b/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.ts @@ -87,7 +87,7 @@ export const useLensSuggestions = ({ const interval = computeInterval(timeRange, data); const language = getAggregateQueryMode(query); const safeQuery = cleanupESQLQueryForLensSuggestions(query[language]); - const esqlQuery = `${safeQuery} | EVAL timestamp=DATE_TRUNC(${interval}, ${dataView.timeFieldName}) | stats rows = count(*) by timestamp | rename timestamp as \`${dataView.timeFieldName} every ${interval}\``; + const esqlQuery = `${safeQuery} | EVAL timestamp=DATE_TRUNC(${interval}, ${dataView.timeFieldName}) | stats results = count(*) by timestamp | rename timestamp as \`${dataView.timeFieldName} every ${interval}\``; const context = { dataViewSpec: dataView?.toSpec(), fieldName: '', @@ -100,8 +100,8 @@ export const useLensSuggestions = ({ }, }, { - id: 'rows', - name: 'rows', + id: 'results', + name: 'results', meta: { type: 'number', }, diff --git a/src/plugins/unified_histogram/public/layout/layout.test.tsx b/src/plugins/unified_histogram/public/layout/layout.test.tsx index a12c8cf46430e..a10df63e7c328 100644 --- a/src/plugins/unified_histogram/public/layout/layout.test.tsx +++ b/src/plugins/unified_histogram/public/layout/layout.test.tsx @@ -10,7 +10,6 @@ import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_ import { mountWithIntl } from '@kbn/test-jest-helpers'; import type { ReactWrapper } from 'enzyme'; import React from 'react'; -import { act } from 'react-dom/test-utils'; import { of } from 'rxjs'; import { Chart } from '../chart'; import { @@ -153,13 +152,6 @@ describe('Layout', () => { height: `${expectedHeight}px`, }); }); - - it('should pass undefined for onResetChartHeight to Chart when layout mode is ResizableLayoutMode.Static', async () => { - const component = await mountComponent({ topPanelHeight: 123 }); - expect(component.find(Chart).prop('onResetChartHeight')).toBeDefined(); - setBreakpoint(component, 's'); - expect(component.find(Chart).prop('onResetChartHeight')).toBeUndefined(); - }); }); describe('topPanelHeight', () => { @@ -167,39 +159,5 @@ describe('Layout', () => { const component = await mountComponent({ topPanelHeight: undefined }); expect(component.find(ResizableLayout).prop('fixedPanelSize')).toBeGreaterThan(0); }); - - it('should reset the fixedPanelSize to the default when onResetChartHeight is called on Chart', async () => { - const component: ReactWrapper = await mountComponent({ - onTopPanelHeightChange: jest.fn((topPanelHeight) => { - component.setProps({ topPanelHeight }); - }), - }); - const defaultTopPanelHeight = component.find(ResizableLayout).prop('fixedPanelSize'); - const newTopPanelHeight = 123; - expect(component.find(ResizableLayout).prop('fixedPanelSize')).not.toBe(newTopPanelHeight); - act(() => { - component.find(ResizableLayout).prop('onFixedPanelSizeChange')!(newTopPanelHeight); - }); - expect(component.find(ResizableLayout).prop('fixedPanelSize')).toBe(newTopPanelHeight); - act(() => { - component.find(Chart).prop('onResetChartHeight')!(); - }); - expect(component.find(ResizableLayout).prop('fixedPanelSize')).toBe(defaultTopPanelHeight); - }); - - it('should pass undefined for onResetChartHeight to Chart when the chart is the default height', async () => { - const component = await mountComponent({ - topPanelHeight: 123, - onTopPanelHeightChange: jest.fn((topPanelHeight) => { - component.setProps({ topPanelHeight }); - }), - }); - expect(component.find(Chart).prop('onResetChartHeight')).toBeDefined(); - act(() => { - component.find(Chart).prop('onResetChartHeight')!(); - }); - component.update(); - expect(component.find(Chart).prop('onResetChartHeight')).toBeUndefined(); - }); }); }); diff --git a/src/plugins/unified_histogram/public/layout/layout.tsx b/src/plugins/unified_histogram/public/layout/layout.tsx index 17eaf65fcde5f..1a175690eb447 100644 --- a/src/plugins/unified_histogram/public/layout/layout.tsx +++ b/src/plugins/unified_histogram/public/layout/layout.tsx @@ -7,7 +7,7 @@ */ import { EuiSpacer, useEuiTheme, useIsWithinBreakpoints } from '@elastic/eui'; -import React, { PropsWithChildren, ReactElement, useMemo, useState } from 'react'; +import React, { PropsWithChildren, ReactElement, useState } from 'react'; import { Observable } from 'rxjs'; import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; import { css } from '@emotion/css'; @@ -26,7 +26,7 @@ import { ResizableLayoutMode, ResizableLayoutDirection, } from '@kbn/resizable-layout'; -import { Chart } from '../chart'; +import { Chart, checkChartAvailability } from '../chart'; import type { UnifiedHistogramChartContext, UnifiedHistogramServices, @@ -39,6 +39,10 @@ import type { } from '../types'; import { useLensSuggestions } from './hooks/use_lens_suggestions'; +const ChartMemoized = React.memo(Chart); + +const chartSpacer = ; + export interface UnifiedHistogramLayoutProps extends PropsWithChildren { /** * Optional class name to add to the layout container @@ -107,9 +111,9 @@ export interface UnifiedHistogramLayoutProps extends PropsWithChildren */ topPanelHeight?: number; /** - * Append a custom element to the right of the hits count + * This element would replace the default chart toggle buttons */ - appendHitsCounter?: ReactElement; + renderCustomChartToggleActions?: () => ReactElement | undefined; /** * Disable automatic refetching based on props changes, and instead wait for a `refetch` message */ @@ -197,7 +201,7 @@ export const UnifiedHistogramLayout = ({ breakdown, container, topPanelHeight, - appendHitsCounter, + renderCustomChartToggleActions, disableAutoFetching, disableTriggers, disabledActions, @@ -234,6 +238,8 @@ export const UnifiedHistogramLayout = ({ }); const chart = suggestionUnsupported ? undefined : originalChart; + const isChartAvailable = checkChartAvailability({ chart, dataView, isPlainRecord }); + const [topPanelNode] = useState(() => createHtmlPortalNode({ attributes: { class: 'eui-fullHeight' } }) ); @@ -263,17 +269,11 @@ export const UnifiedHistogramLayout = ({ const currentTopPanelHeight = topPanelHeight ?? defaultTopPanelHeight; - const onResetChartHeight = useMemo(() => { - return currentTopPanelHeight !== defaultTopPanelHeight && - panelsMode === ResizableLayoutMode.Resizable - ? () => onTopPanelHeightChange?.(undefined) - : undefined; - }, [currentTopPanelHeight, defaultTopPanelHeight, onTopPanelHeightChange, panelsMode]); - return ( <> - } + renderCustomChartToggleActions={renderCustomChartToggleActions} + appendHistogram={chartSpacer} disableAutoFetching={disableAutoFetching} disableTriggers={disableTriggers} disabledActions={disabledActions} input$={input$} - onResetChartHeight={onResetChartHeight} onChartHiddenChange={onChartHiddenChange} onTimeIntervalChange={onTimeIntervalChange} onBreakdownFieldChange={onBreakdownFieldChange} @@ -311,7 +310,11 @@ export const UnifiedHistogramLayout = ({ withDefaultActions={withDefaultActions} /> - {children} + + {React.isValidElement(children) + ? React.cloneElement(children, { isChartAvailable }) + : children} + field.name === filter.meta.key); } +function getRangeOperatorFromFilter({ + meta: { params: { gte, gt, lte, lt } = {}, negate }, +}: RangeFilter | ScriptedRangeFilter) { + if (negate) { + // if filter is negated, always use 'is not between' operator + return OPERATORS.NOT_BETWEEN; + } + const left = gte ?? gt; + const right = lte ?? lt; + + if (left !== undefined && right === undefined) { + return OPERATORS.GREATER_OR_EQUAL; + } + + if (left === undefined && right !== undefined) { + return OPERATORS.LESS; + } + return OPERATORS.BETWEEN; +} + export function getOperatorFromFilter(filter: Filter) { return FILTER_OPERATORS.find((operator) => { + if (isRangeFilter(filter)) { + return getRangeOperatorFromFilter(filter) === operator.id; + } return filter.meta.type === operator.type && filter.meta.negate === operator.negate; }); } diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_operators.ts b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_operators.ts index 5bfc6540d37d9..1b54defae5b10 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_operators.ts +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_operators.ts @@ -32,6 +32,14 @@ export const strings = { i18n.translate('unifiedSearch.filter.filterEditor.isBetweenOperatorOptionLabel', { defaultMessage: 'is between', }), + getIsGreaterOrEqualOperatorOptionLabel: () => + i18n.translate('unifiedSearch.filter.filterEditor.greaterThanOrEqualOptionLabel', { + defaultMessage: 'greater or equal', + }), + getLessThanOperatorOptionLabel: () => + i18n.translate('unifiedSearch.filter.filterEditor.lessThanOrEqualOptionLabel', { + defaultMessage: 'less than', + }), getIsNotBetweenOperatorOptionLabel: () => i18n.translate('unifiedSearch.filter.filterEditor.isNotBetweenOperatorOptionLabel', { defaultMessage: 'is not between', @@ -46,10 +54,24 @@ export const strings = { }), }; +export enum OPERATORS { + LESS = 'less', + GREATER_OR_EQUAL = 'greater_or_equal', + BETWEEN = 'between', + IS = 'is', + NOT_BETWEEN = 'not_between', + IS_NOT = 'is_not', + IS_ONE_OF = 'is_one_of', + IS_NOT_ONE_OF = 'is_not_one_of', + EXISTS = 'exists', + DOES_NOT_EXIST = 'does_not_exist', +} + export interface Operator { message: string; type: FILTERS; negate: boolean; + id: OPERATORS; /** * KbnFieldTypes applicable for operator @@ -67,12 +89,14 @@ export const isOperator = { message: strings.getIsOperatorOptionLabel(), type: FILTERS.PHRASE, negate: false, + id: OPERATORS.IS, }; export const isNotOperator = { message: strings.getIsNotOperatorOptionLabel(), type: FILTERS.PHRASE, negate: true, + id: OPERATORS.IS_NOT, }; export const isOneOfOperator = { @@ -80,6 +104,7 @@ export const isOneOfOperator = { type: FILTERS.PHRASES, negate: false, fieldTypes: ['string', 'number', 'date', 'ip', 'geo_point', 'geo_shape'], + id: OPERATORS.IS_ONE_OF, }; export const isNotOneOfOperator = { @@ -87,12 +112,11 @@ export const isNotOneOfOperator = { type: FILTERS.PHRASES, negate: true, fieldTypes: ['string', 'number', 'date', 'ip', 'geo_point', 'geo_shape'], + id: OPERATORS.IS_NOT_ONE_OF, }; -export const isBetweenOperator = { - message: strings.getIsBetweenOperatorOptionLabel(), +const rangeOperatorsSharedProps = { type: FILTERS.RANGE, - negate: false, field: (field: DataViewField) => { if (['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'].includes(field.type)) return true; @@ -103,30 +127,46 @@ export const isBetweenOperator = { }, }; +export const isBetweenOperator = { + ...rangeOperatorsSharedProps, + message: strings.getIsBetweenOperatorOptionLabel(), + id: OPERATORS.BETWEEN, + negate: false, +}; + +export const isLessThanOperator = { + ...rangeOperatorsSharedProps, + message: strings.getLessThanOperatorOptionLabel(), + id: OPERATORS.LESS, + negate: false, +}; + +export const isGreaterOrEqualOperator = { + ...rangeOperatorsSharedProps, + message: strings.getIsGreaterOrEqualOperatorOptionLabel(), + id: OPERATORS.GREATER_OR_EQUAL, + negate: false, +}; + export const isNotBetweenOperator = { + ...rangeOperatorsSharedProps, message: strings.getIsNotBetweenOperatorOptionLabel(), - type: FILTERS.RANGE, negate: true, - field: (field: DataViewField) => { - if (['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'].includes(field.type)) - return true; - - if (field.type === 'string' && field.esTypes?.includes(ES_FIELD_TYPES.VERSION)) return true; - - return false; - }, + id: OPERATORS.NOT_BETWEEN, }; export const existsOperator = { message: strings.getExistsOperatorOptionLabel(), type: FILTERS.EXISTS, negate: false, + id: OPERATORS.EXISTS, }; export const doesNotExistOperator = { message: strings.getDoesNotExistOperatorOptionLabel(), type: FILTERS.EXISTS, negate: true, + id: OPERATORS.DOES_NOT_EXIST, }; export const FILTER_OPERATORS: Operator[] = [ @@ -134,6 +174,8 @@ export const FILTER_OPERATORS: Operator[] = [ isNotOperator, isOneOfOperator, isNotOneOfOperator, + isGreaterOrEqualOperator, + isLessThanOperator, isBetweenOperator, isNotBetweenOperator, existsOperator, diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/phrase_value_input.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/phrase_value_input.tsx index 9328ecfa66c50..e2e2d289d64e7 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/phrase_value_input.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/phrase_value_input.tsx @@ -19,6 +19,7 @@ import { MIDDLE_TRUNCATION_PROPS, SINGLE_SELECTION_AS_TEXT_PROPS } from './lib/h interface PhraseValueInputProps extends PhraseSuggestorProps { value?: string; onChange: (value: string | number | boolean) => void; + onBlur?: (value: string | number | boolean) => void; intl: InjectedIntl; fullWidth?: boolean; compressed?: boolean; @@ -43,6 +44,7 @@ class PhraseValueInputUI extends PhraseSuggestorUI { id: 'unifiedSearch.filter.filterEditor.valueInputPlaceholder', defaultMessage: 'Enter a value', })} + onBlur={this.props.onBlur} value={this.props.value} onChange={this.props.onChange} field={this.props.field} diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/range_value_input.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/range_value_input.tsx index 4f35d4a7f2d81..cb24ae53212ee 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/range_value_input.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/range_value_input.tsx @@ -11,8 +11,9 @@ import { EuiFormControlLayoutDelimited } from '@elastic/eui'; import { InjectedIntl, injectI18n } from '@kbn/i18n-react'; import { get } from 'lodash'; import React from 'react'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { KibanaReactContextValue, useKibana } from '@kbn/kibana-react-plugin/public'; import type { DataViewField } from '@kbn/data-views-plugin/common'; +import { CoreStart } from '@kbn/core/public'; import { ValueInputType } from './value_input_type'; interface RangeParams { @@ -36,19 +37,22 @@ export function isRangeParams(params: any): params is RangeParams { return Boolean(params && 'from' in params && 'to' in params); } -function RangeValueInputUI(props: Props) { - const kibana = useKibana(); +export const formatDateChange = ( + value: string | number | boolean, + kibana: KibanaReactContextValue> +) => { + if (typeof value !== 'string' && typeof value !== 'number') return value; - const formatDateChange = (value: string | number | boolean) => { - if (typeof value !== 'string' && typeof value !== 'number') return value; + const tzConfig = kibana.services.uiSettings!.get('dateFormat:tz'); + const tz = !tzConfig || tzConfig === 'Browser' ? moment.tz.guess() : tzConfig; + const momentParsedValue = moment(value).tz(tz); + if (momentParsedValue.isValid()) return momentParsedValue?.format('YYYY-MM-DDTHH:mm:ss.SSSZ'); - const tzConfig = kibana.services.uiSettings!.get('dateFormat:tz'); - const tz = !tzConfig || tzConfig === 'Browser' ? moment.tz.guess() : tzConfig; - const momentParsedValue = moment(value).tz(tz); - if (momentParsedValue.isValid()) return momentParsedValue?.format('YYYY-MM-DDTHH:mm:ss.SSSZ'); + return value; +}; - return value; - }; +function RangeValueInputUI(props: Props) { + const kibana = useKibana(); const onFromChange = (value: string | number | boolean) => { if (typeof value !== 'string' && typeof value !== 'number') { @@ -81,7 +85,7 @@ function RangeValueInputUI(props: Props) { value={props.value ? props.value.from : undefined} onChange={onFromChange} onBlur={(value) => { - onFromChange(formatDateChange(value)); + onFromChange(formatDateChange(value, kibana)); }} placeholder={props.intl.formatMessage({ id: 'unifiedSearch.filter.filterEditor.rangeStartInputPlaceholder', @@ -99,7 +103,7 @@ function RangeValueInputUI(props: Props) { value={props.value ? props.value.to : undefined} onChange={onToChange} onBlur={(value) => { - onToChange(formatDateChange(value)); + onToChange(formatDateChange(value, kibana)); }} placeholder={props.intl.formatMessage({ id: 'unifiedSearch.filter.filterEditor.rangeEndInputPlaceholder', diff --git a/src/plugins/unified_search/public/filters_builder/filter_item/filter_item.tsx b/src/plugins/unified_search/public/filters_builder/filter_item/filter_item.tsx index 97ea1f364b9d2..b789930dcda8d 100644 --- a/src/plugins/unified_search/public/filters_builder/filter_item/filter_item.tsx +++ b/src/plugins/unified_search/public/filters_builder/filter_item/filter_item.tsx @@ -105,22 +105,26 @@ export function FilterItem({ const conditionalOperationType = getBooleanRelationType(filter); const { euiTheme } = useEuiTheme(); let field: DataViewField | undefined; - let operator: Operator | undefined; let params: Filter['meta']['params']; const isMaxNesting = isMaxFilterNesting(path); if (!conditionalOperationType) { field = getFieldFromFilter(filter, dataView!); if (field) { - operator = getOperatorFromFilter(filter); params = getFilterParams(filter); } } + const [operator, setOperator] = useState(() => { + if (!conditionalOperationType && field) { + return getOperatorFromFilter(filter); + } + }); const [multiValueFilterParams, setMultiValueFilterParams] = useState< Array >(Array.isArray(params) ? params : []); const onHandleField = useCallback( (selectedField: DataViewField) => { + setOperator(undefined); dispatch({ type: 'updateFilter', payload: { dest: { path, index }, field: selectedField }, @@ -131,6 +135,7 @@ export function FilterItem({ const onHandleOperator = useCallback( (selectedOperator: Operator) => { + setOperator(selectedOperator); dispatch({ type: 'updateFilter', payload: { dest: { path, index }, field, operator: selectedOperator }, diff --git a/src/plugins/unified_search/public/filters_builder/filter_item/params_editor_input.tsx b/src/plugins/unified_search/public/filters_builder/filter_item/params_editor_input.tsx index 7d2cf5dc9c8d0..c3138f7a14e24 100644 --- a/src/plugins/unified_search/public/filters_builder/filter_item/params_editor_input.tsx +++ b/src/plugins/unified_search/public/filters_builder/filter_item/params_editor_input.tsx @@ -11,6 +11,7 @@ import { i18n } from '@kbn/i18n'; import { DataView, DataViewField } from '@kbn/data-views-plugin/common'; import { EuiFieldText } from '@elastic/eui'; import { Filter } from '@kbn/es-query'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; import { PhraseValueInput, PhrasesValuesInput, @@ -19,6 +20,8 @@ import { } from '../../filter_bar/filter_editor'; import type { Operator } from '../../filter_bar/filter_editor'; import { SuggestionsAbstraction } from '../../typeahead/suggestions_component'; +import { OPERATORS } from '../../filter_bar/filter_editor/lib/filter_operators'; +import { formatDateChange } from '../../filter_bar/filter_editor/range_value_input'; export const strings = { getSelectFieldPlaceholderLabel: () => @@ -70,6 +73,7 @@ export function ParamsEditorInput({ filtersForSuggestions, suggestionsAbstraction, }: ParamsEditorInputProps) { + const kibana = useKibana(); switch (operator?.type) { case 'exists': return null; @@ -106,16 +110,51 @@ export function ParamsEditorInput({ /> ); case 'range': - return ( - - ); + switch (operator.id) { + case OPERATORS.GREATER_OR_EQUAL: + return ( + { + onParamsChange({ from: formatDateChange(value, kibana) }); + }} + field={field!} + value={isRangeParams(params) && params.from ? `${params.from}` : undefined} + onChange={(value) => onParamsChange({ from: value })} + fullWidth + invalid={invalid} + disabled={disabled} + /> + ); + case OPERATORS.LESS: + return ( + { + onParamsChange({ to: formatDateChange(value, kibana) }); + }} + compressed + indexPattern={dataView} + field={field!} + value={isRangeParams(params) && params.to ? `${params.to}` : undefined} + onChange={(value) => onParamsChange({ to: value })} + fullWidth + invalid={invalid} + disabled={disabled} + /> + ); + default: + return ( + + ); + } default: const placeholderText = getPlaceholderText(Boolean(field), Boolean(operator?.type)); return ( diff --git a/src/plugins/vis_default_editor/public/components/agg_params_map.ts b/src/plugins/vis_default_editor/public/components/agg_params_map.ts index 7802da7bf9e2f..253d4e581a4f6 100644 --- a/src/plugins/vis_default_editor/public/components/agg_params_map.ts +++ b/src/plugins/vis_default_editor/public/components/agg_params_map.ts @@ -31,6 +31,9 @@ const buckets = { has_extended_bounds: controls.HasExtendedBoundsParamEditor, extended_bounds: controls.ExtendedBoundsParamEditor, }, + [BUCKET_TYPES.IP_PREFIX]: { + ipPrefix: controls.IpPrefixParamEditor, + }, [BUCKET_TYPES.IP_RANGE]: { ipRangeType: controls.IpRangeTypeParamEditor, ranges: controls.IpRangesParamEditor, diff --git a/src/plugins/vis_default_editor/public/components/controls/index.ts b/src/plugins/vis_default_editor/public/components/controls/index.ts index 3d040130b2acd..b1c2672328fc5 100644 --- a/src/plugins/vis_default_editor/public/components/controls/index.ts +++ b/src/plugins/vis_default_editor/public/components/controls/index.ts @@ -13,6 +13,7 @@ export { FieldParamEditor } from './field'; export { FiltersParamEditor } from './filters'; export { HasExtendedBoundsParamEditor } from './has_extended_bounds'; export { IncludeExcludeParamEditor } from './include_exclude'; +export { IpPrefixParamEditor } from './ip_prefix'; export { IpRangesParamEditor } from './ip_ranges'; export { IpRangeTypeParamEditor } from './ip_range_type'; export { MetricAggParamEditor } from './metric_agg'; diff --git a/src/plugins/vis_default_editor/public/components/controls/ip_prefix.tsx b/src/plugins/vis_default_editor/public/components/controls/ip_prefix.tsx new file mode 100644 index 0000000000000..02bd8111fa9af --- /dev/null +++ b/src/plugins/vis_default_editor/public/components/controls/ip_prefix.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, { ChangeEvent, useCallback } from 'react'; + +import { + EuiFormRow, + EuiFieldNumber, + EuiFieldNumberProps, + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + EuiSwitchEvent, + EuiSwitchProps, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { AggParamEditorProps } from '../agg_param_props'; +import { useValidation } from './utils'; + +export interface IpPrefix { + prefixLength: number; + isIpv6: boolean; +} + +function isPrefixValid({ prefixLength, isIpv6 }: IpPrefix): boolean { + if (prefixLength < 0) { + return false; + } else if (prefixLength > 32 && !isIpv6) { + return false; + } else if (prefixLength > 128 && isIpv6) { + return false; + } + + return true; +} + +const prefixLengthLabel = i18n.translate('visDefaultEditor.controls.IpPrefix.prefixLength', { + defaultMessage: 'Prefix length', +}); + +const isIpv6Label = i18n.translate('visDefaultEditor.controls.IpPrefix.isIpv6', { + defaultMessage: 'Prefix applies to IPv6 addresses', +}); + +function IpPrefixParamEditor({ + agg, + value = {} as IpPrefix, + setTouched, + setValue, + setValidity, + showValidation, +}: AggParamEditorProps) { + const isValid = isPrefixValid(value); + let error; + + if (!isValid) { + if (!value.isIpv6) { + error = i18n.translate('visDefaultEditor.controls.ipPrefix.errorMessageIpv4', { + defaultMessage: 'Prefix length must be between 0 and 32 for IPv4 addresses.', + }); + } else { + error = i18n.translate('visDefaultEditor.controls.ipPrefix.errorMessageIpv6', { + defaultMessage: 'Prefix length must be between 0 and 128 for IPv6 addresses.', + }); + } + } + + useValidation(setValidity, isValid); + + const onPrefixLengthChange: EuiFieldNumberProps['onChange'] = useCallback( + (ev: ChangeEvent) => { + setValue({ ...value, prefixLength: ev.target.valueAsNumber }); + }, + [setValue, value] + ); + + const onIsIpv6Change: EuiSwitchProps['onChange'] = useCallback( + (ev: EuiSwitchEvent) => { + setValue({ ...value, isIpv6: ev.target.checked }); + }, + [setValue, value] + ); + + return ( + + + + + + + + + + + ); +} + +export { IpPrefixParamEditor }; diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx index 7743ca46f95ba..285612700863c 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx @@ -40,6 +40,7 @@ import { import type { RenderMode } from '@kbn/expressions-plugin/common'; import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/public'; import { mapAndFlattenFilters } from '@kbn/data-plugin/public'; +import { isChartSizeEvent } from '@kbn/chart-expressions-common'; import { isFallbackDataView } from '../visualize_app/utils'; import { VisualizationMissedSavedObjectError } from '../components/visualization_missed_saved_object_error'; import VisualizationError from '../components/visualization_error'; @@ -477,6 +478,10 @@ export class VisualizeEmbeddable this.handler.events$ .pipe( mergeMap(async (event) => { + // Visualize doesn't respond to sizing events, so ignore. + if (isChartSizeEvent(event)) { + return; + } if (!this.input.disableTriggers) { const triggerId = get(VIS_EVENT_TO_TRIGGER, event.name, VIS_EVENT_TO_TRIGGER.filter); let context; diff --git a/src/plugins/visualizations/tsconfig.json b/src/plugins/visualizations/tsconfig.json index 813c47ca83872..296367543271a 100644 --- a/src/plugins/visualizations/tsconfig.json +++ b/src/plugins/visualizations/tsconfig.json @@ -66,6 +66,7 @@ "@kbn/search-response-warnings", "@kbn/logging", "@kbn/content-management-table-list-view-common", + "@kbn/chart-expressions-common", "@kbn/shared-ux-utility" ], "exclude": [ diff --git a/test/accessibility/apps/discover.ts b/test/accessibility/apps/discover.ts index 3f9a1ef9bce0c..4deb2acb66d74 100644 --- a/test/accessibility/apps/discover.ts +++ b/test/accessibility/apps/discover.ts @@ -159,24 +159,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await a11y.testAppSnapshot(); }); - it('a11y test for chart options panel', async () => { - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await a11y.testAppSnapshot(); - }); - it('a11y test for data grid with hidden chart', async () => { - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.closeHistogramPanel(); await a11y.testAppSnapshot(); - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.openHistogramPanel(); }); it('a11y test for time interval panel', async () => { - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramTimeIntervalPanel'); + await testSubjects.click('unifiedHistogramTimeIntervalSelectorButton'); await a11y.testAppSnapshot(); - await testSubjects.click('contextMenuPanelTitleButton'); - await testSubjects.click('unifiedHistogramChartOptionsToggle'); }); it('a11y test for data grid sort panel', async () => { @@ -205,7 +196,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('a11y test for data grid with collapsed side bar', async () => { await PageObjects.discover.closeSidebar(); await a11y.testAppSnapshot(); - await PageObjects.discover.toggleSidebarCollapse(); + await PageObjects.discover.openSidebar(); }); it('a11y test for adding a field from side bar', async () => { diff --git a/test/functional/apps/discover/group1/_discover.ts b/test/functional/apps/discover/group1/_discover.ts index a041939f2b809..0210c7d8cc7f2 100644 --- a/test/functional/apps/discover/group1/_discover.ts +++ b/test/functional/apps/discover/group1/_discover.ts @@ -114,10 +114,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should show correct initial chart interval of Auto', async function () { await PageObjects.timePicker.setDefaultAbsoluteRange(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.click('unifiedHistogramQueryHits'); // to cancel out tooltips + await testSubjects.click('discoverQueryHits'); // to cancel out tooltips const actualInterval = await PageObjects.discover.getChartInterval(); - const expectedInterval = 'Auto'; + const expectedInterval = 'auto'; expect(actualInterval).to.be(expectedInterval); }); diff --git a/test/functional/apps/discover/group1/_discover_histogram.ts b/test/functional/apps/discover/group1/_discover_histogram.ts index 64e9b0e47dc90..ad5563e78f918 100644 --- a/test/functional/apps/discover/group1/_discover_histogram.ts +++ b/test/functional/apps/discover/group1/_discover_histogram.ts @@ -156,6 +156,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const chartCanvasExist = await elasticChart.canvasExists(); expect(chartCanvasExist).to.be(true); const chartIntervalIconTip = await PageObjects.discover.getChartIntervalWarningIcon(); + expect(chartIntervalIconTip).to.be(false); + }); + it('should visualize monthly data with different years scaled to seconds', async () => { + const from = 'Jan 1, 2010 @ 00:00:00.000'; + const to = 'Mar 21, 2019 @ 00:00:00.000'; + await prepareTest({ from, to }, 'Second'); + const chartCanvasExist = await elasticChart.canvasExists(); + expect(chartCanvasExist).to.be(true); + const chartIntervalIconTip = await PageObjects.discover.getChartIntervalWarningIcon(); expect(chartIntervalIconTip).to.be(true); }); it('should allow hide/show histogram, persisted in url state', async () => { @@ -164,8 +173,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await prepareTest({ from, to }); let canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(true); - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(false); @@ -174,8 +182,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await browser.refresh(); canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(false); - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); @@ -189,8 +196,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await prepareTest({ from, to }); // close chart for saved search - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); let canvasExists: boolean; await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); @@ -212,8 +218,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(canvasExists).to.be(false); // open chart for saved search - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await retry.waitFor(`Discover histogram to be displayed`, async () => { canvasExists = await elasticChart.canvasExists(); return canvasExists; @@ -235,8 +240,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should show permitted hidden histogram state when returning back to discover', async () => { // close chart - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); let canvasExists: boolean; await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); @@ -248,8 +252,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); // open chart - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(true); @@ -266,8 +269,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(canvasExists).to.be(true); // close chart - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(false); @@ -278,8 +280,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('discover'); await PageObjects.discover.waitUntilSearchingHasFinished(); // Make sure the chart is visible - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await PageObjects.discover.waitUntilSearchingHasFinished(); // type an invalid search query, hit refresh await queryBar.setQuery('this is > not valid'); diff --git a/test/functional/apps/discover/group2/_data_grid.ts b/test/functional/apps/discover/group2/_data_grid.ts index 2facbc95c93ce..cdce56db6e856 100644 --- a/test/functional/apps/discover/group2/_data_grid.ts +++ b/test/functional/apps/discover/group2/_data_grid.ts @@ -71,17 +71,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should hide elements beneath the table when in full screen mode regardless of their z-index', async () => { await retry.try(async () => { - expect(await isVisible('unifiedHistogramQueryHits')).to.be(true); + expect(await isVisible('discover-dataView-switch-link')).to.be(true); expect(await isVisible('unifiedHistogramResizableButton')).to.be(true); }); await testSubjects.click('dataGridFullScreenButton'); await retry.try(async () => { - expect(await isVisible('unifiedHistogramQueryHits')).to.be(false); + expect(await isVisible('discover-dataView-switch-link')).to.be(false); expect(await isVisible('unifiedHistogramResizableButton')).to.be(false); }); await testSubjects.click('dataGridFullScreenButton'); await retry.try(async () => { - expect(await isVisible('unifiedHistogramQueryHits')).to.be(true); + expect(await isVisible('discover-dataView-switch-link')).to.be(true); expect(await isVisible('unifiedHistogramResizableButton')).to.be(true); }); }); diff --git a/test/functional/apps/discover/group3/_panels_toggle.ts b/test/functional/apps/discover/group3/_panels_toggle.ts new file mode 100644 index 0000000000000..d471969d3528f --- /dev/null +++ b/test/functional/apps/discover/group3/_panels_toggle.ts @@ -0,0 +1,261 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const testSubjects = getService('testSubjects'); + const monacoEditor = getService('monacoEditor'); + const PageObjects = getPageObjects([ + 'settings', + 'common', + 'discover', + 'header', + 'timePicker', + 'unifiedFieldList', + ]); + const security = getService('security'); + const defaultSettings = { + defaultIndex: 'logstash-*', + hideAnnouncements: true, + }; + + describe('discover panels toggle', function () { + before(async () => { + await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover'); + }); + + after(async () => { + await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover'); + await kibanaServer.uiSettings.replace({}); + await kibanaServer.savedObjects.cleanStandardList(); + }); + + async function checkSidebarAndHistogram({ + shouldSidebarBeOpen, + shouldHistogramBeOpen, + isChartAvailable, + totalHits, + }: { + shouldSidebarBeOpen: boolean; + shouldHistogramBeOpen: boolean; + isChartAvailable: boolean; + totalHits: string; + }) { + expect(await PageObjects.discover.getHitCount()).to.be(totalHits); + + if (shouldSidebarBeOpen) { + expect(await PageObjects.discover.isSidebarPanelOpen()).to.be(true); + await testSubjects.existOrFail('unifiedFieldListSidebar__toggle-collapse'); + await testSubjects.missingOrFail('dscShowSidebarButton'); + } else { + expect(await PageObjects.discover.isSidebarPanelOpen()).to.be(false); + await testSubjects.missingOrFail('unifiedFieldListSidebar__toggle-collapse'); + await testSubjects.existOrFail('dscShowSidebarButton'); + } + + if (isChartAvailable) { + expect(await PageObjects.discover.isChartVisible()).to.be(shouldHistogramBeOpen); + if (shouldHistogramBeOpen) { + await testSubjects.existOrFail('dscPanelsToggleInHistogram'); + await testSubjects.existOrFail('dscHideHistogramButton'); + + await testSubjects.missingOrFail('dscPanelsToggleInPage'); + await testSubjects.missingOrFail('dscShowHistogramButton'); + } else { + await testSubjects.existOrFail('dscPanelsToggleInPage'); + await testSubjects.existOrFail('dscShowHistogramButton'); + + await testSubjects.missingOrFail('dscPanelsToggleInHistogram'); + await testSubjects.missingOrFail('dscHideHistogramButton'); + } + } else { + expect(await PageObjects.discover.isChartVisible()).to.be(false); + await testSubjects.missingOrFail('dscPanelsToggleInHistogram'); + await testSubjects.missingOrFail('dscHideHistogramButton'); + await testSubjects.missingOrFail('dscShowHistogramButton'); + + if (shouldSidebarBeOpen) { + await testSubjects.missingOrFail('dscPanelsToggleInPage'); + } else { + await testSubjects.existOrFail('dscPanelsToggleInPage'); + } + } + } + + function checkPanelsToggle({ + isChartAvailable, + totalHits, + }: { + isChartAvailable: boolean; + totalHits: string; + }) { + it('sidebar can be toggled', async () => { + await checkSidebarAndHistogram({ + shouldSidebarBeOpen: true, + shouldHistogramBeOpen: true, + isChartAvailable, + totalHits, + }); + + await PageObjects.discover.closeSidebar(); + + await checkSidebarAndHistogram({ + shouldSidebarBeOpen: false, + shouldHistogramBeOpen: true, + isChartAvailable, + totalHits, + }); + + await PageObjects.discover.openSidebar(); + + await checkSidebarAndHistogram({ + shouldSidebarBeOpen: true, + shouldHistogramBeOpen: true, + isChartAvailable, + totalHits, + }); + }); + + if (isChartAvailable) { + it('histogram can be toggled', async () => { + await checkSidebarAndHistogram({ + shouldSidebarBeOpen: true, + shouldHistogramBeOpen: true, + isChartAvailable, + totalHits, + }); + + await PageObjects.discover.closeHistogramPanel(); + + await checkSidebarAndHistogram({ + shouldSidebarBeOpen: true, + shouldHistogramBeOpen: false, + isChartAvailable, + totalHits, + }); + + await PageObjects.discover.openHistogramPanel(); + + await checkSidebarAndHistogram({ + shouldSidebarBeOpen: true, + shouldHistogramBeOpen: true, + isChartAvailable, + totalHits, + }); + }); + + it('sidebar and histogram can be toggled', async () => { + await checkSidebarAndHistogram({ + shouldSidebarBeOpen: true, + shouldHistogramBeOpen: true, + isChartAvailable, + totalHits, + }); + + await PageObjects.discover.closeSidebar(); + await PageObjects.discover.closeHistogramPanel(); + + await checkSidebarAndHistogram({ + shouldSidebarBeOpen: false, + shouldHistogramBeOpen: false, + isChartAvailable, + totalHits, + }); + + await PageObjects.discover.openSidebar(); + await PageObjects.discover.openHistogramPanel(); + + await checkSidebarAndHistogram({ + shouldSidebarBeOpen: true, + shouldHistogramBeOpen: true, + isChartAvailable, + totalHits, + }); + }); + } + } + + describe('time based data view', function () { + before(async function () { + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update(defaultSettings); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); + }); + + checkPanelsToggle({ isChartAvailable: true, totalHits: '14,004' }); + }); + + describe('non-time based data view', function () { + before(async function () { + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update(defaultSettings); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.createAdHocDataView('log*', false); + await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); + }); + + checkPanelsToggle({ isChartAvailable: false, totalHits: '14,004' }); + }); + + describe('text-based with histogram chart', function () { + before(async function () { + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update(defaultSettings); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.selectTextBaseLang(); + await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); + }); + + checkPanelsToggle({ isChartAvailable: true, totalHits: '10' }); + }); + + describe('text-based with aggs chart', function () { + before(async function () { + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update(defaultSettings); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.selectTextBaseLang(); + await monacoEditor.setCodeEditorValue( + 'from logstash-* | stats avg(bytes) by extension | limit 100' + ); + await testSubjects.click('querySubmitButton'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); + }); + + checkPanelsToggle({ isChartAvailable: true, totalHits: '5' }); + }); + + describe('text-based without a time field', function () { + before(async function () { + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update(defaultSettings); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.createAdHocDataView('log*', false); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.selectTextBaseLang(); + await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); + }); + + checkPanelsToggle({ isChartAvailable: false, totalHits: '10' }); + }); + }); +} diff --git a/test/functional/apps/discover/group3/_request_counts.ts b/test/functional/apps/discover/group3/_request_counts.ts index a1038b3f7e4ee..d462155a3e029 100644 --- a/test/functional/apps/discover/group3/_request_counts.ts +++ b/test/functional/apps/discover/group3/_request_counts.ts @@ -240,7 +240,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { savedSearch: 'esql test', query1: 'from logstash-* | where bytes > 1000 | stats countB = count(bytes) ', query2: 'from logstash-* | where bytes < 2000 | stats countB = count(bytes) ', - savedSearchesRequests: 4, + savedSearchesRequests: 3, setQuery: (query) => monacoEditor.setCodeEditorValue(query), }); }); diff --git a/test/functional/apps/discover/group3/_sidebar.ts b/test/functional/apps/discover/group3/_sidebar.ts index 313c350209930..cae06dd375b46 100644 --- a/test/functional/apps/discover/group3/_sidebar.ts +++ b/test/functional/apps/discover/group3/_sidebar.ts @@ -273,13 +273,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should collapse when clicked', async function () { - await PageObjects.discover.toggleSidebarCollapse(); - await testSubjects.existOrFail('discover-sidebar'); + await PageObjects.discover.closeSidebar(); + await testSubjects.existOrFail('dscShowSidebarButton'); await testSubjects.missingOrFail('fieldList'); }); it('should expand when clicked', async function () { - await PageObjects.discover.toggleSidebarCollapse(); + await PageObjects.discover.openSidebar(); await testSubjects.existOrFail('discover-sidebar'); await testSubjects.existOrFail('fieldList'); }); diff --git a/test/functional/apps/discover/group3/_unsaved_changes_badge.ts b/test/functional/apps/discover/group3/_unsaved_changes_badge.ts index c931a11f4f5f4..305298ff2ccc6 100644 --- a/test/functional/apps/discover/group3/_unsaved_changes_badge.ts +++ b/test/functional/apps/discover/group3/_unsaved_changes_badge.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../ftr_provider_context'; const SAVED_SEARCH_NAME = 'test saved search'; const SAVED_SEARCH_WITH_FILTERS_NAME = 'test saved search with filters'; +const SAVED_SEARCH_ESQL = 'test saved search ES|QL'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -18,6 +19,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const dataGrid = getService('dataGrid'); const filterBar = getService('filterBar'); + const monacoEditor = getService('monacoEditor'); + const browser = getService('browser'); const PageObjects = getPageObjects([ 'settings', 'common', @@ -194,5 +197,32 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await filterBar.isFilterNegated('bytes')).to.be(false); expect(await PageObjects.discover.getHitCount()).to.be('1,373'); }); + + it('should not show a badge after loading an ES|QL saved search, only after changes', async () => { + await PageObjects.discover.selectTextBaseLang(); + + await monacoEditor.setCodeEditorValue('from logstash-* | limit 10'); + await testSubjects.click('querySubmitButton'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await PageObjects.discover.saveSearch(SAVED_SEARCH_ESQL); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + + await browser.refresh(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.missingOrFail('unsavedChangesBadge'); + + await monacoEditor.setCodeEditorValue('from logstash-* | limit 100'); + await testSubjects.click('querySubmitButton'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.existOrFail('unsavedChangesBadge'); + }); }); } diff --git a/test/functional/apps/discover/group3/index.ts b/test/functional/apps/discover/group3/index.ts index 848bdc84def4d..e2e0706cd6b4a 100644 --- a/test/functional/apps/discover/group3/index.ts +++ b/test/functional/apps/discover/group3/index.ts @@ -27,5 +27,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_doc_viewer')); loadTestFile(require.resolve('./_view_mode_toggle')); loadTestFile(require.resolve('./_unsaved_changes_badge')); + loadTestFile(require.resolve('./_panels_toggle')); }); } diff --git a/test/functional/apps/discover/group4/_esql_view.ts b/test/functional/apps/discover/group4/_esql_view.ts index 2b6547152970d..fd9060f9b9ec8 100644 --- a/test/functional/apps/discover/group4/_esql_view.ts +++ b/test/functional/apps/discover/group4/_esql_view.ts @@ -52,7 +52,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await testSubjects.exists('addFilter')).to.be(true); expect(await testSubjects.exists('dscViewModeDocumentButton')).to.be(true); expect(await testSubjects.exists('unifiedHistogramChart')).to.be(true); - expect(await testSubjects.exists('unifiedHistogramQueryHits')).to.be(true); + expect(await testSubjects.exists('discoverQueryHits')).to.be(true); expect(await testSubjects.exists('discoverAlertsButton')).to.be(true); expect(await testSubjects.exists('shareTopNavButton')).to.be(true); expect(await testSubjects.exists('docTableExpandToggleColumn')).to.be(true); @@ -74,7 +74,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await testSubjects.exists('dscViewModeDocumentButton')).to.be(false); // when Lens suggests a table, we render an ESQL based histogram expect(await testSubjects.exists('unifiedHistogramChart')).to.be(true); - expect(await testSubjects.exists('unifiedHistogramQueryHits')).to.be(true); + expect(await testSubjects.exists('discoverQueryHits')).to.be(true); expect(await testSubjects.exists('discoverAlertsButton')).to.be(true); expect(await testSubjects.exists('shareTopNavButton')).to.be(true); expect(await testSubjects.exists('dataGridColumnSortingButton')).to.be(false); diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 3ec60eae8e407..658e235c77d33 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -215,12 +215,26 @@ export class DiscoverPageObject extends FtrService { ); } - public async chooseBreakdownField(field: string) { - await this.comboBox.set('unifiedHistogramBreakdownFieldSelector', field); + public async chooseBreakdownField(field: string, value?: string) { + await this.retry.try(async () => { + await this.testSubjects.click('unifiedHistogramBreakdownSelectorButton'); + await this.testSubjects.existOrFail('unifiedHistogramBreakdownSelectorSelectable'); + }); + + await ( + await this.testSubjects.find('unifiedHistogramBreakdownSelectorSelectorSearch') + ).type(field); + + const option = await this.find.byCssSelector( + `[data-test-subj="unifiedHistogramBreakdownSelectorSelectable"] .euiSelectableListItem[value="${ + value ?? field + }"]` + ); + await option.click(); } public async clearBreakdownField() { - await this.comboBox.clear('unifiedHistogramBreakdownFieldSelector'); + await this.chooseBreakdownField('No breakdown', '__EMPTY_SELECTOR_OPTION__'); } public async chooseLensChart(chart: string) { @@ -248,36 +262,52 @@ export class DiscoverPageObject extends FtrService { } public async toggleChartVisibility() { - await this.testSubjects.moveMouseTo('unifiedHistogramChartOptionsToggle'); - await this.testSubjects.click('unifiedHistogramChartOptionsToggle'); - await this.testSubjects.exists('unifiedHistogramChartToggle'); - await this.testSubjects.click('unifiedHistogramChartToggle'); + if (await this.isChartVisible()) { + await this.testSubjects.click('dscHideHistogramButton'); + } else { + await this.testSubjects.click('dscShowHistogramButton'); + } + await this.header.waitUntilLoadingHasFinished(); + } + + public async openHistogramPanel() { + await this.testSubjects.click('dscShowHistogramButton'); + await this.header.waitUntilLoadingHasFinished(); + } + + public async closeHistogramPanel() { + await this.testSubjects.click('dscHideHistogramButton'); await this.header.waitUntilLoadingHasFinished(); } public async getChartInterval() { - await this.testSubjects.click('unifiedHistogramChartOptionsToggle'); - await this.testSubjects.click('unifiedHistogramTimeIntervalPanel'); - const selectedOption = await this.find.byCssSelector(`.unifiedHistogramIntervalSelected`); - return selectedOption.getVisibleText(); + const button = await this.testSubjects.find('unifiedHistogramTimeIntervalSelectorButton'); + return await button.getAttribute('data-selected-value'); } public async getChartIntervalWarningIcon() { - await this.testSubjects.click('unifiedHistogramChartOptionsToggle'); await this.header.waitUntilLoadingHasFinished(); - return await this.find.existsByCssSelector('.euiToolTipAnchor'); + return await this.find.existsByCssSelector( + '[data-test-subj="unifiedHistogramRendered"] .euiToolTipAnchor' + ); } - public async setChartInterval(interval: string) { - await this.testSubjects.click('unifiedHistogramChartOptionsToggle'); - await this.testSubjects.click('unifiedHistogramTimeIntervalPanel'); - await this.testSubjects.click(`unifiedHistogramTimeInterval-${interval}`); + public async setChartInterval(intervalTitle: string) { + await this.retry.try(async () => { + await this.testSubjects.click('unifiedHistogramTimeIntervalSelectorButton'); + await this.testSubjects.existOrFail('unifiedHistogramTimeIntervalSelectorSelectable'); + }); + + const option = await this.find.byCssSelector( + `[data-test-subj="unifiedHistogramTimeIntervalSelectorSelectable"] .euiSelectableListItem[title="${intervalTitle}"]` + ); + await option.click(); return await this.header.waitUntilLoadingHasFinished(); } public async getHitCount() { await this.header.waitUntilLoadingHasFinished(); - return await this.testSubjects.getVisibleText('unifiedHistogramQueryHits'); + return await this.testSubjects.getVisibleText('discoverQueryHits'); } public async getHitCountInt() { @@ -398,8 +428,12 @@ export class DiscoverPageObject extends FtrService { return await Promise.all(marks.map((mark) => mark.getVisibleText())); } - public async toggleSidebarCollapse() { - return await this.testSubjects.click('unifiedFieldListSidebar__toggle'); + public async openSidebar() { + await this.testSubjects.click('dscShowSidebarButton'); + + await this.retry.waitFor('sidebar to appear', async () => { + return await this.isSidebarPanelOpen(); + }); } public async closeSidebar() { @@ -410,6 +444,13 @@ export class DiscoverPageObject extends FtrService { }); } + public async isSidebarPanelOpen() { + return ( + (await this.testSubjects.exists('fieldList')) && + (await this.testSubjects.exists('unifiedFieldListSidebar__toggle-collapse')) + ); + } + public async editField(field: string) { await this.retry.try(async () => { await this.unifiedFieldList.pressEnterFieldListItemToggle(field); diff --git a/tsconfig.base.json b/tsconfig.base.json index 406b2f3dda838..a4f658e7acb62 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1100,6 +1100,8 @@ "@kbn/ml-url-state/*": ["x-pack/packages/ml/url_state/*"], "@kbn/mock-idp-plugin": ["packages/kbn-mock-idp-plugin"], "@kbn/mock-idp-plugin/*": ["packages/kbn-mock-idp-plugin/*"], + "@kbn/mock-idp-utils": ["packages/kbn-mock-idp-utils"], + "@kbn/mock-idp-utils/*": ["packages/kbn-mock-idp-utils/*"], "@kbn/monaco": ["packages/kbn-monaco"], "@kbn/monaco/*": ["packages/kbn-monaco/*"], "@kbn/monitoring-collection-plugin": ["x-pack/plugins/monitoring_collection"], diff --git a/x-pack/packages/ml/in_memory_table/hooks/use_table_state.ts b/x-pack/packages/ml/in_memory_table/hooks/use_table_state.ts index 726c3eb0dd268..74c8a54b69c57 100644 --- a/x-pack/packages/ml/in_memory_table/hooks/use_table_state.ts +++ b/x-pack/packages/ml/in_memory_table/hooks/use_table_state.ts @@ -39,10 +39,11 @@ export interface UseTableState { export function useTableState( items: T[], initialSortField: string, - initialSortDirection: 'asc' | 'desc' = 'asc' + initialSortDirection: 'asc' | 'desc' = 'asc', + initialPagionation?: Partial ) { - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(10); + const [pageIndex, setPageIndex] = useState(initialPagionation?.pageIndex ?? 0); + const [pageSize, setPageSize] = useState(initialPagionation?.pageSize ?? 10); const [sortField, setSortField] = useState(initialSortField); const [sortDirection, setSortDirection] = useState(initialSortDirection); @@ -63,7 +64,7 @@ export function useTableState( pageIndex, pageSize, totalItemCount: (items ?? []).length, - pageSizeOptions: [10, 20, 50], + pageSizeOptions: initialPagionation?.pageSizeOptions ?? [10, 20, 50], showPerPageOptions: true, }; diff --git a/x-pack/performance/journeys/infra_hosts_view.ts b/x-pack/performance/journeys/infra_hosts_view.ts new file mode 100644 index 0000000000000..b936cc7c1719c --- /dev/null +++ b/x-pack/performance/journeys/infra_hosts_view.ts @@ -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 { Journey } from '@kbn/journeys'; +import { + createLogger, + InfraSynthtraceEsClient, + LogLevel, + InfraSynthtraceKibanaClient, +} from '@kbn/apm-synthtrace'; +import { infra, timerange } from '@kbn/apm-synthtrace-client'; +import { subj } from '@kbn/test-subj-selector'; + +export const journey = new Journey({ + beforeSteps: async ({ kbnUrl, auth, es }) => { + const logger = createLogger(LogLevel.debug); + const synthKibanaClient = new InfraSynthtraceKibanaClient({ + logger, + target: kbnUrl.get(), + username: auth.getUsername(), + password: auth.getPassword(), + }); + + const pkgVersion = await synthKibanaClient.fetchLatestSystemPackageVersion(); + await synthKibanaClient.installSystemPackage(pkgVersion); + + const synthEsClient = new InfraSynthtraceEsClient({ + logger, + client: es, + refreshAfterIndex: true, + }); + + const start = Date.now() - 1000 * 60 * 10; + await synthEsClient.index( + generateHostsData({ + from: new Date(start).toISOString(), + to: new Date().toISOString(), + count: 1000, + }) + ); + }, +}).step('Navigate to Hosts view and load 500 hosts', async ({ page, kbnUrl, kibanaPage }) => { + await page.goto( + kbnUrl.get( + `app/metrics/hosts?_a=(dateRange:(from:now-15m,to:now),filters:!(),limit:500,panelFilters:!(),query:(language:kuery,query:''))` + ) + ); + // wait for table to be loaded + await page.waitForSelector(subj('hostsView-table-loaded')); + // wait for metric charts to be loaded + await kibanaPage.waitForCharts({ count: 5, timeout: 60000 }); +}); + +export function generateHostsData({ + from, + to, + count = 1, +}: { + from: string; + to: string; + count: number; +}) { + const range = timerange(from, to); + + const hosts = Array(count) + .fill(0) + .map((_, idx) => infra.host(`my-host-${idx}`)); + + return range + .interval('30s') + .rate(1) + .generator((timestamp, index) => + hosts.flatMap((host) => [ + host.cpu().timestamp(timestamp), + host.memory().timestamp(timestamp), + host.network().timestamp(timestamp), + host.load().timestamp(timestamp), + host.filesystem().timestamp(timestamp), + host.diskio().timestamp(timestamp), + ]) + ); +} diff --git a/x-pack/plugins/aiops/common/constants.ts b/x-pack/plugins/aiops/common/constants.ts index 5916464e90980..334bb64dd2484 100644 --- a/x-pack/plugins/aiops/common/constants.ts +++ b/x-pack/plugins/aiops/common/constants.ts @@ -26,9 +26,19 @@ export const CASES_ATTACHMENT_CHANGE_POINT_CHART = 'aiopsChangePointChart'; export const EMBEDDABLE_CHANGE_POINT_CHART_TYPE = 'aiopsChangePointChart' as const; +export type EmbeddableChangePointType = typeof EMBEDDABLE_CHANGE_POINT_CHART_TYPE; + export const AIOPS_TELEMETRY_ID = { AIOPS_DEFAULT_SOURCE: 'ml_aiops_labs', AIOPS_ANALYSIS_RUN_ORIGIN: 'aiops-analysis-run-origin', } as const; export const EMBEDDABLE_ORIGIN = 'embeddable'; + +export const CHANGE_POINT_DETECTION_VIEW_TYPE = { + CHARTS: 'charts', + TABLE: 'table', +} as const; + +export type ChangePointDetectionViewType = + typeof CHANGE_POINT_DETECTION_VIEW_TYPE[keyof typeof CHANGE_POINT_DETECTION_VIEW_TYPE]; diff --git a/x-pack/plugins/aiops/public/cases/change_point_charts_attachment.tsx b/x-pack/plugins/aiops/public/cases/change_point_charts_attachment.tsx index 4aa830328e805..c5b88bc7e92cd 100644 --- a/x-pack/plugins/aiops/public/cases/change_point_charts_attachment.tsx +++ b/x-pack/plugins/aiops/public/cases/change_point_charts_attachment.tsx @@ -45,7 +45,7 @@ export const initComponent = memoize( return ( <> - + ); }, diff --git a/x-pack/plugins/aiops/public/cases/register_change_point_charts_attachment.tsx b/x-pack/plugins/aiops/public/cases/register_change_point_charts_attachment.tsx index cc70d7ff98a3b..d91a50ab13d36 100644 --- a/x-pack/plugins/aiops/public/cases/register_change_point_charts_attachment.tsx +++ b/x-pack/plugins/aiops/public/cases/register_change_point_charts_attachment.tsx @@ -10,7 +10,10 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { CasesUiSetup } from '@kbn/cases-plugin/public'; import type { CoreStart } from '@kbn/core/public'; -import { CASES_ATTACHMENT_CHANGE_POINT_CHART } from '../../common/constants'; +import { + CASES_ATTACHMENT_CHANGE_POINT_CHART, + EMBEDDABLE_CHANGE_POINT_CHART_TYPE, +} from '../../common/constants'; import { getEmbeddableChangePointChart } from '../embeddable/embeddable_change_point_chart_component'; import { AiopsPluginStartDeps } from '../types'; @@ -19,7 +22,11 @@ export function registerChangePointChartsAttachment( coreStart: CoreStart, pluginStart: AiopsPluginStartDeps ) { - const EmbeddableComponent = getEmbeddableChangePointChart(coreStart, pluginStart); + const EmbeddableComponent = getEmbeddableChangePointChart( + EMBEDDABLE_CHANGE_POINT_CHART_TYPE, + coreStart, + pluginStart + ); cases.attachmentFramework.registerPersistableState({ id: CASES_ATTACHMENT_CHANGE_POINT_CHART, diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx index 84c5723548ee2..6abf5102a37ca 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx @@ -7,35 +7,37 @@ import { EuiBadge, - type EuiBasicTableColumn, EuiEmptyPrompt, EuiIcon, EuiInMemoryTable, EuiToolTip, type DefaultItemAction, + type EuiBasicTableColumn, } from '@elastic/eui'; -import React, { type FC, useMemo } from 'react'; +import { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types'; +import { FilterStateStore, type Filter } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types'; -import { type Filter, FilterStateStore } from '@kbn/es-query'; -import { NoChangePointsWarning } from './no_change_points_warning'; +import { useTableState } from '@kbn/ml-in-memory-table'; +import React, { useCallback, useEffect, useMemo, useRef, type FC } from 'react'; +import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; import { useDataSource } from '../../hooks/use_data_source'; -import { useCommonChartProps } from './use_common_chart_props'; import { - type ChangePointAnnotation, FieldConfig, SelectedChangePoint, useChangePointDetectionContext, + type ChangePointAnnotation, } from './change_point_detection_context'; import { type ChartComponentProps } from './chart_component'; -import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; +import { NoChangePointsWarning } from './no_change_points_warning'; +import { useCommonChartProps } from './use_common_chart_props'; export interface ChangePointsTableProps { annotations: ChangePointAnnotation[]; fieldConfig: FieldConfig; isLoading: boolean; - onSelectionChange: (update: SelectedChangePoint[]) => void; + onSelectionChange?: (update: SelectedChangePoint[]) => void; + onRenderComplete?: () => void; } function getFilterConfig( @@ -68,31 +70,62 @@ function getFilterConfig( }; } +const pageSizeOptions = [5, 10, 15]; + export const ChangePointsTable: FC = ({ isLoading, annotations, fieldConfig, onSelectionChange, + onRenderComplete, }) => { const { fieldFormats, data: { query: { filterManager }, }, + embeddingOrigin, } = useAiopsAppContext(); const { dataView } = useDataSource(); + const chartLoadingCount = useRef(0); + + const { onTableChange, pagination, sorting } = useTableState( + annotations ?? [], + 'p_value', + 'asc', + { + pageIndex: 0, + pageSize: 10, + pageSizeOptions, + } + ); + const dateFormatter = useMemo(() => fieldFormats.deserialize({ id: 'date' }), [fieldFormats]); - const defaultSorting = { - sort: { - field: 'p_value', - // Lower p_value indicates a bigger change point, hence the asc sorting - direction: 'asc' as const, + useEffect(() => { + // Reset loading counter on pagination or sort change + chartLoadingCount.current = 0; + }, [pagination.pageIndex, pagination.pageSize, sorting.sort]); + + /** + * Callback to track render of each chart component + * to report when all charts on the current page are ready. + */ + const onChartRenderCompleteCallback = useCallback( + (isLoadingChart: boolean) => { + if (!onRenderComplete) return; + if (!isLoadingChart) { + chartLoadingCount.current++; + } + if (chartLoadingCount.current === pagination.pageSize) { + onRenderComplete(); + } }, - }; + [onRenderComplete, pagination.pageSize] + ); - const hasActions = fieldConfig.splitField !== undefined; + const hasActions = fieldConfig.splitField !== undefined && embeddingOrigin !== 'cases'; const { bucketInterval } = useChangePointDetectionContext(); @@ -131,6 +164,7 @@ export const ChangePointsTable: FC = ({ annotation={annotation} fieldConfig={fieldConfig} interval={bucketInterval.expression} + onRenderComplete={onChartRenderCompleteCallback.bind(null, false)} /> ); }, @@ -190,70 +224,83 @@ export const ChangePointsTable: FC = ({ truncateText: false, sortable: true, }, - { - name: i18n.translate('xpack.aiops.changePointDetection.actionsColumn', { - defaultMessage: 'Actions', - }), - actions: [ - { - name: i18n.translate( - 'xpack.aiops.changePointDetection.actions.filterForValueAction', - { - defaultMessage: 'Filter for value', - } - ), - description: i18n.translate( - 'xpack.aiops.changePointDetection.actions.filterForValueAction', - { - defaultMessage: 'Filter for value', - } - ), - icon: 'plusInCircle', - color: 'primary', - type: 'icon', - onClick: (item) => { - filterManager.addFilters( - getFilterConfig(dataView.id!, item as Required, false)! - ); - }, - isPrimary: true, - 'data-test-subj': 'aiopsChangePointFilterForValue', - }, - { - name: i18n.translate( - 'xpack.aiops.changePointDetection.actions.filterOutValueAction', - { - defaultMessage: 'Filter out value', - } - ), - description: i18n.translate( - 'xpack.aiops.changePointDetection.actions.filterOutValueAction', - { - defaultMessage: 'Filter out value', - } - ), - icon: 'minusInCircle', - color: 'primary', - type: 'icon', - onClick: (item) => { - filterManager.addFilters( - getFilterConfig(dataView.id!, item as Required, true)! - ); + ...(hasActions + ? [ + { + name: i18n.translate('xpack.aiops.changePointDetection.actionsColumn', { + defaultMessage: 'Actions', + }), + actions: [ + { + name: i18n.translate( + 'xpack.aiops.changePointDetection.actions.filterForValueAction', + { + defaultMessage: 'Filter for value', + } + ), + description: i18n.translate( + 'xpack.aiops.changePointDetection.actions.filterForValueAction', + { + defaultMessage: 'Filter for value', + } + ), + icon: 'plusInCircle', + color: 'primary', + type: 'icon', + onClick: (item) => { + filterManager.addFilters( + getFilterConfig( + dataView.id!, + item as Required, + false + )! + ); + }, + isPrimary: true, + 'data-test-subj': 'aiopsChangePointFilterForValue', + }, + { + name: i18n.translate( + 'xpack.aiops.changePointDetection.actions.filterOutValueAction', + { + defaultMessage: 'Filter out value', + } + ), + description: i18n.translate( + 'xpack.aiops.changePointDetection.actions.filterOutValueAction', + { + defaultMessage: 'Filter out value', + } + ), + icon: 'minusInCircle', + color: 'primary', + type: 'icon', + onClick: (item) => { + filterManager.addFilters( + getFilterConfig( + dataView.id!, + item as Required, + true + )! + ); + }, + isPrimary: true, + 'data-test-subj': 'aiopsChangePointFilterOutValue', + }, + ] as Array>, }, - isPrimary: true, - 'data-test-subj': 'aiopsChangePointFilterOutValue', - }, - ] as Array>, - }, + ] + : []), ] : []), ]; - const selectionValue = useMemo>(() => { + const selectionValue = useMemo | undefined>(() => { + if (!onSelectionChange) return; return { selectable: (item) => true, onSelectionChange: (selection) => { - onSelectionChange( + onSelectionChange!( selection.map((s) => { return { ...s, @@ -273,8 +320,11 @@ export const ChangePointsTable: FC = ({ data-test-subj={`aiopsChangePointResultsTable ${isLoading ? 'loading' : 'loaded'}`} items={annotations} columns={columns} - pagination={{ pageSizeOptions: [5, 10, 15] }} - sorting={defaultSorting} + pagination={ + pagination.pageSizeOptions![0] > pagination!.totalItemCount ? undefined : pagination + } + sorting={sorting} + onTableChange={onTableChange} hasActions={hasActions} rowProps={(item) => ({ 'data-test-subj': `aiopsChangePointResultsTableRow row-${item.id}`, @@ -300,7 +350,12 @@ export const ChangePointsTable: FC = ({ ); }; -export const MiniChartPreview: FC = ({ fieldConfig, annotation }) => { +export const MiniChartPreview: FC = ({ + fieldConfig, + annotation, + onRenderComplete, + onLoading, +}) => { const { lens: { EmbeddableComponent }, } = useAiopsAppContext(); @@ -314,8 +369,31 @@ export const MiniChartPreview: FC = ({ fieldConfig, annotat bucketInterval: bucketInterval.expression, }); + const chartWrapperRef = useRef(null); + + const renderCompleteListener = useCallback( + (event: Event) => { + if (event.target === chartWrapperRef.current) return; + if (onRenderComplete) { + onRenderComplete(); + } + }, + [onRenderComplete] + ); + + useEffect(() => { + if (!chartWrapperRef.current) { + throw new Error('Reference to the chart wrapper is not set'); + } + const chartWrapper = chartWrapperRef.current; + chartWrapper.addEventListener('renderComplete', renderCompleteListener); + return () => { + chartWrapper.removeEventListener('renderComplete', renderCompleteListener); + }; + }, [renderCompleteListener]); + return ( -
+
= ({ fieldConfig, annotat type: 'aiops_change_point_detection_chart', name: 'Change point detection', }} + onLoad={onLoading} />
); diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/fields_config.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/fields_config.tsx index 7429ad7ba9f0a..3a6a00624b719 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/fields_config.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/fields_config.tsx @@ -33,7 +33,11 @@ import { import { EuiContextMenuProps } from '@elastic/eui/src/components/context_menu/context_menu'; import { isDefined } from '@kbn/ml-is-defined'; import { MaxSeriesControl } from './max_series_control'; -import { EMBEDDABLE_CHANGE_POINT_CHART_TYPE } from '../../../common/constants'; +import { + ChangePointDetectionViewType, + CHANGE_POINT_DETECTION_VIEW_TYPE, + EMBEDDABLE_CHANGE_POINT_CHART_TYPE, +} from '../../../common/constants'; import { useCasesModal } from '../../hooks/use_cases_modal'; import { type EmbeddableChangePointChartInput } from '../../embeddable/embeddable_change_point_chart'; import { useDataSource } from '../../hooks/use_data_source'; @@ -51,6 +55,7 @@ import { } from './change_point_detection_context'; import { useChangePointResults } from './use_change_point_agg_request'; import { useSplitFieldCardinality } from './use_split_field_cardinality'; +import { ViewTypeSelector } from './view_type_selector'; const selectControlCss = { width: '350px' }; @@ -191,10 +196,17 @@ const FieldPanel: FC = ({ const [dashboardAttachment, setDashboardAttachment] = useState<{ applyTimeRange: boolean; maxSeriesToPlot: number; + viewType: ChangePointDetectionViewType; }>({ applyTimeRange: false, maxSeriesToPlot: 6, + viewType: CHANGE_POINT_DETECTION_VIEW_TYPE.CHARTS, }); + + const [caseAttachment, setCaseAttachment] = useState<{ + viewType: ChangePointDetectionViewType; + }>({ viewType: CHANGE_POINT_DETECTION_VIEW_TYPE.CHARTS }); + const [dashboardAttachmentReady, setDashboardAttachmentReady] = useState(false); const { @@ -294,20 +306,7 @@ const FieldPanel: FC = ({ } : {}), 'data-test-subj': 'aiopsChangePointDetectionAttachToCaseButton', - onClick: () => { - openCasesModalCallback({ - timeRange, - fn: fieldConfig.fn, - metricField: fieldConfig.metricField, - dataViewId: dataView.id, - ...(fieldConfig.splitField - ? { - splitField: fieldConfig.splitField, - partitions: selectedPartitions, - } - : {}), - }); - }, + panel: 'attachToCasePanel', }, ] : []), @@ -324,6 +323,17 @@ const FieldPanel: FC = ({ + { + setDashboardAttachment((prevState) => { + return { + ...prevState, + viewType: v, + }; + }); + }} + /> = ({ fill type={'submit'} fullWidth - onClick={setDashboardAttachmentReady.bind(null, true)} + onClick={() => { + setIsActionMenuOpen(false); + setDashboardAttachmentReady(true); + }} + disabled={!isDashboardFormValid} + > + + + + + ), + }, + { + id: 'attachToCasePanel', + title: i18n.translate('xpack.aiops.changePointDetection.attachToCaseTitle', { + defaultMessage: 'Attach to case', + }), + size: 's', + content: ( + + + + { + setCaseAttachment((prevState) => { + return { + ...prevState, + viewType: v, + }; + }); + }} + /> + { + setIsActionMenuOpen(false); + openCasesModalCallback({ + timeRange, + viewType: caseAttachment.viewType, + fn: fieldConfig.fn, + metricField: fieldConfig.metricField, + dataViewId: dataView.id, + ...(fieldConfig.splitField + ? { + splitField: fieldConfig.splitField, + partitions: selectedPartitions, + } + : {}), + }); + }} disabled={!isDashboardFormValid} > = ({ canCreateCase, canEditDashboards, canUpdateCase, + caseAttachment.viewType, caseAttachmentButtonDisabled, dashboardAttachment.applyTimeRange, dashboardAttachment.maxSeriesToPlot, + dashboardAttachment.viewType, dataView.id, fieldConfig.fn, fieldConfig.metricField, @@ -405,6 +473,7 @@ const FieldPanel: FC = ({ const embeddableInput: Partial = { title: newTitle, description: newDescription, + viewType: dashboardAttachment.viewType, dataViewId: dataView.id, metricField: fieldConfig.metricField, splitField: fieldConfig.splitField, @@ -428,12 +497,13 @@ const FieldPanel: FC = ({ }, [ embeddable, + dashboardAttachment.viewType, + dashboardAttachment.applyTimeRange, + dashboardAttachment.maxSeriesToPlot, dataView.id, fieldConfig.metricField, fieldConfig.splitField, fieldConfig.fn, - dashboardAttachment.applyTimeRange, - dashboardAttachment.maxSeriesToPlot, timeRange, selectedChangePoints, panelIndex, diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/partitions_selector.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/partitions_selector.tsx index 4363de30ce162..79b6930e7e50a 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/partitions_selector.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/partitions_selector.tsx @@ -6,7 +6,14 @@ */ import React, { type FC, useState, useCallback, useMemo, useEffect } from 'react'; -import { EuiComboBox, EuiFormRow } from '@elastic/eui'; +import { + EuiComboBox, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiToolTip, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { type SearchRequest } from '@elastic/elasticsearch/lib/api/types'; import { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types'; @@ -171,9 +178,26 @@ export const PartitionsSelector: FC = ({ return ( + + {i18n.translate('xpack.aiops.changePointDetection.partitionsLabel', { + defaultMessage: 'Partitions', + })} + + + + + + + + } > isLoading={isLoading} diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts b/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts index 0393ab5e5a6fc..b8e43511c8c58 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts +++ b/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts @@ -136,7 +136,7 @@ export function useChangePointResults( /** * null also means the fetching has been complete */ - const [progress, setProgress] = useState(null); + const [progress, setProgress] = useState(0); const isSingleMetric = !isDefined(fieldConfig.splitField); diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/view_type_selector.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/view_type_selector.tsx new file mode 100644 index 0000000000000..1182a56fbe9d4 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/change_point_detection/view_type_selector.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiButtonGroup, EuiFormRow, type EuiButtonGroupOptionProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ChangePointDetectionViewType } from '../../../common/constants'; + +const viewTypeOptions: EuiButtonGroupOptionProps[] = [ + { + id: `charts`, + label: ( + + ), + iconType: 'visLine', + }, + { + id: `table`, + label: ( + + ), + iconType: 'visTable', + }, +]; + +export interface ViewTypeSelectorProps { + value: ChangePointDetectionViewType; + onChange: (update: ChangePointDetectionViewType) => void; +} + +export const ViewTypeSelector: FC = ({ value, onChange }) => { + return ( + + void} + /> + + ); +}; diff --git a/x-pack/plugins/aiops/public/embeddable/change_point_chart_initializer.tsx b/x-pack/plugins/aiops/public/embeddable/change_point_chart_initializer.tsx index 71780f26a4fcb..83ee5c65b082d 100644 --- a/x-pack/plugins/aiops/public/embeddable/change_point_chart_initializer.tsx +++ b/x-pack/plugins/aiops/public/embeddable/change_point_chart_initializer.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { EuiButton, EuiButtonEmpty, @@ -18,28 +17,30 @@ import { EuiModalHeader, EuiModalHeaderTitle, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { ES_FIELD_TYPES } from '@kbn/field-types'; import { i18n } from '@kbn/i18n'; -import usePrevious from 'react-use/lib/usePrevious'; -import { pick } from 'lodash'; +import { FormattedMessage } from '@kbn/i18n-react'; import { isPopulatedObject } from '@kbn/ml-is-populated-object'; -import { ES_FIELD_TYPES } from '@kbn/field-types'; -import { PartitionsSelector } from '../components/change_point_detection/partitions_selector'; -import { DEFAULT_SERIES } from './const'; -import { EmbeddableChangePointChartProps } from './embeddable_change_point_chart_component'; -import { type EmbeddableChangePointChartExplicitInput } from './types'; -import { MaxSeriesControl } from '../components/change_point_detection/max_series_control'; -import { SplitFieldSelector } from '../components/change_point_detection/split_field_selector'; -import { MetricFieldSelector } from '../components/change_point_detection/metric_field_selector'; +import { pick } from 'lodash'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import usePrevious from 'react-use/lib/usePrevious'; import { ChangePointDetectionControlsContextProvider, useChangePointDetectionControlsContext, } from '../components/change_point_detection/change_point_detection_context'; -import { useAiopsAppContext } from '../hooks/use_aiops_app_context'; -import { EmbeddableChangePointChartInput } from './embeddable_change_point_chart'; +import { DEFAULT_AGG_FUNCTION } from '../components/change_point_detection/constants'; import { FunctionPicker } from '../components/change_point_detection/function_picker'; +import { MaxSeriesControl } from '../components/change_point_detection/max_series_control'; +import { MetricFieldSelector } from '../components/change_point_detection/metric_field_selector'; +import { PartitionsSelector } from '../components/change_point_detection/partitions_selector'; +import { SplitFieldSelector } from '../components/change_point_detection/split_field_selector'; +import { ViewTypeSelector } from '../components/change_point_detection/view_type_selector'; +import { useAiopsAppContext } from '../hooks/use_aiops_app_context'; import { DataSourceContextProvider } from '../hooks/use_data_source'; -import { DEFAULT_AGG_FUNCTION } from '../components/change_point_detection/constants'; +import { DEFAULT_SERIES } from './const'; +import { EmbeddableChangePointChartInput } from './embeddable_change_point_chart'; +import { EmbeddableChangePointChartProps } from './embeddable_change_point_chart_component'; +import { type EmbeddableChangePointChartExplicitInput } from './types'; export interface AnomalyChartsInitializerProps { initialInput?: Partial; @@ -59,6 +60,7 @@ export const ChangePointChartInitializer: FC = ({ } = useAiopsAppContext(); const [dataViewId, setDataViewId] = useState(initialInput?.dataViewId ?? ''); + const [viewType, setViewType] = useState(initialInput?.viewType ?? 'charts'); const [formInput, setFormInput] = useState( pick(initialInput ?? {}, [ @@ -75,6 +77,7 @@ export const ChangePointChartInitializer: FC = ({ const updatedProps = useMemo(() => { return { ...formInput, + viewType, title: isPopulatedObject(formInput) ? i18n.translate('xpack.aiops.changePointDetection.attachmentTitle', { defaultMessage: 'Change point: {function}({metric}){splitBy}', @@ -92,7 +95,7 @@ export const ChangePointChartInitializer: FC = ({ : '', dataViewId, }; - }, [formInput, dataViewId]); + }, [formInput, dataViewId, viewType]); return ( @@ -100,13 +103,14 @@ export const ChangePointChartInitializer: FC = ({ + = ({ }} /> - diff --git a/x-pack/plugins/aiops/public/embeddable/embeddable_change_point_chart.tsx b/x-pack/plugins/aiops/public/embeddable/embeddable_change_point_chart.tsx index 3422d980f5fd8..42fffe5edaac1 100644 --- a/x-pack/plugins/aiops/public/embeddable/embeddable_change_point_chart.tsx +++ b/x-pack/plugins/aiops/public/embeddable/embeddable_change_point_chart.tsx @@ -23,8 +23,9 @@ import { LensPublicStart } from '@kbn/lens-plugin/public'; import { Subject } from 'rxjs'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/common'; +import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import { EmbeddableInputTracker } from './embeddable_chart_component_wrapper'; -import { EMBEDDABLE_CHANGE_POINT_CHART_TYPE, EMBEDDABLE_ORIGIN } from '../../common/constants'; +import { EMBEDDABLE_ORIGIN, EmbeddableChangePointType } from '../../common/constants'; import { AiopsAppContext, type AiopsAppDependencies } from '../hooks/use_aiops_app_context'; import { EmbeddableChangePointChartProps } from './embeddable_change_point_chart_component'; @@ -42,6 +43,7 @@ export interface EmbeddableChangePointChartDeps { i18n: CoreStart['i18n']; lens: LensPublicStart; usageCollection: UsageCollectionSetup; + fieldFormats: FieldFormatsStart; } export type IEmbeddableChangePointChart = typeof EmbeddableChangePointChart; @@ -50,8 +52,6 @@ export class EmbeddableChangePointChart extends AbstractEmbeddable< EmbeddableChangePointChartInput, EmbeddableChangePointChartOutput > { - public readonly type = EMBEDDABLE_CHANGE_POINT_CHART_TYPE; - private reload$ = new Subject(); public reload(): void { @@ -64,6 +64,7 @@ export class EmbeddableChangePointChart extends AbstractEmbeddable< deferEmbeddableLoad = true; constructor( + public readonly type: EmbeddableChangePointType, private readonly deps: EmbeddableChangePointChartDeps, initialInput: EmbeddableChangePointChartInput, parent?: IContainer @@ -91,9 +92,9 @@ export class EmbeddableChangePointChart extends AbstractEmbeddable< return true; } - public onLoading() { + public onLoading(isLoading: boolean) { this.renderComplete.dispatchInProgress(); - this.updateOutput({ loading: true, error: undefined }); + this.updateOutput({ loading: isLoading, error: undefined }); } public onError(error: Error) { @@ -103,7 +104,7 @@ export class EmbeddableChangePointChart extends AbstractEmbeddable< public onRenderComplete() { this.renderComplete.dispatchComplete(); - this.updateOutput({ loading: false, error: undefined }); + this.updateOutput({ loading: false, rendered: true, error: undefined }); } render(el: HTMLElement): void { @@ -127,8 +128,8 @@ export class EmbeddableChangePointChart extends AbstractEmbeddable< const input$ = this.getInput$(); const aiopsAppContextValue = { + embeddingOrigin: this.parent?.type ?? input.embeddingOrigin ?? EMBEDDABLE_ORIGIN, ...this.deps, - embeddingOrigin: this.parent?.type ?? EMBEDDABLE_ORIGIN, } as unknown as AiopsAppDependencies; ReactDOM.render( diff --git a/x-pack/plugins/aiops/public/embeddable/embeddable_change_point_chart_component.tsx b/x-pack/plugins/aiops/public/embeddable/embeddable_change_point_chart_component.tsx index 38a75ca327c95..9ce286fddf94c 100644 --- a/x-pack/plugins/aiops/public/embeddable/embeddable_change_point_chart_component.tsx +++ b/x-pack/plugins/aiops/public/embeddable/embeddable_change_point_chart_component.tsx @@ -15,12 +15,16 @@ import { useEmbeddableFactory, } from '@kbn/embeddable-plugin/public'; import { EuiLoadingChart } from '@elastic/eui'; -import { EMBEDDABLE_CHANGE_POINT_CHART_TYPE } from '../../common/constants'; +import { + type ChangePointDetectionViewType, + type EmbeddableChangePointType, +} from '../../common/constants'; import type { AiopsPluginStartDeps } from '../types'; import type { EmbeddableChangePointChartInput } from './embeddable_change_point_chart'; import type { ChangePointAnnotation } from '../components/change_point_detection/change_point_detection_context'; export interface EmbeddableChangePointChartProps { + viewType?: ChangePointDetectionViewType; dataViewId: string; timeRange: TimeRange; fn: 'avg' | 'sum' | 'min' | 'max' | string; @@ -40,12 +44,16 @@ export interface EmbeddableChangePointChartProps { * Last reload request time, can be used for manual reload */ lastReloadRequestTime?: number; + /** Origin of the embeddable instance */ + embeddingOrigin?: string; } -export function getEmbeddableChangePointChart(core: CoreStart, plugins: AiopsPluginStartDeps) { +export function getEmbeddableChangePointChart( + visType: EmbeddableChangePointType, + core: CoreStart, + plugins: AiopsPluginStartDeps +) { const { embeddable: embeddableStart } = plugins; - const factory = embeddableStart.getEmbeddableFactory( - EMBEDDABLE_CHANGE_POINT_CHART_TYPE - )!; + const factory = embeddableStart.getEmbeddableFactory(visType)!; return (props: EmbeddableChangePointChartProps) => { const input = { ...props }; diff --git a/x-pack/plugins/aiops/public/embeddable/embeddable_change_point_chart_factory.ts b/x-pack/plugins/aiops/public/embeddable/embeddable_change_point_chart_factory.ts index ef7c3a431cc18..8dfc6e28ac760 100644 --- a/x-pack/plugins/aiops/public/embeddable/embeddable_change_point_chart_factory.ts +++ b/x-pack/plugins/aiops/public/embeddable/embeddable_change_point_chart_factory.ts @@ -13,7 +13,10 @@ import { import { i18n } from '@kbn/i18n'; import { type DataPublicPluginStart } from '@kbn/data-plugin/public'; import { StartServicesAccessor } from '@kbn/core-lifecycle-browser'; -import { EMBEDDABLE_CHANGE_POINT_CHART_TYPE } from '../../common/constants'; +import { + EMBEDDABLE_CHANGE_POINT_CHART_TYPE, + EmbeddableChangePointType, +} from '../../common/constants'; import type { AiopsPluginStart, AiopsPluginStartDeps } from '../types'; import { EmbeddableChangePointChart, @@ -27,8 +30,6 @@ export interface EmbeddableChangePointChartStartServices { export type EmbeddableChangePointChartType = typeof EMBEDDABLE_CHANGE_POINT_CHART_TYPE; export class EmbeddableChangePointChartFactory implements EmbeddableFactoryDefinition { - public readonly type = EMBEDDABLE_CHANGE_POINT_CHART_TYPE; - public readonly grouping = [ { id: 'ml', @@ -41,6 +42,8 @@ export class EmbeddableChangePointChartFactory implements EmbeddableFactoryDefin ]; constructor( + public readonly type: EmbeddableChangePointType, + private readonly name: string, private readonly getStartServices: StartServicesAccessor ) {} @@ -49,9 +52,7 @@ export class EmbeddableChangePointChartFactory implements EmbeddableFactoryDefin }; getDisplayName() { - return i18n.translate('xpack.aiops.embeddableChangePointChartDisplayName', { - defaultMessage: 'Change point detection', - }); + return this.name; } canCreateNew() { @@ -73,10 +74,11 @@ export class EmbeddableChangePointChartFactory implements EmbeddableFactoryDefin try { const [ { i18n: i18nService, theme, http, uiSettings, notifications }, - { lens, data, usageCollection }, + { lens, data, usageCollection, fieldFormats }, ] = await this.getStartServices(); return new EmbeddableChangePointChart( + this.type, { theme, http, @@ -86,6 +88,7 @@ export class EmbeddableChangePointChartFactory implements EmbeddableFactoryDefin notifications, lens, usageCollection, + fieldFormats, }, input, parent diff --git a/x-pack/plugins/aiops/public/embeddable/embeddable_chart_component_wrapper.tsx b/x-pack/plugins/aiops/public/embeddable/embeddable_chart_component_wrapper.tsx index 0392f28184b8b..0cb49eb4ccf39 100644 --- a/x-pack/plugins/aiops/public/embeddable/embeddable_chart_component_wrapper.tsx +++ b/x-pack/plugins/aiops/public/embeddable/embeddable_chart_component_wrapper.tsx @@ -5,12 +5,14 @@ * 2.0. */ -import { BehaviorSubject, type Observable, combineLatest } from 'rxjs'; -import { map, distinctUntilChanged } from 'rxjs/operators'; +import { BehaviorSubject, combineLatest, type Observable } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; import React, { FC, useEffect, useMemo, useState } from 'react'; import { useTimefilter } from '@kbn/ml-date-picker'; import { css } from '@emotion/react'; import useObservable from 'react-use/lib/useObservable'; +import { ChangePointsTable } from '../components/change_point_detection/change_points_table'; +import { CHANGE_POINT_DETECTION_VIEW_TYPE } from '../../common/constants'; import { ReloadContextProvider } from '../hooks/use_reload'; import { type ChangePointAnnotation, @@ -42,7 +44,7 @@ export interface EmbeddableInputTrackerProps { reload$: Observable; onOutputChange: (output: Partial) => void; onRenderComplete: () => void; - onLoading: () => void; + onLoading: (isLoading: boolean) => void; onError: (error: Error) => void; } @@ -86,6 +88,7 @@ export const EmbeddableInputTracker: FC = ({ = ({ export const ChartGridEmbeddableWrapper: FC< EmbeddableChangePointChartProps & { onRenderComplete: () => void; - onLoading: () => void; + onLoading: (isLoading: boolean) => void; onError: (error: Error) => void; } > = ({ + viewType = CHANGE_POINT_DETECTION_VIEW_TYPE.CHARTS, fn, metricField, maxSeriesToPlot, @@ -202,9 +206,7 @@ export const ChartGridEmbeddableWrapper: FC< ); useEffect(() => { - if (isLoading) { - onLoading(); - } + onLoading(isLoading); }, [onLoading, isLoading]); const changePoints = useMemo(() => { @@ -235,16 +237,27 @@ export const ChartGridEmbeddableWrapper: FC< `} > {changePoints.length > 0 ? ( - ({ ...r, ...fieldConfig }))} - interval={requestParams.interval} - onRenderComplete={onRenderComplete} - /> - ) : emptyState ? ( - emptyState - ) : ( - - )} + viewType === CHANGE_POINT_DETECTION_VIEW_TYPE.CHARTS ? ( + ({ ...r, ...fieldConfig }))} + interval={requestParams.interval} + onRenderComplete={onRenderComplete} + /> + ) : viewType === CHANGE_POINT_DETECTION_VIEW_TYPE.TABLE ? ( + + ) : null + ) : !isLoading ? ( + emptyState ? ( + emptyState + ) : ( + + ) + ) : null}
); }; diff --git a/x-pack/plugins/aiops/public/embeddable/register_embeddable.ts b/x-pack/plugins/aiops/public/embeddable/register_embeddable.ts index e3a3f31e757a0..219b628552557 100644 --- a/x-pack/plugins/aiops/public/embeddable/register_embeddable.ts +++ b/x-pack/plugins/aiops/public/embeddable/register_embeddable.ts @@ -7,6 +7,8 @@ import type { CoreSetup } from '@kbn/core-lifecycle-browser'; import type { EmbeddableSetup } from '@kbn/embeddable-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { EMBEDDABLE_CHANGE_POINT_CHART_TYPE } from '../../common/constants'; import type { AiopsPluginStart, AiopsPluginStartDeps } from '../types'; import { EmbeddableChangePointChartFactory } from './embeddable_change_point_chart_factory'; @@ -14,6 +16,12 @@ export const registerEmbeddable = ( core: CoreSetup, embeddable: EmbeddableSetup ) => { - const factory = new EmbeddableChangePointChartFactory(core.getStartServices); - embeddable.registerEmbeddableFactory(factory.type, factory); + const changePointChartFactory = new EmbeddableChangePointChartFactory( + EMBEDDABLE_CHANGE_POINT_CHART_TYPE, + i18n.translate('xpack.aiops.embeddableChangePointChartDisplayName', { + defaultMessage: 'Change point detection', + }), + core.getStartServices + ); + embeddable.registerEmbeddableFactory(changePointChartFactory.type, changePointChartFactory); }; diff --git a/x-pack/plugins/aiops/public/embeddable/types.ts b/x-pack/plugins/aiops/public/embeddable/types.ts index 0184c2e4fa3eb..aa4ae65dbc5a9 100644 --- a/x-pack/plugins/aiops/public/embeddable/types.ts +++ b/x-pack/plugins/aiops/public/embeddable/types.ts @@ -5,7 +5,9 @@ * 2.0. */ +import type { FC } from 'react'; import { IEmbeddable } from '@kbn/embeddable-plugin/public'; +import type { SelectedChangePoint } from '../components/change_point_detection/change_point_detection_context'; import { EmbeddableChangePointChartInput, EmbeddableChangePointChartOutput, @@ -19,3 +21,9 @@ export type EmbeddableChangePointChartExplicitInput = { export interface EditChangePointChartsPanelContext { embeddable: IEmbeddable; } + +export type ViewComponent = FC<{ + changePoints: SelectedChangePoint[]; + interval: string; + onRenderComplete?: () => void; +}>; diff --git a/x-pack/plugins/aiops/public/plugin.tsx b/x-pack/plugins/aiops/public/plugin.tsx index 12a7f659135ae..e9f470cd6e04d 100755 --- a/x-pack/plugins/aiops/public/plugin.tsx +++ b/x-pack/plugins/aiops/public/plugin.tsx @@ -8,6 +8,7 @@ import type { CoreStart, Plugin } from '@kbn/core/public'; import { type CoreSetup } from '@kbn/core/public'; import { firstValueFrom } from 'rxjs'; +import { EMBEDDABLE_CHANGE_POINT_CHART_TYPE } from '../common/constants'; import type { AiopsPluginSetup, AiopsPluginSetupDeps, @@ -58,7 +59,11 @@ export class AiopsPlugin public start(core: CoreStart, plugins: AiopsPluginStartDeps): AiopsPluginStart { return { - EmbeddableChangePointChart: getEmbeddableChangePointChart(core, plugins), + EmbeddableChangePointChart: getEmbeddableChangePointChart( + EMBEDDABLE_CHANGE_POINT_CHART_TYPE, + core, + plugins + ), }; } diff --git a/x-pack/plugins/aiops/public/types.ts b/x-pack/plugins/aiops/public/types.ts index 8b40d4c257434..0ecb0851572a4 100755 --- a/x-pack/plugins/aiops/public/types.ts +++ b/x-pack/plugins/aiops/public/types.ts @@ -11,13 +11,12 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import type { LensPublicStart } from '@kbn/lens-plugin/public'; -import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; +import type { LicensingPluginSetup, LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; -import type { UiActionsStart, UiActionsSetup } from '@kbn/ui-actions-plugin/public'; +import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import type { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public'; import type { CasesUiSetup } from '@kbn/cases-plugin/public'; -import type { LicensingPluginSetup } from '@kbn/licensing-plugin/public'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import type { EmbeddableChangePointChartInput } from './embeddable/embeddable_change_point_chart'; diff --git a/x-pack/plugins/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap b/x-pack/plugins/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap index 95f1fca184d56..b3d9e08a16369 100644 --- a/x-pack/plugins/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap +++ b/x-pack/plugins/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap @@ -5802,7 +5802,11 @@ Object { } `; -exports[`Alert as data fields checks detect AAD fields changes for: siem.notifications 1`] = `undefined`; +exports[`Alert as data fields checks detect AAD fields changes for: siem.notifications 1`] = ` +Object { + "fieldMap": Object {}, +} +`; exports[`Alert as data fields checks detect AAD fields changes for: siem.queryRule 1`] = ` Object { diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx index b3e36fc8bebb9..4d3186c784447 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx @@ -17,7 +17,7 @@ import { import { i18n } from '@kbn/i18n'; import { ALERT_STATUS_ACTIVE } from '@kbn/rule-data-utils'; import { TypeOf } from '@kbn/typed-react-router-config'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { ServiceHealthStatus } from '../../../../../common/service_health_status'; import { ServiceInventoryFieldName, @@ -358,6 +358,16 @@ export function ServiceList({ ] ); + const handleSort = useCallback( + (itemsToSort, sortField, sortDirection) => + sortFn( + itemsToSort, + sortField as ServiceInventoryFieldName, + sortDirection + ), + [sortFn] + ); + return ( @@ -405,13 +415,7 @@ export function ServiceList({ initialSortField={initialSortField} initialSortDirection={initialSortDirection} initialPageSize={initialPageSize} - sortFn={(itemsToSort, sortField, sortDirection) => - sortFn( - itemsToSort, - sortField as ServiceInventoryFieldName, - sortDirection - ) - } + sortFn={handleSort} /> diff --git a/x-pack/plugins/apm/public/components/app/settings/general_settings/index.tsx b/x-pack/plugins/apm/public/components/app/settings/general_settings/index.tsx index 8926e2155592d..c9d3351ce2ebc 100644 --- a/x-pack/plugins/apm/public/components/app/settings/general_settings/index.tsx +++ b/x-pack/plugins/apm/public/components/app/settings/general_settings/index.tsx @@ -20,6 +20,7 @@ import { apmEnableContinuousRollups, enableAgentExplorerView, apmEnableProfilingIntegration, + apmEnableTableSearchBar, } from '@kbn/observability-plugin/common'; import { isEmpty } from 'lodash'; import React from 'react'; @@ -41,6 +42,7 @@ const apmSettingsKeys = [ apmEnableServiceMetrics, apmEnableContinuousRollups, enableAgentExplorerView, + apmEnableTableSearchBar, apmEnableProfilingIntegration, ]; diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx index 39ad6f4945dff..35f559d81f982 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_item.tsx @@ -10,7 +10,10 @@ import { i18n } from '@kbn/i18n'; import React, { ReactNode, useRef, useState, useEffect } from 'react'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; import { useTheme } from '../../../../../../hooks/use_theme'; -import { isRumAgentName } from '../../../../../../../common/agent_name'; +import { + isMobileAgentName, + isRumAgentName, +} from '../../../../../../../common/agent_name'; import { TRACE_ID, TRANSACTION_ID, @@ -335,6 +338,18 @@ function RelatedErrors({ kuery += ` and ${TRANSACTION_ID} : "${item.doc.transaction?.id}"`; } + const mobileHref = apmRouter.link( + `/mobile-services/{serviceName}/errors-and-crashes`, + { + path: { serviceName: item.doc.service.name }, + query: { + ...query, + serviceGroup: '', + kuery, + }, + } + ); + const href = apmRouter.link(`/services/{serviceName}/errors`, { path: { serviceName: item.doc.service.name }, query: { @@ -349,7 +364,7 @@ function RelatedErrors({ // eslint-disable-next-line jsx-a11y/click-events-have-key-events
e.stopPropagation()}> diff --git a/x-pack/plugins/apm/public/hooks/use_breakpoints.ts b/x-pack/plugins/apm/public/hooks/use_breakpoints.ts index 9ec8b20bb472d..5e991cc477762 100644 --- a/x-pack/plugins/apm/public/hooks/use_breakpoints.ts +++ b/x-pack/plugins/apm/public/hooks/use_breakpoints.ts @@ -9,19 +9,20 @@ import { useIsWithinMaxBreakpoint, useIsWithinMinBreakpoint, } from '@elastic/eui'; +import { useMemo } from 'react'; export type Breakpoints = Record; export function useBreakpoints() { - const screenSizes = { - isXSmall: useIsWithinMaxBreakpoint('xs'), - isSmall: useIsWithinMaxBreakpoint('s'), - isMedium: useIsWithinMaxBreakpoint('m'), - isLarge: useIsWithinMaxBreakpoint('l'), - isXl: useIsWithinMaxBreakpoint('xl'), - isXXL: useIsWithinMaxBreakpoint('xxl'), - isXXXL: useIsWithinMinBreakpoint('xxxl'), - }; + const isXSmall = useIsWithinMaxBreakpoint('xs'); + const isSmall = useIsWithinMaxBreakpoint('s'); + const isMedium = useIsWithinMaxBreakpoint('m'); + const isLarge = useIsWithinMaxBreakpoint('l'); + const isXl = useIsWithinMaxBreakpoint('xl'); + const isXXL = useIsWithinMaxBreakpoint('xxl'); + const isXXXL = useIsWithinMinBreakpoint('xxxl'); - return screenSizes; + return useMemo(() => { + return { isXSmall, isSmall, isMedium, isLarge, isXl, isXXL, isXXXL }; + }, [isXSmall, isSmall, isMedium, isLarge, isXl, isXXL, isXXXL]); } diff --git a/x-pack/plugins/apm/server/routes/services/annotations/index.test.ts b/x-pack/plugins/apm/server/routes/services/annotations/index.test.ts index 729a2c16dd65e..c33ff0881dbe6 100644 --- a/x-pack/plugins/apm/server/routes/services/annotations/index.test.ts +++ b/x-pack/plugins/apm/server/routes/services/annotations/index.test.ts @@ -16,7 +16,8 @@ import { Annotation, AnnotationType } from '../../../../common/annotations'; import { errors } from '@elastic/elasticsearch'; import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client'; -describe('getServiceAnnotations', () => { +// FLAKY: https://github.com/elastic/kibana/issues/169106 +describe.skip('getServiceAnnotations', () => { const storedAnnotations = [ { type: AnnotationType.VERSION, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/render.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/render.tsx index e53dce0a46886..c921bf5db2fe1 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/render.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/render.tsx @@ -17,6 +17,7 @@ export const defaultHandlers: RendererHandlers = { isSyncColorsEnabled: () => false, isSyncCursorEnabled: () => true, isSyncTooltipsEnabled: () => false, + shouldUseSizeTransitionVeil: () => false, isInteractive: () => true, onComplete: (fn) => undefined, onEmbeddableDestroyed: action('onEmbeddableDestroyed'), diff --git a/x-pack/plugins/canvas/public/lib/create_handlers.ts b/x-pack/plugins/canvas/public/lib/create_handlers.ts index 374bdaff99721..b9c0ad97f4eb1 100644 --- a/x-pack/plugins/canvas/public/lib/create_handlers.ts +++ b/x-pack/plugins/canvas/public/lib/create_handlers.ts @@ -29,6 +29,7 @@ export const createBaseHandlers = (): IInterpreterRenderHandlers => ({ isSyncColorsEnabled: () => false, isSyncTooltipsEnabled: () => false, isSyncCursorEnabled: () => true, + shouldUseSizeTransitionVeil: () => false, isInteractive: () => true, getExecutionContext: () => undefined, }); diff --git a/x-pack/plugins/cases/public/components/all_cases/assignees_filter.test.tsx b/x-pack/plugins/cases/public/components/all_cases/assignees_filter.test.tsx index 90e204fe8ccfb..191fa5c8cbf5d 100644 --- a/x-pack/plugins/cases/public/components/all_cases/assignees_filter.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/assignees_filter.test.tsx @@ -19,7 +19,8 @@ import { MAX_ASSIGNEES_FILTER_LENGTH } from '../../../common/constants'; jest.mock('../../containers/user_profiles/api'); -describe('AssigneesFilterPopover', () => { +// FLAKY: https://github.com/elastic/kibana/issues/174520 +describe.skip('AssigneesFilterPopover', () => { let appMockRender: AppMockRenderer; let defaultProps: AssigneesFilterPopoverProps; diff --git a/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx b/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx index 2429e68cbefa5..4386f996608f6 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx @@ -10,7 +10,7 @@ import { mount } from 'enzyme'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { basicCase } from '../../containers/mock'; +import { basicCase, basicCaseClosed } from '../../containers/mock'; import type { CaseActionBarProps } from '.'; import { CaseActionBar } from '.'; import { @@ -74,6 +74,18 @@ describe('CaseActionBar', () => { ); }); + it('should show the status as closed when the case is closed', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toBe( + 'Closed' + ); + }); + it('should show the correct date', () => { const wrapper = mount( diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx index a2fb4a8ae1a08..59ca9a98de12e 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx @@ -6,248 +6,104 @@ */ import React from 'react'; -import { waitFor, within, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { waitFor, screen } from '@testing-library/react'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import '../../common/mock/match_media'; -import { useCaseViewNavigation, useUrlParams } from '../../common/navigation/hooks'; -import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; -import { basicCaseClosed, connectorsMock, getCaseUsersMockResponse } from '../../containers/mock'; -import type { UseGetCase } from '../../containers/use_get_case'; -import { useGetCase } from '../../containers/use_get_case'; -import { useGetCaseMetrics } from '../../containers/use_get_case_metrics'; -import { useFindCaseUserActions } from '../../containers/use_find_case_user_actions'; -import { useGetTags } from '../../containers/use_get_tags'; -import { usePostPushToService } from '../../containers/use_post_push_to_service'; -import { useGetCaseConnectors } from '../../containers/use_get_case_connectors'; -import { useUpdateCase } from '../../containers/use_update_case'; -import { useGetCaseUsers } from '../../containers/use_get_case_users'; +import { useUrlParams } from '../../common/navigation/hooks'; import { CaseViewPage } from './case_view_page'; -import { - caseData, - caseViewProps, - defaultGetCase, - defaultGetCaseMetrics, - defaultInfiniteUseFindCaseUserActions, - defaultUpdateCaseState, - defaultUseFindCaseUserActions, -} from './mocks'; +import { caseData, caseViewProps } from './mocks'; import type { CaseViewPageProps } from './types'; -import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; -import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; -import { getCaseConnectorsMockResponse } from '../../common/mock/connectors'; -import { useInfiniteFindCaseUserActions } from '../../containers/use_infinite_find_case_user_actions'; -import { useGetCaseUserActionsStats } from '../../containers/use_get_case_user_actions_stats'; -import { createQueryWithMarkup } from '../../common/test_utils'; -import { useCasesFeatures } from '../../common/use_cases_features'; -import { CaseMetricsFeature } from '../../../common/types/api'; +import { waitForComponentToUpdate } from '../../common/test_utils'; +import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs'; -jest.mock('../../containers/use_get_action_license'); -jest.mock('../../containers/use_update_case'); -jest.mock('../../containers/use_get_case_metrics'); -jest.mock('../../containers/use_find_case_user_actions'); -jest.mock('../../containers/use_infinite_find_case_user_actions'); -jest.mock('../../containers/use_get_case_user_actions_stats'); -jest.mock('../../containers/use_get_tags'); -jest.mock('../../containers/use_get_case'); -jest.mock('../../containers/configure/use_get_supported_action_connectors'); -jest.mock('../../containers/use_post_push_to_service'); -jest.mock('../../containers/use_get_case_connectors'); -jest.mock('../../containers/use_get_case_users'); -jest.mock('../../containers/user_profiles/use_bulk_get_user_profiles'); -jest.mock('../../common/use_cases_features'); -jest.mock('../user_actions/timestamp', () => ({ - UserActionTimestamp: () => <>, -})); jest.mock('../../common/navigation/hooks'); +jest.mock('../use_breadcrumbs'); +jest.mock('./use_on_refresh_case_view_page'); jest.mock('../../common/hooks'); -jest.mock('../connectors/resilient/api'); jest.mock('../../common/lib/kibana'); -const useFetchCaseMock = useGetCase as jest.Mock; -const useUrlParamsMock = useUrlParams as jest.Mock; -const useCaseViewNavigationMock = useCaseViewNavigation as jest.Mock; -const useUpdateCaseMock = useUpdateCase as jest.Mock; -const useFindCaseUserActionsMock = useFindCaseUserActions as jest.Mock; -const useInfiniteFindCaseUserActionsMock = useInfiniteFindCaseUserActions as jest.Mock; -const useGetCaseUserActionsStatsMock = useGetCaseUserActionsStats as jest.Mock; -const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock; -const usePostPushToServiceMock = usePostPushToService as jest.Mock; -const useGetCaseConnectorsMock = useGetCaseConnectors as jest.Mock; -const useGetCaseMetricsMock = useGetCaseMetrics as jest.Mock; -const useGetTagsMock = useGetTags as jest.Mock; -const useGetCaseUsersMock = useGetCaseUsers as jest.Mock; -const useCasesFeaturesMock = useCasesFeatures as jest.Mock; +jest.mock('../header_page', () => ({ + HeaderPage: jest + .fn() + .mockReturnValue(
{'Case view header'}
), +})); -const mockGetCase = (props: Partial = {}) => { - const data = { - ...defaultGetCase.data, - ...props.data, - }; +jest.mock('./metrics', () => ({ + CaseViewMetrics: jest + .fn() + .mockReturnValue(
{'Case view metrics'}
), +})); - useFetchCaseMock.mockReturnValue({ - ...defaultGetCase, - ...props, - data, - }); -}; +jest.mock('./components/case_view_activity', () => ({ + CaseViewActivity: jest + .fn() + .mockReturnValue(
{'Case view activity'}
), +})); -export const caseProps: CaseViewPageProps = { +jest.mock('./components/case_view_alerts', () => ({ + CaseViewAlerts: jest + .fn() + .mockReturnValue(
{'Case view alerts'}
), +})); + +jest.mock('./components/case_view_files', () => ({ + CaseViewFiles: jest + .fn() + .mockReturnValue(
{'Case view files'}
), +})); + +const useUrlParamsMock = useUrlParams as jest.Mock; +const useCasesTitleBreadcrumbsMock = useCasesTitleBreadcrumbs as jest.Mock; + +const caseProps: CaseViewPageProps = { ...caseViewProps, - caseId: caseData.id, caseData, fetchCase: jest.fn(), }; -export const caseClosedProps: CaseViewPageProps = { - ...caseProps, - caseData: basicCaseClosed, -}; - -const userActionsStats = { - total: 21, - totalComments: 9, - totalOtherActions: 11, -}; - describe('CaseViewPage', () => { - const updateCaseProperty = defaultUpdateCaseState.mutate; - const pushCaseToExternalService = jest.fn(); - const caseConnectors = getCaseConnectorsMockResponse(); - const caseUsers = getCaseUsersMockResponse(); - let appMockRenderer: AppMockRenderer; - // eslint-disable-next-line prefer-object-spread - const originalGetComputedStyle = Object.assign({}, window.getComputedStyle); - - const platinumLicense = licensingMock.createLicense({ - license: { type: 'platinum' }, - }); - - beforeAll(() => { - // The JSDOM implementation is too slow - // Especially for dropdowns that try to position themselves - // perf issue - https://github.com/jsdom/jsdom/issues/3234 - Object.defineProperty(window, 'getComputedStyle', { - value: (el: HTMLElement) => { - /** - * This is based on the jsdom implementation of getComputedStyle - * https://github.com/jsdom/jsdom/blob/9dae17bf0ad09042cfccd82e6a9d06d3a615d9f4/lib/jsdom/browser/Window.js#L779-L820 - * - * It is missing global style parsing and will only return styles applied directly to an element. - * Will not return styles that are global or from emotion - */ - const declaration = new CSSStyleDeclaration(); - const { style } = el; - - Array.prototype.forEach.call(style, (property: string) => { - declaration.setProperty( - property, - style.getPropertyValue(property), - style.getPropertyPriority(property) - ); - }); - - return declaration; - }, - configurable: true, - writable: true, - }); - }); - beforeEach(() => { jest.clearAllMocks(); - mockGetCase(); - useUpdateCaseMock.mockReturnValue(defaultUpdateCaseState); - useGetCaseMetricsMock.mockReturnValue(defaultGetCaseMetrics); - useFindCaseUserActionsMock.mockReturnValue(defaultUseFindCaseUserActions); - useInfiniteFindCaseUserActionsMock.mockReturnValue(defaultInfiniteUseFindCaseUserActions); - useGetCaseUserActionsStatsMock.mockReturnValue({ data: userActionsStats, isLoading: false }); - usePostPushToServiceMock.mockReturnValue({ - isLoading: false, - mutateAsync: pushCaseToExternalService, - }); - useGetCaseConnectorsMock.mockReturnValue({ - isLoading: false, - data: caseConnectors, - }); - useGetConnectorsMock.mockReturnValue({ data: connectorsMock, isLoading: false }); - useGetTagsMock.mockReturnValue({ data: [], isLoading: false }); - useGetCaseUsersMock.mockReturnValue({ isLoading: false, data: caseUsers }); - useCasesFeaturesMock.mockReturnValue({ - metricsFeatures: [CaseMetricsFeature.ALERTS_COUNT], - pushToServiceAuthorized: true, - caseAssignmentAuthorized: true, - isAlertsEnabled: true, - isSyncAlertsEnabled: true, - }); - - appMockRenderer = createAppMockRenderer({ license: platinumLicense }); - }); - - afterAll(() => { - Object.defineProperty(window, 'getComputedStyle', originalGetComputedStyle); + useUrlParamsMock.mockReturnValue({}); + appMockRenderer = createAppMockRenderer(); }); - it('shows the metrics section', async () => { + it('shows the header section', async () => { appMockRenderer.render(); - expect(await screen.findByTestId('case-view-metrics-panel')).toBeInTheDocument(); + expect(await screen.findByTestId('test-case-view-header')).toBeInTheDocument(); }); - it('should show closed indicators in header when case is closed', async () => { - useUpdateCaseMock.mockImplementation(() => ({ - ...defaultUpdateCaseState, - caseData: basicCaseClosed, - })); - - appMockRenderer.render(); + it('shows the metrics section', async () => { + appMockRenderer.render(); - expect(await screen.findByTestId('case-view-status-dropdown')).toHaveTextContent('Closed'); + expect(await screen.findByTestId('test-case-view-metrics')).toBeInTheDocument(); }); - it('should push updates on button click', async () => { - useGetCaseConnectorsMock.mockImplementation(() => ({ - isLoading: false, - data: { - ...caseConnectors, - 'resilient-2': { - ...caseConnectors['resilient-2'], - push: { ...caseConnectors['resilient-2'].push, needsToBePushed: true }, - }, - }, - })); - + it('shows the activity section', async () => { appMockRenderer.render(); - expect(await screen.findByTestId('edit-connectors')).toBeInTheDocument(); - expect(await screen.findByTestId('push-to-external-service')).toBeInTheDocument(); - - userEvent.click(screen.getByTestId('push-to-external-service')); - - await waitFor(() => { - expect(pushCaseToExternalService).toHaveBeenCalled(); - }); + expect(await screen.findByTestId('test-case-view-activity')).toBeInTheDocument(); }); - it('should disable the push button when connector is invalid', async () => { + it('should set the breadcrumbs correctly', async () => { + const onComponentInitialized = jest.fn(); + appMockRenderer.render( - + ); - expect(await screen.findByTestId('edit-connectors')).toBeInTheDocument(); - expect(await screen.findByTestId('push-to-external-service')).toBeDisabled(); + await waitFor(() => { + expect(useCasesTitleBreadcrumbsMock).toHaveBeenCalledWith(caseProps.caseData.title); + }); }); it('should call onComponentInitialized on mount', async () => { const onComponentInitialized = jest.fn(); + appMockRenderer.render( ); @@ -257,229 +113,21 @@ describe('CaseViewPage', () => { }); }); - it('should show loading content when loading user actions stats', async () => { - const useFetchAlertData = jest.fn().mockReturnValue([true]); - useGetCaseUserActionsStatsMock.mockReturnValue({ isLoading: true }); - - appMockRenderer.render(); - - expect(await screen.findByTestId('case-view-loading-content')).toBeInTheDocument(); - expect(screen.queryByTestId('user-actions-list')).not.toBeInTheDocument(); - }); - - it('should call show alert details with expected arguments', async () => { - const showAlertDetails = jest.fn(); - appMockRenderer.render(); - - userEvent.click((await screen.findAllByTestId('comment-action-show-alert-alert-action-id'))[1]); - - await waitFor(() => { - expect(showAlertDetails).toHaveBeenCalledWith('alert-id-1', 'alert-index-1'); - }); - }); - - it('should show the rule name', async () => { - appMockRenderer.render(); - - expect( - ( - await screen.findAllByTestId('user-action-alert-comment-create-action-alert-action-id') - )[1].querySelector('.euiCommentEvent__headerEvent') - ).toHaveTextContent('added an alert from Awesome rule'); - }); - - it('should update settings', async () => { - appMockRenderer.render(); - - userEvent.click(await screen.findByTestId('sync-alerts-switch')); - - await waitFor(() => { - const updateObject = updateCaseProperty.mock.calls[0][0]; - - expect(updateObject.updateKey).toEqual('settings'); - expect(updateObject.updateValue).toEqual({ syncAlerts: false }); - }); - }); - - it('should show the correct connector name on the push button', async () => { - useGetConnectorsMock.mockImplementation(() => ({ data: connectorsMock, isLoading: false })); + it('should call onComponentInitialized only once', async () => { + const onComponentInitialized = jest.fn(); - appMockRenderer.render( - + const { rerender } = appMockRenderer.render( + ); - expect(await screen.findByTestId('edit-connectors')).toBeInTheDocument(); - expect(await screen.findByText('Update My Resilient connector incident')).toBeInTheDocument(); - }); - - describe('Callouts', () => { - const errorText = - 'The connector used to send updates to the external service has been deleted or you do not have the appropriate licenseExternal link(opens in a new tab or window) to use it. To update cases in external systems, select a different connector or create a new one.'; - - it('it shows the danger callout when a connector has been deleted', async () => { - useGetConnectorsMock.mockImplementation(() => ({ data: [], isLoading: false })); - appMockRenderer.render(); - - expect(await screen.findByTestId('edit-connectors')).toBeInTheDocument(); - - const getByText = createQueryWithMarkup(screen.getByText); - expect(getByText(errorText)).toBeInTheDocument(); - }); - - it('it does NOT shows the danger callout when connectors are loading', async () => { - useGetConnectorsMock.mockImplementation(() => ({ data: [], isLoading: true })); - appMockRenderer.render(); - - expect(await screen.findByTestId('edit-connectors')).toBeInTheDocument(); - expect( - screen.queryByTestId('case-callout-a25a5b368b6409b179ef4b6c5168244f') - ).not.toBeInTheDocument(); - }); - }); - - // FLAKY: https://github.com/elastic/kibana/issues/149777 - describe.skip('Tabs', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders tabs correctly', async () => { - appMockRenderer.render(); - - expect(await screen.findByRole('tablist')).toBeInTheDocument(); - - expect(await screen.findByTestId('case-view-tab-title-activity')).toBeInTheDocument(); - expect(await screen.findByTestId('case-view-tab-title-alerts')).toBeInTheDocument(); - expect(await screen.findByTestId('case-view-tab-title-files')).toBeInTheDocument(); - }); - - it('renders the activity tab by default', async () => { - appMockRenderer.render(); - expect(await screen.findByTestId('case-view-tab-content-activity')).toBeInTheDocument(); - }); - - it('renders the alerts tab when the query parameter tabId has alerts', async () => { - useUrlParamsMock.mockReturnValue({ - urlParams: { - tabId: CASE_VIEW_PAGE_TABS.ALERTS, - }, - }); - - appMockRenderer.render(); - - expect(await screen.findByTestId('case-view-tab-content-alerts')).toBeInTheDocument(); - expect(await screen.findByTestId('alerts-table')).toBeInTheDocument(); - }); - - it('renders the activity tab when the query parameter tabId has activity', async () => { - useUrlParamsMock.mockReturnValue({ - urlParams: { - tabId: CASE_VIEW_PAGE_TABS.ACTIVITY, - }, - }); - - appMockRenderer.render(); - - expect(await screen.findByTestId('case-view-tab-content-activity')).toBeInTheDocument(); - }); - - it('renders the activity tab when the query parameter tabId has an unknown value', async () => { - useUrlParamsMock.mockReturnValue({ - urlParams: { - tabId: 'what-is-love', - }, - }); - - appMockRenderer.render(); - - expect(await screen.findByTestId('case-view-tab-content-activity')).toBeInTheDocument(); - expect(screen.queryByTestId('case-view-tab-content-alerts')).not.toBeInTheDocument(); - }); - - it('navigates to the activity tab when the activity tab is clicked', async () => { - const navigateToCaseViewMock = useCaseViewNavigationMock().navigateToCaseView; - appMockRenderer.render(); - - userEvent.click(await screen.findByTestId('case-view-tab-title-activity')); - - await waitFor(() => { - expect(navigateToCaseViewMock).toHaveBeenCalledWith({ - detailName: caseData.id, - tabId: CASE_VIEW_PAGE_TABS.ACTIVITY, - }); - }); - }); - - it('navigates to the alerts tab when the alerts tab is clicked', async () => { - const navigateToCaseViewMock = useCaseViewNavigationMock().navigateToCaseView; - appMockRenderer.render(); - - userEvent.click(await screen.findByTestId('case-view-tab-title-alerts')); - - await waitFor(async () => { - expect(navigateToCaseViewMock).toHaveBeenCalledWith({ - detailName: caseData.id, - tabId: CASE_VIEW_PAGE_TABS.ALERTS, - }); - }); - }); - - it('should display the alerts tab when the feature is enabled', async () => { - appMockRenderer = createAppMockRenderer({ features: { alerts: { enabled: true } } }); - appMockRenderer.render(); - - expect(await screen.findByTestId('case-view-tab-title-activity')).toBeInTheDocument(); - expect(await screen.findByTestId('case-view-tab-title-alerts')).toBeInTheDocument(); - }); - - it('should not display the alerts tab when the feature is disabled', async () => { - appMockRenderer = createAppMockRenderer({ features: { alerts: { enabled: false } } }); - appMockRenderer.render(); - - expect(await screen.findByTestId('case-view-tab-title-activity')).toBeInTheDocument(); - expect(screen.queryByTestId('case-view-tab-title-alerts')).not.toBeInTheDocument(); - }); - - it('should not show the experimental badge on the alerts table', async () => { - appMockRenderer = createAppMockRenderer({ - features: { alerts: { isExperimental: false } }, - }); - appMockRenderer.render(); - - expect( - screen.queryByTestId('case-view-alerts-table-experimental-badge') - ).not.toBeInTheDocument(); - }); - - it('should show the experimental badge on the alerts table', async () => { - appMockRenderer = createAppMockRenderer({ features: { alerts: { isExperimental: true } } }); - appMockRenderer.render(); - - expect( - await screen.findByTestId('case-view-alerts-table-experimental-badge') - ).toBeInTheDocument(); + await waitFor(() => { + expect(onComponentInitialized).toHaveBeenCalled(); }); - describe('description', () => { - it('renders the description correctly', async () => { - appMockRenderer.render(); + rerender(); - const description = within(await screen.findByTestId('description')); + await waitForComponentToUpdate(); - expect(await description.findByText(caseData.description)).toBeInTheDocument(); - }); - - it('should display description when case is loading', async () => { - useUpdateCaseMock.mockImplementation(() => ({ - ...defaultUpdateCaseState, - isLoading: true, - updateKey: 'description', - })); - - appMockRenderer.render(); - - expect(await screen.findByTestId('description')).toBeInTheDocument(); - }); - }); + expect(onComponentInitialized).toBeCalledTimes(1); }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx index 30af8a4a00552..4d9e3ba640449 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx @@ -6,7 +6,7 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; import { useUrlParams } from '../../common/navigation'; import { useCasesContext } from '../cases_context/use_cases_context'; @@ -23,6 +23,14 @@ import type { CaseViewPageProps } from './types'; import { useRefreshCaseViewPage } from './use_on_refresh_case_view_page'; import { useOnUpdateField } from './use_on_update_field'; +const getActiveTabId = (tabId?: string) => { + if (tabId && Object.values(CASE_VIEW_PAGE_TABS).includes(tabId as CASE_VIEW_PAGE_TABS)) { + return tabId; + } + + return CASE_VIEW_PAGE_TABS.ACTIVITY; +}; + export const CaseViewPage = React.memo( ({ caseData, @@ -39,12 +47,7 @@ export const CaseViewPage = React.memo( useCasesTitleBreadcrumbs(caseData.title); - const activeTabId = useMemo(() => { - if (urlParams.tabId && Object.values(CASE_VIEW_PAGE_TABS).includes(urlParams.tabId)) { - return urlParams.tabId; - } - return CASE_VIEW_PAGE_TABS.ACTIVITY; - }, [urlParams.tabId]); + const activeTabId = getActiveTabId(urlParams?.tabId); const init = useRef(true); const timelineUi = useTimelineContext()?.ui; @@ -113,15 +116,12 @@ export const CaseViewPage = React.memo( onUpdateField={onUpdateField} /> - - - {activeTabId === CASE_VIEW_PAGE_TABS.ACTIVITY && ( { expect(screen.queryByTestId('case-view-files-stats-badge')).not.toBeInTheDocument(); }); - it('the files tab count has a different colour if the tab is not active', async () => { + it('the files tab count has a different color if the tab is not active', async () => { appMockRenderer.render(); expect( @@ -140,7 +140,7 @@ describe('CaseViewTabs', () => { expect(badge).toHaveTextContent('3'); }); - it('the alerts tab count has a different colour if the tab is not active', async () => { + it('the alerts tab count has a different color if the tab is not active', async () => { appMockRenderer.render( ); @@ -191,4 +191,54 @@ describe('CaseViewTabs', () => { }); }); }); + + it('should display the alerts tab when the feature is enabled', async () => { + appMockRenderer = createAppMockRenderer({ features: { alerts: { enabled: true } } }); + + appMockRenderer.render( + + ); + + expect(await screen.findByTestId('case-view-tab-title-alerts')).toBeInTheDocument(); + }); + + it('should not display the alerts tab when the feature is disabled', async () => { + appMockRenderer = createAppMockRenderer({ features: { alerts: { enabled: false } } }); + + appMockRenderer.render( + + ); + + expect(await screen.findByTestId('case-view-tabs')).toBeInTheDocument(); + expect(screen.queryByTestId('case-view-tab-title-alerts')).not.toBeInTheDocument(); + }); + + it('should not show the experimental badge on the alerts table', async () => { + appMockRenderer = createAppMockRenderer({ + features: { alerts: { isExperimental: false } }, + }); + + appMockRenderer.render( + + ); + + expect(await screen.findByTestId('case-view-tabs')).toBeInTheDocument(); + expect( + screen.queryByTestId('case-view-alerts-table-experimental-badge') + ).not.toBeInTheDocument(); + }); + + it('should show the experimental badge on the alerts table', async () => { + appMockRenderer = createAppMockRenderer({ + features: { alerts: { isExperimental: true } }, + }); + + appMockRenderer.render( + + ); + + expect( + await screen.findByTestId('case-view-alerts-table-experimental-badge') + ).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx index 671ce6606a141..cf5b2e7bd6802 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx @@ -143,7 +143,7 @@ export const CaseViewTabs = React.memo(({ caseData, activeTab return ( <> - {renderTabs()} + {renderTabs()} ); diff --git a/x-pack/plugins/cases/public/components/case_view/index.tsx b/x-pack/plugins/cases/public/components/case_view/index.tsx index 4ba3f912b9d0b..ecbde67ba15df 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.tsx @@ -86,7 +86,6 @@ export const CaseView = React.memo( {getLegacyUrlConflictCallout()} void; caseData: CaseUI; } diff --git a/x-pack/plugins/cases/public/components/description/index.test.tsx b/x-pack/plugins/cases/public/components/description/index.test.tsx index 386d6b6e7154f..8c801b8efae95 100644 --- a/x-pack/plugins/cases/public/components/description/index.test.tsx +++ b/x-pack/plugins/cases/public/components/description/index.test.tsx @@ -144,6 +144,14 @@ describe('Description', () => { expect(screen.queryByTestId('description-edit-icon')).not.toBeInTheDocument(); }); + it('should display description when case is loading', async () => { + appMockRender.render( + + ); + + expect(await screen.findByTestId('description')).toBeInTheDocument(); + }); + describe('draft message', () => { const draftStorageKey = `cases.testAppId.basic-case-id.description.markdownEditor`; diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx index b68641526c46a..1f50670b05291 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx @@ -15,13 +15,15 @@ import { EditConnector } from '.'; import { type AppMockRenderer, createAppMockRenderer, - readCasesPermissions, - noPushCasesPermissions, TestProviders, noConnectorsCasePermission, + noCasesPermissions, } from '../../common/mock'; import { basicCase, connectorsMock } from '../../containers/mock'; import { getCaseConnectorsMockResponse } from '../../common/mock/connectors'; +import type { ReturnUsePushToService } from '../use_push_to_service'; +import { usePushToService } from '../use_push_to_service'; +import { ConnectorTypes } from '../../../common'; const onSubmit = jest.fn(); const caseConnectors = getCaseConnectorsMockResponse(); @@ -34,35 +36,50 @@ const defaultProps: EditConnectorProps = { onSubmit, }; +jest.mock('../use_push_to_service'); + +const handlePushToService = jest.fn(); +const usePushToServiceMock = usePushToService as jest.Mock; + +const errorMsg = { id: 'test-error-msg', title: 'My error msg', description: 'My error desc' }; + +const usePushToServiceMockRes: ReturnUsePushToService = { + errorsMsg: [], + hasErrorMessages: false, + needsToBePushed: true, + hasBeenPushed: true, + isLoading: false, + hasLicenseError: false, + hasPushPermissions: true, + handlePushToService, +}; + describe('EditConnector ', () => { let appMockRender: AppMockRenderer; beforeEach(() => { jest.clearAllMocks(); appMockRender = createAppMockRenderer(); + usePushToServiceMock.mockReturnValue(usePushToServiceMockRes); }); - it('Renders the none connector', async () => { + it('renders an error message correctly', async () => { + usePushToServiceMock.mockReturnValue({ + ...usePushToServiceMockRes, + errorsMsg: [errorMsg], + hasErrorMessages: true, + }); + render( ); - expect( - await screen.findByText( - 'To create and update a case in an external system, select a connector.' - ) - ).toBeInTheDocument(); - - userEvent.click(screen.getByTestId('connector-edit-button')); - - await waitFor(() => { - expect(screen.getAllByTestId('dropdown-connector-no-connector').length).toBeGreaterThan(0); - }); + expect(await screen.findByText(errorMsg.description)).toBeInTheDocument(); }); - it('Edit external service on submit', async () => { + it('calls onSubmit when changing connector', async () => { render( @@ -97,6 +114,31 @@ describe('EditConnector ', () => { ); }); + it('should call handlePushToService when pushing to an external service', async () => { + usePushToServiceMock.mockReturnValue({ ...usePushToServiceMockRes, needsToBePushed: true }); + const props = { + ...defaultProps, + caseData: { + ...defaultProps.caseData, + connector: { + ...defaultProps.caseData.connector, + id: 'servicenow-1', + }, + }, + }; + + render( + + + + ); + + expect(await screen.findByTestId('push-to-external-service')).toBeInTheDocument(); + userEvent.click(screen.getByTestId('push-to-external-service')); + + await waitFor(() => expect(handlePushToService).toHaveBeenCalled()); + }); + it('reverts to the initial selection if the caseData do not change', async () => { const props = { ...defaultProps, @@ -201,6 +243,7 @@ describe('EditConnector ', () => { it('does not shows the callouts when is loading', async () => { const props = { ...defaultProps, isLoading: true }; + usePushToServiceMock.mockReturnValue({ ...usePushToServiceMockRes, errorsMsg: [errorMsg] }); render( @@ -215,7 +258,7 @@ describe('EditConnector ', () => { it('does not allow the connector to be edited when the user does not have write permissions', async () => { render( - + ); @@ -229,25 +272,15 @@ describe('EditConnector ', () => { }); }); - it('display the callout message when none is selected', async () => { - // default props has the none connector as selected - const result = appMockRender.render(); - - await waitFor(() => { - expect(result.getByTestId('push-callouts')).toBeInTheDocument(); - }); - }); - it('shows the actions permission message if the user does not have read access to actions', async () => { appMockRender.coreStart.application.capabilities = { ...appMockRender.coreStart.application.capabilities, actions: { save: false, show: false }, }; - const result = appMockRender.render(); - await waitFor(() => { - expect(result.getByTestId('edit-connector-permissions-error-msg')).toBeInTheDocument(); - }); + appMockRender.render(); + + expect(await screen.findByTestId('edit-connector-permissions-error-msg')).toBeInTheDocument(); }); it('does not show the actions permission message if the user has read access to actions', async () => { @@ -256,35 +289,34 @@ describe('EditConnector ', () => { actions: { save: true, show: true }, }; - const result = appMockRender.render(); - await waitFor(() => { - expect(result.queryByTestId('edit-connector-permissions-error-msg')).toBe(null); - }); + appMockRender.render(); + + expect(screen.queryByTestId('edit-connector-permissions-error-msg')).not.toBeInTheDocument(); }); it('does not show the callout if the user does not have read access to actions', async () => { const props = { ...defaultProps, connectors: [] }; + appMockRender.coreStart.application.capabilities = { ...appMockRender.coreStart.application.capabilities, actions: { save: false, show: false }, }; - const result = appMockRender.render(); - await waitFor(() => { - expect(result.getByTestId('edit-connector-permissions-error-msg')).toBeInTheDocument(); - expect(result.queryByTestId('push-callouts')).toBe(null); - }); + appMockRender.render(); + + expect(await screen.findByTestId('edit-connector-permissions-error-msg')).toBeInTheDocument(); + expect(screen.queryByTestId('push-callouts')).not.toBeInTheDocument(); }); - it('does not show the callout if the user does not have access to cases connectors', async () => { + it('does not show the callouts if the user does not have access to cases connectors', async () => { + usePushToServiceMock.mockReturnValue({ ...usePushToServiceMockRes, errorsMsg: [errorMsg] }); const props = { ...defaultProps, connectors: [] }; + appMockRender = createAppMockRenderer({ permissions: noConnectorsCasePermission() }); - const result = appMockRender.render(); - await waitFor(() => { - expect(result.getByTestId('edit-connector-permissions-error-msg')).toBeInTheDocument(); - expect(result.queryByTestId('push-callouts')).toBe(null); - }); + appMockRender.render(); + + expect(screen.queryByTestId('push-callouts')).toBe(null); }); it('does not show the connectors previewer if the user does not have read access to actions', async () => { @@ -294,16 +326,16 @@ describe('EditConnector ', () => { actions: { save: false, show: false }, }; - const result = appMockRender.render(); - expect(result.queryByTestId('connector-fields-preview')).not.toBeInTheDocument(); + appMockRender.render(); + expect(screen.queryByTestId('connector-fields-preview')).not.toBeInTheDocument(); }); it('does not show the connectors previewer if the user does not have access to cases connectors', async () => { const props = { ...defaultProps, connectors: [] }; appMockRender = createAppMockRenderer({ permissions: noConnectorsCasePermission() }); - const result = appMockRender.render(); - expect(result.queryByTestId('connector-fields-preview')).not.toBeInTheDocument(); + appMockRender.render(); + expect(screen.queryByTestId('connector-fields-preview')).not.toBeInTheDocument(); }); it('does not show the connectors form if the user does not have read access to actions', async () => { @@ -313,16 +345,16 @@ describe('EditConnector ', () => { actions: { save: false, show: false }, }; - const result = appMockRender.render(); - expect(result.queryByTestId('edit-connector-fields-form-flex-item')).not.toBeInTheDocument(); + appMockRender.render(); + expect(screen.queryByTestId('edit-connector-fields-form-flex-item')).not.toBeInTheDocument(); }); it('does not show the connectors form if the user does not have access to cases connectors', async () => { const props = { ...defaultProps, connectors: [] }; appMockRender = createAppMockRenderer({ permissions: noConnectorsCasePermission() }); - const result = appMockRender.render(); - expect(result.queryByTestId('edit-connector-fields-form-flex-item')).not.toBeInTheDocument(); + appMockRender.render(); + expect(screen.queryByTestId('edit-connector-fields-form-flex-item')).not.toBeInTheDocument(); }); it('does not show the push button if the user does not have read access to actions', async () => { @@ -331,32 +363,45 @@ describe('EditConnector ', () => { actions: { save: false, show: false }, }; - const result = appMockRender.render(); - await waitFor(() => { - expect(result.queryByTestId('push-to-external-service')).toBe(null); - }); + appMockRender.render(); + + expect(screen.queryByTestId('push-to-external-service')).not.toBeInTheDocument(); }); it('does not show the push button if the user does not have push permissions', async () => { - appMockRender = createAppMockRenderer({ permissions: noPushCasesPermissions() }); - const result = appMockRender.render(); + usePushToServiceMock.mockReturnValue({ ...usePushToServiceMockRes, hasPushPermissions: false }); + appMockRender.render(); - await waitFor(() => { - expect(result.queryByTestId('push-to-external-service')).toBe(null); - }); + expect(screen.queryByTestId('push-to-external-service')).not.toBeInTheDocument(); + }); + + it('disable the push button when connector is invalid', async () => { + usePushToServiceMock.mockReturnValue({ ...usePushToServiceMockRes, needsToBePushed: true }); + + appMockRender.render( + + ); + + expect(await screen.findByTestId('push-to-external-service')).toBeDisabled(); }); it('does not show the push button if the user does not have access to cases actions', async () => { appMockRender = createAppMockRenderer({ permissions: noConnectorsCasePermission() }); - const result = appMockRender.render(); - await waitFor(() => { - expect(result.queryByTestId('push-to-external-service')).toBe(null); - }); + appMockRender.render(); + + expect(screen.queryByTestId('push-to-external-service')).not.toBeInTheDocument(); }); it('does not show the edit connectors pencil if the user does not have read access to actions', async () => { const props = { ...defaultProps, connectors: [] }; + appMockRender.coreStart.application.capabilities = { ...appMockRender.coreStart.application.capabilities, actions: { save: false, show: false }, @@ -364,10 +409,8 @@ describe('EditConnector ', () => { appMockRender.render(); - await waitFor(() => { - expect(screen.getByTestId('connector-edit-header')).toBeInTheDocument(); - expect(screen.queryByTestId('connector-edit-button')).not.toBeInTheDocument(); - }); + expect(await screen.findByTestId('connector-edit-header')).toBeInTheDocument(); + expect(screen.queryByTestId('connector-edit-button')).not.toBeInTheDocument(); }); it('does not show the edit connectors pencil if the user does not have access to case connectors', async () => { @@ -378,21 +421,36 @@ describe('EditConnector ', () => { appMockRender.render(); - await waitFor(() => { - expect(screen.getByTestId('connector-edit-header')).toBeInTheDocument(); - expect(screen.queryByTestId('connector-edit-button')).not.toBeInTheDocument(); - }); + expect(await screen.findByTestId('connector-edit-header')).toBeInTheDocument(); + expect(screen.queryByTestId('connector-edit-button')).not.toBeInTheDocument(); }); it('does not show the edit connectors pencil if the user does not have push permissions', async () => { const props = { ...defaultProps, connectors: [] }; - appMockRender = createAppMockRenderer({ permissions: noPushCasesPermissions() }); + usePushToServiceMock.mockReturnValue({ ...usePushToServiceMockRes, hasPushPermissions: false }); appMockRender.render(); - await waitFor(() => { - expect(screen.getByTestId('connector-edit-header')).toBeInTheDocument(); - expect(screen.queryByTestId('connector-edit-button')).toBe(null); - }); + expect(await screen.findByTestId('connector-edit-header')).toBeInTheDocument(); + expect(screen.queryByTestId('connector-edit-button')).not.toBeInTheDocument(); + }); + + it('should show the correct connector name on the push button', async () => { + const props = { + ...defaultProps, + caseData: { + ...defaultProps.caseData, + connector: { + id: 'resilient-2', + name: 'old name', + type: ConnectorTypes.resilient, + fields: null, + }, + }, + }; + + appMockRender.render(); + + expect(await screen.findByText('Update My Resilient connector incident')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx index 636fa61b8d6f0..d0404c089077e 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx @@ -7,8 +7,8 @@ import React from 'react'; import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { useForm, Form, FIELD_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { waitFor, fireEvent, screen, render, act } from '@testing-library/react'; +import { FIELD_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { waitFor, fireEvent, screen, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; import * as i18n from '../../common/translations'; @@ -16,7 +16,8 @@ import * as i18n from '../../common/translations'; const { emptyField, maxLengthField } = fieldValidators; import { EditableMarkdown } from '.'; -import { TestProviders } from '../../common/mock'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; jest.mock('../../common/lib/kibana'); @@ -61,26 +62,11 @@ const defaultProps = { }; describe('EditableMarkdown', () => { - const MockHookWrapperComponent: React.FC<{ testProviderProps?: unknown }> = ({ - children, - testProviderProps = {}, - }) => { - const { form } = useForm<{ content: string }>({ - defaultValue: { content }, - options: { stripEmptyFields: false }, - schema: mockSchema, - }); - - return ( - // @ts-expect-error ts upgrade v4.7.4 - -
{children}
-
- ); - }; + let appMockRender: AppMockRenderer; beforeEach(() => { jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); }); afterEach(() => { @@ -88,17 +74,13 @@ describe('EditableMarkdown', () => { }); it('Save button click calls onSaveContent and onChangeEditable when text area value changed', async () => { - render( - - - - ); + appMockRender.render(); - fireEvent.change(screen.getByTestId('euiMarkdownEditorTextArea'), { + fireEvent.change(await screen.findByTestId('euiMarkdownEditorTextArea'), { target: { value: newValue }, }); - userEvent.click(screen.getByTestId('editable-save-markdown')); + userEvent.click(await screen.findByTestId('editable-save-markdown')); await waitFor(() => { expect(onSaveContent).toHaveBeenCalledWith(newValue); @@ -107,13 +89,9 @@ describe('EditableMarkdown', () => { }); it('Does not call onSaveContent if no change from current text', async () => { - render( - - - - ); + appMockRender.render(); - userEvent.click(screen.getByTestId('editable-save-markdown')); + userEvent.click(await screen.findByTestId('editable-save-markdown')); await waitFor(() => { expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); @@ -122,13 +100,9 @@ describe('EditableMarkdown', () => { }); it('Cancel button click calls only onChangeEditable', async () => { - render( - - - - ); + appMockRender.render(); - userEvent.click(screen.getByTestId('editable-cancel-markdown')); + userEvent.click(await screen.findByTestId('editable-cancel-markdown')); await waitFor(() => { expect(onSaveContent).not.toHaveBeenCalled(); @@ -138,60 +112,40 @@ describe('EditableMarkdown', () => { describe('errors', () => { it('Shows error message and save button disabled if current text is empty', async () => { - render( - - - - ); - - userEvent.clear(screen.getByTestId('euiMarkdownEditorTextArea')); + appMockRender.render(); - userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), ''); + userEvent.clear(await screen.findByTestId('euiMarkdownEditorTextArea')); + userEvent.paste(await screen.findByTestId('euiMarkdownEditorTextArea'), ''); - await waitFor(() => { - expect(screen.getByText('Required field')).toBeInTheDocument(); - expect(screen.getByTestId('editable-save-markdown')).toHaveProperty('disabled'); - }); + expect(await screen.findByText('Required field')).toBeInTheDocument(); + expect(await screen.findByTestId('editable-save-markdown')).toHaveProperty('disabled'); }); it('Shows error message and save button disabled if current text is of empty characters', async () => { - render( - - - - ); + appMockRender.render(); - userEvent.clear(screen.getByTestId('euiMarkdownEditorTextArea')); + userEvent.clear(await screen.findByTestId('euiMarkdownEditorTextArea')); + userEvent.paste(await screen.findByTestId('euiMarkdownEditorTextArea'), ' '); - userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), ' '); - - await waitFor(() => { - expect(screen.getByText('Required field')).toBeInTheDocument(); - expect(screen.getByTestId('editable-save-markdown')).toHaveProperty('disabled'); - }); + expect(await screen.findByText('Required field')).toBeInTheDocument(); + expect(await screen.findByTestId('editable-save-markdown')).toHaveProperty('disabled'); }); it('Shows error message and save button disabled if current text is too long', async () => { const longComment = 'b'.repeat(maxLength + 1); - render( - - - - ); + appMockRender.render(); - const markdown = screen.getByTestId('euiMarkdownEditorTextArea'); + const markdown = await screen.findByTestId('euiMarkdownEditorTextArea'); userEvent.paste(markdown, longComment); - await waitFor(() => { - expect( - screen.getByText( - `The length of the textarea is too long. The maximum length is ${maxLength} characters.` - ) - ).toBeInTheDocument(); - expect(screen.getByTestId('editable-save-markdown')).toHaveProperty('disabled'); - }); + expect( + await screen.findByText( + `The length of the textarea is too long. The maximum length is ${maxLength} characters.` + ) + ).toBeInTheDocument(); + expect(await screen.findByTestId('editable-save-markdown')).toHaveProperty('disabled'); }); }); @@ -214,13 +168,9 @@ describe('EditableMarkdown', () => { }); it('Save button click clears session storage', async () => { - const result = render( - - - - ); + appMockRender.render(); - fireEvent.change(result.getByTestId('euiMarkdownEditorTextArea'), { + fireEvent.change(await screen.findByTestId('euiMarkdownEditorTextArea'), { target: { value: newValue }, }); @@ -230,7 +180,7 @@ describe('EditableMarkdown', () => { expect(sessionStorage.getItem(draftStorageKey)).toBe(newValue); - fireEvent.click(result.getByTestId(`editable-save-markdown`)); + fireEvent.click(await screen.findByTestId(`editable-save-markdown`)); await waitFor(() => { expect(onSaveContent).toHaveBeenCalledWith(newValue); @@ -240,15 +190,11 @@ describe('EditableMarkdown', () => { }); it('Cancel button click clears session storage', async () => { - const result = render( - - - - ); + appMockRender.render(); expect(sessionStorage.getItem(draftStorageKey)).toBe(''); - fireEvent.change(result.getByTestId('euiMarkdownEditorTextArea'), { + fireEvent.change(await screen.findByTestId('euiMarkdownEditorTextArea'), { target: { value: newValue }, }); @@ -260,7 +206,7 @@ describe('EditableMarkdown', () => { expect(sessionStorage.getItem(draftStorageKey)).toBe(newValue); }); - fireEvent.click(result.getByTestId('editable-cancel-markdown')); + fireEvent.click(await screen.findByTestId('editable-cancel-markdown')); await waitFor(() => { expect(sessionStorage.getItem(draftStorageKey)).toBe(null); @@ -273,13 +219,9 @@ describe('EditableMarkdown', () => { }); it('should have session storage value same as draft comment', async () => { - const result = render( - - - - ); + appMockRender.render(); - expect(result.getByText('value set in storage')).toBeInTheDocument(); + expect(await screen.findByText('value set in storage')).toBeInTheDocument(); }); }); }); diff --git a/x-pack/plugins/cases/public/components/user_actions/property_actions/alert_property_actions.test.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions/alert_property_actions.test.tsx index 79636d52572ba..ae0d7e39d5e1b 100644 --- a/x-pack/plugins/cases/public/components/user_actions/property_actions/alert_property_actions.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/property_actions/alert_property_actions.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; -import { waitFor } from '@testing-library/react'; +import { waitFor, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { AppMockRenderer } from '../../../common/mock'; import { @@ -27,93 +27,89 @@ describe('AlertPropertyActions', () => { }; beforeEach(() => { - appMock = createAppMockRenderer(); jest.clearAllMocks(); + appMock = createAppMockRenderer(); }); it('renders the correct number of actions', async () => { - const result = appMock.render(); + appMock.render(); - expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument(); + expect(await screen.findByTestId('property-actions-user-action')).toBeInTheDocument(); - userEvent.click(result.getByTestId('property-actions-user-action-ellipses')); + userEvent.click(await screen.findByTestId('property-actions-user-action-ellipses')); await waitForEuiPopoverOpen(); - expect(result.getByTestId('property-actions-user-action-group').children.length).toBe(1); - expect(result.queryByTestId('property-actions-user-action-minusInCircle')).toBeInTheDocument(); + expect((await screen.findByTestId('property-actions-user-action-group')).children.length).toBe( + 1 + ); + + expect( + await screen.findByTestId('property-actions-user-action-minusInCircle') + ).toBeInTheDocument(); }); it('renders the modal info correctly for one alert', async () => { - const result = appMock.render(); + appMock.render(); - expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument(); + expect(await screen.findByTestId('property-actions-user-action')).toBeInTheDocument(); - userEvent.click(result.getByTestId('property-actions-user-action-ellipses')); + userEvent.click(await screen.findByTestId('property-actions-user-action-ellipses')); await waitForEuiPopoverOpen(); - expect(result.queryByTestId('property-actions-user-action-minusInCircle')).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('property-actions-user-action-minusInCircle')); - userEvent.click(result.getByTestId('property-actions-user-action-minusInCircle')); - - await waitFor(() => { - expect(result.queryByTestId('property-actions-confirm-modal')).toBeInTheDocument(); - }); + expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument(); - expect(result.getByTestId('confirmModalTitleText')).toHaveTextContent('Remove alert'); - expect(result.getByText('Remove')).toBeInTheDocument(); + expect(await screen.findByTestId('confirmModalTitleText')).toHaveTextContent('Remove alert'); + expect(await screen.findByText('Remove')).toBeInTheDocument(); }); it('renders the modal info correctly for multiple alert', async () => { - const result = appMock.render(); + appMock.render(); - expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument(); + expect(await screen.findByTestId('property-actions-user-action')).toBeInTheDocument(); - userEvent.click(result.getByTestId('property-actions-user-action-ellipses')); + userEvent.click(await screen.findByTestId('property-actions-user-action-ellipses')); await waitForEuiPopoverOpen(); - expect(result.queryByTestId('property-actions-user-action-minusInCircle')).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('property-actions-user-action-minusInCircle')); - userEvent.click(result.getByTestId('property-actions-user-action-minusInCircle')); + expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument(); - await waitFor(() => { - expect(result.queryByTestId('property-actions-confirm-modal')).toBeInTheDocument(); - }); - - expect(result.getByTestId('confirmModalTitleText')).toHaveTextContent('Remove alerts'); - expect(result.getByText('Remove')).toBeInTheDocument(); + expect(await screen.findByTestId('confirmModalTitleText')).toHaveTextContent('Remove alerts'); + expect(await screen.findByText('Remove')).toBeInTheDocument(); }); it('remove alerts correctly', async () => { - const result = appMock.render(); + appMock.render(); - expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument(); + expect(await screen.findByTestId('property-actions-user-action')).toBeInTheDocument(); - userEvent.click(result.getByTestId('property-actions-user-action-ellipses')); + userEvent.click(await screen.findByTestId('property-actions-user-action-ellipses')); await waitForEuiPopoverOpen(); - expect(result.queryByTestId('property-actions-user-action-minusInCircle')).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('property-actions-user-action-minusInCircle')); - userEvent.click(result.getByTestId('property-actions-user-action-minusInCircle')); + expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument(); + + userEvent.click(screen.getByText('Remove')); await waitFor(() => { - expect(result.queryByTestId('property-actions-confirm-modal')).toBeInTheDocument(); + expect(props.onDelete).toHaveBeenCalled(); }); - - userEvent.click(result.getByText('Remove')); - expect(props.onDelete).toHaveBeenCalled(); }); it('does not show the property actions without delete permissions', async () => { appMock = createAppMockRenderer({ permissions: noCasesPermissions() }); - const result = appMock.render(); + appMock.render(); - expect(result.queryByTestId('property-actions-user-action')).not.toBeInTheDocument(); + expect(screen.queryByTestId('property-actions-user-action')).not.toBeInTheDocument(); }); it('does show the property actions with only delete permissions', async () => { appMock = createAppMockRenderer({ permissions: onlyDeleteCasesPermission() }); - const result = appMock.render(); + appMock.render(); - expect(result.getByTestId('property-actions-user-action')).toBeInTheDocument(); + expect(await screen.findByTestId('property-actions-user-action')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cases/public/components/user_actions/property_actions/registered_attachments_property_actions.test.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions/registered_attachments_property_actions.test.tsx index 8cbc69e30039f..04b034a1d0a56 100644 --- a/x-pack/plugins/cases/public/components/user_actions/property_actions/registered_attachments_property_actions.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/property_actions/registered_attachments_property_actions.test.tsx @@ -18,8 +18,7 @@ import { import { RegisteredAttachmentsPropertyActions } from './registered_attachments_property_actions'; import { AttachmentActionType } from '../../../client/attachment_framework/types'; -// FLAKY: https://github.com/elastic/kibana/issues/174384 -describe.skip('RegisteredAttachmentsPropertyActions', () => { +describe('RegisteredAttachmentsPropertyActions', () => { let appMock: AppMockRenderer; const props = { @@ -47,7 +46,7 @@ describe.skip('RegisteredAttachmentsPropertyActions', () => { 1 ); - expect(screen.queryByTestId('property-actions-user-action-trash')).toBeInTheDocument(); + expect(await screen.findByTestId('property-actions-user-action-trash')).toBeInTheDocument(); }); it('renders the modal info correctly', async () => { @@ -85,7 +84,7 @@ describe.skip('RegisteredAttachmentsPropertyActions', () => { expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument(); - userEvent.click(screen.getByText('Delete')); + userEvent.click(await screen.findByText('Delete')); await waitFor(() => { expect(props.onDelete).toHaveBeenCalled(); diff --git a/x-pack/plugins/cases/public/containers/use_get_case_user_actions_stats.test.tsx b/x-pack/plugins/cases/public/containers/use_get_case_user_actions_stats.test.tsx index ce4e453ecbbf1..b81f9d1448aa2 100644 --- a/x-pack/plugins/cases/public/containers/use_get_case_user_actions_stats.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case_user_actions_stats.test.tsx @@ -22,7 +22,7 @@ const initialData = { isLoading: true, }; -describe('UseGetCaseUserActionsStats', () => { +describe('useGetCaseUserActionsStats', () => { let appMockRender: AppMockRenderer; beforeEach(() => { diff --git a/x-pack/plugins/cloud_security_posture/common/schemas/csp_vulnerability_finding.ts b/x-pack/plugins/cloud_security_posture/common/schemas/csp_vulnerability_finding.ts index efe26dc6648ba..2dbab1bab77b6 100644 --- a/x-pack/plugins/cloud_security_posture/common/schemas/csp_vulnerability_finding.ts +++ b/x-pack/plugins/cloud_security_posture/common/schemas/csp_vulnerability_finding.ts @@ -87,7 +87,7 @@ export interface Vulnerability { id: string; title: string; reference: string; - severity: VulnSeverity; + severity?: VulnSeverity; cvss: { nvd: VectorScoreBase; redhat?: VectorScoreBase; diff --git a/x-pack/plugins/cloud_security_posture/public/common/api/use_latest_findings_data_view.ts b/x-pack/plugins/cloud_security_posture/public/common/api/use_latest_findings_data_view.ts index c8e98703cdbf0..442330a888a50 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/api/use_latest_findings_data_view.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/api/use_latest_findings_data_view.ts @@ -9,9 +9,15 @@ import { useQuery } from '@tanstack/react-query'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import type { DataView } from '@kbn/data-plugin/common'; import { i18n } from '@kbn/i18n'; -import { LATEST_FINDINGS_INDEX_PATTERN } from '../../../common/constants'; +import { + LATEST_FINDINGS_INDEX_PATTERN, + LATEST_VULNERABILITIES_INDEX_PATTERN, +} from '../../../common/constants'; import { CspClientPluginStartDeps } from '../../types'; +/** + * TODO: Remove this static labels once https://github.com/elastic/kibana/issues/172615 is resolved + */ const cloudSecurityFieldLabels: Record = { 'result.evaluation': i18n.translate( 'xpack.csp.findings.findingsTable.findingsTableColumn.resultColumnLabel', @@ -45,6 +51,30 @@ const cloudSecurityFieldLabels: Record = { 'xpack.csp.findings.findingsTable.findingsTableColumn.lastCheckedColumnLabel', { defaultMessage: 'Last Checked' } ), + 'vulnerability.id': i18n.translate( + 'xpack.csp.findings.findingsTable.findingsTableColumn.vulnerabilityIdColumnLabel', + { defaultMessage: 'Vulnerability' } + ), + 'vulnerability.score.base': i18n.translate( + 'xpack.csp.findings.findingsTable.findingsTableColumn.vulnerabilityScoreColumnLabel', + { defaultMessage: 'CVSS' } + ), + 'vulnerability.severity': i18n.translate( + 'xpack.csp.findings.findingsTable.findingsTableColumn.vulnerabilitySeverityColumnLabel', + { defaultMessage: 'Severity' } + ), + 'package.name': i18n.translate( + 'xpack.csp.findings.findingsTable.findingsTableColumn.packageNameColumnLabel', + { defaultMessage: 'Package' } + ), + 'package.version': i18n.translate( + 'xpack.csp.findings.findingsTable.findingsTableColumn.packageVersionColumnLabel', + { defaultMessage: 'Version' } + ), + 'package.fixed_version': i18n.translate( + 'xpack.csp.findings.findingsTable.findingsTableColumn.packageFixedVersionColumnLabel', + { defaultMessage: 'Fix Version' } + ), } as const; /** @@ -61,7 +91,13 @@ export const useLatestFindingsDataView = (dataView: string) => { throw new Error(`Data view not found [Name: {${dataView}}]`); } - if (dataView === LATEST_FINDINGS_INDEX_PATTERN) { + /** + * TODO: Remove this update logic once https://github.com/elastic/kibana/issues/172615 is resolved + */ + if ( + dataView === LATEST_FINDINGS_INDEX_PATTERN || + dataView === LATEST_VULNERABILITIES_INDEX_PATTERN + ) { let shouldUpdate = false; Object.entries(cloudSecurityFieldLabels).forEach(([field, label]) => { if ( diff --git a/x-pack/plugins/cloud_security_posture/public/common/constants.ts b/x-pack/plugins/cloud_security_posture/public/common/constants.ts index 833f941c95292..bd266c98b8015 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/constants.ts @@ -49,6 +49,9 @@ export const LOCAL_STORAGE_DASHBOARD_BENCHMARK_SORT_KEY = 'cloudPosture:complianceDashboard:benchmarkSort'; export const LOCAL_STORAGE_FINDINGS_LAST_SELECTED_TAB_KEY = 'cloudPosture:findings:lastSelectedTab'; +export const LOCAL_STORAGE_VULNERABILITIES_GROUPING_KEY = 'cspLatestVulnerabilitiesGrouping'; +export const LOCAL_STORAGE_FINDINGS_GROUPING_KEY = 'cspLatestFindingsGrouping'; + export const SESSION_STORAGE_FIELDS_MODAL_SHOW_SELECTED = 'cloudPosture:fieldsModal:showSelected'; export type CloudPostureIntegrations = Record< @@ -225,3 +228,5 @@ export const NO_FINDINGS_STATUS_REFRESH_INTERVAL_MS = 10000; export const DETECTION_ENGINE_RULES_KEY = 'detection_engine_rules'; export const DETECTION_ENGINE_ALERTS_KEY = 'detection_engine_alerts'; + +export const DEFAULT_GROUPING_TABLE_HEIGHT = 512; diff --git a/x-pack/plugins/cloud_security_posture/public/common/contexts/data_view_context.ts b/x-pack/plugins/cloud_security_posture/public/common/contexts/data_view_context.ts new file mode 100644 index 0000000000000..a14928e7133e3 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/contexts/data_view_context.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 { createContext, useContext } from 'react'; +import { DataView } from '@kbn/data-views-plugin/common'; + +interface DataViewContextValue { + dataView: DataView; + dataViewRefetch?: () => void; + dataViewIsRefetching?: boolean; +} + +export const DataViewContext = createContext(undefined); + +/** + * Retrieve context's properties + */ +export const useDataViewContext = (): DataViewContextValue => { + const contextValue = useContext(DataViewContext); + + if (!contextValue) { + throw new Error('useDataViewContext can only be used within DataViewContext provider'); + } + + return contextValue; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_base_es_query.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_base_es_query.ts index 4adffa100e48c..9d5f5f2bf268d 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_base_es_query.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_base_es_query.ts @@ -5,10 +5,12 @@ * 2.0. */ +import { DataView } from '@kbn/data-views-plugin/common'; import { buildEsQuery, EsQueryConfig } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import { useEffect, useMemo } from 'react'; -import { FindingsBaseESQueryConfig, FindingsBaseProps, FindingsBaseURLQuery } from '../../types'; +import { useDataViewContext } from '../../contexts/data_view_context'; +import { FindingsBaseESQueryConfig, FindingsBaseURLQuery } from '../../types'; import { useKibana } from '../use_kibana'; const getBaseQuery = ({ @@ -16,7 +18,10 @@ const getBaseQuery = ({ query, filters, config, -}: FindingsBaseURLQuery & FindingsBaseProps & FindingsBaseESQueryConfig) => { +}: FindingsBaseURLQuery & + FindingsBaseESQueryConfig & { + dataView: DataView; + }) => { try { return { query: buildEsQuery(dataView, query, filters, config), // will throw for malformed query @@ -30,11 +35,10 @@ const getBaseQuery = ({ }; export const useBaseEsQuery = ({ - dataView, filters = [], query, nonPersistedFilters, -}: FindingsBaseURLQuery & FindingsBaseProps) => { +}: FindingsBaseURLQuery) => { const { notifications: { toasts }, data: { @@ -42,6 +46,7 @@ export const useBaseEsQuery = ({ }, uiSettings, } = useKibana().services; + const { dataView } = useDataViewContext(); const allowLeadingWildcards = uiSettings.get('query:allowLeadingWildcards'); const config: EsQueryConfig = useMemo(() => ({ allowLeadingWildcards }), [allowLeadingWildcards]); const baseEsQuery = useMemo( diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_cloud_posture_data_table.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_cloud_posture_data_table.ts index ae21f45c7a4e8..03517383ecc3f 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_cloud_posture_data_table.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/use_cloud_posture_data_table.ts @@ -5,7 +5,6 @@ * 2.0. */ import { Dispatch, SetStateAction, useCallback } from 'react'; -import { type DataView } from '@kbn/data-views-plugin/common'; import { BoolQuery, Filter } from '@kbn/es-query'; import { CriteriaWithPagination } from '@elastic/eui'; import { DataTableRecord } from '@kbn/discover-utils/types'; @@ -46,13 +45,11 @@ export interface CloudPostureDataTableResult { */ export const useCloudPostureDataTable = ({ defaultQuery = getDefaultQuery, - dataView, paginationLocalStorageKey, columnsLocalStorageKey, nonPersistedFilters, }: { defaultQuery?: (params: FindingsBaseURLQuery) => FindingsBaseURLQuery; - dataView: DataView; paginationLocalStorageKey: string; columnsLocalStorageKey?: string; nonPersistedFilters?: Filter[]; @@ -116,7 +113,6 @@ export const useCloudPostureDataTable = ({ * Page URL query to ES query */ const baseEsQuery = useBaseEsQuery({ - dataView, filters: urlQuery.filters, query: urlQuery.query, ...(nonPersistedFilters ? { nonPersistedFilters } : {}), diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/index.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/index.ts deleted file mode 100644 index 06ad2776fb305..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/index.ts +++ /dev/null @@ -1,8 +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. - */ - -export * from './use_cloud_posture_table'; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/use_cloud_posture_table.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/use_cloud_posture_table.ts deleted file mode 100644 index d06e29a95e46d..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/use_cloud_posture_table.ts +++ /dev/null @@ -1,155 +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 { Dispatch, SetStateAction, useCallback } from 'react'; -import { type DataView } from '@kbn/data-views-plugin/common'; -import { BoolQuery } from '@kbn/es-query'; -import { CriteriaWithPagination } from '@elastic/eui'; -import { DataTableRecord } from '@kbn/discover-utils/types'; -import { useUrlQuery } from '../use_url_query'; -import { usePageSize } from '../use_page_size'; -import { getDefaultQuery, useBaseEsQuery, usePersistedQuery } from './utils'; -import { LOCAL_STORAGE_DATA_TABLE_COLUMNS_KEY } from '../../constants'; - -export interface CloudPostureTableResult { - // TODO: Remove any when all finding tables are converted to CloudSecurityDataTable - setUrlQuery: (query: any) => void; - // TODO: Remove any when all finding tables are converted to CloudSecurityDataTable - sort: any; - // TODO: Remove any when all finding tables are converted to CloudSecurityDataTable - filters: any[]; - query?: { bool: BoolQuery }; - queryError?: Error; - pageIndex: number; - // TODO: remove any, urlQuery is an object with query fields but we also add custom fields to it, need to assert usages - urlQuery: any; - setTableOptions: (options: CriteriaWithPagination) => void; - // TODO: Remove any when all finding tables are converted to CloudSecurityDataTable - handleUpdateQuery: (query: any) => void; - pageSize: number; - setPageSize: Dispatch>; - onChangeItemsPerPage: (newPageSize: number) => void; - onChangePage: (newPageIndex: number) => void; - // TODO: Remove any when all finding tables are converted to CloudSecurityDataTable - onSort: (sort: any) => void; - onResetFilters: () => void; - columnsLocalStorageKey: string; - getRowsFromPages: (data: Array<{ page: DataTableRecord[] }> | undefined) => DataTableRecord[]; -} - -/** - * @deprecated will be replaced by useCloudPostureDataTable - */ -export const useCloudPostureTable = ({ - defaultQuery = getDefaultQuery, - dataView, - paginationLocalStorageKey, - columnsLocalStorageKey, -}: { - // TODO: Remove any when all finding tables are converted to CloudSecurityDataTable - defaultQuery?: (params: any) => any; - dataView: DataView; - paginationLocalStorageKey: string; - columnsLocalStorageKey?: string; -}): CloudPostureTableResult => { - const getPersistedDefaultQuery = usePersistedQuery(defaultQuery); - const { urlQuery, setUrlQuery } = useUrlQuery(getPersistedDefaultQuery); - const { pageSize, setPageSize } = usePageSize(paginationLocalStorageKey); - - const onChangeItemsPerPage = useCallback( - (newPageSize) => { - setPageSize(newPageSize); - setUrlQuery({ - pageIndex: 0, - pageSize: newPageSize, - }); - }, - [setPageSize, setUrlQuery] - ); - - const onResetFilters = useCallback(() => { - setUrlQuery({ - pageIndex: 0, - filters: [], - query: { - query: '', - language: 'kuery', - }, - }); - }, [setUrlQuery]); - - const onChangePage = useCallback( - (newPageIndex) => { - setUrlQuery({ - pageIndex: newPageIndex, - }); - }, - [setUrlQuery] - ); - - const onSort = useCallback( - (sort) => { - setUrlQuery({ - sort, - }); - }, - [setUrlQuery] - ); - - const setTableOptions = useCallback( - ({ page, sort }) => { - setPageSize(page.size); - setUrlQuery({ - sort, - pageIndex: page.index, - }); - }, - [setUrlQuery, setPageSize] - ); - - /** - * Page URL query to ES query - */ - const baseEsQuery = useBaseEsQuery({ - dataView, - filters: urlQuery.filters, - query: urlQuery.query, - }); - - const handleUpdateQuery = useCallback( - (query) => { - setUrlQuery({ ...query, pageIndex: 0 }); - }, - [setUrlQuery] - ); - - const getRowsFromPages = (data: Array<{ page: DataTableRecord[] }> | undefined) => - data - ?.map(({ page }: { page: DataTableRecord[] }) => { - return page; - }) - .flat() || []; - - return { - setUrlQuery, - sort: urlQuery.sort, - filters: urlQuery.filters, - query: baseEsQuery.query, - queryError: baseEsQuery.error, - pageIndex: urlQuery.pageIndex, - urlQuery, - setTableOptions, - handleUpdateQuery, - pageSize, - setPageSize, - onChangeItemsPerPage, - onChangePage, - onSort, - onResetFilters, - columnsLocalStorageKey: columnsLocalStorageKey || LOCAL_STORAGE_DATA_TABLE_COLUMNS_KEY, - getRowsFromPages, - }; -}; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/utils.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/utils.ts deleted file mode 100644 index a74abccba1e18..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/utils.ts +++ /dev/null @@ -1,124 +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 { useEffect, useCallback, useMemo } from 'react'; -import { buildEsQuery, EsQueryConfig } from '@kbn/es-query'; -import type { EuiBasicTableProps, Pagination } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { type Query } from '@kbn/es-query'; -import { useKibana } from '../use_kibana'; -import type { - FindingsBaseESQueryConfig, - FindingsBaseProps, - FindingsBaseURLQuery, -} from '../../types'; - -const getBaseQuery = ({ - dataView, - query, - filters, - config, -}: FindingsBaseURLQuery & FindingsBaseProps & FindingsBaseESQueryConfig) => { - try { - return { - query: buildEsQuery(dataView, query, filters, config), // will throw for malformed query - }; - } catch (error) { - return { - query: undefined, - error: error instanceof Error ? error : new Error('Unknown Error'), - }; - } -}; - -type TablePagination = NonNullable['pagination']>; - -export const getPaginationTableParams = ( - params: TablePagination & Pick, 'pageIndex' | 'pageSize'>, - pageSizeOptions = [10, 25, 100], - showPerPageOptions = true -): Required => ({ - ...params, - pageSizeOptions, - showPerPageOptions, -}); - -export const getPaginationQuery = ({ - pageIndex, - pageSize, -}: Required>) => ({ - from: pageIndex * pageSize, - size: pageSize, -}); - -export const useBaseEsQuery = ({ - dataView, - filters, - query, -}: FindingsBaseURLQuery & FindingsBaseProps) => { - const { - notifications: { toasts }, - data: { - query: { filterManager, queryString }, - }, - uiSettings, - } = useKibana().services; - const allowLeadingWildcards = uiSettings.get('query:allowLeadingWildcards'); - const config: EsQueryConfig = useMemo(() => ({ allowLeadingWildcards }), [allowLeadingWildcards]); - const baseEsQuery = useMemo( - () => getBaseQuery({ dataView, filters, query, config }), - [dataView, filters, query, config] - ); - - /** - * Sync filters with the URL query - */ - useEffect(() => { - filterManager.setAppFilters(filters); - queryString.setQuery(query); - }, [filters, filterManager, queryString, query]); - - const handleMalformedQueryError = () => { - const error = baseEsQuery.error; - if (error) { - toasts.addError(error, { - title: i18n.translate('xpack.csp.findings.search.queryErrorToastMessage', { - defaultMessage: 'Query Error', - }), - toastLifeTimeMs: 1000 * 5, - }); - } - }; - - useEffect(handleMalformedQueryError, [baseEsQuery.error, toasts]); - - return baseEsQuery; -}; - -export const usePersistedQuery = (getter: ({ filters, query }: FindingsBaseURLQuery) => T) => { - const { - data: { - query: { filterManager, queryString }, - }, - } = useKibana().services; - - return useCallback( - () => - getter({ - filters: filterManager.getAppFilters(), - query: queryString.getQuery() as Query, - }), - [getter, filterManager, queryString] - ); -}; - -export const getDefaultQuery = ({ query, filters }: any): any => ({ - query, - filters, - sort: { field: '@timestamp', direction: 'desc' }, - pageIndex: 0, -}); diff --git a/x-pack/plugins/cloud_security_posture/public/common/types.ts b/x-pack/plugins/cloud_security_posture/public/common/types.ts index ac483445407e4..d402ea2939062 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/types.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/types.ts @@ -5,7 +5,6 @@ * 2.0. */ import type { Criteria } from '@elastic/eui'; -import type { DataView } from '@kbn/data-views-plugin/common'; import type { BoolQuery, Filter, Query, EsQueryConfig } from '@kbn/es-query'; import { CspFinding } from '../../common/schemas/csp_finding'; @@ -20,12 +19,6 @@ export interface FindingsBaseURLQuery { nonPersistedFilters?: Filter[]; } -export interface FindingsBaseProps { - dataView: DataView; - dataViewRefetch?: () => void; - dataViewIsRefetching?: boolean; -} - export interface FindingsBaseESQueryConfig { config: EsQueryConfig; } diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.test.tsx b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.test.tsx index 7ddbe28a7da07..0fba4f27ed23a 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.test.tsx @@ -6,6 +6,7 @@ */ import { render } from '@testing-library/react'; import React from 'react'; +import { DataViewContext } from '../../common/contexts/data_view_context'; import { TestProvider } from '../../test/test_provider'; import { CloudSecurityDataTable, CloudSecurityDataTableProps } from './cloud_security_data_table'; @@ -47,7 +48,6 @@ const mockCloudPostureDataTable = { const renderDataTable = (props: Partial = {}) => { const defaultProps: CloudSecurityDataTableProps = { - dataView: mockDataView, isLoading: false, defaultColumns: mockDefaultColumns, rows: [], @@ -60,7 +60,9 @@ const renderDataTable = (props: Partial = {}) => { return render( - + + + ); }; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx index 3f0c3da73a986..53ed78b6e9b78 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx @@ -6,7 +6,6 @@ */ import React, { useState, useMemo } from 'react'; import { UnifiedDataTableSettings, useColumns } from '@kbn/unified-data-table'; -import { type DataView } from '@kbn/data-views-plugin/common'; import { UnifiedDataTable, DataLoadingState } from '@kbn/unified-data-table'; import { CellActionsProvider } from '@kbn/cell-actions'; import { SHOW_MULTIFIELDS, SORT_DEFAULT_ORDER_SETTING } from '@kbn/discover-utils'; @@ -22,6 +21,7 @@ import { EmptyState } from '../empty_state'; import { MAX_FINDINGS_TO_LOAD } from '../../common/constants'; import { useStyles } from './use_styles'; import { AdditionalControls } from './additional_controls'; +import { useDataViewContext } from '../../common/contexts/data_view_context'; export interface CloudSecurityDefaultColumn { id: string; @@ -41,7 +41,6 @@ const useNewFieldsApi = true; const controlColumnIds = ['openDetails']; export interface CloudSecurityDataTableProps { - dataView: DataView; isLoading: boolean; defaultColumns: CloudSecurityDefaultColumn[]; rows: DataTableRecord[]; @@ -77,21 +76,10 @@ export interface CloudSecurityDataTableProps { /** * Height override for the data grid. */ - height?: number; - /** - * Callback Function when the DataView field is edited. - * Required to enable editing of the field in the data grid. - */ - dataViewRefetch?: () => void; - /** - * Flag to indicate if the data view is refetching. - * Required for smoothing re-rendering the DataTable columns. - */ - dataViewIsRefetching?: boolean; + height?: number | string; } export const CloudSecurityDataTable = ({ - dataView, isLoading, defaultColumns, rows, @@ -103,8 +91,6 @@ export const CloudSecurityDataTable = ({ customCellRenderer, groupSelectorComponent, height, - dataViewRefetch, - dataViewIsRefetching, ...rest }: CloudSecurityDataTableProps) => { const { @@ -133,6 +119,8 @@ export const CloudSecurityDataTable = ({ } ); + const { dataView, dataViewIsRefetching, dataViewRefetch } = useDataViewContext(); + const [expandedDoc, setExpandedDoc] = useState(undefined); const renderDocumentView = (hit: DataTableRecord) => @@ -245,7 +233,7 @@ export const CloudSecurityDataTable = ({ // Change the height of the grid to fit the page // If there are filters, leave space for the filter bar // Todo: Replace this component with EuiAutoSizer - height: height ?? `calc(100vh - ${filters?.length > 0 ? 443 : 403}px)`, + height: height ?? `calc(100vh - ${filters?.length > 0 ? 454 : 414}px)`, }; const rowHeightState = 0; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/fields_selector/fields_selector_table.tsx b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/fields_selector/fields_selector_table.tsx index bae971749bc78..0afd4332c41db 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/fields_selector/fields_selector_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/fields_selector/fields_selector_table.tsx @@ -73,7 +73,7 @@ export const FieldsSelectorTable = ({ return dataView.fields .getAll() .filter((field) => { - return field.name !== '@timestamp' && field.name !== '_index' && field.visualizable; + return field.name !== '_index' && field.visualizable; }) .map((field) => ({ id: field.name, diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/index.ts b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/index.ts index 35a321d06119d..84353541e8ad8 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/index.ts +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/index.ts @@ -7,3 +7,6 @@ export { useCloudSecurityGrouping } from './use_cloud_security_grouping'; export { CloudSecurityGrouping } from './cloud_security_grouping'; +export { firstNonNullValue } from './utils/first_non_null_value'; +export { NullGroup } from './null_group'; +export { LoadingGroup } from './loading_group'; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/loading_group.tsx b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/loading_group.tsx new file mode 100644 index 0000000000000..8774095be6755 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/loading_group.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiSkeletonTitle } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; + +export const LoadingGroup = () => { + return ( + + + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/null_group.tsx b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/null_group.tsx new file mode 100644 index 0000000000000..bf06d15e61a2a --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/null_group.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiIconTip } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; + +export const NullGroup = ({ + title, + field, + unit, +}: { + title: string; + field: string; + unit: string; +}) => { + return ( + + {title} + + + + + ), + field: {field}, + unit, + }} + /> + + } + position="right" + /> + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/use_cloud_security_grouping.ts b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/use_cloud_security_grouping.ts index c59d382144524..23fd8267e5d76 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/use_cloud_security_grouping.ts +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/use_cloud_security_grouping.ts @@ -19,7 +19,6 @@ import { FindingsBaseURLQuery } from '../../common/types'; import { useBaseEsQuery, usePersistedQuery } from '../../common/hooks/use_cloud_posture_data_table'; const DEFAULT_PAGE_SIZE = 10; -const GROUPING_ID = 'cspLatestFindings'; const MAX_GROUPING_LEVELS = 1; /* @@ -33,6 +32,7 @@ export const useCloudSecurityGrouping = ({ unit, groupPanelRenderer, groupStatsRenderer, + groupingLocalStorageKey, }: { dataView: DataView; groupingTitle: string; @@ -41,6 +41,7 @@ export const useCloudSecurityGrouping = ({ unit: (count: number) => string; groupPanelRenderer?: GroupPanelRenderer; groupStatsRenderer?: GroupStatsRenderer; + groupingLocalStorageKey: string; }) => { const getPersistedDefaultQuery = usePersistedQuery(getDefaultQuery); const { urlQuery, setUrlQuery } = useUrlQuery(getPersistedDefaultQuery); @@ -48,7 +49,6 @@ export const useCloudSecurityGrouping = ({ const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const { query, error } = useBaseEsQuery({ - dataView, filters: urlQuery.filters, query: urlQuery.query, }); @@ -69,7 +69,7 @@ export const useCloudSecurityGrouping = ({ }, defaultGroupingOptions, fields: dataView.fields, - groupingId: GROUPING_ID, + groupingId: groupingLocalStorageKey, maxGroupingLevels: MAX_GROUPING_LEVELS, title: groupingTitle, onGroupChange: () => { diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/utils/first_non_null_value.test.ts b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/utils/first_non_null_value.test.ts new file mode 100644 index 0000000000000..5c332e6924ca1 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/utils/first_non_null_value.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { firstNonNullValue } from './first_non_null_value'; + +describe('firstNonNullValue', () => { + it('returns the value itself for non-null single value', () => { + expect(firstNonNullValue(5)).toBe(5); + }); + + it('returns undefined for a null single value', () => { + expect(firstNonNullValue(null)).toBeUndefined(); + }); + + it('returns undefined for an array of all null values', () => { + expect(firstNonNullValue([null, null, null])).toBeUndefined(); + }); + + it('returns the first non-null value in an array of mixed values', () => { + expect(firstNonNullValue([null, 7, 8])).toBe(7); + }); + + it('returns the first value in an array of all non-null values', () => { + expect(firstNonNullValue([3, 4, 5])).toBe(3); + }); + + it('returns undefined for an empty array', () => { + expect(firstNonNullValue([])).toBeUndefined(); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/utils/first_non_null_value.ts b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/utils/first_non_null_value.ts new file mode 100644 index 0000000000000..a8c5da0500e8a --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_grouping/utils/first_non_null_value.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ECSField } from '@kbn/securitysolution-grouping/src'; + +/** + * Return first non-null value. If the field contains an array, this will return the first value that isn't null. If the field isn't an array it'll be returned unless it's null. + */ +export function firstNonNullValue(valueOrCollection: ECSField): T | undefined { + if (valueOrCollection === null) { + return undefined; + } else if (Array.isArray(valueOrCollection)) { + for (const value of valueOrCollection) { + if (value !== null) { + return value; + } + } + } else { + return valueOrCollection; + } +} diff --git a/x-pack/plugins/cloud_security_posture/public/components/vulnerability_badges.tsx b/x-pack/plugins/cloud_security_posture/public/components/vulnerability_badges.tsx index 20b1326d65526..ff8924833a294 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/vulnerability_badges.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/vulnerability_badges.tsx @@ -14,15 +14,16 @@ import { VulnSeverity } from '../../common/types_old'; import { VULNERABILITIES_CVSS_SCORE_BADGE_SUBJ } from './test_subjects'; interface CVSScoreBadgeProps { - score: float; + score?: float; version?: string; } interface SeverityStatusBadgeProps { - severity: VulnSeverity; + severity?: VulnSeverity; } export const CVSScoreBadge = ({ score, version }: CVSScoreBadgeProps) => { + if (!score) return null; const color = getCvsScoreColor(score); const versionDisplay = version ? `v${version.split('.')[0]}` : null; return ( @@ -56,6 +57,7 @@ export const CVSScoreBadge = ({ score, version }: CVSScoreBadgeProps) => { }; export const SeverityStatusBadge = ({ severity }: SeverityStatusBadgeProps) => { + if (!severity) return null; const color = getSeverityStatusColor(severity); return ( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.tsx index 7e8bbfeedb832..60de443228281 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.tsx @@ -14,8 +14,8 @@ import { NoFindingsStates } from '../../components/no_findings_states'; import { CloudPosturePage } from '../../components/cloud_posture_page'; import { useLatestFindingsDataView } from '../../common/api/use_latest_findings_data_view'; import { cloudPosturePages, findingsNavigation } from '../../common/navigation/constants'; -import { FindingsByResourceContainer } from './latest_findings_by_resource/findings_by_resource_container'; import { LatestFindingsContainer } from './latest_findings/latest_findings_container'; +import { DataViewContext } from '../../common/contexts/data_view_context'; export const Configurations = () => { const location = useLocation(); @@ -31,6 +31,12 @@ export const Configurations = () => { if (!hasConfigurationFindings) return ; + const dataViewContextValue = { + dataView: dataViewQuery.data!, + dataViewRefetch: dataViewQuery.refetch, + dataViewIsRefetching: dataViewQuery.isRefetching, + }; + return ( @@ -50,18 +56,12 @@ export const Configurations = () => { path={findingsNavigation.findings_default.path} render={() => ( - + + + )} /> - } - /> } /> diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/constants.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/constants.ts index e2e4585906bae..3d8200a144bd5 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/constants.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/constants.ts @@ -77,8 +77,6 @@ export const groupingTitle = i18n.translate('xpack.csp.findings.latestFindings.g defaultMessage: 'Group findings by', }); -export const DEFAULT_TABLE_HEIGHT = 512; - export const getDefaultQuery = ({ query, filters, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx index e070847b6df55..11c60718b29f8 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx @@ -4,40 +4,29 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useCallback } from 'react'; +import React from 'react'; import { Filter } from '@kbn/es-query'; import { EuiSpacer } from '@elastic/eui'; +import { DEFAULT_GROUPING_TABLE_HEIGHT } from '../../../common/constants'; import { EmptyState } from '../../../components/empty_state'; import { CloudSecurityGrouping } from '../../../components/cloud_security_grouping'; -import type { FindingsBaseProps } from '../../../common/types'; import { FindingsSearchBar } from '../layout/findings_search_bar'; -import { DEFAULT_TABLE_HEIGHT } from './constants'; import { useLatestFindingsGrouping } from './use_latest_findings_grouping'; import { LatestFindingsTable } from './latest_findings_table'; import { groupPanelRenderer, groupStatsRenderer } from './latest_findings_group_renderer'; import { FindingsDistributionBar } from '../layout/findings_distribution_bar'; import { ErrorCallout } from '../layout/error_callout'; -export const LatestFindingsContainer = ({ - dataView, - dataViewRefetch, - dataViewIsRefetching, -}: FindingsBaseProps) => { - const renderChildComponent = useCallback( - (groupFilters: Filter[]) => { - return ( - - ); - }, - [dataView, dataViewIsRefetching, dataViewRefetch] - ); +export const LatestFindingsContainer = () => { + const renderChildComponent = (groupFilters: Filter[]) => { + return ( + + ); + }; const { isGroupSelected, @@ -57,12 +46,12 @@ export const LatestFindingsContainer = ({ onDistributionBarClick, totalFailedFindings, isEmptyResults, - } = useLatestFindingsGrouping({ dataView, groupPanelRenderer, groupStatsRenderer }); + } = useLatestFindingsGrouping({ groupPanelRenderer, groupStatsRenderer }); if (error || isEmptyResults) { return ( <> - + {error && } {isEmptyResults && } @@ -72,7 +61,7 @@ export const LatestFindingsContainer = ({ if (isGroupSelected) { return ( <> - +
- - + + ); }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_group_renderer.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_group_renderer.tsx index d0684452fb23a..fe8536eaf0f69 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_group_renderer.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_group_renderer.tsx @@ -8,23 +8,20 @@ import { EuiBadge, EuiFlexGroup, EuiFlexItem, - EuiIconTip, - EuiSkeletonTitle, EuiText, EuiTextBlockTruncate, EuiToolTip, useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; -import { - ECSField, - GroupPanelRenderer, - RawBucket, - StatRenderer, -} from '@kbn/securitysolution-grouping/src'; +import { GroupPanelRenderer, RawBucket, StatRenderer } from '@kbn/securitysolution-grouping/src'; import React from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; +import { + NullGroup, + LoadingGroup, + firstNonNullValue, +} from '../../../components/cloud_security_grouping'; import { getAbbreviatedNumber } from '../../../common/utils/get_abbreviated_number'; import { CISBenchmarkIcon } from '../../../components/cis_benchmark_icon'; import { ComplianceScoreBar } from '../../../components/compliance_score_bar'; @@ -32,67 +29,6 @@ import { FindingsGroupingAggregation } from './use_grouped_findings'; import { GROUPING_OPTIONS, NULL_GROUPING_MESSAGES, NULL_GROUPING_UNIT } from './constants'; import { FINDINGS_GROUPING_COUNTER } from '../test_subjects'; -/** - * Return first non-null value. If the field contains an array, this will return the first value that isn't null. If the field isn't an array it'll be returned unless it's null. - */ -export function firstNonNullValue(valueOrCollection: ECSField): T | undefined { - if (valueOrCollection === null) { - return undefined; - } else if (Array.isArray(valueOrCollection)) { - for (const value of valueOrCollection) { - if (value !== null) { - return value; - } - } - } else { - return valueOrCollection; - } -} - -const NullGroupComponent = ({ - title, - field, - unit = NULL_GROUPING_UNIT, -}: { - title: string; - field: string; - unit?: string; -}) => { - return ( - - {title} - - - - - ), - field: {field}, - unit, - }} - /> - - } - position="right" - /> - - ); -}; - export const groupPanelRenderer: GroupPanelRenderer = ( selectedGroup, bucket, @@ -100,20 +36,18 @@ export const groupPanelRenderer: GroupPanelRenderer isLoading ) => { if (isLoading) { - return ( - - - - ); + return ; } const benchmarkId = firstNonNullValue(bucket.benchmarkId?.buckets?.[0]?.key); + + const renderNullGroup = (title: string) => ( + + ); + switch (selectedGroup) { case GROUPING_OPTIONS.RESOURCE_NAME: return nullGroupMessage ? ( - + renderNullGroup(NULL_GROUPING_MESSAGES.RESOURCE_NAME) ) : ( @@ -146,7 +80,7 @@ export const groupPanelRenderer: GroupPanelRenderer ); case GROUPING_OPTIONS.RULE_NAME: return nullGroupMessage ? ( - + renderNullGroup(NULL_GROUPING_MESSAGES.RULE_NAME) ) : ( @@ -168,10 +102,7 @@ export const groupPanelRenderer: GroupPanelRenderer ); case GROUPING_OPTIONS.CLOUD_ACCOUNT_NAME: return nullGroupMessage ? ( - + renderNullGroup(NULL_GROUPING_MESSAGES.CLOUD_ACCOUNT_NAME) ) : ( {benchmarkId && ( @@ -200,10 +131,7 @@ export const groupPanelRenderer: GroupPanelRenderer ); case GROUPING_OPTIONS.ORCHESTRATOR_CLUSTER_NAME: return nullGroupMessage ? ( - + renderNullGroup(NULL_GROUPING_MESSAGES.ORCHESTRATOR_CLUSTER_NAME) ) : ( {benchmarkId && ( @@ -232,7 +160,7 @@ export const groupPanelRenderer: GroupPanelRenderer ); default: return nullGroupMessage ? ( - + renderNullGroup(NULL_GROUPING_MESSAGES.DEFAULT) ) : ( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.tsx index 3adb10259871d..7f215c4d49f99 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_table.tsx @@ -10,7 +10,6 @@ import { DataTableRecord } from '@kbn/discover-utils/types'; import { i18n } from '@kbn/i18n'; import { EuiDataGridCellValueElementProps, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import React from 'react'; -import { FindingsBaseProps } from '../../../common/types'; import * as TEST_SUBJECTS from '../test_subjects'; import { FindingsDistributionBar } from '../layout/findings_distribution_bar'; import { ErrorCallout } from '../layout/error_callout'; @@ -22,14 +21,12 @@ import { CspEvaluationBadge } from '../../../components/csp_evaluation_badge'; import { CspFinding } from '../../../../common/schemas/csp_finding'; import { FindingsRuleFlyout } from '../findings_flyout/findings_flyout'; -type LatestFindingsTableProps = FindingsBaseProps & { +interface LatestFindingsTableProps { groupSelectorComponent?: JSX.Element; height?: number; showDistributionBar?: boolean; nonPersistedFilters?: Filter[]; - dataViewRefetch?: () => void; - dataViewIsRefetching?: boolean; -}; +} /** * Type Guard for checking if the given source is a CspFinding @@ -84,13 +81,10 @@ const customCellRenderer = (rows: DataTableRecord[]) => ({ }); export const LatestFindingsTable = ({ - dataView, groupSelectorComponent, height, showDistributionBar = true, nonPersistedFilters, - dataViewRefetch, - dataViewIsRefetching, }: LatestFindingsTableProps) => { const { cloudPostureDataTable, @@ -104,7 +98,6 @@ export const LatestFindingsTable = ({ canShowDistributionBar, onDistributionBarClick, } = useLatestFindingsTable({ - dataView, getDefaultQuery, nonPersistedFilters, showDistributionBar, @@ -132,7 +125,6 @@ export const LatestFindingsTable = ({ )} )} diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts index 5584b1eae08a6..9968bb9c414bf 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts @@ -29,6 +29,7 @@ import { buildMutedRulesFilter } from '../../../../common/utils/rules_states'; interface UseFindingsOptions extends FindingsBaseEsQuery { sort: string[][]; enabled: boolean; + pageSize: number; } export interface FindingsGroupByNoneQuery { @@ -76,7 +77,7 @@ export const getFindingsQuery = ( must_not: mutedRulesFilterQuery, }, }, - ...(pageParam ? { search_after: pageParam } : {}), + ...(pageParam ? { from: pageParam } : {}), }; }; @@ -125,6 +126,12 @@ export const useLatestFindings = (options: UseFindingsOptions) => { } = useKibana().services; const { data: rulesStates } = useGetCspBenchmarkRulesStatesApi(); + /** + * We're using useInfiniteQuery in this case to allow the user to fetch more data (if available and up to 10k) + * useInfiniteQuery differs from useQuery because it accumulates and caches a chunk of data from the previous fetches into an array + * it uses the getNextPageParam to know if there are more pages to load and retrieve the position of + * the last loaded record to be used as a from parameter to fetch the next chunk of data. + */ return useInfiniteQuery( ['csp_findings', { params: options }], async ({ pageParam }) => { @@ -149,9 +156,11 @@ export const useLatestFindings = (options: UseFindingsOptions) => { enabled: options.enabled && !!rulesStates, keepPreviousData: true, onError: (err: Error) => showErrorToast(toasts, err), - getNextPageParam: (lastPage) => { - if (lastPage.page.length === 0) return undefined; - return lastPage.page[lastPage.page.length - 1].raw.sort; + getNextPageParam: (lastPage, allPages) => { + if (lastPage.page.length < options.pageSize) { + return undefined; + } + return allPages.length * options.pageSize; }, } ); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_grouping.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_grouping.tsx index 7b1f10c406e15..d2386bbdd3493 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_grouping.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_grouping.tsx @@ -14,7 +14,8 @@ import { parseGroupingQuery, } from '@kbn/securitysolution-grouping/src'; import { useMemo } from 'react'; -import { DataView } from '@kbn/data-views-plugin/common'; +import { LOCAL_STORAGE_FINDINGS_GROUPING_KEY } from '../../../common/constants'; +import { useDataViewContext } from '../../../common/contexts/data_view_context'; import { Evaluation } from '../../../../common/types_old'; import { LATEST_FINDINGS_RETENTION_POLICY } from '../../../../common/constants'; import { @@ -124,14 +125,14 @@ export const isFindingsRootGroupingAggregation = ( * for the findings page */ export const useLatestFindingsGrouping = ({ - dataView, groupPanelRenderer, groupStatsRenderer, }: { - dataView: DataView; groupPanelRenderer?: GroupPanelRenderer; groupStatsRenderer?: GroupStatsRenderer; }) => { + const { dataView } = useDataViewContext(); + const { activePageIndex, grouping, @@ -154,6 +155,7 @@ export const useLatestFindingsGrouping = ({ unit: FINDINGS_UNIT, groupPanelRenderer, groupStatsRenderer, + groupingLocalStorageKey: LOCAL_STORAGE_FINDINGS_GROUPING_KEY, }); const { data: rulesStates } = useGetCspBenchmarkRulesStatesApi(); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_table.tsx index b60eefac2ac81..a2c5ad544dadb 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings_table.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import { DataView } from '@kbn/data-views-plugin/common'; import { Filter } from '@kbn/es-query'; import { useMemo } from 'react'; +import { useDataViewContext } from '../../../common/contexts/data_view_context'; import { FindingsBaseURLQuery } from '../../../common/types'; import { Evaluation } from '../../../../common/types_old'; import { LOCAL_STORAGE_DATA_TABLE_PAGE_SIZE_KEY } from '../../../common/constants'; @@ -18,25 +18,25 @@ import { useLatestFindings } from './use_latest_findings'; const columnsLocalStorageKey = 'cloudPosture:latestFindings:columns'; export const useLatestFindingsTable = ({ - dataView, getDefaultQuery, nonPersistedFilters, showDistributionBar, }: { - dataView: DataView; getDefaultQuery: (params: FindingsBaseURLQuery) => FindingsBaseURLQuery; nonPersistedFilters?: Filter[]; showDistributionBar?: boolean; }) => { + const { dataView } = useDataViewContext(); + const cloudPostureDataTable = useCloudPostureDataTable({ - dataView, paginationLocalStorageKey: LOCAL_STORAGE_DATA_TABLE_PAGE_SIZE_KEY, columnsLocalStorageKey, defaultQuery: getDefaultQuery, nonPersistedFilters, }); - const { query, sort, queryError, setUrlQuery, filters, getRowsFromPages } = cloudPostureDataTable; + const { query, sort, queryError, setUrlQuery, filters, getRowsFromPages, pageSize } = + cloudPostureDataTable; const { data, @@ -47,6 +47,7 @@ export const useLatestFindingsTable = ({ query, sort, enabled: !queryError, + pageSize, }); const rows = useMemo(() => getRowsFromPages(data?.pages), [data?.pages, getRowsFromPages]); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx deleted file mode 100644 index 85095b149bce4..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx +++ /dev/null @@ -1,189 +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 React from 'react'; -import { Routes, Route } from '@kbn/shared-ux-router'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; -import { CspFinding } from '../../../../common/schemas/csp_finding'; -import type { Evaluation } from '../../../../common/types_old'; -import { FindingsSearchBar } from '../layout/findings_search_bar'; -import * as TEST_SUBJECTS from '../test_subjects'; -import { usePageSlice } from '../../../common/hooks/use_page_slice'; -import { FindingsByResourceQuery, useFindingsByResource } from './use_findings_by_resource'; -import { FindingsByResourceTable } from './findings_by_resource_table'; -import { getFilters } from '../utils/utils'; -import { LimitedResultsBar } from '../layout/findings_layout'; -import { FindingsGroupBySelector } from '../layout/findings_group_by_selector'; -import { findingsNavigation } from '../../../common/navigation/constants'; -import { ResourceFindings } from './resource_findings/resource_findings_container'; -import { ErrorCallout } from '../layout/error_callout'; -import { CurrentPageOfTotal, FindingsDistributionBar } from '../layout/findings_distribution_bar'; -import { LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY } from '../../../common/constants'; -import type { FindingsBaseURLQuery, FindingsBaseProps } from '../../../common/types'; -import { useCloudPostureTable } from '../../../common/hooks/use_cloud_posture_table'; -import { useLimitProperties } from '../../../common/utils/get_limit_properties'; -import { getPaginationTableParams } from '../../../common/hooks/use_cloud_posture_table/utils'; - -const getDefaultQuery = ({ - query, - filters, -}: FindingsBaseURLQuery): FindingsBaseURLQuery & FindingsByResourceQuery => ({ - query, - filters, - pageIndex: 0, - sort: { field: 'compliance_score' as keyof CspFinding, direction: 'asc' }, -}); - -/** - * @deprecated: This component is deprecated and will be removed in the next release. - */ -export const FindingsByResourceContainer = ({ dataView }: FindingsBaseProps) => ( - - ( - - - - )} - /> - ( - - - - )} - /> - -); - -/** - * @deprecated: This component is deprecated and will be removed in the next release. - */ -const LatestFindingsByResource = ({ dataView }: FindingsBaseProps) => { - const { queryError, query, pageSize, setTableOptions, urlQuery, setUrlQuery, onResetFilters } = - useCloudPostureTable({ - dataView, - defaultQuery: getDefaultQuery, - paginationLocalStorageKey: LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY, - }); - - /** - * Page ES query result - */ - const findingsGroupByResource = useFindingsByResource({ - sortDirection: urlQuery.sort.direction, - query, - enabled: !queryError, - }); - - const error = findingsGroupByResource.error || queryError; - - const slicedPage = usePageSlice(findingsGroupByResource.data?.page, urlQuery.pageIndex, pageSize); - - const { isLastLimitedPage, limitedTotalItemCount } = useLimitProperties({ - total: findingsGroupByResource.data?.total, - pageIndex: urlQuery.pageIndex, - pageSize, - }); - - const handleDistributionClick = (evaluation: Evaluation) => { - setUrlQuery({ - pageIndex: 0, - filters: getFilters({ - filters: urlQuery.filters, - dataView, - field: 'result.evaluation', - value: evaluation, - negate: false, - }), - }); - }; - - return ( -
- { - setUrlQuery({ ...newQuery, pageIndex: 0 }); - }} - loading={findingsGroupByResource.isFetching} - /> - - - {error && } - {!error && ( - <> - {findingsGroupByResource.isSuccess && !!findingsGroupByResource.data.page.length && ( - <> - - - - - - - - - - - - )} - - - setUrlQuery({ - pageIndex: 0, - filters: getFilters({ - filters: urlQuery.filters, - dataView, - field, - value, - negate, - }), - }) - } - /> - - )} - {isLastLimitedPage && } -
- ); -}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_table.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_table.test.tsx deleted file mode 100644 index f0a6375a66178..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_table.test.tsx +++ /dev/null @@ -1,113 +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 React from 'react'; -import { render, screen, within } from '@testing-library/react'; -import * as TEST_SUBJECTS from '../test_subjects'; -import { FindingsByResourceTable, getResourceId } from './findings_by_resource_table'; -import type { PropsOf } from '@elastic/eui'; -import Chance from 'chance'; -import { TestProvider } from '../../../test/test_provider'; -import type { FindingsByResourcePage } from './use_findings_by_resource'; -import { calculatePostureScore } from '../../../../common/utils/helpers'; -import { EMPTY_STATE_TEST_SUBJ } from '../../../components/test_subjects'; - -const chance = new Chance(); - -const getFakeFindingsByResource = (): FindingsByResourcePage => { - const failed = chance.natural(); - const passed = chance.natural(); - const total = failed + passed; - const [resourceName, resourceSubtype, ruleBenchmarkName, ...cisSections] = chance.unique( - chance.word, - 5 - ); - - return { - belongs_to: chance.guid(), - resource_id: chance.guid(), - 'resource.name': resourceName, - 'resource.sub_type': resourceSubtype, - 'rule.section': cisSections, - 'rule.benchmark.name': ruleBenchmarkName, - compliance_score: passed / total, - findings: { - failed_findings: failed, - passed_findings: passed, - normalized: passed / total, - total_findings: total, - }, - }; -}; - -type TableProps = PropsOf; - -describe('', () => { - it('renders the zero state when status success and data has a length of zero ', async () => { - const props: TableProps = { - loading: false, - items: [], - pagination: { pageIndex: 0, pageSize: 10, totalItemCount: 0 }, - sorting: { - sort: { field: 'compliance_score', direction: 'desc' }, - }, - setTableOptions: jest.fn(), - onAddFilter: jest.fn(), - onResetFilters: jest.fn(), - }; - - render( - - - - ); - - expect(screen.getByTestId(EMPTY_STATE_TEST_SUBJ)).toBeInTheDocument(); - }); - - it('renders the table with provided items', () => { - const data = Array.from({ length: 10 }, getFakeFindingsByResource); - - const props: TableProps = { - loading: false, - items: data, - pagination: { pageIndex: 0, pageSize: 10, totalItemCount: 0 }, - sorting: { - sort: { field: 'compliance_score', direction: 'desc' }, - }, - setTableOptions: jest.fn(), - onAddFilter: jest.fn(), - onResetFilters: jest.fn(), - }; - - render( - - - - ); - - data.forEach((item) => { - const row = screen.getByTestId( - TEST_SUBJECTS.getFindingsByResourceTableRowTestId(getResourceId(item)) - ); - expect(row).toBeInTheDocument(); - expect(within(row).getByText(item.resource_id || '')).toBeInTheDocument(); - if (item['resource.name']) - expect(within(row).getByText(item['resource.name'])).toBeInTheDocument(); - if (item['resource.sub_type']) - expect(within(row).getByText(item['resource.sub_type'])).toBeInTheDocument(); - expect( - within(row).getByText( - `${calculatePostureScore( - item.findings.passed_findings, - item.findings.failed_findings - ).toFixed(0)}%` - ) - ).toBeInTheDocument(); - }); - }); -}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_table.tsx deleted file mode 100644 index 71c4219d3b852..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_table.tsx +++ /dev/null @@ -1,212 +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 React, { useMemo } from 'react'; -import { - EuiBasicTable, - type EuiTableFieldDataColumnType, - type CriteriaWithPagination, - type Pagination, - EuiToolTip, - EuiBasicTableProps, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import numeral from '@elastic/numeral'; -import { generatePath, Link } from 'react-router-dom'; -import { i18n } from '@kbn/i18n'; -import { ColumnNameWithTooltip } from '../../../components/column_name_with_tooltip'; -import { ComplianceScoreBar } from '../../../components/compliance_score_bar'; -import * as TEST_SUBJECTS from '../test_subjects'; -import type { FindingsByResourcePage } from './use_findings_by_resource'; -import { findingsNavigation } from '../../../common/navigation/constants'; -import { - createColumnWithFilters, - type OnAddFilter, - baseFindingsColumns, -} from '../layout/findings_layout'; -import { EmptyState } from '../../../components/empty_state'; - -/** - * @deprecated: This function is deprecated and will be removed in the next release. - * use getAbbreviatedNumber from x-pack/plugins/cloud_security_posture/public/common/utils/get_abbreviated_number.ts - */ -export const formatNumber = (value: number) => - value < 1000 ? value : numeral(value).format('0.0a'); - -type Sorting = Required>['sorting']; - -interface Props { - items: FindingsByResourcePage[]; - loading: boolean; - pagination: Pagination; - sorting: Sorting; - setTableOptions(options: CriteriaWithPagination): void; - onAddFilter: OnAddFilter; - onResetFilters: () => void; -} - -/** - * @deprecated: This function is deprecated and will be removed in the next release. - */ -export const getResourceId = (resource: FindingsByResourcePage) => { - const sections = resource['rule.section'] || []; - return [resource.resource_id, ...sections].join('/'); -}; - -/** - * @deprecated: This component is deprecated and will be removed in the next release. - */ -const FindingsByResourceTableComponent = ({ - items, - loading, - pagination, - sorting, - setTableOptions, - onAddFilter, - onResetFilters, -}: Props) => { - const getRowProps = (row: FindingsByResourcePage) => ({ - 'data-test-subj': TEST_SUBJECTS.getFindingsByResourceTableRowTestId(getResourceId(row)), - }); - - const getNonSortableColumn = (column: EuiTableFieldDataColumnType) => ({ - ...column, - sortable: false, - }); - - const columns = useMemo( - () => [ - { - ...getNonSortableColumn(findingsByResourceColumns.resource_id), - ['data-test-subj']: TEST_SUBJECTS.FINDINGS_BY_RESOURCE_TABLE_RESOURCE_ID_COLUMN, - }, - createColumnWithFilters( - getNonSortableColumn(findingsByResourceColumns['resource.sub_type']), - { onAddFilter } - ), - createColumnWithFilters(getNonSortableColumn(findingsByResourceColumns['resource.name']), { - onAddFilter, - }), - createColumnWithFilters( - getNonSortableColumn(findingsByResourceColumns['rule.benchmark.name']), - { onAddFilter } - ), - getNonSortableColumn(findingsByResourceColumns.belongs_to), - findingsByResourceColumns.compliance_score, - ], - [onAddFilter] - ); - - if (!loading && !items.length) { - return ; - } - - return ( - - ); -}; - -const baseColumns: Array> = [ - { - ...baseFindingsColumns['resource.id'], - field: 'resource_id', - width: '15%', - render: (resourceId: FindingsByResourcePage['resource_id']) => { - if (!resourceId) return; - - return ( - - {resourceId} - - ); - }, - }, - baseFindingsColumns['resource.sub_type'], - baseFindingsColumns['resource.name'], - baseFindingsColumns['rule.benchmark.name'], - { - field: 'rule.section', - truncateText: true, - name: ( - - ), - render: (sections: string[]) => { - const items = sections.join(', '); - return ( - - <>{items} - - ); - }, - }, - { - field: 'belongs_to', - name: ( - - ), - truncateText: true, - }, - { - field: 'compliance_score', - width: '150px', - truncateText: true, - sortable: true, - name: ( - - ), - render: (complianceScore: FindingsByResourcePage['compliance_score'], data) => ( - - ), - dataType: 'number', - }, -]; - -type BaseFindingColumnName = typeof baseColumns[number]['field']; - -/** - * @deprecated: This function is deprecated and will be removed in the next release. - */ -export const findingsByResourceColumns = Object.fromEntries( - baseColumns.map((column) => [column.field, column]) -) as Record; - -/** - * @deprecated: This component is deprecated and will be removed in the next release. - */ -export const FindingsByResourceTable = React.memo(FindingsByResourceTableComponent); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_container.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_container.test.tsx deleted file mode 100644 index 95eb978f8e1ca..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_container.test.tsx +++ /dev/null @@ -1,50 +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 React from 'react'; -import { render } from '@testing-library/react'; -import { TestProvider } from '../../../../test/test_provider'; -import { useResourceFindings } from './use_resource_findings'; -import { FindingsBaseProps } from '../../../../common/types'; -import { ResourceFindings } from './resource_findings_container'; - -jest.mock('./use_resource_findings', () => ({ - useResourceFindings: jest.fn().mockReturnValue({ - data: undefined, - error: false, - }), -})); - -describe('', () => { - it('should fetch resources with the correct parameters', async () => { - const props: FindingsBaseProps = { - dataView: {} as any, - }; - - render( - - - - ); - - expect(useResourceFindings).toHaveBeenNthCalledWith(1, { - enabled: true, - query: { - bool: { - filter: [], - must: [], - must_not: [], - should: [], - }, - }, - resourceId: 'undefined', - sort: { - direction: 'asc', - field: 'result.evaluation', - }, - }); - }); -}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_container.tsx deleted file mode 100644 index bc6e67b887096..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_container.tsx +++ /dev/null @@ -1,301 +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 React, { useCallback } from 'react'; -import { - EuiSpacer, - EuiButtonEmpty, - type EuiDescriptionListProps, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; -import { Link, useParams } from 'react-router-dom'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { generatePath } from 'react-router-dom'; -import { i18n } from '@kbn/i18n'; -import { CspInlineDescriptionList } from '../../../../components/csp_inline_description_list'; -import type { Evaluation } from '../../../../../common/types_old'; -import { CspFinding } from '../../../../../common/schemas/csp_finding'; -import { CloudPosturePageTitle } from '../../../../components/cloud_posture_page_title'; -import * as TEST_SUBJECTS from '../../test_subjects'; -import { LimitedResultsBar, PageTitle, PageTitleText } from '../../layout/findings_layout'; -import { findingsNavigation } from '../../../../common/navigation/constants'; -import { ResourceFindingsQuery, useResourceFindings } from './use_resource_findings'; -import { usePageSlice } from '../../../../common/hooks/use_page_slice'; -import { getFilters } from '../../utils/utils'; -import { ResourceFindingsTable } from './resource_findings_table'; -import { FindingsSearchBar } from '../../layout/findings_search_bar'; -import { ErrorCallout } from '../../layout/error_callout'; -import { - CurrentPageOfTotal, - FindingsDistributionBar, -} from '../../layout/findings_distribution_bar'; -import { LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY } from '../../../../common/constants'; -import type { FindingsBaseURLQuery, FindingsBaseProps } from '../../../../common/types'; -import { useCloudPostureTable } from '../../../../common/hooks/use_cloud_posture_table'; -import { useLimitProperties } from '../../../../common/utils/get_limit_properties'; -import { getPaginationTableParams } from '../../../../common/hooks/use_cloud_posture_table/utils'; - -const getDefaultQuery = ({ - query, - filters, -}: FindingsBaseURLQuery): FindingsBaseURLQuery & - ResourceFindingsQuery & { findingIndex: number } => ({ - query, - filters, - sort: { field: 'result.evaluation' as keyof CspFinding, direction: 'asc' }, - pageIndex: 0, - findingIndex: -1, -}); - -const BackToResourcesButton = () => ( - - - - - -); - -const getResourceFindingSharedValues = (sharedValues: { - resourceId: string; - resourceSubType: string; - resourceName: string; - clusterId: string; - cloudAccountName: string; -}): EuiDescriptionListProps['listItems'] => [ - { - title: i18n.translate('xpack.csp.findings.resourceFindingsSharedValues.resourceTypeTitle', { - defaultMessage: 'Resource Type', - }), - description: sharedValues.resourceSubType, - }, - { - title: i18n.translate('xpack.csp.findings.resourceFindingsSharedValues.resourceIdTitle', { - defaultMessage: 'Resource ID', - }), - description: sharedValues.resourceId, - }, - { - title: i18n.translate('xpack.csp.findings.resourceFindingsSharedValues.clusterIdTitle', { - defaultMessage: 'Cluster ID', - }), - description: sharedValues.clusterId, - }, - { - title: i18n.translate('xpack.csp.findings.resourceFindingsSharedValues.cloudAccountName', { - defaultMessage: 'Cloud Account Name', - }), - description: sharedValues.cloudAccountName, - }, -]; - -/** - * @deprecated: This component is deprecated and will be removed in the next release. - */ -export const ResourceFindings = ({ dataView }: FindingsBaseProps) => { - const params = useParams<{ resourceId: string }>(); - const decodedResourceId = decodeURIComponent(params.resourceId); - - const { - pageIndex, - sort, - query, - queryError, - pageSize, - setTableOptions, - urlQuery, - setUrlQuery, - onResetFilters, - } = useCloudPostureTable({ - dataView, - defaultQuery: getDefaultQuery, - paginationLocalStorageKey: LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY, - }); - - /** - * Page ES query result - */ - const resourceFindings = useResourceFindings({ - sort, - resourceId: decodedResourceId, - enabled: !queryError, - query, - }); - - const error = resourceFindings.error || queryError; - - const slicedPage = usePageSlice(resourceFindings.data?.page, urlQuery.pageIndex, pageSize); - - const { isLastLimitedPage, limitedTotalItemCount } = useLimitProperties({ - total: resourceFindings.data?.total, - pageIndex: urlQuery.pageIndex, - pageSize, - }); - - const handleDistributionClick = (evaluation: Evaluation) => { - setUrlQuery({ - pageIndex: 0, - filters: getFilters({ - filters: urlQuery.filters, - dataView, - field: 'result.evaluation', - value: evaluation, - negate: false, - }), - }); - }; - - const flyoutFindingIndex = urlQuery?.findingIndex; - - const pagination = getPaginationTableParams({ - pageSize, - pageIndex, - totalItemCount: limitedTotalItemCount, - }); - - const onOpenFlyout = useCallback( - (flyoutFinding: CspFinding) => { - setUrlQuery({ - findingIndex: slicedPage.findIndex( - (finding) => - finding.resource.id === flyoutFinding?.resource.id && - finding.rule.id === flyoutFinding?.rule.id - ), - }); - }, - [slicedPage, setUrlQuery] - ); - - const onCloseFlyout = () => - setUrlQuery({ - findingIndex: -1, - }); - - const onPaginateFlyout = useCallback( - (nextFindingIndex: number) => { - // the index of the finding in the current page - const newFindingIndex = nextFindingIndex % pageSize; - - // if the finding is not in the current page, we need to change the page - const flyoutPageIndex = Math.floor(nextFindingIndex / pageSize); - - setUrlQuery({ - pageIndex: flyoutPageIndex, - findingIndex: newFindingIndex, - }); - }, - [pageSize, setUrlQuery] - ); - - return ( -
- { - setUrlQuery({ ...newQuery, pageIndex: 0 }); - }} - loading={resourceFindings.isFetching} - /> - - - - - } - /> - - - {resourceFindings.data && ( - - )} - - - {error && } - {!error && ( - <> - {resourceFindings.isSuccess && !!resourceFindings.data.page.length && ( - <> - - - - - - - - - )} - - - setUrlQuery({ - pageIndex: 0, - filters: getFilters({ - filters: urlQuery.filters, - dataView, - field, - value, - negate, - }), - }) - } - /> - - )} - {isLastLimitedPage && } -
- ); -}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_table.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_table.test.tsx deleted file mode 100644 index b47366938db8d..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_table.test.tsx +++ /dev/null @@ -1,86 +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 React from 'react'; -import { render, screen, within } from '@testing-library/react'; -import * as TEST_SUBJECTS from '../../test_subjects'; -import { ResourceFindingsTable, ResourceFindingsTableProps } from './resource_findings_table'; -import { TestProvider } from '../../../../test/test_provider'; - -import { capitalize } from 'lodash'; -import moment from 'moment'; -import { getFindingsFixture } from '../../../../test/fixtures/findings_fixture'; -import { EMPTY_STATE_TEST_SUBJ } from '../../../../components/test_subjects'; - -describe('', () => { - it('should render no findings empty state when status success and data has a length of zero ', async () => { - const resourceFindingsProps: ResourceFindingsTableProps = { - loading: false, - items: [], - pagination: { pageIndex: 0, pageSize: 10, totalItemCount: 0 }, - sorting: { - sort: { field: '@timestamp', direction: 'desc' }, - }, - setTableOptions: jest.fn(), - onAddFilter: jest.fn(), - flyoutFindingIndex: -1, - onOpenFlyout: jest.fn(), - onCloseFlyout: jest.fn(), - onPaginateFlyout: jest.fn(), - onResetFilters: jest.fn(), - }; - - render( - - - - ); - - expect(screen.getByTestId(EMPTY_STATE_TEST_SUBJ)).toBeInTheDocument(); - }); - - it('should render resource finding table content when data has a non zero length', () => { - const data = Array.from({ length: 10 }, getFindingsFixture); - - const props: ResourceFindingsTableProps = { - loading: false, - items: data, - pagination: { pageIndex: 0, pageSize: 10, totalItemCount: 0 }, - sorting: { - sort: { field: 'cluster_id', direction: 'desc' }, - }, - setTableOptions: jest.fn(), - onAddFilter: jest.fn(), - flyoutFindingIndex: -1, - onOpenFlyout: jest.fn(), - onCloseFlyout: jest.fn(), - onPaginateFlyout: jest.fn(), - onResetFilters: jest.fn(), - }; - - render( - - - - ); - - data.forEach((item, i) => { - const row = screen.getByTestId( - TEST_SUBJECTS.getResourceFindingsTableRowTestId(item.resource.id) - ); - const { evaluation } = item.result; - const evaluationStatusText = capitalize( - item.result.evaluation.slice(0, evaluation.length - 2) - ); - - expect(row).toBeInTheDocument(); - expect(within(row).queryByText(item.rule.name)).toBeInTheDocument(); - expect(within(row).queryByText(evaluationStatusText)).toBeInTheDocument(); - expect(within(row).queryByText(moment(item['@timestamp']).fromNow())).toBeInTheDocument(); - expect(within(row).queryByText(item.rule.section)).toBeInTheDocument(); - }); - }); -}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_table.tsx deleted file mode 100644 index 4dd7070af88f1..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_table.tsx +++ /dev/null @@ -1,112 +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 React, { useMemo } from 'react'; -import { - EuiBasicTable, - type CriteriaWithPagination, - type Pagination, - type EuiBasicTableColumn, - type EuiTableActionsColumnType, - type EuiBasicTableProps, - useEuiTheme, -} from '@elastic/eui'; -import { CspFinding } from '../../../../../common/schemas/csp_finding'; -import { - baseFindingsColumns, - createColumnWithFilters, - getExpandColumn, - type OnAddFilter, -} from '../../layout/findings_layout'; -import { FindingsRuleFlyout } from '../../findings_flyout/findings_flyout'; -import { getSelectedRowStyle } from '../../utils/utils'; -import * as TEST_SUBJECTS from '../../test_subjects'; -import { EmptyState } from '../../../../components/empty_state'; - -export interface ResourceFindingsTableProps { - items: CspFinding[]; - loading: boolean; - pagination: Pagination & { pageSize: number }; - sorting: Required>['sorting']; - setTableOptions(options: CriteriaWithPagination): void; - onAddFilter: OnAddFilter; - onPaginateFlyout: (pageIndex: number) => void; - onCloseFlyout: () => void; - onOpenFlyout: (finding: CspFinding) => void; - flyoutFindingIndex: number; - onResetFilters: () => void; -} - -const ResourceFindingsTableComponent = ({ - items, - loading, - pagination, - sorting, - setTableOptions, - onAddFilter, - onOpenFlyout, - flyoutFindingIndex, - onPaginateFlyout, - onCloseFlyout, - onResetFilters, -}: ResourceFindingsTableProps) => { - const { euiTheme } = useEuiTheme(); - - const selectedFinding = items[flyoutFindingIndex]; - - const getRowProps = (row: CspFinding) => ({ - style: getSelectedRowStyle(euiTheme, row, selectedFinding), - 'data-test-subj': TEST_SUBJECTS.getResourceFindingsTableRowTestId(row.resource.id), - }); - - const columns: [ - EuiTableActionsColumnType, - ...Array> - ] = useMemo( - () => [ - getExpandColumn({ onClick: onOpenFlyout }), - createColumnWithFilters(baseFindingsColumns['result.evaluation'], { onAddFilter }), - baseFindingsColumns['rule.benchmark.rule_number'], - createColumnWithFilters(baseFindingsColumns['rule.name'], { onAddFilter }), - createColumnWithFilters(baseFindingsColumns['rule.section'], { onAddFilter }), - baseFindingsColumns['@timestamp'], - ], - [onAddFilter, onOpenFlyout] - ); - - if (!loading && !items.length) { - return ; - } - - return ( - <> - - {selectedFinding && ( - - )} - - ); -}; - -/** - * @deprecated: This component is deprecated and will be removed in the next release. - */ -export const ResourceFindingsTable = React.memo(ResourceFindingsTableComponent); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/use_resource_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/use_resource_findings.ts deleted file mode 100644 index 46a5e12665660..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/use_resource_findings.ts +++ /dev/null @@ -1,136 +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 { useQuery } from '@tanstack/react-query'; -import { lastValueFrom } from 'rxjs'; -import { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/common'; -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { Pagination } from '@elastic/eui'; -import { number } from 'io-ts'; -import { getSafeKspmClusterIdRuntimeMapping } from '../../../../../common/runtime_mappings/get_safe_kspm_cluster_id_runtime_mapping'; -import { CspFinding } from '../../../../../common/schemas/csp_finding'; -import { getAggregationCount, getFindingsCountAggQuery } from '../../utils/utils'; -import { useKibana } from '../../../../common/hooks/use_kibana'; -import type { FindingsBaseEsQuery, Sort } from '../../../../common/types'; -import { CSP_LATEST_FINDINGS_DATA_VIEW } from '../../../../../common/constants'; -import { MAX_FINDINGS_TO_LOAD } from '../../../../common/constants'; -import { showErrorToast } from '../../../../common/utils/show_error_toast'; - -interface UseResourceFindingsOptions extends FindingsBaseEsQuery { - resourceId: string; - sort: Sort; - enabled: boolean; -} - -export interface ResourceFindingsQuery { - pageIndex: Pagination['pageIndex']; - sort: Sort; -} - -type ResourceFindingsRequest = IKibanaSearchRequest; -type ResourceFindingsResponse = IKibanaSearchResponse< - estypes.SearchResponse ->; - -export type ResourceFindingsResponseAggs = Record< - 'count' | 'clusterId' | 'resourceSubType' | 'resourceName' | 'cloudAccountName', - estypes.AggregationsMultiBucketAggregateBase< - estypes.AggregationsStringRareTermsBucketKeys | undefined - > ->; - -const getResourceFindingsQuery = ({ - query, - resourceId, - sort, -}: UseResourceFindingsOptions): estypes.SearchRequest => ({ - index: CSP_LATEST_FINDINGS_DATA_VIEW, - body: { - size: MAX_FINDINGS_TO_LOAD, - runtime_mappings: { - ...getSafeKspmClusterIdRuntimeMapping(), - }, - query: { - ...query, - bool: { - ...query?.bool, - filter: [...(query?.bool?.filter || []), { term: { 'resource.id': resourceId } }], - }, - }, - sort: [{ [sort.field]: sort.direction }], - aggs: { - ...getFindingsCountAggQuery(), - cloudAccountName: { - terms: { field: 'cloud.account.name' }, - }, - clusterId: { - terms: { field: 'safe_kspm_cluster_id' }, - }, - resourceSubType: { - terms: { field: 'resource.sub_type' }, - }, - resourceName: { - terms: { field: 'resource.name' }, - }, - }, - }, - ignore_unavailable: false, -}); - -/** - * @deprecated: This hook is deprecated and will be removed in the next release. - */ -export const useResourceFindings = (options: UseResourceFindingsOptions) => { - const { - data, - notifications: { toasts }, - } = useKibana().services; - - const params = { ...options }; - - return useQuery( - ['csp_resource_findings', { params }], - () => - lastValueFrom( - data.search.search({ - params: getResourceFindingsQuery(params), - }) - ), - { - enabled: options.enabled, - keepPreviousData: true, - select: ({ rawResponse: { hits, aggregations } }: ResourceFindingsResponse) => { - if (!aggregations) throw new Error('expected aggregations to exists'); - assertNonBucketsArray(aggregations.count?.buckets); - assertNonBucketsArray(aggregations.clusterId?.buckets); - assertNonBucketsArray(aggregations.resourceSubType?.buckets); - assertNonBucketsArray(aggregations.resourceName?.buckets); - assertNonBucketsArray(aggregations.cloudAccountName?.buckets); - - return { - page: hits.hits.map((hit) => hit._source!), - total: number.is(hits.total) ? hits.total : 0, - count: getAggregationCount(aggregations.count?.buckets), - clusterId: getFirstBucketKey(aggregations.clusterId?.buckets), - resourceSubType: getFirstBucketKey(aggregations.resourceSubType?.buckets), - resourceName: getFirstBucketKey(aggregations.resourceName?.buckets), - cloudAccountName: getFirstBucketKey(aggregations.cloudAccountName?.buckets), - }; - }, - onError: (err: Error) => showErrorToast(toasts, err), - } - ); -}; - -function assertNonBucketsArray(arr: unknown): asserts arr is T[] { - if (!Array.isArray(arr)) { - throw new Error('expected buckets to be an array'); - } -} - -const getFirstBucketKey = ( - buckets: Array -): string | undefined => buckets[0]?.key; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/use_findings_by_resource.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/use_findings_by_resource.ts deleted file mode 100644 index e4bbd955f6092..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/use_findings_by_resource.ts +++ /dev/null @@ -1,218 +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 { useQuery } from '@tanstack/react-query'; -import { lastValueFrom } from 'rxjs'; -import { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/common'; -import type { Pagination } from '@elastic/eui'; -import { - AggregationsCardinalityAggregate, - AggregationsMultiBucketAggregateBase, - AggregationsMultiBucketBase, - AggregationsScriptedMetricAggregate, - AggregationsStringRareTermsBucketKeys, - AggregationsStringTermsBucketKeys, - SearchRequest, - SearchResponse, -} from '@elastic/elasticsearch/lib/api/types'; -import { CspFinding } from '../../../../common/schemas/csp_finding'; -import { getBelongsToRuntimeMapping } from '../../../../common/runtime_mappings/get_belongs_to_runtime_mapping'; -import { MAX_FINDINGS_TO_LOAD } from '../../../common/constants'; -import { useKibana } from '../../../common/hooks/use_kibana'; -import { showErrorToast } from '../../../common/utils/show_error_toast'; -import type { FindingsBaseEsQuery, Sort } from '../../../common/types'; -import { getAggregationCount, getFindingsCountAggQuery } from '../utils/utils'; -import { CSP_LATEST_FINDINGS_DATA_VIEW } from '../../../../common/constants'; - -interface UseFindingsByResourceOptions extends FindingsBaseEsQuery { - enabled: boolean; - sortDirection: Sort['direction']; -} - -// Maximum number of grouped findings, default limit in elasticsearch is set to 65,536 (ref: https://www.elastic.co/guide/en/elasticsearch/reference/current/search-settings.html#search-settings-max-buckets) -const MAX_BUCKETS = 60 * 1000; - -export interface FindingsByResourceQuery { - pageIndex: Pagination['pageIndex']; - sort: Sort; -} - -type FindingsAggRequest = IKibanaSearchRequest; -type FindingsAggResponse = IKibanaSearchResponse>; - -export interface FindingsByResourcePage { - findings: { - failed_findings: number; - passed_findings: number; - normalized: number; - total_findings: number; - }; - compliance_score: number; - resource_id?: string; - belongs_to?: string; - 'resource.name'?: string; - 'resource.sub_type'?: string; - 'rule.benchmark.name'?: string; - 'rule.section'?: string[]; -} - -interface FindingsByResourceAggs { - resource_total: AggregationsCardinalityAggregate; - resources: AggregationsMultiBucketAggregateBase; - count: AggregationsMultiBucketAggregateBase; -} - -interface FindingsAggBucket extends AggregationsStringRareTermsBucketKeys { - failed_findings: AggregationsMultiBucketBase; - compliance_score: AggregationsScriptedMetricAggregate; - passed_findings: AggregationsMultiBucketBase; - name: AggregationsMultiBucketAggregateBase; - subtype: AggregationsMultiBucketAggregateBase; - belongs_to: AggregationsMultiBucketAggregateBase; - benchmarkName: AggregationsMultiBucketAggregateBase; - cis_sections: AggregationsMultiBucketAggregateBase; -} - -/** - * @deprecated: This hook is deprecated and will be removed in the next release. - */ -export const getFindingsByResourceAggQuery = ({ - query, - sortDirection, -}: UseFindingsByResourceOptions): SearchRequest => ({ - index: CSP_LATEST_FINDINGS_DATA_VIEW, - query, - size: 0, - runtime_mappings: getBelongsToRuntimeMapping(), - aggs: { - ...getFindingsCountAggQuery(), - resource_total: { cardinality: { field: 'resource.id' } }, - resources: { - terms: { field: 'resource.id', size: MAX_BUCKETS }, - aggs: { - name: { - terms: { field: 'resource.name', size: 1 }, - }, - subtype: { - terms: { field: 'resource.sub_type', size: 1 }, - }, - benchmarkName: { - terms: { field: 'rule.benchmark.name' }, - }, - cis_sections: { - terms: { field: 'rule.section' }, - }, - failed_findings: { - filter: { term: { 'result.evaluation': 'failed' } }, - }, - passed_findings: { - filter: { term: { 'result.evaluation': 'passed' } }, - }, - // this field is runtime generated - belongs_to: { - terms: { field: 'belongs_to', size: 1 }, - }, - compliance_score: { - bucket_script: { - buckets_path: { - passed: 'passed_findings>_count', - failed: 'failed_findings>_count', - }, - script: 'params.passed / (params.passed + params.failed)', - }, - }, - sort_by_compliance_score: { - bucket_sort: { - size: MAX_FINDINGS_TO_LOAD, - sort: [ - { - compliance_score: { order: sortDirection }, - _count: { order: 'desc' }, - _key: { order: 'asc' }, - }, - ], - }, - }, - }, - }, - }, - ignore_unavailable: false, -}); - -const getFirstKey = ( - buckets: AggregationsMultiBucketAggregateBase['buckets'] -): undefined | string => { - if (!!Array.isArray(buckets) && !!buckets.length) return buckets[0].key; -}; - -const getKeysList = ( - buckets: AggregationsMultiBucketAggregateBase['buckets'] -): undefined | string[] => { - if (!!Array.isArray(buckets) && !!buckets.length) return buckets.map((v) => v.key); -}; - -const createFindingsByResource = (resource: FindingsAggBucket): FindingsByResourcePage => ({ - resource_id: resource.key, - ['resource.name']: getFirstKey(resource.name.buckets), - ['resource.sub_type']: getFirstKey(resource.subtype.buckets), - ['rule.section']: getKeysList(resource.cis_sections.buckets), - ['rule.benchmark.name']: getFirstKey(resource.benchmarkName.buckets), - belongs_to: getFirstKey(resource.belongs_to.buckets), - compliance_score: resource.compliance_score.value, - findings: { - failed_findings: resource.failed_findings.doc_count, - normalized: - resource.doc_count > 0 ? resource.failed_findings.doc_count / resource.doc_count : 0, - total_findings: resource.doc_count, - passed_findings: resource.passed_findings.doc_count, - }, -}); - -/** - * @deprecated: This hook is deprecated and will be removed in the next release. - */ -export const useFindingsByResource = (options: UseFindingsByResourceOptions) => { - const { - data, - notifications: { toasts }, - } = useKibana().services; - - const params = { ...options }; - - return useQuery( - ['csp_findings_resource', { params }], - async () => { - const { - rawResponse: { aggregations }, - } = await lastValueFrom( - data.search.search({ - params: getFindingsByResourceAggQuery(params), - }) - ); - - if (!aggregations) throw new Error('Failed to aggregate by, missing resource id'); - - if ( - !Array.isArray(aggregations.resources.buckets) || - !Array.isArray(aggregations.count.buckets) - ) - throw new Error('Failed to group by, missing resource id'); - - const page = aggregations.resources.buckets.map(createFindingsByResource); - - return { - page, - total: aggregations.resource_total.value, - count: getAggregationCount(aggregations.count.buckets), - }; - }, - { - enabled: options.enabled, - keepPreviousData: true, - onError: (err: Error) => showErrorToast(toasts, err), - } - ); -}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_layout.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_layout.tsx deleted file mode 100644 index 2a39550a3c7d4..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_layout.tsx +++ /dev/null @@ -1,318 +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 React from 'react'; -import { - EuiBottomBar, - EuiButtonIcon, - EuiSpacer, - EuiTableActionsColumnType, - EuiTableFieldDataColumnType, - EuiText, - EuiTitle, - EuiToolTip, - PropsOf, -} from '@elastic/eui'; -import { css } from '@emotion/react'; -import { i18n } from '@kbn/i18n'; -import { euiThemeVars } from '@kbn/ui-theme'; -import type { Serializable } from '@kbn/utility-types'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { FindingsByResourcePage } from '../latest_findings_by_resource/use_findings_by_resource'; -import { MAX_FINDINGS_TO_LOAD } from '../../../common/constants'; -import { TimestampTableCell } from '../../../components/timestamp_table_cell'; -import { ColumnNameWithTooltip } from '../../../components/column_name_with_tooltip'; -import { CspEvaluationBadge } from '../../../components/csp_evaluation_badge'; -import { - FINDINGS_TABLE_CELL_ADD_FILTER, - FINDINGS_TABLE_CELL_ADD_NEGATED_FILTER, - FINDINGS_TABLE_EXPAND_COLUMN, -} from '../test_subjects'; - -export type OnAddFilter = (key: T, value: Serializable, negate: boolean) => void; - -export const PageTitle: React.FC = ({ children }) => ( - -
{children}
-
-); - -export const PageTitleText = ({ title }: { title: React.ReactNode }) => ( - -

{title}

-
-); - -export const getExpandColumn = ({ - onClick, -}: { - onClick(item: T): void; -}): EuiTableActionsColumnType => ({ - width: '40px', - actions: [ - { - 'data-test-subj': FINDINGS_TABLE_EXPAND_COLUMN, - name: i18n.translate('xpack.csp.expandColumnNameLabel', { defaultMessage: 'Expand' }), - description: i18n.translate('xpack.csp.expandColumnDescriptionLabel', { - defaultMessage: 'Expand', - }), - type: 'icon', - icon: 'expand', - onClick, - }, - ], -}); - -const baseColumns = [ - { - field: 'resource.id', - name: ( - - ), - truncateText: true, - width: '180px', - sortable: true, - render: (filename: string) => ( - - {filename} - - ), - }, - { - field: 'result.evaluation', - name: i18n.translate('xpack.csp.findings.findingsTable.findingsTableColumn.resultColumnLabel', { - defaultMessage: 'Result', - }), - width: '80px', - sortable: true, - render: (type: PropsOf['type']) => ( - - ), - }, - { - field: 'resource.sub_type', - name: i18n.translate( - 'xpack.csp.findings.findingsTable.findingsTableColumn.resourceTypeColumnLabel', - { defaultMessage: 'Resource Type' } - ), - sortable: true, - truncateText: true, - width: '10%', - }, - { - field: 'resource.name', - name: i18n.translate( - 'xpack.csp.findings.findingsTable.findingsTableColumn.resourceNameColumnLabel', - { defaultMessage: 'Resource Name' } - ), - sortable: true, - truncateText: true, - width: '12%', - render: (name: FindingsByResourcePage['resource.name']) => { - if (!name) return; - - return ( - - <>{name} - - ); - }, - }, - { - field: 'rule.name', - name: i18n.translate( - 'xpack.csp.findings.findingsTable.findingsTableColumn.ruleNameColumnLabel', - { defaultMessage: 'Rule Name' } - ), - sortable: true, - render: (name: string) => ( - - <>{name} - - ), - }, - { - field: 'rule.benchmark.rule_number', - name: i18n.translate( - 'xpack.csp.findings.findingsTable.findingsTableColumn.ruleNumberColumnLabel', - { - defaultMessage: 'Rule Number', - } - ), - sortable: true, - width: '120px', - }, - { - field: 'rule.benchmark.name', - name: ( - - ), - sortable: true, - truncateText: true, - }, - { - field: 'rule.section', - name: i18n.translate( - 'xpack.csp.findings.findingsTable.findingsTableColumn.ruleSectionColumnLabel', - { defaultMessage: 'CIS Section' } - ), - width: '150px', - sortable: true, - truncateText: true, - render: (section: string) => ( - - <>{section} - - ), - }, - { - field: '@timestamp', - align: 'right', - width: '10%', - name: i18n.translate( - 'xpack.csp.findings.findingsTable.findingsTableColumn.lastCheckedColumnLabel', - { defaultMessage: 'Last Checked' } - ), - truncateText: true, - sortable: true, - render: (timestamp: number) => , - }, -] as const; - -export const baseFindingsColumns = Object.fromEntries( - baseColumns.map((column) => [column.field, column]) -) as Record; - -export const createColumnWithFilters = ( - column: EuiTableFieldDataColumnType, - { onAddFilter }: { onAddFilter: OnAddFilter } -): EuiTableFieldDataColumnType => ({ - ...column, - render: (cellValue: Serializable, item: T) => ( - onAddFilter(column.field as string, cellValue, false)} - onAddNegateFilter={() => onAddFilter(column.field as string, cellValue, true)} - field={column.field as string} - > - {column.render?.(cellValue, item) || getCellValue(cellValue)} - - ), -}); - -const getCellValue = (value: unknown) => { - if (!value) return; - if (typeof value === 'string' || typeof value === 'number') return value; -}; - -const FilterableCell: React.FC<{ - onAddFilter(): void; - onAddNegateFilter(): void; - field: string; -}> = ({ children, onAddFilter, onAddNegateFilter, field }) => ( -
.__filter_buttons { - opacity: 1; - } - > .__filter_value { - max-width: calc(100% - calc(${euiThemeVars.euiSizeL} * 2)); - } - } - `} - > -
- {children} -
-
- - - - - - - -
-
-); - -export const LimitedResultsBar = () => ( - <> - - - - - - - -); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_search_bar.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_search_bar.tsx index 9b6e7bcb60c53..43077778c4fdf 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_search_bar.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_search_bar.tsx @@ -8,9 +8,9 @@ import React, { useContext } from 'react'; import { css } from '@emotion/react'; import { EuiThemeComputed, useEuiTheme } from '@elastic/eui'; import { useKibana } from '@kbn/kibana-react-plugin/public'; -import type { DataView } from '@kbn/data-plugin/common'; import { i18n } from '@kbn/i18n'; import type { Filter } from '@kbn/es-query'; +import { useDataViewContext } from '../../../common/contexts/data_view_context'; import { SecuritySolutionContext } from '../../../application/security_solution_context'; import type { FindingsBaseURLQuery } from '../../../common/types'; import type { CspClientPluginStartDeps } from '../../../types'; @@ -25,13 +25,12 @@ interface FindingsSearchBarProps { } export const FindingsSearchBar = ({ - dataView, loading, setQuery, placeholder = i18n.translate('xpack.csp.findings.searchBar.searchPlaceholder', { defaultMessage: 'Search findings (eg. rule.section : "API Server" )', }), -}: FindingsSearchBarProps & { dataView: DataView }) => { +}: FindingsSearchBarProps) => { const { euiTheme } = useEuiTheme(); const { unifiedSearch: { @@ -41,6 +40,8 @@ export const FindingsSearchBar = ({ const securitySolutionContext = useContext(SecuritySolutionContext); + const { dataView } = useDataViewContext(); + let searchBarNode = (
({ + query, + filters, + sort: [ + [VULNERABILITY_FIELDS.SEVERITY, 'asc'], + [VULNERABILITY_FIELDS.SCORE_BASE, 'desc'], + ], +}); + +export const defaultColumns: CloudSecurityDefaultColumn[] = [ + { id: VULNERABILITY_FIELDS.VULNERABILITY_ID, width: 130 }, + { id: VULNERABILITY_FIELDS.SCORE_BASE, width: 80 }, + { id: VULNERABILITY_FIELDS.RESOURCE_NAME }, + { id: VULNERABILITY_FIELDS.RESOURCE_ID }, + { id: VULNERABILITY_FIELDS.SEVERITY, width: 100 }, + { id: VULNERABILITY_FIELDS.PACKAGE_NAME }, + { id: VULNERABILITY_FIELDS.PACKAGE_VERSION }, + { id: VULNERABILITY_FIELDS.PACKAGE_FIXED_VERSION }, +]; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_grouped_vulnerabilities.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_grouped_vulnerabilities.tsx new file mode 100644 index 0000000000000..fda12c4b06d41 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_grouped_vulnerabilities.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SearchResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { IKibanaSearchResponse } from '@kbn/data-plugin/public'; +import { GenericBuckets, GroupingQuery, RootAggregation } from '@kbn/securitysolution-grouping/src'; +import { useQuery } from '@tanstack/react-query'; +import { lastValueFrom } from 'rxjs'; +import { LATEST_VULNERABILITIES_INDEX_PATTERN } from '../../../../common/constants'; +import { useKibana } from '../../../common/hooks/use_kibana'; +import { showErrorToast } from '../../../common/utils/show_error_toast'; + +// Elasticsearch returns `null` when a sub-aggregation cannot be computed +type NumberOrNull = number | null; + +export interface VulnerabilitiesGroupingAggregation { + unitsCount?: { + value?: NumberOrNull; + }; + groupsCount?: { + value?: NumberOrNull; + }; + groupByFields?: { + buckets?: GenericBuckets[]; + }; + description?: { + buckets?: GenericBuckets[]; + }; + resourceId?: { + buckets?: GenericBuckets[]; + }; + isLoading?: boolean; +} + +export type VulnerabilitiesRootGroupingAggregation = + RootAggregation; + +export const getGroupedVulnerabilitiesQuery = (query: GroupingQuery) => ({ + ...query, + index: LATEST_VULNERABILITIES_INDEX_PATTERN, + size: 0, +}); + +export const useGroupedVulnerabilities = ({ + query, + enabled = true, +}: { + query: GroupingQuery; + enabled: boolean; +}) => { + const { + data, + notifications: { toasts }, + } = useKibana().services; + + return useQuery( + ['csp_grouped_vulnerabilities', { query }], + async () => { + const { + rawResponse: { aggregations }, + } = await lastValueFrom( + data.search.search< + {}, + IKibanaSearchResponse> + >({ + params: getGroupedVulnerabilitiesQuery(query), + }) + ); + + if (!aggregations) throw new Error('Failed to aggregate by, missing resource id'); + + return aggregations; + }, + { + onError: (err: Error) => showErrorToast(toasts, err), + enabled, + // This allows the UI to keep the previous data while the new data is being fetched + keepPreviousData: true, + } + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities.tsx index a3ae53a25f4d9..df9d5446ea926 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery } from '@tanstack/react-query'; import { lastValueFrom } from 'rxjs'; import type { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/common'; import { number } from 'io-ts'; @@ -13,33 +13,71 @@ import { SearchResponse, AggregationsMultiBucketAggregateBase, AggregationsStringRareTermsBucketKeys, - Sort, } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { buildDataTableRecord } from '@kbn/discover-utils'; +import { EsHitRecord } from '@kbn/discover-utils/types'; +import { MAX_FINDINGS_TO_LOAD } from '../../../common/constants'; import { CspVulnerabilityFinding } from '../../../../common/schemas'; -import { LATEST_VULNERABILITIES_INDEX_PATTERN } from '../../../../common/constants'; +import { + LATEST_VULNERABILITIES_INDEX_PATTERN, + LATEST_VULNERABILITIES_RETENTION_POLICY, +} from '../../../../common/constants'; import { useKibana } from '../../../common/hooks/use_kibana'; import { showErrorToast } from '../../../common/utils/show_error_toast'; import { FindingsBaseEsQuery } from '../../../common/types'; +import { VULNERABILITY_FIELDS } from '../constants'; +import { getCaseInsensitiveSortScript } from '../utils/custom_sort_script'; type LatestFindingsRequest = IKibanaSearchRequest; -type LatestFindingsResponse = IKibanaSearchResponse>; +type LatestFindingsResponse = IKibanaSearchResponse< + SearchResponse +>; interface FindingsAggs { count: AggregationsMultiBucketAggregateBase; } - interface VulnerabilitiesQuery extends FindingsBaseEsQuery { - sort: Sort; + sort: string[][]; enabled: boolean; - pageIndex: number; pageSize: number; } -export const getFindingsQuery = ({ query, sort, pageIndex, pageSize }: VulnerabilitiesQuery) => ({ +const getMultiFieldsSort = (sort: string[][]) => { + return sort.map(([id, direction]) => { + if (id === VULNERABILITY_FIELDS.PACKAGE_NAME) { + return getCaseInsensitiveSortScript(id, direction); + } + + return { + [id]: direction, + }; + }); +}; + +export const getVulnerabilitiesQuery = ( + { query, sort }: VulnerabilitiesQuery, + pageParam: number +) => ({ index: LATEST_VULNERABILITIES_INDEX_PATTERN, - query, - from: pageIndex * pageSize, - size: pageSize, - sort, + sort: getMultiFieldsSort(sort), + size: MAX_FINDINGS_TO_LOAD, + query: { + ...query, + bool: { + ...query?.bool, + filter: [ + ...(query?.bool?.filter ?? []), + { + range: { + '@timestamp': { + gte: `now-${LATEST_VULNERABILITIES_RETENTION_POLICY}`, + lte: 'now', + }, + }, + }, + ], + }, + }, + ...(pageParam ? { from: pageParam } : {}), }); export const useLatestVulnerabilities = (options: VulnerabilitiesQuery) => { @@ -47,19 +85,25 @@ export const useLatestVulnerabilities = (options: VulnerabilitiesQuery) => { data, notifications: { toasts }, } = useKibana().services; - return useQuery( + /** + * We're using useInfiniteQuery in this case to allow the user to fetch more data (if available and up to 10k) + * useInfiniteQuery differs from useQuery because it accumulates and caches a chunk of data from the previous fetches into an array + * it uses the getNextPageParam to know if there are more pages to load and retrieve the position of + * the last loaded record to be used as a from parameter to fetch the next chunk of data. + */ + return useInfiniteQuery( [LATEST_VULNERABILITIES_INDEX_PATTERN, options], - async () => { + async ({ pageParam }) => { const { rawResponse: { hits }, } = await lastValueFrom( data.search.search({ - params: getFindingsQuery(options), + params: getVulnerabilitiesQuery(options, pageParam), }) ); return { - page: hits.hits.map((hit) => hit._source!) as CspVulnerabilityFinding[], + page: hits.hits.map((hit) => buildDataTableRecord(hit as EsHitRecord)), total: number.is(hits.total) ? hits.total : 0, }; }, @@ -68,6 +112,12 @@ export const useLatestVulnerabilities = (options: VulnerabilitiesQuery) => { keepPreviousData: true, enabled: options.enabled, onError: (err: Error) => showErrorToast(toasts, err), + getNextPageParam: (lastPage, allPages) => { + if (lastPage.page.length < options.pageSize) { + return undefined; + } + return allPages.length * options.pageSize; + }, } ); }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities_grouping.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities_grouping.tsx new file mode 100644 index 0000000000000..45fdc5c71a342 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities_grouping.tsx @@ -0,0 +1,157 @@ +/* + * 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 { getGroupingQuery } from '@kbn/securitysolution-grouping'; +import { + GroupingAggregation, + GroupPanelRenderer, + GroupStatsRenderer, + isNoneGroup, + NamedAggregation, + parseGroupingQuery, +} from '@kbn/securitysolution-grouping/src'; +import { useMemo } from 'react'; +import { LOCAL_STORAGE_VULNERABILITIES_GROUPING_KEY } from '../../../common/constants'; +import { useDataViewContext } from '../../../common/contexts/data_view_context'; +import { LATEST_VULNERABILITIES_RETENTION_POLICY } from '../../../../common/constants'; +import { + VulnerabilitiesGroupingAggregation, + VulnerabilitiesRootGroupingAggregation, + useGroupedVulnerabilities, +} from './use_grouped_vulnerabilities'; +import { + defaultGroupingOptions, + getDefaultQuery, + GROUPING_OPTIONS, + VULNERABILITY_FIELDS, +} from '../constants'; +import { useCloudSecurityGrouping } from '../../../components/cloud_security_grouping'; +import { VULNERABILITIES_UNIT, groupingTitle } from '../translations'; + +const getTermAggregation = (key: keyof VulnerabilitiesGroupingAggregation, field: string) => ({ + [key]: { + terms: { field, size: 1 }, + }, +}); + +const getAggregationsByGroupField = (field: string): NamedAggregation[] => { + if (isNoneGroup([field])) { + return []; + } + const aggMetrics: NamedAggregation[] = [ + { + groupByField: { + cardinality: { + field, + }, + }, + }, + ]; + + switch (field) { + case GROUPING_OPTIONS.RESOURCE_NAME: + return [...aggMetrics, getTermAggregation('resourceId', VULNERABILITY_FIELDS.RESOURCE_ID)]; + } + return aggMetrics; +}; + +/** + * Type Guard for checking if the given source is a VulnerabilitiesRootGroupingAggregation + */ +export const isVulnerabilitiesRootGroupingAggregation = ( + groupData: Record | undefined +): groupData is VulnerabilitiesRootGroupingAggregation => { + return groupData?.unitsCount?.value !== undefined; +}; + +/** + * Utility hook to get the latest vulnerabilities grouping data + * for the vulnerabilities page + */ +export const useLatestVulnerabilitiesGrouping = ({ + groupPanelRenderer, + groupStatsRenderer, +}: { + groupPanelRenderer?: GroupPanelRenderer; + groupStatsRenderer?: GroupStatsRenderer; +}) => { + const { dataView } = useDataViewContext(); + + const { + activePageIndex, + grouping, + pageSize, + query, + selectedGroup, + onChangeGroupsItemsPerPage, + onChangeGroupsPage, + setUrlQuery, + uniqueValue, + isNoneSelected, + onResetFilters, + error, + filters, + } = useCloudSecurityGrouping({ + dataView, + groupingTitle, + defaultGroupingOptions, + getDefaultQuery, + unit: VULNERABILITIES_UNIT, + groupPanelRenderer, + groupStatsRenderer, + groupingLocalStorageKey: LOCAL_STORAGE_VULNERABILITIES_GROUPING_KEY, + }); + + const groupingQuery = getGroupingQuery({ + additionalFilters: query ? [query] : [], + groupByField: selectedGroup, + uniqueValue, + from: `now-${LATEST_VULNERABILITIES_RETENTION_POLICY}`, + to: 'now', + pageNumber: activePageIndex * pageSize, + size: pageSize, + sort: [{ groupByField: { order: 'desc' } }], + statsAggregations: getAggregationsByGroupField(selectedGroup), + }); + + const { data, isFetching } = useGroupedVulnerabilities({ + query: groupingQuery, + enabled: !isNoneSelected, + }); + + const groupData = useMemo( + () => + parseGroupingQuery( + selectedGroup, + uniqueValue, + data as GroupingAggregation + ), + [data, selectedGroup, uniqueValue] + ); + + const isEmptyResults = + !isFetching && + isVulnerabilitiesRootGroupingAggregation(groupData) && + groupData.unitsCount?.value === 0; + + return { + groupData, + grouping, + isFetching, + activePageIndex, + pageSize, + selectedGroup, + onChangeGroupsItemsPerPage, + onChangeGroupsPage, + setUrlQuery, + isGroupSelected: !isNoneSelected, + isGroupLoading: !data, + onResetFilters, + filters, + error, + isEmptyResults, + }; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities_table.tsx new file mode 100644 index 0000000000000..6c6f9cd112c57 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities_table.tsx @@ -0,0 +1,58 @@ +/* + * 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 { Filter } from '@kbn/es-query'; +import { LOCAL_STORAGE_DATA_TABLE_PAGE_SIZE_KEY } from '../../../common/constants'; +import { FindingsBaseURLQuery } from '../../../common/types'; +import { useCloudPostureDataTable } from '../../../common/hooks/use_cloud_posture_data_table'; +import { useLatestVulnerabilities } from './use_latest_vulnerabilities'; + +const columnsLocalStorageKey = 'cloudPosture:latestVulnerabilities:columns'; + +export const useLatestVulnerabilitiesTable = ({ + getDefaultQuery, + nonPersistedFilters, +}: { + getDefaultQuery: (params: FindingsBaseURLQuery) => FindingsBaseURLQuery; + nonPersistedFilters?: Filter[]; +}) => { + const cloudPostureDataTable = useCloudPostureDataTable({ + paginationLocalStorageKey: LOCAL_STORAGE_DATA_TABLE_PAGE_SIZE_KEY, + columnsLocalStorageKey, + defaultQuery: getDefaultQuery, + nonPersistedFilters, + }); + + const { query, sort, queryError, getRowsFromPages, pageSize } = cloudPostureDataTable; + + const { + data, + error: fetchError, + isFetching, + fetchNextPage, + } = useLatestVulnerabilities({ + query, + sort, + enabled: !queryError, + pageSize, + }); + + const rows = useMemo(() => getRowsFromPages(data?.pages), [data?.pages, getRowsFromPages]); + const total = data?.pages[0].total || 0; + + const error = fetchError || queryError; + + return { + cloudPostureDataTable, + rows, + error, + isFetching, + fetchNextPage, + total, + }; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_styles.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_styles.ts deleted file mode 100644 index c09490d719f1f..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_styles.ts +++ /dev/null @@ -1,86 +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 { useEuiTheme } from '@elastic/eui'; -import { css, keyframes } from '@emotion/css'; - -export const useStyles = () => { - const { euiTheme } = useEuiTheme(); - - const highlight = keyframes` - 0% { background-color: ${euiTheme.colors.warning};} - 50% { background-color: ${euiTheme.colors.emptyShade};} - 75% { background-color: ${euiTheme.colors.warning};} - 100% { background-color: ${euiTheme.colors.emptyShade};} - `; - - const gridStyle = css` - & .euiDataGrid__content { - background: transparent; - } - & .euiDataGridHeaderCell__icon { - display: none; - } - & .euiDataGrid__controls { - border-bottom: none; - margin-bottom: ${euiTheme.size.s}; - - & .euiButtonEmpty { - font-weight: ${euiTheme.font.weight.bold}; - } - } - & .euiDataGrid__leftControls { - > .euiButtonEmpty:hover:not(:disabled), - .euiButtonEmpty:focus { - text-decoration: none; - cursor: default; - } - } - & .euiButtonIcon { - color: ${euiTheme.colors.primary}; - } - & .euiDataGridRowCell { - font-size: ${euiTheme.size.m}; - - // Vertically center content - .euiDataGridRowCell__content { - display: flex; - align-items: center; - } - } - /* EUI QUESTION: Why is this being done via CSS instead of setting isExpandable: false in the columns API? */ - & .euiDataGridRowCell__actions > .euiDataGridRowCell__expandCell { - display: none; - } - & .euiDataGridRowCell.euiDataGridRowCell--numeric { - text-align: left; - } - & .euiDataGridHeaderCell--numeric .euiDataGridHeaderCell__content { - flex-grow: 0; - text-align: left; - } - `; - - const highlightStyle = css` - & [data-test-subj='dataGridColumnSortingButton'] .euiButtonEmpty__text { - animation: ${highlight} 1s ease-out infinite; - color: ${euiTheme.colors.darkestShade}; - } - `; - - const groupBySelector = css` - width: 188px; - display: inline-block; - margin-left: 8px; - `; - - return { - highlightStyle, - gridStyle, - groupBySelector, - }; -}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/latest_vulnerabilities_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/latest_vulnerabilities_container.tsx new file mode 100644 index 0000000000000..8ba50c3aac4f1 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/latest_vulnerabilities_container.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 { Filter } from '@kbn/es-query'; +import React from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import { useLatestVulnerabilitiesGrouping } from './hooks/use_latest_vulnerabilities_grouping'; +import { LatestVulnerabilitiesTable } from './latest_vulnerabilities_table'; +import { groupPanelRenderer, groupStatsRenderer } from './latest_vulnerabilities_group_renderer'; +import { FindingsSearchBar } from '../configurations/layout/findings_search_bar'; +import { ErrorCallout } from '../configurations/layout/error_callout'; +import { EmptyState } from '../../components/empty_state'; +import { CloudSecurityGrouping } from '../../components/cloud_security_grouping'; +import { DEFAULT_GROUPING_TABLE_HEIGHT } from '../../common/constants'; + +export const LatestVulnerabilitiesContainer = () => { + const renderChildComponent = (groupFilters: Filter[]) => { + return ( + + ); + }; + + const { + isGroupSelected, + groupData, + grouping, + isFetching, + activePageIndex, + pageSize, + selectedGroup, + onChangeGroupsItemsPerPage, + onChangeGroupsPage, + setUrlQuery, + isGroupLoading, + onResetFilters, + error, + isEmptyResults, + } = useLatestVulnerabilitiesGrouping({ groupPanelRenderer, groupStatsRenderer }); + + if (error || isEmptyResults) { + return ( + <> + + + {error && } + {isEmptyResults && } + + ); + } + if (isGroupSelected) { + return ( + <> + +
+ + +
+ + ); + } + + return ( + <> + + + + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/latest_vulnerabilities_group_renderer.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/latest_vulnerabilities_group_renderer.tsx new file mode 100644 index 0000000000000..82626fd684513 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/latest_vulnerabilities_group_renderer.tsx @@ -0,0 +1,124 @@ +/* + * 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 { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiTextBlockTruncate, + EuiToolTip, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { GroupPanelRenderer, RawBucket, StatRenderer } from '@kbn/securitysolution-grouping/src'; +import React from 'react'; +import { VulnerabilitiesGroupingAggregation } from './hooks/use_grouped_vulnerabilities'; +import { GROUPING_OPTIONS } from './constants'; +import { VULNERABILITIES_GROUPING_COUNTER } from './test_subjects'; +import { NULL_GROUPING_MESSAGES, NULL_GROUPING_UNIT, VULNERABILITIES } from './translations'; +import { getAbbreviatedNumber } from '../../common/utils/get_abbreviated_number'; +import { LoadingGroup, NullGroup } from '../../components/cloud_security_grouping'; + +export const groupPanelRenderer: GroupPanelRenderer = ( + selectedGroup, + bucket, + nullGroupMessage, + isLoading +) => { + if (isLoading) { + return ; + } + + const renderNullGroup = (title: string) => ( + + ); + + switch (selectedGroup) { + case GROUPING_OPTIONS.RESOURCE_NAME: + return nullGroupMessage ? ( + renderNullGroup(NULL_GROUPING_MESSAGES.RESOURCE_NAME) + ) : ( + + + + + + + {bucket.key_as_string} {bucket.resourceId?.buckets?.[0].key} + + + + + + + ); + default: + return nullGroupMessage ? ( + renderNullGroup(NULL_GROUPING_MESSAGES.DEFAULT) + ) : ( + + + + + + {bucket.key_as_string} + + + + + + ); + } +}; + +const VulnerabilitiesCountComponent = ({ + bucket, +}: { + bucket: RawBucket; +}) => { + const { euiTheme } = useEuiTheme(); + + return ( + + + {getAbbreviatedNumber(bucket.doc_count)} + + + ); +}; + +const VulnerabilitiesCount = React.memo(VulnerabilitiesCountComponent); + +export const groupStatsRenderer = ( + selectedGroup: string, + bucket: RawBucket +): StatRenderer[] => { + const defaultBadges = [ + { + title: VULNERABILITIES, + renderer: , + }, + ]; + + return defaultBadges; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/latest_vulnerabilities_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/latest_vulnerabilities_table.tsx new file mode 100644 index 0000000000000..b27ebfb459fe4 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/latest_vulnerabilities_table.tsx @@ -0,0 +1,123 @@ +/* + * 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 { DataTableRecord } from '@kbn/discover-utils/types'; +import { i18n } from '@kbn/i18n'; +import { EuiDataGridCellValueElementProps, EuiSpacer } from '@elastic/eui'; +import { Filter } from '@kbn/es-query'; +import { CspVulnerabilityFinding } from '../../../common/schemas'; +import { CloudSecurityDataTable } from '../../components/cloud_security_data_table'; +import { useLatestVulnerabilitiesTable } from './hooks/use_latest_vulnerabilities_table'; +import { LATEST_VULNERABILITIES_TABLE } from './test_subjects'; +import { getDefaultQuery, defaultColumns } from './constants'; +import { VulnerabilityFindingFlyout } from './vulnerabilities_finding_flyout/vulnerability_finding_flyout'; +import { ErrorCallout } from '../configurations/layout/error_callout'; +import { CVSScoreBadge, SeverityStatusBadge } from '../../components/vulnerability_badges'; + +interface LatestVulnerabilitiesTableProps { + groupSelectorComponent?: JSX.Element; + height?: number; + nonPersistedFilters?: Filter[]; +} +/** + * Type Guard for checking if the given source is a CspVulnerabilityFinding + */ +const isCspVulnerabilityFinding = ( + source: Record | undefined +): source is CspVulnerabilityFinding => { + return source?.vulnerability?.id !== undefined; +}; + +/** + * This Wrapper component renders the children if the given row is a CspVulnerabilityFinding + * it uses React's Render Props pattern + */ +const CspVulnerabilityFindingRenderer = ({ + row, + children, +}: { + row: DataTableRecord; + children: ({ finding }: { finding: CspVulnerabilityFinding }) => JSX.Element; +}) => { + const source = row.raw._source; + const finding = isCspVulnerabilityFinding(source) && (source as CspVulnerabilityFinding); + if (!finding) return <>; + return children({ finding }); +}; + +const flyoutComponent = (row: DataTableRecord, onCloseFlyout: () => void): JSX.Element => { + return ( + + {({ finding }) => ( + + )} + + ); +}; + +const title = i18n.translate('xpack.csp.findings.latestVulnerabilities.tableRowTypeLabel', { + defaultMessage: 'Vulnerabilities', +}); + +const customCellRenderer = (rows: DataTableRecord[]) => ({ + 'vulnerability.score.base': ({ rowIndex }: EuiDataGridCellValueElementProps) => ( + + {({ finding }) => ( + + )} + + ), + 'vulnerability.severity': ({ rowIndex }: EuiDataGridCellValueElementProps) => ( + + {({ finding }) => } + + ), +}); + +export const LatestVulnerabilitiesTable = ({ + groupSelectorComponent, + height, + nonPersistedFilters, +}: LatestVulnerabilitiesTableProps) => { + const { cloudPostureDataTable, rows, total, error, isFetching, fetchNextPage } = + useLatestVulnerabilitiesTable({ + getDefaultQuery, + nonPersistedFilters, + }); + + const { filters } = cloudPostureDataTable; + + return ( + <> + {error ? ( + <> + + + + ) : ( + 0 ? 404 : 364}px)`} + /> + )} + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/test_subjects.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/test_subjects.ts index 72211cc778431..8ad512f8a41ee 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/test_subjects.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/test_subjects.ts @@ -13,3 +13,7 @@ export const OVERVIEW_TAB_VULNERABILITY_FLYOUT = 'vulnerability_overview_tab_fly export const SEVERITY_STATUS_VULNERABILITY_FLYOUT = 'vulnerability_severity_status_flyout'; export const TAB_ID_VULNERABILITY_FLYOUT = (tabId: string) => `vulnerability-finding-flyout-tab-${tabId}`; + +export const LATEST_VULNERABILITIES_TABLE = 'latest_vulnerabilities_table'; + +export const VULNERABILITIES_GROUPING_COUNTER = 'vulnerabilities_grouping_counter'; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/translations.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/translations.ts index b2c0ca0ca8366..65ca61056f612 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/translations.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/translations.ts @@ -22,3 +22,35 @@ export const SEARCH_BAR_PLACEHOLDER = i18n.translate( export const VULNERABILITIES = i18n.translate('xpack.csp.vulnerabilities', { defaultMessage: 'Vulnerabilities', }); + +export const VULNERABILITIES_UNIT = (totalCount: number) => + i18n.translate('xpack.csp.vulnerabilities.unit', { + values: { totalCount }, + defaultMessage: `{totalCount, plural, =1 {vulnerability} other {vulnerabilities}}`, + }); + +export const NULL_GROUPING_UNIT = i18n.translate( + 'xpack.csp.vulnerabilities.grouping.nullGroupUnit', + { + defaultMessage: 'vulnerabilities', + } +); + +export const NULL_GROUPING_MESSAGES = { + RESOURCE_NAME: i18n.translate('xpack.csp.vulnerabilities.grouping.resource.nullGroupTitle', { + defaultMessage: 'No resource', + }), + DEFAULT: i18n.translate('xpack.csp.vulnerabilities.grouping.default.nullGroupTitle', { + defaultMessage: 'No grouping', + }), +}; + +export const GROUPING_LABELS = { + RESOURCE_NAME: i18n.translate('xpack.csp.findings.latestFindings.groupByResource', { + defaultMessage: 'Resource', + }), +}; + +export const groupingTitle = i18n.translate('xpack.csp.vulnerabilities.latestFindings.groupBy', { + defaultMessage: 'Group vulnerabilities by', +}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/get_filters.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/get_filters.ts deleted file mode 100644 index 7f7d9ff544c62..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/get_filters.ts +++ /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 { - type Filter, - buildFilter, - FILTERS, - FilterStateStore, - compareFilters, - FilterCompareOptions, -} from '@kbn/es-query'; -import type { Serializable } from '@kbn/utility-types'; -import type { FindingsBaseProps } from '../../../common/types'; - -const compareOptions: FilterCompareOptions = { - negate: false, -}; - -/** - * adds a new filter to a new filters array - * removes existing filter if negated filter is added - * - * @returns {Filter[]} a new array of filters to be added back to filterManager - */ -export const getFilters = ({ - filters: existingFilters, - dataView, - field, - value, - negate, -}: { - filters: Filter[]; - dataView: FindingsBaseProps['dataView']; - field: string; - value: Serializable; - negate: boolean; -}): Filter[] => { - const dataViewField = dataView.fields.find((f) => f.spec.name === field); - if (!dataViewField) return existingFilters; - - const phraseFilter = buildFilter( - dataView, - dataViewField, - FILTERS.PHRASE, - negate, - false, - value, - null, - FilterStateStore.APP_STATE - ); - - const nextFilters = [ - ...existingFilters.filter( - // Exclude existing filters that match the newly added 'phraseFilter' - (filter) => !compareFilters(filter, phraseFilter, compareOptions) - ), - phraseFilter, - ]; - - return nextFilters; -}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/get_vulnerabilities_grid_cell_actions.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/get_vulnerabilities_grid_cell_actions.test.tsx deleted file mode 100644 index 1f84f294d8be1..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/get_vulnerabilities_grid_cell_actions.test.tsx +++ /dev/null @@ -1,189 +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 { getRowValueByColumnId } from './get_vulnerabilities_grid_cell_actions'; -import { vulnerabilitiesColumns } from '../vulnerabilities_table_columns'; -import { vulnerabilitiesByResourceColumns } from '../vulnerabilities_by_resource/vulnerabilities_by_resource_table_columns'; -import { CspVulnerabilityFinding } from '../../../../common/schemas'; - -describe('getRowValueByColumnId', () => { - it('should return vulnerability id', () => { - const vulnerabilityRow = { - vulnerability: { - id: 'CVE-2017-1000117', - }, - }; - const columns = vulnerabilitiesColumns; - const columnId = columns.vulnerability; - - expect( - getRowValueByColumnId(vulnerabilityRow as Partial, columns, columnId) - ).toEqual('CVE-2017-1000117'); - }); - - it('should return base as a vulnerability score', () => { - const vulnerabilityRow = { - vulnerability: { - score: { - base: 5, - version: 'v1', - }, - }, - }; - const columns = vulnerabilitiesColumns; - const columnId = columns.cvss; - - expect( - getRowValueByColumnId(vulnerabilityRow as Partial, columns, columnId) - ).toEqual(5); - }); - - it('should return undefined when no base score is available', () => { - const vulnerabilityRow = { - vulnerability: {}, - }; - const columns = vulnerabilitiesColumns; - const columnId = columns.cvss; - - expect( - getRowValueByColumnId(vulnerabilityRow as Partial, columns, columnId) - ).toEqual(undefined); - - const vulnerabilityRow2 = { - vulnerability: { - score: { - version: 'v1', - }, - }, - }; - - expect( - getRowValueByColumnId( - vulnerabilityRow2 as Partial, - columns, - columnId - ) - ).toEqual(undefined); - }); - - it('should return resource id', () => { - const vulnerabilityRow = { - resource: { - id: 'i-1234567890abcdef0', - }, - }; - const columns = vulnerabilitiesByResourceColumns; - const columnId = columns.resourceId; - - expect( - getRowValueByColumnId(vulnerabilityRow as Partial, columns, columnId) - ).toEqual('i-1234567890abcdef0'); - }); - - it('should return resource name', () => { - const vulnerabilityRow = { - resource: { - name: 'test', - }, - }; - const columns1 = vulnerabilitiesByResourceColumns; - const columns2 = vulnerabilitiesColumns; - const columnId1 = columns1.resourceName; - const columnId2 = columns2.resourceName; - - expect( - getRowValueByColumnId( - vulnerabilityRow as Partial, - columns1, - columnId1 - ) - ).toEqual('test'); - expect( - getRowValueByColumnId( - vulnerabilityRow as Partial, - columns2, - columnId2 - ) - ).toEqual('test'); - }); - - it('should return vulnerability severity', () => { - const vulnerabilityRow = { - vulnerability: { - severity: 'high', - }, - }; - const columns = vulnerabilitiesColumns; - const columnId = columns.severity; - - expect( - getRowValueByColumnId(vulnerabilityRow as Partial, columns, columnId) - ).toEqual('high'); - }); - - it('should return package fields', () => { - const vulnerabilityRow = { - package: { - name: 'test', - version: '1.0.0', - fixed_version: '1.0.1', - }, - }; - const columns1 = vulnerabilitiesColumns; - const columnId1 = columns1.package; - const columnId2 = columns1.version; - const columnId3 = columns1.fixedVersion; - - expect( - getRowValueByColumnId( - vulnerabilityRow as Partial, - columns1, - columnId1 - ) - ).toEqual('test'); - expect( - getRowValueByColumnId( - vulnerabilityRow as Partial, - columns1, - columnId2 - ) - ).toEqual('1.0.0'); - expect( - getRowValueByColumnId( - vulnerabilityRow as Partial, - columns1, - columnId3 - ) - ).toEqual('1.0.1'); - }); - - it('should return undefined is package is missing', () => { - const vulnerabilityRow = { - vulnerability: {}, - }; - const columns = vulnerabilitiesColumns; - const columnId = columns.package; - - expect( - getRowValueByColumnId(vulnerabilityRow as Partial, columns, columnId) - ).toEqual(undefined); - }); - - it('should return cloud region', () => { - const vulnerabilityRow = { - cloud: { - region: 'us-east-1', - }, - }; - const columns = vulnerabilitiesByResourceColumns; - const columnId = columns.region; - - expect( - getRowValueByColumnId(vulnerabilityRow as Partial, columns, columnId) - ).toEqual('us-east-1'); - }); -}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/get_vulnerabilities_grid_cell_actions.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/get_vulnerabilities_grid_cell_actions.tsx deleted file mode 100644 index dbde094d6f43b..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/get_vulnerabilities_grid_cell_actions.tsx +++ /dev/null @@ -1,166 +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 React from 'react'; -import { EuiDataGridColumn, EuiDataGridColumnCellAction, EuiToolTip } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { CspVulnerabilityFinding } from '../../../../common/schemas'; -import { getFilters } from './get_filters'; -import { FILTER_IN, FILTER_OUT } from '../translations'; - -export const getRowValueByColumnId = ( - vulnerabilityRow: Partial, - columns: Record, - columnId: string -) => { - if (columnId === columns.vulnerability) { - return vulnerabilityRow.vulnerability?.id; - } - if (columnId === columns.cvss) { - return vulnerabilityRow.vulnerability?.score?.base; - } - if (columnId === columns.resourceId) { - return vulnerabilityRow.resource?.id; - } - if (columnId === columns.resourceName) { - return vulnerabilityRow.resource?.name; - } - if (columnId === columns.severity) { - return vulnerabilityRow.vulnerability?.severity; - } - if (columnId === columns.package) { - return vulnerabilityRow.package?.name; - } - if (columnId === columns.version) { - return vulnerabilityRow.package?.version; - } - if (columnId === columns.fixedVersion) { - return vulnerabilityRow.package?.fixed_version; - } - if (columnId === columns.region) { - return vulnerabilityRow.cloud?.region; - } -}; - -export const getVulnerabilitiesGridCellActions = < - T extends Array> ->({ - data, - columns, - columnGridFn, - pageSize, - setUrlQuery, - filters, - dataView, -}: { - data: T; - columns: Record; - columnGridFn: (cellActions: EuiDataGridColumnCellAction[]) => EuiDataGridColumn[]; - pageSize: number; - setUrlQuery: (query: any) => void; - filters: any; - dataView: any; -}) => { - const getColumnIdValue = (rowIndex: number, columnId: string) => { - const vulnerabilityRow = data[rowIndex]; - if (!vulnerabilityRow) return null; - - return getRowValueByColumnId(vulnerabilityRow, columns, columnId); - }; - - const cellActions: EuiDataGridColumnCellAction[] = [ - ({ Component, rowIndex, columnId }) => { - const rowIndexFromPage = rowIndex > pageSize - 1 ? rowIndex % pageSize : rowIndex; - - const value = getColumnIdValue(rowIndexFromPage, columnId); - - if (!value) return null; - return ( - - { - setUrlQuery({ - pageIndex: 0, - filters: getFilters({ - filters, - dataView, - field: columnId, - value, - negate: false, - }), - }); - }} - > - {FILTER_IN} - - - ); - }, - ({ Component, rowIndex, columnId }) => { - const rowIndexFromPage = rowIndex > pageSize - 1 ? rowIndex % pageSize : rowIndex; - - const value = getColumnIdValue(rowIndexFromPage, columnId); - - if (!value) return null; - return ( - - { - setUrlQuery({ - pageIndex: 0, - filters: getFilters({ - filters, - dataView, - field: columnId, - value, - negate: true, - }), - }); - }} - > - {FILTER_OUT} - - - ); - }, - ]; - - return columnGridFn(cellActions); -}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx index 9c74d7640beac..aca54e19bfccf 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx @@ -4,486 +4,42 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { - EuiButtonEmpty, - EuiButtonIcon, - EuiDataGrid, - EuiDataGridCellValueElementProps, - EuiFlexItem, - EuiProgress, - EuiSpacer, - useEuiTheme, -} from '@elastic/eui'; -import { cx } from '@emotion/css'; -import { DataView } from '@kbn/data-views-plugin/common'; -import React, { useCallback, useMemo, useState, useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; +import React from 'react'; import { Routes, Route } from '@kbn/shared-ux-router'; -import { LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY } from '../../common/constants'; -import { - CloudPostureTableResult, - useCloudPostureTable, -} from '../../common/hooks/use_cloud_posture_table'; -import { useLatestVulnerabilities } from './hooks/use_latest_vulnerabilities'; -import type { VulnerabilitiesQueryData } from './types'; import { LATEST_VULNERABILITIES_INDEX_PATTERN } from '../../../common/constants'; -import { ErrorCallout } from '../configurations/layout/error_callout'; -import { FindingsSearchBar } from '../configurations/layout/findings_search_bar'; -import { CVSScoreBadge, SeverityStatusBadge } from '../../components/vulnerability_badges'; -import { EmptyState } from '../../components/empty_state'; -import { VulnerabilityFindingFlyout } from './vulnerabilities_finding_flyout/vulnerability_finding_flyout'; import { NoVulnerabilitiesStates } from '../../components/no_vulnerabilities_states'; import { useCspSetupStatusApi } from '../../common/api/use_setup_status_api'; -import { useLimitProperties } from '../../common/utils/get_limit_properties'; -import { LimitedResultsBar } from '../configurations/layout/findings_layout'; -import { - getVulnerabilitiesColumnsGrid, - vulnerabilitiesColumns, -} from './vulnerabilities_table_columns'; -import { defaultLoadingRenderer, defaultNoDataRenderer } from '../../components/cloud_posture_page'; -import { SEARCH_BAR_PLACEHOLDER, VULNERABILITIES } from './translations'; -import { - severitySchemaConfig, - severitySortScript, - getCaseInsensitiveSortScript, -} from './utils/custom_sort_script'; -import { useStyles } from './hooks/use_styles'; -import { FindingsGroupBySelector } from '../configurations/layout/findings_group_by_selector'; -import { vulnerabilitiesPathnameHandler } from './utils/vulnerabilities_pathname_handler'; +import { CloudPosturePage } from '../../components/cloud_posture_page'; import { findingsNavigation } from '../../common/navigation/constants'; -import { VulnerabilitiesByResource } from './vulnerabilities_by_resource/vulnerabilities_by_resource'; -import { ResourceVulnerabilities } from './vulnerabilities_by_resource/resource_vulnerabilities/resource_vulnerabilities'; -import { getVulnerabilitiesGridCellActions } from './utils/get_vulnerabilities_grid_cell_actions'; import { useLatestFindingsDataView } from '../../common/api/use_latest_findings_data_view'; - -const getDefaultQuery = ({ query, filters }: any): any => ({ - query, - filters, - sort: [ - { id: vulnerabilitiesColumns.severity, direction: 'desc' }, - { id: vulnerabilitiesColumns.cvss, direction: 'desc' }, - ], - pageIndex: 0, -}); - -const VulnerabilitiesDataGrid = ({ - dataView, - data, - isFetching, - onChangeItemsPerPage, - onChangePage, - onSort, - urlQuery, - onResetFilters, - pageSize, - setUrlQuery, - pageIndex, - sort, -}: { - dataView: DataView; - data: VulnerabilitiesQueryData | undefined; - isFetching: boolean; -} & Pick< - CloudPostureTableResult, - | 'pageIndex' - | 'sort' - | 'pageSize' - | 'onChangeItemsPerPage' - | 'onChangePage' - | 'onSort' - | 'urlQuery' - | 'setUrlQuery' - | 'onResetFilters' ->) => { - const { euiTheme } = useEuiTheme(); - const styles = useStyles(); - const [showHighlight, setHighlight] = useState(false); - - const invalidIndex = -1; - - const selectedVulnerability = useMemo(() => { - if (urlQuery.vulnerabilityIndex !== undefined) { - return data?.page[urlQuery.vulnerabilityIndex]; - } - }, [data?.page, urlQuery.vulnerabilityIndex]); - - const onCloseFlyout = () => { - setUrlQuery({ - vulnerabilityIndex: invalidIndex, - }); - }; - - const onSortHandler = useCallback( - (newSort: any) => { - onSort(newSort); - if (newSort.length !== sort.length) { - setHighlight(true); - setTimeout(() => { - setHighlight(false); - }, 2000); - } - }, - [onSort, sort] - ); - - const { isLastLimitedPage, limitedTotalItemCount } = useLimitProperties({ - total: data?.total, - pageIndex, - pageSize, - }); - - const onOpenFlyout = useCallback( - (vulnerabilityRow: VulnerabilitiesQueryData['page'][number]) => { - const vulnerabilityIndex = data?.page.findIndex( - (vulnerabilityRecord: VulnerabilitiesQueryData['page'][number]) => - vulnerabilityRecord.vulnerability?.id === vulnerabilityRow.vulnerability?.id && - vulnerabilityRecord.resource?.id === vulnerabilityRow.resource?.id && - vulnerabilityRecord.package.name === vulnerabilityRow.package.name && - vulnerabilityRecord.package.version === vulnerabilityRow.package.version - ); - setUrlQuery({ - vulnerabilityIndex, - }); - }, - [setUrlQuery, data?.page] - ); - - const columns = useMemo(() => { - if (!data?.page) { - return []; - } - return getVulnerabilitiesGridCellActions({ - columnGridFn: getVulnerabilitiesColumnsGrid, - columns: vulnerabilitiesColumns, - dataView, - pageSize, - data: data.page, - setUrlQuery, - filters: urlQuery.filters, - }); - }, [data?.page, dataView, pageSize, setUrlQuery, urlQuery.filters]); - - // Column visibility - const [visibleColumns, setVisibleColumns] = useState( - columns.map(({ id }) => id) // initialize to the full set of columns - ); - - const flyoutVulnerabilityIndex = urlQuery?.vulnerabilityIndex; - - const selectedVulnerabilityIndex = flyoutVulnerabilityIndex - ? flyoutVulnerabilityIndex + pageIndex * pageSize - : undefined; - - const renderCellValue = useMemo(() => { - const Cell: React.FC = ({ - columnId, - rowIndex, - setCellProps, - }): React.ReactElement | null => { - const rowIndexFromPage = rowIndex > pageSize - 1 ? rowIndex % pageSize : rowIndex; - - const vulnerabilityRow = data?.page[rowIndexFromPage]; - - useEffect(() => { - if (selectedVulnerabilityIndex === rowIndex) { - setCellProps({ - style: { - backgroundColor: euiTheme.colors.highlight, - }, - }); - } else { - setCellProps({ - style: { - backgroundColor: 'inherit', - }, - }); - } - }, [rowIndex, setCellProps]); - - if (isFetching) return null; - if (!vulnerabilityRow) return null; - if (!vulnerabilityRow.vulnerability?.id) return null; - - if (columnId === vulnerabilitiesColumns.actions) { - return ( - { - onOpenFlyout(vulnerabilityRow); - }} - /> - ); - } - if (columnId === vulnerabilitiesColumns.vulnerability) { - return <>{vulnerabilityRow.vulnerability?.id}; - } - if (columnId === vulnerabilitiesColumns.cvss) { - if ( - !vulnerabilityRow.vulnerability.score?.base || - !vulnerabilityRow.vulnerability.score?.version - ) { - return null; - } - return ( - - ); - } - if (columnId === vulnerabilitiesColumns.resourceName) { - return <>{vulnerabilityRow.resource?.name}; - } - if (columnId === vulnerabilitiesColumns.resourceId) { - return <>{vulnerabilityRow.resource?.id}; - } - if (columnId === vulnerabilitiesColumns.severity) { - if (!vulnerabilityRow.vulnerability.severity) { - return null; - } - return ; - } - - if (columnId === vulnerabilitiesColumns.package) { - return <>{vulnerabilityRow?.package?.name}; - } - if (columnId === vulnerabilitiesColumns.version) { - return <>{vulnerabilityRow?.package?.version}; - } - if (columnId === vulnerabilitiesColumns.fixedVersion) { - return <>{vulnerabilityRow?.package?.fixed_version}; - } - - return null; - }; - - return Cell; - }, [ - data?.page, - euiTheme.colors.highlight, - onOpenFlyout, - pageSize, - selectedVulnerabilityIndex, - isFetching, - ]); - - const showVulnerabilityFlyout = flyoutVulnerabilityIndex > invalidIndex; - - if (data?.page.length === 0) { - return ; - } - - const dataTableStyle = { - // Change the height of the grid to fit the page - // If there are filters, leave space for the filter bar - // Todo: Replace this component with EuiAutoSizer - height: `calc(100vh - ${urlQuery.filters.length > 0 ? 403 : 363}px)`, - minHeight: 400, - }; - - return ( - <> - -
- - - {i18n.translate('xpack.csp.vulnerabilities.totalVulnerabilities', { - defaultMessage: - '{total, plural, one {# Vulnerability} other {# Vulnerabilities}}', - values: { total: data?.total }, - })} - - - ), - }, - right: ( - - - - ), - }, - }} - gridStyle={{ - border: 'horizontal', - cellPadding: 'l', - stripes: false, - rowHover: 'none', - header: 'underline', - }} - renderCellValue={renderCellValue} - inMemory={{ level: 'enhancements' }} - sorting={{ columns: sort, onSort: onSortHandler }} - pagination={{ - pageIndex, - pageSize, - pageSizeOptions: [10, 25, 100], - onChangeItemsPerPage, - onChangePage, - }} - virtualizationOptions={{ - overscanRowCount: 20, - }} - /> - {isLastLimitedPage && } -
- {showVulnerabilityFlyout && selectedVulnerability && ( - - )} - - ); -}; - -const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => { - const { - sort, - query, - queryError, - pageSize, - pageIndex, - onChangeItemsPerPage, - onChangePage, - onSort, - urlQuery, - setUrlQuery, - onResetFilters, - } = useCloudPostureTable({ - dataView, - defaultQuery: getDefaultQuery, - paginationLocalStorageKey: LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY, - }); - - const multiFieldsSort = useMemo(() => { - return sort.map(({ id, direction }: { id: string; direction: string }) => { - if (id === vulnerabilitiesColumns.severity) { - return severitySortScript(direction); - } - if (id === vulnerabilitiesColumns.package) { - return getCaseInsensitiveSortScript(id, direction); - } - - return { - [id]: direction, - }; - }); - }, [sort]); - - const { data, isLoading, isFetching } = useLatestVulnerabilities({ - query, - sort: multiFieldsSort, - enabled: !queryError, - pageIndex, - pageSize, - }); - - const error = queryError || null; - - if (isLoading && !error) { - return defaultLoadingRenderer(); - } - - if (!data?.page && !error) { - return defaultNoDataRenderer(); - } - - return ( - <> - { - setUrlQuery({ ...newQuery, pageIndex: 0 }); - }} - loading={isFetching} - placeholder={SEARCH_BAR_PLACEHOLDER} - /> - - {error && } - {!error && ( - - )} - - ); -}; +import { LatestVulnerabilitiesContainer } from './latest_vulnerabilities_container'; +import { DataViewContext } from '../../common/contexts/data_view_context'; export const Vulnerabilities = () => { - const { data, isLoading, error } = useLatestFindingsDataView( - LATEST_VULNERABILITIES_INDEX_PATTERN - ); + const dataViewQuery = useLatestFindingsDataView(LATEST_VULNERABILITIES_INDEX_PATTERN); const getSetupStatus = useCspSetupStatusApi(); if (getSetupStatus?.data?.vuln_mgmt?.status !== 'indexed') return ; - if (error) { - return ; - } - if (isLoading) { - return defaultLoadingRenderer(); - } - - if (!data) { - return defaultNoDataRenderer(); - } + const dataViewContextValue = { + dataView: dataViewQuery.data!, + dataViewRefetch: dataViewQuery.refetch, + dataViewIsRefetching: dataViewQuery.isRefetching, + }; return ( - - } - /> - } - /> - } - /> - + + + ( + + + + )} + /> + + ); }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/__mocks__/vulnerabilities_by_resource.mock.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/__mocks__/vulnerabilities_by_resource.mock.ts deleted file mode 100644 index 4ad13e2d215a1..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/__mocks__/vulnerabilities_by_resource.mock.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const getVulnerabilitiesByResourceData = () => ({ - total: 2, - total_vulnerabilities: 8, - page: [ - { - resource: { id: 'resource-id-1', name: 'resource-test-1' }, - cloud: { region: 'us-test-1' }, - vulnerabilities_count: 4, - severity_map: { - critical: 1, - high: 1, - medium: 1, - low: 1, - }, - }, - { - resource: { id: 'resource-id-2', name: 'resource-test-2' }, - cloud: { region: 'us-test-1' }, - vulnerabilities_count: 4, - severity_map: { - critical: 1, - high: 1, - medium: 1, - low: 1, - }, - }, - ], -}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/resource_vulnerabilities/__mocks__/resource_vulnerabilities.mock.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/resource_vulnerabilities/__mocks__/resource_vulnerabilities.mock.ts deleted file mode 100644 index 8328192062cc0..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/resource_vulnerabilities/__mocks__/resource_vulnerabilities.mock.ts +++ /dev/null @@ -1,122 +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. - */ - -export const getResourceVulnerabilitiesMockData = () => ({ - page: [ - { - agent: { - name: 'ip-172-31-15-210', - id: '2d262db5-b637-4e46-a2a0-db409825ff46', - ephemeral_id: '2af1be77-0bdf-4313-b375-592848fe60d7', - type: 'cloudbeat', - version: '8.8.0', - }, - package: { - path: 'usr/lib/snapd/snapd', - fixed_version: '3.0.0-20220521103104-8f96da9f5d5e', - name: 'gopkg.in/yaml.v3', - type: 'gobinary', - version: 'v3.0.0-20210107192922-496545a6307b', - }, - resource: { - name: 'elastic-agent-instance-a6c683d0-0977-11ee-bb0b-0af2059ffbbf', - id: '0d103e99f17f355ba', - }, - elastic_agent: { - id: '2d262db5-b637-4e46-a2a0-db409825ff46', - version: '8.8.0', - snapshot: false, - }, - vulnerability: { - severity: 'HIGH', - package: { - fixed_version: '3.0.0-20220521103104-8f96da9f5d5e', - name: 'gopkg.in/yaml.v3', - version: 'v3.0.0-20210107192922-496545a6307b', - }, - description: - 'An issue in the Unmarshal function in Go-Yaml v3 causes the program to crash when attempting to deserialize invalid input.', - title: 'crash when attempting to deserialize invalid input', - classification: 'CVSS', - data_source: { - ID: 'go-vulndb', - URL: 'https://github.com/golang/vulndb', - Name: 'The Go Vulnerability Database', - }, - cwe: ['CWE-502'], - reference: 'https://avd.aquasec.com/nvd/cve-2022-28948', - score: { - version: '3.1', - base: 7.5, - }, - report_id: 1686633719, - scanner: { - vendor: 'Trivy', - version: 'v0.35.0', - }, - id: 'CVE-2022-28948', - enumeration: 'CVE', - published_date: '2022-05-19T20:15:00Z', - class: 'lang-pkgs', - cvss: { - redhat: { - V3Vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', - V3Score: 7.5, - }, - nvd: { - V3Vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', - V2Vector: 'AV:N/AC:L/Au:N/C:N/I:N/A:P', - V3Score: 7.5, - V2Score: 5, - }, - ghsa: { - V3Vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', - V3Score: 7.5, - }, - }, - }, - cloud: { - provider: 'aws', - region: 'us-east-1', - account: { - name: 'elastic-security-cloud-security-dev', - id: '704479110758', - }, - }, - '@timestamp': '2023-06-13T06:15:16.182Z', - cloudbeat: { - commit_sha: '8497f3a4b4744c645233c5a13b45400367411c2f', - commit_time: '2023-05-09T16:07:58Z', - version: '8.8.0', - }, - ecs: { - version: '8.6.0', - }, - data_stream: { - namespace: 'default', - type: 'logs', - dataset: 'cloud_security_posture.vulnerabilities', - }, - host: { - name: 'ip-172-31-15-210', - }, - event: { - agent_id_status: 'auth_metadata_missing', - sequence: 1686633719, - ingested: '2023-06-15T18:37:56Z', - created: '2023-06-13T06:15:16.18250081Z', - kind: 'state', - id: '5cad2983-4a74-455d-ab39-6c584acd3994', - type: ['info'], - category: ['vulnerability'], - dataset: 'cloud_security_posture.vulnerabilities', - outcome: 'success', - }, - }, - ], - total: 1, -}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/resource_vulnerabilities/resource_vulnerabilities.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/resource_vulnerabilities/resource_vulnerabilities.test.tsx deleted file mode 100644 index 9de1a61bebe38..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/resource_vulnerabilities/resource_vulnerabilities.test.tsx +++ /dev/null @@ -1,123 +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 React from 'react'; -import { render, screen } from '@testing-library/react'; -import { useParams } from 'react-router-dom'; -import { ResourceVulnerabilities } from './resource_vulnerabilities'; -import { TestProvider } from '../../../../test/test_provider'; -import { useLatestVulnerabilities } from '../../hooks/use_latest_vulnerabilities'; -import { getResourceVulnerabilitiesMockData } from './__mocks__/resource_vulnerabilities.mock'; -import { VULNERABILITIES_CVSS_SCORE_BADGE_SUBJ } from '../../../../components/test_subjects'; - -jest.mock('../../hooks/use_latest_vulnerabilities', () => ({ - useLatestVulnerabilities: jest.fn(), -})); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: jest.fn().mockReturnValue({ - integration: undefined, - }), -})); - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe('ResourceVulnerabilities', () => { - const dataView: any = {}; - - const renderVulnerabilityByResource = () => { - return render( - - - - ); - }; - - it('renders the loading state', () => { - (useLatestVulnerabilities as jest.Mock).mockReturnValue({ - data: undefined, - isLoading: true, - isFetching: true, - }); - renderVulnerabilityByResource(); - expect(screen.getByText(/loading/i)).toBeInTheDocument(); - }); - it('renders the no data state', () => { - (useLatestVulnerabilities as jest.Mock).mockReturnValue({ - data: undefined, - isLoading: false, - isFetching: false, - }); - - renderVulnerabilityByResource(); - expect(screen.getByText(/no data/i)).toBeInTheDocument(); - }); - - it('applies the correct filter on fetch', () => { - const resourceId = 'test'; - (useParams as jest.Mock).mockReturnValue({ - resourceId, - }); - renderVulnerabilityByResource(); - expect(useLatestVulnerabilities).toHaveBeenCalledWith( - expect.objectContaining({ - query: { - bool: { - filter: [ - { - term: { - 'resource.id': resourceId, - }, - }, - ], - must: [], - must_not: [], - should: [], - }, - }, - }) - ); - }); - - it('renders the empty state component', () => { - (useLatestVulnerabilities as jest.Mock).mockReturnValue({ - data: { total: 0, total_vulnerabilities: 0, page: [] }, - isLoading: false, - isFetching: false, - }); - - renderVulnerabilityByResource(); - expect(screen.getByText(/no results/i)).toBeInTheDocument(); - }); - - it('renders the Table', () => { - (useLatestVulnerabilities as jest.Mock).mockReturnValue({ - data: getResourceVulnerabilitiesMockData(), - isLoading: false, - isFetching: false, - }); - - renderVulnerabilityByResource(); - - // Header - expect(screen.getByText(/0d103e99f17f355ba/i)).toBeInTheDocument(); - expect(screen.getByText(/us-east-1/i)).toBeInTheDocument(); - expect( - screen.getByText(/elastic-agent-instance-a6c683d0-0977-11ee-bb0b-0af2059ffbbf/i) - ).toBeInTheDocument(); - - // Table - expect(screen.getByText(/CVE-2022-28948/i)).toBeInTheDocument(); - expect(screen.getByTestId(VULNERABILITIES_CVSS_SCORE_BADGE_SUBJ)).toHaveTextContent(/7.5/i); - expect(screen.getByTestId(VULNERABILITIES_CVSS_SCORE_BADGE_SUBJ)).toHaveTextContent(/v3/i); - expect(screen.getByText(/high/i)).toBeInTheDocument(); - expect(screen.getByText(/gopkg.in\/yaml.v3/i)).toBeInTheDocument(); - expect(screen.getByText(/v3.0.0-20210107192922-496545a6307b/i)).toBeInTheDocument(); - expect(screen.getByText(/3.0.0-20220521103104-8f96da9f5d5e/i)).toBeInTheDocument(); - }); -}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/resource_vulnerabilities/resource_vulnerabilities.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/resource_vulnerabilities/resource_vulnerabilities.tsx deleted file mode 100644 index 673dd2e8130e4..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/resource_vulnerabilities/resource_vulnerabilities.tsx +++ /dev/null @@ -1,497 +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 { - EuiButtonEmpty, - EuiButtonIcon, - EuiDataGrid, - EuiDataGridCellValueElementProps, - EuiProgress, - EuiSpacer, - useEuiTheme, -} from '@elastic/eui'; -import { cx } from '@emotion/css'; -import { DataView } from '@kbn/data-views-plugin/common'; -import React, { useCallback, useMemo, useState, useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { Link, useParams, generatePath } from 'react-router-dom'; -import type { BoolQuery } from '@kbn/es-query'; -import { LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY } from '../../../../common/constants'; -import { - CloudPostureTableResult, - useCloudPostureTable, -} from '../../../../common/hooks/use_cloud_posture_table'; -import { useLatestVulnerabilities } from '../../hooks/use_latest_vulnerabilities'; -import type { VulnerabilitiesQueryData } from '../../types'; -import { ErrorCallout } from '../../../configurations/layout/error_callout'; -import { FindingsSearchBar } from '../../../configurations/layout/findings_search_bar'; -import { CVSScoreBadge, SeverityStatusBadge } from '../../../../components/vulnerability_badges'; -import { EmptyState } from '../../../../components/empty_state'; -import { VulnerabilityFindingFlyout } from '../../vulnerabilities_finding_flyout/vulnerability_finding_flyout'; -import { useLimitProperties } from '../../../../common/utils/get_limit_properties'; -import { - LimitedResultsBar, - PageTitle, - PageTitleText, -} from '../../../configurations/layout/findings_layout'; -import { - getVulnerabilitiesColumnsGrid, - vulnerabilitiesColumns, -} from '../../vulnerabilities_table_columns'; -import { - defaultLoadingRenderer, - defaultNoDataRenderer, -} from '../../../../components/cloud_posture_page'; -import { SEARCH_BAR_PLACEHOLDER, VULNERABILITIES } from '../../translations'; -import { - severitySchemaConfig, - severitySortScript, - getCaseInsensitiveSortScript, -} from '../../utils/custom_sort_script'; -import { useStyles } from '../../hooks/use_styles'; -import { findingsNavigation } from '../../../../common/navigation/constants'; -import { CspInlineDescriptionList } from '../../../../components/csp_inline_description_list'; -import { getVulnerabilitiesGridCellActions } from '../../utils/get_vulnerabilities_grid_cell_actions'; - -const getDefaultQuery = ({ query, filters }: any) => ({ - query, - filters, - sort: [ - { id: vulnerabilitiesColumns.severity, direction: 'desc' }, - { id: vulnerabilitiesColumns.cvss, direction: 'desc' }, - ], - pageIndex: 0, -}); - -const ResourceVulnerabilitiesDataGrid = ({ - dataView, - data, - isFetching, - pageIndex, - sort, - pageSize, - onChangeItemsPerPage, - onChangePage, - onSort, - urlQuery, - setUrlQuery, - onResetFilters, -}: { - dataView: DataView; - data: VulnerabilitiesQueryData; - isFetching: boolean; -} & Pick< - CloudPostureTableResult, - | 'pageIndex' - | 'sort' - | 'pageSize' - | 'onChangeItemsPerPage' - | 'onChangePage' - | 'onSort' - | 'urlQuery' - | 'setUrlQuery' - | 'onResetFilters' ->) => { - const { euiTheme } = useEuiTheme(); - const styles = useStyles(); - - const [showHighlight, setHighlight] = useState(false); - - const onSortHandler = useCallback( - (newSort: any) => { - onSort(newSort); - if (newSort.length !== sort.length) { - setHighlight(true); - setTimeout(() => { - setHighlight(false); - }, 2000); - } - }, - [onSort, sort] - ); - - const invalidIndex = -1; - - const selectedVulnerability = useMemo(() => { - return data?.page[urlQuery.vulnerabilityIndex]; - }, [data?.page, urlQuery.vulnerabilityIndex]); - - const onCloseFlyout = () => { - setUrlQuery({ - vulnerabilityIndex: invalidIndex, - }); - }; - - const onOpenFlyout = useCallback( - (vulnerabilityRow: VulnerabilitiesQueryData['page'][number]) => { - const vulnerabilityIndex = data?.page.findIndex( - (vulnerabilityRecord: VulnerabilitiesQueryData['page'][number]) => - vulnerabilityRecord.vulnerability?.id === vulnerabilityRow.vulnerability?.id && - vulnerabilityRecord.resource?.id === vulnerabilityRow.resource?.id && - vulnerabilityRecord.package.name === vulnerabilityRow.package.name && - vulnerabilityRecord.package.version === vulnerabilityRow.package.version - ); - setUrlQuery({ - vulnerabilityIndex, - }); - }, - [setUrlQuery, data?.page] - ); - - const { isLastLimitedPage, limitedTotalItemCount } = useLimitProperties({ - total: data?.total, - pageIndex, - pageSize, - }); - - const columns = useMemo(() => { - if (!data?.page) { - return []; - } - - return getVulnerabilitiesGridCellActions({ - columnGridFn: getVulnerabilitiesColumnsGrid, - columns: vulnerabilitiesColumns, - dataView, - pageSize, - data: data.page, - setUrlQuery, - filters: urlQuery.filters, - }).filter( - (column) => - column.id !== vulnerabilitiesColumns.resourceName && - column.id !== vulnerabilitiesColumns.resourceId - ); - }, [data?.page, dataView, pageSize, setUrlQuery, urlQuery.filters]); - - const flyoutVulnerabilityIndex = urlQuery?.vulnerabilityIndex; - - const selectedVulnerabilityIndex = flyoutVulnerabilityIndex + pageIndex * pageSize; - - const renderCellValue = useMemo(() => { - const Cell: React.FC = ({ - columnId, - rowIndex, - setCellProps, - }): React.ReactElement | null => { - const rowIndexFromPage = rowIndex > pageSize - 1 ? rowIndex % pageSize : rowIndex; - - const vulnerabilityRow = data?.page[rowIndexFromPage]; - - useEffect(() => { - if (selectedVulnerabilityIndex === rowIndex) { - setCellProps({ - style: { - backgroundColor: euiTheme.colors.highlight, - }, - }); - } else { - setCellProps({ - style: { - backgroundColor: 'inherit', - }, - }); - } - }, [rowIndex, setCellProps]); - - if (isFetching) return null; - if (!vulnerabilityRow) return null; - if (!vulnerabilityRow.vulnerability?.id) return null; - - if (columnId === vulnerabilitiesColumns.actions) { - return ( - { - onOpenFlyout(vulnerabilityRow); - }} - /> - ); - } - if (columnId === vulnerabilitiesColumns.vulnerability) { - return <>{vulnerabilityRow.vulnerability?.id}; - } - if (columnId === vulnerabilitiesColumns.cvss) { - if ( - !vulnerabilityRow.vulnerability.score?.base || - !vulnerabilityRow.vulnerability.score?.version - ) { - return null; - } - return ( - - ); - } - if (columnId === vulnerabilitiesColumns.severity) { - if (!vulnerabilityRow.vulnerability.severity) { - return null; - } - return ; - } - - if (columnId === vulnerabilitiesColumns.package) { - return <>{vulnerabilityRow?.package?.name}; - } - if (columnId === vulnerabilitiesColumns.version) { - return <>{vulnerabilityRow?.package?.version}; - } - if (columnId === vulnerabilitiesColumns.fixedVersion) { - return <>{vulnerabilityRow?.package?.fixed_version}; - } - - return null; - }; - - return Cell; - }, [ - data?.page, - euiTheme.colors.highlight, - onOpenFlyout, - pageSize, - selectedVulnerabilityIndex, - isFetching, - ]); - - const onPaginateFlyout = useCallback( - (nextVulnerabilityIndex: number) => { - // the index of the vulnerability in the current page - const newVulnerabilityIndex = nextVulnerabilityIndex % pageSize; - - // if the vulnerability is not in the current page, we need to change the page - const flyoutPageIndex = Math.floor(nextVulnerabilityIndex / pageSize); - - setUrlQuery({ - pageIndex: flyoutPageIndex, - vulnerabilityIndex: newVulnerabilityIndex, - }); - }, - [pageSize, setUrlQuery] - ); - - const showVulnerabilityFlyout = flyoutVulnerabilityIndex > invalidIndex; - - if (data.page.length === 0) { - return ; - } - - return ( - <> - - id), - setVisibleColumns: () => {}, - }} - height={undefined} - width={undefined} - schemaDetectors={[severitySchemaConfig]} - rowCount={limitedTotalItemCount} - rowHeightsOptions={{ - defaultHeight: 40, - }} - toolbarVisibility={{ - showColumnSelector: false, - showDisplaySelector: false, - showKeyboardShortcuts: false, - showFullScreenSelector: false, - additionalControls: { - left: { - prepend: ( - <> - - {i18n.translate('xpack.csp.vulnerabilities.totalVulnerabilities', { - defaultMessage: - '{total, plural, one {# Vulnerability} other {# Vulnerabilities}}', - values: { total: data?.total }, - })} - - - ), - }, - }, - }} - gridStyle={{ - border: 'horizontal', - cellPadding: 'l', - stripes: false, - rowHover: 'none', - header: 'underline', - }} - renderCellValue={renderCellValue} - inMemory={{ level: 'enhancements' }} - sorting={{ columns: sort, onSort: onSortHandler }} - pagination={{ - pageIndex, - pageSize, - pageSizeOptions: [10, 25, 100], - onChangeItemsPerPage, - onChangePage, - }} - /> - {isLastLimitedPage && } - {showVulnerabilityFlyout && selectedVulnerability && ( - - )} - - ); -}; -export const ResourceVulnerabilities = ({ dataView }: { dataView: DataView }) => { - const params = useParams<{ resourceId: string }>(); - const resourceId = decodeURIComponent(params.resourceId); - - const { - pageIndex, - pageSize, - onChangeItemsPerPage, - onChangePage, - query, - sort, - onSort, - queryError, - urlQuery, - setUrlQuery, - onResetFilters, - } = useCloudPostureTable({ - dataView, - defaultQuery: getDefaultQuery, - paginationLocalStorageKey: LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY, - }); - - const multiFieldsSort = useMemo(() => { - return sort.map(({ id, direction }: { id: string; direction: string }) => { - if (id === vulnerabilitiesColumns.severity) { - return severitySortScript(direction); - } - if (id === vulnerabilitiesColumns.package) { - return getCaseInsensitiveSortScript(id, direction); - } - - return { - [id]: direction, - }; - }); - }, [sort]); - - const { data, isLoading, isFetching } = useLatestVulnerabilities({ - query: { - ...query, - bool: { - ...(query?.bool as BoolQuery), - filter: [...(query?.bool?.filter || []), { term: { 'resource.id': resourceId } }], - }, - }, - sort: multiFieldsSort, - enabled: !queryError, - pageIndex, - pageSize, - }); - - const error = queryError || null; - - if (isLoading) { - return defaultLoadingRenderer(); - } - - if (!data?.page) { - return defaultNoDataRenderer(); - } - - return ( - <> - { - setUrlQuery({ ...newQuery, pageIndex: 0 }); - }} - loading={isFetching} - placeholder={SEARCH_BAR_PLACEHOLDER} - /> - - - - - - - - - - - - - - {error && } - {!error && ( - - )} - - ); -}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/test_subjects.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/test_subjects.ts deleted file mode 100644 index 027b0b1cdb2ed..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/test_subjects.ts +++ /dev/null @@ -1,8 +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. - */ - -export const VULNERABILITY_RESOURCE_COUNT = 'vulnerability_resource_count'; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/vulnerabilities_by_resource.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/vulnerabilities_by_resource.test.tsx deleted file mode 100644 index bd3fb0913bc3e..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/vulnerabilities_by_resource.test.tsx +++ /dev/null @@ -1,83 +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 React from 'react'; -import { render, screen } from '@testing-library/react'; -import { VulnerabilitiesByResource } from './vulnerabilities_by_resource'; -import { TestProvider } from '../../../test/test_provider'; -import { useLatestVulnerabilitiesByResource } from '../hooks/use_latest_vulnerabilities_by_resource'; -import { VULNERABILITY_RESOURCE_COUNT } from './test_subjects'; -import { getVulnerabilitiesByResourceData } from './__mocks__/vulnerabilities_by_resource.mock'; - -jest.mock('../hooks/use_latest_vulnerabilities_by_resource', () => ({ - useLatestVulnerabilitiesByResource: jest.fn(), -})); - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe('VulnerabilitiesByResource', () => { - const dataView: any = {}; - - const renderVulnerabilityByResource = () => { - return render( - - - - ); - }; - - it('renders the loading state', () => { - (useLatestVulnerabilitiesByResource as jest.Mock).mockReturnValue({ - data: undefined, - isLoading: true, - isFetching: true, - }); - renderVulnerabilityByResource(); - expect(screen.getByText(/loading/i)).toBeInTheDocument(); - }); - it('renders the no data state', () => { - (useLatestVulnerabilitiesByResource as jest.Mock).mockReturnValue({ - data: undefined, - isLoading: false, - isFetching: false, - }); - - renderVulnerabilityByResource(); - expect(screen.getByText(/no data/i)).toBeInTheDocument(); - }); - - it('renders the empty state component', () => { - (useLatestVulnerabilitiesByResource as jest.Mock).mockReturnValue({ - data: { total: 0, total_vulnerabilities: 0, page: [] }, - isLoading: false, - isFetching: false, - }); - - renderVulnerabilityByResource(); - expect(screen.getByText(/no results/i)).toBeInTheDocument(); - }); - - it('renders the Table', () => { - (useLatestVulnerabilitiesByResource as jest.Mock).mockReturnValue({ - data: getVulnerabilitiesByResourceData(), - isLoading: false, - isFetching: false, - }); - - renderVulnerabilityByResource(); - expect(screen.getByText(/2 resources/i)).toBeInTheDocument(); - expect(screen.getByText(/8 vulnerabilities/i)).toBeInTheDocument(); - expect(screen.getByText(/resource-id-1/i)).toBeInTheDocument(); - expect(screen.getByText(/resource-id-2/i)).toBeInTheDocument(); - expect(screen.getByText(/resource-test-1/i)).toBeInTheDocument(); - expect(screen.getAllByText(/us-test-1/i)).toHaveLength(2); - expect(screen.getAllByTestId(VULNERABILITY_RESOURCE_COUNT)).toHaveLength(2); - expect(screen.getAllByTestId(VULNERABILITY_RESOURCE_COUNT)[0]).toHaveTextContent('4'); - expect(screen.getAllByTestId(VULNERABILITY_RESOURCE_COUNT)[1]).toHaveTextContent('4'); - }); -}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/vulnerabilities_by_resource.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/vulnerabilities_by_resource.tsx deleted file mode 100644 index 89488bf52046b..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/vulnerabilities_by_resource.tsx +++ /dev/null @@ -1,309 +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 { - EuiBadge, - EuiButtonEmpty, - EuiDataGrid, - EuiDataGridCellValueElementProps, - EuiFlexItem, - EuiProgress, - EuiSpacer, -} from '@elastic/eui'; -import { DataView } from '@kbn/data-views-plugin/common'; -import React, { useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { Link, generatePath } from 'react-router-dom'; -import { LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY } from '../../../common/constants'; -import { findingsNavigation } from '../../../common/navigation/constants'; -import { - CloudPostureTableResult, - useCloudPostureTable, -} from '../../../common/hooks/use_cloud_posture_table'; -import { ErrorCallout } from '../../configurations/layout/error_callout'; -import { FindingsSearchBar } from '../../configurations/layout/findings_search_bar'; -import { useLimitProperties } from '../../../common/utils/get_limit_properties'; -import { LimitedResultsBar } from '../../configurations/layout/findings_layout'; -import { - getVulnerabilitiesByResourceColumnsGrid, - vulnerabilitiesByResourceColumns, -} from './vulnerabilities_by_resource_table_columns'; -import { - defaultLoadingRenderer, - defaultNoDataRenderer, -} from '../../../components/cloud_posture_page'; -import { SEARCH_BAR_PLACEHOLDER, VULNERABILITIES } from '../translations'; -import { useStyles } from '../hooks/use_styles'; -import { FindingsGroupBySelector } from '../../configurations/layout/findings_group_by_selector'; -import { vulnerabilitiesPathnameHandler } from '../utils/vulnerabilities_pathname_handler'; -import { useLatestVulnerabilitiesByResource } from '../hooks/use_latest_vulnerabilities_by_resource'; -import { EmptyState } from '../../../components/empty_state'; -import { SeverityMap } from './severity_map'; -import { VULNERABILITY_RESOURCE_COUNT } from './test_subjects'; -import { getVulnerabilitiesGridCellActions } from '../utils/get_vulnerabilities_grid_cell_actions'; -import type { VulnerabilitiesByResourceQueryData } from '../types'; - -const getDefaultQuery = ({ query, filters }: any): any => ({ - query, - filters, - sort: [{ id: vulnerabilitiesByResourceColumns.vulnerabilities_count, direction: 'desc' }], - pageIndex: 0, -}); - -const VulnerabilitiesByResourceDataGrid = ({ - dataView, - data, - isFetching, - pageIndex, - sort, - pageSize, - onChangeItemsPerPage, - onChangePage, - onSort, - urlQuery, - setUrlQuery, - onResetFilters, -}: { - dataView: DataView; - data: VulnerabilitiesByResourceQueryData | undefined; - isFetching: boolean; -} & Pick< - CloudPostureTableResult, - | 'pageIndex' - | 'sort' - | 'pageSize' - | 'onChangeItemsPerPage' - | 'onChangePage' - | 'onSort' - | 'urlQuery' - | 'setUrlQuery' - | 'onResetFilters' ->) => { - const styles = useStyles(); - - const { isLastLimitedPage, limitedTotalItemCount } = useLimitProperties({ - total: data?.total, - pageIndex, - pageSize, - }); - - const columns = useMemo(() => { - if (!data?.page) { - return []; - } - return getVulnerabilitiesGridCellActions({ - columnGridFn: getVulnerabilitiesByResourceColumnsGrid, - columns: vulnerabilitiesByResourceColumns, - dataView, - pageSize, - data: data.page, - setUrlQuery, - filters: urlQuery.filters, - }); - }, [data, dataView, pageSize, setUrlQuery, urlQuery.filters]); - - const renderCellValue = useMemo(() => { - const Cell: React.FC = ({ - columnId, - rowIndex, - }): React.ReactElement | null => { - const rowIndexFromPage = rowIndex > pageSize - 1 ? rowIndex % pageSize : rowIndex; - - const resourceVulnerabilityRow = data?.page[rowIndexFromPage]; - - if (isFetching) return null; - if (!resourceVulnerabilityRow?.resource?.id) return null; - - if (columnId === vulnerabilitiesByResourceColumns.resourceId) { - return ( - - {resourceVulnerabilityRow?.resource?.id} - - ); - } - if (columnId === vulnerabilitiesByResourceColumns.resourceName) { - return <>{resourceVulnerabilityRow?.resource?.name}; - } - if (columnId === vulnerabilitiesByResourceColumns.region) { - return <>{resourceVulnerabilityRow?.cloud?.region}; - } - if (columnId === vulnerabilitiesByResourceColumns.vulnerabilities_count) { - return ( - - {resourceVulnerabilityRow.vulnerabilities_count} - - ); - } - - if (columnId === vulnerabilitiesByResourceColumns.severity_map) { - return ( - - ); - } - return null; - }; - - return Cell; - }, [data?.page, pageSize, isFetching]); - - if (data?.page.length === 0) { - return ; - } - - return ( - <> - - id), - setVisibleColumns: () => {}, - }} - rowCount={limitedTotalItemCount} - toolbarVisibility={{ - showColumnSelector: false, - showDisplaySelector: false, - showKeyboardShortcuts: false, - showSortSelector: false, - showFullScreenSelector: false, - additionalControls: { - left: { - prepend: ( - <> - - {i18n.translate('xpack.csp.vulnerabilitiesByResource.totalResources', { - defaultMessage: '{total, plural, one {# Resource} other {# Resources}}', - values: { total: data?.total }, - })} - - - {i18n.translate('xpack.csp.vulnerabilitiesByResource.totalVulnerabilities', { - defaultMessage: - '{total, plural, one {# Vulnerability} other {# Vulnerabilities}}', - values: { total: data?.total_vulnerabilities }, - })} - - - ), - }, - right: ( - - - - ), - }, - }} - gridStyle={{ - border: 'horizontal', - cellPadding: 'l', - stripes: false, - rowHover: 'none', - header: 'underline', - }} - renderCellValue={renderCellValue} - inMemory={{ level: 'enhancements' }} - sorting={{ columns: sort, onSort }} - pagination={{ - pageIndex, - pageSize, - pageSizeOptions: [10, 25, 100], - onChangeItemsPerPage, - onChangePage, - }} - /> - {isLastLimitedPage && } - - ); -}; - -export const VulnerabilitiesByResource = ({ dataView }: { dataView: DataView }) => { - const { - pageIndex, - onChangeItemsPerPage, - onChangePage, - pageSize, - query, - sort, - onSort, - queryError, - urlQuery, - setUrlQuery, - onResetFilters, - } = useCloudPostureTable({ - dataView, - defaultQuery: getDefaultQuery, - paginationLocalStorageKey: LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY, - }); - - const { data, isLoading, isFetching } = useLatestVulnerabilitiesByResource({ - query, - sortOrder: sort[0]?.direction, - enabled: !queryError, - pageIndex, - pageSize, - }); - - const error = queryError || null; - - if (isLoading && !error) { - return defaultLoadingRenderer(); - } - - if (!data?.page && !error) { - return defaultNoDataRenderer(); - } - - return ( - <> - { - setUrlQuery({ ...newQuery, pageIndex: 0 }); - }} - loading={isFetching} - placeholder={SEARCH_BAR_PLACEHOLDER} - /> - - {error && } - {!error && ( - - )} - - ); -}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/vulnerabilities_by_resource_table_columns.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/vulnerabilities_by_resource_table_columns.ts deleted file mode 100644 index 42196f151bd07..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_by_resource/vulnerabilities_by_resource_table_columns.ts +++ /dev/null @@ -1,86 +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 { EuiDataGridColumn, EuiDataGridColumnCellAction } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -export const vulnerabilitiesByResourceColumns = { - resourceId: 'resource.id', - resourceName: 'resource.name', - region: 'cloud.region', - vulnerabilities_count: 'vulnerabilities_count', - severity_map: 'severity_map', -}; - -const defaultColumnProps = (): Partial => ({ - isExpandable: false, - actions: { - showHide: false, - showMoveLeft: false, - showMoveRight: false, - showSortAsc: false, - showSortDesc: false, - }, - isSortable: false, -}); - -export const getVulnerabilitiesByResourceColumnsGrid = ( - cellActions: EuiDataGridColumnCellAction[] -): EuiDataGridColumn[] => [ - { - ...defaultColumnProps(), - id: vulnerabilitiesByResourceColumns.resourceId, - displayAsText: i18n.translate('xpack.csp.vulnerabilityByResourceTable.column.resourceId', { - defaultMessage: 'Resource ID', - }), - cellActions, - }, - { - ...defaultColumnProps(), - id: vulnerabilitiesByResourceColumns.resourceName, - displayAsText: i18n.translate('xpack.csp.vulnerabilityByResourceTable.column.resourceName', { - defaultMessage: 'Resource Name', - }), - cellActions, - }, - { - ...defaultColumnProps(), - id: vulnerabilitiesByResourceColumns.region, - displayAsText: i18n.translate('xpack.csp.vulnerabilityByResourceTable.column.region', { - defaultMessage: 'Region', - }), - cellActions, - initialWidth: 150, - }, - { - ...defaultColumnProps(), - actions: { - showHide: false, - showMoveLeft: false, - showMoveRight: false, - showSortAsc: true, - showSortDesc: true, - }, - id: vulnerabilitiesByResourceColumns.vulnerabilities_count, - displayAsText: i18n.translate('xpack.csp.vulnerabilityByResourceTable.column.vulnerabilities', { - defaultMessage: 'Vulnerabilities', - }), - initialWidth: 140, - isResizable: false, - isSortable: true, - }, - { - ...defaultColumnProps(), - id: vulnerabilitiesByResourceColumns.severity_map, - displayAsText: i18n.translate('xpack.csp.vulnerabilityByResourceTable.column.severityMap', { - defaultMessage: 'Severity Map', - }), - cellActions, - initialWidth: 110, - isResizable: false, - }, -]; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_finding_flyout.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_finding_flyout.test.tsx index c1ffdfeb41914..da7587cfc8ad0 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_finding_flyout.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_finding_flyout.test.tsx @@ -42,7 +42,7 @@ describe('', () => { expect(descriptionList.textContent).toEqual( `Resource ID:${mockVulnerabilityHit.resource?.id}Resource Name:${mockVulnerabilityHit.resource?.name}Package:${mockVulnerabilityHit.package.name}Version:${mockVulnerabilityHit.package.version}` ); - getByText(mockVulnerabilityHit.vulnerability.severity); + getByText(mockVulnerabilityHit.vulnerability.severity!); }); }); @@ -93,19 +93,24 @@ describe('', () => { }); }); - it('should allow pagination with next', async () => { - const { getByTestId } = render(); + /** + * TODO: Enable this test once https://github.com/elastic/kibana/issues/168619 is resolved + */ + describe.skip('Flyout Pagination', () => { + it('should allow pagination with next', async () => { + const { getByTestId } = render(); - userEvent.click(getByTestId('pagination-button-next')); + userEvent.click(getByTestId('pagination-button-next')); - expect(onPaginate).toHaveBeenCalledWith(1); - }); + expect(onPaginate).toHaveBeenCalledWith(1); + }); - it('should allow pagination with previous', async () => { - const { getByTestId } = render(); + it('should allow pagination with previous', async () => { + const { getByTestId } = render(); - userEvent.click(getByTestId('pagination-button-previous')); + userEvent.click(getByTestId('pagination-button-previous')); - expect(onPaginate).toHaveBeenCalledWith(0); + expect(onPaginate).toHaveBeenCalledWith(0); + }); }); }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_finding_flyout.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_finding_flyout.tsx index ac8c98e87f411..06b8fdc2ad941 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_finding_flyout.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_finding_flyout.tsx @@ -81,18 +81,18 @@ const getFlyoutDescriptionList = ( export const VulnerabilityFindingFlyout = ({ closeFlyout, + vulnerabilityRecord, onPaginate, totalVulnerabilitiesCount, flyoutIndex, - vulnerabilityRecord, - isLoading, + isLoading = false, }: { closeFlyout: () => void; + vulnerabilityRecord: CspVulnerabilityFinding; onPaginate?: (pageIndex: number) => void; - totalVulnerabilitiesCount: number; + totalVulnerabilitiesCount?: number; flyoutIndex?: number; - vulnerabilityRecord: CspVulnerabilityFinding; - isLoading: boolean; + isLoading?: boolean; }) => { const [selectedTabId, setSelectedTabId] = useState(overviewTabId); const vulnerability = vulnerabilityRecord?.vulnerability; @@ -241,7 +241,7 @@ export const VulnerabilityFindingFlyout = ({ alignItems="center" justifyContent={onPaginate ? 'spaceBetween' : 'flexEnd'} > - {onPaginate && ( + {onPaginate && totalVulnerabilitiesCount && flyoutIndex && ( ({ - isExpandable: false, - actions: { - showHide: false, - showMoveLeft: false, - showMoveRight: false, - }, -}); - -export const getVulnerabilitiesColumnsGrid = ( - cellActions: EuiDataGridColumnCellAction[] -): EuiDataGridColumn[] => [ - { - ...defaultColumnProps(), - id: vulnerabilitiesColumns.actions, - initialWidth: 40, - display: [], - actions: false, - isSortable: false, - isResizable: false, - cellActions: [], - }, - { - ...defaultColumnProps(), - id: vulnerabilitiesColumns.vulnerability, - displayAsText: i18n.translate('xpack.csp.vulnerabilityTable.column.vulnerability', { - defaultMessage: 'Vulnerability', - }), - initialWidth: 130, - cellActions, - }, - { - ...defaultColumnProps(), - id: vulnerabilitiesColumns.cvss, - displayAsText: 'CVSS', - initialWidth: 80, - isResizable: false, - cellActions, - }, - { - ...defaultColumnProps(), - id: vulnerabilitiesColumns.resourceId, - displayAsText: i18n.translate('xpack.csp.vulnerabilityTable.column.resourceId', { - defaultMessage: 'Resource ID', - }), - cellActions, - }, - { - ...defaultColumnProps(), - id: vulnerabilitiesColumns.resourceName, - displayAsText: i18n.translate('xpack.csp.vulnerabilityTable.column.resourceName', { - defaultMessage: 'Resource Name', - }), - cellActions, - }, - { - ...defaultColumnProps(), - id: vulnerabilitiesColumns.severity, - displayAsText: i18n.translate('xpack.csp.vulnerabilityTable.column.severity', { - defaultMessage: 'Severity', - }), - initialWidth: 100, - cellActions, - schema: severitySchemaConfig.type, - }, - { - ...defaultColumnProps(), - id: vulnerabilitiesColumns.package, - displayAsText: i18n.translate('xpack.csp.vulnerabilityTable.column.package', { - defaultMessage: 'Package', - }), - cellActions, - }, - { - ...defaultColumnProps(), - id: vulnerabilitiesColumns.version, - displayAsText: i18n.translate('xpack.csp.vulnerabilityTable.column.version', { - defaultMessage: 'Version', - }), - cellActions, - }, - { - ...defaultColumnProps(), - id: vulnerabilitiesColumns.fixedVersion, - displayAsText: i18n.translate('xpack.csp.vulnerabilityTable.column.fixVersion', { - defaultMessage: 'Fix Version', - }), - cellActions, - }, -]; diff --git a/x-pack/plugins/fleet/common/types/index.ts b/x-pack/plugins/fleet/common/types/index.ts index e822b1e8e579d..b2c66a01dfcaa 100644 --- a/x-pack/plugins/fleet/common/types/index.ts +++ b/x-pack/plugins/fleet/common/types/index.ts @@ -40,6 +40,7 @@ export interface FleetConfigType { }; setup?: { agentPolicySchemaUpgradeBatchSize?: number; + uninstallTokenVerificationBatchSize?: number; }; developer?: { maxAgentPoliciesWithInactivityTimeout?: number; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.test.tsx index 15f4fc928eada..1fa4b2176f1ab 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.test.tsx @@ -43,6 +43,7 @@ describe('Agent policy advanced options content', () => { const render = ({ isProtected = false, + isManaged = false, policyId = 'agent-policy-1', newAgentPolicy = false, packagePolicy = [createPackagePolicyMock()], @@ -54,6 +55,7 @@ describe('Agent policy advanced options content', () => { ...createAgentPolicyMock(), package_policies: packagePolicy, id: policyId, + is_managed: isManaged, }; } @@ -91,6 +93,16 @@ describe('Agent policy advanced options content', () => { render(); expect(renderResult.queryByTestId('tamperProtectionSwitch')).not.toBeInTheDocument(); }); + it('should be visible if policy is not managed/hosted', () => { + usePlatinumLicense(); + render({ isManaged: false }); + expect(renderResult.queryByTestId('tamperProtectionSwitch')).toBeInTheDocument(); + }); + it('should not be visible if policy is managed/hosted', () => { + usePlatinumLicense(); + render({ isManaged: true }); + expect(renderResult.queryByTestId('tamperProtectionSwitch')).not.toBeInTheDocument(); + }); it('switched to true enables the uninstall command link', async () => { usePlatinumLicense(); render({ isProtected: true }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx index 686934377fdf3..0060258fe905f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx @@ -293,7 +293,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent = }} /> - {agentTamperProtectionEnabled && licenseService.isPlatinum() && ( + {agentTamperProtectionEnabled && licenseService.isPlatinum() && !agentPolicy.is_managed && ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_list_table.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_list_table.tsx index 94414965183f0..0a723bab1b969 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_list_table.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_list_table.tsx @@ -215,7 +215,7 @@ export const AgentListTable: React.FC = (props: Props) => { content={ } > diff --git a/x-pack/plugins/fleet/server/config.ts b/x-pack/plugins/fleet/server/config.ts index 0ab1320650920..a47058a80c828 100644 --- a/x-pack/plugins/fleet/server/config.ts +++ b/x-pack/plugins/fleet/server/config.ts @@ -151,6 +151,7 @@ export const config: PluginConfigDescriptor = { setup: schema.maybe( schema.object({ agentPolicySchemaUpgradeBatchSize: schema.maybe(schema.number()), + uninstallTokenVerificationBatchSize: schema.maybe(schema.number()), }) ), developer: schema.object({ diff --git a/x-pack/plugins/fleet/server/routes/uninstall_token/handlers.test.ts b/x-pack/plugins/fleet/server/routes/uninstall_token/handlers.test.ts index 96bda0ed31ae8..1ed3290625141 100644 --- a/x-pack/plugins/fleet/server/routes/uninstall_token/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/uninstall_token/handlers.test.ts @@ -27,16 +27,20 @@ import type { FleetRequestHandlerContext } from '../..'; import type { MockedFleetAppContext } from '../../mocks'; import { createAppContextStartContractMock, xpackMocks } from '../../mocks'; -import { appContextService } from '../../services'; +import { agentPolicyService, appContextService } from '../../services'; import type { GetUninstallTokenRequestSchema, GetUninstallTokensMetadataRequestSchema, } from '../../types/rest_spec/uninstall_token'; +import { createAgentPolicyMock } from '../../../common/mocks'; + import { registerRoutes } from '.'; import { getUninstallTokenHandler, getUninstallTokensMetadataHandler } from './handlers'; +jest.mock('../../services/agent_policy'); + describe('uninstall token handlers', () => { let context: FleetRequestHandlerContext; let response: ReturnType; @@ -74,10 +78,17 @@ describe('uninstall token handlers', () => { unknown, TypeOf >; + const mockAgentPolicyService = agentPolicyService as jest.Mocked; beforeEach(() => { const uninstallTokenService = appContextService.getUninstallTokenService()!; getTokenMetadataMock = uninstallTokenService.getTokenMetadata as jest.Mock; + mockAgentPolicyService.list.mockResolvedValue({ + items: [createAgentPolicyMock()], + total: 1, + page: 1, + perPage: 1, + }); request = httpServerMock.createKibanaRequest(); }); diff --git a/x-pack/plugins/fleet/server/routes/uninstall_token/handlers.ts b/x-pack/plugins/fleet/server/routes/uninstall_token/handlers.ts index 50dc1263ddf07..8c0220b4a1d17 100644 --- a/x-pack/plugins/fleet/server/routes/uninstall_token/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/uninstall_token/handlers.ts @@ -8,7 +8,7 @@ import type { TypeOf } from '@kbn/config-schema'; import type { CustomHttpResponseOptions, ResponseError } from '@kbn/core-http-server'; -import { appContextService } from '../../services'; +import { appContextService, agentPolicyService } from '../../services'; import type { FleetRequestHandler } from '../../types'; import type { GetUninstallTokensMetadataRequestSchema, @@ -16,6 +16,7 @@ import type { } from '../../types/rest_spec/uninstall_token'; import { defaultFleetErrorHandler } from '../../errors'; import type { GetUninstallTokenResponse } from '../../../common/types/rest_spec/uninstall_token'; +import { AGENT_POLICY_SAVED_OBJECT_TYPE, SO_SEARCH_LIMIT } from '../../constants'; const UNINSTALL_TOKEN_SERVICE_UNAVAILABLE_ERROR: CustomHttpResponseOptions = { statusCode: 500, @@ -32,13 +33,24 @@ export const getUninstallTokensMetadataHandler: FleetRequestHandler< } try { + const fleetContext = await context.fleet; + const soClient = fleetContext.internalSoClient; + + const { items: managedPolicies } = await agentPolicyService.list(soClient, { + fields: ['id'], + perPage: SO_SEARCH_LIMIT, + kuery: `${AGENT_POLICY_SAVED_OBJECT_TYPE}.is_managed:true`, + }); + + const managedPolicyIds = managedPolicies.map((policy) => policy.id); + const { page = 1, perPage = 20, policyId } = request.query; const body = await uninstallTokenService.getTokenMetadata( policyId?.trim(), page, perPage, - 'policy-elastic-agent-on-cloud' + managedPolicyIds.length > 0 ? managedPolicyIds : undefined ); return response.ok({ body }); diff --git a/x-pack/plugins/fleet/server/services/agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policy.test.ts index 3e97594ee959f..ab6d125f39685 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.test.ts @@ -11,7 +11,11 @@ import { securityMock } from '@kbn/security-plugin/server/mocks'; import { loggerMock } from '@kbn/logging-mocks'; import type { Logger } from '@kbn/core/server'; -import { PackagePolicyRestrictionRelatedError, FleetUnauthorizedError } from '../errors'; +import { + PackagePolicyRestrictionRelatedError, + FleetUnauthorizedError, + HostedAgentPolicyRestrictionRelatedError, +} from '../errors'; import type { AgentPolicy, FullAgentPolicy, @@ -603,6 +607,27 @@ describe('agent policy', () => { expect(calledWith[2]).toHaveProperty('is_managed', true); }); + it('should throw a HostedAgentRestrictionRelated error if user enables "is_protected" for a managed policy', async () => { + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + soClient.get.mockResolvedValue({ + attributes: { is_managed: true }, + id: 'mocked', + type: 'mocked', + references: [], + }); + + await expect( + agentPolicyService.update(soClient, esClient, 'test-id', { + is_protected: true, + }) + ).rejects.toThrowError( + new HostedAgentPolicyRestrictionRelatedError('Cannot update is_protected') + ); + }); + it('should call audit logger', async () => { const soClient = savedObjectsClientMock.create(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; diff --git a/x-pack/plugins/fleet/server/services/security/uninstall_token_service/index.test.ts b/x-pack/plugins/fleet/server/services/security/uninstall_token_service/index.test.ts index a782fc605ccf5..5dfd1e5c951f0 100644 --- a/x-pack/plugins/fleet/server/services/security/uninstall_token_service/index.test.ts +++ b/x-pack/plugins/fleet/server/services/security/uninstall_token_service/index.test.ts @@ -13,6 +13,8 @@ import type { SavedObjectsClientContract } from '@kbn/core/server'; import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { errors } from '@elastic/elasticsearch'; + import { UninstallTokenError } from '../../../../common/errors'; import { SO_SEARCH_LIMIT } from '../../../../common'; @@ -527,6 +529,48 @@ describe('UninstallTokenService', () => { ).resolves.toBeNull(); }); + describe('avoiding `too_many_nested_clauses` error', () => { + it('performs one query if number of policies is smaller than batch size', async () => { + mockCreatePointInTimeFinderAsInternalUser(); + await uninstallTokenService.checkTokenValidityForAllPolicies(); + + expect(esoClientMock.createPointInTimeFinderDecryptedAsInternalUser).toBeCalledTimes(1); + expect(esoClientMock.createPointInTimeFinderDecryptedAsInternalUser).toBeCalledWith({ + filter: + 'fleet-uninstall-tokens.id: "test-so-id" or fleet-uninstall-tokens.id: "test-so-id-two"', + perPage: 10000, + type: 'fleet-uninstall-tokens', + }); + }); + + it('performs multiple queries if number of policies is larger than batch size', async () => { + // @ts-ignore + appContextService.getConfig().setup = { uninstallTokenVerificationBatchSize: 1 }; + + mockCreatePointInTimeFinderAsInternalUser(); + + await uninstallTokenService.checkTokenValidityForAllPolicies(); + + expect(esoClientMock.createPointInTimeFinderDecryptedAsInternalUser).toBeCalledTimes(2); + + expect( + esoClientMock.createPointInTimeFinderDecryptedAsInternalUser + ).toHaveBeenNthCalledWith(1, { + filter: 'fleet-uninstall-tokens.id: "test-so-id"', + perPage: 10000, + type: 'fleet-uninstall-tokens', + }); + + expect( + esoClientMock.createPointInTimeFinderDecryptedAsInternalUser + ).toHaveBeenNthCalledWith(2, { + filter: 'fleet-uninstall-tokens.id: "test-so-id-two"', + perPage: 10000, + type: 'fleet-uninstall-tokens', + }); + }); + }); + it('returns error if any of the tokens is missing', async () => { mockCreatePointInTimeFinderAsInternalUser([okaySO, missingTokenSO2]); @@ -597,6 +641,26 @@ describe('UninstallTokenService', () => { }); }); + it('returns error on `too_many_nested_clauses` error', async () => { + // @ts-ignore + const responseError = new errors.ResponseError({}); + responseError.message = 'this is a too_many_nested_clauses error'; + + esoClientMock.createPointInTimeFinderDecryptedAsInternalUser = jest + .fn() + .mockRejectedValueOnce(responseError); + + await expect( + uninstallTokenService.checkTokenValidityForAllPolicies() + ).resolves.toStrictEqual({ + error: new UninstallTokenError( + 'Failed to validate uninstall tokens: `too_many_nested_clauses` error received. ' + + 'Setting/decreasing the value of `xpack.fleet.setup.uninstallTokenVerificationBatchSize` in your kibana.yml should help. ' + + `Current value is 500.` + ), + }); + }); + it('throws error in case of unknown error', async () => { esoClientMock.createPointInTimeFinderDecryptedAsInternalUser = jest .fn() diff --git a/x-pack/plugins/fleet/server/services/security/uninstall_token_service/index.ts b/x-pack/plugins/fleet/server/services/security/uninstall_token_service/index.ts index 330007e23963d..2215035684c67 100644 --- a/x-pack/plugins/fleet/server/services/security/uninstall_token_service/index.ts +++ b/x-pack/plugins/fleet/server/services/security/uninstall_token_service/index.ts @@ -23,13 +23,15 @@ import type { import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; import type { KibanaRequest } from '@kbn/core-http-server'; import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; -import { asyncForEach } from '@kbn/std'; +import { asyncForEach, asyncMap } from '@kbn/std'; import type { AggregationsTermsInclude, AggregationsTermsExclude, } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { isResponseError } from '@kbn/es-errors'; + import { UninstallTokenError } from '../../../../common/errors'; import type { GetUninstallTokensMetadataResponse } from '../../../../common/types/rest_spec/uninstall_token'; @@ -77,14 +79,14 @@ export interface UninstallTokenServiceInterface { * @param policyIdFilter a string for partial matching the policyId * @param page * @param perPage - * @param policyIdExcludeFilter + * @param excludePolicyIds * @returns Uninstall Tokens Metadata Response */ getTokenMetadata( policyIdFilter?: string, page?: number, perPage?: number, - policyIdExcludeFilter?: string + excludePolicyIds?: string[] ): Promise; /** @@ -176,14 +178,11 @@ export class UninstallTokenService implements UninstallTokenServiceInterface { policyIdFilter?: string, page = 1, perPage = 20, - policyIdExcludeFilter?: string + excludePolicyIds?: string[] ): Promise { const includeFilter = policyIdFilter ? `.*${policyIdFilter}.*` : undefined; - const tokenObjects = await this.getTokenObjectsByIncludeFilter( - includeFilter, - policyIdExcludeFilter - ); + const tokenObjects = await this.getTokenObjectsByIncludeFilter(includeFilter, excludePolicyIds); const items: UninstallTokenMetadata[] = tokenObjects .slice((page - 1) * perPage, page * perPage) @@ -208,15 +207,31 @@ export class UninstallTokenService implements UninstallTokenServiceInterface { return []; } - const filter: string = tokenObjectHits - .map(({ _id }) => { - return `${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.id: "${_id}"`; - }) - .join(' or '); + const filterEntries: string[] = tokenObjectHits.map( + ({ _id }) => `${UNINSTALL_TOKENS_SAVED_OBJECT_TYPE}.id: "${_id}"` + ); + + const uninstallTokenChunks: UninstallToken[][] = await asyncMap( + chunk(filterEntries, this.getUninstallTokenVerificationBatchSize()), + (entries) => { + const filter = entries.join(' or '); + return this.getDecryptedTokens({ filter }); + } + ); - return this.getDecryptedTokens({ filter }); + return uninstallTokenChunks.flat(); } + private getUninstallTokenVerificationBatchSize = () => { + /** If `uninstallTokenVerificationBatchSize` is too large, we get an error of `too_many_nested_clauses`. + * Assuming that `max_clause_count` >= 1024, and experiencing that batch size should be less than half + * than `max_clause_count` with our current query, batch size below 512 should be okay on every env. + */ + const config = appContextService.getConfig(); + + return config?.setup?.uninstallTokenVerificationBatchSize ?? 500; + }; + private getDecryptedTokens = async ( options: Partial ): Promise => { @@ -523,6 +538,16 @@ export class UninstallTokenService implements UninstallTokenServiceInterface { if (error instanceof UninstallTokenError) { // known errors are considered non-fatal return { error }; + } else if (isResponseError(error) && error.message.includes('too_many_nested_clauses')) { + // `too_many_nested_clauses` is considered non-fatal + const errorMessage = + 'Failed to validate uninstall tokens: `too_many_nested_clauses` error received. ' + + 'Setting/decreasing the value of `xpack.fleet.setup.uninstallTokenVerificationBatchSize` in your kibana.yml should help. ' + + `Current value is ${this.getUninstallTokenVerificationBatchSize()}.`; + + appContextService.getLogger().warn(`${errorMessage}: '${error}'`); + + return { error: new UninstallTokenError(errorMessage) }; } else { const errorMessage = 'Unknown error happened while checking Uninstall Tokens validity'; appContextService.getLogger().error(`${errorMessage}: '${error}'`); diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index cc4be9bab0bcc..a9af93e70f7ab 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -369,6 +369,7 @@ export async function ensureFleetDirectories() { try { await fs.stat(bundledPackageLocation); + logger.debug(`Bundled package directory ${bundledPackageLocation} exists`); } catch (error) { logger.warn( `Bundled package directory ${bundledPackageLocation} does not exist. All packages will be sourced from ${registryUrl}.` diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx index 1123e47a60ede..d4e5876761c36 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx @@ -528,7 +528,6 @@ describe('', () => { await actions.completeStepOne({ name: TEMPLATE_NAME, indexPatterns: DEFAULT_INDEX_PATTERNS, - dataStream: {}, allowAutoCreate: true, }); // Component templates @@ -618,7 +617,6 @@ describe('', () => { await testBed.actions.completeStepOne({ name: TEMPLATE_NAME, indexPatterns: DEFAULT_INDEX_PATTERNS, - dataStream: {}, lifecycle: { enabled: true, value: 1, diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts index 88cee63b0e693..745b2a69b9498 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts @@ -145,10 +145,10 @@ export const formSetup = async (initTestBed: SetupFunc) => { order, priority, version, - dataStream, + enableDataStream, lifecycle, allowAutoCreate, - }: Partial = {}) => { + }: Partial & { enableDataStream?: boolean } = {}) => { const { component, form, find } = testBed; if (name) { @@ -174,7 +174,12 @@ export const formSetup = async (initTestBed: SetupFunc) => { form.setInputValue('orderField.input', JSON.stringify(order)); } - if (dataStream) { + // Deal with toggling the data stream switch + const isDataStreamEnabled = find('dataStreamField.input').props().checked; + + if (enableDataStream && !isDataStreamEnabled) { + form.toggleEuiSwitch('dataStreamField.input'); + } else if (!enableDataStream && isDataStreamEnabled) { form.toggleEuiSwitch('dataStreamField.input'); } diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx index 9d494e0a558d9..4eab54356eabf 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx @@ -116,6 +116,7 @@ export const TemplateForm = ({ const indexTemplate = defaultValue ?? { name: '', indexPatterns: [], + dataStream: {}, template: {}, _kbnMeta: { type: 'default', diff --git a/x-pack/plugins/index_management/public/application/constants/index.ts b/x-pack/plugins/index_management/public/application/constants/index.ts index 8ced3a40a80e9..f72e56310a9c7 100644 --- a/x-pack/plugins/index_management/public/application/constants/index.ts +++ b/x-pack/plugins/index_management/public/application/constants/index.ts @@ -5,8 +5,6 @@ * 2.0. */ -export { REFRESH_RATE_INDEX_LIST } from './refresh_intervals'; - export const REACT_ROOT_ID = 'indexManagementReactRoot'; export const ENRICH_POLICIES_REQUIRED_PRIVILEGES = ['manage_enrich']; diff --git a/x-pack/plugins/index_management/public/application/constants/refresh_intervals.ts b/x-pack/plugins/index_management/public/application/constants/refresh_intervals.ts deleted file mode 100644 index 1e7ce89ea2d87..0000000000000 --- a/x-pack/plugins/index_management/public/application/constants/refresh_intervals.ts +++ /dev/null @@ -1,8 +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. - */ - -export const REFRESH_RATE_INDEX_LIST = 30000; diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page.tsx b/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page.tsx index f028dee3d8eee..7872f2eacda78 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page.tsx @@ -11,7 +11,11 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { EuiPageTemplate, EuiText, EuiCode } from '@elastic/eui'; import { SectionLoading } from '@kbn/es-ui-shared-plugin/public'; -import { IndexDetailsSection, IndexDetailsTabId } from '../../../../../../common/constants'; +import { + IndexDetailsSection, + IndexDetailsTabId, + Section, +} from '../../../../../../common/constants'; import { Index } from '../../../../../../common'; import { Error } from '../../../../../shared_imports'; import { loadIndex } from '../../../../services'; @@ -29,6 +33,10 @@ export const DetailsPage: FunctionComponent< const [error, setError] = useState(null); const [index, setIndex] = useState(); + const navigateToIndicesList = useCallback(() => { + history.push(`/${Section.Indices}`); + }, [history]); + const fetchIndexDetails = useCallback(async () => { if (indexName) { setIsLoading(true); @@ -87,7 +95,13 @@ export const DetailsPage: FunctionComponent< ); } if (error || !index) { - return ; + return ( + + ); } return ( ); }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_content.tsx b/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_content.tsx index adeadee6132c0..9b2beafd31cf5 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_content.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_content.tsx @@ -24,7 +24,6 @@ import { IndexDetailsSection, IndexDetailsTab, IndexDetailsTabId, - Section, } from '../../../../../../common/constants'; import { getIndexDetailsLink } from '../../../../services/routing'; import { useAppContext } from '../../../../app_context'; @@ -79,12 +78,14 @@ interface Props { tab: IndexDetailsTabId; history: RouteComponentProps['history']; fetchIndexDetails: () => Promise; + navigateToIndicesList: () => void; } export const DetailsPageContent: FunctionComponent = ({ index, tab, history, fetchIndexDetails, + navigateToIndicesList, }) => { const { config: { enableIndexStats }, @@ -115,10 +116,6 @@ export const DetailsPageContent: FunctionComponent = ({ [history, index] ); - const navigateToAllIndices = useCallback(() => { - history.push(`/${Section.Indices}`); - }, [history]); - const headerTabs = useMemo(() => { return tabs.map((tabConfig) => ({ onClick: () => onSectionChange(tabConfig.id), @@ -143,11 +140,11 @@ export const DetailsPageContent: FunctionComponent = ({ data-test-subj="indexDetailsBackToIndicesButton" color="text" iconType="arrowLeft" - onClick={navigateToAllIndices} + onClick={navigateToIndicesList} > @@ -161,7 +158,7 @@ export const DetailsPageContent: FunctionComponent = ({ , ]} rightSideGroupProps={{ diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_error.tsx b/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_error.tsx index 9eaca894b963e..9656dcb77268b 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_error.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_error.tsx @@ -7,14 +7,23 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiButton, EuiPageTemplate, EuiSpacer, EuiText } from '@elastic/eui'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiPageTemplate, + EuiText, +} from '@elastic/eui'; export const DetailsPageError = ({ indexName, resendRequest, + navigateToIndicesList, }: { indexName: string; resendRequest: () => Promise; + navigateToIndicesList: () => void; }) => { return ( } body={ - <> - - - - - - - - + + + + } + actions={ + + + + + + + + + + + + } /> ); diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_overview/data_stream_details.tsx b/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_overview/data_stream_details.tsx index 0ebc7afae33d7..a9b2768e140bb 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_overview/data_stream_details.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_overview/data_stream_details.tsx @@ -10,12 +10,10 @@ import { i18n } from '@kbn/i18n'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiTextColor } from '@elastic/eui'; import { css } from '@emotion/react'; import { euiThemeVars } from '@kbn/ui-theme'; -import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; import { SectionLoading } from '@kbn/es-ui-shared-plugin/public'; import { FormattedMessage } from '@kbn/i18n-react'; -import { getDataStreamDetailsLink } from '../../../../../services/routing'; -import { getTemplateDetailsLink } from '../../../../../..'; +import { getDataStreamDetailsLink, getTemplateDetailsLink } from '../../../../../services/routing'; import { useLoadDataStream } from '../../../../../services/api'; import { useAppContext } from '../../../../../app_context'; import { humanizeTimeStamp } from '../../../data_stream_list/humanize_time_stamp'; @@ -25,7 +23,9 @@ export const DataStreamDetails: FunctionComponent<{ dataStreamName: string }> = dataStreamName, }) => { const { error, data: dataStream, isLoading, resendRequest } = useLoadDataStream(dataStreamName); - const { history } = useAppContext(); + const { + core: { getUrlForApp }, + } = useAppContext(); const hasError = !isLoading && (error || !dataStream); let contentLeft: ReactNode = ( @@ -53,7 +53,10 @@ export const DataStreamDetails: FunctionComponent<{ dataStreamName: string }> = {i18n.translate('xpack.idxMgmt.indexDetails.overviewTab.dataStream.dataStreamLinkLabel', { defaultMessage: 'See details', @@ -63,10 +66,12 @@ export const DataStreamDetails: FunctionComponent<{ dataStreamName: string }> = {i18n.translate('xpack.idxMgmt.indexDetails.overviewTab.dataStream.templateLinkLabel', { defaultMessage: 'Related template', diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_overview/languages.ts b/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_overview/languages.ts index 9904f7cbb70c7..d5a8bbb60a091 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_overview/languages.ts +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/details_page_overview/languages.ts @@ -23,6 +23,7 @@ export const curlDefinition: LanguageDefinition = { -d' { "index" : { "_index" : "${indexName ?? INDEX_NAME_PLACEHOLDER}" } } {"name": "foo", "title": "bar" } +' `, }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/manage_index_button.tsx b/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/manage_index_button.tsx index 8bfaffa51273f..7de07493ef78e 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/manage_index_button.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/details_page/manage_index_button.tsx @@ -43,7 +43,7 @@ const getIndexStatusByName = ( interface Props { index: Index; reloadIndexDetails: () => Promise; - navigateToAllIndices: () => void; + navigateToIndicesList: () => void; } /** @@ -55,7 +55,7 @@ interface Props { export const ManageIndexButton: FunctionComponent = ({ index, reloadIndexDetails, - navigateToAllIndices, + navigateToIndicesList, }) => { const [isLoading, setIsLoading] = useState(false); @@ -212,12 +212,12 @@ export const ManageIndexButton: FunctionComponent = ({ values: { indexNames: indexNames.join(', ') }, }) ); - navigateToAllIndices(); + navigateToIndicesList(); } catch (error) { setIsLoading(false); notificationService.showDangerToast(error.body.message); } - }, [navigateToAllIndices, indexNames]); + }, [navigateToIndicesList, indexNames]); const performExtensionAction = useCallback( async ( diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.js index 838aee24a9c58..6ea481ae463a6 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.js +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.js @@ -24,7 +24,6 @@ import { pageSizeChanged, sortChanged, loadIndices, - reloadIndices, toggleChanged, } from '../../../../store/actions'; @@ -64,9 +63,6 @@ const mapDispatchToProps = (dispatch) => { loadIndices: () => { dispatch(loadIndices()); }, - reloadIndices: (indexNames, options) => { - dispatch(reloadIndices(indexNames, options)); - }, }; }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js index 000c34988310b..2c26a91a5108f 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js @@ -41,7 +41,6 @@ import { reactRouterNavigate, attemptToURIDecode, } from '../../../../../shared_imports'; -import { REFRESH_RATE_INDEX_LIST } from '../../../../constants'; import { getDataStreamDetailsLink, getIndexDetailsLink } from '../../../../services/routing'; import { documentationService } from '../../../../services/documentation'; import { AppContextConsumer } from '../../../../app_context'; @@ -119,14 +118,6 @@ export class IndexTable extends Component { componentDidMount() { this.props.loadIndices(); - this.interval = setInterval( - () => - this.props.reloadIndices( - this.props.indices.map((i) => i.name), - { asSystemRequest: true } - ), - REFRESH_RATE_INDEX_LIST - ); const { location, filterChanged } = this.props; const { filter } = qs.parse((location && location.search) || ''); if (filter) { @@ -147,7 +138,6 @@ export class IndexTable extends Component { // navigating back to this tab would just show an empty list because the backing indices // would be hidden. this.props.filterChanged(''); - clearInterval(this.interval); } readURLParams() { @@ -617,9 +607,7 @@ export class IndexTable extends Component { { - loadIndices(); - }} + onClick={loadIndices} iconType="refresh" data-test-subj="reloadIndicesButton" > diff --git a/x-pack/plugins/infra/public/containers/metrics_source/source.tsx b/x-pack/plugins/infra/public/containers/metrics_source/source.tsx index ffbc7148bc017..4cce1e9540f75 100644 --- a/x-pack/plugins/infra/public/containers/metrics_source/source.tsx +++ b/x-pack/plugins/infra/public/containers/metrics_source/source.tsx @@ -8,8 +8,8 @@ import createContainer from 'constate'; import React, { useEffect, useState } from 'react'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; import { IHttpFetchError } from '@kbn/core-http-browser'; +import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; import type { MetricsSourceConfigurationResponse, MetricsSourceConfiguration, @@ -34,11 +34,13 @@ export const pickIndexPattern = ( }; export const useSource = ({ sourceId }: { sourceId: string }) => { - const { services } = useKibana(); + const { + services: { http, telemetry }, + } = useKibanaContextForPlugin(); const notify = useSourceNotifier(); - const fetchService = services.http; + const fetchService = http; const API_URL = `/api/metrics/source/${sourceId}`; const [source, setSource] = useState(undefined); @@ -46,12 +48,22 @@ export const useSource = ({ sourceId }: { sourceId: string }) => { const [loadSourceRequest, loadSource] = useTrackedPromise( { cancelPreviousOn: 'resolution', - createPromise: () => { + createPromise: async () => { if (!fetchService) { throw new MissingHttpClientException(); } - return fetchService.fetch(API_URL, { method: 'GET' }); + const start = performance.now(); + const response = await fetchService.fetch(API_URL, { + method: 'GET', + }); + telemetry.reportPerformanceMetricEvent( + 'infra_source_load', + performance.now() - start, + {}, + {} + ); + return response; }, onResolve: (response) => { if (response) { diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_table.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_table.tsx index 2082689cb6e1a..bbb8c44a85f7e 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_table.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_table.tsx @@ -44,7 +44,7 @@ export const HostsTable = () => { /> { const { sourceId } = useSourceContext(); const { - services: { http, data }, + services: { http, data, telemetry }, } = useKibanaContextForPlugin(); const { buildQuery, parsedDateRange, searchCriteria } = useUnifiedSearchContext(); const abortCtrlRef = useRef(new AbortController()); @@ -59,14 +59,26 @@ export const useHostsView = () => { ); const [state, refetch] = useAsyncFn( - () => { + async () => { abortCtrlRef.current.abort(); abortCtrlRef.current = new AbortController(); - return http.post(`${BASE_INFRA_METRICS_PATH}`, { - signal: abortCtrlRef.current.signal, - body: JSON.stringify(baseRequest), - }); + const start = performance.now(); + const metricsResponse = await http.post( + `${BASE_INFRA_METRICS_PATH}`, + { + signal: abortCtrlRef.current.signal, + body: JSON.stringify(baseRequest), + } + ); + const duration = performance.now() - start; + telemetry.reportPerformanceMetricEvent( + 'infra_hosts_table_load', + duration, + { key1: 'data_load', value1: duration }, + { limit: searchCriteria.limit } + ); + return metricsResponse; }, [baseRequest, http], { loading: true } diff --git a/x-pack/plugins/infra/public/services/telemetry/telemetry_client.mock.ts b/x-pack/plugins/infra/public/services/telemetry/telemetry_client.mock.ts index 1f354ecd1670f..08a2f9fb9eedb 100644 --- a/x-pack/plugins/infra/public/services/telemetry/telemetry_client.mock.ts +++ b/x-pack/plugins/infra/public/services/telemetry/telemetry_client.mock.ts @@ -15,4 +15,5 @@ export const createTelemetryClientMock = (): jest.Mocked => ({ reportHostsViewTotalHostCountRetrieved: jest.fn(), reportAssetDetailsFlyoutViewed: jest.fn(), reportAssetDetailsPageViewed: jest.fn(), + reportPerformanceMetricEvent: jest.fn(), }); diff --git a/x-pack/plugins/infra/public/services/telemetry/telemetry_client.ts b/x-pack/plugins/infra/public/services/telemetry/telemetry_client.ts index d4acc0a8bd96d..2c8cac426635d 100644 --- a/x-pack/plugins/infra/public/services/telemetry/telemetry_client.ts +++ b/x-pack/plugins/infra/public/services/telemetry/telemetry_client.ts @@ -6,6 +6,7 @@ */ import type { AnalyticsServiceSetup } from '@kbn/core-analytics-server'; +import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; import { AssetDetailsFlyoutViewedParams, AssetDetailsPageViewedParams, @@ -15,6 +16,7 @@ import { HostsViewQuerySubmittedParams, InfraTelemetryEventTypes, ITelemetryClient, + PerformanceMetricInnerEvents, } from './types'; /** @@ -70,4 +72,18 @@ export class TelemetryClient implements ITelemetryClient { public reportAssetDetailsPageViewed = (params: AssetDetailsPageViewedParams) => { this.analytics.reportEvent(InfraTelemetryEventTypes.ASSET_DETAILS_PAGE_VIEWED, params); }; + + public reportPerformanceMetricEvent = ( + eventName: string, + duration: number, + innerEvents: PerformanceMetricInnerEvents = {}, + meta: Record = {} + ) => { + reportPerformanceMetricEvent(this.analytics, { + eventName, + duration, + meta, + ...innerEvents, + }); + }; } diff --git a/x-pack/plugins/infra/public/services/telemetry/types.ts b/x-pack/plugins/infra/public/services/telemetry/types.ts index 769cc303def50..0556b20af0fb4 100644 --- a/x-pack/plugins/infra/public/services/telemetry/types.ts +++ b/x-pack/plugins/infra/public/services/telemetry/types.ts @@ -61,6 +61,11 @@ export type InfraTelemetryEventParams = | HostsViewQueryHostsCountRetrievedParams | AssetDetailsFlyoutViewedParams; +export interface PerformanceMetricInnerEvents { + key1?: string; + value1?: number; +} + export interface ITelemetryClient { reportHostEntryClicked(params: HostEntryClickedParams): void; reportHostFlyoutFilterRemoved(params: HostFlyoutFilterActionParams): void; @@ -69,6 +74,12 @@ export interface ITelemetryClient { reportHostsViewQuerySubmitted(params: HostsViewQuerySubmittedParams): void; reportAssetDetailsFlyoutViewed(params: AssetDetailsFlyoutViewedParams): void; reportAssetDetailsPageViewed(params: AssetDetailsPageViewedParams): void; + reportPerformanceMetricEvent( + eventName: string, + duration: number, + innerEvents: PerformanceMetricInnerEvents, + meta: Record + ): void; } export type InfraTelemetryEvent = diff --git a/x-pack/plugins/infra/tsconfig.json b/x-pack/plugins/infra/tsconfig.json index cbcbdbbbc365f..955a3a18cf2e1 100644 --- a/x-pack/plugins/infra/tsconfig.json +++ b/x-pack/plugins/infra/tsconfig.json @@ -79,7 +79,8 @@ "@kbn/profiling-utils", "@kbn/profiling-data-access-plugin", "@kbn/core-http-request-handler-context-server", - "@kbn/observability-get-padded-alert-time-range-util" + "@kbn/observability-get-padded-alert-time-range-util", + "@kbn/ebt-tools" ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.test.ts b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.test.ts index bc646ac140c95..57638b61db1a9 100644 --- a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.test.ts +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.test.ts @@ -5,6 +5,7 @@ * 2.0. */ import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { fetchFieldsFromESQL } from '@kbn/text-based-editor'; import type { LensPluginStartDependencies } from '../../../plugin'; import { createMockStartDependencies } from '../../../editor_frame_service/mocks'; import { @@ -14,7 +15,6 @@ import { mockAllSuggestions, } from '../../../mocks'; import { suggestionsApi } from '../../../lens_suggestions_api'; -import { fetchFieldsFromESQL } from '../../../datasources/text_based/fetch_fields_from_esql'; import { getSuggestions } from './helpers'; const mockSuggestionApi = suggestionsApi as jest.Mock; @@ -24,7 +24,7 @@ jest.mock('../../../lens_suggestions_api', () => ({ suggestionsApi: jest.fn(() => mockAllSuggestions), })); -jest.mock('../../../datasources/text_based/fetch_fields_from_esql', () => ({ +jest.mock('@kbn/text-based-editor', () => ({ fetchFieldsFromESQL: jest.fn(() => { return { columns: [ diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts index 1e1fd600b6879..4555f3f8a576d 100644 --- a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts @@ -7,12 +7,12 @@ import { i18n } from '@kbn/i18n'; import { getIndexPatternFromSQLQuery, getIndexPatternFromESQLQuery } from '@kbn/es-query'; import type { AggregateQuery, Query, Filter } from '@kbn/es-query'; +import { fetchFieldsFromESQL } from '@kbn/text-based-editor'; import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/public'; import type { Suggestion } from '../../../types'; import type { TypedLensByValueInput } from '../../../embeddable/embeddable_component'; import type { LensPluginStartDependencies } from '../../../plugin'; import type { DatasourceMap, VisualizationMap } from '../../../types'; -import { fetchFieldsFromESQL } from '../../../datasources/text_based/fetch_fields_from_esql'; import { suggestionsApi } from '../../../lens_suggestions_api'; export const getQueryColumns = async (query: AggregateQuery, deps: LensPluginStartDependencies) => { diff --git a/x-pack/plugins/lens/public/datasources/text_based/components/dimension_trigger.tsx b/x-pack/plugins/lens/public/datasources/text_based/components/dimension_trigger.tsx index b1eec31ea3892..f6062068cee77 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/components/dimension_trigger.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/components/dimension_trigger.tsx @@ -7,6 +7,7 @@ import React, { useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; +import { fetchFieldsFromESQL } from '@kbn/text-based-editor'; import { DimensionTrigger } from '@kbn/visualization-ui-components'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { DatasourceDimensionTriggerProps } from '../../../types'; @@ -16,7 +17,6 @@ import { addColumnsToCache, retrieveLayerColumnsFromCache, } from '../fieldlist_cache'; -import { fetchFieldsFromESQL } from '../fetch_fields_from_esql'; export type TextBasedDimensionTrigger = DatasourceDimensionTriggerProps & { columnLabelMap: Record; diff --git a/x-pack/plugins/lens/public/datasources/text_based/fetch_fields_from_esql.ts b/x-pack/plugins/lens/public/datasources/text_based/fetch_fields_from_esql.ts deleted file mode 100644 index e19fde19187ff..0000000000000 --- a/x-pack/plugins/lens/public/datasources/text_based/fetch_fields_from_esql.ts +++ /dev/null @@ -1,52 +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 { pluck } from 'rxjs/operators'; -import { lastValueFrom } from 'rxjs'; -import type { Query, AggregateQuery } from '@kbn/es-query'; -import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; -import type { Datatable } from '@kbn/expressions-plugin/public'; -import { textBasedQueryStateToAstWithValidation } from '@kbn/data-plugin/common'; - -interface TextBasedLanguagesErrorResponse { - error: { - message: string; - }; - type: 'error'; -} - -export function fetchFieldsFromESQL(query: Query | AggregateQuery, expressions: ExpressionsStart) { - return textBasedQueryStateToAstWithValidation({ - query, - }) - .then((ast) => { - if (ast) { - const execution = expressions.run(ast, null); - let finalData: Datatable; - let error: string | undefined; - execution.pipe(pluck('result')).subscribe((resp) => { - const response = resp as Datatable | TextBasedLanguagesErrorResponse; - if (response.type === 'error') { - error = response.error.message; - } else { - finalData = response; - } - }); - return lastValueFrom(execution).then(() => { - if (error) { - throw new Error(error); - } else { - return finalData; - } - }); - } - return undefined; - }) - .catch((err) => { - throw new Error(err.message); - }); -} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index 8b7705260c1e4..842904741ec9b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -27,6 +27,7 @@ import type { Datatable } from '@kbn/expressions-plugin/public'; import { DropIllustration } from '@kbn/chart-icons'; import { DragDrop, useDragDropContext, DragDropIdentifier } from '@kbn/dom-drag-drop'; import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; +import { ChartSizeSpec, isChartSizeEvent } from '@kbn/chart-expressions-common'; import { trackUiCounterEvents } from '../../../lens_ui_telemetry'; import { getSearchWarningMessages } from '../../../utils'; import { @@ -43,6 +44,7 @@ import { UserMessagesGetter, AddUserMessages, isMessageRemovable, + VisualizationDisplayOptions, } from '../../../types'; import { switchToSuggestion } from '../suggestion_helpers'; import { buildExpression } from '../expression_helpers'; @@ -413,6 +415,8 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ } }, [expressionExists, localState.expressionToRender]); + const [chartSizeSpec, setChartSize] = useState(); + const onEvent = useCallback( (event: ExpressionRendererEvent) => { if (!plugins.uiActions) { @@ -443,10 +447,15 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ }) ); } + + if (isChartSizeEvent(event)) { + setChartSize(event.data); + } }, [plugins.data.datatableUtilities, plugins.uiActions, activeVisualization, dispatchLens] ); + const displayOptions = activeVisualization?.getDisplayOptions?.(); const hasCompatibleActions = useCallback( async (event: ExpressionRendererEvent) => { if (!plugins.uiActions) { @@ -478,6 +487,10 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ const IS_DARK_THEME: boolean = useObservable(core.theme.theme$, { darkMode: false }).darkMode; const renderDragDropPrompt = () => { + if (chartSizeSpec) { + setChartSize(undefined); + } + return ( { + if (chartSizeSpec) { + setChartSize(undefined); + } + const applyChangesString = i18n.translate('xpack.lens.editorFrame.applyChanges', { defaultMessage: 'Apply changes', }); @@ -590,6 +607,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ onComponentRendered={() => { visualizationRenderStartTime.current = performance.now(); }} + displayOptions={displayOptions} /> ); }; @@ -639,7 +657,6 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ return ( {renderWorkspace()} @@ -686,6 +704,7 @@ export const VisualizationWrapper = ({ onRender$, onData$, onComponentRendered, + displayOptions, }: { expression: string | null | undefined; lensInspector: LensInspector; @@ -699,6 +718,7 @@ export const VisualizationWrapper = ({ onRender$: () => void; onData$: (data: unknown, adapters?: Partial) => void; onComponentRendered: () => void; + displayOptions: VisualizationDisplayOptions | undefined; }) => { useEffect(() => { onComponentRendered(); @@ -751,7 +771,7 @@ export const VisualizationWrapper = ({ > * { + &>* { flex: 1 1 100%; display: flex; align-items: center; @@ -37,6 +37,7 @@ &.lnsWorkspacePanelWrapper--fullscreen { margin-bottom: 0; + .lnsWorkspacePanelWrapper__pageContentBody { box-shadow: none; } @@ -45,8 +46,6 @@ } .lnsWorkspacePanel__dragDrop { - border: $euiBorderWidthThin solid transparent; - &.domDragDrop-isDropTarget { p { transition: filter $euiAnimSpeedFast ease-in-out; @@ -120,9 +119,7 @@ // Hard-coded px values OK (@cchaos) // sass-lint:disable-block indentation filter: - drop-shadow(0 6px 12px transparentize($euiShadowColor, .8)) - drop-shadow(0 4px 4px transparentize($euiShadowColor, .8)) - drop-shadow(0 2px 2px transparentize($euiShadowColor, .8)); + drop-shadow(0 6px 12px transparentize($euiShadowColor, .8)) drop-shadow(0 4px 4px transparentize($euiShadowColor, .8)) drop-shadow(0 2px 2px transparentize($euiShadowColor, .8)); } .lnsDropIllustration__adjustFill { @@ -134,20 +131,51 @@ } @keyframes lnsWorkspacePanel__illustrationPulseArrow { - 0% { transform: translateY(0%); } - 65% { transform: translateY(0%); } - 72% { transform: translateY(10%); } - 79% { transform: translateY(7%); } - 86% { transform: translateY(10%); } - 95% { transform: translateY(0); } + 0% { + transform: translateY(0%); + } + + 65% { + transform: translateY(0%); + } + + 72% { + transform: translateY(10%); + } + + 79% { + transform: translateY(7%); + } + + 86% { + transform: translateY(10%); + } + + 95% { + transform: translateY(0); + } } @keyframes lnsWorkspacePanel__illustrationPulseContinuous { - 0% { transform: translateY(10%); } - 25% { transform: translateY(15%); } - 50% { transform: translateY(10%); } - 75% { transform: translateY(15%); } - 100% { transform: translateY(10%); } + 0% { + transform: translateY(10%); + } + + 25% { + transform: translateY(15%); + } + + 50% { + transform: translateY(10%); + } + + 75% { + transform: translateY(15%); + } + + 100% { + transform: translateY(10%); + } } .lnsVisualizationToolbar--fixed { @@ -155,4 +183,4 @@ width: 100%; z-index: 1; background-color: $euiColorLightestShade; -} +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.test.tsx index eeec313584117..8694cc7c27dcf 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.test.tsx @@ -34,7 +34,6 @@ describe('workspace_panel_wrapper', () => { <> { lensInspector={{} as unknown as LensInspector} getUserMessages={() => []} children={} + displayOptions={undefined} {...propsOverrides} /> = { + pixels: 'px', + percentage: '%', +}; + +const getAspectRatioStyles = ({ x, y }: { x: number; y: number }) => { + return { + aspectRatio: `${x}/${y}`, + ...(y > x + ? { + height: '100%', + width: 'auto', + } + : { + height: 'auto', + width: '100%', + }), + }; +}; + export function VisualizationToolbar(props: { activeVisualization: Visualization | null; framePublicAPI: FramePublicAPI; @@ -98,12 +120,12 @@ export function VisualizationToolbar(props: { export function WorkspacePanelWrapper({ children, framePublicAPI, - visualizationState, visualizationId, visualizationMap, datasourceMap, isFullscreen, getUserMessages, + displayOptions, }: WorkspacePanelWrapperProps) { const dispatchLens = useLensDispatch(); @@ -113,6 +135,34 @@ export function WorkspacePanelWrapper({ const activeVisualization = visualizationId ? visualizationMap[visualizationId] : null; const userMessages = getUserMessages('toolbar'); + const aspectRatio = displayOptions?.aspectRatio; + const maxDimensions = displayOptions?.maxDimensions; + const minDimensions = displayOptions?.minDimensions; + + let visDimensionsCSS: Interpolation = {}; + + if (aspectRatio) { + visDimensionsCSS = getAspectRatioStyles(aspectRatio ?? maxDimensions); + } + + if (maxDimensions) { + visDimensionsCSS.maxWidth = maxDimensions.x + ? `${maxDimensions.x.value}${unitToCSSUnit[maxDimensions.x.unit]}` + : ''; + visDimensionsCSS.maxHeight = maxDimensions.y + ? `${maxDimensions.y.value}${unitToCSSUnit[maxDimensions.y.unit]}` + : ''; + } + + if (minDimensions) { + visDimensionsCSS.minWidth = minDimensions.x + ? `${minDimensions.x.value}${unitToCSSUnit[minDimensions.x.unit]}` + : ''; + visDimensionsCSS.minHeight = minDimensions.y + ? `${minDimensions.y.value}${unitToCSSUnit[minDimensions.y.unit]}` + : ''; + } + return ( )} + - - {children} + + + + {children} + + ); diff --git a/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx index edb54facff31c..6c038125b0718 100644 --- a/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx +++ b/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx @@ -92,6 +92,7 @@ export function ExpressionWrapper({ syncTooltips={syncTooltips} syncCursor={syncCursor} executionContext={executionContext} + shouldUseSizeTransitionVeil={true} renderError={(errorMessage, error) => { const messages = getOriginalRequestErrorMessages(error || null); addUserMessages(messages); diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 53bb59c0a5459..38054103183a5 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -42,6 +42,7 @@ import { CellValueContext } from '@kbn/embeddable-plugin/public'; import { EventAnnotationGroupConfig } from '@kbn/event-annotation-common'; import type { DraggingIdentifier, DragDropIdentifier, DropType } from '@kbn/dom-drag-drop'; import type { AccessorConfig } from '@kbn/visualization-ui-components'; +import type { ChartSizeEvent } from '@kbn/chart-expressions-common'; import type { DateRange, LayerType, SortingHint } from '../common/types'; import type { LensSortActionData, @@ -1391,6 +1392,7 @@ export interface ILensInterpreterRenderHandlers extends IInterpreterRenderHandle | BrushTriggerEvent | LensEditEvent | LensTableRowContextMenuEvent + | ChartSizeEvent ) => void; } diff --git a/x-pack/plugins/lens/public/visualizations/datatable/expression.tsx b/x-pack/plugins/lens/public/visualizations/datatable/expression.tsx index 6c97dae2baab1..eae35c5a925dc 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/expression.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/expression.tsx @@ -18,6 +18,7 @@ import type { IInterpreterRenderHandlers, } from '@kbn/expressions-plugin/common'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { ChartSizeEvent } from '@kbn/chart-expressions-common'; import { trackUiCounterEvents } from '../../lens_ui_telemetry'; import { DatatableComponent } from './components/table_basic'; @@ -103,6 +104,18 @@ export const getDatatableRenderer = (dependencies: { handlers.done(); }; + const chartSizeEvent: ChartSizeEvent = { + name: 'chartSize', + data: { + maxDimensions: { + x: { value: 100, unit: 'percentage' }, + y: { value: 100, unit: 'percentage' }, + }, + }, + }; + + handlers.event(chartSizeEvent); + // An entry for each table row, whether it has any actions attached to // ROW_CLICK_TRIGGER trigger. let rowHasRowClickTriggerActions: boolean[] = []; diff --git a/x-pack/plugins/lens/public/visualizations/metric/visualization.test.ts b/x-pack/plugins/lens/public/visualizations/metric/visualization.test.ts index 1b45671e29cc6..3b79cb69cd38c 100644 --- a/x-pack/plugins/lens/public/visualizations/metric/visualization.test.ts +++ b/x-pack/plugins/lens/public/visualizations/metric/visualization.test.ts @@ -143,7 +143,7 @@ describe('metric visualization', () => { ).toMatchInlineSnapshot(` Array [ Object { - "color": "#f5f7fa", + "color": "#ffffff", "columnId": "metric-col-id", "triggerIconType": "color", }, @@ -727,7 +727,7 @@ describe('metric visualization', () => { datasourceLayers ) as ExpressionAstExpression ).chain[1].arguments.color[0] - ).toBe(euiLightVars.euiColorLightestShade); + ).toBe(euiLightVars.euiColorEmptyShade); expect( ( @@ -741,7 +741,7 @@ describe('metric visualization', () => { datasourceLayers ) as ExpressionAstExpression ).chain[1].arguments.color[0] - ).toBe(euiLightVars.euiColorLightestShade); + ).toBe(euiLightVars.euiColorEmptyShade); // this case isn't currently relevant because other parts of the code don't allow showBar to be // set when there isn't a max dimension but this test covers the branch anyhow @@ -757,7 +757,7 @@ describe('metric visualization', () => { datasourceLayers ) as ExpressionAstExpression ).chain[1].arguments.color[0] - ).toEqual(euiThemeVars.euiColorLightestShade); + ).toEqual(euiThemeVars.euiColorEmptyShade); }); }); diff --git a/x-pack/plugins/lens/public/visualizations/metric/visualization.tsx b/x-pack/plugins/lens/public/visualizations/metric/visualization.tsx index 00b931714c1be..28c547df25ebd 100644 --- a/x-pack/plugins/lens/public/visualizations/metric/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/metric/visualization.tsx @@ -45,7 +45,7 @@ export const showingBar = ( export const getDefaultColor = (state: MetricVisualizationState, isMetricNumeric?: boolean) => { return showingBar(state) && isMetricNumeric ? euiLightVars.euiColorPrimary - : euiThemeVars.euiColorLightestShade; + : euiThemeVars.euiColorEmptyShade; }; export interface MetricVisualizationState { diff --git a/x-pack/plugins/lens/tsconfig.json b/x-pack/plugins/lens/tsconfig.json index 350cd1ad19a9e..ddffc5c8114c8 100644 --- a/x-pack/plugins/lens/tsconfig.json +++ b/x-pack/plugins/lens/tsconfig.json @@ -103,7 +103,8 @@ "@kbn/lens-formula-docs", "@kbn/visualization-utils", "@kbn/test-eui-helpers", - "@kbn/shared-ux-utility" + "@kbn/shared-ux-utility", + "@kbn/text-based-editor" ], "exclude": [ "target/**/*" diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/expression_renderer.tsx b/x-pack/plugins/maps/public/lens/choropleth_chart/expression_renderer.tsx index 615945280c264..059d612883d82 100644 --- a/x-pack/plugins/maps/public/lens/choropleth_chart/expression_renderer.tsx +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/expression_renderer.tsx @@ -13,6 +13,7 @@ import { METRIC_TYPE } from '@kbn/analytics'; import type { CoreSetup, CoreStart } from '@kbn/core/public'; import type { FileLayer } from '@elastic/ems-client'; import type { KibanaExecutionContext } from '@kbn/core-execution-context-common'; +import { ChartSizeEvent } from '@kbn/chart-expressions-common'; import type { MapsPluginStartDependencies } from '../../plugin'; import type { ChoroplethChartProps } from './types'; import type { MapEmbeddableInput, MapEmbeddableOutput } from '../../embeddable'; @@ -92,6 +93,18 @@ export function getExpressionRenderer(coreSetup: CoreSetup = { requiresPageReload: true, type: 'boolean', }, + [apmEnableTableSearchBar]: { + category: [observabilityFeatureId], + name: i18n.translate('xpack.observability.apmEnableTableSearchBar', { + defaultMessage: 'Instant table search', + }), + description: i18n.translate('xpack.observability.apmEnableTableSearchBarDescription', { + defaultMessage: + '{betaLabel} Enables faster searching in APM tables by adding a handy search bar with live filtering. Available for the following tables: Services, Transactions and Errors', + values: { + betaLabel: `[${betaLabel}]`, + }, + }), + schema: schema.boolean(), + value: false, + requiresPageReload: false, + type: 'boolean', + }, [apmAWSLambdaPriceFactor]: { category: [observabilityFeatureId], name: i18n.translate('xpack.observability.apmAWSLambdaPricePerGbSeconds', { diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-operators.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-operators.txt index 29204aad6a3f6..dc1eca3a0fc5c 100644 --- a/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-operators.txt +++ b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-operators.txt @@ -124,23 +124,6 @@ an element in a list of literals, fields or expressions: ROW a = 1, b = 4, c = 3 | WHERE c-a IN (3, b / 2, a) -IS_FINITE -IS_FINITE - -Returns a boolean that indicates whether its input is a finite number. -ROW d = 1.0 -| EVAL s = IS_FINITE(d/0) - -IS_INFINITE -IS_INFINITE - -Returns a boolean that indicates whether its input is infinite. -ROW d = 1.0 -| EVAL s = IS_INFINITE(d/0) - -IS_NAN -IS_NAN - Returns a boolean that indicates whether its input is not a number. ROW d = 1.0 | EVAL s = IS_NAN(d) diff --git a/x-pack/plugins/observability_onboarding/e2e/cypress/e2e/logs/custom_logs/install_elastic_agent.cy.ts b/x-pack/plugins/observability_onboarding/e2e/cypress/e2e/logs/custom_logs/install_elastic_agent.cy.ts index 19e84284b671b..c4d5edee7d3af 100644 --- a/x-pack/plugins/observability_onboarding/e2e/cypress/e2e/logs/custom_logs/install_elastic_agent.cy.ts +++ b/x-pack/plugins/observability_onboarding/e2e/cypress/e2e/logs/custom_logs/install_elastic_agent.cy.ts @@ -623,7 +623,9 @@ describe('[Logs onboarding] Custom logs - install elastic agent', () => { cy.getByTestSubj('obltOnboardingExploreLogs').should('exist').click(); cy.url().should('include', '/app/observability-log-explorer'); - cy.get('button').contains('[Mylogs] mylogs').should('exist'); + cy.get('[data-test-subj="datasetSelectorPopoverButton"]') + .contains('[Mylogs] mylogs', { matchCase: false }) + .should('exist'); }); }); }); diff --git a/x-pack/plugins/observability_onboarding/e2e/cypress/support/commands.ts b/x-pack/plugins/observability_onboarding/e2e/cypress/support/commands.ts index e989089f491eb..843aca4c258ea 100644 --- a/x-pack/plugins/observability_onboarding/e2e/cypress/support/commands.ts +++ b/x-pack/plugins/observability_onboarding/e2e/cypress/support/commands.ts @@ -125,30 +125,17 @@ Cypress.Commands.add('deleteIntegration', (integrationName: string) => { cy.request({ log: false, - method: 'GET', + method: 'DELETE', url: `${kibanaUrl}/api/fleet/epm/packages/${integrationName}`, + body: { + force: false, + }, headers: { 'kbn-xsrf': 'e2e_test', + 'Elastic-Api-Version': '1', }, auth: { user: 'editor', pass: 'changeme' }, failOnStatusCode: false, - }).then((response) => { - const status = response.body.item?.status; - if (status === 'installed') { - cy.request({ - log: false, - method: 'DELETE', - url: `${kibanaUrl}/api/fleet/epm/packages/${integrationName}`, - body: { - force: false, - }, - headers: { - 'kbn-xsrf': 'e2e_test', - 'Elastic-Api-Version': '1', - }, - auth: { user: 'editor', pass: 'changeme' }, - }); - } }); }); diff --git a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts index f8da02ad0666d..b9680d6472691 100644 --- a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts +++ b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts @@ -33,6 +33,9 @@ export const EMPTY_RESPONSE: RuleRegistrySearchResponse = { export const RULE_SEARCH_STRATEGY_NAME = 'privateRuleRegistryAlertsSearchStrategy'; +// these are deprecated types should never show up in any alert table +const EXCLUDED_RULE_TYPE_IDS = ['siem.notifications']; + export const ruleRegistrySearchStrategyProvider = ( data: PluginStart, alerting: AlertingStart, @@ -85,14 +88,16 @@ export const ruleRegistrySearchStrategyProvider = ( featureIds.length > 0 ? await authorization.getAuthorizedRuleTypes(AlertingAuthorizationEntity.Alert, fIds) : []; + return { space, authzFilter, authorizedRuleTypes }; }; return from(getAsync(request.featureIds)).pipe( mergeMap(({ space, authzFilter, authorizedRuleTypes }) => { - const indices = alerting.getAlertIndicesAlias( - authorizedRuleTypes.map((art: { id: any }) => art.id), - space?.id + const allRuleTypes = authorizedRuleTypes.map((art: { id: string }) => art.id); + const ruleTypes = (allRuleTypes ?? []).filter( + (ruleTypeId: string) => !EXCLUDED_RULE_TYPE_IDS.includes(ruleTypeId) ); + const indices = alerting.getAlertIndicesAlias(ruleTypes, space?.id); if (indices.length === 0) { return of(EMPTY_RESPONSE); } diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx index d2346147da3d1..18bc6db281939 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx @@ -18,28 +18,31 @@ import { EuiFlyoutBody, EuiFlyoutFooter, EuiFlyoutHeader, - EuiFormFieldset, EuiFormRow, EuiHorizontalRule, + EuiIcon, + EuiPanel, EuiSkeletonText, EuiSpacer, EuiSwitch, EuiText, EuiTitle, + useEuiTheme, } from '@elastic/eui'; import { Form, FormikProvider, useFormik } from 'formik'; import moment from 'moment-timezone'; import type { FunctionComponent } from 'react'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import useAsyncFn from 'react-use/lib/useAsyncFn'; import { CodeEditorField } from '@kbn/code-editor'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { FormattedDate, FormattedMessage } from '@kbn/i18n-react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { KibanaServerError } from '@kbn/kibana-utils-plugin/public'; import type { CategorizedApiKey } from './api_keys_grid_page'; -import { ApiKeyBadge, ApiKeyStatus, TimeToolTip, UsernameWithIcon } from './api_keys_grid_page'; +import { ApiKeyBadge, ApiKeyStatus, TimeToolTip } from './api_keys_grid_page'; import type { ApiKeyRoleDescriptors } from '../../../../common/model'; import { DocLink } from '../../../components/doc_link'; import { FormField } from '../../../components/form_field'; @@ -56,6 +59,27 @@ import type { UpdateAPIKeyResult, } from '../api_keys_api_client'; +const TypeLabel = () => ( + +); + +const NameLabel = () => ( + +); + +const invalidJsonError = i18n.translate( + 'xpack.security.management.apiKeys.apiKeyFlyout.invalidJsonError', + { + defaultMessage: 'Enter valid JSON.', + } +); + export interface ApiKeyFormValues { name: string; type: string; @@ -89,10 +113,10 @@ export type ApiKeyFlyoutProps = ExclusiveUnion = ({ canManageCrossClusterApiKeys = false, readOnly = false, }) => { + const { euiTheme } = useEuiTheme(); const { services } = useKibana(); const { value: currentUser, loading: isLoadingCurrentUser } = useCurrentUser(); const [{ value: roles, loading: isLoadingRoles }, getRoles] = useAsyncFn( () => new RolesAPIClient(services.http!).getRoles(), [services.http] ); + const [responseError, setResponseError] = useState(undefined); const formik = useFormik({ onSubmit: async (values) => { @@ -143,7 +169,9 @@ export const ApiKeyFlyout: FunctionComponent = ({ onSuccess?.(createApiKeyResponse); } + setResponseError(undefined); } catch (error) { + setResponseError(error.body); throw error; } }, @@ -209,6 +237,12 @@ export const ApiKeyFlyout: FunctionComponent = ({ values: { isSubmitting: formik.isSubmitting }, }); + let expirationDate: Date | undefined; + if (formik.values.customExpiration) { + expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + parseInt(formik.values.expiration, 10)); + } + return ( @@ -223,6 +257,22 @@ export const ApiKeyFlyout: FunctionComponent = ({ + {responseError && ( + <> + + } + > + {responseError.message} + + + + )} {apiKey && !readOnly ? ( !isOwner ? ( <> @@ -252,243 +302,406 @@ export const ApiKeyFlyout: FunctionComponent = ({ ) : null ) : null} - - - } - fullWidth - > - - - - {apiKey ? ( - <> - - - - - } - > + + {apiKey ? ( + <> + +

+ +

+
+ + + + + + + + + + {apiKey.name} + + + + + + + + + + + + + + + + {apiKey.username} + + + + + + + + + + + + + + -
-
- - - } - > - - - - - - } - > + +
+ + + + + + + + + + + - - - - - } - > + + + + + + + + + + + + + - - - - - ) : canManageCrossClusterApiKeys ? ( - - } - fullWidth - > - - - - -

- -

-
- - - - - - } - onChange={() => formik.setFieldValue('type', 'rest')} - checked={formik.values.type === 'rest'} +
+
+ + ) : ( + <> + + + + + + +

+ +

+
+
+
+ + +

+ +

+
+ + } fullWidth> + -
- - + {canManageCrossClusterApiKeys ? ( + } fullWidth> + + + + +

+ +

+
+ + + + + + } + onChange={() => formik.setFieldValue('type', 'rest')} + checked={formik.values.type === 'rest'} + /> +
+ + + +

+ +

+
+ + + + + + } + onChange={() => formik.setFieldValue('type', 'cross_cluster')} + checked={formik.values.type === 'cross_cluster'} + /> +
+
+
+ ) : ( + }> + + + )} + + )} + + + {!apiKey && ( + <> + +
+ - -

- -

-
- - + +

- - +

+
} - onChange={() => formik.setFieldValue('type', 'cross_cluster')} - checked={formik.values.type === 'cross_cluster'} + checked={Boolean(formik.values.customExpiration)} + disabled={readOnly || !!apiKey} + onChange={(e) => formik.setFieldValue('customExpiration', e.target.checked)} /> - - - - ) : ( - - } - > - - + + +

+ +

+
+
+ {formik.values.customExpiration && ( + <> + + + + + ), + }} + /> + } + > + + + + )} +
+ + )} - - {formik.values.type === 'cross_cluster' ? ( - - } - helpText={ - + +
+ + + + + + +

+ {i18n.translate( + 'xpack.security.accountManagement.apiKeyFlyout.accessPermissions.title', + { + defaultMessage: 'Access Permissions', + } + )} +

+
+
+
+
+ -
- } - fullWidth - > - formik.setFieldValue('access', value)} - validate={(value: string) => { - if (!value) { - return i18n.translate( - 'xpack.security.management.apiKeys.apiKeyFlyout.accessRequired', - { - defaultMessage: 'Enter access permissions or disable this option.', - } - ); + } + helpText={ + + + + } + fullWidth + > + formik.setFieldValue('access', value)} + validate={(value: string) => { + if (!value) { + return i18n.translate( + 'xpack.security.management.apiKeys.apiKeyFlyout.accessRequired', + { + defaultMessage: 'Enter access permissions or disable this option.', + } + ); + } + try { + JSON.parse(value); + } catch (e) { + return invalidJsonError; + } + }} + fullWidth + languageId="xjson" + height={200} + /> +
+ + ) : ( + +
+ +

+ {i18n.translate( + 'xpack.security.accountManagement.apiKeyFlyout.privileges.title', + { + defaultMessage: 'Control security privileges', + } + )} +

+ } - try { - JSON.parse(value); - } catch (e) { - return i18n.translate( - 'xpack.security.management.apiKeys.apiKeyFlyout.invalidJsonError', + checked={formik.values.customPrivileges} + data-test-subj="apiKeysRoleDescriptorsSwitch" + onChange={(e) => formik.setFieldValue('customPrivileges', e.target.checked)} + disabled={readOnly || (apiKey && !canEdit)} + /> + + +

+ {i18n.translate( + 'xpack.security.accountManagement.apiKeyFlyout.privileges.description', { - defaultMessage: 'Enter valid JSON.', + defaultMessage: + 'Control access to specific Elasticsearch APIs and resources using predefined roles or custom privileges per API key.', } - ); - } - }} - fullWidth - languageId="xjson" - height={200} - /> - - ) : ( - - - } - checked={formik.values.customPrivileges} - data-test-subj="apiKeysRoleDescriptorsSwitch" - onChange={(e) => formik.setFieldValue('customPrivileges', e.target.checked)} - disabled={readOnly || (apiKey && !canEdit)} - /> + )} +

+
+
{formik.values.customPrivileges && ( <> - + = ({ try { JSON.parse(value); } catch (e) { - return i18n.translate( - 'xpack.security.management.apiKeys.apiKeyFlyout.invalidJsonError', - { - defaultMessage: 'Enter valid JSON.', - } - ); + return invalidJsonError; } }} fullWidth @@ -543,89 +751,42 @@ export const ApiKeyFlyout: FunctionComponent = ({ height={200} /> - )} - +
)} - - {!apiKey && ( - <> - - - - } - checked={formik.values.customExpiration} - onChange={(e) => formik.setFieldValue('customExpiration', e.target.checked)} - disabled={readOnly || !!apiKey} - data-test-subj="apiKeyCustomExpirationSwitch" - /> - {formik.values.customExpiration && ( - <> - - - } - fullWidth - > - + +
+ +

+ - - - - )} - - - )} - - - - } - data-test-subj="apiKeysMetadataSwitch" - checked={formik.values.includeMetadata} - disabled={readOnly || (apiKey && !canEdit)} - onChange={(e) => formik.setFieldValue('includeMetadata', e.target.checked)} - /> +

+ + } + data-test-subj="apiKeysMetadataSwitch" + checked={formik.values.includeMetadata} + disabled={readOnly || (apiKey && !canEdit)} + onChange={(e) => formik.setFieldValue('includeMetadata', e.target.checked)} + /> + + +

+ +

+
+
{formik.values.includeMetadata && ( <> - + = ({ try { JSON.parse(value); } catch (e) { - return i18n.translate( - 'xpack.security.management.apiKeys.apiKeyFlyout.invalidJsonError', - { - defaultMessage: 'Enter valid JSON.', - } - ); + return invalidJsonError; } }} fullWidth @@ -679,10 +835,9 @@ export const ApiKeyFlyout: FunctionComponent = ({ height={200} /> - )} -
+ diff --git a/x-pack/plugins/security/tsconfig.json b/x-pack/plugins/security/tsconfig.json index 0b54e40c228e7..5a28a341b94da 100644 --- a/x-pack/plugins/security/tsconfig.json +++ b/x-pack/plugins/security/tsconfig.json @@ -3,7 +3,12 @@ "compilerOptions": { "outDir": "target/types", }, - "include": ["common/**/*", "public/**/*", "server/**/*", "__mocks__/**/*"], + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "__mocks__/**/*" + ], "kbn_references": [ "@kbn/cloud-plugin", "@kbn/features-plugin", @@ -66,6 +71,7 @@ "@kbn/security-plugin-types-common", "@kbn/security-plugin-types-public", "@kbn/security-plugin-types-server", + "@kbn/kibana-utils-plugin", "@kbn/code-editor", "@kbn/code-editor-mock", ], diff --git a/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route.ts b/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route.ts index 4aadb73283676..c6b8f1baf6974 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route.ts +++ b/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route.ts @@ -7,6 +7,10 @@ import * as rt from 'io-ts'; -export const deleteTimelinesSchema = rt.type({ +const searchId = rt.partial({ searchIds: rt.array(rt.string) }); + +const baseDeleteTimelinesSchema = rt.type({ savedObjectIds: rt.array(rt.string), }); + +export const deleteTimelinesSchema = rt.intersection([baseDeleteTimelinesSchema, searchId]); diff --git a/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route_schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route_schema.yaml index e6c262f70626e..dba0471992729 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route_schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/delete_timelines/delete_timelines_route_schema.yaml @@ -33,6 +33,10 @@ paths: type: array items: type: string + searchId: + type: array + items: + type: string responses: 200: description: Indicates the timeline was successfully deleted. diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 2c7910166b196..e454048b52f1e 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 @@ -105,6 +104,13 @@ export const allowedExperimentalValues = Object.freeze({ **/ newUserDetailsFlyout: false, + /* + * Enables the Managed User section inside the new user details flyout. + * To see this section you also need newUserDetailsFlyout flag enabled. + * + **/ + newUserDetailsFlyoutManagedUser: false, + /* * Enables the new host details flyout displayed on the Alerts table. * diff --git a/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.tsx b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.tsx index 6cfc5729b726a..47c1d8b478c2d 100644 --- a/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.tsx +++ b/x-pack/plugins/security_solution/public/common/components/discover_in_timeline/use_discover_in_timeline_actions.tsx @@ -48,7 +48,7 @@ export const useDiscoverInTimelineActions = ( const timeline = useShallowEqualSelector( (state) => getTimeline(state, TimelineId.active) ?? timelineDefaults ); - const { savedSearchId } = timeline; + const { savedSearchId, version } = timeline; // We're using a ref here to prevent a cyclic hook-dependency chain of updateSavedSearch const timelineRef = useRef(timeline); @@ -56,7 +56,7 @@ export const useDiscoverInTimelineActions = ( const queryClient = useQueryClient(); - const { mutateAsync: saveSavedSearch } = useMutation({ + const { mutateAsync: saveSavedSearch, status } = useMutation({ mutationFn: ({ savedSearch, savedSearchOptions, @@ -75,6 +75,7 @@ export const useDiscoverInTimelineActions = ( } queryClient.invalidateQueries({ queryKey: ['savedSearchById', savedSearchId] }); }, + mutationKey: [version], }); const getDefaultDiscoverAppState: () => Promise = useCallback(async () => { @@ -217,7 +218,7 @@ export const useDiscoverInTimelineActions = ( const responseIsEmpty = !response || !response?.id; if (responseIsEmpty) { throw new Error('Response is empty'); - } else if (!savedSearchId && !responseIsEmpty) { + } else if (!savedSearchId && !responseIsEmpty && status !== 'loading') { dispatch( timelineActions.updateSavedSearchId({ id: TimelineId.active, @@ -236,7 +237,7 @@ export const useDiscoverInTimelineActions = ( } } }, - [persistSavedSearch, savedSearchId, dispatch, discoverDataService] + [persistSavedSearch, savedSearchId, dispatch, discoverDataService, status] ); const initializeLocalSavedSearch = useCallback( diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx index 417ce415d0b57..76b6c355cf35c 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx @@ -52,6 +52,7 @@ const alwaysDisplayedFields: EventSummaryField[] = [ { id: 'agent.id', overrideField: AGENT_STATUS_FIELD_NAME, label: i18n.AGENT_STATUS }, { id: SENTINEL_ONE_AGENT_ID_FIELD, + overrideField: AGENT_STATUS_FIELD_NAME, label: i18n.AGENT_STATUS, }, // ** // 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/detection_engine/rule_exceptions/utils/helpers.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.test.tsx index 957aa11cdf6bc..8c2bb6bb20dcc 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.test.tsx @@ -1777,6 +1777,7 @@ describe('Exception helpers', () => { }, { id: 'observer.serial_number', + overrideField: 'agent.status', label: 'Agent status', }, { @@ -1801,6 +1802,7 @@ describe('Exception helpers', () => { }, { id: 'observer.serial_number', + overrideField: 'agent.status', label: 'Agent status', }, { diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/sentinel_one_agent_status.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/sentinel_one_agent_status.tsx index 4f40b338544a3..3d643dffc51cc 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/sentinel_one_agent_status.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/sentinel_one_agent_status.tsx @@ -45,7 +45,7 @@ const EuiFlexGroupStyled = styled(EuiFlexGroup)` `; export const SentinelOneAgentStatus = React.memo( - ({ agentId, dataTestSubj }: { agentId: string; dataTestSubj?: string }) => { + ({ agentId, 'data-test-subj': dataTestSubj }: { agentId: string; 'data-test-subj'?: string }) => { const { data, isFetched } = useSentinelOneAgentData({ agentId }); const label = useMemo(() => { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.test.tsx index d1322fd76a91f..c58d05ce81b8f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.test.tsx @@ -41,11 +41,12 @@ describe('useInstalledIntegrations', () => { return wrapper; }; - const render = () => + const render = ({ skip } = { skip: false }) => renderHook( () => useInstalledIntegrations({ packages: [], + skip, }), { wrapper: createReactQueryWrapper(), @@ -68,6 +69,17 @@ describe('useInstalledIntegrations', () => { ); }); + it('does not call the API when skip is true', async () => { + const fetchInstalledIntegrations = jest.spyOn( + fleetIntegrationsApi, + 'fetchInstalledIntegrations' + ); + + render({ skip: true }); + + expect(fetchInstalledIntegrations).toHaveBeenCalledTimes(0); + }); + it('fetches data from the API', async () => { const { result, waitForNextUpdate } = render(); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.tsx index 1d6fe71d65afe..01b7d5fe6e613 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.tsx @@ -16,9 +16,13 @@ const ONE_MINUTE = 60000; export interface UseInstalledIntegrationsArgs { packages?: string[]; + skip?: boolean; } -export const useInstalledIntegrations = ({ packages }: UseInstalledIntegrationsArgs) => { +export const useInstalledIntegrations = ({ + packages, + skip = false, +}: UseInstalledIntegrationsArgs) => { // const { addError } = useAppToasts(); return useQuery( @@ -38,6 +42,7 @@ export const useInstalledIntegrations = ({ packages }: UseInstalledIntegrationsA { keepPreviousData: true, staleTime: ONE_MINUTE * 5, + enabled: !skip, onError: (e) => { // Suppressing for now to prevent excessive errors when fleet isn't configured // addError(e, { title: i18n.INTEGRATIONS_FETCH_FAILURE }); 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/flyout/document_details/right/components/highlighted_fields.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields.tsx index 5e6bcb2add441..059de0cc1f736 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields.tsx @@ -35,6 +35,10 @@ export interface HighlightedFieldsTableRow { * Highlighted field name (overrideField or if null, falls back to id) */ field: string; + /** + * Highlighted field's original name, when the field is overridden + */ + originalField?: string; /** * Highlighted field value */ @@ -74,6 +78,7 @@ const columns: Array> = [ width: '70%', render: (description: { field: string; + originalField?: string; values: string[] | null | undefined; scopeId: string; isPreview: boolean; @@ -94,7 +99,11 @@ const columns: Array> = [ : [] } > - + ), }, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.test.tsx index 0112e06cb489f..b5a1e0f364281 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.test.tsx @@ -20,8 +20,10 @@ import { LeftPanelInsightsTab, DocumentDetailsLeftPanelKey } from '../../left'; import { TestProviders } from '../../../../common/mock'; import { ENTITIES_TAB_ID } from '../../left/components/entities_details'; import { useGetEndpointDetails } from '../../../../management/hooks'; +import { useSentinelOneAgentData } from '../../../../detections/components/host_isolation/use_sentinelone_host_isolation'; jest.mock('../../../../management/hooks'); +jest.mock('../../../../detections/components/host_isolation/use_sentinelone_host_isolation'); const flyoutContextValue = { openLeftPanel: jest.fn(), @@ -86,7 +88,22 @@ describe('', () => { expect(getByTestId(HIGHLIGHTED_FIELDS_AGENT_STATUS_CELL_TEST_ID)).toBeInTheDocument(); }); - it('should render agent status component if override field is agent.status', () => { + it('should render sentinelone agent status cell if field is agent.status and origialField is observer.serial_number', () => { + (useSentinelOneAgentData as jest.Mock).mockReturnValue({ isFetched: true }); + const { getByTestId } = render( + + + + ); + + expect(getByTestId(HIGHLIGHTED_FIELDS_AGENT_STATUS_CELL_TEST_ID)).toBeInTheDocument(); + }); + + it('should not render if values is null', () => { const { container } = render(); expect(container).toBeEmptyDOMElement(); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.tsx index 9833d050acfe7..3e2570f8f8737 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_cell.tsx @@ -66,6 +66,10 @@ export interface HighlightedFieldsCellProps { * Highlighted field's name used to know what component to display */ field: string; + /** + * Highlighted field's original name, when the field is overridden + */ + originalField?: string; /** * Highlighted field's value to display */ @@ -75,7 +79,11 @@ export interface HighlightedFieldsCellProps { /** * Renders a component in the highlighted fields table cell based on the field name */ -export const HighlightedFieldsCell: VFC = ({ values, field }) => ( +export const HighlightedFieldsCell: VFC = ({ + values, + field, + originalField, +}) => ( <> {values != null && values.map((value, i) => { @@ -87,13 +95,17 @@ export const HighlightedFieldsCell: VFC = ({ values, > {field === HOST_NAME_FIELD_NAME || field === USER_NAME_FIELD_NAME ? ( + ) : field === AGENT_STATUS_FIELD_NAME && + originalField === SENTINEL_ONE_AGENT_ID_FIELD ? ( + ) : field === AGENT_STATUS_FIELD_NAME ? ( - ) : field === SENTINEL_ONE_AGENT_ID_FIELD ? ( - ) : ( {value} )} diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.test.tsx index b45af8ea45d17..5c551d928cad9 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.test.tsx @@ -9,6 +9,7 @@ import { renderHook } from '@testing-library/react-hooks'; import { mockDataFormattedForFieldBrowser } from '../mocks/mock_data_formatted_for_field_browser'; import { useHighlightedFields } from './use_highlighted_fields'; +import { SENTINEL_ONE_AGENT_ID_FIELD } from '../../../../common/utils/sentinelone_alert_check'; const dataFormattedForFieldBrowser = mockDataFormattedForFieldBrowser; @@ -21,4 +22,112 @@ describe('useHighlightedFields', () => { }, }); }); + + it('should omit endpoint agent id field if data is not s1 alert', () => { + const hookResult = renderHook(() => + useHighlightedFields({ + dataFormattedForFieldBrowser: dataFormattedForFieldBrowser.concat({ + category: 'agent', + field: 'agent.id', + values: ['deb35a20-70f8-458e-a64a-c9e6f7575893'], + originalValue: ['deb35a20-70f8-458e-a64a-c9e6f7575893'], + isObjectArray: false, + }), + investigationFields: ['agent.status', 'agent.id'], + }) + ); + + expect(hookResult.result.current).toEqual({ + 'kibana.alert.rule.type': { + values: ['query'], + }, + }); + }); + + it('should return endpoint agent id field if data is s1 alert', () => { + const hookResult = renderHook(() => + useHighlightedFields({ + dataFormattedForFieldBrowser: dataFormattedForFieldBrowser.concat([ + { + category: 'agent', + field: 'agent.type', + values: ['endpoint'], + originalValue: ['endpoint'], + isObjectArray: false, + }, + { + category: 'agent', + field: 'agent.id', + values: ['deb35a20-70f8-458e-a64a-c9e6f7575893'], + originalValue: ['deb35a20-70f8-458e-a64a-c9e6f7575893'], + isObjectArray: false, + }, + ]), + investigationFields: ['agent.status', 'agent.id'], + }) + ); + + expect(hookResult.result.current).toEqual({ + 'kibana.alert.rule.type': { + values: ['query'], + }, + 'agent.id': { + values: ['deb35a20-70f8-458e-a64a-c9e6f7575893'], + }, + }); + }); + + it('should omit sentinelone agent id field if data is not s1 alert', () => { + const hookResult = renderHook(() => + useHighlightedFields({ + dataFormattedForFieldBrowser: dataFormattedForFieldBrowser.concat({ + category: 'observer', + field: `observer.${SENTINEL_ONE_AGENT_ID_FIELD}`, + values: ['deb35a20-70f8-458e-a64a-c9e6f7575893'], + originalValue: ['deb35a20-70f8-458e-a64a-c9e6f7575893'], + isObjectArray: false, + }), + investigationFields: ['agent.status', 'observer.serial_number'], + }) + ); + + expect(hookResult.result.current).toEqual({ + 'kibana.alert.rule.type': { + values: ['query'], + }, + }); + }); + + it('should return sentinelone agent id field if data is s1 alert', () => { + const hookResult = renderHook(() => + useHighlightedFields({ + dataFormattedForFieldBrowser: dataFormattedForFieldBrowser.concat([ + { + category: 'event', + field: 'event.module', + values: ['sentinel_one'], + originalValue: ['sentinel_one'], + isObjectArray: false, + }, + { + category: 'observer', + field: SENTINEL_ONE_AGENT_ID_FIELD, + values: ['deb35a20-70f8-458e-a64a-c9e6f7575893'], + originalValue: ['deb35a20-70f8-458e-a64a-c9e6f7575893'], + isObjectArray: false, + }, + ]), + investigationFields: ['agent.status', 'observer.serial_number'], + }) + ); + + expect(hookResult.result.current).toEqual({ + 'kibana.alert.rule.type': { + values: ['query'], + }, + 'observer.serial_number': { + values: ['deb35a20-70f8-458e-a64a-c9e6f7575893'], + }, + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.ts index 72526c904bbb2..986bbb7604d0e 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.ts @@ -8,6 +8,10 @@ import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; import { find, isEmpty } from 'lodash/fp'; import { ALERT_RULE_TYPE } from '@kbn/rule-data-utils'; +import { + SENTINEL_ONE_AGENT_ID_FIELD, + isAlertFromSentinelOneEvent, +} from '../../../../common/utils/sentinelone_alert_check'; import { isAlertFromEndpointEvent } from '../../../../common/utils/endpoint_alert_check'; import { getEventCategoriesFromData, @@ -99,6 +103,14 @@ export const useHighlightedFields = ({ return acc; } + // if the field is observer.serial_number and the event is not a sentinel one event we skip it + if ( + field.id === SENTINEL_ONE_AGENT_ID_FIELD && + !isAlertFromSentinelOneEvent({ data: dataFormattedForFieldBrowser }) + ) { + return acc; + } + return { ...acc, [field.id]: { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils/highlighted_fields_helpers.test.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils/highlighted_fields_helpers.test.ts index 1565837f90fc2..2fe057b8a6e1d 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils/highlighted_fields_helpers.test.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils/highlighted_fields_helpers.test.ts @@ -45,6 +45,7 @@ describe('convertHighlightedFieldsToTableRow', () => { field: 'host.name-override', description: { field: 'host.name-override', + originalField: 'host.name', values: ['host-1'], scopeId: 'scopeId', isPreview, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils/highlighted_fields_helpers.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils/highlighted_fields_helpers.ts index 6cf1ec9291efe..0ffbd0923dde9 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils/highlighted_fields_helpers.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/utils/highlighted_fields_helpers.ts @@ -29,6 +29,7 @@ export const convertHighlightedFieldsToTableRow = ( field, description: { field, + ...(overrideFieldName ? { originalField: fieldName } : {}), values, scopeId, isPreview, diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx index 89844f3faece7..3567e01074edb 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx @@ -8,6 +8,7 @@ import { EuiHorizontalRule } from '@elastic/eui'; import React from 'react'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { AssetCriticalitySelector } from '../../../entity_analytics/components/asset_criticality/asset_criticality_selector'; import { OBSERVED_USER_QUERY_ID } from '../../../explore/users/containers/users/observed_details'; @@ -45,6 +46,7 @@ export const UserPanelContent = ({ openDetailsPanel, }: UserPanelContentProps) => { const observedFields = useObservedUserItems(observedUser); + const isManagedUserEnable = useIsExperimentalFeatureEnabled('newUserDetailsFlyoutManagedUser'); return ( @@ -68,12 +70,14 @@ export const UserPanelContent = ({ queryId={OBSERVED_USER_QUERY_ID} /> - + {isManagedUserEnable && ( + + )} ); }; diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.test.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.test.tsx index 9961b3ea086e2..6d5bcc4019c3c 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.test.tsx @@ -45,6 +45,11 @@ jest.mock('./hooks/use_observed_user', () => ({ useObservedUser: () => mockedUseObservedUser(), })); +const mockedUseIsExperimentalFeatureEnabled = jest.fn().mockReturnValue(true); +jest.mock('../../../common/hooks/use_experimental_features', () => ({ + useIsExperimentalFeatureEnabled: () => mockedUseIsExperimentalFeatureEnabled(), +})); + describe('UserPanel', () => { beforeEach(() => { mockedUseRiskScore.mockReturnValue(mockRiskScoreState); @@ -95,6 +100,18 @@ describe('UserPanel', () => { expect(getByTestId('securitySolutionFlyoutLoading')).toBeInTheDocument(); }); + it('does not render managed user when experimental flag is disabled', () => { + mockedUseIsExperimentalFeatureEnabled.mockReturnValue(false); + + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('managedUser-accordion-button')).not.toBeInTheDocument(); + }); + it('renders loading state when managed user is loading', () => { mockedUseManagedUser.mockReturnValue({ ...mockManagedUserData, 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, }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.tsx index 54592e6a494cf..b76565989fb74 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.tsx @@ -27,13 +27,14 @@ interface Props { onComplete?: () => void; isModalOpen: boolean; savedObjectIds: string[]; + savedSearchIds?: string[]; title: string | null; } /** * Renders a button that when clicked, displays the `Delete Timeline` modal */ export const DeleteTimelineModalOverlay = React.memo( - ({ deleteTimelines, isModalOpen, savedObjectIds, title, onComplete }) => { + ({ deleteTimelines, isModalOpen, savedObjectIds, title, onComplete, savedSearchIds }) => { const { addSuccess } = useAppToasts(); const { tabName: timelineType } = useParams<{ tabName: TimelineType }>(); @@ -43,9 +44,16 @@ export const DeleteTimelineModalOverlay = React.memo( } }, [onComplete]); const onDelete = useCallback(() => { - if (savedObjectIds.length > 0) { + if (savedObjectIds.length > 0 && savedSearchIds != null && savedSearchIds.length > 0) { + deleteTimelines(savedObjectIds, savedSearchIds); + addSuccess({ + title: + timelineType === TimelineType.template + ? i18n.SUCCESSFULLY_DELETED_TIMELINE_TEMPLATES(savedObjectIds.length) + : i18n.SUCCESSFULLY_DELETED_TIMELINES(savedObjectIds.length), + }); + } else if (savedObjectIds.length > 0) { deleteTimelines(savedObjectIds); - addSuccess({ title: timelineType === TimelineType.template @@ -53,10 +61,11 @@ export const DeleteTimelineModalOverlay = React.memo( : i18n.SUCCESSFULLY_DELETED_TIMELINES(savedObjectIds.length), }); } + if (onComplete != null) { onComplete(); } - }, [deleteTimelines, savedObjectIds, onComplete, addSuccess, timelineType]); + }, [deleteTimelines, savedObjectIds, onComplete, addSuccess, timelineType, savedSearchIds]); return ( <> {isModalOpen && } diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx index 7504e38db6ddb..67d0c5a9e4599 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx @@ -15,14 +15,7 @@ import * as i18n from './translations'; import type { DeleteTimelines, OpenTimelineResult } from './types'; import { EditTimelineActions } from './export_timeline'; import { useEditTimelineActions } from './edit_timeline_actions'; - -const getExportedIds = (selectedTimelines: OpenTimelineResult[]) => { - const array = Array.isArray(selectedTimelines) ? selectedTimelines : [selectedTimelines]; - return array.reduce( - (acc, item) => (item.savedObjectId != null ? [...acc, item.savedObjectId] : [...acc]), - [] as string[] - ); -}; +import { getSelectedTimelineIdsAndSearchIds, getRequestIds } from '.'; export const useEditTimelineBatchActions = ({ deleteTimelines, @@ -56,7 +49,13 @@ export const useEditTimelineBatchActions = ({ [disableExportTimelineDownloader, onCloseDeleteTimelineModal, tableRef] ); - const selectedIds = useMemo(() => getExportedIds(selectedItems ?? []), [selectedItems]); + const { timelineIds, searchIds } = useMemo(() => { + if (selectedItems != null) { + return getRequestIds(getSelectedTimelineIdsAndSearchIds(selectedItems)); + } else { + return { timelineIds: [], searchIds: undefined }; + } + }, [selectedItems]); const handleEnableExportTimelineDownloader = useCallback( () => enableExportTimelineDownloader(), @@ -102,7 +101,8 @@ export const useEditTimelineBatchActions = ({ <> void; @@ -27,6 +28,7 @@ export const EditTimelineActionsComponent: React.FC<{ }> = ({ deleteTimelines, ids, + savedSearchIds, isEnableDownloader, isDeleteTimelineModalOpen, onComplete, @@ -46,6 +48,7 @@ export const EditTimelineActionsComponent: React.FC<{ isModalOpen={isDeleteTimelineModalOpen} onComplete={onComplete} savedObjectIds={ids} + savedSearchIds={savedSearchIds} title={title} /> )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx index dc2cca5104497..a7751cfb02d2e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx @@ -74,14 +74,51 @@ export type OpenTimelineOwnProps = OwnProps & >; /** Returns a collection of selected timeline ids */ -export const getSelectedTimelineIds = (selectedItems: OpenTimelineResult[]): string[] => - selectedItems.reduce( - (validSelections, timelineResult) => - timelineResult.savedObjectId != null - ? [...validSelections, timelineResult.savedObjectId] - : validSelections, - [] +export const getSelectedTimelineIdsAndSearchIds = ( + selectedItems: OpenTimelineResult[] +): Array<{ timelineId: string; searchId?: string | null }> => { + return selectedItems.reduce>( + (validSelections, timelineResult) => { + if (timelineResult.savedObjectId != null && timelineResult.savedSearchId != null) { + return [ + ...validSelections, + { timelineId: timelineResult.savedObjectId, searchId: timelineResult.savedSearchId }, + ]; + } else if (timelineResult.savedObjectId != null) { + return [...validSelections, { timelineId: timelineResult.savedObjectId }]; + } else { + return validSelections; + } + }, + [] as Array<{ timelineId: string; searchId?: string | null }> + ); +}; + +interface DeleteTimelinesValues { + timelineIds: string[]; + searchIds: string[]; +} + +export const getRequestIds = ( + timelineIdsWithSearch: Array<{ timelineId: string; searchId?: string | null }> +) => { + return timelineIdsWithSearch.reduce( + (acc, { timelineId, searchId }) => { + let requestValues = acc; + if (searchId != null) { + requestValues = { ...requestValues, searchIds: [...requestValues.searchIds, searchId] }; + } + if (timelineId != null) { + requestValues = { + ...requestValues, + timelineIds: [...requestValues.timelineIds, timelineId], + }; + } + return requestValues; + }, + { timelineIds: [], searchIds: [] } ); +}; /** Manages the state (e.g table selection) of the (pure) `OpenTimeline` component */ // eslint-disable-next-line react/display-name @@ -208,7 +245,7 @@ export const StatefulOpenTimelineComponent = React.memo( // }; const deleteTimelines: DeleteTimelines = useCallback( - async (timelineIds: string[]) => { + async (timelineIds: string[], searchIds?: string[]) => { startTransaction({ name: timelineIds.length > 1 ? TIMELINE_ACTIONS.BULK_DELETE : TIMELINE_ACTIONS.DELETE, }); @@ -225,16 +262,16 @@ export const StatefulOpenTimelineComponent = React.memo( ); } - await deleteTimelinesByIds(timelineIds); + await deleteTimelinesByIds(timelineIds, searchIds); refetch(); }, [startTransaction, timelineSavedObjectId, refetch, dispatch, dataViewId, selectedPatterns] ); const onDeleteOneTimeline: OnDeleteOneTimeline = useCallback( - async (timelineIds: string[]) => { + async (timelineIds: string[], searchIds?: string[]) => { // The type for `deleteTimelines` is incorrect, it returns a Promise - await deleteTimelines(timelineIds); + await deleteTimelines(timelineIds, searchIds); }, [deleteTimelines] ); @@ -242,7 +279,9 @@ export const StatefulOpenTimelineComponent = React.memo( /** Invoked when the user clicks the action to delete the selected timelines */ const onDeleteSelected: OnDeleteSelected = useCallback(async () => { // The type for `deleteTimelines` is incorrect, it returns a Promise - await deleteTimelines(getSelectedTimelineIds(selectedItems)); + const timelineIdsWithSearch = getSelectedTimelineIdsAndSearchIds(selectedItems); + const { timelineIds, searchIds } = getRequestIds(timelineIdsWithSearch); + await deleteTimelines(timelineIds, searchIds); // NOTE: we clear the selection state below, but if the server fails to // delete a timeline, it will remain selected in the table: diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx index de993c8aa4ff9..d1392a65192f8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx @@ -129,6 +129,12 @@ export const OpenTimeline = React.memo( [actionItem] ); + const actionItemSavedSearchId = useMemo(() => { + return actionItem != null && actionItem.savedSearchId != null + ? [actionItem.savedSearchId] + : undefined; + }, [actionItem]); + const onRefreshBtnClick = useCallback(() => { if (refetch != null) { refetch(); @@ -197,6 +203,7 @@ export const OpenTimeline = React.memo( > | null; queryType?: { hasEql: boolean; hasQuery: boolean }; savedObjectId?: string | null; + savedSearchId?: string | null; status?: TimelineStatus | null; title?: string | null; templateTimelineId?: string | null; @@ -77,7 +77,7 @@ export interface EuiSearchBarQuery { } /** Performs IO to delete the specified timelines */ -export type DeleteTimelines = (timelineIds: string[], variables?: AllTimelinesVariables) => void; +export type DeleteTimelines = (timelineIds: string[], searchIds?: string[]) => void; /** Invoked when the user clicks the action create rule from timeline */ export type OnCreateRuleFromTimeline = (savedObjectId: string) => void; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/back_to_alert_details_link.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/back_to_alert_details_link.tsx index 2ce97cfbce32e..6ae4207a46f78 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/back_to_alert_details_link.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/back_to_alert_details_link.tsx @@ -5,34 +5,58 @@ * 2.0. */ -import { EuiButtonEmpty, EuiText, EuiTitle } from '@elastic/eui'; +import { + EuiBetaBadge, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { css } from '@emotion/react'; import React from 'react'; import { ISOLATE_HOST, UNISOLATE_HOST, } from '../../../../../detections/components/host_isolation/translations'; -import { ALERT_DETAILS } from '../translations'; +import { ALERT_DETAILS, TECHNICAL_PREVIEW, TECHNICAL_PREVIEW_DESCRIPTION } from '../translations'; const BackToAlertDetailsLinkComponent = ({ showAlertDetails, + showExperimentalBadge, isolateAction, }: { showAlertDetails: () => void; + showExperimentalBadge?: boolean; isolateAction: 'isolateHost' | 'unisolateHost'; -}) => { - return ( - <> - - -

{ALERT_DETAILS}

-
-
- -

{isolateAction === 'isolateHost' ? ISOLATE_HOST : UNISOLATE_HOST}

-
- - ); -}; +}) => ( + <> + + +

{ALERT_DETAILS}

+
+
+ + + +

{isolateAction === 'isolateHost' ? ISOLATE_HOST : UNISOLATE_HOST}

+
+
+ + {showExperimentalBadge && ( + + )} + +
+ +); const BackToAlertDetailsLink = React.memo(BackToAlertDetailsLinkComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx index d5df4304a0894..0d139449b1ca2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx @@ -6,8 +6,9 @@ */ import { EuiFlyoutHeader } from '@elastic/eui'; -import React from 'react'; +import React, { useMemo } from 'react'; +import { SENTINEL_ONE_AGENT_ID_FIELD } from '../../../../../common/utils/sentinelone_alert_check'; import type { GetFieldsData } from '../../../../../common/hooks/use_get_fields_data'; import { ExpandableEventTitle } from '../expandable_event'; import { BackToAlertDetailsLink } from './back_to_alert_details_link'; @@ -43,10 +44,19 @@ const FlyoutHeaderContentComponent = ({ refetchFlyoutData, getFieldsData, }: FlyoutHeaderComponentProps) => { + const isSentinelOneAlert = useMemo( + () => !!(isAlert && getFieldsData(SENTINEL_ONE_AGENT_ID_FIELD)?.length), + [getFieldsData, isAlert] + ); + return ( <> {isHostIsolationPanelOpen ? ( - + ) : ( { + const skip = useIsExperimentalFeatureEnabled('newUserDetailsFlyoutManagedUser'); const { to, from, isInitializing, deleteQuery, setQuery } = useGlobalTime(); const spaceId = useSpaceId(); const { @@ -57,17 +60,18 @@ export const useManagedUser = ( ); useEffect(() => { - if (!isInitializing && defaultIndex.length > 0 && !isLoading && userName) { + if (!isInitializing && defaultIndex.length > 0 && !isLoading && userName && !skip) { search({ defaultIndex, userEmail: email, userName, }); } - }, [from, search, to, isInitializing, defaultIndex, userName, isLoading, email]); + }, [from, search, to, isInitializing, defaultIndex, userName, isLoading, email, skip]); const { data: installedIntegrations, isLoading: loadingIntegrations } = useInstalledIntegrations({ packages, + skip, }); useQueryInspector({ diff --git a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx index 72e85c77b0dbf..5e88cf8b63cfe 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx @@ -88,6 +88,7 @@ export const getAllTimeline = memoizeOne( ) : null, savedObjectId: timeline.savedObjectId, + savedSearchId: timeline.savedSearchId, status: timeline.status, title: timeline.title, updated: timeline.updated, diff --git a/x-pack/plugins/security_solution/public/timelines/containers/api.ts b/x-pack/plugins/security_solution/public/timelines/containers/api.ts index f39143bbfa767..4b1c106230fdd 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/api.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/api.ts @@ -480,13 +480,20 @@ export const persistFavorite = async ({ return decodeResponseFavoriteTimeline(response); }; -export const deleteTimelinesByIds = async (savedObjectIds: string[]) => { +export const deleteTimelinesByIds = async (savedObjectIds: string[], searchIds?: string[]) => { let requestBody; try { - requestBody = JSON.stringify({ - savedObjectIds, - }); + if (searchIds) { + requestBody = JSON.stringify({ + savedObjectIds, + searchIds, + }); + } else { + requestBody = JSON.stringify({ + savedObjectIds, + }); + } } catch (err) { return Promise.reject(new Error(`Failed to stringify query: ${JSON.stringify(err)}`)); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 2f6ea38b40b81..0459636ff75ca 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -46,7 +46,7 @@ import { } from '../../../../../common/api/detection_engine/signals_migration/mocks'; // eslint-disable-next-line no-restricted-imports -import type { LegacyRuleNotificationAlertType } from '../../rule_actions_legacy'; +import type { LegacyRuleNotificationRuleType } from '../../rule_actions_legacy'; import type { RuleAlertType, RuleParams } from '../../rule_schema'; import { getQueryRuleParams } from '../../rule_schema/mocks'; @@ -520,7 +520,7 @@ export const legacyGetNotificationResult = ({ }: { id?: string; ruleId?: string; -} = {}): LegacyRuleNotificationAlertType => ({ +} = {}): LegacyRuleNotificationRuleType => ({ id, name: 'Notification for Rule Test', tags: [], @@ -567,7 +567,7 @@ export const legacyGetNotificationResult = ({ */ export const legacyGetFindNotificationsResultWithSingleHit = ( ruleId = '123' -): FindHit => ({ +): FindHit => ({ page: 1, perPage: 1, total: 1, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/api/create_legacy_notification/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/api/create_legacy_notification/route.ts index df2c4bb3366b9..518ece11dbefe 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/api/create_legacy_notification/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/api/create_legacy_notification/route.ts @@ -14,7 +14,7 @@ import { legacyUpdateOrCreateRuleActionsSavedObject } from '../../logic/rule_act // eslint-disable-next-line no-restricted-imports import { legacyReadNotifications } from '../../logic/notifications/legacy_read_notifications'; // eslint-disable-next-line no-restricted-imports -import type { LegacyRuleNotificationAlertTypeParams } from '../../logic/notifications/legacy_types'; +import type { LegacyRuleNotificationRuleTypeParams } from '../../logic/notifications/legacy_types'; // eslint-disable-next-line no-restricted-imports import { legacyCreateNotifications } from '../../logic/notifications/legacy_create_notifications'; import { UPDATE_OR_CREATE_LEGACY_ACTIONS } from '../../../../../../common/constants'; @@ -75,7 +75,7 @@ export const legacyCreateLegacyNotificationRoute = ( ruleAlertId, }); if (notification != null) { - await rulesClient.update({ + await rulesClient.update({ id: notification.id, data: { tags: [], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/index.ts index fddf872583040..e577d0ac5355a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/index.ts @@ -8,13 +8,13 @@ export * from './api/register_routes'; // eslint-disable-next-line no-restricted-imports -export { legacyRulesNotificationAlertType } from './logic/notifications/legacy_rules_notification_alert_type'; +export { legacyRulesNotificationRuleType } from './logic/notifications/legacy_rules_notification_rule_type'; // eslint-disable-next-line no-restricted-imports -export { legacyIsNotificationAlertExecutor } from './logic/notifications/legacy_types'; +export { isLegacyNotificationRuleExecutor } from './logic/notifications/legacy_types'; // eslint-disable-next-line no-restricted-imports export type { - LegacyRuleNotificationAlertType, - LegacyRuleNotificationAlertTypeParams, + LegacyRuleNotificationRuleType, + LegacyRuleNotificationRuleTypeParams, } from './logic/notifications/legacy_types'; export type { NotificationRuleTypeParams } from './logic/notifications/schedule_notification_actions'; export { scheduleNotificationActions } from './logic/notifications/schedule_notification_actions'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_create_notifications.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_create_notifications.ts index 983519404b222..34428140e9a12 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_create_notifications.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_create_notifications.ts @@ -10,7 +10,7 @@ import { SERVER_APP_ID, LEGACY_NOTIFICATIONS_ID } from '../../../../../../common // eslint-disable-next-line no-restricted-imports import type { CreateNotificationParams, - LegacyRuleNotificationAlertTypeParams, + LegacyRuleNotificationRuleTypeParams, } from './legacy_types'; /** @@ -23,8 +23,8 @@ export const legacyCreateNotifications = async ({ ruleAlertId, interval, name, -}: CreateNotificationParams): Promise> => - rulesClient.create({ +}: CreateNotificationParams): Promise> => + rulesClient.create({ data: { name, tags: [], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_read_notifications.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_read_notifications.ts index 187cde7ce8c9d..fda0b6b45ca56 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_read_notifications.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_read_notifications.ts @@ -9,7 +9,7 @@ import type { RuleTypeParams, SanitizedRule } from '@kbn/alerting-plugin/common' // eslint-disable-next-line no-restricted-imports import type { LegacyReadNotificationParams } from './legacy_types'; // eslint-disable-next-line no-restricted-imports -import { legacyIsAlertType } from './legacy_types'; +import { isLegacyRuleType } from './legacy_types'; // eslint-disable-next-line no-restricted-imports import { legacyFindNotifications } from './legacy_find_notifications'; @@ -24,7 +24,7 @@ export const legacyReadNotifications = async ({ if (id != null) { try { const notification = await rulesClient.get({ id }); - if (legacyIsAlertType(notification)) { + if (isLegacyRuleType(notification)) { return notification; } else { return null; @@ -43,10 +43,7 @@ export const legacyReadNotifications = async ({ filter: `alert.attributes.params.ruleAlertId: "${ruleAlertId}"`, page: 1, }); - if ( - notificationFromFind.data.length === 0 || - !legacyIsAlertType(notificationFromFind.data[0]) - ) { + if (notificationFromFind.data.length === 0 || !isLegacyRuleType(notificationFromFind.data[0])) { return null; } else { return notificationFromFind.data[0]; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_rules_notification_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_rules_notification_rule_type.test.ts similarity index 57% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_rules_notification_alert_type.test.ts rename to x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_rules_notification_rule_type.test.ts index 0f0eeece6f8f6..e3a5e24bd14c2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_rules_notification_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_rules_notification_rule_type.test.ts @@ -12,7 +12,7 @@ import { DEFAULT_FLAPPING_SETTINGS } from '@kbn/alerting-plugin/common'; import { getRuleMock } from '../../../routes/__mocks__/request_responses'; // eslint-disable-next-line no-restricted-imports -import { legacyRulesNotificationAlertType } from './legacy_rules_notification_alert_type'; +import { legacyRulesNotificationRuleType } from './legacy_rules_notification_rule_type'; import { buildSignalsSearchQuery } from './build_signals_query'; // eslint-disable-next-line no-restricted-imports import type { LegacyNotificationExecutorOptions } from './legacy_types'; @@ -26,12 +26,123 @@ import { getQueryRuleParams } from '../../../rule_schema/mocks'; jest.mock('./build_signals_query'); +const reported = { + actionGroup: 'default', + context: { + alerts: [ + { + '@timestamp': expect.any(String), + destination: { + ip: '127.0.0.1', + }, + someKey: 'someValue', + source: { + ip: '127.0.0.1', + }, + }, + ], + results_link: + '/app/security/detections/rules/id/rule-id?timerange=(global:(linkTo:!(timeline),timerange:(from:1576255233400,kind:absolute,to:1576341633400)),timeline:(linkTo:!(global),timerange:(from:1576255233400,kind:absolute,to:1576341633400)))', + rule: { + alert_suppression: undefined, + author: ['Elastic'], + building_block_type: 'default', + data_view_id: undefined, + description: 'Detecting root and admin users', + exceptions_list: [ + { + id: 'some_uuid', + list_id: 'list_id_single', + namespace_type: 'single', + type: 'detection', + }, + { + id: 'endpoint_list', + list_id: 'endpoint_list', + namespace_type: 'agnostic', + type: 'endpoint', + }, + ], + false_positives: [], + filters: [ + { + query: { + match_phrase: { + 'host.name': 'some-host', + }, + }, + }, + ], + from: 'now-6m', + id: 'rule-id', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + investigation_fields: undefined, + language: 'kuery', + license: 'Elastic License', + max_signals: 10000, + name: 'Detect Root/Admin Users', + namespace: undefined, + note: '# Investigative notes', + output_index: '.siem-signals', + query: 'user.name: root or user.name: admin', + references: ['http://example.com', 'https://example.com'], + related_integrations: [], + required_fields: [], + response_actions: undefined, + risk_score: 50, + risk_score_mapping: [], + rule_id: 'rule-1', + rule_name_override: undefined, + saved_id: undefined, + setup: '', + severity: 'high', + severity_mapping: [], + threat: [ + { + framework: 'MITRE ATT&CK', + tactic: { + id: 'TA0000', + name: 'test tactic', + reference: 'https://attack.mitre.org/tactics/TA0000/', + }, + technique: [ + { + id: 'T0000', + name: 'test technique', + reference: 'https://attack.mitre.org/techniques/T0000/', + subtechnique: [ + { + id: 'T0000.000', + name: 'test subtechnique', + reference: 'https://attack.mitre.org/techniques/T0000/000/', + }, + ], + }, + ], + }, + ], + timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', + timestamp_override: undefined, + timestamp_override_fallback_disabled: undefined, + to: 'now', + type: 'query', + version: 1, + }, + }, + id: '1111', + state: { + signals_count: 1, + }, +}; + /** * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function */ -describe('legacyRules_notification_alert_type', () => { +describe('legacyRules_notification_rule_type', () => { let payload: LegacyNotificationExecutorOptions; - let alert: ReturnType; + let rule: ReturnType; let logger: ReturnType; let alertServices: RuleExecutorServicesMock; @@ -78,7 +189,7 @@ describe('legacyRules_notification_alert_type', () => { }, }; - alert = legacyRulesNotificationAlertType({ + rule = legacyRulesNotificationRuleType({ logger, }); }); @@ -91,7 +202,7 @@ describe('legacyRules_notification_alert_type', () => { type: 'type', references: [], }); - await alert.executor(payload); + await rule.executor(payload); expect(logger.error).toHaveBeenCalledWith( `Security Solution notification (Legacy) saved object for alert ${payload.params.ruleAlertId} was not found with id: \"1111\". space id: \"\" This indicates a dangling (Legacy) notification alert. You should delete this rule through \"Kibana UI -> Stack Management -> Rules and Connectors\" to remove this error message.` ); @@ -109,7 +220,7 @@ describe('legacyRules_notification_alert_type', () => { sampleDocSearchResultsWithSortId() ); - await alert.executor(payload); + await rule.executor(payload); expect(buildSignalsSearchQuery).toHaveBeenCalledWith( expect.objectContaining({ @@ -135,17 +246,8 @@ describe('legacyRules_notification_alert_type', () => { sampleDocSearchResultsWithSortId() ); - await alert.executor(payload); - expect(alertServices.alertFactory.create).toHaveBeenCalled(); - - const [{ value: alertInstanceMock }] = alertServices.alertFactory.create.mock.results; - expect(alertInstanceMock.scheduleActions).toHaveBeenCalledWith( - 'default', - expect.objectContaining({ - results_link: - '/app/security/detections/rules/id/rule-id?timerange=(global:(linkTo:!(timeline),timerange:(from:1576255233400,kind:absolute,to:1576341633400)),timeline:(linkTo:!(global),timerange:(from:1576255233400,kind:absolute,to:1576341633400)))', - }) - ); + await rule.executor(payload); + expect(alertServices.alertsClient.report).toHaveBeenCalledWith(reported); }); it('should resolve results_link when meta is an empty object to use "/app/security"', async () => { @@ -160,17 +262,11 @@ describe('legacyRules_notification_alert_type', () => { alertServices.scopedClusterClient.asCurrentUser.search.mockResponse( sampleDocSearchResultsWithSortId() ); - await alert.executor(payload); - expect(alertServices.alertFactory.create).toHaveBeenCalled(); - - const [{ value: alertInstanceMock }] = alertServices.alertFactory.create.mock.results; - expect(alertInstanceMock.scheduleActions).toHaveBeenCalledWith( - 'default', - expect.objectContaining({ - results_link: - '/app/security/detections/rules/id/rule-id?timerange=(global:(linkTo:!(timeline),timerange:(from:1576255233400,kind:absolute,to:1576341633400)),timeline:(linkTo:!(global),timerange:(from:1576255233400,kind:absolute,to:1576341633400)))', - }) - ); + await rule.executor(payload); + expect(alertServices.alertsClient.report).toHaveBeenCalledWith({ + ...reported, + context: { ...reported.context, rule: { ...reported.context.rule, meta: {} } }, + }); }); it('should resolve results_link to custom kibana link when given one', async () => { @@ -187,20 +283,24 @@ describe('legacyRules_notification_alert_type', () => { alertServices.scopedClusterClient.asCurrentUser.search.mockResponse( sampleDocSearchResultsWithSortId() ); - await alert.executor(payload); - expect(alertServices.alertFactory.create).toHaveBeenCalled(); - - const [{ value: alertInstanceMock }] = alertServices.alertFactory.create.mock.results; - expect(alertInstanceMock.scheduleActions).toHaveBeenCalledWith( - 'default', - expect.objectContaining({ + await rule.executor(payload); + expect(alertServices.alertsClient.report).toHaveBeenCalledWith({ + ...reported, + context: { + ...reported.context, results_link: 'http://localhost/detections/rules/id/rule-id?timerange=(global:(linkTo:!(timeline),timerange:(from:1576255233400,kind:absolute,to:1576341633400)),timeline:(linkTo:!(global),timerange:(from:1576255233400,kind:absolute,to:1576341633400)))', - }) - ); + rule: { + ...reported.context.rule, + meta: { + kibana_siem_app_url: 'http://localhost', + }, + }, + }, + }); }); - it('should not call alertFactory.create if signalsCount was 0', async () => { + it('should not call alertsClient.report if signalsCount was 0', async () => { const ruleAlert = getRuleMock(getQueryRuleParams()); alertServices.savedObjectsClient.get.mockResolvedValue({ id: 'id', @@ -212,9 +312,9 @@ describe('legacyRules_notification_alert_type', () => { sampleEmptyDocSearchResults() ); - await alert.executor(payload); + await rule.executor(payload); - expect(alertServices.alertFactory.create).not.toHaveBeenCalled(); + expect(alertServices.alertsClient.report).not.toHaveBeenCalled(); }); it('should call scheduleActions if signalsCount was greater than 0', async () => { @@ -229,22 +329,32 @@ describe('legacyRules_notification_alert_type', () => { sampleDocSearchResultsNoSortIdNoVersion() ); - await alert.executor(payload); - - expect(alertServices.alertFactory.create).toHaveBeenCalled(); + await rule.executor(payload); - const [{ value: alertInstanceMock }] = alertServices.alertFactory.create.mock.results; - expect(alertInstanceMock.replaceState).toHaveBeenCalledWith( - expect.objectContaining({ signals_count: 100 }) - ); - expect(alertInstanceMock.scheduleActions).toHaveBeenCalledWith( - 'default', - expect.objectContaining({ - rule: expect.objectContaining({ - name: ruleAlert.name, - }), - }) - ); + expect(alertServices.alertsClient.report).toHaveBeenCalledWith({ + ...reported, + context: { + ...reported.context, + alerts: [ + { + '@timestamp': expect.any(String), + someKey: 'someValue', + }, + ], + results_link: + '/app/security/detections/rules/id/id?timerange=(global:(linkTo:!(timeline),timerange:(from:1576255233400,kind:absolute,to:1576341633400)),timeline:(linkTo:!(global),timerange:(from:1576255233400,kind:absolute,to:1576341633400)))', + rule: { + ...reported.context.rule, + id: 'id', + meta: { + someMeta: 'someField', + }, + }, + }, + state: { + signals_count: 100, + }, + }); }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_rules_notification_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_rules_notification_rule_type.ts similarity index 85% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_rules_notification_alert_type.ts rename to x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_rules_notification_rule_type.ts index 206444e82d7aa..5e237f8564559 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_rules_notification_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_rules_notification_rule_type.ts @@ -5,9 +5,11 @@ * 2.0. */ +import { mapKeys, snakeCase } from 'lodash/fp'; import type { Logger } from '@kbn/core/server'; import { parseScheduleDates } from '@kbn/securitysolution-io-ts-utils'; import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; +import { AlertsClientError, DEFAULT_AAD_CONFIG } from '@kbn/alerting-plugin/server'; import { DEFAULT_RULE_NOTIFICATION_QUERY_SIZE, LEGACY_NOTIFICATIONS_ID, @@ -15,12 +17,12 @@ import { } from '../../../../../../common/constants'; // eslint-disable-next-line no-restricted-imports -import type { LegacyNotificationAlertTypeDefinition } from './legacy_types'; +import type { LegacyNotificationRuleTypeDefinition } from './legacy_types'; // eslint-disable-next-line no-restricted-imports import { legacyRulesNotificationParams } from './legacy_types'; import type { AlertAttributes } from '../../../rule_types/types'; import { siemRuleActionGroups } from '../../../rule_types/utils/siem_rule_action_groups'; -import { scheduleNotificationActions } from './schedule_notification_actions'; +import { formatAlertsForNotificationActions } from './schedule_notification_actions'; import { getNotificationResultsLink } from './utils'; import { getSignals } from './get_signals'; // eslint-disable-next-line no-restricted-imports @@ -31,11 +33,11 @@ import { legacyInjectReferences } from './legacy_saved_object_references/legacy_ /** * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function */ -export const legacyRulesNotificationAlertType = ({ +export const legacyRulesNotificationRuleType = ({ logger, }: { logger: Logger; -}): LegacyNotificationAlertTypeDefinition => ({ +}): LegacyNotificationRuleTypeDefinition => ({ id: LEGACY_NOTIFICATIONS_ID, name: 'Security Solution notification (Legacy)', actionGroups: siemRuleActionGroups, @@ -52,6 +54,7 @@ export const legacyRulesNotificationAlertType = ({ }, minimumLicenseRequired: 'basic', isExportable: false, + alerts: DEFAULT_AAD_CONFIG, async executor({ startedAt, previousStartedAt, @@ -60,6 +63,11 @@ export const legacyRulesNotificationAlertType = ({ params, spaceId, }) { + const { alertsClient } = services; + if (!alertsClient) { + throw new AlertsClientError(); + } + const ruleAlertSavedObject = await services.savedObjectsClient.get( 'alert', params.ruleAlertId @@ -127,13 +135,17 @@ export const legacyRulesNotificationAlertType = ({ ); if (signalsCount !== 0) { - const alertInstance = services.alertFactory.create(ruleId); - scheduleNotificationActions({ - alertInstance, - signalsCount, - resultsLink, - ruleParams, - signals, + alertsClient.report({ + id: ruleId, + actionGroup: 'default', + state: { + signals_count: signalsCount, + }, + context: { + results_link: resultsLink, + rule: mapKeys(snakeCase, ruleParams), + alerts: formatAlertsForNotificationActions(signals), + }, }); } }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_saved_object_references/README.md b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_saved_object_references/README.md index 22e1da8dff5b3..09792ae0441bf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_saved_object_references/README.md +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_saved_object_references/README.md @@ -2,7 +2,7 @@ This is where you add code when you have rules which contain saved object refere when you have "joins" in the saved objects between one saved object and another one. This can be a 1 to M (1 to many) relationship for example where you have a rule which contains the "id" of another saved object. -NOTE: This is the "legacy saved object references" and should only be for the "legacy_rules_notification_alert_type". +NOTE: This is the "legacy saved object references" and should only be for the "legacy_rules_notification_rule_type". The legacy notification system is being phased out and deprecated in favor of using the newer alerting notification system. It would be considered wrong to see additional code being added here at this point. However, maintenance should be expected until we have all users moved away from the legacy system. diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_saved_object_references/legacy_extract_references.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_saved_object_references/legacy_extract_references.ts index 069017fcabbcb..c388422aedcf9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_saved_object_references/legacy_extract_references.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_saved_object_references/legacy_extract_references.ts @@ -30,7 +30,7 @@ import { legacyExtractRuleId } from './legacy_extract_rule_id'; * Optionally you can remove any parameters you do not want to store within the Saved Object here: * const paramsWithoutSavedObjectReferences = { removeParam, ...otherParams }; * - * If you do remove params, then update the types in: security_solution/server/lib/detection_engine/notifications/legacy_rules_notification_alert_type.ts + * If you do remove params, then update the types in: security_solution/server/lib/detection_engine/notifications/legacy_rules_notification_rule_type.ts * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function * @param logger Kibana injected logger * @param params The params of the base rule(s). diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_types.ts index 2aea76d8c51e1..824ad7573e10c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_types.ts @@ -19,19 +19,20 @@ import type { RuleExecutorOptions, } from '@kbn/alerting-plugin/server'; import type { Rule, RuleAction } from '@kbn/alerting-plugin/common'; +import type { DefaultAlert } from '@kbn/alerts-as-data-utils'; import { LEGACY_NOTIFICATIONS_ID } from '../../../../../../common/constants'; /** * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function */ -export interface LegacyRuleNotificationAlertTypeParams extends RuleTypeParams { +export interface LegacyRuleNotificationRuleTypeParams extends RuleTypeParams { ruleAlertId: string; } /** * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function */ -export type LegacyRuleNotificationAlertType = Rule; +export type LegacyRuleNotificationRuleType = Rule; /** * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function @@ -81,35 +82,40 @@ export interface LegacyReadNotificationParams { /** * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function */ -export const legacyIsAlertType = ( - partialAlert: PartialRule -): partialAlert is LegacyRuleNotificationAlertType => { - return partialAlert.alertTypeId === LEGACY_NOTIFICATIONS_ID; +export const isLegacyRuleType = ( + partialRule: PartialRule +): partialRule is LegacyRuleNotificationRuleType => { + return partialRule.alertTypeId === LEGACY_NOTIFICATIONS_ID; }; /** * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function */ export type LegacyNotificationExecutorOptions = RuleExecutorOptions< - LegacyRuleNotificationAlertTypeParams, + LegacyRuleNotificationRuleTypeParams, RuleTypeState, AlertInstanceState, - AlertInstanceContext + AlertInstanceContext, + 'default', + DefaultAlert >; /** - * This returns true because by default a NotificationAlertTypeDefinition is an AlertType + * This returns true because by default a NotificationRuleTypeDefinition is an RuleType * since we are only increasing the strictness of params. * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function */ -export const legacyIsNotificationAlertExecutor = ( - obj: LegacyNotificationAlertTypeDefinition +export const isLegacyNotificationRuleExecutor = ( + obj: LegacyNotificationRuleTypeDefinition ): obj is RuleType< - LegacyRuleNotificationAlertTypeParams, - LegacyRuleNotificationAlertTypeParams, + LegacyRuleNotificationRuleTypeParams, + LegacyRuleNotificationRuleTypeParams, RuleTypeState, AlertInstanceState, - AlertInstanceContext + AlertInstanceContext, + 'default', + never, + DefaultAlert > => { return true; }; @@ -117,14 +123,16 @@ export const legacyIsNotificationAlertExecutor = ( /** * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function */ -export type LegacyNotificationAlertTypeDefinition = Omit< +export type LegacyNotificationRuleTypeDefinition = Omit< RuleType< - LegacyRuleNotificationAlertTypeParams, - LegacyRuleNotificationAlertTypeParams, + LegacyRuleNotificationRuleTypeParams, + LegacyRuleNotificationRuleTypeParams, RuleTypeState, AlertInstanceState, AlertInstanceContext, - 'default' + 'default', + never, + DefaultAlert >, 'executor' > & { @@ -136,17 +144,17 @@ export type LegacyNotificationAlertTypeDefinition = Omit< }; /** - * This is the notification type used within legacy_rules_notification_alert_type for the alert params. + * This is the notification type used within legacy_rules_notification_rule_type for the alert params. * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function - * @see legacy_rules_notification_alert_type + * @see legacy_rules_notification_rule_type */ export const legacyRulesNotificationParams = schema.object({ ruleAlertId: schema.string(), }); /** - * This legacy rules notification type used within legacy_rules_notification_alert_type for the alert params. + * This legacy rules notification type used within legacy_rules_notification_rule_type for the alert params. * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function - * @see legacy_rules_notification_alert_type + * @see legacy_rules_notification_rule_type */ export type LegacyRulesNotificationParams = TypeOf; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/post_legacy_notification.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/post_legacy_notification.sh index f160d1e899a55..317b9d8c0fafc 100755 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/post_legacy_notification.sh +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/post_legacy_notification.sh @@ -20,6 +20,7 @@ NOTIFICATIONS=${2:-./legacy_notifications/one_action.json} curl -s -k \ -H 'Content-Type: application/json' \ -H 'kbn-xsrf: 123' \ + -H 'elastic-api-version: 1' \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X POST ${KIBANA_URL}${SPACE_URL}/internal/api/detection/legacy/notifications?alert_id="$1" \ -d @${NOTIFICATIONS} | jq . diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/fleet_agent_response.ts b/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/fleet_agent_response.ts new file mode 100644 index 0000000000000..2c9c54f696c4f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/fleet_agent_response.ts @@ -0,0 +1,482 @@ +/* + * 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 { Agent } from '@kbn/fleet-plugin/common'; + +export const stubFleetAgentResponse: { + agents: Agent[]; + total: number; + page: number; + perPage: number; +} = { + agents: [ + { + id: '45112616-62e0-42c5-a8f9-2f8a71a92040', + type: 'PERMANENT', + active: true, + enrolled_at: '2024-01-11T03:39:21.515Z', + upgraded_at: null, + upgrade_started_at: null, + access_api_key_id: 'jY3dWnkBj1tiuAw9pAmq', + default_api_key_id: 'so3dWnkBj1tiuAw9yAm3', + policy_id: '147b2096-bd12-4b7e-a100-061dc11ba799', + last_checkin: '2024-01-11T04:00:35.217Z', + last_checkin_status: 'online', + policy_revision: 2, + packages: [], + sort: [1704944361515, 'Host-roan3tb8c3'], + components: [ + { + id: 'endpoint-0', + type: 'endpoint', + status: 'STARTING', + message: 'Running as external service', + units: [ + { + id: 'endpoint-1', + type: 'input', + status: 'STARTING', + message: 'Protecting machine', + payload: { + extra: 'payload', + }, + }, + { + id: 'shipper', + type: 'output', + status: 'STARTING', + message: 'Connected over GRPC', + payload: { + extra: 'payload', + }, + }, + ], + }, + ], + agent: { + id: '45112616-62e0-42c5-a8f9-2f8a71a92040', + version: '8.13.0', + }, + user_provided_metadata: {}, + local_metadata: { + elastic: { + agent: { + 'build.original': '8.0.0-SNAPSHOT (build: j74oz at 2021-05-07 18:42:49 +0000 UTC)', + id: '45112616-62e0-42c5-a8f9-2f8a71a92040', + log_level: 'info', + snapshot: true, + upgradeable: true, + version: '8.13.0', + }, + }, + host: { + architecture: '4nil08yn9j', + hostname: 'Host-roan3tb8c3', + id: '866c98c0-c323-4f6b-9e4c-8cc4694e4ba7', + ip: ['00.00.000.00'], + mac: ['00-00-00-00-00-00', '00-00-00-00-00-00', '0-00-00-00-00-00'], + name: 'Host-roan3tb8c3', + os: { + name: 'Windows', + full: 'Windows 10', + version: '10.0', + platform: 'Windows', + family: 'windows', + Ext: { + variant: 'Windows Pro', + }, + }, + }, + os: { + family: 'windows', + full: 'Windows 10', + kernel: '10.0.17763.1879 (Build.160101.0800)', + name: 'Windows', + platform: 'Windows', + version: '10.0', + Ext: { + variant: 'Windows Pro', + }, + }, + }, + status: 'online', + }, + { + id: '74550426-040d-4216-a227-599fd3efa91c', + type: 'PERMANENT', + active: true, + enrolled_at: '2024-01-11T03:39:21.512Z', + upgraded_at: null, + upgrade_started_at: null, + access_api_key_id: 'jY3dWnkBj1tiuAw9pAmq', + default_api_key_id: 'so3dWnkBj1tiuAw9yAm3', + policy_id: '16608650-4839-4053-a0eb-6ee9d11ac84d', + last_checkin: '2024-01-11T04:00:35.302Z', + last_checkin_status: 'online', + policy_revision: 2, + packages: [], + sort: [1704944361512, 'Host-vso4lwuc51'], + components: [ + { + id: 'endpoint-0', + type: 'endpoint', + status: 'FAILED', + message: 'Running as external service', + units: [ + { + id: 'endpoint-1', + type: 'input', + status: 'FAILED', + message: 'Protecting machine', + payload: { + error: { + code: -272, + message: 'Unable to connect to Elasticsearch', + }, + }, + }, + { + id: 'shipper', + type: 'output', + status: 'FAILED', + message: 'Connected over GRPC', + payload: { + extra: 'payload', + }, + }, + ], + }, + ], + agent: { + id: '74550426-040d-4216-a227-599fd3efa91c', + version: '8.13.0', + }, + user_provided_metadata: {}, + local_metadata: { + elastic: { + agent: { + 'build.original': '8.0.0-SNAPSHOT (build: 315fp at 2021-05-07 18:42:49 +0000 UTC)', + id: '74550426-040d-4216-a227-599fd3efa91c', + log_level: 'info', + snapshot: true, + upgradeable: true, + version: '8.13.0', + }, + }, + host: { + architecture: '3oem2enr1y', + hostname: 'Host-vso4lwuc51', + id: '3cdfece3-8b4e-4006-a19e-7ab7e953bb38', + ip: ['00.00.000.00'], + mac: ['00-00-00-00-00-00', '00-00-00-00-00-00', '0-00-00-00-00-00'], + name: 'Host-vso4lwuc51', + os: { + name: 'Windows', + full: 'Windows Server 2012', + version: '6.2', + platform: 'Windows', + family: 'windows', + Ext: { + variant: 'Windows Server', + }, + }, + }, + os: { + family: 'windows', + full: 'Windows Server 2012', + kernel: '10.0.17763.1879 (Build.160101.0800)', + name: 'Windows', + platform: 'Windows', + version: '6.2', + Ext: { + variant: 'Windows Server', + }, + }, + }, + status: 'online', + }, + { + id: 'b80bc33e-1c65-41b3-80d6-8f9757552ab1', + type: 'PERMANENT', + active: true, + enrolled_at: '2024-01-11T03:31:22.832Z', + upgraded_at: null, + upgrade_started_at: null, + access_api_key_id: 'jY3dWnkBj1tiuAw9pAmq', + default_api_key_id: 'so3dWnkBj1tiuAw9yAm3', + policy_id: '125f0769-20b4-4604-81ce-f0db812d510b', + last_checkin: '2024-01-11T04:00:36.305Z', + last_checkin_status: 'online', + policy_revision: 2, + packages: [], + sort: [1704943882832, 'Host-y0zwnrnucm'], + components: [ + { + id: 'endpoint-0', + type: 'endpoint', + status: 'STOPPING', + message: 'Running as external service', + units: [ + { + id: 'endpoint-1', + type: 'input', + status: 'STOPPING', + message: 'Protecting machine', + payload: { + extra: 'payload', + }, + }, + { + id: 'shipper', + type: 'output', + status: 'STOPPING', + message: 'Connected over GRPC', + payload: { + extra: 'payload', + }, + }, + ], + }, + ], + agent: { + id: 'b80bc33e-1c65-41b3-80d6-8f9757552ab1', + version: '8.13.0', + }, + user_provided_metadata: {}, + local_metadata: { + elastic: { + agent: { + 'build.original': '8.0.0-SNAPSHOT (build: klkq1 at 2021-05-07 18:42:49 +0000 UTC)', + id: 'b80bc33e-1c65-41b3-80d6-8f9757552ab1', + log_level: 'info', + snapshot: true, + upgradeable: true, + version: '8.13.0', + }, + }, + host: { + architecture: 'ogtqmitmts', + hostname: 'Host-y0zwnrnucm', + id: 'aca58288-ac9b-4ce7-9cef-67692fe10363', + ip: ['00.00.000.00'], + mac: ['00-00-00-00-00-00', '00-00-00-00-00-00', '0-00-00-00-00-00'], + name: 'Host-y0zwnrnucm', + os: { + name: 'macOS', + full: 'macOS Monterey', + version: '12.6.1', + platform: 'macOS', + family: 'Darwin', + Ext: { + variant: 'Darwin', + }, + }, + }, + os: { + family: 'Darwin', + full: 'macOS Monterey', + kernel: '10.0.17763.1879 (Build.160101.0800)', + name: 'macOS', + platform: 'macOS', + version: '12.6.1', + Ext: { + variant: 'Darwin', + }, + }, + }, + status: 'online', + }, + { + id: 'cbd4cda1-3bac-45a7-9914-812d3b9c5f44', + type: 'PERMANENT', + active: true, + enrolled_at: '2024-01-11T03:21:13.662Z', + upgraded_at: null, + upgrade_started_at: null, + access_api_key_id: 'jY3dWnkBj1tiuAw9pAmq', + default_api_key_id: 'so3dWnkBj1tiuAw9yAm3', + policy_id: '004e29f7-3b96-4ce3-8de8-c3024f56eae2', + last_checkin: '2024-01-11T04:00:37.315Z', + last_checkin_status: 'online', + policy_revision: 2, + packages: [], + sort: [1704943273662, 'Host-60j0gd14nc'], + components: [ + { + id: 'endpoint-0', + type: 'endpoint', + status: 'STOPPING', + message: 'Running as external service', + units: [ + { + id: 'endpoint-1', + type: 'input', + status: 'STOPPING', + message: 'Protecting machine', + payload: { + extra: 'payload', + }, + }, + { + id: 'shipper', + type: 'output', + status: 'STOPPING', + message: 'Connected over GRPC', + payload: { + extra: 'payload', + }, + }, + ], + }, + ], + agent: { + id: 'cbd4cda1-3bac-45a7-9914-812d3b9c5f44', + version: '8.13.0', + }, + user_provided_metadata: {}, + local_metadata: { + elastic: { + agent: { + 'build.original': '8.0.0-SNAPSHOT (build: slgsg at 2021-05-07 18:42:49 +0000 UTC)', + id: 'cbd4cda1-3bac-45a7-9914-812d3b9c5f44', + log_level: 'info', + snapshot: true, + upgradeable: true, + version: '8.13.0', + }, + }, + host: { + architecture: '3cgyqy4tx4', + hostname: 'Host-60j0gd14nc', + id: 'e76f684a-1f5c-4082-9a21-145d34c2d901', + ip: ['00.00.000.00'], + mac: ['00-00-00-00-00-00', '00-00-00-00-00-00', '0-00-00-00-00-00'], + name: 'Host-60j0gd14nc', + os: { + name: 'Windows', + full: 'Windows Server 2012', + version: '6.2', + platform: 'Windows', + family: 'windows', + Ext: { + variant: 'Windows Server', + }, + }, + }, + os: { + family: 'windows', + full: 'Windows Server 2012', + kernel: '10.0.17763.1879 (Build.160101.0800)', + name: 'Windows', + platform: 'Windows', + version: '6.2', + Ext: { + variant: 'Windows Server', + }, + }, + }, + status: 'online', + }, + { + id: '9918e050-035a-4764-bdd3-5cd536a7201c', + type: 'PERMANENT', + active: true, + enrolled_at: '2024-01-11T03:20:54.483Z', + upgraded_at: null, + upgrade_started_at: null, + access_api_key_id: 'jY3dWnkBj1tiuAw9pAmq', + default_api_key_id: 'so3dWnkBj1tiuAw9yAm3', + policy_id: '004e29f7-3b96-4ce3-8de8-c3024f56eae2', + last_checkin: '2024-01-11T04:00:38.328Z', + last_checkin_status: 'online', + policy_revision: 2, + packages: [], + sort: [1704943254483, 'Host-nh94b0esjr'], + components: [ + { + id: 'endpoint-0', + type: 'endpoint', + status: 'STARTING', + message: 'Running as external service', + units: [ + { + id: 'endpoint-1', + type: 'input', + status: 'STARTING', + message: 'Protecting machine', + payload: { + extra: 'payload', + }, + }, + { + id: 'shipper', + type: 'output', + status: 'STARTING', + message: 'Connected over GRPC', + payload: { + extra: 'payload', + }, + }, + ], + }, + ], + agent: { + id: '9918e050-035a-4764-bdd3-5cd536a7201c', + version: '8.13.0', + }, + user_provided_metadata: {}, + local_metadata: { + elastic: { + agent: { + 'build.original': '8.0.0-SNAPSHOT (build: pd6rl at 2021-05-07 18:42:49 +0000 UTC)', + id: '9918e050-035a-4764-bdd3-5cd536a7201c', + log_level: 'info', + snapshot: true, + upgradeable: true, + version: '8.13.0', + }, + }, + host: { + architecture: 'q5ni746k3b', + hostname: 'Host-nh94b0esjr', + id: 'd036aae1-8a97-4aa6-988c-2e178665272a', + ip: ['00.00.000.00'], + mac: ['00-00-00-00-00-00', '00-00-00-00-00-00', '0-00-00-00-00-00'], + name: 'Host-nh94b0esjr', + os: { + Ext: { + variant: 'Debian', + }, + kernel: '4.19.0-21-cloud-amd64 #1 SMP Debian 4.19.249-2 (2022-06-30)', + name: 'Linux', + family: 'debian', + type: 'linux', + version: '10.12', + platform: 'debian', + full: 'Debian 10.12', + }, + }, + os: { + family: 'debian', + full: 'Debian 10.12', + kernel: '4.19.0-21-cloud-amd64 #1 SMP Debian 4.19.249-2 (2022-06-30)', + name: 'Linux', + platform: 'debian', + version: '10.12', + Ext: { + variant: 'Debian', + }, + type: 'linux', + }, + }, + status: 'online', + }, + ], + total: 5, + page: 1, + perPage: 10000, +}; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/index.ts b/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/index.ts index 97a0aa16a1518..a5d38b430ec92 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/index.ts @@ -16,6 +16,7 @@ import { stubEndpointAlertResponse, stubProcessTree, stubFetchTimelineEvents } f import { stubEndpointMetricsResponse } from './metrics'; import { prebuiltRuleAlertsResponse } from './prebuilt_rule_alerts'; import type { ESClusterInfo, ESLicense } from '../types'; +import { stubFleetAgentResponse } from './fleet_agent_response'; export const createMockTelemetryEventsSender = ( enableTelemetry?: boolean, @@ -81,7 +82,7 @@ export const createMockTelemetryReceiver = ( fetchClusterInfo: jest.fn().mockReturnValue(stubClusterInfo), fetchLicenseInfo: jest.fn().mockReturnValue(stubLicenseInfo), copyLicenseFields: jest.fn(), - fetchFleetAgents: jest.fn(), + fetchFleetAgents: jest.fn().mockReturnValue(stubFleetAgentResponse), openPointInTime: jest.fn().mockReturnValue(Promise.resolve('test-pit-id')), getAlertsIndex: jest.fn().mockReturnValue('alerts-*'), fetchDiagnosticAlertsBatch: jest.fn().mockReturnValue(diagnosticsAlert ?? jest.fn()), diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.test.ts index f0c359b9e0b0a..4cffeccc0db3c 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.test.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.test.ts @@ -56,4 +56,29 @@ describe('endpoint telemetry task test', () => { 1 ); }); + + test('endpoint telemetry task should fetch endpoint data even if fetchPolicyConfigs throws an error', async () => { + const testTaskExecutionPeriod = { + last: new Date().toISOString(), + current: new Date().toISOString(), + }; + const mockTelemetryEventsSender = createMockTelemetryEventsSender(); + mockTelemetryEventsSender.getTelemetryUsageCluster = jest + .fn() + .mockReturnValue(telemetryUsageCounter); + const mockTelemetryReceiver = createMockTelemetryReceiver(); + mockTelemetryReceiver.fetchPolicyConfigs = jest.fn().mockRejectedValueOnce(new Error()); + const telemetryEndpointTaskConfig = createTelemetryEndpointTaskConfig(1); + + await telemetryEndpointTaskConfig.runTask( + 'test-id', + logger, + mockTelemetryReceiver, + mockTelemetryEventsSender, + testTaskExecutionPeriod + ); + + expect(mockTelemetryReceiver.fetchPolicyConfigs).toHaveBeenCalled(); + expect(mockTelemetryEventsSender.sendOnDemand).toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts index 2c5eabe612c09..f221a11204bf4 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts @@ -6,6 +6,7 @@ */ import type { Logger } from '@kbn/core/server'; +import type { AgentPolicy } from '@kbn/fleet-plugin/common'; import { FLEET_ENDPOINT_PACKAGE } from '@kbn/fleet-plugin/common'; import type { ITelemetryEventsSender } from '../sender'; import type { @@ -16,6 +17,7 @@ import type { EndpointMetadataDocument, ESClusterInfo, ESLicense, + Nullable, } from '../types'; import type { ITelemetryReceiver } from '../receiver'; import type { TaskExecutionPeriod } from '../task'; @@ -167,7 +169,12 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) { policyInfo !== undefined && !endpointPolicyCache.has(policyInfo) ) { - const agentPolicy = await receiver.fetchPolicyConfigs(policyInfo); + let agentPolicy: Nullable; + try { + agentPolicy = await receiver.fetchPolicyConfigs(policyInfo); + } catch (err) { + tlog(logger, `error fetching policy config due to ${err?.message}`); + } const packagePolicies = agentPolicy?.package_policies; if (packagePolicies !== undefined && isPackagePolicyList(packagePolicies)) { diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts index 433c1c01cf0bc..cbff7156eaf15 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts @@ -458,6 +458,8 @@ export interface ValueListResponse { indicatorMatchMetricsResponse: ValueListIndicatorMatchResponseAggregation; } +export type Nullable = T | null | undefined; + export interface ExtraInfo { clusterInfo: ESClusterInfo; licenseInfo: ESLicense | undefined; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/delete_timelines/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/delete_timelines/index.ts index c9a515f5566c2..602d29ae061ab 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/delete_timelines/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/delete_timelines/index.ts @@ -42,9 +42,9 @@ export const deleteTimelinesRoute = ( try { const frameworkRequest = await buildFrameworkRequest(context, security, request); - const { savedObjectIds } = request.body; + const { savedObjectIds, searchIds } = request.body; - await deleteTimeline(frameworkRequest, savedObjectIds); + await deleteTimeline(frameworkRequest, savedObjectIds, searchIds); return response.ok({ body: { data: { deleteTimeline: true } } }); } catch (err) { const error = transformError(err); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/saved_search/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/saved_search/index.ts new file mode 100644 index 0000000000000..de90a09248eba --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/saved_search/index.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 { FrameworkRequest } from '../../../framework'; + +export const deleteSearchByTimelineId = async ( + request: FrameworkRequest, + savedSearchIds?: string[] +) => { + if (savedSearchIds !== undefined) { + const savedObjectsClient = (await request.context.core).savedObjects.client; + const objects = savedSearchIds.map((id) => ({ id, type: 'search' })); + + await savedObjectsClient.bulkDelete(objects); + } else { + return Promise.resolve(); + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts index 9cdc9189b16fa..037639464a3e8 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts @@ -38,6 +38,7 @@ import type { SavedObjectTimelineWithoutExternalRefs } from '../../../../../comm import type { FrameworkRequest } from '../../../framework'; import * as note from '../notes/saved_object'; import * as pinnedEvent from '../pinned_events'; +import { deleteSearchByTimelineId } from '../saved_search'; import { convertSavedObjectToSavedTimeline } from './convert_saved_object_to_savedtimeline'; import { pickSavedTimeline } from './pick_saved_timeline'; import { timelineSavedObjectType } from '../../saved_object_mappings'; @@ -572,18 +573,23 @@ export const resetTimeline = async ( return response; }; -export const deleteTimeline = async (request: FrameworkRequest, timelineIds: string[]) => { +export const deleteTimeline = async ( + request: FrameworkRequest, + timelineIds: string[], + searchIds?: string[] +) => { const savedObjectsClient = (await request.context.core).savedObjects.client; - await Promise.all( - timelineIds.map((timelineId) => + await Promise.all([ + ...timelineIds.map((timelineId) => Promise.all([ savedObjectsClient.delete(timelineSavedObjectType, timelineId), note.deleteNoteByTimelineId(request, timelineId), pinnedEvent.deleteAllPinnedEventsOnTimeline(request, timelineId), ]) - ) - ); + ), + deleteSearchByTimelineId(request, searchIds), + ]); }; export const copyTimeline = async ( diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 27bdcfe796e76..ffe4e7a6e342b 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -73,8 +73,8 @@ import type { } from './lib/detection_engine/rule_types/types'; // eslint-disable-next-line no-restricted-imports import { - legacyIsNotificationAlertExecutor, - legacyRulesNotificationAlertType, + isLegacyNotificationRuleExecutor, + legacyRulesNotificationRuleType, } from './lib/detection_engine/rule_actions_legacy'; import { createSecurityRuleTypeWrapper, @@ -354,9 +354,9 @@ export class Plugin implements ISecuritySolutionPlugin { ); if (plugins.alerting != null) { - const ruleNotificationType = legacyRulesNotificationAlertType({ logger }); + const ruleNotificationType = legacyRulesNotificationRuleType({ logger }); - if (legacyIsNotificationAlertExecutor(ruleNotificationType)) { + if (isLegacyNotificationRuleExecutor(ruleNotificationType)) { plugins.alerting.registerType(ruleNotificationType); } } diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 28bccd4ff73a6..9c1597c95ca9e 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -2440,6 +2440,9 @@ "discover.viewAlert.searchSourceErrorTitle": "Erreur lors de la récupération de la source de recherche", "discover.viewModes.document.label": "Documents", "discover.viewModes.fieldStatistics.label": "Statistiques de champ", + "discover.hitsCounter.hitsPluralTitle": "{formattedHits} {hits, plural, one {résultat} many {résultats} other {résultats}}", + "discover.hitsCounter.partialHitsPluralTitle": "≥{formattedHits} {hits, plural, one {résultat} many {résultats} other {résultats}}", + "discover.hitsCounter.hitCountSpinnerAriaLabel": "Nombre final de résultats toujours en chargement", "domDragDrop.announce.cancelled": "Mouvement annulé. {label} revenu à sa position initiale", "domDragDrop.announce.cancelledItem": "Mouvement annulé. {label} revenu au groupe {groupLabel} à la position {position}", "domDragDrop.announce.dropped.combineCompatible": "{label} combiné dans le groupe {groupLabel} en {dropLabel} dans le groupe {dropGroupLabel} à la position {dropPosition} dans le calque {dropLayerNumber}", @@ -5640,9 +5643,6 @@ "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.greatestFunction.markdown": "### GREATEST\nRenvoie la valeur maximale de plusieurs colonnes. Cette fonction est similaire à `MV_MAX`. Toutefois, elle est destinée à être exécutée sur plusieurs colonnes à la fois.\n\n```\nROW a = 10, b = 20\n| EVAL g = GREATEST(a, b);\n```\n\nRemarque : lorsque cette fonction est exécutée sur les champs `keyword` ou `text`, elle renvoie la dernière chaîne dans l'ordre alphabétique. Lorsqu'elle est exécutée sur des colonnes `boolean`, elle renvoie `true` si l'une des valeurs l'est.\n ", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.grok.markdown": "### GROK\n`GROK` vous permet d'extraire des données structurées d'une chaîne. `GROK` compare la chaîne à des modèles,sur la base d’expressions régulières, et extrait les modèles indiqués en tant que colonnes.\n\nPour obtenir la syntaxe des modèles `grok`, consultez [la documentation relative au processeur `grok`](https://www.elastic.co/guide/en/elasticsearch/reference/current/grok-processor.html).\n\n```\nROW a = \"12 15.5 15.6 true\"\n| GROK a \"%\\{NUMBER:b:int\\} %\\{NUMBER:c:float\\} %\\{NUMBER:d:double\\} %\\{WORD:e:boolean\\}\"\n```\n ", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.inOperator.markdown": "### IN\nL'opérateur `IN` permet de tester si un champ ou une expression est égal à un élément d'une liste de littéraux, de champs ou d'expressions :\n\n```\nROW a = 1, b = 4, c = 3\n| WHERE c-a IN (3, b / 2, a)\n```\n ", - "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.isFiniteFunction.markdown": "### IS_FINITE\nRenvoie un booléen qui indique si son entrée est un nombre fini.\n\n```\nROW d = 1.0 \n| EVAL s = IS_FINITE(d/0)\n```\n ", - "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.isInfiniteFunction.markdown": "### IS_INFINITE\nRenvoie un booléen qui indique si son entrée est un nombre infini.\n\n```\nROW d = 1.0 \n| EVAL s = IS_INFINITE(d/0)\n```\n ", - "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.isNanFunction.markdown": "### IS_NAN\nRenvoie un booléen qui indique si son entrée n'est pas un nombre.\n\n```\nROW d = 1.0 \n| EVAL s = IS_NAN(d)\n```\n ", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.keep.markdown": "### KEEP\nLa commande `KEEP` permet de définir les colonnes qui seront renvoyées et l'ordre dans lequel elles le seront.\n\nPour limiter les colonnes retournées, utilisez une liste de noms de colonnes séparés par des virgules. Les colonnes sont renvoyées dans l'ordre indiqué :\n \n```\nFROM employees\n| KEEP first_name, last_name, height\n```\n\nPlutôt que de spécifier chaque colonne par son nom, vous pouvez utiliser des caractères génériques pour renvoyer toutes les colonnes dont le nom correspond à un modèle :\n\n```\nFROM employees\n| KEEP h*\n```\n\nLe caractère générique de l'astérisque (`*`) placé de manière isolée transpose l'ensemble des colonnes qui ne correspondent pas aux autres arguments. La requête suivante renverra en premier lieu toutes les colonnes dont le nom commence par un h, puis toutes les autres colonnes :\n\n```\nFROM employees\n| KEEP h*, *\n```\n ", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.leastFunction.markdown": "### LEAST\nRenvoie la valeur minimale de plusieurs colonnes. Cette fonction est similaire à `MV_MIN`. Toutefois, elle est destinée à être exécutée sur plusieurs colonnes à la fois.\n\n```\nROW a = 10, b = 20\n| EVAL l = LEAST(a, b)\n```\n\nRemarque : lorsque cette fonction est exécutée sur les champs `keyword` ou `text`, elle renvoie la première chaîne dans l'ordre alphabétique. Lorsqu'elle est exécutée sur des colonnes `boolean`, elle renvoie `false` si l'une des valeurs l'est.\n ", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.leftFunction.markdown": "### LEFT\nRenvoie la sous-chaîne qui extrait la longueur des caractères de la chaîne en partant de la gauche.\n\n```\nFROM employees\n| KEEP last_name\n| EVAL left = LEFT(last_name, 3)\n| SORT last_name ASC\n| LIMIT 5\n```\n ", @@ -5776,9 +5776,6 @@ "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.greatestFunction": "GREATEST", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.grok": "GROK", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.inOperator": "IN", - "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.isFiniteFunction": "IS_FINITE", - "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.isInfiniteFunction": "IS_INFINITE", - "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.isNanFunction": "IS_NAN", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.keep": "KEEP", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.leastFunction": "LEAST", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.leftFunction": "LEFT", @@ -6184,7 +6181,6 @@ "unifiedFieldList.fieldListSidebar.fieldsMobileButtonLabel": "Champs", "unifiedFieldList.fieldListSidebar.flyoutBackIcon": "Retour", "unifiedFieldList.fieldListSidebar.flyoutHeading": "Liste des champs", - "unifiedFieldList.fieldListSidebar.indexAndFieldsSectionAriaLabel": "Index et champs", "unifiedFieldList.fieldListSidebar.toggleSidebarLegend": "Activer/Désactiver la barre latérale", "unifiedFieldList.fieldNameSearch.filterByNameLabel": "Rechercher les noms de champs", "unifiedFieldList.fieldPopover.addExistsFilterLabel": "Filtrer sur le champ", @@ -6229,31 +6225,18 @@ "unifiedHistogram.breakdownColumnLabel": "Top 3 des valeurs de {fieldName}", "unifiedHistogram.bucketIntervalTooltip": "Cet intervalle crée {bucketsDescription} pour un affichage dans la plage temporelle sélectionnée. Il a donc été scalé à {bucketIntervalDescription}.", "unifiedHistogram.histogramTimeRangeIntervalDescription": "(intervalle : {value})", - "unifiedHistogram.hitsPluralTitle": "{formattedHits} {hits, plural, one {résultat} many {résultats} other {résultats}}", - "unifiedHistogram.partialHits": "≥{formattedHits} {hits, plural, one {résultat} many {résultats} other {résultats}}", - "unifiedHistogram.timeIntervalWithValue": "Intervalle de temps : {timeInterval}", - "unifiedHistogram.breakdownFieldSelectorAriaLabel": "Répartir par", - "unifiedHistogram.breakdownFieldSelectorLabel": "Répartir par", - "unifiedHistogram.breakdownFieldSelectorPlaceholder": "Sélectionner un champ", "unifiedHistogram.bucketIntervalTooltip.tooLargeBucketsText": "des compartiments trop volumineux", "unifiedHistogram.bucketIntervalTooltip.tooManyBucketsText": "un trop grand nombre de compartiments", - "unifiedHistogram.chartOptions": "Options de graphique", - "unifiedHistogram.chartOptionsButton": "Options de graphique", "unifiedHistogram.countColumnLabel": "Nombre d'enregistrements", "unifiedHistogram.editVisualizationButton": "Modifier la visualisation", - "unifiedHistogram.hideChart": "Masquer le graphique", "unifiedHistogram.histogramOfFoundDocumentsAriaLabel": "Histogramme des documents détectés", "unifiedHistogram.histogramTimeRangeIntervalAuto": "Auto", "unifiedHistogram.histogramTimeRangeIntervalLoading": "Chargement", - "unifiedHistogram.hitCountSpinnerAriaLabel": "Nombre final de résultats toujours en chargement", "unifiedHistogram.inspectorRequestDataTitleTotalHits": "Nombre total de résultats", "unifiedHistogram.inspectorRequestDescriptionTotalHits": "Cette requête interroge Elasticsearch afin de récupérer le nombre total de résultats.", "unifiedHistogram.lensTitle": "Modifier la visualisation", - "unifiedHistogram.resetChartHeight": "Réinitialiser à la hauteur par défaut", "unifiedHistogram.saveVisualizationButton": "Enregistrer la visualisation", - "unifiedHistogram.showChart": "Afficher le graphique", "unifiedHistogram.suggestionSelectorPlaceholder": "Sélectionner la visualisation", - "unifiedHistogram.timeIntervals": "Intervalles de temps", "unifiedHistogram.timeIntervalWithValueWarning": "Avertissement", "unifiedSearch.filter.filterBar.filterActionsMessage": "Filtrer : {innerText}. Sélectionner pour plus d’actions de filtrage.", "unifiedSearch.filter.filterBar.filterItemBadgeIconAriaLabel": "Supprimer {filter}", @@ -11918,13 +11901,7 @@ "xpack.csp.cloudPosturePage.kspmIntegration.packageNotInstalled.description": "Utilisez notre intégration {integrationFullName} (KSPM) pour détecter les erreurs de configuration de sécurité dans vos clusters Kubernetes.", "xpack.csp.complianceScoreBar.tooltipTitle": "{failed} résultats en échec et {passed} ayant réussi", "xpack.csp.eksIntegration.docsLink": "Lisez {docs} pour en savoir plus", - "xpack.csp.findings..bottomBarLabel": "Voici les {maxItems} premiers résultats correspondant à votre recherche. Veuillez l'affiner pour en voir davantage.", "xpack.csp.findings.distributionBar.showingPageOfTotalLabel": "Affichage de {pageStart}-{pageEnd} sur {total} {type}", - "xpack.csp.findings.findingsTableCell.addFilterButton": "Ajouter un filtre {field}", - "xpack.csp.findings.findingsTableCell.addFilterButtonTooltip": "Ajouter un filtre {field}", - "xpack.csp.findings.findingsTableCell.addNegatedFilterButtonTooltip": "Ajouter un filtre {field} négatif", - "xpack.csp.findings.findingsTableCell.addNegateFilterButton": "Ajouter un filtre {field} négatif", - "xpack.csp.findings.resourceFindings.resourceFindingsPageTitle": "{resourceName} {hyphen} Résultats", "xpack.csp.findingsFlyout.alerts.alertCount": "{alertCount, plural, one {# alerte} many {# alertes} other {Alertes #}}", "xpack.csp.findingsFlyout.alerts.detectionRuleCount": "{ruleCount, plural, one {# règle de détection} many {# règles de détection} other {# règles de détection}}", "xpack.csp.noFindingsStates.indexTimeout.indexTimeoutDescription": "La collecte des résultats prend plus de temps que prévu. {docs}.", @@ -11932,15 +11909,7 @@ "xpack.csp.rules.rulesTable.showingPageOfTotalLabel": "Affichage de {pageSize} sur {total, plural, one {# règle} many {# règles bien mises} other {# règles}}", "xpack.csp.subscriptionNotAllowed.promptDescription": "Pour utiliser ces fonctionnalités de sécurité du cloud, vous devez {link}.", "xpack.csp.vulnerabilities.detectionRuleNamePrefix": "Vulnérabilité : {vulnerabilityId}", - "xpack.csp.vulnerabilities.resourceVulnerabilities.vulnerabilitiesPageTitle": "{resourceName} {hyphen} vulnérabilités", - "xpack.csp.vulnerabilities.totalVulnerabilities": "{total, plural, one {# vulnérabilité} many {# vulnérabilités} other {# vulnérabilités}}", - "xpack.csp.vulnerabilities.vulnerabilitiesTableCell.addFilterButton": "Ajouter un filtre {columnId}", - "xpack.csp.vulnerabilities.vulnerabilitiesTableCell.addFilterButtonTooltip": "Ajouter un filtre {columnId}", - "xpack.csp.vulnerabilities.vulnerabilitiesTableCell.addNegatedFilterButtonTooltip": "Ajouter un filtre {columnId} négatif", - "xpack.csp.vulnerabilities.vulnerabilitiesTableCell.addNegateFilterButton": "Ajouter un filtre {columnId} négatif", "xpack.csp.vulnerabilities.vulnerabilityOverviewTile.publishedDateText": "{date}", - "xpack.csp.vulnerabilitiesByResource.totalResources": "{total, plural, one {# ressource} many {# ressources} other {# ressources}}", - "xpack.csp.vulnerabilitiesByResource.totalVulnerabilities": "{total, plural, one {# vulnérabilité} many {# vulnérabilités} other {# vulnérabilités}}", "xpack.csp.awsIntegration.accessKeyIdLabel": "ID de clé d'accès", "xpack.csp.awsIntegration.assumeRoleDescription": "Un nom ARN (Amazon Resource Name) de rôle IAM est une identité IAM que vous pouvez créer dans votre compte AWS. Lors de la création d'un rôle IAM, les utilisateurs peuvent définir les autorisations accordées au rôle. Les rôles n'ont pas d'informations d'identification à long terme standard telles que des mots de passe ou des clés d'accès.", "xpack.csp.awsIntegration.assumeRoleLabel": "Assumer un rôle", @@ -12075,15 +12044,10 @@ "xpack.csp.emptyState.readDocsLink": "Lisez les documents", "xpack.csp.emptyState.resetFiltersButton": "Réinitialiser les filtres", "xpack.csp.emptyState.title": "Aucun résultat ne correspond à vos critères de recherche.", - "xpack.csp.expandColumnDescriptionLabel": "Développer", - "xpack.csp.expandColumnNameLabel": "Développer", "xpack.csp.findings.distributionBar.totalFailedLabel": "Échec des résultats", "xpack.csp.findings.distributionBar.totalPassedLabel": "Réussite des résultats", "xpack.csp.findings.errorCallout.pageSearchErrorTitle": "Une erreur s’est produite lors de la récupération des résultats de recherche.", "xpack.csp.findings.errorCallout.showErrorButtonLabel": "Afficher le message d'erreur", - "xpack.csp.findings.findingsByResource.tableRowTypeLabel": "Ressources", - "xpack.csp.findings.findingsByResourceTable.cisSectionsColumnLabel": "Sections CIS", - "xpack.csp.findings.findingsByResourceTable.postureScoreColumnLabel": "Score du niveau", "xpack.csp.findings.findingsErrorToast.searchFailedTitle": "Échec de la recherche", "xpack.csp.findings.findingsFlyout.jsonTabTitle": "JSON", "xpack.csp.findings.findingsFlyout.overviewTab.alertsTitle": "Alertes", @@ -12115,16 +12079,11 @@ "xpack.csp.findings.findingsFlyout.ruleTab.tagsTitle": "Balises", "xpack.csp.findings.findingsFlyout.ruleTabTitle": "Règle", "xpack.csp.findings.findingsFlyout.tableTabTitle": "Tableau", - "xpack.csp.findings.findingsTable.findingsTableColumn.clusterIdColumnLabel": "Appartient à", - "xpack.csp.findings.findingsTable.findingsTableColumn.clusterIdColumnTooltipLabel": "ID de cluster Kubernetes ou nom de compte cloud", "xpack.csp.findings.findingsTable.findingsTableColumn.lastCheckedColumnLabel": "Dernière vérification", "xpack.csp.findings.findingsTable.findingsTableColumn.resourceIdColumnLabel": "ID ressource", - "xpack.csp.findings.findingsTable.findingsTableColumn.resourceIdColumnTooltipLabel": "ID ressource Elastic personnalisée", "xpack.csp.findings.findingsTable.findingsTableColumn.resourceNameColumnLabel": "Nom de ressource", "xpack.csp.findings.findingsTable.findingsTableColumn.resourceTypeColumnLabel": "Type de ressource", "xpack.csp.findings.findingsTable.findingsTableColumn.resultColumnLabel": "Résultat", - "xpack.csp.findings.findingsTable.findingsTableColumn.ruleBenchmarkColumnLabel": "Benchmark applicable", - "xpack.csp.findings.findingsTable.findingsTableColumn.ruleBenchmarkColumnTooltipLabel": "Le benchmark utilisé pour évaluer cette ressource", "xpack.csp.findings.findingsTable.findingsTableColumn.ruleNameColumnLabel": "Nom de règle", "xpack.csp.findings.findingsTable.findingsTableColumn.ruleNumberColumnLabel": "Numéro de règle", "xpack.csp.findings.findingsTable.findingsTableColumn.ruleSectionColumnLabel": "Section CIS", @@ -12135,12 +12094,6 @@ "xpack.csp.findings.groupBySelector.groupByNoneLabel": "Aucun", "xpack.csp.findings.groupBySelector.groupByResourceIdLabel": "Ressource", "xpack.csp.findings.latestFindings.tableRowTypeLabel": "Résultats", - "xpack.csp.findings.resourceFindings.backToResourcesPageButtonLabel": "Retour aux ressources", - "xpack.csp.findings.resourceFindings.tableRowTypeLabel": "Résultats", - "xpack.csp.findings.resourceFindingsSharedValues.cloudAccountName": "Nom du compte cloud", - "xpack.csp.findings.resourceFindingsSharedValues.clusterIdTitle": "ID cluster", - "xpack.csp.findings.resourceFindingsSharedValues.resourceIdTitle": "ID ressource", - "xpack.csp.findings.resourceFindingsSharedValues.resourceTypeTitle": "Type de ressource", "xpack.csp.findings.search.queryErrorToastMessage": "Erreur de requête", "xpack.csp.findings.searchBar.searchPlaceholder": "Rechercher dans les résultats (par ex. rule.section : \"serveur d'API\")", "xpack.csp.findings.tabs.misconfigurations": "Configurations incorrectes", @@ -12255,9 +12208,6 @@ "xpack.csp.vulnerabilities": "Vulnérabilités", "xpack.csp.vulnerabilities.flyoutTabs.fieldLabel": "Champ", "xpack.csp.vulnerabilities.flyoutTabs.fieldValueLabel": "Valeur", - "xpack.csp.vulnerabilities.resourceVulnerabilities.backToResourcesPageButtonLabel": "Retour aux ressources", - "xpack.csp.vulnerabilities.resourceVulnerabilities.regionTitle": "Région", - "xpack.csp.vulnerabilities.resourceVulnerabilities.resourceIdTitle": "ID ressource", "xpack.csp.vulnerabilities.searchBar.placeholder": "Rechercher des vulnérabilités (par exemple vulnerability.severity : \"CRITICAL\" )", "xpack.csp.vulnerabilities.table.filterIn": "Inclure", "xpack.csp.vulnerabilities.table.filterOut": "Exclure", @@ -12280,24 +12230,12 @@ "xpack.csp.vulnerabilities.vulnerabilityOverviewTile.publishedDate": "Date de publication", "xpack.csp.vulnerabilitiesByResource.severityMap.tooltipTitle": "Carte des degrés de gravité", "xpack.csp.vulnerability_dashboard.cspPageTemplate.pageTitle": "Gestion des vulnérabilités natives du cloud", - "xpack.csp.vulnerabilityByResourceTable.column.region": "Région", - "xpack.csp.vulnerabilityByResourceTable.column.resourceId": "ID ressource", - "xpack.csp.vulnerabilityByResourceTable.column.resourceName": "Nom de ressource", - "xpack.csp.vulnerabilityByResourceTable.column.severityMap": "Carte des degrés de gravité", - "xpack.csp.vulnerabilityByResourceTable.column.vulnerabilities": "Vulnérabilités", "xpack.csp.vulnerabilityDashboard.trendGraphChart.accountsDropDown.option.allTitle": "Tous", "xpack.csp.vulnerabilityDashboard.trendGraphChart.accountsDropDown.prepend.accountsTitle": "Comptes", "xpack.csp.vulnerabilityDashboard.trendGraphChart.trendBySeverityTitle": "Tendance par degré de gravité", "xpack.csp.vulnerabilityDashboard.viewAllButton.buttonTitle": "Tout afficher", - "xpack.csp.vulnerabilityTable.column.fixVersion": "Version du correctif", - "xpack.csp.vulnerabilityTable.column.package": "Pack", - "xpack.csp.vulnerabilityTable.column.resourceId": "ID ressource", - "xpack.csp.vulnerabilityTable.column.resourceName": "Nom de ressource", - "xpack.csp.vulnerabilityTable.column.severity": "Sévérité", "xpack.csp.vulnerabilityTable.column.sortAscending": "Basse -> Critique", "xpack.csp.vulnerabilityTable.column.sortDescending": "Critique -> Basse", - "xpack.csp.vulnerabilityTable.column.version": "Version", - "xpack.csp.vulnerabilityTable.column.vulnerability": "Vulnérabilité", "xpack.csp.vulnerabilityTable.panel.buttonText": "Afficher toutes les vulnérabilités", "xpack.csp.vulnMgmtIntegration.awsOption.nameTitle": "Amazon Web Services", "xpack.csp.vulnMgmtIntegration.azureOption.nameTitle": "Azure", @@ -30885,11 +30823,7 @@ "xpack.security.accountManagement.apiKeyFlyout.createTitle": "Créer une clé d'API", "xpack.security.accountManagement.apiKeyFlyout.crossClusterTypeDescription": "Autorise les clusters distants à se connecter à votre cluster local.", "xpack.security.accountManagement.apiKeyFlyout.crossClusterTypeLabel": "Clé d'API inter-clusters", - "xpack.security.accountManagement.apiKeyFlyout.customExpirationInputLabel": "Durée de vie", - "xpack.security.accountManagement.apiKeyFlyout.customExpirationLabel": "Délai d'expiration", - "xpack.security.accountManagement.apiKeyFlyout.customPrivilegesLabel": "Limiter les privilèges", "xpack.security.accountManagement.apiKeyFlyout.expirationUnit": "jours", - "xpack.security.accountManagement.apiKeyFlyout.includeMetadataLabel": "Inclure les métadonnées", "xpack.security.accountManagement.apiKeyFlyout.metadataHelpText": "Découvrez comment structurer les métadonnées.", "xpack.security.accountManagement.apiKeyFlyout.nameLabel": "Nom", "xpack.security.accountManagement.apiKeyFlyout.ownerLabel": "Propriétaire", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a0e2e78f91975..666069cd7d041 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2454,6 +2454,9 @@ "discover.viewAlert.searchSourceErrorTitle": "検索ソースの取得エラー", "discover.viewModes.document.label": "ドキュメント", "discover.viewModes.fieldStatistics.label": "フィールド統計情報", + "discover.hitsCounter.hitsPluralTitle": "{formattedHits} {hits, plural, other {ヒット}}", + "discover.hitsCounter.partialHitsPluralTitle": "≥{formattedHits}{hits, plural, other {ヒット}}", + "discover.hitsCounter.hitCountSpinnerAriaLabel": "読み込み中の最終一致件数", "domDragDrop.announce.cancelled": "移動がキャンセルされました。{label}は初期位置に戻りました", "domDragDrop.announce.cancelledItem": "移動がキャンセルされました。{label}は位置{position}の{groupLabel}グループに戻りました", "domDragDrop.announce.dropped.combineCompatible": "レイヤー{dropLayerNumber}の位置{dropPosition}でグループ{dropGroupLabel}の{dropLabel}にグループ{groupLabel}の{label}を結合しました。", @@ -5655,9 +5658,6 @@ "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.greatestFunction.markdown": "### GREATEST\n多数の列から最大値を返します。これはMV_MAXと似ていますが、一度に複数の列に対して実行します。\n\n```\nROW a = 10, b = 20\n| EVAL g = GREATEST(a, b);\n```\n\n注:keywordまたはtextフィールドに対して実行すると、アルファベット順の最後の文字列を返します。boolean列に対して実行すると、値がtrueの場合にtrueを返します。\n ", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.grok.markdown": "### GROK\nGROKを使うと、文字列から構造化データを抽出できます。GROKは正規表現に基づいて文字列をパターンと一致させ、指定されたパターンを列として抽出します。\n\ngrokパターンの構文については、 [grokプロセッサードキュメント](https://www.elastic.co/guide/en/elasticsearch/reference/current/grok-processor.html)を参照してください。\n\n```\nROW a = \"12 15.5 15.6 true\"\n| GROK a \"%\\{NUMBER:b:int\\} %\\{NUMBER:c:float\\} %\\{NUMBER:d:double\\} %\\{WORD:e:boolean\\}\"\n```\n ", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.inOperator.markdown": "### IN\nIN演算子は、フィールドや式がリテラル、フィールド、式のリストの要素と等しいかどうかをテストすることができます。\n\n```\nROW a = 1, b = 4, c = 3\n| WHERE c-a IN (3, b / 2, a)\n```\n ", - "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.isFiniteFunction.markdown": "### IS_FINITE\n入力が有限数であるかどうかを示すブール値を返します。\n\n```\nROW d = 1.0 \n| EVAL s = IS_FINITE(d/0)\n```\n ", - "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.isInfiniteFunction.markdown": "### IS_INFINITE\n入力が無限数であるかどうかを示すブール値を返します。\n\n```\nROW d = 1.0 \n| EVAL s = IS_INFINITE(d/0)\n```\n ", - "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.isNanFunction.markdown": "### IS_NAN\n入力が数値ではないかどうかを示すブール値を返します。\n\n```\nROW d = 1.0 \n| EVAL s = IS_NAN(d)\n```\n ", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.keep.markdown": "### KEEP\nKEEPコマンドは、返される列と、列が返される順序を指定することができます。\n\n返される列を制限するには、カンマで区切りの列名リストを使用します。列は指定された順序で返されます。\n \n```\nFROM employees\n| KEEP first_name, last_name, height\n```\n\n各列を名前で指定するのではなく、ワイルドカードを使って、パターンと一致する名前の列をすべて返すことができます。\n\n```\nFROM employees\n| KEEP h*\n```\n\nアスタリスクワイルドカード(*)は単独で、他の引数と一致しないすべての列に変換されます。このクエリは、最初にhで始まる名前の列をすべて返し、その後にその他の列をすべて返します。\n\n```\nFROM employees\n| KEEP h*, *\n```\n ", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.leastFunction.markdown": "### LEAST\n多数の列から最小値を返します。これはMV_MINと似ていますが、一度に複数の列に対して実行します。\n\n```\nROW a = 10, b = 20\n| EVAL l = LEAST(a, b)\n```\n\n注:keywordまたはtextフィールドに対して実行すると、アルファベット順の最初の文字列を返します。boolean列に対して実行すると、値がfalseの場合にfalseを返します。\n ", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.leftFunction.markdown": "### LEFT\nstringから左から順にlength文字を抜き出したサブ文字列を返します。\n\n```\nFROM employees\n| KEEP last_name\n| EVAL left = LEFT(last_name, 3)\n| SORT last_name ASC\n| LIMIT 5\n```\n ", @@ -5791,9 +5791,6 @@ "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.greatestFunction": "GREATEST", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.grok": "GROK", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.inOperator": "IN", - "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.isFiniteFunction": "IS_FINITE", - "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.isInfiniteFunction": "IS_INFINITE", - "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.isNanFunction": "IS_NAN", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.keep": "KEEP", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.leastFunction": "LEAST", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.leftFunction": "LEFT", @@ -6199,7 +6196,6 @@ "unifiedFieldList.fieldListSidebar.fieldsMobileButtonLabel": "フィールド", "unifiedFieldList.fieldListSidebar.flyoutBackIcon": "戻る", "unifiedFieldList.fieldListSidebar.flyoutHeading": "フィールドリスト", - "unifiedFieldList.fieldListSidebar.indexAndFieldsSectionAriaLabel": "インデックスとフィールド", "unifiedFieldList.fieldListSidebar.toggleSidebarLegend": "サイドバーを切り替える", "unifiedFieldList.fieldNameSearch.filterByNameLabel": "検索フィールド名", "unifiedFieldList.fieldPopover.addExistsFilterLabel": "フィールド表示のフィルター", @@ -6244,31 +6240,18 @@ "unifiedHistogram.breakdownColumnLabel": "{fieldName}のトップ3の値", "unifiedHistogram.bucketIntervalTooltip": "この間隔は選択された時間範囲に表示される{bucketsDescription}を作成するため、{bucketIntervalDescription}にスケーリングされています。", "unifiedHistogram.histogramTimeRangeIntervalDescription": "(間隔:{value})", - "unifiedHistogram.hitsPluralTitle": "{formattedHits} {hits, plural, other {ヒット}}", - "unifiedHistogram.partialHits": "≥{formattedHits}{hits, plural, other {ヒット}}", - "unifiedHistogram.timeIntervalWithValue": "時間間隔:{timeInterval}", - "unifiedHistogram.breakdownFieldSelectorAriaLabel": "内訳の基準", - "unifiedHistogram.breakdownFieldSelectorLabel": "内訳の基準", - "unifiedHistogram.breakdownFieldSelectorPlaceholder": "フィールドを選択", "unifiedHistogram.bucketIntervalTooltip.tooLargeBucketsText": "大きすぎるバケット", "unifiedHistogram.bucketIntervalTooltip.tooManyBucketsText": "バケットが多すぎます", - "unifiedHistogram.chartOptions": "グラフオプション", - "unifiedHistogram.chartOptionsButton": "グラフオプション", "unifiedHistogram.countColumnLabel": "レコード数", "unifiedHistogram.editVisualizationButton": "ビジュアライゼーションを編集", - "unifiedHistogram.hideChart": "グラフを非表示", "unifiedHistogram.histogramOfFoundDocumentsAriaLabel": "検出されたドキュメントのヒストグラム", "unifiedHistogram.histogramTimeRangeIntervalAuto": "自動", "unifiedHistogram.histogramTimeRangeIntervalLoading": "読み込み中", - "unifiedHistogram.hitCountSpinnerAriaLabel": "読み込み中の最終一致件数", "unifiedHistogram.inspectorRequestDataTitleTotalHits": "総ヒット数", "unifiedHistogram.inspectorRequestDescriptionTotalHits": "このリクエストはElasticsearchにクエリをかけ、合計一致数を取得します。", "unifiedHistogram.lensTitle": "ビジュアライゼーションを編集", - "unifiedHistogram.resetChartHeight": "デフォルトの高さにリセット", "unifiedHistogram.saveVisualizationButton": "ビジュアライゼーションを保存", - "unifiedHistogram.showChart": "グラフを表示", "unifiedHistogram.suggestionSelectorPlaceholder": "ビジュアライゼーションを選択", - "unifiedHistogram.timeIntervals": "時間間隔", "unifiedHistogram.timeIntervalWithValueWarning": "警告", "unifiedSearch.filter.filterBar.filterActionsMessage": "フィルター:{innerText}。他のフィルターアクションを使用するには選択してください。", "unifiedSearch.filter.filterBar.filterItemBadgeIconAriaLabel": "{filter}削除", @@ -11932,13 +11915,7 @@ "xpack.csp.cloudPosturePage.kspmIntegration.packageNotInstalled.description": "{integrationFullName}(CSPM)統合を使用して、Kubernetesクラスターの構成エラーを検出します。", "xpack.csp.complianceScoreBar.tooltipTitle": "{failed}が失敗し、{passed}が調査結果に合格しました", "xpack.csp.eksIntegration.docsLink": "詳細は{docs}をご覧ください", - "xpack.csp.findings..bottomBarLabel": "これらは検索条件に一致した初めの{maxItems}件の調査結果です。他の結果を表示するには検索条件を絞ってください。", "xpack.csp.findings.distributionBar.showingPageOfTotalLabel": "{total} {type}ページ中{pageStart}-{pageEnd}ページを表示中", - "xpack.csp.findings.findingsTableCell.addFilterButton": "{field}フィルターを追加", - "xpack.csp.findings.findingsTableCell.addFilterButtonTooltip": "{field}フィルターを追加", - "xpack.csp.findings.findingsTableCell.addNegatedFilterButtonTooltip": "{field}否定フィルターを追加", - "xpack.csp.findings.findingsTableCell.addNegateFilterButton": "{field}否定フィルターを追加", - "xpack.csp.findings.resourceFindings.resourceFindingsPageTitle": "{resourceName} {hyphen}調査結果", "xpack.csp.findingsFlyout.alerts.alertCount": "{alertCount, plural, other {#件のアラート}}", "xpack.csp.findingsFlyout.alerts.detectionRuleCount": "{ruleCount, plural, other {#検出ルール}}", "xpack.csp.noFindingsStates.indexTimeout.indexTimeoutDescription": "調査結果の収集に想定よりも時間がかかっています。{docs}。", @@ -11946,15 +11923,7 @@ "xpack.csp.rules.rulesTable.showingPageOfTotalLabel": "{total, plural, other {#個のルール}} 件中{pageSize}を表示中", "xpack.csp.subscriptionNotAllowed.promptDescription": "これらのクラウドセキュリティ機能を使用するには、{link}する必要があります。", "xpack.csp.vulnerabilities.detectionRuleNamePrefix": "脆弱性:{vulnerabilityId}", - "xpack.csp.vulnerabilities.resourceVulnerabilities.vulnerabilitiesPageTitle": "{resourceName} {hyphen} 脆弱性", - "xpack.csp.vulnerabilities.totalVulnerabilities": "{total, plural, other {#件の脆弱性}}", - "xpack.csp.vulnerabilities.vulnerabilitiesTableCell.addFilterButton": "{columnId}フィルターを追加", - "xpack.csp.vulnerabilities.vulnerabilitiesTableCell.addFilterButtonTooltip": "{columnId}フィルターを追加", - "xpack.csp.vulnerabilities.vulnerabilitiesTableCell.addNegatedFilterButtonTooltip": "{columnId}否定フィルターを追加", - "xpack.csp.vulnerabilities.vulnerabilitiesTableCell.addNegateFilterButton": "{columnId}否定フィルターを追加", "xpack.csp.vulnerabilities.vulnerabilityOverviewTile.publishedDateText": "{date}", - "xpack.csp.vulnerabilitiesByResource.totalResources": "{total, plural, other {#個のリソース}}", - "xpack.csp.vulnerabilitiesByResource.totalVulnerabilities": "{total, plural, other {#件の脆弱性}}", "xpack.csp.awsIntegration.accessKeyIdLabel": "アクセスキーID", "xpack.csp.awsIntegration.assumeRoleDescription": "IAMロールAmazon Resource Name(ARN)は、AWSアカウントで作成できるIAM IDです。IAMロールを作成するときには、ユーザーはロールの権限を定義できます。ロールには、パスワードやアクセスキーなどの標準の長期的な資格情報がありません。", "xpack.csp.awsIntegration.assumeRoleLabel": "ロールを想定", @@ -12089,15 +12058,10 @@ "xpack.csp.emptyState.readDocsLink": "ドキュメントを読む", "xpack.csp.emptyState.resetFiltersButton": "フィルターをリセット", "xpack.csp.emptyState.title": "検索条件と一致する結果がありません。", - "xpack.csp.expandColumnDescriptionLabel": "拡張", - "xpack.csp.expandColumnNameLabel": "拡張", "xpack.csp.findings.distributionBar.totalFailedLabel": "失敗した調査結果", "xpack.csp.findings.distributionBar.totalPassedLabel": "合格した調査結果", "xpack.csp.findings.errorCallout.pageSearchErrorTitle": "検索結果の取得中にエラーが発生しました", "xpack.csp.findings.errorCallout.showErrorButtonLabel": "エラーメッセージを表示", - "xpack.csp.findings.findingsByResource.tableRowTypeLabel": "リソース", - "xpack.csp.findings.findingsByResourceTable.cisSectionsColumnLabel": "CISセクション", - "xpack.csp.findings.findingsByResourceTable.postureScoreColumnLabel": "態勢スコア", "xpack.csp.findings.findingsErrorToast.searchFailedTitle": "検索失敗", "xpack.csp.findings.findingsFlyout.jsonTabTitle": "JSON", "xpack.csp.findings.findingsFlyout.overviewTab.alertsTitle": "アラート", @@ -12129,16 +12093,11 @@ "xpack.csp.findings.findingsFlyout.ruleTab.tagsTitle": "タグ", "xpack.csp.findings.findingsFlyout.ruleTabTitle": "ルール", "xpack.csp.findings.findingsFlyout.tableTabTitle": "表", - "xpack.csp.findings.findingsTable.findingsTableColumn.clusterIdColumnLabel": "属します", - "xpack.csp.findings.findingsTable.findingsTableColumn.clusterIdColumnTooltipLabel": "KubernetesクラスターIDまたはクラウドアカウント名", "xpack.csp.findings.findingsTable.findingsTableColumn.lastCheckedColumnLabel": "最終確認", "xpack.csp.findings.findingsTable.findingsTableColumn.resourceIdColumnLabel": "リソースID", - "xpack.csp.findings.findingsTable.findingsTableColumn.resourceIdColumnTooltipLabel": "カスタムElasticリソースID", "xpack.csp.findings.findingsTable.findingsTableColumn.resourceNameColumnLabel": "リソース名", "xpack.csp.findings.findingsTable.findingsTableColumn.resourceTypeColumnLabel": "リソースタイプ", "xpack.csp.findings.findingsTable.findingsTableColumn.resultColumnLabel": "結果", - "xpack.csp.findings.findingsTable.findingsTableColumn.ruleBenchmarkColumnLabel": "適用されるベンチマーク", - "xpack.csp.findings.findingsTable.findingsTableColumn.ruleBenchmarkColumnTooltipLabel": "このリソースの評価に使用されるベンチマーク", "xpack.csp.findings.findingsTable.findingsTableColumn.ruleNameColumnLabel": "ルール名", "xpack.csp.findings.findingsTable.findingsTableColumn.ruleNumberColumnLabel": "ルール番号", "xpack.csp.findings.findingsTable.findingsTableColumn.ruleSectionColumnLabel": "CISセクション", @@ -12149,12 +12108,6 @@ "xpack.csp.findings.groupBySelector.groupByNoneLabel": "なし", "xpack.csp.findings.groupBySelector.groupByResourceIdLabel": "リソース", "xpack.csp.findings.latestFindings.tableRowTypeLabel": "調査結果", - "xpack.csp.findings.resourceFindings.backToResourcesPageButtonLabel": "リソースに戻る", - "xpack.csp.findings.resourceFindings.tableRowTypeLabel": "調査結果", - "xpack.csp.findings.resourceFindingsSharedValues.cloudAccountName": "クラウドアカウント名", - "xpack.csp.findings.resourceFindingsSharedValues.clusterIdTitle": "クラスターID", - "xpack.csp.findings.resourceFindingsSharedValues.resourceIdTitle": "リソースID", - "xpack.csp.findings.resourceFindingsSharedValues.resourceTypeTitle": "リソースタイプ", "xpack.csp.findings.search.queryErrorToastMessage": "クエリエラー", "xpack.csp.findings.searchBar.searchPlaceholder": "検索結果(例:rule.section:\"API Server\")", "xpack.csp.findings.tabs.misconfigurations": "構成エラー", @@ -12269,9 +12222,6 @@ "xpack.csp.vulnerabilities": "脆弱性", "xpack.csp.vulnerabilities.flyoutTabs.fieldLabel": "フィールド", "xpack.csp.vulnerabilities.flyoutTabs.fieldValueLabel": "値", - "xpack.csp.vulnerabilities.resourceVulnerabilities.backToResourcesPageButtonLabel": "リソースに戻る", - "xpack.csp.vulnerabilities.resourceVulnerabilities.regionTitle": "地域", - "xpack.csp.vulnerabilities.resourceVulnerabilities.resourceIdTitle": "リソースID", "xpack.csp.vulnerabilities.searchBar.placeholder": "脆弱性を検索(例:vulnerability.severity :\"CRITICAL\")", "xpack.csp.vulnerabilities.table.filterIn": "フィルタリング", "xpack.csp.vulnerabilities.table.filterOut": "除外", @@ -12294,24 +12244,12 @@ "xpack.csp.vulnerabilities.vulnerabilityOverviewTile.publishedDate": "公開日", "xpack.csp.vulnerabilitiesByResource.severityMap.tooltipTitle": "重要度マップ", "xpack.csp.vulnerability_dashboard.cspPageTemplate.pageTitle": "Cloud Native Vulnerability Management", - "xpack.csp.vulnerabilityByResourceTable.column.region": "地域", - "xpack.csp.vulnerabilityByResourceTable.column.resourceId": "リソースID", - "xpack.csp.vulnerabilityByResourceTable.column.resourceName": "リソース名", - "xpack.csp.vulnerabilityByResourceTable.column.severityMap": "重要度マップ", - "xpack.csp.vulnerabilityByResourceTable.column.vulnerabilities": "脆弱性", "xpack.csp.vulnerabilityDashboard.trendGraphChart.accountsDropDown.option.allTitle": "すべて", "xpack.csp.vulnerabilityDashboard.trendGraphChart.accountsDropDown.prepend.accountsTitle": "アカウント", "xpack.csp.vulnerabilityDashboard.trendGraphChart.trendBySeverityTitle": "重要度別傾向", "xpack.csp.vulnerabilityDashboard.viewAllButton.buttonTitle": "すべて表示", - "xpack.csp.vulnerabilityTable.column.fixVersion": "修正バージョン", - "xpack.csp.vulnerabilityTable.column.package": "パッケージ", - "xpack.csp.vulnerabilityTable.column.resourceId": "リソースID", - "xpack.csp.vulnerabilityTable.column.resourceName": "リソース名", - "xpack.csp.vulnerabilityTable.column.severity": "深刻度", "xpack.csp.vulnerabilityTable.column.sortAscending": "低 -> 重大", "xpack.csp.vulnerabilityTable.column.sortDescending": "重大 -> 低", - "xpack.csp.vulnerabilityTable.column.version": "バージョン", - "xpack.csp.vulnerabilityTable.column.vulnerability": "脆弱性", "xpack.csp.vulnerabilityTable.panel.buttonText": "すべての脆弱性を表示", "xpack.csp.vulnMgmtIntegration.awsOption.nameTitle": "Amazon Web Services", "xpack.csp.vulnMgmtIntegration.azureOption.nameTitle": "Azure", @@ -30884,11 +30822,7 @@ "xpack.security.accountManagement.apiKeyFlyout.createTitle": "APIキーを作成", "xpack.security.accountManagement.apiKeyFlyout.crossClusterTypeDescription": "リモートクラスターがローカルクラスターに接続できるようにします。", "xpack.security.accountManagement.apiKeyFlyout.crossClusterTypeLabel": "クラスター横断APIキー", - "xpack.security.accountManagement.apiKeyFlyout.customExpirationInputLabel": "寿命", - "xpack.security.accountManagement.apiKeyFlyout.customExpirationLabel": "時間の後に有効期限切れ", - "xpack.security.accountManagement.apiKeyFlyout.customPrivilegesLabel": "権限を制限", "xpack.security.accountManagement.apiKeyFlyout.expirationUnit": "日", - "xpack.security.accountManagement.apiKeyFlyout.includeMetadataLabel": "メタデータを含む", "xpack.security.accountManagement.apiKeyFlyout.metadataHelpText": "メタデータを構成する方法を参照してください。", "xpack.security.accountManagement.apiKeyFlyout.nameLabel": "名前", "xpack.security.accountManagement.apiKeyFlyout.ownerLabel": "所有者", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 29445c517013b..8581c5dc69dad 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2454,6 +2454,9 @@ "discover.viewAlert.searchSourceErrorTitle": "提取搜索源时出错", "discover.viewModes.document.label": "文档", "discover.viewModes.fieldStatistics.label": "字段统计信息", + "discover.hitsCounter.hitsPluralTitle": "{formattedHits} {hits, plural, other {命中数}}", + "discover.hitsCounter.partialHitsPluralTitle": "≥{formattedHits} {hits, plural, other {命中数}}", + "discover.hitsCounter.hitCountSpinnerAriaLabel": "最终命中计数仍在加载", "domDragDrop.announce.cancelled": "移动已取消。{label} 已返回至其初始位置", "domDragDrop.announce.cancelledItem": "移动已取消。{label} 返回至 {groupLabel} 组中的位置 {position}", "domDragDrop.announce.dropped.combineCompatible": "已将组 {groupLabel} 中的 {label} 组合到图层 {dropLayerNumber} 的组 {dropGroupLabel} 中的位置 {dropPosition} 上的 {dropLabel}", @@ -5748,9 +5751,6 @@ "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.greatestFunction.markdown": "### GREATEST\n返回许多列中的最大值。除了可一次对多个列运行以外,此函数与 `MV_MAX` 类似。\n\n```\nROW a = 10, b = 20\n| EVAL g = GREATEST(a, b);\n```\n\n注意,对 `keyword` 或 `text` 字段运行时,此函数将按字母顺序返回最后一个字符串。对 `boolean` 列运行时,如果任何值为 `true`,此函数将返回 `true`。\n ", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.grok.markdown": "### GROK\n使用 `GROK`,您可以从字符串中提取结构化数据。`GROK` 将基于正则表达式根据模式来匹配字符串,并提取指定模式作为列。\n\n请参阅 [grok 处理器文档](https://www.elastic.co/guide/en/elasticsearch/reference/current/grok-processor.html)了解 grok 模式的语法。\n\n```\nROW a = \"12 15.5 15.6 true\"\n| GROK a \"%\\{NUMBER:b:int\\} %\\{NUMBER:c:float\\} %\\{NUMBER:d:double\\} %\\{WORD:e:boolean\\}\"\n```\n ", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.inOperator.markdown": "### IN\n`IN` 运算符允许测试字段或表达式是否等于文本、字段或表达式列表中的元素:\n\n```\nROW a = 1, b = 4, c = 3\n| WHERE c-a IN (3, b / 2, a)\n```\n ", - "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.isFiniteFunction.markdown": "### IS_FINITE\n返回布尔值,指示其输入是否为有限数。\n\n```\nROW d = 1.0 \n| EVAL s = IS_FINITE(d/0)\n```\n ", - "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.isInfiniteFunction.markdown": "### IS_INFINITE\n返回布尔值,指示其输入是否为无限数。\n\n```\nROW d = 1.0 \n| EVAL s = IS_INFINITE(d/0)\n```\n ", - "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.isNanFunction.markdown": "### IS_NAN\n返回布尔值,指示其输入是否不是数字。\n\n```\nROW d = 1.0 \n| EVAL s = IS_NAN(d)\n```\n ", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.keep.markdown": "### KEEP\n使用 `KEEP` 命令,您可以指定将返回哪些列以及返回这些列的顺序。\n\n要限制返回的列数,请使用列名的逗号分隔列表。将按指定顺序返回这些列:\n \n```\nFROM employees\n| KEEP first_name, last_name, height\n```\n\n您不必按名称指定每个列,而可以使用通配符返回名称匹配某种模式的所有列:\n\n```\nFROM employees\n| KEEP h*\n```\n\n星号通配符 (`*`) 自身将转换为不与其他参数匹配的所有列。此查询将首先返回所有名称以 h 开头的所有列,随后返回所有其他列:\n\n```\nFROM employees\n| KEEP h*, *\n```\n ", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.leastFunction.markdown": "### LEAST\n返回许多列中的最小值。除了可一次对多个列运行以外,此函数与 `MV_MIN` 类似。\n\n```\nROW a = 10, b = 20\n| EVAL l = LEAST(a, b)\n```\n\n注意,对 `keyword` 或 `text` 字段运行时,此函数将按字母顺序返回第一个字符串。对 `boolean` 列运行时,如果任何值为 `false`,此函数将返回 `false`。\n ", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.leftFunction.markdown": "### LEFT\n返回从 `string` 中提取 `length` 字符的子字符串,从左侧开始。\n\n```\nFROM employees\n| KEEP last_name\n| EVAL left = LEFT(last_name, 3)\n| SORT last_name ASC\n| LIMIT 5\n```\n ", @@ -5884,9 +5884,6 @@ "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.greatestFunction": "GREATEST", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.grok": "GROK", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.inOperator": "IN", - "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.isFiniteFunction": "IS_FINITE", - "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.isInfiniteFunction": "IS_INFINITE", - "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.isNanFunction": "IS_NAN", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.keep": "KEEP", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.leastFunction": "LEAST", "textBasedEditor.query.textBasedLanguagesEditor.documentationESQL.leftFunction": "LEFT", @@ -6292,7 +6289,6 @@ "unifiedFieldList.fieldListSidebar.fieldsMobileButtonLabel": "字段", "unifiedFieldList.fieldListSidebar.flyoutBackIcon": "返回", "unifiedFieldList.fieldListSidebar.flyoutHeading": "字段列表", - "unifiedFieldList.fieldListSidebar.indexAndFieldsSectionAriaLabel": "索引和字段", "unifiedFieldList.fieldListSidebar.toggleSidebarLegend": "切换侧边栏", "unifiedFieldList.fieldNameSearch.filterByNameLabel": "搜索字段名称", "unifiedFieldList.fieldPopover.addExistsFilterLabel": "筛留存在的字段", @@ -6337,31 +6333,18 @@ "unifiedHistogram.breakdownColumnLabel": "{fieldName} 的排名前 3 值", "unifiedHistogram.bucketIntervalTooltip": "此时间间隔创建的{bucketsDescription}无法在选定时间范围内显示,因此已调整为 {bucketIntervalDescription}。", "unifiedHistogram.histogramTimeRangeIntervalDescription": "(时间间隔:{value})", - "unifiedHistogram.hitsPluralTitle": "{formattedHits} {hits, plural, other {命中数}}", - "unifiedHistogram.partialHits": "≥{formattedHits} {hits, plural, other {命中数}}", - "unifiedHistogram.timeIntervalWithValue": "时间间隔:{timeInterval}", - "unifiedHistogram.breakdownFieldSelectorAriaLabel": "细分方式", - "unifiedHistogram.breakdownFieldSelectorLabel": "细分方式", - "unifiedHistogram.breakdownFieldSelectorPlaceholder": "选择字段", "unifiedHistogram.bucketIntervalTooltip.tooLargeBucketsText": "存储桶过大", "unifiedHistogram.bucketIntervalTooltip.tooManyBucketsText": "存储桶过多", - "unifiedHistogram.chartOptions": "图表选项", - "unifiedHistogram.chartOptionsButton": "图表选项", "unifiedHistogram.countColumnLabel": "记录计数", "unifiedHistogram.editVisualizationButton": "编辑可视化", - "unifiedHistogram.hideChart": "隐藏图表", "unifiedHistogram.histogramOfFoundDocumentsAriaLabel": "已找到文档的直方图", "unifiedHistogram.histogramTimeRangeIntervalAuto": "自动", "unifiedHistogram.histogramTimeRangeIntervalLoading": "正在加载", - "unifiedHistogram.hitCountSpinnerAriaLabel": "最终命中计数仍在加载", "unifiedHistogram.inspectorRequestDataTitleTotalHits": "总命中数", "unifiedHistogram.inspectorRequestDescriptionTotalHits": "此请求将查询 Elasticsearch 以获取总命中数。", "unifiedHistogram.lensTitle": "编辑可视化", - "unifiedHistogram.resetChartHeight": "重置为默认高度", "unifiedHistogram.saveVisualizationButton": "保存可视化", - "unifiedHistogram.showChart": "显示图表", "unifiedHistogram.suggestionSelectorPlaceholder": "选择可视化", - "unifiedHistogram.timeIntervals": "时间间隔", "unifiedHistogram.timeIntervalWithValueWarning": "警告", "unifiedSearch.filter.filterBar.filterActionsMessage": "筛选:{innerText}。选择以获取更多筛选操作。", "unifiedSearch.filter.filterBar.filterItemBadgeIconAriaLabel": "删除 {filter}", @@ -12026,13 +12009,7 @@ "xpack.csp.cloudPosturePage.kspmIntegration.packageNotInstalled.description": "使用我们的 {integrationFullName} (KSPM) 集成可在您的 Kubernetes 集群中检测安全配置错误。", "xpack.csp.complianceScoreBar.tooltipTitle": "{failed} 个失败和 {passed} 个通过的结果", "xpack.csp.eksIntegration.docsLink": "请参阅 {docs} 了解更多详情", - "xpack.csp.findings..bottomBarLabel": "这些是匹配您的搜索的前 {maxItems} 个结果,请优化搜索以查看其他结果。", "xpack.csp.findings.distributionBar.showingPageOfTotalLabel": "正在显示第 {pageStart}-{pageEnd} 个 {type}(共 {total} 个)", - "xpack.csp.findings.findingsTableCell.addFilterButton": "添加 {field} 筛选", - "xpack.csp.findings.findingsTableCell.addFilterButtonTooltip": "添加 {field} 筛选", - "xpack.csp.findings.findingsTableCell.addNegatedFilterButtonTooltip": "添加 {field} 作废筛选", - "xpack.csp.findings.findingsTableCell.addNegateFilterButton": "添加 {field} 作废筛选", - "xpack.csp.findings.resourceFindings.resourceFindingsPageTitle": "{resourceName} {hyphen} 结果", "xpack.csp.findingsFlyout.alerts.alertCount": "{alertCount, plural, other {# 个告警}}", "xpack.csp.findingsFlyout.alerts.detectionRuleCount": "{ruleCount, plural, other {# 个检测规则}}", "xpack.csp.noFindingsStates.indexTimeout.indexTimeoutDescription": "收集结果所需的时间长于预期。{docs}。", @@ -12040,15 +12017,7 @@ "xpack.csp.rules.rulesTable.showingPageOfTotalLabel": "正在显示 {pageSize} 个规则(共 {total, plural, other {# 个规则}})", "xpack.csp.subscriptionNotAllowed.promptDescription": "要使用这些云安全功能,您必须 {link}。", "xpack.csp.vulnerabilities.detectionRuleNamePrefix": "漏洞:{vulnerabilityId}", - "xpack.csp.vulnerabilities.resourceVulnerabilities.vulnerabilitiesPageTitle": "{resourceName} {hyphen} 漏洞", - "xpack.csp.vulnerabilities.totalVulnerabilities": "{total, plural, other {# 个漏洞}}", - "xpack.csp.vulnerabilities.vulnerabilitiesTableCell.addFilterButton": "添加 {columnId} 筛选", - "xpack.csp.vulnerabilities.vulnerabilitiesTableCell.addFilterButtonTooltip": "添加 {columnId} 筛选", - "xpack.csp.vulnerabilities.vulnerabilitiesTableCell.addNegatedFilterButtonTooltip": "添加 {columnId} 作废筛选", - "xpack.csp.vulnerabilities.vulnerabilitiesTableCell.addNegateFilterButton": "添加 {columnId} 作废筛选", "xpack.csp.vulnerabilities.vulnerabilityOverviewTile.publishedDateText": "{date}", - "xpack.csp.vulnerabilitiesByResource.totalResources": "{total, plural, other {# 项资源}}", - "xpack.csp.vulnerabilitiesByResource.totalVulnerabilities": "{total, plural, other {# 个漏洞}}", "xpack.csp.awsIntegration.accessKeyIdLabel": "访问密钥 ID", "xpack.csp.awsIntegration.assumeRoleDescription": "IAM 角色 Amazon 资源名称 (ARN) 是您可在 AWS 帐户中创建的 IAM 身份。创建 IAM 角色时,用户可以定义该角色的权限。角色没有标准的长期凭据,如密码或访问密钥。", "xpack.csp.awsIntegration.assumeRoleLabel": "接管角色", @@ -12183,15 +12152,10 @@ "xpack.csp.emptyState.readDocsLink": "阅读文档", "xpack.csp.emptyState.resetFiltersButton": "重置筛选", "xpack.csp.emptyState.title": "没有任何结果匹配您的搜索条件", - "xpack.csp.expandColumnDescriptionLabel": "展开", - "xpack.csp.expandColumnNameLabel": "展开", "xpack.csp.findings.distributionBar.totalFailedLabel": "失败的结果", "xpack.csp.findings.distributionBar.totalPassedLabel": "通过的结果", "xpack.csp.findings.errorCallout.pageSearchErrorTitle": "检索搜索结果时遇到问题", "xpack.csp.findings.errorCallout.showErrorButtonLabel": "显示错误消息", - "xpack.csp.findings.findingsByResource.tableRowTypeLabel": "资源", - "xpack.csp.findings.findingsByResourceTable.cisSectionsColumnLabel": "CIS 部分", - "xpack.csp.findings.findingsByResourceTable.postureScoreColumnLabel": "态势分数", "xpack.csp.findings.findingsErrorToast.searchFailedTitle": "搜索失败", "xpack.csp.findings.findingsFlyout.jsonTabTitle": "JSON", "xpack.csp.findings.findingsFlyout.overviewTab.alertsTitle": "告警", @@ -12223,16 +12187,11 @@ "xpack.csp.findings.findingsFlyout.ruleTab.tagsTitle": "标签", "xpack.csp.findings.findingsFlyout.ruleTabTitle": "规则", "xpack.csp.findings.findingsFlyout.tableTabTitle": "表", - "xpack.csp.findings.findingsTable.findingsTableColumn.clusterIdColumnLabel": "属于", - "xpack.csp.findings.findingsTable.findingsTableColumn.clusterIdColumnTooltipLabel": "Kubernetes 集群 ID 或云帐户名称", "xpack.csp.findings.findingsTable.findingsTableColumn.lastCheckedColumnLabel": "上次检查时间", "xpack.csp.findings.findingsTable.findingsTableColumn.resourceIdColumnLabel": "资源 ID", - "xpack.csp.findings.findingsTable.findingsTableColumn.resourceIdColumnTooltipLabel": "定制 Elastic 资源 ID", "xpack.csp.findings.findingsTable.findingsTableColumn.resourceNameColumnLabel": "资源名称", "xpack.csp.findings.findingsTable.findingsTableColumn.resourceTypeColumnLabel": "资源类型", "xpack.csp.findings.findingsTable.findingsTableColumn.resultColumnLabel": "结果", - "xpack.csp.findings.findingsTable.findingsTableColumn.ruleBenchmarkColumnLabel": "适用基准", - "xpack.csp.findings.findingsTable.findingsTableColumn.ruleBenchmarkColumnTooltipLabel": "用于评估此资源的基准", "xpack.csp.findings.findingsTable.findingsTableColumn.ruleNameColumnLabel": "规则名称", "xpack.csp.findings.findingsTable.findingsTableColumn.ruleNumberColumnLabel": "规则编号", "xpack.csp.findings.findingsTable.findingsTableColumn.ruleSectionColumnLabel": "CIS 部分", @@ -12243,12 +12202,6 @@ "xpack.csp.findings.groupBySelector.groupByNoneLabel": "无", "xpack.csp.findings.groupBySelector.groupByResourceIdLabel": "资源", "xpack.csp.findings.latestFindings.tableRowTypeLabel": "结果", - "xpack.csp.findings.resourceFindings.backToResourcesPageButtonLabel": "返回到资源", - "xpack.csp.findings.resourceFindings.tableRowTypeLabel": "结果", - "xpack.csp.findings.resourceFindingsSharedValues.cloudAccountName": "云帐户名称", - "xpack.csp.findings.resourceFindingsSharedValues.clusterIdTitle": "集群 ID", - "xpack.csp.findings.resourceFindingsSharedValues.resourceIdTitle": "资源 ID", - "xpack.csp.findings.resourceFindingsSharedValues.resourceTypeTitle": "资源类型", "xpack.csp.findings.search.queryErrorToastMessage": "查询错误", "xpack.csp.findings.searchBar.searchPlaceholder": "搜索结果(例如,rule.section:“APM 服务器”)", "xpack.csp.findings.tabs.misconfigurations": "错误配置", @@ -12363,9 +12316,6 @@ "xpack.csp.vulnerabilities": "漏洞", "xpack.csp.vulnerabilities.flyoutTabs.fieldLabel": "字段", "xpack.csp.vulnerabilities.flyoutTabs.fieldValueLabel": "值", - "xpack.csp.vulnerabilities.resourceVulnerabilities.backToResourcesPageButtonLabel": "返回到资源", - "xpack.csp.vulnerabilities.resourceVulnerabilities.regionTitle": "地区", - "xpack.csp.vulnerabilities.resourceVulnerabilities.resourceIdTitle": "资源 ID", "xpack.csp.vulnerabilities.searchBar.placeholder": "搜索漏洞(例如,vulnerability.severity:“CRITICAL”)", "xpack.csp.vulnerabilities.table.filterIn": "筛选范围", "xpack.csp.vulnerabilities.table.filterOut": "筛除", @@ -12388,24 +12338,12 @@ "xpack.csp.vulnerabilities.vulnerabilityOverviewTile.publishedDate": "发布日期", "xpack.csp.vulnerabilitiesByResource.severityMap.tooltipTitle": "严重性映射", "xpack.csp.vulnerability_dashboard.cspPageTemplate.pageTitle": "云原生漏洞管理", - "xpack.csp.vulnerabilityByResourceTable.column.region": "地区", - "xpack.csp.vulnerabilityByResourceTable.column.resourceId": "资源 ID", - "xpack.csp.vulnerabilityByResourceTable.column.resourceName": "资源名称", - "xpack.csp.vulnerabilityByResourceTable.column.severityMap": "严重性映射", - "xpack.csp.vulnerabilityByResourceTable.column.vulnerabilities": "漏洞", "xpack.csp.vulnerabilityDashboard.trendGraphChart.accountsDropDown.option.allTitle": "全部", "xpack.csp.vulnerabilityDashboard.trendGraphChart.accountsDropDown.prepend.accountsTitle": "帐户", "xpack.csp.vulnerabilityDashboard.trendGraphChart.trendBySeverityTitle": "趋势(按严重性)", "xpack.csp.vulnerabilityDashboard.viewAllButton.buttonTitle": "查看全部", - "xpack.csp.vulnerabilityTable.column.fixVersion": "修复版本", - "xpack.csp.vulnerabilityTable.column.package": "软件包", - "xpack.csp.vulnerabilityTable.column.resourceId": "资源 ID", - "xpack.csp.vulnerabilityTable.column.resourceName": "资源名称", - "xpack.csp.vulnerabilityTable.column.severity": "严重性", "xpack.csp.vulnerabilityTable.column.sortAscending": "低 -> 严重", "xpack.csp.vulnerabilityTable.column.sortDescending": "严重 -> 低", - "xpack.csp.vulnerabilityTable.column.version": "版本", - "xpack.csp.vulnerabilityTable.column.vulnerability": "漏洞", "xpack.csp.vulnerabilityTable.panel.buttonText": "查看所有漏洞", "xpack.csp.vulnMgmtIntegration.awsOption.nameTitle": "Amazon Web Services", "xpack.csp.vulnMgmtIntegration.azureOption.nameTitle": "Azure", @@ -30866,11 +30804,7 @@ "xpack.security.accountManagement.apiKeyFlyout.createTitle": "创建 API 密钥", "xpack.security.accountManagement.apiKeyFlyout.crossClusterTypeDescription": "允许远程集群连接到本地集群。", "xpack.security.accountManagement.apiKeyFlyout.crossClusterTypeLabel": "跨集群 API 密钥", - "xpack.security.accountManagement.apiKeyFlyout.customExpirationInputLabel": "寿命", - "xpack.security.accountManagement.apiKeyFlyout.customExpirationLabel": "有效时间", - "xpack.security.accountManagement.apiKeyFlyout.customPrivilegesLabel": "限制权限", "xpack.security.accountManagement.apiKeyFlyout.expirationUnit": "天", - "xpack.security.accountManagement.apiKeyFlyout.includeMetadataLabel": "包括元数据", "xpack.security.accountManagement.apiKeyFlyout.metadataHelpText": "了解如何结构化元数据。", "xpack.security.accountManagement.apiKeyFlyout.nameLabel": "名称", "xpack.security.accountManagement.apiKeyFlyout.ownerLabel": "所有者", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/field_browser/field_browser.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/field_browser/field_browser.test.tsx index 5c3a33d5b17c8..32a6b1b85019b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/field_browser/field_browser.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/field_browser/field_browser.test.tsx @@ -42,7 +42,12 @@ describe('FieldsBrowser', () => { result.getByTestId('show-field-browser').click(); await waitFor(() => { + // the container is rendered now expect(result.getByTestId('fields-browser-container')).toBeInTheDocument(); + // by default, no categories are selected + expect(result.getByTestId('category-badges')).toHaveTextContent(''); + // the view: all button is shown by default + result.getByText('View: all'); }); }); }); diff --git a/x-pack/test/api_integration/apis/asset_manager/tests/helpers.ts b/x-pack/test/api_integration/apis/asset_manager/tests/helpers.ts index 8983b139d9462..3d1086ca8b8e4 100644 --- a/x-pack/test/api_integration/apis/asset_manager/tests/helpers.ts +++ b/x-pack/test/api_integration/apis/asset_manager/tests/helpers.ts @@ -100,5 +100,5 @@ export function generateHostsData({ return range .interval('1m') .rate(1) - .generator((timestamp, index) => hosts.map((host) => host.metrics().timestamp(timestamp))); + .generator((timestamp, index) => hosts.map((host) => host.cpu().timestamp(timestamp))); } diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts b/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts index eb7ea67560154..5c42f0e9bf9ae 100644 --- a/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts +++ b/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts @@ -12,6 +12,10 @@ import type { FtrProviderContext } from '../ftr_provider_context'; // Defined in CSP plugin const FINDINGS_INDEX = 'logs-cloud_security_posture.findings-default'; const FINDINGS_LATEST_INDEX = 'logs-cloud_security_posture.findings_latest-default'; +export const VULNERABILITIES_INDEX_DEFAULT_NS = + 'logs-cloud_security_posture.vulnerabilities-default'; +export const LATEST_VULNERABILITIES_INDEX_DEFAULT_NS = + 'logs-cloud_security_posture.vulnerabilities_latest-default'; export function FindingsPageProvider({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); @@ -35,49 +39,49 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider log.debug('CSP plugin is initialized'); }); + const deleteByQuery = async (index: string) => { + await es.deleteByQuery({ + index, + query: { + match_all: {}, + }, + ignore_unavailable: true, + refresh: true, + }); + }; + + const insertOperation = (index: string, findingsMock: Array>) => { + return findingsMock.flatMap((doc) => [{ index: { _index: index } }, doc]); + }; + const index = { + remove: () => + Promise.all([deleteByQuery(FINDINGS_INDEX), deleteByQuery(FINDINGS_LATEST_INDEX)]), + add: async (findingsMock: Array>) => { + await es.bulk({ + refresh: true, + operations: [ + ...insertOperation(FINDINGS_INDEX, findingsMock), + ...insertOperation(FINDINGS_LATEST_INDEX, findingsMock), + ], + }); + }, + }; + + const vulnerabilitiesIndex = { remove: () => Promise.all([ - es.deleteByQuery({ - index: FINDINGS_INDEX, - query: { - match_all: {}, - }, - ignore_unavailable: true, - refresh: true, - }), - es.deleteByQuery({ - index: FINDINGS_LATEST_INDEX, - query: { - match_all: {}, - }, - ignore_unavailable: true, - refresh: true, - }), + deleteByQuery(VULNERABILITIES_INDEX_DEFAULT_NS), + deleteByQuery(LATEST_VULNERABILITIES_INDEX_DEFAULT_NS), ]), add: async (findingsMock: Array>) => { - await Promise.all([ - ...findingsMock.map((finding) => - es.index({ - index: FINDINGS_INDEX, - body: { - ...finding, - '@timestamp': finding['@timestamp'] ?? new Date().toISOString(), - }, - refresh: true, - }) - ), - ...findingsMock.map((finding) => - es.index({ - index: FINDINGS_LATEST_INDEX, - body: { - ...finding, - '@timestamp': finding['@timestamp'] ?? new Date().toISOString(), - }, - refresh: true, - }) - ), - ]); + await es.bulk({ + refresh: true, + operations: [ + ...insertOperation(VULNERABILITIES_INDEX_DEFAULT_NS, findingsMock), + ...insertOperation(LATEST_VULNERABILITIES_INDEX_DEFAULT_NS, findingsMock), + ], + }); }, }; @@ -229,122 +233,15 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider }, }); - const createTableObject = (tableTestSubject: string) => ({ - getElement() { - return testSubjects.find(tableTestSubject); - }, - - async getHeaders() { - const element = await this.getElement(); - return await element.findAllByCssSelector('thead tr :is(th,td)'); - }, - - async getColumnIndex(columnName: string) { - const headers = await this.getHeaders(); - const texts = await Promise.all(headers.map((header) => header.getVisibleText())); - const columnIndex = texts.findIndex((i) => i === columnName); - expect(columnIndex).to.be.greaterThan(-1); - return columnIndex + 1; - }, - - async getColumnHeaderCell(columnName: string) { - const headers = await this.getHeaders(); - const headerIndexes = await Promise.all(headers.map((header) => header.getVisibleText())); - const columnIndex = headerIndexes.findIndex((i) => i === columnName); - return headers[columnIndex]; - }, - - async getRowsCount() { - const element = await this.getElement(); - const rows = await element.findAllByCssSelector('tbody tr'); - return rows.length; - }, - - async getFindingsCount(type: 'passed' | 'failed') { - const element = await this.getElement(); - const items = await element.findAllByCssSelector(`span[data-test-subj="${type}_finding"]`); - return items.length; - }, - - async getRowIndexForValue(columnName: string, value: string) { - const values = await this.getColumnValues(columnName); - const rowIndex = values.indexOf(value); - expect(rowIndex).to.be.greaterThan(-1); - return rowIndex + 1; - }, - - async getFilterElementButton(rowIndex: number, columnIndex: number, negated = false) { - const tableElement = await this.getElement(); - const button = negated - ? 'findings_table_cell_add_negated_filter' - : 'findings_table_cell_add_filter'; - const selector = `tbody tr:nth-child(${rowIndex}) td:nth-child(${columnIndex}) button[data-test-subj="${button}"]`; - return tableElement.findByCssSelector(selector); - }, - - async addCellFilter(columnName: string, cellValue: string, negated = false) { - const columnIndex = await this.getColumnIndex(columnName); - const rowIndex = await this.getRowIndexForValue(columnName, cellValue); - const filterElement = await this.getFilterElementButton(rowIndex, columnIndex, negated); - await filterElement.click(); - }, - - async getColumnValues(columnName: string) { - const elementsWithNoFilterCell = ['CIS Section', '@timestamp']; - const tableElement = await this.getElement(); - const columnIndex = await this.getColumnIndex(columnName); - const selector = elementsWithNoFilterCell.includes(columnName) - ? `tbody tr td:nth-child(${columnIndex})` - : `tbody tr td:nth-child(${columnIndex}) div[data-test-subj="filter_cell_value"]`; - const columnCells = await tableElement.findAllByCssSelector(selector); - - return await Promise.all(columnCells.map((cell) => cell.getVisibleText())); - }, - - async hasColumnValue(columnName: string, value: string) { - const values = await this.getColumnValues(columnName); - return values.includes(value); - }, - - async toggleColumnSort(columnName: string, direction: 'asc' | 'desc') { - const element = await this.getColumnHeaderCell(columnName); - const currentSort = await element.getAttribute('aria-sort'); - if (currentSort === 'none') { - // a click is needed to focus on Eui column header - await element.click(); - - // default is ascending - if (direction === 'desc') { - const nonStaleElement = await this.getColumnHeaderCell(columnName); - await nonStaleElement.click(); - } - } - if ( - (currentSort === 'ascending' && direction === 'desc') || - (currentSort === 'descending' && direction === 'asc') - ) { - // Without getting the element again, the click throws an error (stale element reference) - const nonStaleElement = await this.getColumnHeaderCell(columnName); - await nonStaleElement.click(); - } - }, - - async openFlyoutAt(rowIndex: number) { - const table = await this.getElement(); - const flyoutButton = await table.findAllByTestSubject('findings_table_expand_column'); - await flyoutButton[rowIndex].click(); - }, - }); - const navigateToLatestFindingsPage = async () => { await PageObjects.common.navigateToUrl( 'securitySolution', // Defined in Security Solution plugin - 'cloud_security_posture/findings', + 'cloud_security_posture/findings/configurations', { shouldUseHashForSubUrl: false } ); }; - const navigateToVulnerabilities = async () => { + const navigateToLatestVulnerabilitiesPage = async () => { await PageObjects.common.navigateToUrl( 'securitySolution', // Defined in Security Solution plugin 'cloud_security_posture/findings/vulnerabilities', @@ -361,20 +258,8 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider }; const latestFindingsTable = createDataTableObject('latest_findings_table'); - const resourceFindingsTable = createTableObject('resource_findings_table'); - const findingsByResourceTable = { - ...createTableObject('findings_by_resource_table'), - async clickResourceIdLink(resourceId: string, sectionName: string) { - const table = await this.getElement(); - const row = await table.findByCssSelector( - `[data-test-subj="findings_resource_table_row_${resourceId}/${sectionName}"]` - ); - const link = await row.findByCssSelector( - '[data-test-subj="findings_by_resource_table_resource_id_column"' - ); - await link.click(); - }, - }; + const latestVulnerabilitiesTable = createDataTableObject('latest_vulnerabilities_table'); + const notInstalledVulnerabilities = createNotInstalledObject('cnvm-integration-not-installed'); const notInstalledCSP = createNotInstalledObject('cloud_posture_page_package_not_installed'); @@ -463,14 +348,14 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider return { navigateToLatestFindingsPage, - navigateToVulnerabilities, + navigateToLatestVulnerabilitiesPage, navigateToMisconfigurations, latestFindingsTable, - resourceFindingsTable, - findingsByResourceTable, + latestVulnerabilitiesTable, notInstalledVulnerabilities, notInstalledCSP, index, + vulnerabilitiesIndex, waitForPluginInitialized, distributionBar, vulnerabilityDataGrid, diff --git a/x-pack/test/cloud_security_posture_functional/pages/findings_onboarding.ts b/x-pack/test/cloud_security_posture_functional/pages/findings_onboarding.ts index 4919e4102df87..765dc7fae1370 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/findings_onboarding.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/findings_onboarding.ts @@ -28,7 +28,7 @@ export default ({ getPageObjects }: FtrProviderContext) => { }); it('clicking on the `No integrations installed` prompt action button - `install CNVM`: navigates to the CNVM integration installation page', async () => { - await findings.navigateToVulnerabilities(); + await findings.navigateToLatestVulnerabilitiesPage(); await PageObjects.header.waitUntilLoadingHasFinished(); const element = await notInstalledVulnerabilities.getElement(); expect(element).to.not.be(null); diff --git a/x-pack/test/cloud_security_posture_functional/pages/index.ts b/x-pack/test/cloud_security_posture_functional/pages/index.ts index 9da8cbbeeed54..f4039dc08466f 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/index.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/index.ts @@ -18,5 +18,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./vulnerability_dashboard')); loadTestFile(require.resolve('./cis_integration')); loadTestFile(require.resolve('./findings_old_data')); + loadTestFile(require.resolve('./vulnerabilities')); + loadTestFile(require.resolve('./vulnerabilities_grouping')); }); } diff --git a/x-pack/test/cloud_security_posture_functional/pages/vulnerabilities.ts b/x-pack/test/cloud_security_posture_functional/pages/vulnerabilities.ts new file mode 100644 index 0000000000000..d882d1765f752 --- /dev/null +++ b/x-pack/test/cloud_security_posture_functional/pages/vulnerabilities.ts @@ -0,0 +1,183 @@ +/* + * 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 expect from '@kbn/expect'; +import type { FtrProviderContext } from '../ftr_provider_context'; +import { vulnerabilitiesLatestMock } from '../mocks/vulnerabilities_latest_mock'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const queryBar = getService('queryBar'); + const filterBar = getService('filterBar'); + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const pageObjects = getPageObjects(['common', 'findings', 'header']); + + const resourceName1 = 'name-ng-1-Node'; + const resourceName2 = 'othername-june12-8-8-0-1'; + + describe('Vulnerabilities Page - DataTable', function () { + this.tags(['cloud_security_posture_vulnerabilities']); + let findings: typeof pageObjects.findings; + let latestVulnerabilitiesTable: typeof findings.latestVulnerabilitiesTable; + + before(async () => { + findings = pageObjects.findings; + latestVulnerabilitiesTable = findings.latestVulnerabilitiesTable; + + // Before we start any test we must wait for cloud_security_posture plugin to complete its initialization + await findings.waitForPluginInitialized(); + + // Prepare mocked findings + await findings.vulnerabilitiesIndex.remove(); + await findings.vulnerabilitiesIndex.add(vulnerabilitiesLatestMock); + + await findings.navigateToLatestVulnerabilitiesPage(); + await retry.waitFor( + 'Findings table to be loaded', + async () => + (await latestVulnerabilitiesTable.getRowsCount()) === vulnerabilitiesLatestMock.length + ); + pageObjects.header.waitUntilLoadingHasFinished(); + }); + + after(async () => { + await findings.vulnerabilitiesIndex.remove(); + }); + + describe('SearchBar', () => { + it('add filter', async () => { + // Filter bar uses the field's customLabel in the DataView + await filterBar.addFilter({ + field: 'Resource Name', + operation: 'is', + value: resourceName1, + }); + + expect(await filterBar.hasFilter('resource.name', resourceName1)).to.be(true); + expect( + await latestVulnerabilitiesTable.hasColumnValue('resource.name', resourceName1) + ).to.be(true); + }); + + it('remove filter', async () => { + await filterBar.removeFilter('resource.name'); + + expect(await filterBar.hasFilter('resource.name', resourceName1)).to.be(false); + expect(await latestVulnerabilitiesTable.getRowsCount()).to.be( + vulnerabilitiesLatestMock.length + ); + }); + + it('set search query', async () => { + await queryBar.setQuery(resourceName1); + await queryBar.submitQuery(); + + expect( + await latestVulnerabilitiesTable.hasColumnValue('resource.name', resourceName1) + ).to.be(true); + expect( + await latestVulnerabilitiesTable.hasColumnValue('resource.name', resourceName2) + ).to.be(false); + + await queryBar.setQuery(''); + await queryBar.submitQuery(); + + expect(await latestVulnerabilitiesTable.getRowsCount()).to.be( + vulnerabilitiesLatestMock.length + ); + }); + }); + + describe('DataTable features', () => { + it('Edit data view field option is Enabled', async () => { + await latestVulnerabilitiesTable.toggleEditDataViewFieldsOption('vulnerability.id'); + expect(await testSubjects.find('gridEditFieldButton')).to.be.ok(); + await latestVulnerabilitiesTable.toggleEditDataViewFieldsOption('vulnerability.id'); + }); + }); + + describe('Vulnerabilities - Fields selector', () => { + const CSP_FIELDS_SELECTOR_MODAL = 'cloudSecurityFieldsSelectorModal'; + const CSP_FIELDS_SELECTOR_OPEN_BUTTON = 'cloudSecurityFieldsSelectorOpenButton'; + const CSP_FIELDS_SELECTOR_RESET_BUTTON = 'cloudSecurityFieldsSelectorResetButton'; + const CSP_FIELDS_SELECTOR_CLOSE_BUTTON = 'cloudSecurityFieldsSelectorCloseButton'; + + it('Add fields to the Vulnerabilities DataTable', async () => { + const fieldsButton = await testSubjects.find(CSP_FIELDS_SELECTOR_OPEN_BUTTON); + await fieldsButton.click(); + await testSubjects.existOrFail(CSP_FIELDS_SELECTOR_MODAL); + + const agentIdCheckbox = await testSubjects.find( + 'cloud-security-fields-selector-item-agent.id' + ); + await agentIdCheckbox.click(); + + const agentNameCheckbox = await testSubjects.find( + 'cloud-security-fields-selector-item-agent.name' + ); + await agentNameCheckbox.click(); + + await testSubjects.existOrFail('dataGridHeaderCell-agent.id'); + await testSubjects.existOrFail('dataGridHeaderCell-agent.name'); + + const closeFieldsButton = await testSubjects.find(CSP_FIELDS_SELECTOR_CLOSE_BUTTON); + await closeFieldsButton.click(); + await testSubjects.missingOrFail(CSP_FIELDS_SELECTOR_MODAL); + }); + + it('Remove fields from the Vulnerabilities DataTable', async () => { + const fieldsButton = await testSubjects.find(CSP_FIELDS_SELECTOR_OPEN_BUTTON); + await fieldsButton.click(); + + const agentIdCheckbox = await testSubjects.find( + 'cloud-security-fields-selector-item-agent.id' + ); + await agentIdCheckbox.click(); + + const agentNameCheckbox = await testSubjects.find( + 'cloud-security-fields-selector-item-agent.name' + ); + await agentNameCheckbox.click(); + + await testSubjects.missingOrFail('dataGridHeaderCell-agent.id'); + await testSubjects.missingOrFail('dataGridHeaderCell-agent.name'); + + const closeFieldsButton = await testSubjects.find(CSP_FIELDS_SELECTOR_CLOSE_BUTTON); + await closeFieldsButton.click(); + await testSubjects.missingOrFail(CSP_FIELDS_SELECTOR_MODAL); + }); + it('Reset fields to default', async () => { + const fieldsButton = await testSubjects.find(CSP_FIELDS_SELECTOR_OPEN_BUTTON); + await fieldsButton.click(); + + const agentIdCheckbox = await testSubjects.find( + 'cloud-security-fields-selector-item-agent.id' + ); + await agentIdCheckbox.click(); + + const agentNameCheckbox = await testSubjects.find( + 'cloud-security-fields-selector-item-agent.name' + ); + await agentNameCheckbox.click(); + + await testSubjects.existOrFail('dataGridHeaderCell-agent.id'); + await testSubjects.existOrFail('dataGridHeaderCell-agent.name'); + + const resetFieldsButton = await testSubjects.find(CSP_FIELDS_SELECTOR_RESET_BUTTON); + await resetFieldsButton.click(); + + await testSubjects.missingOrFail('dataGridHeaderCell-agent.id'); + await testSubjects.missingOrFail('dataGridHeaderCell-agent.name'); + + const closeFieldsButton = await testSubjects.find(CSP_FIELDS_SELECTOR_CLOSE_BUTTON); + await closeFieldsButton.click(); + await testSubjects.missingOrFail(CSP_FIELDS_SELECTOR_MODAL); + }); + }); + }); +} diff --git a/x-pack/test/cloud_security_posture_functional/pages/vulnerabilities_grouping.ts b/x-pack/test/cloud_security_posture_functional/pages/vulnerabilities_grouping.ts new file mode 100644 index 0000000000000..8e569d27b8a4d --- /dev/null +++ b/x-pack/test/cloud_security_posture_functional/pages/vulnerabilities_grouping.ts @@ -0,0 +1,160 @@ +/* + * 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 expect from '@kbn/expect'; +import { asyncForEach } from '@kbn/std'; +import type { FtrProviderContext } from '../ftr_provider_context'; +import { vulnerabilitiesLatestMock } from '../mocks/vulnerabilities_latest_mock'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const queryBar = getService('queryBar'); + const filterBar = getService('filterBar'); + const pageObjects = getPageObjects(['common', 'findings', 'header']); + + const resourceName1 = 'name-ng-1-Node'; + const resourceName2 = 'othername-june12-8-8-0-1'; + + describe('Vulnerabilities Page - Grouping', function () { + this.tags(['cloud_security_posture_findings_grouping']); + let findings: typeof pageObjects.findings; + + before(async () => { + findings = pageObjects.findings; + + // Before we start any test we must wait for cloud_security_posture plugin to complete its initialization + await findings.waitForPluginInitialized(); + + // Prepare mocked findings + await findings.vulnerabilitiesIndex.remove(); + await findings.vulnerabilitiesIndex.add(vulnerabilitiesLatestMock); + + await findings.navigateToLatestVulnerabilitiesPage(); + await pageObjects.header.waitUntilLoadingHasFinished(); + }); + + after(async () => { + const groupSelector = await findings.groupSelector(); + await groupSelector.openDropDown(); + await groupSelector.setValue('None'); + await findings.vulnerabilitiesIndex.remove(); + }); + + describe('Default Grouping', async () => { + it('groups vulnerabilities by resource and sort by compliance score desc', async () => { + const groupSelector = await findings.groupSelector(); + await groupSelector.openDropDown(); + await groupSelector.setValue('Resource'); + + const grouping = await findings.findingsGrouping(); + + const resourceOrder = [ + { + resourceName: resourceName1, + resourceId: vulnerabilitiesLatestMock[0].resource.id, + findingsCount: '1', + }, + { + resourceName: resourceName2, + resourceId: vulnerabilitiesLatestMock[1].resource.id, + findingsCount: '1', + }, + ]; + + await asyncForEach( + resourceOrder, + async ({ resourceName, resourceId, findingsCount }, index) => { + const groupRow = await grouping.getRowAtIndex(index); + expect(await groupRow.getVisibleText()).to.contain(resourceName); + expect(await groupRow.getVisibleText()).to.contain(resourceId); + expect( + await ( + await groupRow.findByTestSubject('vulnerabilities_grouping_counter') + ).getVisibleText() + ).to.be(findingsCount); + } + ); + + const groupCount = await grouping.getGroupCount(); + expect(groupCount).to.be('2 groups'); + + const unitCount = await grouping.getUnitCount(); + expect(unitCount).to.be('2 vulnerabilities'); + }); + }); + describe('SearchBar', () => { + it('add filter', async () => { + // Filter bar uses the field's customLabel in the DataView + await filterBar.addFilter({ + field: 'Resource Name', + operation: 'is', + value: resourceName1, + }); + expect(await filterBar.hasFilter('resource.name', resourceName1)).to.be(true); + + const grouping = await findings.findingsGrouping(); + + const groupRow = await grouping.getRowAtIndex(0); + expect(await groupRow.getVisibleText()).to.contain(resourceName1); + + const groupCount = await grouping.getGroupCount(); + expect(groupCount).to.be('1 group'); + + const unitCount = await grouping.getUnitCount(); + expect(unitCount).to.be('1 vulnerability'); + }); + + it('remove filter', async () => { + await filterBar.removeFilter('resource.name'); + + expect(await filterBar.hasFilter('resource.name', resourceName1)).to.be(false); + + const grouping = await findings.findingsGrouping(); + const groupCount = await grouping.getGroupCount(); + expect(groupCount).to.be('2 groups'); + + const unitCount = await grouping.getUnitCount(); + expect(unitCount).to.be('2 vulnerabilities'); + }); + + it('set search query', async () => { + await queryBar.setQuery(resourceName1); + await queryBar.submitQuery(); + + const grouping = await findings.findingsGrouping(); + + const groupRow = await grouping.getRowAtIndex(0); + expect(await groupRow.getVisibleText()).to.contain(resourceName1); + + const groupCount = await grouping.getGroupCount(); + expect(groupCount).to.be('1 group'); + + const unitCount = await grouping.getUnitCount(); + expect(unitCount).to.be('1 vulnerability'); + + await queryBar.setQuery(''); + await queryBar.submitQuery(); + + expect(await grouping.getGroupCount()).to.be('2 groups'); + expect(await grouping.getUnitCount()).to.be('2 vulnerabilities'); + }); + }); + + describe('Group table', async () => { + it('shows vulnerabilities table when expanding', async () => { + const grouping = await findings.findingsGrouping(); + const firstRow = await grouping.getRowAtIndex(0); + await (await firstRow.findByCssSelector('button')).click(); + const latestFindingsTable = findings.createDataTableObject('latest_vulnerabilities_table'); + expect(await latestFindingsTable.getRowsCount()).to.be(1); + expect(await latestFindingsTable.hasColumnValue('resource.name', resourceName1)).to.be( + true + ); + }); + }); + }); +} diff --git a/x-pack/test/fleet_api_integration/apis/agents/list.ts b/x-pack/test/fleet_api_integration/apis/agents/list.ts index 4bf7e84d70e7f..91796d5a9b9dd 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/list.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/list.ts @@ -17,10 +17,8 @@ export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const es = getService('es'); let elasticAgentpkgVersion: string; - // Failing: See https://github.com/elastic/kibana/issues/170690 - // Failing: See https://github.com/elastic/kibana/issues/170690 - // Failing: See https://github.com/elastic/kibana/issues/170690 - describe.skip('fleet_list_agent', () => { + + describe('fleet_list_agent', () => { before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/fleet/agents'); const getPkRes = await supertest @@ -159,6 +157,7 @@ export default function ({ getService }: FtrProviderContext) { dataset: 'elastic_agent.elastic_agent', }, elastic_agent: { id: 'agent1', process: 'elastic_agent' }, + component: { id: 'component1' }, system: { process: { memory: { @@ -179,6 +178,7 @@ export default function ({ getService }: FtrProviderContext) { document: { '@timestamp': new Date(now - 1 * 60 * 1000).toISOString(), elastic_agent: { id: 'agent1', process: 'elastic_agent' }, + component: { id: 'component2' }, data_stream: { namespace: 'default', type: 'metrics', diff --git a/x-pack/test/fleet_api_integration/apis/epm/get.ts b/x-pack/test/fleet_api_integration/apis/epm/get.ts index 045a6d034a0a0..7b79272bbef0b 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/get.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/get.ts @@ -13,6 +13,7 @@ import { FtrProviderContext } from '../../../api_integration/ftr_provider_contex import { skipIfNoDockerRegistry } from '../../helpers'; import { setupFleetAndAgents } from '../agents/services'; import { testUsers } from '../test_users'; +import { bundlePackage, removeBundledPackages } from './install_bundled'; export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; @@ -22,6 +23,7 @@ export default function (providerContext: FtrProviderContext) { const testPkgName = 'apache'; const testPkgVersion = '0.1.4'; + const log = getService('log'); const uninstallPackage = async (name: string, version: string) => { await supertest.delete(`/api/fleet/epm/packages/${name}/${version}`).set('kbn-xsrf', 'xxxx'); @@ -38,8 +40,7 @@ export default function (providerContext: FtrProviderContext) { '../fixtures/direct_upload_packages/apache_0.1.4.zip' ); - // FLAKY: https://github.com/elastic/kibana/issues/163203 - describe.skip('EPM - get', () => { + describe('EPM - get', () => { skipIfNoDockerRegistry(providerContext); setupFleetAndAgents(providerContext); @@ -114,6 +115,7 @@ export default function (providerContext: FtrProviderContext) { before(async () => { await installPackage(testPkgName, testPkgVersion); await installPackage('experimental', '0.1.0'); + await bundlePackage('endpoint-8.6.1'); await installPackage('endpoint', '8.6.1'); }); after(async () => { @@ -121,6 +123,9 @@ export default function (providerContext: FtrProviderContext) { await uninstallPackage('experimental', '0.1.0'); await uninstallPackage('endpoint', '8.6.1'); }); + after(async () => { + await removeBundledPackages(log); + }); it('Allows the fetching of installed packages', async () => { const res = await supertest.get(`/api/fleet/epm/packages/installed`).expect(200); const packages = res.body.items; diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_bundled.ts b/x-pack/test/fleet_api_integration/apis/epm/install_bundled.ts index 3fdb609129994..4b91e8ac88e54 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_bundled.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_bundled.ts @@ -9,58 +9,59 @@ import expect from '@kbn/expect'; import fs from 'fs/promises'; import path from 'path'; +import { ToolingLog } from '@kbn/tooling-log'; import { BUNDLED_PACKAGE_DIR } from '../../config.base'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { skipIfNoDockerRegistry } from '../../helpers'; import { setupFleetAndAgents } from '../agents/services'; -export default function (providerContext: FtrProviderContext) { - const { getService } = providerContext; - const supertest = getService('supertest'); - const log = getService('log'); - - const BUNDLED_PACKAGE_FIXTURES_DIR = path.join( - path.dirname(__filename), - '../fixtures/bundled_packages' +const BUNDLED_PACKAGE_FIXTURES_DIR = path.join( + path.dirname(__filename), + '../fixtures/bundled_packages' +); + +export const bundlePackage = async (name: string) => { + try { + await fs.access(BUNDLED_PACKAGE_DIR); + } catch (error) { + await fs.mkdir(BUNDLED_PACKAGE_DIR); + } + + await fs.copyFile( + path.join(BUNDLED_PACKAGE_FIXTURES_DIR, `${name}.zip`), + path.join(BUNDLED_PACKAGE_DIR, `${name}.zip`) ); +}; - const bundlePackage = async (name: string) => { - try { - await fs.access(BUNDLED_PACKAGE_DIR); - } catch (error) { - await fs.mkdir(BUNDLED_PACKAGE_DIR); - } - - await fs.copyFile( - path.join(BUNDLED_PACKAGE_FIXTURES_DIR, `${name}.zip`), - path.join(BUNDLED_PACKAGE_DIR, `${name}.zip`) - ); - }; +export const removeBundledPackages = async (log: ToolingLog) => { + try { + const files = await fs.readdir(BUNDLED_PACKAGE_DIR); - const removeBundledPackages = async () => { - try { - const files = await fs.readdir(BUNDLED_PACKAGE_DIR); + for (const file of files) { + const isFixtureFile = !!(await fs.readFile(path.join(BUNDLED_PACKAGE_FIXTURES_DIR, file))); - for (const file of files) { - const isFixtureFile = !!(await fs.readFile(path.join(BUNDLED_PACKAGE_FIXTURES_DIR, file))); - - // Only remove fixture files - leave normal bundled packages in place - if (isFixtureFile) { - await fs.unlink(path.join(BUNDLED_PACKAGE_DIR, file)); - } + // Only remove fixture files - leave normal bundled packages in place + if (isFixtureFile) { + await fs.unlink(path.join(BUNDLED_PACKAGE_DIR, file)); } - } catch (error) { - log.error('Error removing bundled packages'); - log.error(error); } - }; + } catch (error) { + log.error('Error removing bundled packages'); + log.error(error); + } +}; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + const supertest = getService('supertest'); + const log = getService('log'); describe('installing bundled packages', async () => { skipIfNoDockerRegistry(providerContext); setupFleetAndAgents(providerContext); afterEach(async () => { - await removeBundledPackages(); + await removeBundledPackages(log); }); describe('without registry', () => { diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_endpoint.ts b/x-pack/test/fleet_api_integration/apis/epm/install_endpoint.ts index 892f89f7c2bb6..a4f313383f06c 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_endpoint.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_endpoint.ts @@ -9,14 +9,14 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { skipIfNoDockerRegistry } from '../../helpers'; import { setupFleetAndAgents } from '../agents/services'; +import { bundlePackage, removeBundledPackages } from './install_bundled'; export default function (providerContext: FtrProviderContext) { /** * There are a few features that are only currently supported for the Endpoint * package due to security concerns. */ - // Failing: See https://github.com/elastic/kibana/issues/156941 - describe.skip('Install endpoint package', () => { + describe('Install endpoint package', () => { const { getService } = providerContext; skipIfNoDockerRegistry(providerContext); setupFleetAndAgents(providerContext); @@ -25,8 +25,9 @@ export default function (providerContext: FtrProviderContext) { const dockerServers = getService('dockerServers'); const server = dockerServers.get('registry'); const es = getService('es'); + const log = getService('log'); const pkgName = 'endpoint'; - let pkgVersion: string; + const pkgVersion = '8.6.1'; const transforms = [ { @@ -39,12 +40,21 @@ export default function (providerContext: FtrProviderContext) { }, ]; + const installPackage = async (name: string, version: string) => { + await supertest + .post(`/api/fleet/epm/packages/${name}/${version}`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true }); + }; + before(async () => { if (!server.enabled) return; - // The latest endpoint package is already installed by default in our FTR config, - // just get the most recent version number. - const getResp = await supertest.get(`/api/fleet/epm/packages/${pkgName}`).expect(200); - pkgVersion = getResp.body.response.version; + await bundlePackage('endpoint-8.6.1'); + await installPackage('endpoint', '8.6.1'); + }); + after(async () => { + await uninstallPackage('endpoint', '8.6.1'); + await removeBundledPackages(log); }); describe('install', () => { diff --git a/x-pack/test/fleet_api_integration/apis/epm/list.ts b/x-pack/test/fleet_api_integration/apis/epm/list.ts index e07c215e4ad9f..6bccbc37a678c 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/list.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/list.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../../api_integration/ftr_provider_contex import { skipIfNoDockerRegistry } from '../../helpers'; import { setupFleetAndAgents } from '../agents/services'; import { testUsers } from '../test_users'; +import { bundlePackage, removeBundledPackages } from './install_bundled'; export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; @@ -21,9 +22,9 @@ export default function (providerContext: FtrProviderContext) { // because `this` has to point to the Mocha context // see https://mochajs.org/#arrow-functions - // FLAKY: https://github.com/elastic/kibana/issues/167188 - describe.skip('EPM - list', async function () { + describe('EPM - list', async function () { skipIfNoDockerRegistry(providerContext); + const log = getService('log'); before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); @@ -32,6 +33,9 @@ export default function (providerContext: FtrProviderContext) { after(async () => { await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); }); + after(async () => { + await removeBundledPackages(log); + }); describe('list api tests', async () => { it('lists all packages from the registry', async function () { @@ -47,6 +51,7 @@ export default function (providerContext: FtrProviderContext) { }); it('lists all limited packages from the registry', async function () { + await bundlePackage('endpoint-8.6.1'); const fetchLimitedPackageList = async () => { const response = await supertest .get('/api/fleet/epm/packages/limited') diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/bundled_packages/endpoint-8.6.1.zip b/x-pack/test/fleet_api_integration/apis/fixtures/bundled_packages/endpoint-8.6.1.zip new file mode 100644 index 0000000000000..4c20854aee729 Binary files /dev/null and b/x-pack/test/fleet_api_integration/apis/fixtures/bundled_packages/endpoint-8.6.1.zip differ diff --git a/x-pack/test/functional/apps/api_keys/home_page.ts b/x-pack/test/functional/apps/api_keys/home_page.ts index 316a2e32c47fd..7eb37c4665554 100644 --- a/x-pack/test/functional/apps/api_keys/home_page.ts +++ b/x-pack/test/functional/apps/api_keys/home_page.ts @@ -170,9 +170,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('Update API key'); - // Verify name input box are disabled - const apiKeyNameInput = await pageObjects.apiKeys.getApiKeyName(); - expect(await apiKeyNameInput.isEnabled()).to.be(false); + // Verify name input box is not present + expect(await pageObjects.apiKeys.isApiKeyNamePresent()).to.be(false); // Status should be displayed const apiKeyStatus = await pageObjects.apiKeys.getFlyoutApiKeyStatus(); @@ -278,9 +277,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('API key details'); - // Verify name input box are disabled - const apiKeyNameInput = await pageObjects.apiKeys.getApiKeyName(); - expect(await apiKeyNameInput.isEnabled()).to.be(false); + // Verify name input box is not present + expect(await pageObjects.apiKeys.isApiKeyNamePresent()).to.be(false); // Status should be displayed const apiKeyStatus = await pageObjects.apiKeys.getFlyoutApiKeyStatus(); @@ -324,9 +322,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('API key details'); - // Verify name input box are disabled - const apiKeyNameInput = await pageObjects.apiKeys.getApiKeyName(); - expect(await apiKeyNameInput.isEnabled()).to.be(false); + // Verify name input box is not present + expect(await pageObjects.apiKeys.isApiKeyNamePresent()).to.be(false); // Status should be displayed const apiKeyStatus = await pageObjects.apiKeys.getFlyoutApiKeyStatus(); @@ -365,9 +362,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('API key details'); - // Verify name input box are disabled - const apiKeyNameInput = await pageObjects.apiKeys.getApiKeyName(); - expect(await apiKeyNameInput.isEnabled()).to.be(false); + // Verify name input box is not present + expect(await pageObjects.apiKeys.isApiKeyNamePresent()).to.be(false); // Status should be displayed const apiKeyStatus = await pageObjects.apiKeys.getFlyoutApiKeyStatus(); diff --git a/x-pack/test/functional/apps/index_management/index_templates_tab/create_index_template.ts b/x-pack/test/functional/apps/index_management/index_templates_tab/create_index_template.ts index 9b9d869fb9980..68b75fa86a0c5 100644 --- a/x-pack/test/functional/apps/index_management/index_templates_tab/create_index_template.ts +++ b/x-pack/test/functional/apps/index_management/index_templates_tab/create_index_template.ts @@ -32,8 +32,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // Complete required fields from step 1 await testSubjects.setValue('nameField', INDEX_TEMPLATE_NAME); await testSubjects.setValue('indexPatternsField', 'test-1'); - // Enable data stream - await testSubjects.click('dataStreamField > input'); // Enable data retention await testSubjects.click('dataRetentionToggle > input'); // Set the retention to 7 hours diff --git a/x-pack/test/functional/apps/infra/hosts_view.ts b/x-pack/test/functional/apps/infra/hosts_view.ts index 13657713faac7..fc1750e1867d5 100644 --- a/x-pack/test/functional/apps/infra/hosts_view.ts +++ b/x-pack/test/functional/apps/infra/hosts_view.ts @@ -122,7 +122,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await retry.waitFor( 'wait for table and KPI charts to load', async () => - (await pageObjects.infraHostsView.isHostTableLoading()) && + (await pageObjects.infraHostsView.isHostTableLoaded()) && (await pageObjects.infraHostsView.isKPIChartsLoaded()) ); diff --git a/x-pack/test/functional/apps/lens/group1/ad_hoc_data_view.ts b/x-pack/test/functional/apps/lens/group1/ad_hoc_data_view.ts index cfe9cc7e5f304..fc8cbd2b38d66 100644 --- a/x-pack/test/functional/apps/lens/group1/ad_hoc_data_view.ts +++ b/x-pack/test/functional/apps/lens/group1/ad_hoc_data_view.ts @@ -69,7 +69,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ).getVisibleText(); expect(actualIndexPattern).to.be('*stash*'); - const actualDiscoverQueryHits = await testSubjects.getVisibleText('unifiedHistogramQueryHits'); + const actualDiscoverQueryHits = await testSubjects.getVisibleText('discoverQueryHits'); expect(actualDiscoverQueryHits).to.be('14,005'); expect(await PageObjects.unifiedSearch.isAdHocDataView()).to.be(true); }; @@ -208,9 +208,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ).getVisibleText(); expect(actualIndexPattern).to.be('*stash*'); - const actualDiscoverQueryHits = await testSubjects.getVisibleText( - 'unifiedHistogramQueryHits' - ); + const actualDiscoverQueryHits = await testSubjects.getVisibleText('discoverQueryHits'); expect(actualDiscoverQueryHits).to.be('14,005'); const prevDataViewId = await PageObjects.discover.getCurrentDataViewId(); diff --git a/x-pack/test/functional/apps/lens/group3/dashboard_inline_editing.ts b/x-pack/test/functional/apps/lens/group3/dashboard_inline_editing.ts index 1f09b68fbf4f2..0dd8ebe13e805 100644 --- a/x-pack/test/functional/apps/lens/group3/dashboard_inline_editing.ts +++ b/x-pack/test/functional/apps/lens/group3/dashboard_inline_editing.ts @@ -72,7 +72,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { subtitle: undefined, extraText: 'Maximum of bytes 19,986', value: '5,727.322', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', trendlineColor: undefined, showingTrendline: false, showingBar: false, @@ -138,7 +138,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { subtitle: undefined, extraText: '', value: '5,727.322', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', trendlineColor: undefined, showingTrendline: false, showingBar: false, diff --git a/x-pack/test/functional/apps/lens/group6/index.ts b/x-pack/test/functional/apps/lens/group6/index.ts index bc59c2878805e..60b9ce859b07f 100644 --- a/x-pack/test/functional/apps/lens/group6/index.ts +++ b/x-pack/test/functional/apps/lens/group6/index.ts @@ -80,8 +80,9 @@ export default ({ getService, loadTestFile, getPageObjects }: FtrProviderContext loadTestFile(require.resolve('./inspector')); // 1m 19s loadTestFile(require.resolve('./error_handling')); // 1m 8s loadTestFile(require.resolve('./lens_tagging')); // 1m 9s + loadTestFile(require.resolve('./workspace_size')); + // keep these last in the group in this order because they are messing with the default saved objects loadTestFile(require.resolve('./lens_reporting')); // 3m - // keep these two last in the group in this order because they are messing with the default saved objects loadTestFile(require.resolve('./rollup')); // 1m 30s loadTestFile(require.resolve('./no_data')); // 36s }); diff --git a/x-pack/test/functional/apps/lens/group6/metric.ts b/x-pack/test/functional/apps/lens/group6/metric.ts index e1e2644907096..14e46705d6d6b 100644 --- a/x-pack/test/functional/apps/lens/group6/metric.ts +++ b/x-pack/test/functional/apps/lens/group6/metric.ts @@ -127,8 +127,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { subtitle: 'Average of bytes', extraText: 'Average of bytes 19,755', value: '19,755', - color: 'rgba(245, 247, 250, 1)', - trendlineColor: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', + trendlineColor: 'rgba(255, 255, 255, 1)', showingTrendline: true, showingBar: false, }, @@ -137,8 +137,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { subtitle: 'Average of bytes', extraText: 'Average of bytes 18,994', value: '18,994', - color: 'rgba(245, 247, 250, 1)', - trendlineColor: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', + trendlineColor: 'rgba(255, 255, 255, 1)', showingTrendline: true, showingBar: false, }, @@ -147,8 +147,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { subtitle: 'Average of bytes', extraText: 'Average of bytes 17,246', value: '17,246', - color: 'rgba(245, 247, 250, 1)', - trendlineColor: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', + trendlineColor: 'rgba(255, 255, 255, 1)', showingTrendline: true, showingBar: false, }, @@ -157,8 +157,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { subtitle: 'Average of bytes', extraText: 'Average of bytes 15,687', value: '15,687', - color: 'rgba(245, 247, 250, 1)', - trendlineColor: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', + trendlineColor: 'rgba(255, 255, 255, 1)', showingTrendline: true, showingBar: false, }, @@ -167,8 +167,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { subtitle: 'Average of bytes', extraText: 'Average of bytes 15,614.333', value: '15,614.333', - color: 'rgba(245, 247, 250, 1)', - trendlineColor: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', + trendlineColor: 'rgba(255, 255, 255, 1)', showingTrendline: true, showingBar: false, }, @@ -177,8 +177,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { subtitle: 'Average of bytes', extraText: 'Average of bytes 5,722.775', value: '5,722.775', - color: 'rgba(245, 247, 250, 1)', - trendlineColor: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', + trendlineColor: 'rgba(255, 255, 255, 1)', showingTrendline: true, showingBar: false, }, diff --git a/x-pack/test/functional/apps/lens/group6/workspace_size.ts b/x-pack/test/functional/apps/lens/group6/workspace_size.ts new file mode 100644 index 0000000000000..165b429b03733 --- /dev/null +++ b/x-pack/test/functional/apps/lens/group6/workspace_size.ts @@ -0,0 +1,254 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['visualize', 'lens', 'common']); + const browser = getService('browser'); + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const log = getService('log'); + + describe('lens workspace size', () => { + let originalWindowSize: { + height: number; + width: number; + x: number; + y: number; + }; + + const DEFAULT_WINDOW_SIZE = [1400, 900]; + + before(async () => { + originalWindowSize = await browser.getWindowSize(); + + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + }); + + beforeEach(async () => { + await browser.setWindowSize(DEFAULT_WINDOW_SIZE[0], DEFAULT_WINDOW_SIZE[1]); + }); + + after(async () => { + await browser.setWindowSize(originalWindowSize.width, originalWindowSize.height); + }); + + const pxToN = (pixels: string) => Number(pixels.substring(0, pixels.length - 2)); + + const assertWorkspaceDimensions = async (expectedWidth: string, expectedHeight: string) => { + const tolerance = 1; + + await retry.try(async () => { + const { width, height } = await PageObjects.lens.getWorkspaceVisContainerDimensions(); + + expect(pxToN(width)).to.within( + pxToN(expectedWidth) - tolerance, + pxToN(expectedWidth) + tolerance + ); + expect(pxToN(height)).to.within( + pxToN(expectedHeight) - tolerance, + pxToN(expectedHeight) + tolerance + ); + }); + }; + + const assertWorkspaceAspectRatio = async (expectedRatio: number) => { + const tolerance = 0.05; + + await retry.try(async () => { + const { width, height } = await PageObjects.lens.getWorkspaceVisContainerDimensions(); + + expect(pxToN(width) / pxToN(height)).to.within( + expectedRatio - tolerance, + expectedRatio + tolerance + ); + }); + }; + + const assertWorkspaceStyles = async (expectedStyles: { + aspectRatio: string; + minHeight: string; + minWidth: string; + maxHeight: string; + maxWidth: string; + }) => { + const actualStyles = await PageObjects.lens.getWorkspaceVisContainerStyles(); + + expect(actualStyles).to.eql(expectedStyles); + }; + + const VERTICAL_16_9 = 16 / 9; + const outerWorkspaceDimensions = { width: 690, height: 400 }; + const UNCONSTRAINED = outerWorkspaceDimensions.width / outerWorkspaceDimensions.height; + + it('workspace size recovers from special vis types', async () => { + /** + * This list is specifically designed to test dimension transitions. + * + * I have attempted to order the vis types to maximize the number of transitions. + * + * Excluding XY charts since they are tested separately. + */ + const visTypes: Array<{ + id: string; + searchText?: string; + expectedHeight?: string; + expectedWidth?: string; + aspectRatio?: number; + }> = [ + { + id: 'lnsMetric', + expectedWidth: '300px', + expectedHeight: '300px', + }, + { id: 'lnsDatatable', aspectRatio: UNCONSTRAINED }, + { + id: 'lnsMetric', + expectedWidth: '300px', + expectedHeight: '300px', + }, + { id: 'lnsLegacyMetric', aspectRatio: UNCONSTRAINED }, + { + id: 'lnsMetric', + expectedWidth: '300px', + expectedHeight: '300px', + }, + { id: 'donut', aspectRatio: UNCONSTRAINED }, + { + id: 'lnsMetric', + expectedWidth: '300px', + expectedHeight: '300px', + }, + { id: 'mosaic', aspectRatio: UNCONSTRAINED }, + { + id: 'lnsMetric', + expectedWidth: '300px', + expectedHeight: '300px', + }, + { id: 'pie', aspectRatio: UNCONSTRAINED }, + { + id: 'lnsMetric', + expectedWidth: '300px', + expectedHeight: '300px', + }, + { id: 'treemap', aspectRatio: UNCONSTRAINED }, + { + id: 'lnsMetric', + expectedWidth: '300px', + expectedHeight: '300px', + }, + { id: 'waffle', aspectRatio: UNCONSTRAINED }, + // { id: 'heatmap', ...UNCONSTRAINED }, // heatmap blocks render unless it's given two dimensions. This stops the expression renderer from requesting new dimensions. + // { id: 'lnsChoropleth', ...UNCONSTRAINED }, // choropleth currently erases all dimensions + // { id: 'lnsTagcloud', ...UNCONSTRAINED }, // tag cloud currently erases all dimensions + ]; + + while (visTypes.length) { + const vis = visTypes.pop()!; + await retry.try(async () => { + await PageObjects.lens.switchToVisualization(vis.id, vis.searchText); + }); + + log.debug(`Testing ${vis.id}... expecting ${vis.expectedWidth}x${vis.expectedHeight}`); + + if (vis.aspectRatio) { + await assertWorkspaceAspectRatio(vis.aspectRatio); + } else { + await assertWorkspaceDimensions(vis.expectedWidth!, vis.expectedHeight!); + } + } + }); + + it('metric size (absolute pixels)', async () => { + await retry.try(async () => { + await PageObjects.lens.switchToVisualization('lnsMetric'); + }); + + await assertWorkspaceDimensions('300px', '300px'); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsMetric_breakdownByDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'ip', + }); + + await assertWorkspaceDimensions('600px', '400px'); + + await PageObjects.lens.openDimensionEditor('lnsMetric_breakdownByDimensionPanel'); + await testSubjects.setValue('lnsMetric_max_cols', '2'); + + await assertWorkspaceDimensions('400px', '400px'); + }); + + it('gauge size (absolute pixels)', async () => { + await retry.try(async () => { + await PageObjects.lens.switchToVisualization('horizontalBullet', 'gauge'); + }); + + await assertWorkspaceDimensions('600px', '300px'); + + await retry.try(async () => { + await PageObjects.lens.switchToVisualization('verticalBullet', 'gauge'); + }); + + // this height is below the requested 600px + // that is because the window size isn't large enough to fit the requested dimensions + // and the chart is forced to shrink. + // + // this is a good thing because it makes this a test case for that scenario + await assertWorkspaceDimensions('300px', '400px'); + }); + + it('XY chart size', async () => { + // XY charts should have 100% width and 100% height unless they are a vertical chart with a time dimension + await retry.try(async () => { + // not important that this is specifically a line chart + await PageObjects.lens.switchToVisualization('line'); + }); + + await assertWorkspaceStyles({ + aspectRatio: 'auto', + minHeight: 'auto', + minWidth: 'auto', + maxHeight: '100%', + maxWidth: '100%', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + + await assertWorkspaceStyles({ + aspectRatio: '16 / 9', + minHeight: '300px', + minWidth: '100%', + maxHeight: 'none', + maxWidth: 'none', + }); + + await assertWorkspaceAspectRatio(VERTICAL_16_9); + + await retry.try(async () => { + await PageObjects.lens.switchToVisualization('bar_horizontal_stacked'); + }); + + await assertWorkspaceAspectRatio(UNCONSTRAINED); + }); + }); +} diff --git a/x-pack/test/functional/apps/lens/open_in_lens/agg_based/goal.ts b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/goal.ts index 55cb376db2e24..d9152af7411ba 100644 --- a/x-pack/test/functional/apps/lens/open_in_lens/agg_based/goal.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/goal.ts @@ -48,7 +48,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: undefined, extraText: '', value: '140.05%', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', trendlineColor: undefined, showingBar: true, showingTrendline: false, @@ -80,7 +80,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: undefined, extraText: '', value: '131,040,360.81%', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', trendlineColor: undefined, showingBar: true, showingTrendline: false, @@ -112,7 +112,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: undefined, extraText: '', value: '14.37%', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', trendlineColor: undefined, showingBar: true, showingTrendline: false, @@ -156,7 +156,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: 'Average machine.ram', extraText: '', value: '65,047,486.03', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', trendlineColor: undefined, showingBar: true, showingTrendline: false, @@ -166,7 +166,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: 'Average machine.ram', extraText: '', value: '66,144,823.35', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', trendlineColor: undefined, showingBar: true, showingTrendline: false, @@ -176,7 +176,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: 'Average machine.ram', extraText: '', value: '65,933,477.76', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', trendlineColor: undefined, showingBar: true, showingTrendline: false, @@ -186,7 +186,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: 'Average machine.ram', extraText: '', value: '65,157,898.23', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', trendlineColor: undefined, showingBar: true, showingTrendline: false, @@ -196,7 +196,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: 'Average machine.ram', extraText: '', value: '65,365,950.93', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', trendlineColor: undefined, showingBar: true, showingTrendline: false, diff --git a/x-pack/test/functional/apps/lens/open_in_lens/agg_based/metric.ts b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/metric.ts index 89cb1d7880baa..632af7eed9f98 100644 --- a/x-pack/test/functional/apps/lens/open_in_lens/agg_based/metric.ts +++ b/x-pack/test/functional/apps/lens/open_in_lens/agg_based/metric.ts @@ -49,7 +49,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: undefined, extraText: '', value: '14,005', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', trendlineColor: undefined, showingBar: false, showingTrendline: false, @@ -80,7 +80,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: undefined, extraText: '', value: '13,104,036,080.615', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', trendlineColor: undefined, showingBar: false, showingTrendline: false, @@ -111,7 +111,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: undefined, extraText: '', value: '1,437', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', trendlineColor: undefined, showingBar: false, showingTrendline: false, @@ -166,7 +166,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: 'Average machine.ram', extraText: '', value: '13,228,964,670.613', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', trendlineColor: undefined, showingBar: false, showingTrendline: false, @@ -176,7 +176,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: 'Average machine.ram', extraText: '', value: '13,186,695,551.251', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', trendlineColor: undefined, showingBar: false, showingTrendline: false, @@ -186,7 +186,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: 'Average machine.ram', extraText: '', value: '13,073,190,186.423', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', trendlineColor: undefined, showingBar: false, showingTrendline: false, @@ -196,7 +196,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: 'Average machine.ram', extraText: '', value: '13,031,579,645.108', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', trendlineColor: undefined, showingBar: false, showingTrendline: false, @@ -206,7 +206,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: 'Average machine.ram', extraText: '', value: '13,009,497,206.823', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', trendlineColor: undefined, showingBar: false, showingTrendline: false, diff --git a/x-pack/test/functional/page_objects/api_keys_page.ts b/x-pack/test/functional/page_objects/api_keys_page.ts index 0875774470018..8f74f927b976f 100644 --- a/x-pack/test/functional/page_objects/api_keys_page.ts +++ b/x-pack/test/functional/page_objects/api_keys_page.ts @@ -45,6 +45,10 @@ export function ApiKeysPageProvider({ getService }: FtrProviderContext) { return await testSubjects.find('apiKeyNameInput'); }, + async isApiKeyNamePresent() { + return await testSubjects.exists('apiKeyNameInput'); + }, + async setApiKeyCustomExpiration(expirationTime: string) { return await testSubjects.setValue('apiKeyCustomExpirationInput', expirationTime); }, diff --git a/x-pack/test/functional/page_objects/infra_hosts_view.ts b/x-pack/test/functional/page_objects/infra_hosts_view.ts index 0c3004acc4fe8..60c4f0727e7c0 100644 --- a/x-pack/test/functional/page_objects/infra_hosts_view.ts +++ b/x-pack/test/functional/page_objects/infra_hosts_view.ts @@ -49,11 +49,11 @@ export function InfraHostsViewProvider({ getService }: FtrProviderContext) { // Table async getHostsTable() { - return testSubjects.find('hostsView-table'); + return testSubjects.find('hostsView-table-loaded'); }, - async isHostTableLoading() { - return !(await testSubjects.exists('tbody[class*=euiBasicTableBodyLoading]')); + async isHostTableLoaded() { + return !(await testSubjects.exists('hostsView-table-loading')); }, async getHostsTableData() { diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 2af514b4a1fdc..747767a71befe 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -1947,5 +1947,28 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont await this.closeDimensionEditor(); }, + + async getWorkspaceVisContainerDimensions() { + const visContainer = await testSubjects.find('lnsWorkspacePanelWrapper__innerContent'); + const [width, height] = await Promise.all([ + visContainer.getComputedStyle('width'), + visContainer.getComputedStyle('height'), + ]); + + return { width, height }; + }, + + async getWorkspaceVisContainerStyles() { + const visContainer = await testSubjects.find('lnsWorkspacePanelWrapper__innerContent'); + const [maxWidth, maxHeight, minWidth, minHeight, aspectRatio] = await Promise.all([ + visContainer.getComputedStyle('max-width'), + visContainer.getComputedStyle('max-height'), + visContainer.getComputedStyle('min-width'), + visContainer.getComputedStyle('min-height'), + visContainer.getComputedStyle('aspect-ratio'), + ]); + + return { maxWidth, maxHeight, minWidth, minHeight, aspectRatio }; + }, }); } diff --git a/x-pack/test/functional/services/aiops/log_pattern_analysis_page.ts b/x-pack/test/functional/services/aiops/log_pattern_analysis_page.ts index 9c0ab19ac64c4..18b5004393027 100644 --- a/x-pack/test/functional/services/aiops/log_pattern_analysis_page.ts +++ b/x-pack/test/functional/services/aiops/log_pattern_analysis_page.ts @@ -133,13 +133,13 @@ export function LogPatternAnalysisPageProvider({ getService, getPageObject }: Ft async assertDiscoverDocCountExists() { await retry.tryForTime(30 * 1000, async () => { - await testSubjects.existOrFail('unifiedHistogramQueryHits'); + await testSubjects.existOrFail('discoverQueryHits'); }); }, async assertDiscoverDocCount(expectedDocCount: number) { await retry.tryForTime(5000, async () => { - const docCount = await testSubjects.getVisibleText('unifiedHistogramQueryHits'); + const docCount = await testSubjects.getVisibleText('discoverQueryHits'); const formattedDocCount = docCount.replaceAll(',', ''); expect(formattedDocCount).to.eql( expectedDocCount, diff --git a/x-pack/test/functional/services/cases/navigation.ts b/x-pack/test/functional/services/cases/navigation.ts index 8d3ba0e73a24c..f0d4fb52ba5e4 100644 --- a/x-pack/test/functional/services/cases/navigation.ts +++ b/x-pack/test/functional/services/cases/navigation.ts @@ -22,8 +22,9 @@ export function CasesNavigationProvider({ getPageObject, getService }: FtrProvid await common.clickAndValidate('configure-case-button', 'case-configure-title'); }, - async navigateToSingleCase(app: string = 'cases', caseId: string) { - await common.navigateToUrlWithBrowserHistory(app, caseId); + async navigateToSingleCase(app: string = 'cases', caseId: string, tabId?: string) { + const search = tabId != null ? `?tabId=${tabId}` : ''; + await common.navigateToUrlWithBrowserHistory(app, caseId, search); }, }; } diff --git a/x-pack/test/functional/services/transform/discover.ts b/x-pack/test/functional/services/transform/discover.ts index 07bd66cce1780..02cf7a98310eb 100644 --- a/x-pack/test/functional/services/transform/discover.ts +++ b/x-pack/test/functional/services/transform/discover.ts @@ -14,11 +14,9 @@ export function TransformDiscoverProvider({ getService }: FtrProviderContext) { return { async assertDiscoverQueryHits(expectedDiscoverQueryHits: string) { - await testSubjects.existOrFail('unifiedHistogramQueryHits'); + await testSubjects.existOrFail('discoverQueryHits'); - const actualDiscoverQueryHits = await testSubjects.getVisibleText( - 'unifiedHistogramQueryHits' - ); + const actualDiscoverQueryHits = await testSubjects.getVisibleText('discoverQueryHits'); expect(actualDiscoverQueryHits).to.eql( expectedDiscoverQueryHits, @@ -27,18 +25,16 @@ export function TransformDiscoverProvider({ getService }: FtrProviderContext) { }, async assertDiscoverQueryHitsMoreThanZero() { - await testSubjects.existOrFail('unifiedHistogramQueryHits'); + await testSubjects.existOrFail('discoverQueryHits'); - const actualDiscoverQueryHits = await testSubjects.getVisibleText( - 'unifiedHistogramQueryHits' - ); + const actualDiscoverQueryHits = await testSubjects.getVisibleText('discoverQueryHits'); const hits = parseInt(actualDiscoverQueryHits, 10); expect(hits).to.greaterThan(0, `Discover query hits should be more than 0, got ${hits}`); }, async assertNoResults(expectedDestinationIndex: string) { - await testSubjects.missingOrFail('unifiedHistogramQueryHits'); + await testSubjects.missingOrFail('discoverQueryHits'); // Discover should use the destination index pattern const actualIndexPatternSwitchLinkText = await ( diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts index 6979c45f867ba..cab3cc82e8a4b 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts @@ -46,7 +46,10 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('should show the case view page correctly', async () => { await testSubjects.existOrFail('case-view-title'); await testSubjects.existOrFail('header-page-supplements'); + await testSubjects.existOrFail('case-action-bar-wrapper'); + await testSubjects.existOrFail('case-view-tabs'); + await testSubjects.existOrFail('case-view-tab-title-alerts'); await testSubjects.existOrFail('case-view-tab-title-activity'); await testSubjects.existOrFail('case-view-tab-title-files'); await testSubjects.existOrFail('description'); @@ -1013,11 +1016,26 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { describe('Tabs', () => { createOneCaseBeforeDeleteAllAfter(getPageObject, getService); + it('renders tabs correctly', async () => { + await testSubjects.existOrFail('case-view-tab-title-activity'); + await testSubjects.existOrFail('case-view-tab-title-files'); + await testSubjects.existOrFail('case-view-tab-title-alerts'); + }); + it('shows the "activity" tab by default', async () => { await testSubjects.existOrFail('case-view-tab-title-activity'); await testSubjects.existOrFail('case-view-tab-content-activity'); }); + it("shows the 'activity' tab when clicked", async () => { + // Go to the files tab first + await testSubjects.click('case-view-tab-title-files'); + await testSubjects.existOrFail('case-view-tab-content-files'); + + await testSubjects.click('case-view-tab-title-activity'); + await testSubjects.existOrFail('case-view-tab-content-activity'); + }); + it("shows the 'alerts' tab when clicked", async () => { await testSubjects.click('case-view-tab-title-alerts'); await testSubjects.existOrFail('case-view-tab-content-alerts'); @@ -1027,6 +1045,36 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await testSubjects.click('case-view-tab-title-files'); await testSubjects.existOrFail('case-view-tab-content-files'); }); + + describe('Query params', () => { + it('renders the activity tab when the query parameter tabId=activity', async () => { + const theCase = await createAndNavigateToCase(getPageObject, getService); + + await cases.navigation.navigateToSingleCase('cases', theCase.id, 'activity'); + await testSubjects.existOrFail('case-view-tab-title-activity'); + }); + + it('renders the activity tab when the query parameter tabId=alerts', async () => { + const theCase = await createAndNavigateToCase(getPageObject, getService); + + await cases.navigation.navigateToSingleCase('cases', theCase.id, 'alerts'); + await testSubjects.existOrFail('case-view-tab-title-activity'); + }); + + it('renders the activity tab when the query parameter tabId=files', async () => { + const theCase = await createAndNavigateToCase(getPageObject, getService); + + await cases.navigation.navigateToSingleCase('cases', theCase.id, 'files'); + await testSubjects.existOrFail('case-view-tab-content-files'); + }); + + it('renders the activity tab when the query parameter tabId has an unknown value', async () => { + const theCase = await createAndNavigateToCase(getPageObject, getService); + + await cases.navigation.navigateToSingleCase('cases', theCase.id, 'fake'); + await testSubjects.existOrFail('case-view-tab-title-activity'); + }); + }); }); describe('Files', () => { diff --git a/x-pack/test/security_api_integration/plugins/saml_provider/kibana.jsonc b/x-pack/test/security_api_integration/plugins/saml_provider/kibana.jsonc index 82ab9eb2cf59a..1aa22257908ce 100644 --- a/x-pack/test/security_api_integration/plugins/saml_provider/kibana.jsonc +++ b/x-pack/test/security_api_integration/plugins/saml_provider/kibana.jsonc @@ -5,6 +5,9 @@ "plugin": { "id": "samlProviderPlugin", "server": true, - "browser": false + "browser": false, + "optionalPlugins": [ + "cloud" + ] } } diff --git a/x-pack/test/security_api_integration/plugins/saml_provider/server/index.ts b/x-pack/test/security_api_integration/plugins/saml_provider/server/index.ts index 9a5efa5fa6861..ad297baf7246f 100644 --- a/x-pack/test/security_api_integration/plugins/saml_provider/server/index.ts +++ b/x-pack/test/security_api_integration/plugins/saml_provider/server/index.ts @@ -6,10 +6,17 @@ */ import type { PluginInitializer, Plugin } from '@kbn/core/server'; +import { CloudSetup } from '@kbn/cloud-plugin/server'; import { initRoutes } from './init_routes'; -export const plugin: PluginInitializer = async (): Promise => ({ - setup: (core) => initRoutes(core), +export interface PluginSetupDependencies { + cloud?: CloudSetup; +} + +export const plugin: PluginInitializer = async ( + context +): Promise => ({ + setup: (core, plugins: PluginSetupDependencies) => initRoutes(context, core, plugins), start: () => {}, stop: () => {}, }); diff --git a/x-pack/test/security_api_integration/plugins/saml_provider/server/init_routes.ts b/x-pack/test/security_api_integration/plugins/saml_provider/server/init_routes.ts index 6ef30b808ada2..ea23e04201a61 100644 --- a/x-pack/test/security_api_integration/plugins/saml_provider/server/init_routes.ts +++ b/x-pack/test/security_api_integration/plugins/saml_provider/server/init_routes.ts @@ -5,13 +5,18 @@ * 2.0. */ -import { CoreSetup } from '@kbn/core/server'; +import { CoreSetup, PluginInitializerContext } from '@kbn/core/server'; import { getSAMLResponse, getSAMLRequestId, } from '@kbn/security-api-integration-helpers/saml/saml_tools'; +import { PluginSetupDependencies } from '.'; -export function initRoutes(core: CoreSetup) { +export function initRoutes( + pluginContext: PluginInitializerContext, + core: CoreSetup, + plugins: PluginSetupDependencies +) { const serverInfo = core.http.getServerInfo(); core.http.resources.register( { @@ -59,6 +64,26 @@ export function initRoutes(core: CoreSetup) { } ); + // [HACK]: On CI, Kibana runs Serverless functional tests against the production Kibana build but still relies on Mock + // IdP for SAML authentication in tests. The Mock IdP SAML realm, in turn, is linked to a Mock IDP plugin in Kibana + // that's only included in development mode and not available in the production Kibana build. Until our testing + // framework can properly support all SAML flows, we should forward all relevant Mock IDP plugin endpoints to a logout + // destination normally used in the Serverless setup. + if (pluginContext.env.mode.prod) { + core.http.resources.register( + { + path: '/mock_idp/login', + validate: false, + options: { authRequired: false }, + }, + async (context, request, response) => { + return response.redirected({ + headers: { location: plugins.cloud?.projectsUrl ?? '/login' }, + }); + } + ); + } + let attemptsCounter = 0; core.http.resources.register( { diff --git a/x-pack/test/security_api_integration/plugins/saml_provider/tsconfig.json b/x-pack/test/security_api_integration/plugins/saml_provider/tsconfig.json index 5063eccab4842..5021e45b8f8b3 100644 --- a/x-pack/test/security_api_integration/plugins/saml_provider/tsconfig.json +++ b/x-pack/test/security_api_integration/plugins/saml_provider/tsconfig.json @@ -11,6 +11,7 @@ "target/**/*", ], "kbn_references": [ + "@kbn/cloud-plugin", "@kbn/core", "@kbn/security-api-integration-helpers", ] diff --git a/x-pack/test/security_solution_api_integration/config/ess/config.base.ts b/x-pack/test/security_solution_api_integration/config/ess/config.base.ts index 89e6df3c68cd2..a73480c051ee4 100644 --- a/x-pack/test/security_solution_api_integration/config/ess/config.base.ts +++ b/x-pack/test/security_solution_api_integration/config/ess/config.base.ts @@ -51,7 +51,7 @@ export function createTestConfig(options: CreateTestConfigOptions, testFiles?: s servers, services, junit: { - reportName: 'X-Pack Detection Engine API Integration Tests', + reportName: 'X-Pack Security Solution API Integration Tests', }, esTestCluster: { ...xPackApiIntegrationTestsConfig.get('esTestCluster'), diff --git a/x-pack/test/security_solution_api_integration/package.json b/x-pack/test/security_solution_api_integration/package.json index b3b75eed86ca3..4dc18852ec0e9 100644 --- a/x-pack/test/security_solution_api_integration/package.json +++ b/x-pack/test/security_solution_api_integration/package.json @@ -13,36 +13,43 @@ "run-tests:ea:default": "node ./scripts/index.js runner entity_analytics default_license", "initialize-server:lists:default": "node ./scripts/index.js server lists_and_exception_lists default_license", "run-tests:lists:default": "node ./scripts/index.js runner lists_and_exception_lists default_license", + "exception_workflows:server:serverless": "npm run initialize-server:dr:default exceptions/workflows serverless", "exception_workflows:runner:serverless": "npm run run-tests:dr:default exceptions/workflows serverless serverlessEnv", "exception_workflows:qa:serverless": "npm run run-tests:dr:default exceptions/workflows serverless qaEnv", "exception_workflows:server:ess": "npm run initialize-server:dr:default exceptions/workflows ess", "exception_workflows:runner:ess": "npm run run-tests:dr:default exceptions/workflows ess essEnv", + "exception_operators_date_numeric_types:server:serverless": "npm run initialize-server:dr:default exceptions/operators_data_types/date_numeric_types serverless", "exception_operators_date_numeric_types:runner:serverless": "npm run run-tests:dr:default exceptions/operators_data_types/date_numeric_types serverless serverlessEnv", "exception_operators_date_numeric_types:qa:serverless": "npm run run-tests:dr:default exceptions/operators_data_types/date_numeric_types serverless qaEnv", "exception_operators_date_numeric_types:server:ess": "npm run initialize-server:dr:default exceptions/operators_data_types/date_numeric_types ess", "exception_operators_date_numeric_types:runner:ess": "npm run run-tests:dr:default exceptions/operators_data_types/date_numeric_types ess essEnv", + "exception_operators_keyword:server:serverless": "npm run initialize-server:dr:default exceptions/operators_data_types/keyword serverless", "exception_operators_keyword:runner:serverless": "npm run run-tests:dr:default exceptions/operators_data_types/keyword serverless serverlessEnv", "exception_operators_keyword:qa:serverless": "npm run run-tests:dr:default exceptions/operators_data_types/keyword serverless qaEnv", "exception_operators_keyword:server:ess": "npm run initialize-server:dr:default exceptions/operators_data_types/keyword ess", "exception_operators_keyword:runner:ess": "npm run run-tests:dr:default exceptions/operators_data_types/keyword ess essEnv", + "exception_operators_ips:server:serverless": "npm run initialize-server:dr:default exceptions/operators_data_types/ips serverless", "exception_operators_ips:runner:serverless": "npm run run-tests:dr:default exceptions/operators_data_types/ips serverless serverlessEnv", "exception_operators_ips:qa:serverless": "npm run run-tests:dr:default exceptions/operators_data_types/ips serverless qaEnv", "exception_operators_ips:server:ess": "npm run initialize-server:dr:default exceptions/operators_data_types/ips ess", "exception_operators_ips:runner:ess": "npm run run-tests:dr:default exceptions/operators_data_types/ips ess essEnv", + "exception_operators_long:server:serverless": "npm run initialize-server:dr:default exceptions/operators_data_types/long serverless", "exception_operators_long:runner:serverless": "npm run run-tests:dr:default exceptions/operators_data_types/long serverless serverlessEnv", "exception_operators_long:qa:serverless": "npm run run-tests:dr:default exceptions/operators_data_types/long serverless qaEnv", "exception_operators_long:server:ess": "npm run initialize-server:dr:default exceptions/operators_data_types/long ess", "exception_operators_long:runner:ess": "npm run run-tests:dr:default exceptions/operators_data_types/long ess essEnv", + "exception_operators_text:server:serverless": "npm run initialize-server:dr:default exceptions/operators_data_types/text serverless", "exception_operators_text:runner:serverless": "npm run run-tests:dr:default exceptions/operators_data_types/text serverless serverlessEnv", "exception_operators_text:qa:serverless": "npm run run-tests:dr:default exceptions/operators_data_types/text serverless qaEnv", "exception_operators_text:server:ess": "npm run initialize-server:dr:default exceptions/operators_data_types/text ess", "exception_operators_text:runner:ess": "npm run run-tests:dr:default exceptions/operators_data_types/text ess essEnv", + "exception_operators_ips_text_array:server:serverless": "npm run initialize-server:dr:default exceptions/operators_data_types/ips_text_array serverless", "exception_operators_ips_text_array:runner:serverless": "npm run run-tests:dr:default exceptions/operators_data_types/ips_text_array serverless serverlessEnv", "exception_operators_ips_text_array:qa:serverless": "npm run run-tests:dr:default exceptions/operators_data_types/ips_text_array serverless qaEnv", @@ -54,31 +61,37 @@ "actions:qa:serverless": "npm run run-tests:dr:default actions serverless qaEnv", "actions:server:ess": "npm run initialize-server:dr:default actions ess", "actions:runner:ess": "npm run run-tests:dr:default actions ess essEnv", + "alerts:server:serverless": "npm run initialize-server:dr:default alerts serverless", "alerts:runner:serverless": "npm run run-tests:dr:default alerts serverless serverlessEnv", "alerts:qa:serverless": "npm run run-tests:dr:default alerts serverless qaEnv", "alerts:server:ess": "npm run initialize-server:dr:default alerts ess", "alerts:runner:ess": "npm run run-tests:dr:default alerts ess essEnv", + "entity_analytics:server:serverless": "npm run initialize-server:ea:default risk_engine serverless", "entity_analytics:runner:serverless": "npm run run-tests:ea:default risk_engine serverless serverlessEnv", "entity_analytics:qa:serverless": "npm run run-tests:ea:default risk_engine serverless qaEnv", "entity_analytics:server:ess": "npm run initialize-server:ea:default risk_engine ess", "entity_analytics:runner:ess": "npm run run-tests:ea:default risk_engine ess essEnv", + "prebuilt_rules_management:server:serverless": "npm run initialize-server:dr:default prebuilt_rules/management serverless", "prebuilt_rules_management:runner:serverless": "npm run run-tests:dr:default prebuilt_rules/management serverless serverlessEnv", "prebuilt_rules_management:qa:serverless": "npm run run-tests:dr:default prebuilt_rules/management serverless qaEnv", "prebuilt_rules_management:server:ess": "npm run initialize-server:dr:default prebuilt_rules/management ess", "prebuilt_rules_management:runner:ess": "npm run run-tests:dr:default prebuilt_rules/management ess essEnv", + "prebuilt_rules_bundled_prebuilt_rules_package:server:serverless": "npm run initialize-server:dr:default prebuilt_rules/bundled_prebuilt_rules_package serverless", "prebuilt_rules_bundled_prebuilt_rules_package:runner:serverless": "npm run run-tests:dr:default prebuilt_rules/bundled_prebuilt_rules_package serverless serverlessEnv", "prebuilt_rules_bundled_prebuilt_rules_package:qa:serverless": "npm run run-tests:dr:default prebuilt_rules/bundled_prebuilt_rules_package serverless qaEnv", "prebuilt_rules_bundled_prebuilt_rules_package:server:ess": "npm run initialize-server:dr:default prebuilt_rules/bundled_prebuilt_rules_package ess", "prebuilt_rules_bundled_prebuilt_rules_package:runner:ess": "npm run run-tests:dr:default prebuilt_rules/bundled_prebuilt_rules_package ess essEnv", + "prebuilt_rules_large_prebuilt_rules_package:server:serverless": "npm run initialize-server:dr:default prebuilt_rules/large_prebuilt_rules_package serverless", "prebuilt_rules_large_prebuilt_rules_package:runner:serverless": "npm run run-tests:dr:default prebuilt_rules/large_prebuilt_rules_package serverless serverlessEnv", "prebuilt_rules_large_prebuilt_rules_package:qa:serverless": "npm run run-tests:dr:default prebuilt_rules/large_prebuilt_rules_package serverless qaEnv", "prebuilt_rules_large_prebuilt_rules_package:server:ess": "npm run initialize-server:dr:default prebuilt_rules/large_prebuilt_rules_package ess", "prebuilt_rules_large_prebuilt_rules_package:runner:ess": "npm run run-tests:dr:default prebuilt_rules/large_prebuilt_rules_package ess essEnv", + "prebuilt_rules_update_prebuilt_rules_package:server:serverless": "npm run initialize-server:dr:default prebuilt_rules/update_prebuilt_rules_package serverless", "prebuilt_rules_update_prebuilt_rules_package:runner:serverless": "npm run run-tests:dr:default prebuilt_rules/update_prebuilt_rules_package serverless serverlessEnv", "prebuilt_rules_update_prebuilt_rules_package:qa:serverless": "npm run run-tests:dr:default prebuilt_rules/update_prebuilt_rules_package serverless qaEnv", @@ -151,17 +164,11 @@ "rule_read:server:ess": "npm run initialize-server:dr:default rule_read ess", "rule_read:runner:ess": "npm run run-tests:dr:default rule_read ess essEnv", - "detection_engine_basicessentionals:server:serverless": "npm run initialize-server:dr:basicEssentials detection_engine serverless", - "detection_engine_basicessentionals:runner:serverless": "npm run run-tests:dr:basicEssentials detection_engine serverless serverlessEnv", - "detection_engine_basicessentionals:qa:serverless": "npm run run-tests:dr:basicEssentials detection_engine serverless qaEnv", - "detection_engine_basicessentionals:server:ess": "npm run initialize-server:dr:basicEssentials detection_engine ess", - "detection_engine_basicessentionals:runner:ess": "npm run run-tests:dr:basicEssentials detection_engine ess essEnv", - - "rule_management_basicessentionals:server:serverless": "npm run initialize-server:dr:basicEssentials rule_management serverless", - "rule_management_basicessentionals:runner:serverless": "npm run run-tests:dr:basicEssentials rule_management serverless serverlessEnv", - "rule_management_basicessentionals:qa:serverless": "npm run run-tests:dr:basicEssentials rule_management serverless qaEnv", - "rule_management_basicessentionals:server:ess": "npm run initialize-server:dr:basicEssentials rule_management ess", - "rule_management_basicessentionals:runner:ess": "npm run run-tests:dr:basicEssentials rule_management ess essEnv", + "detection_engine:essentials:server:serverless": "npm run initialize-server:dr:essentials detection_engine serverless", + "detection_engine:essentials:runner:serverless": "npm run run-tests:dr:essentials detection_engine serverless serverlessEnv", + "detection_engine:essentials:qa:serverless": "npm run run-tests:dr:essentials detection_engine serverless qaEnv", + "detection_engine:basic:server:ess": "npm run initialize-server:dr:basic detection:engine ess", + "detection_engine:basic:runner:ess": "npm run run-tests:dr:basic detection_engine ess essEnv", "exception_lists_items:server:serverless": "npm run initialize-server:lists:default exception_lists_items serverless", "exception_lists_items:runner:serverless": "npm run run-tests:lists:default exception_lists_items serverless serverlessEnv", diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/alerts/open_close_alerts.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/alerts/open_close_alerts.ts index 4af66d1da4a93..ae9533d8d3ce2 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/alerts/open_close_alerts.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/alerts/open_close_alerts.ts @@ -34,7 +34,7 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const isServerless = config.get('serverless'); const dataPathBuilder = new EsArchivePathBuilder(isServerless); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/configs/ess.config.ts index b980aef5f783a..7176cc1421ec6 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/configs/ess.config.ts @@ -16,7 +16,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine ESS - Basic Integration Tests', + reportName: 'Detection Engine - Integration Tests - ESS Env - Basic License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/configs/serverless.config.ts index 8a4199ccfb44d..c920ca94da57b 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/configs/serverless.config.ts @@ -10,6 +10,6 @@ import { createTestConfig } from '../../../../../config/serverless/config.base.e export default createTestConfig({ testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine Serverless - Essentials Integration Tests', + reportName: 'Detection Engine - Integration Tests - Serverless Env - Essentials License ', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/rules/create_ml_rules_privileges.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/rules/create_ml_rules_privileges.ts index 0b4bcea421c70..a9537d0426c01 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/rules/create_ml_rules_privileges.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/rules/create_ml_rules_privileges.ts @@ -25,7 +25,7 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); const isServerless = config.get('serverless'); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/rules/create_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/rules/create_rules.ts index 6a3fff87611da..281fa37bb2d5d 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/rules/create_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/rules/create_rules.ts @@ -30,7 +30,7 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); const isServerless = config.get('serverless'); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/actions/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/actions/configs/ess.config.ts index e508918b0538d..883267119e173 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/actions/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/actions/configs/ess.config.ts @@ -16,7 +16,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine ESS - Actions API Integration Tests', + reportName: 'Detection Engine - Rule Actions Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/actions/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/actions/configs/serverless.config.ts index ea876833ea839..22a7c56a7c434 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/actions/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/actions/configs/serverless.config.ts @@ -10,6 +10,7 @@ import { createTestConfig } from '../../../../../config/serverless/config.base'; export default createTestConfig({ testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine Serverless - Actions API Integration Tests', + reportName: + 'Detection Engine - Rule Actions Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/configs/ess.config.ts index 2a8468856732f..94a2ae7368534 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/configs/ess.config.ts @@ -16,7 +16,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine API Integration Tests - ESS - Alerts', + reportName: 'Detection Engine - Alerts Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/configs/serverless.config.ts index 9c61a18b25abc..b4d510ae05174 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/configs/serverless.config.ts @@ -10,6 +10,6 @@ import { createTestConfig } from '../../../../../config/serverless/config.base'; export default createTestConfig({ testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine API Integration Tests - Serverless - Alerts', + reportName: 'Detection Engine - Alerts Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/open_close_alerts.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/open_close_alerts.ts index 20b52f7be5059..44718cc823529 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/open_close_alerts.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/open_close_alerts.ts @@ -44,7 +44,7 @@ export default ({ getService }: FtrProviderContext) => { const supertestWithoutAuth = getService('supertestWithoutAuth'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const isServerless = config.get('serverless'); const dataPathBuilder = new EsArchivePathBuilder(isServerless); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/set_alert_tags.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/set_alert_tags.ts index 15920ab3993b0..775a6da06d9d8 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/set_alert_tags.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/alerts/set_alert_tags.ts @@ -34,7 +34,7 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const isServerless = config.get('serverless'); const dataPathBuilder = new EsArchivePathBuilder(isServerless); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/date_numeric_types/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/date_numeric_types/configs/ess.config.ts index 9d03e3503a480..74bbb3e6fe9a8 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/date_numeric_types/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/date_numeric_types/configs/ess.config.ts @@ -17,7 +17,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { testFiles: [require.resolve('..')], junit: { reportName: - 'Detection Engine ESS - Exception Operators Data Types API - Date_numeric_types Integration Tests', + 'Detection Engine - Exception Operators Date & Numeric Types Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/date_numeric_types/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/date_numeric_types/configs/serverless.config.ts index df64ace832d80..3e030f426d993 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/date_numeric_types/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/date_numeric_types/configs/serverless.config.ts @@ -11,6 +11,6 @@ export default createTestConfig({ testFiles: [require.resolve('..')], junit: { reportName: - 'Detection Engine Serverless - Exception Operators Data Types API - Date_numeric_types Integration Tests', + 'Detection Engine - Exception Operators Date & Numeric Types Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/ips/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/ips/configs/ess.config.ts index 114f4e628b7ac..966c0d58c57f3 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/ips/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/ips/configs/ess.config.ts @@ -17,7 +17,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { testFiles: [require.resolve('..')], junit: { reportName: - 'Detection Engine ESS - Exception Operators Data Types API- IPS Integration Tests', + 'Detection Engine - Exception Operators IP Types Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/ips/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/ips/configs/serverless.config.ts index 80ec0198524b3..4ce0ff0d41059 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/ips/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/ips/configs/serverless.config.ts @@ -11,6 +11,6 @@ export default createTestConfig({ testFiles: [require.resolve('..')], junit: { reportName: - 'Detection Engine Serverless - Exception Operators Data Types API- IPS API Integration Tests', + 'Detection Engine - Exception Operators IP Types Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/keyword/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/keyword/configs/ess.config.ts index 8b19e9b0d8c6d..f58f354407f5f 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/keyword/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/keyword/configs/ess.config.ts @@ -17,7 +17,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { testFiles: [require.resolve('..')], junit: { reportName: - 'Detection Engine ESS - Exception Operators Data Types API- Keyword Integration Tests', + 'Detection Engine - Exception Operators Keyword Types Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/keyword/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/keyword/configs/serverless.config.ts index 3e209f3c04e85..f5093ce32ed63 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/keyword/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/keyword/configs/serverless.config.ts @@ -11,6 +11,6 @@ export default createTestConfig({ testFiles: [require.resolve('..')], junit: { reportName: - 'Detection Engine Serverless - Exception Operators Data Types API - Keyword Integration Tests', + 'Detection Engine - Exception Operators Keyword Types Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/long/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/long/configs/ess.config.ts index 5438e929d9b22..e18b5debbcd51 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/long/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/long/configs/ess.config.ts @@ -17,7 +17,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { testFiles: [require.resolve('..')], junit: { reportName: - 'Detection Engine ESS - Exception Operators Data Types API - Long Integration Tests', + 'Detection Engine - Exception Operators Long Types Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/long/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/long/configs/serverless.config.ts index 646062b09db91..735bb46a9a6b0 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/long/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/long/configs/serverless.config.ts @@ -11,6 +11,6 @@ export default createTestConfig({ testFiles: [require.resolve('..')], junit: { reportName: - 'Detection Engine Serverless - Exception Operators Data Types API - Long Integration Tests', + 'Detection Engine - Exception Operators Long Types Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/text/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/text/configs/ess.config.ts index 01bb5ebdd21eb..c9774e4f590a5 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/text/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/text/configs/ess.config.ts @@ -17,7 +17,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { testFiles: [require.resolve('..')], junit: { reportName: - 'Detection Engine ESS - - Exception Operators Data Types API - Text Integration Tests', + 'Detection Engine - Exception Operators Text Types Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/text/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/text/configs/serverless.config.ts index 3c67f4c7ad06c..c7a7beb13099d 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/text/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/operators_data_types/text/configs/serverless.config.ts @@ -11,6 +11,6 @@ export default createTestConfig({ testFiles: [require.resolve('..')], junit: { reportName: - 'Detection Engine Serverless - Exception Operators Data Types API - Text Integration Tests', + 'Detection Engine - Exception Operators Text Types Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/workflows/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/workflows/configs/ess.config.ts index 4a9004910d3b5..04bc56b399b77 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/workflows/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/workflows/configs/ess.config.ts @@ -16,7 +16,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine ESS - Exception - Workflows API Integration Tests', + reportName: + 'Detection Engine - Exception Workflows Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/workflows/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/workflows/configs/serverless.config.ts index 32e5ca5e8d061..64763286226ae 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/workflows/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/workflows/configs/serverless.config.ts @@ -10,6 +10,7 @@ import { createTestConfig } from '../../../../../../config/serverless/config.bas export default createTestConfig({ testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine Serverless - Exception - Workflows API Integration Tests', + reportName: + 'Detection Engine - Exception Workflows Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/workflows/create_rule_exceptions.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/workflows/create_rule_exceptions.ts index 1f1e4d91d4a09..85bfc549a3a34 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/workflows/create_rule_exceptions.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/workflows/create_rule_exceptions.ts @@ -27,6 +27,7 @@ import { getRuleSOById, createRuleThroughAlertingEndpoint, getRuleSavedObjectWithLegacyInvestigationFields, + checkInvestigationFieldSoValue, } from '../../../utils'; import { deleteAllExceptions, @@ -290,10 +291,14 @@ export default ({ getService }: FtrProviderContext) => { hits: [{ _source: ruleSO }], }, } = await getRuleSOById(es, ruleWithLegacyInvestigationField.id); + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue(ruleSO, { + field_names: ['client.address', 'agent.name'], + }); + expect( ruleSO?.alert.params.exceptionsList.some((list) => list.type === 'rule_default') ).to.eql(true); - expect(ruleSO?.alert.params.investigationFields).to.eql(['client.address', 'agent.name']); + expect(isInvestigationFieldMigratedInSo).to.eql(false); }); }); }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/workflows/role_based_rule_exceptions_workflows.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/workflows/role_based_rule_exceptions_workflows.ts index a0b7145dbc952..e67d7d9f5fc1e 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/workflows/role_based_rule_exceptions_workflows.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/exceptions/workflows/role_based_rule_exceptions_workflows.ts @@ -73,7 +73,7 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); const isServerless = config.get('serverless'); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/bundled_prebuilt_rules_package/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/bundled_prebuilt_rules_package/configs/ess.config.ts index 87c0b1b3c43d8..68bfe2e9314b0 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/bundled_prebuilt_rules_package/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/bundled_prebuilt_rules_package/configs/ess.config.ts @@ -22,7 +22,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine ESS / Bundled Prebuilt Rules Package API Integration Tests', + reportName: + 'Rules Management - Bundled Prebuilt Rules Integration Tests - ESS Env - Trial License', }, kbnTestServer: { ...functionalConfig.get('kbnTestServer'), diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/bundled_prebuilt_rules_package/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/bundled_prebuilt_rules_package/configs/serverless.config.ts index db6e8e11082e0..492c3c13870c6 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/bundled_prebuilt_rules_package/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/bundled_prebuilt_rules_package/configs/serverless.config.ts @@ -16,7 +16,7 @@ export default createTestConfig({ testFiles: [require.resolve('..')], junit: { reportName: - 'Detection Engine Serverless / Bundled Prebuilte Rules Package API Integration Tests', + 'Rules Management - Bundled Prebuilt Rules Integration Tests - Serverless Env - Complete License', }, kbnTestServerArgs: [ /* Tests in this directory simulate an air-gapped environment in which the instance doesn't have access to EPR. diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/large_prebuilt_rules_package/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/large_prebuilt_rules_package/configs/ess.config.ts index 9b056de5b8252..2d96db1382f35 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/large_prebuilt_rules_package/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/large_prebuilt_rules_package/configs/ess.config.ts @@ -23,7 +23,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { testFiles: [require.resolve('..')], junit: { reportName: - 'Detection Engine ESS / Large Prebuilt Rules Package Installation API Integration Tests', + 'Rules Management - Large Prebuilt Rules Package Integration Tests - ESS Env - Trial License', }, kbnTestServer: { ...functionalConfig.get('kbnTestServer'), diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/large_prebuilt_rules_package/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/large_prebuilt_rules_package/configs/serverless.config.ts index 29b6ec1c4cc6c..89bd5f723a9fe 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/large_prebuilt_rules_package/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/large_prebuilt_rules_package/configs/serverless.config.ts @@ -16,7 +16,7 @@ export default createTestConfig({ testFiles: [require.resolve('..')], junit: { reportName: - 'Detection Engine Serverless / Large Prebuilt Rules Package Installation API Integration Tests', + 'Rules Management - Large Prebuilt Rules Package Installation Integration Tests - Serverless Env - Complete License', }, kbnTestServerArgs: [ /* Tests in this directory simulate an air-gapped environment in which the instance doesn't have access to EPR. diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/configs/ess.config.ts index 7fec27a5d9276..eebdce7697d3c 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/configs/ess.config.ts @@ -16,7 +16,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine ESS / Prebuilt Rules Management API Integration Tests', + reportName: + 'Rules Management - Prebuilt Rules Management Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/configs/serverless.config.ts index 89916d26e7a73..91836b3997774 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/configs/serverless.config.ts @@ -10,6 +10,7 @@ import { createTestConfig } from '../../../../../../config/serverless/config.bas export default createTestConfig({ testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine Serverless / Prebuilt Rules Management API Integration Tests', + reportName: + 'Rules Management - Prebuilt Rules Management Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/update_prebuilt_rules_package/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/update_prebuilt_rules_package/configs/ess.config.ts index 0def0b0f17a5f..23b22b80b8573 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/update_prebuilt_rules_package/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/update_prebuilt_rules_package/configs/ess.config.ts @@ -16,7 +16,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine ESS / Update Prebuilt Rules Package - API Integration Tests', + reportName: + 'Rules Management - Update Prebuilt Rules Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/update_prebuilt_rules_package/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/update_prebuilt_rules_package/configs/serverless.config.ts index 5f6716342c924..c05eef46de73b 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/update_prebuilt_rules_package/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/update_prebuilt_rules_package/configs/serverless.config.ts @@ -11,6 +11,6 @@ export default createTestConfig({ testFiles: [require.resolve('..')], junit: { reportName: - 'Detection Engine Serverless / Update Prebuilt Rules Package - API Integration Tests', + 'Rules Management - Update Prebuilt Rules Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_bulk_actions/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_bulk_actions/configs/ess.config.ts index 92303ddd2445f..1519833895210 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_bulk_actions/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_bulk_actions/configs/ess.config.ts @@ -16,7 +16,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Rule Bulk Actions API Integration Tests - ESS - Rule bulk actions logic', + reportName: + 'Rules Management - Rule Bulk Actions Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_bulk_actions/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_bulk_actions/configs/serverless.config.ts index 9e4f790d3ded7..14da93e9eb6c2 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_bulk_actions/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_bulk_actions/configs/serverless.config.ts @@ -9,6 +9,7 @@ import { createTestConfig } from '../../../../../config/serverless/config.base'; export default createTestConfig({ testFiles: [require.resolve('..')], junit: { - reportName: 'Rule Bulk Actions API Integration Tests - Serverless - Rule bulk actions logic', + reportName: + 'Rules Management - Rule Bulk Actions Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_bulk_actions/perform_bulk_action.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_bulk_actions/perform_bulk_action.ts index f7e48ac30a6eb..2c3a0570ea51a 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_bulk_actions/perform_bulk_action.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_bulk_actions/perform_bulk_action.ts @@ -43,7 +43,7 @@ export default ({ getService }: FtrProviderContext): void => { const es = getService('es'); const log = getService('log'); const esArchiver = getService('esArchiver'); - // TODO: add a new service + // TODO: add a new service for pulling kibana username, similar to getService('es') const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_bulk_actions/perform_bulk_action_ess.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_bulk_actions/perform_bulk_action_ess.ts index e85103b67cd22..98b711a5837e7 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_bulk_actions/perform_bulk_action_ess.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_bulk_actions/perform_bulk_action_ess.ts @@ -34,6 +34,7 @@ import { createRuleThroughAlertingEndpoint, getRuleSavedObjectWithLegacyInvestigationFields, getRuleSavedObjectWithLegacyInvestigationFieldsEmptyArray, + checkInvestigationFieldSoValue, } from '../../utils'; import { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -470,6 +471,7 @@ export default ({ getService }: FtrProviderContext): void => { describe('legacy investigation fields', () => { let ruleWithLegacyInvestigationField: Rule; let ruleWithLegacyInvestigationFieldEmptyArray: Rule; + let ruleWithIntendedInvestigationField: RuleResponse; beforeEach(async () => { await deleteAllAlerts(supertest, log, es); @@ -483,7 +485,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest, getRuleSavedObjectWithLegacyInvestigationFieldsEmptyArray() ); - await createRule(supertest, log, { + ruleWithIntendedInvestigationField = await createRule(supertest, log, { ...getSimpleRule('rule-with-investigation-field'), name: 'Test investigation fields object', investigation_fields: { field_names: ['host.name'] }, @@ -528,12 +530,14 @@ export default ({ getService }: FtrProviderContext): void => { * the SO itself is migrated to the inteded object type, or if the transformation is * happening just on the response. In this case, change should not include a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], - }, - } = await getRuleSOById(es, JSON.parse(rule1).id); - expect(ruleSO?.alert?.params?.investigationFields).to.eql(['client.address', 'agent.name']); + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( + undefined, + { field_names: ['client.address', 'agent.name'] }, + es, + JSON.parse(rule1).id + ); + + expect(isInvestigationFieldMigratedInSo).to.eql(false); const exportDetails = JSON.parse(exportDetailsJson); expect(exportDetails).to.eql({ @@ -618,7 +622,6 @@ export default ({ getService }: FtrProviderContext): void => { (returnedRule: RuleResponse) => returnedRule.rule_id === 'rule-with-investigation-field' ); expect(ruleWithIntendedType.investigation_fields).to.eql({ field_names: ['host.name'] }); - /** * Confirm type on SO so that it's clear in the tests whether it's expected that * the SO itself is migrated to the inteded object type, or if the transformation is @@ -629,7 +632,12 @@ export default ({ getService }: FtrProviderContext): void => { hits: [{ _source: ruleSO }], }, } = await getRuleSOById(es, ruleWithLegacyField.id); - expect(ruleSO?.alert?.params?.investigationFields).to.eql(['client.address', 'agent.name']); + + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue(ruleSO, { + field_names: ['client.address', 'agent.name'], + }); + + expect(isInvestigationFieldMigratedInSo).to.eql(false); expect(ruleSO?.alert?.enabled).to.eql(true); const { @@ -688,26 +696,36 @@ export default ({ getService }: FtrProviderContext): void => { * the SO itself is migrated to the inteded object type, or if the transformation is * happening just on the response. In this case, change should not include a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], - }, - } = await getRuleSOById(es, ruleWithLegacyField.id); - expect(ruleSO?.alert?.params?.investigationFields).to.eql(['client.address', 'agent.name']); - - const { - hits: { - hits: [{ _source: ruleSO2 }], - }, - } = await getRuleSOById(es, ruleWithEmptyArray.id); - expect(ruleSO2?.alert?.params?.investigationFields).to.eql([]); - - const { - hits: { - hits: [{ _source: ruleSO3 }], - }, - } = await getRuleSOById(es, ruleWithIntendedType.id); - expect(ruleSO3?.alert?.params?.investigationFields).to.eql({ field_names: ['host.name'] }); + const isInvestigationFieldForRuleWithLegacyFieldMigratedInSo = + await checkInvestigationFieldSoValue( + undefined, + { + field_names: ['client.address', 'agent.name'], + }, + es, + ruleWithLegacyField.id + ); + expect(isInvestigationFieldForRuleWithLegacyFieldMigratedInSo).to.eql(false); + + const isInvestigationFieldForRuleWithEmptyArraydMigratedInSo = + await checkInvestigationFieldSoValue( + undefined, + { + field_names: [], + }, + es, + ruleWithEmptyArray.id + ); + expect(isInvestigationFieldForRuleWithEmptyArraydMigratedInSo).to.eql(false); + + const isInvestigationFieldForRuleWithIntendedTypeMigratedInSo = + await checkInvestigationFieldSoValue( + undefined, + { field_names: ['host.name'] }, + es, + ruleWithIntendedType.id + ); + expect(isInvestigationFieldForRuleWithIntendedTypeMigratedInSo).to.eql(true); }); it('should duplicate rules with legacy investigation fields and transform field in response', async () => { @@ -751,64 +769,75 @@ export default ({ getService }: FtrProviderContext): void => { returnedRule.name === 'Test investigation fields object [Duplicate]' ); + // DUPLICATED RULES /** * Confirm type on SO so that it's clear in the tests whether it's expected that * the SO itself is migrated to the inteded object type, or if the transformation is * happening just on the response. In this case, duplicated * rules should NOT have migrated value on write. */ - const { - hits: { - hits: [{ _source: ruleSO }], - }, - } = await getRuleSOById(es, ruleWithLegacyField.id); - - expect(ruleSO?.alert?.params?.investigationFields).to.eql(['client.address', 'agent.name']); - - const { - hits: { - hits: [{ _source: ruleSO2 }], - }, - } = await getRuleSOById(es, ruleWithEmptyArray.id); - expect(ruleSO2?.alert?.params?.investigationFields).to.eql([]); - - const { - hits: { - hits: [{ _source: ruleSO3 }], - }, - } = await getRuleSOById(es, ruleWithIntendedType.id); - expect(ruleSO3?.alert?.params?.investigationFields).to.eql({ field_names: ['host.name'] }); + const isInvestigationFieldForRuleWithLegacyFieldMigratedInSo = + await checkInvestigationFieldSoValue( + undefined, + { field_names: ['client.address', 'agent.name'] }, + es, + ruleWithLegacyField.id + ); + expect(isInvestigationFieldForRuleWithLegacyFieldMigratedInSo).to.eql(false); + + const isInvestigationFieldForRuleWithEmptyArrayMigratedInSo = + await checkInvestigationFieldSoValue( + undefined, + { field_names: [] }, + es, + ruleWithEmptyArray.id + ); + expect(isInvestigationFieldForRuleWithEmptyArrayMigratedInSo).to.eql(false); + + const isInvestigationFieldForRuleWithIntendedTypeMigratedInSo = + await checkInvestigationFieldSoValue( + undefined, + { field_names: ['host.name'] }, + es, + ruleWithIntendedType.id + ); + expect(isInvestigationFieldForRuleWithIntendedTypeMigratedInSo).to.eql({ + field_names: ['host.name'], + }); + // ORIGINAL RULES - rules selected to be duplicated /** * Confirm type on SO so that it's clear in the tests whether it's expected that * the SO itself is migrated to the inteded object type, or if the transformation is * happening just on the response. In this case, the original * rules selected to be duplicated should not be migrated. */ - const { - hits: { - hits: [{ _source: ruleSOOriginalLegacy }], - }, - } = await getRuleSOById(es, ruleWithLegacyInvestigationField.id); - - expect(ruleSOOriginalLegacy?.alert?.params?.investigationFields).to.eql([ - 'client.address', - 'agent.name', - ]); - - const { - hits: { - hits: [{ _source: ruleSOOriginalLegacyEmptyArray }], - }, - } = await getRuleSOById(es, ruleWithLegacyInvestigationFieldEmptyArray.id); - expect(ruleSOOriginalLegacyEmptyArray?.alert?.params?.investigationFields).to.eql([]); - - const { - hits: { - hits: [{ _source: ruleSOOriginalNoLegacy }], - }, - } = await getRuleSOById(es, ruleWithIntendedType.id); - expect(ruleSOOriginalNoLegacy?.alert?.params?.investigationFields).to.eql({ + const isInvestigationFieldForOriginalRuleWithLegacyFieldMigratedInSo = + await checkInvestigationFieldSoValue( + undefined, + { field_names: ['client.address', 'agent.name'] }, + es, + ruleWithLegacyInvestigationField.id + ); + expect(isInvestigationFieldForOriginalRuleWithLegacyFieldMigratedInSo).to.eql(false); + + const isInvestigationFieldForOriginalRuleWithEmptyArrayMigratedInSo = + await checkInvestigationFieldSoValue( + undefined, + { field_names: [] }, + es, + ruleWithLegacyInvestigationFieldEmptyArray.id + ); + expect(isInvestigationFieldForOriginalRuleWithEmptyArrayMigratedInSo).to.eql(false); + + const isInvestigationFieldForOriginalRuleWithIntendedTypeMigratedInSo = + await checkInvestigationFieldSoValue( + undefined, + { field_names: ['host.name'] }, + es, + ruleWithIntendedInvestigationField.id + ); + expect(isInvestigationFieldForOriginalRuleWithIntendedTypeMigratedInSo).to.eql({ field_names: ['host.name'], }); }); @@ -860,26 +889,32 @@ export default ({ getService }: FtrProviderContext): void => { * the SO itself is migrated to the inteded object type, or if the transformation is * happening just on the response. In this case, change should not include a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], - }, - } = await getRuleSOById(es, ruleWithLegacyInvestigationField.id); - expect(ruleSO?.alert?.params?.investigationFields).to.eql(['client.address', 'agent.name']); - - const { - hits: { - hits: [{ _source: ruleSO2 }], - }, - } = await getRuleSOById(es, ruleWithLegacyInvestigationFieldEmptyArray.id); - expect(ruleSO2?.alert?.params?.investigationFields).to.eql([]); - - const { - hits: { - hits: [{ _source: ruleSO3 }], - }, - } = await getRuleSOById(es, ruleWithIntendedType.id); - expect(ruleSO3?.alert?.params?.investigationFields).to.eql({ field_names: ['host.name'] }); + const isInvestigationFieldForRuleWithLegacyFieldMigratedInSo = + await checkInvestigationFieldSoValue( + undefined, + { field_names: ['client.address', 'agent.name'] }, + es, + ruleWithLegacyInvestigationField.id + ); + expect(isInvestigationFieldForRuleWithLegacyFieldMigratedInSo).to.eql(false); + + const isInvestigationFieldForRuleWithEmptyArrayFieldMigratedInSo = + await checkInvestigationFieldSoValue( + undefined, + { field_names: [] }, + es, + ruleWithLegacyInvestigationFieldEmptyArray.id + ); + expect(isInvestigationFieldForRuleWithEmptyArrayFieldMigratedInSo).to.eql(false); + + const isInvestigationFieldForRuleWithIntendedTypeMigratedInSo = + await checkInvestigationFieldSoValue( + undefined, + { field_names: ['host.name'] }, + es, + ruleWithIntendedType.id + ); + expect(isInvestigationFieldForRuleWithIntendedTypeMigratedInSo).to.eql(true); }); }); }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/configs/ess.config.ts index 4fbad71828a44..2f04f8c18d6b4 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/configs/ess.config.ts @@ -16,7 +16,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine ESS/ Rule creation API Integration Tests', + reportName: 'Detection Engine - Rule Creation Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/configs/serverless.config.ts index 3c214b340ab74..4a8c7d24f7b36 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/configs/serverless.config.ts @@ -10,6 +10,7 @@ import { createTestConfig } from '../../../../../config/serverless/config.base'; export default createTestConfig({ testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine Serverless/ Rule creation API Integration Tests', + reportName: + 'Detection Engine - Rule Creation Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/create_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/create_rules.ts index 49ed77a4dc48e..aad42c2e4ea6c 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/create_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/create_rules.ts @@ -45,7 +45,7 @@ export default ({ getService }: FtrProviderContext) => { const supertestWithoutAuth = getService('supertestWithoutAuth'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for pulling kibana username, similar to getService('es') const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/create_rules_bulk.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/create_rules_bulk.ts index aa07404205652..b3d954773b518 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/create_rules_bulk.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/create_rules_bulk.ts @@ -42,9 +42,7 @@ export default ({ getService }: FtrProviderContext): void => { const log = getService('log'); const es = getService('es'); - // Marking as ESS and brokenInServerless as it's currently exposed in both, but if this is already - // deprecated, it should cease being exposed in Serverless prior to GA, in which case this - // test would be run for ESS only. + // See https://github.com/elastic/kibana/issues/130963 for discussion on deprecation describe('@ess @brokenInServerless @skipInQA create_rules_bulk', () => { describe('deprecations', () => { afterEach(async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/preview_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/preview_rules.ts index bcfbf77ef23e1..95ba7de98eab7 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/preview_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_creation/preview_rules.ts @@ -24,7 +24,7 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const supertestWithoutAuth = getService('supertestWithoutAuth'); const log = getService('log'); - // TODO: add a new service + // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const isServerless = config.get('serverless'); const dataPathBuilder = new EsArchivePathBuilder(isServerless); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/configs/ess.config.ts index 11f644695b9dc..3c8ba6dd0ba99 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/configs/ess.config.ts @@ -16,7 +16,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Rule Management API Integration Tests - ESS - Rule Delete logic', + reportName: 'Detection Engine - Rule Deletion Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/configs/serverless.config.ts index ed7c4e3d11a71..89f417d00f551 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/configs/serverless.config.ts @@ -9,6 +9,7 @@ import { createTestConfig } from '../../../../../config/serverless/config.base'; export default createTestConfig({ testFiles: [require.resolve('..')], junit: { - reportName: 'Rule Management API Integration Tests - Serverless - Rule Delete logic', + reportName: + 'Detection Engine - Rule Deletion Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/delete_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/delete_rules.ts index 1966ab101ab0c..ed325c15dae40 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/delete_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/delete_rules.ts @@ -27,7 +27,7 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for pulling kibana username, similar to getService('es') const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/delete_rules_bulk.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/delete_rules_bulk.ts index 10d768152ddc3..a4f4df7868005 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/delete_rules_bulk.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/delete_rules_bulk.ts @@ -32,13 +32,11 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for pulling kibana username, similar to getService('es') const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); - // Marking as ESS and brokenInServerless as it's currently exposed in both, but if this is already - // deprecated, it should cease being exposed in Serverless prior to GA, in which case this - // test would be run for ESS only. + // See https://github.com/elastic/kibana/issues/130963 for discussion on deprecation describe('@ess @brokenInServerless @skipInQA delete_rules_bulk', () => { describe('deprecations', () => { it('should return a warning header', async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/delete_rules_bulk_legacy.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/delete_rules_bulk_legacy.ts index 85a5814fdf732..53a8ac37e5abb 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/delete_rules_bulk_legacy.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/delete_rules_bulk_legacy.ts @@ -176,7 +176,7 @@ export default ({ getService }: FtrProviderContext): void => { // Test to ensure that we have exactly 0 legacy actions by querying the Alerting client REST API directly // See: https://www.elastic.co/guide/en/kibana/current/find-rules-api.html - // Note: We specifically query for both the filter of type "siem.notifications" and the "has_reference" to keep it very specific + // Note: We specifically filter for both the type "siem.notifications" and the "has_reference" field to ensure we only retrieve legacy actions const { body: bodyAfterDelete } = await supertest .get(`${BASE_ALERTING_API_PATH}/rules/_find`) .query({ diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/delete_rules_legacy.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/delete_rules_legacy.ts index 9db8143c6ad3c..214217cdbfe5b 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/delete_rules_legacy.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete/delete_rules_legacy.ts @@ -27,7 +27,7 @@ export default ({ getService }: FtrProviderContext): void => { const log = getService('log'); const es = getService('es'); - describe('@ess delete_rules_legacy', () => { + describe('@ess Legacy route for deleting rules', () => { describe('deleting rules', () => { beforeEach(async () => { await createAlertsIndex(supertest, log); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/configs/ess.config.ts index bbf6c6c0e3f7b..392716ccea85b 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/configs/ess.config.ts @@ -16,7 +16,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine API Integration Tests - ESS - Rule Execution Logic', + reportName: + 'Detection Engine - Rule Execution Logic Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/configs/serverless.config.ts index 1f43395efcd90..0a425d6845878 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/configs/serverless.config.ts @@ -9,7 +9,8 @@ import { createTestConfig } from '../../../../../config/serverless/config.base'; export default createTestConfig({ testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine API Integration Tests - Serverless - Rule Execution Logic', + reportName: + 'Detection Engine - Rule Execution Logic Integration Tests - Serverless Env - Complete License', }, kbnTestServerArgs: [ `--xpack.securitySolution.alertIgnoreFields=${JSON.stringify([ diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/eql.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/eql.ts index 03af11e239c68..3b2921f90dce2 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/eql.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/eql.ts @@ -53,7 +53,7 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); - // TODO: add a new service + // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const isServerless = config.get('serverless'); const dataPathBuilder = new EsArchivePathBuilder(isServerless); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/machine_learning.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/machine_learning.ts index 8787a51871125..ad5f546d2fd6c 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/machine_learning.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/machine_learning.ts @@ -52,7 +52,7 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); - // TODO: add a new service + // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const isServerless = config.get('serverless'); const dataPathBuilder = new EsArchivePathBuilder(isServerless); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/new_terms.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/new_terms.ts index 9aea83afb95d0..aff5d52eeac94 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/new_terms.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/new_terms.ts @@ -41,7 +41,7 @@ export default ({ getService }: FtrProviderContext) => { index: 'new_terms', log, }); - // TODO: add a new service + // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); const isServerless = config.get('serverless'); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/query.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/query.ts index 38930bafa564e..feabae41ebea1 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/query.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/query.ts @@ -88,7 +88,7 @@ export default ({ getService }: FtrProviderContext) => { const es = getService('es'); const log = getService('log'); const esDeleteAllIndices = getService('esDeleteAllIndices'); - // TODO: add a new service + // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const isServerless = config.get('serverless'); const dataPathBuilder = new EsArchivePathBuilder(isServerless); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/saved_query.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/saved_query.ts index e387a2f840c41..c0a197e64f292 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/saved_query.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/saved_query.ts @@ -37,7 +37,7 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); - // TODO: add a new service + // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const isServerless = config.get('serverless'); const dataPathBuilder = new EsArchivePathBuilder(isServerless); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/threat_match.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/threat_match.ts index 734583d009ca3..7d97563f4c1b5 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/threat_match.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/threat_match.ts @@ -147,7 +147,7 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const es = getService('es'); const log = getService('log'); - // TODO: add a new service + // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const isServerless = config.get('serverless'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/threshold.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/threshold.ts index dce4886bc1ba5..c13702f37bef5 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/threshold.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/execution_logic/threshold.ts @@ -39,7 +39,7 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); - // TODO: add a new service + // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const isServerless = config.get('serverless'); const dataPathBuilder = new EsArchivePathBuilder(isServerless); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/timestamps.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/timestamps.ts index d7c645c115082..2a51b1da2e444 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/timestamps.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_execution_logic/timestamps.ts @@ -35,7 +35,7 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); - // TODO: add a new service + // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const isServerless = config.get('serverless'); const dataPathBuilder = new EsArchivePathBuilder(isServerless); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/configs/ess.config.ts index 0221afa650a09..ee0cff6c55b86 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/configs/ess.config.ts @@ -16,7 +16,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Rule Management API Integration Tests - ESS - Rule Import and Export logic', + reportName: + 'Rules Management - Rule Import And Export Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/configs/serverless.config.ts index 5be8cda08a16d..5d9fdbac927df 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/configs/serverless.config.ts @@ -9,6 +9,7 @@ import { createTestConfig } from '../../../../../config/serverless/config.base'; export default createTestConfig({ testFiles: [require.resolve('..')], junit: { - reportName: 'Rule Management API Integration Tests - Serverless - Rule Import and Export logic', + reportName: + 'Rules Management - Rule Import And Export Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/export_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/export_rules.ts index bde3148c84320..42ec2a27c5d7f 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/export_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/export_rules.ts @@ -28,7 +28,7 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for pulling kibana username, similar to getService('es') const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/export_rules_ess.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/export_rules_ess.ts index 0a58efd57359f..5af0d9a8814cd 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/export_rules_ess.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/export_rules_ess.ts @@ -26,9 +26,9 @@ import { removeServerGeneratedProperties, getRuleSavedObjectWithLegacyInvestigationFields, getRuleSavedObjectWithLegacyInvestigationFieldsEmptyArray, - getRuleSOById, updateUsername, createRuleThroughAlertingEndpoint, + checkInvestigationFieldSoValue, } from '../../utils'; import { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -36,7 +36,7 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for pulling kibana username, similar to getService('es') const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); @@ -417,21 +417,20 @@ export default ({ getService }: FtrProviderContext): void => { expect(exportedRule.investigation_fields).toEqual({ field_names: ['client.address', 'agent.name'], }); + /** * Confirm type on SO so that it's clear in the tests whether it's expected that * the SO itself is migrated to the inteded object type, or if the transformation is * happening just on the response. In this case, change should * NOT include a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], - }, - } = await getRuleSOById(es, ruleWithLegacyInvestigationField.id); - expect(ruleSO?.alert?.params?.investigationFields).toEqual([ - 'client.address', - 'agent.name', - ]); + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( + undefined, + { field_names: ['client.address', 'agent.name'] }, + es, + ruleWithLegacyInvestigationField.id + ); + expect(isInvestigationFieldMigratedInSo).toEqual(false); }); it('exports a rule that has a legacy investigation field set to empty array and unsets field in response', async () => { @@ -455,12 +454,13 @@ export default ({ getService }: FtrProviderContext): void => { * happening just on the response. In this case, change should * NOT include a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], - }, - } = await getRuleSOById(es, ruleWithLegacyInvestigationFieldEmptyArray.id); - expect(ruleSO?.alert?.params?.investigationFields).toEqual([]); + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( + undefined, + { field_names: [] }, + es, + ruleWithLegacyInvestigationFieldEmptyArray.id + ); + expect(isInvestigationFieldMigratedInSo).toEqual(false); }); it('exports rule with investigation fields as intended object type', async () => { @@ -484,12 +484,14 @@ export default ({ getService }: FtrProviderContext): void => { * the SO itself is migrated to the inteded object type, or if the transformation is * happening just on the response. In this case, change should * NOT include a migration on SO. - */ const { - hits: { - hits: [{ _source: ruleSO }], - }, - } = await getRuleSOById(es, exportedRule.id); - expect(ruleSO?.alert?.params?.investigationFields).toEqual({ field_names: ['host.name'] }); + */ + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( + undefined, + { field_names: ['host.name'] }, + es, + exportedRule.id + ); + expect(isInvestigationFieldMigratedInSo).toEqual(true); }); }); }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/import_rules_ess.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/import_rules_ess.ts index aaeb01904e066..bd63c3150588a 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/import_rules_ess.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_import_export/import_rules_ess.ts @@ -22,9 +22,9 @@ import { getLegacyActionSO, createRule, fetchRule, - getRuleSOById, getWebHookAction, getSimpleRuleAsNdjson, + checkInvestigationFieldSoValue, } from '../../utils'; import { createUserAndRole, @@ -308,18 +308,20 @@ export default ({ getService }: FtrProviderContext): void => { const rule = await fetchRule(supertest, { ruleId: 'rule-1' }); expect(rule.investigation_fields).to.eql({ field_names: ['foo', 'bar'] }); + /** * Confirm type on SO so that it's clear in the tests whether it's expected that * the SO itself is migrated to the inteded object type, or if the transformation is * happening just on the response. In this case, change should * include a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], - }, - } = await getRuleSOById(es, rule.id); - expect(ruleSO?.alert?.params?.investigationFields).to.eql({ field_names: ['foo', 'bar'] }); + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( + undefined, + { field_names: ['foo', 'bar'] }, + es, + rule.id + ); + expect(isInvestigationFieldMigratedInSo).to.eql(true); }); it('imports rule with investigation fields as empty array', async () => { @@ -342,18 +344,20 @@ export default ({ getService }: FtrProviderContext): void => { const rule = await fetchRule(supertest, { ruleId: 'rule-1' }); expect(rule.investigation_fields).to.eql(undefined); + /** * Confirm type on SO so that it's clear in the tests whether it's expected that * the SO itself is migrated to the inteded object type, or if the transformation is * happening just on the response. In this case, change should * include a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], - }, - } = await getRuleSOById(es, rule.id); - expect(ruleSO?.alert?.params?.investigationFields).to.eql(undefined); + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( + undefined, + undefined, + es, + rule.id + ); + expect(isInvestigationFieldMigratedInSo).to.eql(true); }); it('imports rule with investigation fields as intended object type', async () => { @@ -381,12 +385,13 @@ export default ({ getService }: FtrProviderContext): void => { * happening just on the response. In this case, change should * include a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], - }, - } = await getRuleSOById(es, rule.id); - expect(ruleSO?.alert?.params?.investigationFields).to.eql({ field_names: ['foo'] }); + const isInvestigationFieldIntendedTypeInSo = await checkInvestigationFieldSoValue( + undefined, + { field_names: ['foo'] }, + es, + rule.id + ); + expect(isInvestigationFieldIntendedTypeInSo).to.eql(true); }); }); }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_management/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_management/configs/ess.config.ts index 94ea13264eaab..978d5f2268dee 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_management/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_management/configs/ess.config.ts @@ -16,7 +16,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Rule Management API Integration Tests - ESS - Rule management logic', + reportName: 'Rules Management - Rule Management Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_management/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_management/configs/serverless.config.ts index 0f86bfe4d5ebb..86c288c6dacea 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_management/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_management/configs/serverless.config.ts @@ -9,6 +9,7 @@ import { createTestConfig } from '../../../../../config/serverless/config.base'; export default createTestConfig({ testFiles: [require.resolve('..')], junit: { - reportName: 'Rule Management API Integration Tests - Serverless - Rule management logic', + reportName: + 'Rules Management - Rule Management Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_management/get_rule_execution_results.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_management/get_rule_execution_results.ts index 3463518a51af4..a45636b08ea0d 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_management/get_rule_execution_results.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_management/get_rule_execution_results.ts @@ -41,7 +41,7 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const es = getService('es'); const log = getService('log'); - // TODO: add a new service + // TODO: add a new service for loading archiver files similar to "getService('es')" const config = getService('config'); const isServerless = config.get('serverless'); const dataPathBuilder = new EsArchivePathBuilder(isServerless); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/configs/ess.config.ts index f8c742a881ded..30b7daf5d02b3 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/configs/ess.config.ts @@ -16,7 +16,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Rule Management API Integration Tests - ESS - Rule Patch logic', + reportName: 'Detection Engine - Rule Patch Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/configs/serverless.config.ts index 7ed12808c452e..e95130ab73891 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/configs/serverless.config.ts @@ -9,6 +9,7 @@ import { createTestConfig } from '../../../../../config/serverless/config.base'; export default createTestConfig({ testFiles: [require.resolve('..')], junit: { - reportName: 'Rule Management API Integration Tests - Serverless - Rule Patch logic', + reportName: + 'Detection Engine - Rule Patch Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/patch_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/patch_rules.ts index d267e6398eca0..43abe1c3b591b 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/patch_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/patch_rules.ts @@ -41,7 +41,7 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for pulling kibana username, similar to getService('es') const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/patch_rules_bulk.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/patch_rules_bulk.ts index 0bf1bd43ab99c..94c07f20d7d60 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/patch_rules_bulk.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/patch_rules_bulk.ts @@ -29,6 +29,7 @@ import { createRuleThroughAlertingEndpoint, getRuleSavedObjectWithLegacyInvestigationFieldsEmptyArray, getRuleSavedObjectWithLegacyInvestigationFields, + checkInvestigationFieldSoValue, } from '../../utils'; import { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -36,13 +37,11 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for pulling kibana username, similar to getService('es') const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); - // Marking as ESS and brokenInServerless as it's currently exposed in both, but if this is already - // deprecated, it should cease being exposed in Serverless prior to GA, in which case this - // test would be run for ESS only. + // See https://github.com/elastic/kibana/issues/130963 for discussion on deprecation describe('@ess @brokenInServerless @skipInQA patch_rules_bulk', () => { describe('deprecations', () => { afterEach(async () => { @@ -588,18 +587,20 @@ export default ({ getService }: FtrProviderContext) => { field_names: ['client.address', 'agent.name'], }); expect(bodyToCompareLegacyField.name).to.eql('some other name'); + /** * Confirm type on SO so that it's clear in the tests whether it's expected that * the SO itself is migrated to the inteded object type, or if the transformation is * happening just on the response. In this case, change should * NOT include a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], - }, - } = await getRuleSOById(es, body[0].id); - expect(ruleSO?.alert?.params?.investigationFields).to.eql(['client.address', 'agent.name']); + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( + undefined, + { field_names: ['client.address', 'agent.name'] }, + es, + body[0].id + ); + expect(isInvestigationFieldMigratedInSo).to.eql(false); }); it('should patch a rule with a legacy investigation field - empty array - and transform field in response', async () => { @@ -619,18 +620,20 @@ export default ({ getService }: FtrProviderContext) => { const bodyToCompareLegacyFieldEmptyArray = removeServerGeneratedProperties(body[0]); expect(bodyToCompareLegacyFieldEmptyArray.investigation_fields).to.eql(undefined); expect(bodyToCompareLegacyFieldEmptyArray.name).to.eql('some other name 2'); + /** * Confirm type on SO so that it's clear in the tests whether it's expected that * the SO itself is migrated to the inteded object type, or if the transformation is * happening just on the response. In this case, change should * NOT include a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], - }, - } = await getRuleSOById(es, body[0].id); - expect(ruleSO?.alert?.params?.investigationFields).to.eql([]); + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( + undefined, + { field_names: [] }, + es, + body[0].id + ); + expect(isInvestigationFieldMigratedInSo).to.eql(false); }); it('should patch a rule with an investigation field', async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/patch_rules_ess.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/patch_rules_ess.ts index 06b530c113352..613ed6b5de9b3 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/patch_rules_ess.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch/patch_rules_ess.ts @@ -16,7 +16,6 @@ import { deleteAllRules, deleteAllAlerts, removeServerGeneratedProperties, - getRuleSOById, createRuleThroughAlertingEndpoint, getRuleSavedObjectWithLegacyInvestigationFields, getRuleSavedObjectWithLegacyInvestigationFieldsEmptyArray, @@ -26,6 +25,7 @@ import { updateUsername, createLegacyRuleAction, getSimpleRule, + checkInvestigationFieldSoValue, } from '../../utils'; import { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -33,7 +33,7 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for pulling kibana username, similar to getService('es') const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); @@ -158,15 +158,15 @@ export default ({ getService }: FtrProviderContext) => { * happening just on the response. In this case, change should * NOT include a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( + undefined, + { + field_names: ['client.address', 'agent.name'], }, - } = await getRuleSOById(es, body.id); - expect(ruleSO?.alert?.params?.investigationFields).to.eql([ - 'client.address', - 'agent.name', - ]); + es, + body.id + ); + expect(isInvestigationFieldMigratedInSo).to.eql(false); }); it('should patch a rule with a legacy investigation field - empty array - and transform response', async () => { @@ -188,12 +188,15 @@ export default ({ getService }: FtrProviderContext) => { * happening just on the response. In this case, change should * NOT include a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( + undefined, + { + field_names: [], }, - } = await getRuleSOById(es, body.id); - expect(ruleSO?.alert?.params?.investigationFields).to.eql([]); + es, + body.id + ); + expect(isInvestigationFieldMigratedInSo).to.eql(false); }); }); }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/configs/ess.config.ts index 5c1925861aa39..9d0830b927c6e 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/configs/ess.config.ts @@ -16,7 +16,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Rule Management API Integration Tests - ESS - Rule Read logic', + reportName: 'Rules Management - Rule Read Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/configs/serverless.config.ts index 81c0e71881466..853ffc6443890 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/configs/serverless.config.ts @@ -9,6 +9,7 @@ import { createTestConfig } from '../../../../../config/serverless/config.base'; export default createTestConfig({ testFiles: [require.resolve('..')], junit: { - reportName: 'Rule Management API Integration Tests - Serverless - Rule Read logic', + reportName: + 'Rules Management - Rule Read Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/find_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/find_rules.ts index 8c8804bc59c68..4a9740358e928 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/find_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/find_rules.ts @@ -27,7 +27,7 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const log = getService('log'); - // TODO: add a new service + // TODO: add a new service for pulling kibana username, similar to getService('es') const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/find_rules_ess.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/find_rules_ess.ts index 9b380d3a0a40a..f15ea25fbdd16 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/find_rules_ess.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/find_rules_ess.ts @@ -18,7 +18,6 @@ import { createRule, createRuleThroughAlertingEndpoint, deleteAllRules, - getRuleSOById, getSimpleRule, getSimpleRuleOutput, getWebHookAction, @@ -26,6 +25,7 @@ import { removeServerGeneratedProperties, getRuleSavedObjectWithLegacyInvestigationFields, getRuleSavedObjectWithLegacyInvestigationFieldsEmptyArray, + checkInvestigationFieldSoValue, } from '../../utils'; import { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -33,7 +33,7 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for pulling kibana username, similar to getService('es') const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); @@ -171,24 +171,36 @@ export default ({ getService }: FtrProviderContext): void => { * happening just on the response. In this case, change should * NOT include a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( + undefined, + { + field_names: ['client.address', 'agent.name'], }, - } = await getRuleSOById(es, ruleWithLegacyInvestigationField.id); - expect(ruleSO?.alert?.params?.investigationFields).to.eql(['client.address', 'agent.name']); - const { - hits: { - hits: [{ _source: ruleSO2 }], - }, - } = await getRuleSOById(es, ruleWithLegacyInvestigationFieldEmptyArray.id); - expect(ruleSO2?.alert?.params?.investigationFields).to.eql([]); - const { - hits: { - hits: [{ _source: ruleSO3 }], + es, + ruleWithLegacyInvestigationField.id + ); + expect(isInvestigationFieldMigratedInSo).to.eql(false); + + const isInvestigationFieldMigratedInSoForRuleWithEmptyArray = + await checkInvestigationFieldSoValue( + undefined, + { + field_names: [], + }, + es, + ruleWithLegacyInvestigationFieldEmptyArray.id + ); + expect(isInvestigationFieldMigratedInSoForRuleWithEmptyArray).to.eql(false); + + const isInvestigationFieldSoExpectedType = await checkInvestigationFieldSoValue( + undefined, + { + field_names: ['host.name'], }, - } = await getRuleSOById(es, ruleWithExpectedTyping.id); - expect(ruleSO3?.alert?.params?.investigationFields).to.eql({ field_names: ['host.name'] }); + es, + ruleWithExpectedTyping.id + ); + expect(isInvestigationFieldSoExpectedType).to.eql(true); }); }); }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/read_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/read_rules.ts index a26c3dba358c5..d7a4ba65b98da 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/read_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/read_rules.ts @@ -27,7 +27,7 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for pulling kibana username, similar to getService('es') const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/read_rules_ess.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/read_rules_ess.ts index dcbaf8b10615e..6780a639cbd8c 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/read_rules_ess.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_read/read_rules_ess.ts @@ -21,11 +21,11 @@ import { getSimpleRuleOutput, getWebHookAction, removeServerGeneratedProperties, - getRuleSOById, updateUsername, getRuleSavedObjectWithLegacyInvestigationFields, createRuleThroughAlertingEndpoint, getRuleSavedObjectWithLegacyInvestigationFieldsEmptyArray, + checkInvestigationFieldSoValue, } from '../../utils'; import { FtrProviderContext } from '../../../../ftr_provider_context'; @@ -33,7 +33,7 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for pulling kibana username, similar to getService('es') const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); @@ -164,12 +164,15 @@ export default ({ getService }: FtrProviderContext) => { * happening just on the response. In this case, change should * just be a transform on read, not a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( + undefined, + { + field_names: ['client.address', 'agent.name'], }, - } = await getRuleSOById(es, body.id); - expect(ruleSO?.alert?.params?.investigationFields).to.eql(['client.address', 'agent.name']); + es, + body.id + ); + expect(isInvestigationFieldMigratedInSo).to.eql(false); }); it('should be able to read a rule with a legacy investigation field - empty array', async () => { @@ -190,12 +193,15 @@ export default ({ getService }: FtrProviderContext) => { * happening just on the response. In this case, change should * just be a transform on read, not a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], + const isInvestigationFieldMigratedInSo = await checkInvestigationFieldSoValue( + undefined, + { + field_names: [], }, - } = await getRuleSOById(es, body.id); - expect(ruleSO?.alert?.params?.investigationFields).to.eql([]); + es, + body.id + ); + expect(isInvestigationFieldMigratedInSo).to.eql(false); }); it('does not migrate investigation fields when intended object type', async () => { @@ -214,12 +220,13 @@ export default ({ getService }: FtrProviderContext) => { * happening just on the response. In this case, change should * just be a transform on read, not a migration on SO. */ - const { - hits: { - hits: [{ _source: ruleSO }], - }, - } = await getRuleSOById(es, body.id); - expect(ruleSO?.alert?.params?.investigationFields).to.eql({ field_names: ['host.name'] }); + const isInvestigationFieldIntendedTypeInSo = await checkInvestigationFieldSoValue( + undefined, + { field_names: ['host.name'] }, + es, + body.id + ); + expect(isInvestigationFieldIntendedTypeInSo).to.eql(true); }); }); }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/configs/ess.config.ts index 1774ff3ae28ea..fa76a9537ab3a 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/configs/ess.config.ts @@ -16,7 +16,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Rule Management API Integration Tests - ESS - Rule Update logic', + reportName: 'Detection Engine - Rule Update Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/configs/serverless.config.ts index 017b5dec486b1..ea732668ee155 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/configs/serverless.config.ts @@ -9,6 +9,7 @@ import { createTestConfig } from '../../../../../config/serverless/config.base'; export default createTestConfig({ testFiles: [require.resolve('..')], junit: { - reportName: 'Rule Management API Integration Tests - Serverless - Rule Update logic', + reportName: + 'Detection Engine - Rule Update Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/update_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/update_rules.ts index 3c6a3e7735a4a..69dc28fddf6b4 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/update_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/update_rules.ts @@ -44,7 +44,7 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for pulling kibana username, similar to getService('es') const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/update_rules_bulk.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/update_rules_bulk.ts index 49c9ffdd817fd..d3a7a124ae59c 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/update_rules_bulk.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/update_rules_bulk.ts @@ -48,13 +48,11 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for pulling kibana username, similar to getService('es') const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); - // Marking as ESS and brokenInServerless as it's currently exposed in both, but if this is already - // deprecated, it should cease being exposed in Serverless prior to GA, in which case this - // test would be run for ESS only. + // See https://github.com/elastic/kibana/issues/130963 for discussion on deprecation describe('@ess @brokenInServerless @skipInQA update_rules_bulk', () => { describe('deprecations', () => { afterEach(async () => { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/update_rules_ess.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/update_rules_ess.ts index 38a5a5a07a9f0..3338634ea0511 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/update_rules_ess.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update/update_rules_ess.ts @@ -34,7 +34,7 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const log = getService('log'); const es = getService('es'); - // TODO: add a new service + // TODO: add a new service for pulling kibana username, similar to getService('es') const config = getService('config'); const ELASTICSEARCH_USERNAME = config.get('servers.kibana.username'); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/telemetry/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/telemetry/configs/ess.config.ts index 787542036e084..2626c12f9a825 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/telemetry/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/telemetry/configs/ess.config.ts @@ -16,7 +16,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine API Integration Tests - ESS - Telemetry', + reportName: 'Detection Engine - Telemetry Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/telemetry/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/telemetry/configs/serverless.config.ts index 99bd2458c69a4..2601dba13f00c 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/telemetry/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/telemetry/configs/serverless.config.ts @@ -10,7 +10,8 @@ import { createTestConfig } from '../../../../../config/serverless/config.base'; export default createTestConfig({ testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine API Integration Tests - Serverless - Telemetry', + reportName: + 'Detection Engine - Telemetry Integration Tests - Serverless Env - Complete License', }, kbnTestServerArgs: [ `--xpack.securitySolution.enableExperimental=${JSON.stringify(['previewTelemetryUrlEnabled'])}`, diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/user_roles/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/user_roles/configs/ess.config.ts index 59e01e74c719c..51ea7037f1d40 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/user_roles/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/user_roles/configs/ess.config.ts @@ -16,7 +16,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine ESS - User roles API Integration Tests', + reportName: 'Detection Engine - User Roles Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/user_roles/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/user_roles/configs/serverless.config.ts index d8e9843c3eb92..a2dd062fa0ac3 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/user_roles/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/user_roles/configs/serverless.config.ts @@ -10,6 +10,7 @@ import { createTestConfig } from '../../../../../config/serverless/config.base'; export default createTestConfig({ testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine Serverless - User roles API Integration Tests', + reportName: + 'Detection Engine - User Roles Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/actions/legacy_actions/get_legacy_action_notifications_so_by_id.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/actions/legacy_actions/get_legacy_action_notifications_so_by_id.ts index 3d92779e010d2..bed1197fc91ee 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/actions/legacy_actions/get_legacy_action_notifications_so_by_id.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/actions/legacy_actions/get_legacy_action_notifications_so_by_id.ts @@ -9,9 +9,9 @@ import type { Client } from '@elastic/elasticsearch'; import type { SearchResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; import { SavedObjectReference } from '@kbn/core/server'; -import { LegacyRuleNotificationAlertTypeParams } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_actions_legacy'; +import { LegacyRuleNotificationRuleTypeParams } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_actions_legacy'; -interface LegacyActionNotificationSO extends LegacyRuleNotificationAlertTypeParams { +interface LegacyActionNotificationSO extends LegacyRuleNotificationRuleTypeParams { references: SavedObjectReference[]; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/check_investigation_field_in_so.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/check_investigation_field_in_so.ts new file mode 100644 index 0000000000000..36804d6c0f50f --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/check_investigation_field_in_so.ts @@ -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 { Client } from '@elastic/elasticsearch'; +import { SavedObjectReference } from '@kbn/core/server'; +import { InvestigationFields } from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { Rule } from '@kbn/alerting-plugin/common'; +import { BaseRuleParams } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_schema'; +import { isEqual } from 'lodash/fp'; +import { getRuleSOById } from './get_rule_so_by_id'; + +interface RuleSO { + alert: Rule; + references: SavedObjectReference[]; +} + +export const checkInvestigationFieldSoValue = async ( + ruleSO: RuleSO | undefined, + expectedSoValue: undefined | InvestigationFields, + es?: Client, + ruleId?: string +): Promise => { + if (!ruleSO && es && ruleId) { + const { + hits: { + hits: [{ _source: rule }], + }, + } = await getRuleSOById(es, ruleId); + + return isEqual(rule?.alert.params.investigationFields, expectedSoValue); + } + + return isEqual(ruleSO?.alert.params.investigationFields, expectedSoValue); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/index.ts index 90f3ae07871c8..501a5579fbfde 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/index.ts @@ -10,6 +10,7 @@ export * from './create_rule_with_exception_entries'; export * from './create_rule_saved_object'; export * from './create_rule_with_auth'; export * from './create_non_security_rule'; +export * from './check_investigation_field_in_so'; export * from './downgrade_immutable_rule'; export * from './delete_all_rules'; export * from './delete_rule'; diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/default_license/risk_engine/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/default_license/risk_engine/configs/ess.config.ts index 97686465c8073..db1ed95945baf 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/default_license/risk_engine/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/default_license/risk_engine/configs/ess.config.ts @@ -24,7 +24,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { }, testFiles: [require.resolve('..')], junit: { - reportName: 'Entity Analytics API Integration Tests - ESS - Risk Engine', + reportName: 'Entity Analytics - Risk Engine Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/default_license/risk_engine/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/default_license/risk_engine/configs/serverless.config.ts index ccbbcd9dc8cb8..35f50c7ad9f40 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/default_license/risk_engine/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/default_license/risk_engine/configs/serverless.config.ts @@ -15,6 +15,7 @@ export default createTestConfig({ ], testFiles: [require.resolve('..')], junit: { - reportName: 'Entity Analytics API Integration Tests - Serverless - Risk Engine', + reportName: + 'Entity Analytics - Risk Engine Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/exception_lists_items/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/exception_lists_items/configs/ess.config.ts index 366e0b956e370..3a0a941f48020 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/exception_lists_items/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/exception_lists_items/configs/ess.config.ts @@ -16,7 +16,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine ESS - Execption Lists and Items Integration Tests APIS', + reportName: 'Detection Engine - Exception Lists Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/exception_lists_items/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/exception_lists_items/configs/serverless.config.ts index bb1410030e0db..989ebcd4a34f5 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/exception_lists_items/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/exception_lists_items/configs/serverless.config.ts @@ -10,6 +10,7 @@ import { createTestConfig } from '../../../../../config/serverless/config.base'; export default createTestConfig({ testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine Serverless - Execption Lists and Items Integration Tests APIS', + reportName: + 'Detection Engine - Exception Lists Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/lists_items/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/lists_items/configs/ess.config.ts index 522c44b41d85a..0af6ce99fbbb3 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/lists_items/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/lists_items/configs/ess.config.ts @@ -16,7 +16,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine ESS - Lists and Items Integration Tests APIS', + reportName: 'Detection Engine - Value Lists Integration Tests - ESS Env - Trial License', }, }; } diff --git a/x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/lists_items/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/lists_items/configs/serverless.config.ts index 7e324d6e29836..f2e5509441e09 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/lists_items/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/lists_items/configs/serverless.config.ts @@ -10,6 +10,7 @@ import { createTestConfig } from '../../../../../config/serverless/config.base'; export default createTestConfig({ testFiles: [require.resolve('..')], junit: { - reportName: 'Detection Engine Serverless - Lists and Items Integration Tests APIS', + reportName: + 'Detection Engine - Value Lists Integration Tests - Serverless Env - Complete License', }, }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/detection_alerts/assignments/assignments.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/detection_alerts/assignments/assignments.cy.ts index 21a67b7fb4ea4..5dbf14796c583 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/detection_alerts/assignments/assignments.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/detection_alerts/assignments/assignments.cy.ts @@ -42,7 +42,8 @@ import { } from '../../../../../tasks/alert_assignments'; import { ALERTS_COUNT } from '../../../../../screens/alerts'; -describe('Alert user assignment - ESS & Serverless', { tags: ['@ess', '@serverless'] }, () => { +// Failing: See https://github.com/elastic/kibana/issues/173429 +describe.skip('Alert user assignment - ESS & Serverless', { tags: ['@ess', '@serverless'] }, () => { before(() => { cy.task('esArchiverLoad', { archiveName: 'auditbeat_multiple' }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/custom_saved_query_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/custom_saved_query_rule.cy.ts index 5a87350b730da..913218797fd76 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/custom_saved_query_rule.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/custom_saved_query_rule.cy.ts @@ -51,8 +51,7 @@ const savedQueryName = 'custom saved query'; const savedQueryQuery = 'process.name: test'; const savedQueryFilterKey = 'testAgent.value'; -// TODO: https://github.com/elastic/kibana/issues/161539 -describe('Saved query rules', { tags: ['@ess', '@serverless', '@brokenInServerless'] }, () => { +describe('Saved query rules', { tags: ['@ess', '@serverless'] }, () => { describe('Custom saved_query detection rule creation', () => { beforeEach(() => { login(); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/indicator_match_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/indicator_match_rule.cy.ts index b2b000803e6c4..9ce0bbb5be44a 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/indicator_match_rule.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/indicator_match_rule.cy.ts @@ -115,8 +115,7 @@ import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common'; const DEFAULT_THREAT_MATCH_QUERY = '@timestamp >= "now-30d/d"'; -// TODO: https://github.com/elastic/kibana/issues/161539 -describe('indicator match', { tags: ['@ess', '@serverless', '@brokenInServerless'] }, () => { +describe('indicator match', { tags: ['@ess', '@serverless'] }, () => { describe('Detection rules, Indicator Match', () => { const expectedUrls = getNewThreatIndicatorRule().references?.join(''); const expectedFalsePositives = getNewThreatIndicatorRule().false_positives?.join(''); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/dashboards/enable_risk_score_redirect.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/dashboards/enable_risk_score_redirect.cy.ts index 91488eeb57506..ebff5204d4981 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/dashboards/enable_risk_score_redirect.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/dashboards/enable_risk_score_redirect.cy.ts @@ -18,7 +18,6 @@ import { RiskScoreEntity } from '../../../tasks/risk_scores/common'; import { ENTITY_ANALYTICS_URL } from '../../../urls/navigation'; import { PAGE_TITLE } from '../../../screens/entity_analytics_management'; -// FLAKY: https://github.com/elastic/kibana/issues/165644 describe('Enable risk scores from dashboard', { tags: ['@ess', '@serverless'] }, () => { beforeEach(() => { login(); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/dashboards/entity_analytics.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/dashboards/entity_analytics.cy.ts index f586105483b9c..332da13af4359 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/dashboards/entity_analytics.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/dashboards/entity_analytics.cy.ts @@ -51,11 +51,10 @@ import { kqlSearch } from '../../../tasks/security_header'; import { setEndDate, setStartDate, updateDates } from '../../../tasks/date_picker'; import { enableJob, + mockRiskEngineEnabled, navigateToNextPage, waitForAnomaliesToBeLoaded, } from '../../../tasks/entity_analytics'; -import { deleteRiskEngineConfiguration } from '../../../tasks/api_calls/risk_engine'; -import { enableRiskEngine } from '../../../tasks/entity_analytics'; const TEST_USER_ALERTS = 1; const TEST_USER_NAME = 'test'; @@ -68,8 +67,6 @@ const OLDEST_DATE = moment('2019-01-19T16:22:56.217Z').format(DATE_FORMAT); describe('Entity Analytics Dashboard', { tags: ['@ess', '@serverless'] }, () => { before(() => { cy.task('esArchiverLoad', { archiveName: 'auditbeat_multiple' }); - login(); - deleteRiskEngineConfiguration(); }); after(() => { @@ -328,225 +325,207 @@ describe('Entity Analytics Dashboard', { tags: ['@ess', '@serverless'] }, () => }); }); - describe('Risk Score enabled but still no data', () => { - before(() => { - cy.task('esArchiverLoad', { archiveName: 'risk_scores_new_no_data' }); - }); - - beforeEach(() => { - login(); - enableRiskEngine(); - visitWithTimeRange(ENTITY_ANALYTICS_URL); - }); - - afterEach(() => { - deleteRiskEngineConfiguration(); - }); - - after(() => { - cy.task('esArchiverUnload', 'risk_scores_new_no_data'); - }); - - it('shows no data detected prompt for host and user risk scores', () => { - cy.get(HOST_RISK_SCORE_NO_DATA_DETECTED).should('be.visible'); - cy.get(USER_RISK_SCORE_NO_DATA_DETECTED).should('be.visible'); - }); - }); - - describe('With host risk data', () => { - before(() => { - cy.task('esArchiverLoad', { archiveName: 'risk_scores_new' }); - login(); - enableRiskEngine(); - }); - + describe('When risk engine is enabled', () => { beforeEach(() => { login(); + mockRiskEngineEnabled(); visitWithTimeRange(ENTITY_ANALYTICS_URL); }); - after(() => { - cy.task('esArchiverUnload', 'risk_scores_new'); - deleteRiskEngineConfiguration(); - }); - - it('renders donut chart', () => { - cy.get(HOSTS_DONUT_CHART).should('include.text', '6Total'); - }); - - it('renders table', () => { - cy.get(HOSTS_TABLE).should('be.visible'); - cy.get(HOSTS_TABLE_ROWS).should('have.length', 5); - }); - - it('renders alerts column', () => { - cy.get(HOSTS_TABLE_ALERT_CELL).should('have.length', 5); - }); - - it('filters by risk level', () => { - cy.get(HOSTS_DONUT_CHART).should('include.text', '6Total'); - openRiskTableFilterAndSelectTheCriticalOption(); - - cy.get(HOSTS_DONUT_CHART).should('include.text', '1Total'); - cy.get(HOSTS_TABLE_ROWS).should('have.length', 1); - - removeCriticalFilterAndCloseRiskTableFilter(); - }); + describe('Without data (before the risk engine runs for the first time)', () => { + before(() => { + cy.task('esArchiverLoad', { archiveName: 'risk_scores_new_no_data' }); + }); - it('filters the host risk table with KQL search bar query', () => { - kqlSearch(`host.name : ${SIEM_KIBANA_HOST_NAME}{enter}`); + after(() => { + cy.task('esArchiverUnload', 'risk_scores_new_no_data'); + }); - cy.get(HOSTS_DONUT_CHART).should('include.text', '1Total'); - cy.get(HOSTS_TABLE_ROWS).should('have.length', 1); + it('shows no data detected prompt for host and user risk scores', () => { + cy.get(HOST_RISK_SCORE_NO_DATA_DETECTED).should('be.visible'); + cy.get(USER_RISK_SCORE_NO_DATA_DETECTED).should('be.visible'); + }); }); - describe('With alerts data', () => { + describe('With host risk data', () => { before(() => { - createRule(getNewRule()); - }); - - beforeEach(() => { - login(); - visitWithTimeRange(ALERTS_URL); - waitForAlertsToPopulate(); - visitWithTimeRange(ENTITY_ANALYTICS_URL); + cy.task('esArchiverLoad', { archiveName: 'risk_scores_new' }); }); after(() => { - deleteAlertsAndRules(); + cy.task('esArchiverUnload', 'risk_scores_new'); }); - it('populates alerts column', () => { - cy.get(HOSTS_TABLE_ALERT_CELL).first().should('include.text', SIEM_KIBANA_HOST_ALERTS); + it('renders donut chart', () => { + cy.get(HOSTS_DONUT_CHART).should('include.text', '6Total'); }); - it('filters the alerts count with time range', () => { - setEndDate(DATE_BEFORE_ALERT_CREATION); - updateDates(); + it('renders table', () => { + cy.get(HOSTS_TABLE).should('be.visible'); + cy.get(HOSTS_TABLE_ROWS).should('have.length', 5); + }); - cy.get(HOSTS_TABLE_ALERT_CELL).first().should('include.text', 0); + it('renders alerts column', () => { + cy.get(HOSTS_TABLE_ALERT_CELL).should('have.length', 5); }); - it('filters risk scores with time range', () => { - const now = moment().format(DATE_FORMAT); - setStartDate(now); - updateDates(); + it('filters by risk level', () => { + cy.get(HOSTS_DONUT_CHART).should('include.text', '6Total'); + openRiskTableFilterAndSelectTheCriticalOption(); - cy.get(HOST_RISK_SCORE_NO_DATA_DETECTED).should('be.visible'); + cy.get(HOSTS_DONUT_CHART).should('include.text', '1Total'); + cy.get(HOSTS_TABLE_ROWS).should('have.length', 1); - // CLEAR DATES - setStartDate(OLDEST_DATE); - updateDates(); + removeCriticalFilterAndCloseRiskTableFilter(); }); - it('opens alerts page when alerts count is clicked', () => { - clickOnFirstHostsAlerts(); - cy.url().should('include', ALERTS_URL); + it('filters the host risk table with KQL search bar query', () => { + kqlSearch(`host.name : ${SIEM_KIBANA_HOST_NAME}{enter}`); - cy.get(OPTION_LIST_LABELS).eq(0).should('include.text', 'Status'); - cy.get(OPTION_LIST_VALUES(0)).should('include.text', 'open'); - cy.get(OPTION_LIST_LABELS).eq(1).should('include.text', 'Host'); - cy.get(OPTION_LIST_VALUES(1)).should('include.text', SIEM_KIBANA_HOST_NAME); + cy.get(HOSTS_DONUT_CHART).should('include.text', '1Total'); + cy.get(HOSTS_TABLE_ROWS).should('have.length', 1); }); - }); - }); - describe('With user risk data', () => { - before(() => { - cy.task('esArchiverLoad', { archiveName: 'risk_scores_new' }); - login(); - enableRiskEngine(); + describe('With alerts data', () => { + before(() => { + createRule(getNewRule()); + }); + + beforeEach(() => { + login(); + visitWithTimeRange(ALERTS_URL); + waitForAlertsToPopulate(); + visitWithTimeRange(ENTITY_ANALYTICS_URL); + }); + + after(() => { + deleteAlertsAndRules(); + }); + + it('populates alerts column', () => { + cy.get(HOSTS_TABLE_ALERT_CELL).first().should('include.text', SIEM_KIBANA_HOST_ALERTS); + }); + + it('filters the alerts count with time range', () => { + setEndDate(DATE_BEFORE_ALERT_CREATION); + updateDates(); + + cy.get(HOSTS_TABLE_ALERT_CELL).first().should('include.text', 0); + }); + + it('filters risk scores with time range', () => { + const now = moment().format(DATE_FORMAT); + setStartDate(now); + updateDates(); + + cy.get(HOST_RISK_SCORE_NO_DATA_DETECTED).should('be.visible'); + + // CLEAR DATES + setStartDate(OLDEST_DATE); + updateDates(); + }); + + it('opens alerts page when alerts count is clicked', () => { + clickOnFirstHostsAlerts(); + cy.url().should('include', ALERTS_URL); + + cy.get(OPTION_LIST_LABELS).eq(0).should('include.text', 'Status'); + cy.get(OPTION_LIST_VALUES(0)).should('include.text', 'open'); + cy.get(OPTION_LIST_LABELS).eq(1).should('include.text', 'Host'); + cy.get(OPTION_LIST_VALUES(1)).should('include.text', SIEM_KIBANA_HOST_NAME); + }); + }); }); - beforeEach(() => { - login(); - visitWithTimeRange(ENTITY_ANALYTICS_URL); - }); + describe('With user risk data', () => { + before(() => { + cy.task('esArchiverLoad', { archiveName: 'risk_scores_new' }); + }); - after(() => { - cy.task('esArchiverUnload', 'risk_scores_new'); - deleteRiskEngineConfiguration(); - }); + after(() => { + cy.task('esArchiverUnload', 'risk_scores_new'); + }); - it('renders donut chart', () => { - cy.get(USERS_DONUT_CHART).should('include.text', '7Total'); - }); + it('renders donut chart', () => { + cy.get(USERS_DONUT_CHART).should('include.text', '7Total'); + }); - it('renders table', () => { - cy.get(USERS_TABLE).should('be.visible'); - cy.get(USERS_TABLE_ROWS).should('have.length', 5); - }); + it('renders table', () => { + cy.get(USERS_TABLE).should('be.visible'); + cy.get(USERS_TABLE_ROWS).should('have.length', 5); + }); - it('renders alerts column', () => { - cy.get(USERS_TABLE_ALERT_CELL).should('have.length', 5); - }); + it('renders alerts column', () => { + cy.get(USERS_TABLE_ALERT_CELL).should('have.length', 5); + }); - it('filters by risk level', () => { - cy.get(USERS_DONUT_CHART).should('include.text', '7Total'); + it('filters by risk level', () => { + cy.get(USERS_DONUT_CHART).should('include.text', '7Total'); - openUserRiskTableFilterAndSelectTheLowOption(1); + openUserRiskTableFilterAndSelectTheLowOption(1); - cy.get(USERS_DONUT_CHART).should('include.text', '1Total'); - cy.get(USERS_TABLE_ROWS).should('have.length', 1); + cy.get(USERS_DONUT_CHART).should('include.text', '1Total'); + cy.get(USERS_TABLE_ROWS).should('have.length', 1); - removeLowFilterAndCloseUserRiskTableFilter(); - }); - - it('filters the host risk table with KQL search bar query', () => { - kqlSearch(`user.name : ${TEST_USER_NAME}{enter}`); + removeLowFilterAndCloseUserRiskTableFilter(); + }); - cy.get(USERS_DONUT_CHART).should('include.text', '1Total'); - cy.get(USERS_TABLE_ROWS).should('have.length', 1); - }); + it('filters the host risk table with KQL search bar query', () => { + kqlSearch(`user.name : ${TEST_USER_NAME}{enter}`); - describe('With alerts data', () => { - before(() => { - createRule(getNewRule()); + cy.get(USERS_DONUT_CHART).should('include.text', '1Total'); + cy.get(USERS_TABLE_ROWS).should('have.length', 1); }); - beforeEach(() => { - login(); - visitWithTimeRange(ALERTS_URL); - waitForAlertsToPopulate(); - visitWithTimeRange(ENTITY_ANALYTICS_URL); - }); + describe('With alerts data', () => { + before(() => { + createRule(getNewRule()); + }); - after(() => { - deleteAlertsAndRules(); - }); + beforeEach(() => { + login(); + visitWithTimeRange(ALERTS_URL); + waitForAlertsToPopulate(); + visitWithTimeRange(ENTITY_ANALYTICS_URL); + }); - it('populates alerts column', () => { - cy.get(USERS_TABLE_ALERT_CELL).first().should('include.text', TEST_USER_ALERTS); - }); + after(() => { + deleteAlertsAndRules(); + }); - it('filters the alerts count with time range', () => { - setEndDate(DATE_BEFORE_ALERT_CREATION); - updateDates(); + it('populates alerts column', () => { + cy.get(USERS_TABLE_ALERT_CELL).first().should('include.text', TEST_USER_ALERTS); + }); - cy.get(USERS_TABLE_ALERT_CELL).first().should('include.text', 0); - }); + it('filters the alerts count with time range', () => { + setEndDate(DATE_BEFORE_ALERT_CREATION); + updateDates(); - it('filters risk scores with time range', () => { - const now = moment().format(DATE_FORMAT); - setStartDate(now); - updateDates(); + cy.get(USERS_TABLE_ALERT_CELL).first().should('include.text', 0); + }); - cy.get(USER_RISK_SCORE_NO_DATA_DETECTED).should('be.visible'); + it('filters risk scores with time range', () => { + const now = moment().format(DATE_FORMAT); + setStartDate(now); + updateDates(); - // CLEAR DATES - setStartDate(OLDEST_DATE); - updateDates(); - }); + cy.get(USER_RISK_SCORE_NO_DATA_DETECTED).should('be.visible'); - it('opens alerts page when alerts count is clicked', () => { - clickOnFirstUsersAlerts(); + // CLEAR DATES + setStartDate(OLDEST_DATE); + updateDates(); + }); - cy.url().should('include', ALERTS_URL); + it('opens alerts page when alerts count is clicked', () => { + clickOnFirstUsersAlerts(); - cy.get(OPTION_LIST_LABELS).eq(0).should('include.text', 'Status'); - cy.get(OPTION_LIST_VALUES(0)).should('include.text', 'open'); - cy.get(OPTION_LIST_LABELS).eq(1).should('include.text', 'User'); - cy.get(OPTION_LIST_VALUES(1)).should('include.text', TEST_USER_NAME); + cy.url().should('include', ALERTS_URL); + + cy.get(OPTION_LIST_LABELS).eq(0).should('include.text', 'Status'); + cy.get(OPTION_LIST_VALUES(0)).should('include.text', 'open'); + cy.get(OPTION_LIST_LABELS).eq(1).should('include.text', 'User'); + cy.get(OPTION_LIST_VALUES(1)).should('include.text', TEST_USER_NAME); + }); }); }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/enrichments.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/enrichments.cy.ts index 60b93650048d9..acb38b4dcefff 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/enrichments.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/enrichments.cy.ts @@ -12,7 +12,6 @@ import { HOST_RISK_COLUMN, USER_RISK_COLUMN, ACTION_COLUMN, - ALERTS_COUNT, } from '../../screens/alerts'; import { ENRICHED_DATA_ROW } from '../../screens/alerts_details'; @@ -30,14 +29,12 @@ import { login } from '../../tasks/login'; import { visitWithTimeRange } from '../../tasks/navigation'; import { ALERTS_URL } from '../../urls/navigation'; -import { deleteRiskEngineConfiguration } from '../../tasks/api_calls/risk_engine'; -import { enableRiskEngine } from '../../tasks/entity_analytics'; +import { mockRiskEngineEnabled } from '../../tasks/entity_analytics'; const CURRENT_HOST_RISK_LEVEL = 'Current host risk level'; const ORIGINAL_HOST_RISK_LEVEL = 'Original host risk level'; -// FLAKY: https://github.com/elastic/kibana/issues/169154 -describe.skip('Enrichment', { tags: ['@ess', '@serverless'] }, () => { +describe('Enrichment', { tags: ['@ess', '@serverless'] }, () => { before(() => { cy.task('esArchiverUnload', 'risk_scores_new'); cy.task('esArchiverUnload', 'risk_scores_new_updated'); @@ -56,7 +53,6 @@ describe.skip('Enrichment', { tags: ['@ess', '@serverless'] }, () => { deleteAlertsAndRules(); createRule(getNewRule({ rule_id: 'rule1' })); login(); - deleteRiskEngineConfiguration(); visitWithTimeRange(ALERTS_URL); waitForAlertsToPopulate(); }); @@ -67,9 +63,6 @@ describe.skip('Enrichment', { tags: ['@ess', '@serverless'] }, () => { }); it('Should has enrichment fields from legacy risk', function () { - cy.get(ALERTS_COUNT) - .invoke('text') - .should('match', /^[1-9].+$/); // Any number of alerts cy.get(HOST_RISK_HEADER_COLUMN).contains('host.risk.calculated_level'); cy.get(USER_RISK_HEADER_COLUMN).contains('user.risk.calculated_level'); scrollAlertTableColumnIntoView(HOST_RISK_COLUMN); @@ -99,7 +92,7 @@ describe.skip('Enrichment', { tags: ['@ess', '@serverless'] }, () => { deleteAlertsAndRules(); createRule(getNewRule({ rule_id: 'rule1' })); login(); - enableRiskEngine(); + mockRiskEngineEnabled(); visitWithTimeRange(ALERTS_URL); waitForAlertsToPopulate(); }); @@ -107,13 +100,9 @@ describe.skip('Enrichment', { tags: ['@ess', '@serverless'] }, () => { afterEach(() => { cy.task('esArchiverUnload', 'risk_scores_new'); cy.task('esArchiverUnload', 'risk_scores_new_updated'); - deleteRiskEngineConfiguration(); }); it('Should has enrichment fields from legacy risk', function () { - cy.get(ALERTS_COUNT) - .invoke('text') - .should('match', /^[1-9].+$/); // Any number of alerts cy.get(HOST_RISK_HEADER_COLUMN).contains('host.risk.calculated_level'); cy.get(USER_RISK_HEADER_COLUMN).contains('user.risk.calculated_level'); scrollAlertTableColumnIntoView(HOST_RISK_COLUMN); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/hosts/host_risk_tab.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/hosts/host_risk_tab.cy.ts index e0ea108e38a00..a45fcbfb53e0d 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/hosts/host_risk_tab.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/hosts/host_risk_tab.cy.ts @@ -20,15 +20,12 @@ import { login } from '../../../tasks/login'; import { visitWithTimeRange } from '../../../tasks/navigation'; import { hostsUrl } from '../../../urls/navigation'; import { kqlSearch } from '../../../tasks/security_header'; -import { deleteRiskEngineConfiguration } from '../../../tasks/api_calls/risk_engine'; -import { enableRiskEngine } from '../../../tasks/entity_analytics'; +import { mockRiskEngineEnabled } from '../../../tasks/entity_analytics'; // Tracked by https://github.com/elastic/security-team/issues/7696 describe('risk tab', { tags: ['@ess', '@serverless'] }, () => { describe('with legacy risk score', () => { before(() => { - login(); - deleteRiskEngineConfiguration(); cy.task('esArchiverLoad', { archiveName: 'risk_hosts' }); }); @@ -53,7 +50,7 @@ describe('risk tab', { tags: ['@ess', '@serverless'] }, () => { cy.get(HOST_BY_RISK_TABLE_CELL).eq(7).should('have.text', 'Low'); }); - it.skip('filters the table', () => { + it('filters the table', () => { openRiskTableFilterAndSelectTheCriticalOption(); cy.get(HOST_BY_RISK_TABLE_CELL).eq(3).should('not.have.text', 'siem-kibana'); @@ -61,8 +58,7 @@ describe('risk tab', { tags: ['@ess', '@serverless'] }, () => { removeCriticalFilterAndCloseRiskTableFilter(); }); - // Flaky - it.skip('should be able to change items count per page', () => { + it('should be able to change items count per page', () => { selectFiveItemsPerPageOption(); cy.get(HOST_BY_RISK_TABLE_HOSTNAME_CELL).should('have.length', 5); @@ -77,12 +73,11 @@ describe('risk tab', { tags: ['@ess', '@serverless'] }, () => { describe('with new risk score', () => { before(() => { cy.task('esArchiverLoad', { archiveName: 'risk_scores_new' }); - login(); - enableRiskEngine(); }); beforeEach(() => { login(); + mockRiskEngineEnabled(); visitWithTimeRange(hostsUrl('allHosts')); // by some reason after navigate to host risk, page is sometimes is reload or go to all host tab // this fix wait until we fave host in all host table, and then we go to risk tab @@ -92,7 +87,6 @@ describe('risk tab', { tags: ['@ess', '@serverless'] }, () => { after(() => { cy.task('esArchiverUnload', 'risk_scores_new'); - deleteRiskEngineConfiguration(); }); it('renders the table', () => { @@ -103,7 +97,7 @@ describe('risk tab', { tags: ['@ess', '@serverless'] }, () => { cy.get(HOST_BY_RISK_TABLE_CELL).eq(7).should('have.text', 'Critical'); }); - it.skip('filters the table', () => { + it('filters the table', () => { openRiskTableFilterAndSelectTheCriticalOption(); cy.get(HOST_BY_RISK_TABLE_CELL).eq(3).should('not.have.text', 'siem-kibana'); @@ -112,7 +106,7 @@ describe('risk tab', { tags: ['@ess', '@serverless'] }, () => { }); // Flaky - it.skip('should be able to change items count per page', () => { + it('should be able to change items count per page', () => { selectFiveItemsPerPageOption(); cy.get(HOST_BY_RISK_TABLE_HOSTNAME_CELL).should('have.length', 5); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/hosts/hosts_risk_column.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/hosts/hosts_risk_column.cy.ts index 4b034d77ed07d..15e62f274145f 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/hosts/hosts_risk_column.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/hosts/hosts_risk_column.cy.ts @@ -11,8 +11,7 @@ import { visitWithTimeRange } from '../../../tasks/navigation'; import { hostsUrl } from '../../../urls/navigation'; import { TABLE_CELL } from '../../../screens/alerts_details'; import { kqlSearch } from '../../../tasks/security_header'; -import { deleteRiskEngineConfiguration } from '../../../tasks/api_calls/risk_engine'; -import { enableRiskEngine } from '../../../tasks/entity_analytics'; +import { mockRiskEngineEnabled } from '../../../tasks/entity_analytics'; describe('All hosts table', { tags: ['@ess', '@serverless'] }, () => { describe('with legacy risk score', () => { @@ -23,7 +22,6 @@ describe('All hosts table', { tags: ['@ess', '@serverless'] }, () => { beforeEach(() => { login(); - deleteRiskEngineConfiguration(); }); after(() => { @@ -47,12 +45,11 @@ describe('All hosts table', { tags: ['@ess', '@serverless'] }, () => { beforeEach(() => { login(); - enableRiskEngine(); + mockRiskEngineEnabled(); }); after(() => { cy.task('esArchiverUnload', 'risk_scores_new'); - deleteRiskEngineConfiguration(); }); it('it renders risk column', () => { diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/fields_browser.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/fields_browser.cy.ts index 776770060e85b..efa1b50ee8147 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/fields_browser.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/fields_browser.cy.ts @@ -13,9 +13,7 @@ import { FIELDS_BROWSER_MESSAGE_HEADER, FIELDS_BROWSER_FILTER_INPUT, FIELDS_BROWSER_CATEGORIES_FILTER_CONTAINER, - FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES, FIELDS_BROWSER_CATEGORY_BADGE, - FIELDS_BROWSER_VIEW_BUTTON, } from '../../../screens/fields_browser'; import { TIMELINE_FIELDS_BUTTON } from '../../../screens/timeline'; @@ -29,12 +27,11 @@ import { resetFields, toggleCategory, activateViewSelected, - activateViewAll, } from '../../../tasks/fields_browser'; import { login } from '../../../tasks/login'; import { visitWithTimeRange } from '../../../tasks/navigation'; import { openTimelineUsingToggle } from '../../../tasks/security_main'; -import { openTimelineFieldsBrowser, populateTimeline } from '../../../tasks/timeline'; +import { openTimelineFieldsBrowser } from '../../../tasks/timeline'; import { hostsUrl } from '../../../urls/navigation'; @@ -49,102 +46,75 @@ const defaultHeaders = [ { id: 'user.name' }, ]; -// Flaky in serverless tests -// FLAKY: https://github.com/elastic/kibana/issues/169363 -describe.skip('Fields Browser', { tags: ['@ess', '@serverless'] }, () => { - context('Fields Browser rendering', () => { - beforeEach(() => { - login(); - visitWithTimeRange(hostsUrl('allHosts')); - openTimelineUsingToggle(); - populateTimeline(); - openTimelineFieldsBrowser(); - }); - - it('displays all categories (by default)', () => { - cy.get(FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES).should('be.empty'); - }); - - it('displays "view all" option by default', () => { - cy.get(FIELDS_BROWSER_VIEW_BUTTON).should('contain.text', 'View: all'); - }); +describe('Fields Browser', { tags: ['@ess', '@serverless'] }, () => { + beforeEach(() => { + login(); + visitWithTimeRange(hostsUrl('allHosts')); + openTimelineUsingToggle(); + openTimelineFieldsBrowser(); + }); - it('displays the expected count of categories that match the filter input', () => { + describe('Fields Browser rendering', () => { + it('should display the expected count of categories and fields that match the filter input', () => { const filterInput = 'host.mac'; filterFieldsBrowser(filterInput); cy.get(FIELDS_BROWSER_CATEGORIES_COUNT).should('have.text', '2'); - }); - - it('displays a search results label with the expected count of fields matching the filter input', () => { - const filterInput = 'host.mac'; - filterFieldsBrowser(filterInput); - cy.get(FIELDS_BROWSER_FIELDS_COUNT).should('contain.text', '2'); }); - it('displays only the selected fields when "view selected" option is enabled', () => { + it('should display only the selected fields when "view selected" option is enabled', () => { activateViewSelected(); cy.get(FIELDS_BROWSER_FIELDS_COUNT).should('contain.text', `${defaultHeaders.length}`); defaultHeaders.forEach((header) => { cy.get(`[data-test-subj="field-${header.id}-checkbox"]`).should('be.checked'); }); - activateViewAll(); }); - it('creates the category badge when it is selected', () => { + it('should create the category badge when it is selected', () => { const category = 'host'; + const categoryCheck = 'event'; cy.get(FIELDS_BROWSER_CATEGORY_BADGE(category)).should('not.exist'); + toggleCategory(category); + cy.get(FIELDS_BROWSER_CATEGORY_BADGE(category)).should('exist'); + toggleCategory(category); - }); - it('search a category should match the category in the category filter', () => { - const category = 'host'; + cy.log('the category filter should contain the filtered category'); filterFieldsBrowser(category); toggleCategoryFilter(); + cy.get(FIELDS_BROWSER_CATEGORIES_FILTER_CONTAINER).should('contain.text', category); - }); - it('search a category should filter out non matching categories in the category filter', () => { - const category = 'host'; - const categoryCheck = 'event'; - filterFieldsBrowser(category); - toggleCategoryFilter(); + cy.log('non-matching categories should not be listed in the category filter'); + cy.get(FIELDS_BROWSER_CATEGORIES_FILTER_CONTAINER).should('not.contain.text', categoryCheck); }); }); - context('Editing the timeline', () => { - beforeEach(() => { - login(); - visitWithTimeRange(hostsUrl('allHosts')); - openTimelineUsingToggle(); - populateTimeline(); - openTimelineFieldsBrowser(); - }); + describe('Editing the timeline', () => { + it('should add/remove columns from the alerts table when the user checks/un-checks them', () => { + const filterInput = 'host.geo.c'; + + cy.log('removing the message column'); - it('removes the message field from the timeline when the user un-checks the field', () => { cy.get(FIELDS_BROWSER_MESSAGE_HEADER).should('exist'); removesMessageField(); closeFieldsBrowser(); cy.get(FIELDS_BROWSER_MESSAGE_HEADER).should('not.exist'); - }); - it('adds a field to the timeline when the user clicks the checkbox', () => { - const filterInput = 'host.geo.c'; + cy.log('add host.geo.city_name column'); - closeFieldsBrowser(); cy.get(FIELDS_BROWSER_HOST_GEO_CITY_NAME_HEADER).should('not.exist'); openTimelineFieldsBrowser(); - filterFieldsBrowser(filterInput); addsHostGeoCityNameToTimeline(); closeFieldsBrowser(); @@ -152,7 +122,7 @@ describe.skip('Fields Browser', { tags: ['@ess', '@serverless'] }, () => { cy.get(FIELDS_BROWSER_HOST_GEO_CITY_NAME_HEADER).should('exist'); }); - it('resets all fields in the timeline when `Reset Fields` is clicked', () => { + it('should reset all fields in the timeline when `Reset Fields` is clicked', () => { const filterInput = 'host.geo.c'; filterFieldsBrowser(filterInput); @@ -168,19 +138,16 @@ describe.skip('Fields Browser', { tags: ['@ess', '@serverless'] }, () => { resetFields(); cy.get(FIELDS_BROWSER_HEADER_HOST_GEO_CONTINENT_NAME_HEADER).should('not.exist'); - }); - it('restores focus to the Customize Columns button when `Reset Fields` is clicked', () => { - openTimelineFieldsBrowser(); - resetFields(); + cy.log('restores focus to the Customize Columns button when `Reset Fields` is clicked'); cy.get(TIMELINE_FIELDS_BUTTON).should('have.focus'); - }); - it('restores focus to the Customize Columns button when Esc is pressed', () => { + cy.log('restores focus to the Customize Columns button when Esc is pressed'); + openTimelineFieldsBrowser(); - cy.get(FIELDS_BROWSER_FILTER_INPUT).type('{esc}'); + cy.get(FIELDS_BROWSER_FILTER_INPUT).type('{esc}'); cy.get(TIMELINE_FIELDS_BUTTON).should('have.focus'); }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/full_screen.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/full_screen.cy.ts index ea9742a9c0829..e3f9023fe189d 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/full_screen.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/full_screen.cy.ts @@ -14,16 +14,14 @@ import { enterFullScreenMode, exitFullScreenMode, } from '../../../tasks/security_main'; -import { populateTimeline } from '../../../tasks/timeline'; import { hostsUrl } from '../../../urls/navigation'; -describe.skip('Toggle full screen', { tags: ['@ess', '@serverless'] }, () => { +describe('Toggle full screen', { tags: ['@ess', '@serverless'] }, () => { beforeEach(() => { login(); visitWithTimeRange(hostsUrl('allHosts')); openTimelineUsingToggle(); - populateTimeline(); }); it('Should hide timeline header and tab list area', () => { diff --git a/x-pack/test/security_solution_cypress/cypress/screens/discover.ts b/x-pack/test/security_solution_cypress/cypress/screens/discover.ts index 6b53754cde44f..8b2d9ccac1bfa 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/discover.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/discover.ts @@ -43,7 +43,7 @@ export const DISCOVER_FILTER_BADGES = `${DISCOVER_CONTAINER} ${getDataTestSubjec 'filter-badge-' )}`; -export const DISCOVER_RESULT_HITS = getDataTestSubjectSelector('unifiedHistogramQueryHits'); +export const DISCOVER_RESULT_HITS = getDataTestSubjectSelector('discoverQueryHits'); export const DISCOVER_FIELDS_LOADING = getDataTestSubjectSelector( 'fieldListGroupedAvailableFields-countLoading' diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/entity_analytics.ts b/x-pack/test/security_solution_cypress/cypress/tasks/entity_analytics.ts index a4e0eafc0018a..5abdf689b1c52 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/entity_analytics.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/entity_analytics.ts @@ -69,12 +69,20 @@ export const mockRiskEngineEnabled = () => { }).as('riskIndexStatus'); }; -export const enableRiskEngine = () => { - cy.visit(ENTITY_ANALYTICS_MANAGEMENT_URL); - cy.get(RISK_SCORE_STATUS).should('have.text', 'Off'); - riskEngineStatusChange(); - cy.get(RISK_SCORE_STATUS).should('have.text', 'On'); -}; +/** + * @deprecated + * At the moment there isn't a way to clean all assets created by the risk engine enablement. + * We can't clean assets after each tests and we can't call this function from the `after` hook (cypress good practice). + * Reintroduce this task when we can safely delete the risk engine data. + * + * Please use `mockRiskEngineEnabled` instead. + */ +// const enableRiskEngine = () => { +// cy.visit(ENTITY_ANALYTICS_MANAGEMENT_URL); +// cy.get(RISK_SCORE_STATUS).should('have.text', 'Off'); +// riskEngineStatusChange(); +// cy.get(RISK_SCORE_STATUS).should('have.text', 'On'); +// }; export const updateRiskEngine = () => { cy.get(RISK_SCORE_UPDATE_BUTTON).click(); diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/fields_browser.ts b/x-pack/test/security_solution_cypress/cypress/tasks/fields_browser.ts index c31196a96a550..f9e83fb449b77 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/fields_browser.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/fields_browser.ts @@ -29,15 +29,11 @@ export const addsFields = (fields: string[]) => { }; export const addsHostGeoCityNameToTimeline = () => { - cy.get(FIELDS_BROWSER_HOST_GEO_CITY_NAME_CHECKBOX).check({ - force: true, - }); + cy.get(FIELDS_BROWSER_HOST_GEO_CITY_NAME_CHECKBOX).check(); }; export const addsHostGeoContinentNameToTimeline = () => { - cy.get(FIELDS_BROWSER_HOST_GEO_CONTINENT_NAME_CHECKBOX).check({ - force: true, - }); + cy.get(FIELDS_BROWSER_HOST_GEO_CONTINENT_NAME_CHECKBOX).check(); }; export const clearFieldsBrowser = () => { @@ -67,7 +63,7 @@ export const filterFieldsBrowser = (fieldName: string) => { }; export const toggleCategoryFilter = () => { - cy.get(FIELDS_BROWSER_CATEGORIES_FILTER_BUTTON).click({ force: true }); + cy.get(FIELDS_BROWSER_CATEGORIES_FILTER_BUTTON).click(); }; export const toggleCategory = (category: string) => { @@ -79,9 +75,7 @@ export const toggleCategory = (category: string) => { }; export const removesMessageField = () => { - cy.get(FIELDS_BROWSER_MESSAGE_CHECKBOX).uncheck({ - force: true, - }); + cy.get(FIELDS_BROWSER_MESSAGE_CHECKBOX).uncheck(); }; export const removeField = (fieldName: string) => { @@ -89,14 +83,14 @@ export const removeField = (fieldName: string) => { }; export const resetFields = () => { - cy.get(FIELDS_BROWSER_RESET_FIELDS).click({ force: true }); + cy.get(FIELDS_BROWSER_RESET_FIELDS).click(); }; export const activateViewSelected = () => { - cy.get(FIELDS_BROWSER_VIEW_BUTTON).click({ force: true }); - cy.get(FIELDS_BROWSER_VIEW_SELECTED).click({ force: true }); + cy.get(FIELDS_BROWSER_VIEW_BUTTON).click(); + cy.get(FIELDS_BROWSER_VIEW_SELECTED).click(); }; export const activateViewAll = () => { - cy.get(FIELDS_BROWSER_VIEW_BUTTON).click({ force: true }); - cy.get(FIELDS_BROWSER_VIEW_ALL).click({ force: true }); + cy.get(FIELDS_BROWSER_VIEW_BUTTON).click(); + cy.get(FIELDS_BROWSER_VIEW_ALL).click(); }; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/security_main.ts b/x-pack/test/security_solution_cypress/cypress/tasks/security_main.ts index 5b034b0ae4b0e..fbf31d19e5a4c 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/security_main.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/security_main.ts @@ -29,9 +29,9 @@ export const closeTimelineUsingCloseButton = () => { }; export const enterFullScreenMode = () => { - cy.get(TIMELINE_FULL_SCREEN_BUTTON).first().click({ force: true }); + cy.get(TIMELINE_FULL_SCREEN_BUTTON).first().click(); }; export const exitFullScreenMode = () => { - cy.get(TIMELINE_EXIT_FULL_SCREEN_BUTTON).first().click({ force: true }); + cy.get(TIMELINE_EXIT_FULL_SCREEN_BUTTON).first().click(); }; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts b/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts index 35248f80ca895..d8543ec852c17 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts @@ -370,7 +370,7 @@ export const markAsFavorite = () => { }; export const openTimelineFieldsBrowser = () => { - cy.get(TIMELINE_FIELDS_BUTTON).first().click({ force: true }); + cy.get(TIMELINE_FIELDS_BUTTON).first().click(); }; export const openTimelineInspectButton = () => { diff --git a/x-pack/test/security_solution_cypress/es_archives/risk_hosts_updated/mappings.json b/x-pack/test/security_solution_cypress/es_archives/risk_hosts_updated/mappings.json index 3e1b52cb22f5e..e250700644c15 100644 --- a/x-pack/test/security_solution_cypress/es_archives/risk_hosts_updated/mappings.json +++ b/x-pack/test/security_solution_cypress/es_archives/risk_hosts_updated/mappings.json @@ -31,10 +31,6 @@ }, "settings": { "index": { - "lifecycle": { - "name": "ml_host_risk_score_latest_default", - "rollover_alias": "ml_host_risk_score_latest_default" - }, "mapping": { "total_fields": { "limit": "10000" @@ -83,10 +79,6 @@ }, "settings": { "index": { - "lifecycle": { - "name": "ml_host_risk_score_default", - "rollover_alias": "ml_host_risk_score_default" - }, "mapping": { "total_fields": { "limit": "10000" diff --git a/x-pack/test/security_solution_cypress/es_archives/threat_indicator/mappings.json b/x-pack/test/security_solution_cypress/es_archives/threat_indicator/mappings.json index bc5f6e3db9169..e22f719255fe5 100644 --- a/x-pack/test/security_solution_cypress/es_archives/threat_indicator/mappings.json +++ b/x-pack/test/security_solution_cypress/es_archives/threat_indicator/mappings.json @@ -811,10 +811,6 @@ }, "settings": { "index": { - "lifecycle": { - "name": "filebeat", - "rollover_alias": "filebeat-7.12.0" - }, "mapping": { "total_fields": { "limit": "10000" diff --git a/x-pack/test_serverless/api_integration/services/saml_tools.ts b/x-pack/test_serverless/api_integration/services/saml_tools.ts index bd5cd03a7edbb..925f963f1223e 100644 --- a/x-pack/test_serverless/api_integration/services/saml_tools.ts +++ b/x-pack/test_serverless/api_integration/services/saml_tools.ts @@ -9,7 +9,7 @@ import expect from '@kbn/expect'; import { parse as parseCookie } from 'tough-cookie'; import Url from 'url'; -import { createSAMLResponse } from '@kbn/mock-idp-plugin/common'; +import { createSAMLResponse } from '@kbn/mock-idp-utils'; import { FtrProviderContext } from '../ftr_provider_context'; export function SamlToolsProvider({ getService }: FtrProviderContext) { diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover.ts b/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover.ts index 684278f7e8638..9768eb43e76ca 100644 --- a/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover.ts +++ b/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover.ts @@ -117,10 +117,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should show correct initial chart interval of Auto', async function () { await PageObjects.timePicker.setDefaultAbsoluteRange(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.click('unifiedHistogramQueryHits'); // to cancel out tooltips + await testSubjects.click('discoverQueryHits'); // to cancel out tooltips const actualInterval = await PageObjects.discover.getChartInterval(); - const expectedInterval = 'Auto'; + const expectedInterval = 'auto'; expect(actualInterval).to.be(expectedInterval); }); diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover_histogram.ts b/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover_histogram.ts index ccba699ffa710..cf581ad3edb51 100644 --- a/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover_histogram.ts +++ b/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover_histogram.ts @@ -159,6 +159,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const chartCanvasExist = await elasticChart.canvasExists(); expect(chartCanvasExist).to.be(true); const chartIntervalIconTip = await PageObjects.discover.getChartIntervalWarningIcon(); + expect(chartIntervalIconTip).to.be(false); + }); + it('should visualize monthly data with different years scaled to seconds', async () => { + const from = 'Jan 1, 2010 @ 00:00:00.000'; + const to = 'Mar 21, 2019 @ 00:00:00.000'; + await prepareTest({ from, to }, 'Second'); + const chartCanvasExist = await elasticChart.canvasExists(); + expect(chartCanvasExist).to.be(true); + const chartIntervalIconTip = await PageObjects.discover.getChartIntervalWarningIcon(); expect(chartIntervalIconTip).to.be(true); }); it('should allow hide/show histogram, persisted in url state', async () => { @@ -167,8 +176,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await prepareTest({ from, to }); let canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(true); - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(false); @@ -177,8 +185,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await browser.refresh(); canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(false); - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); @@ -192,8 +199,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await prepareTest({ from, to }); // close chart for saved search - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); let canvasExists: boolean; await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); @@ -215,8 +221,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(canvasExists).to.be(false); // open chart for saved search - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await retry.waitFor(`Discover histogram to be displayed`, async () => { canvasExists = await elasticChart.canvasExists(); return canvasExists; @@ -238,8 +243,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should show permitted hidden histogram state when returning back to discover', async () => { // close chart - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); let canvasExists: boolean; await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); @@ -251,8 +255,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); // open chart - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(true); @@ -269,8 +272,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(canvasExists).to.be(true); // close chart - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(false); @@ -281,8 +283,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('discover'); await PageObjects.discover.waitUntilSearchingHasFinished(); // Make sure the chart is visible - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await PageObjects.discover.waitUntilSearchingHasFinished(); // type an invalid search query, hit refresh await queryBar.setQuery('this is > not valid'); diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/group3/_sidebar.ts b/x-pack/test_serverless/functional/test_suites/common/discover/group3/_sidebar.ts index 0435fdffa7ede..0504049f89ed5 100644 --- a/x-pack/test_serverless/functional/test_suites/common/discover/group3/_sidebar.ts +++ b/x-pack/test_serverless/functional/test_suites/common/discover/group3/_sidebar.ts @@ -242,13 +242,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should collapse when clicked', async function () { - await PageObjects.discover.toggleSidebarCollapse(); - await testSubjects.existOrFail('discover-sidebar'); + await PageObjects.discover.closeSidebar(); + await testSubjects.existOrFail('dscShowSidebarButton'); await testSubjects.missingOrFail('fieldList'); }); it('should expand when clicked', async function () { - await PageObjects.discover.toggleSidebarCollapse(); + await PageObjects.discover.openSidebar(); await testSubjects.existOrFail('discover-sidebar'); await testSubjects.existOrFail('fieldList'); }); diff --git a/x-pack/test_serverless/functional/test_suites/common/management/index_management/index_templates.ts b/x-pack/test_serverless/functional/test_suites/common/management/index_management/index_templates.ts index 6092473ad27bc..7d591ade32c3c 100644 --- a/x-pack/test_serverless/functional/test_suites/common/management/index_management/index_templates.ts +++ b/x-pack/test_serverless/functional/test_suites/common/management/index_management/index_templates.ts @@ -16,8 +16,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const testSubjects = getService('testSubjects'); const es = getService('es'); const retry = getService('retry'); + const log = getService('log'); const TEST_TEMPLATE = 'a_test_template'; + const INDEX_PATTERN = `index_pattern_${Math.random()}`; describe('Index Templates', function () { before(async () => { @@ -32,6 +34,15 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); after(async () => { + log.debug('Cleaning up created template'); + + try { + await es.indices.deleteIndexTemplate({ name: TEST_TEMPLATE }, { ignore: [404] }); + } catch (e) { + log.debug('[Setup error] Error creating test policy'); + throw e; + } + await pageObjects.svlCommonPage.forceLogout(); }); @@ -45,7 +56,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await es.indices.putIndexTemplate({ name: TEST_TEMPLATE, body: { - index_patterns: ['test*'], + index_patterns: [INDEX_PATTERN], }, }); }); @@ -85,7 +96,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('createTemplateButton'); await testSubjects.setValue('nameField', TEST_TEMPLATE_NAME); - await testSubjects.setValue('indexPatternsField', 'test*'); + await testSubjects.setValue('indexPatternsField', INDEX_PATTERN); // Click form summary step and then the submit button await testSubjects.click('formWizardStep-5'); diff --git a/x-pack/test_serverless/functional/test_suites/common/visualizations/group2/open_in_lens/agg_based/goal.ts b/x-pack/test_serverless/functional/test_suites/common/visualizations/group2/open_in_lens/agg_based/goal.ts index 5de789198f420..01f655af00a1f 100644 --- a/x-pack/test_serverless/functional/test_suites/common/visualizations/group2/open_in_lens/agg_based/goal.ts +++ b/x-pack/test_serverless/functional/test_suites/common/visualizations/group2/open_in_lens/agg_based/goal.ts @@ -50,7 +50,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: undefined, extraText: '', value: '140.05%', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', showingBar: true, showingTrendline: false, }, @@ -77,7 +77,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: undefined, extraText: '', value: '131,040,360.81%', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', showingBar: true, showingTrendline: false, }, @@ -105,7 +105,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: undefined, extraText: '', value: '14.37%', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', showingBar: true, showingTrendline: false, }, @@ -133,7 +133,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: 'Average machine.ram', extraText: '', value: '13,228,964,670.613', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', showingTrendline: false, showingBar: true, }, @@ -142,7 +142,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: 'Average machine.ram', extraText: '', value: '13,186,695,551.251', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', showingTrendline: false, showingBar: true, }, @@ -151,7 +151,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: 'Average machine.ram', extraText: '', value: '13,073,190,186.423', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', showingTrendline: false, showingBar: true, }, @@ -160,7 +160,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: 'Average machine.ram', extraText: '', value: '13,031,579,645.108', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', showingTrendline: false, showingBar: true, }, @@ -169,7 +169,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: 'Average machine.ram', extraText: '', value: '13,009,497,206.823', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', showingTrendline: false, showingBar: true, }, diff --git a/x-pack/test_serverless/functional/test_suites/common/visualizations/group2/open_in_lens/agg_based/metric.ts b/x-pack/test_serverless/functional/test_suites/common/visualizations/group2/open_in_lens/agg_based/metric.ts index abd44aefe4d5a..9bd990484cc81 100644 --- a/x-pack/test_serverless/functional/test_suites/common/visualizations/group2/open_in_lens/agg_based/metric.ts +++ b/x-pack/test_serverless/functional/test_suites/common/visualizations/group2/open_in_lens/agg_based/metric.ts @@ -46,7 +46,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: undefined, extraText: '', value: '14,005', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', showingBar: false, showingTrendline: false, }, @@ -72,7 +72,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: undefined, extraText: '', value: '13,104,036,080.615', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', showingBar: false, showingTrendline: false, }, @@ -99,7 +99,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { subtitle: undefined, extraText: '', value: '1,437', - color: 'rgba(245, 247, 250, 1)', + color: 'rgba(255, 255, 255, 1)', showingBar: false, showingTrendline: false, }, diff --git a/x-pack/test_serverless/functional/test_suites/observability/infra/hosts_page.ts b/x-pack/test_serverless/functional/test_suites/observability/infra/hosts_page.ts index 92dfb107dd440..f9c410acd70a9 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/infra/hosts_page.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/infra/hosts_page.ts @@ -36,7 +36,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await retry.waitFor( 'wait for table and KPI charts to load', async () => - (await pageObjects.infraHostsView.isHostTableLoading()) && + (await pageObjects.infraHostsView.isHostTableLoaded()) && (await pageObjects.infraHostsView.isKPIChartsLoaded()) ); diff --git a/x-pack/test_serverless/shared/config.base.ts b/x-pack/test_serverless/shared/config.base.ts index 6dee26203b532..9c2454d4e7a39 100644 --- a/x-pack/test_serverless/shared/config.base.ts +++ b/x-pack/test_serverless/shared/config.base.ts @@ -18,7 +18,7 @@ import { } from '@kbn/test'; import { CA_CERT_PATH, kibanaDevServiceAccount } from '@kbn/dev-utils'; import { commonFunctionalServices } from '@kbn/ftr-common-functional-services'; -import { MOCK_IDP_REALM_NAME } from '@kbn/mock-idp-plugin/common'; +import { MOCK_IDP_REALM_NAME } from '@kbn/mock-idp-utils'; import { services } from './services'; export default async () => { diff --git a/x-pack/test_serverless/tsconfig.json b/x-pack/test_serverless/tsconfig.json index c9c37a3c3f3a1..dcd1cbcfd43ba 100644 --- a/x-pack/test_serverless/tsconfig.json +++ b/x-pack/test_serverless/tsconfig.json @@ -79,7 +79,7 @@ "@kbn/apm-synthtrace", "@kbn/apm-synthtrace-client", "@kbn/reporting-export-types-csv-common", - "@kbn/mock-idp-plugin", + "@kbn/mock-idp-utils", "@kbn/io-ts-utils", "@kbn/log-explorer-plugin", "@kbn/index-management-plugin", diff --git a/yarn.lock b/yarn.lock index ad3e54a1e5efc..45dce47febdcf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5260,6 +5260,10 @@ version "0.0.0" uid "" +"@kbn/mock-idp-utils@link:packages/kbn-mock-idp-utils": + version "0.0.0" + uid "" + "@kbn/monaco@link:packages/kbn-monaco": version "0.0.0" uid "" @@ -11868,12 +11872,12 @@ axe-core@^4.8.2: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.8.2.tgz#2f6f3cde40935825cf4465e3c1c9e77b240ff6ae" integrity sha512-/dlp0fxyM3R8YW7MFzaHWXrf4zzbr0vaYb23VBFCl83R7nWNPg/yaQw2Dc8jzCMmDVLhSdzH8MjrsuIUuvX+6g== -axios@^1.3.4, axios@^1.6.0, axios@^1.6.5: - version "1.6.5" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.5.tgz#2c090da14aeeab3770ad30c3a1461bc970fb0cd8" - integrity sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg== +axios@^1.3.4, axios@^1.6.0, axios@^1.6.3: + version "1.6.3" + resolved "https://registry.npmjs.org/axios/-/axios-1.6.3.tgz#7f50f23b3aa246eff43c54834272346c396613f4" + integrity sha512-fWyNdeawGam70jXSVlKl+SUNVcL6j6W79CuSIPfi6HnDUmSCH6gyUys/HrqHeA/wU0Az41rRgean494d0Jb+ww== dependencies: - follow-redirects "^1.15.4" + follow-redirects "^1.15.0" form-data "^4.0.0" proxy-from-env "^1.1.0" @@ -17251,10 +17255,10 @@ folktale@2.3.2: resolved "https://registry.yarnpkg.com/folktale/-/folktale-2.3.2.tgz#38231b039e5ef36989920cbf805bf6b227bf4fd4" integrity sha512-+8GbtQBwEqutP0v3uajDDoN64K2ehmHd0cjlghhxh0WpcfPzAIjPA03e1VvHlxL02FVGR0A6lwXsNQKn3H1RNQ== -follow-redirects@^1.0.0, follow-redirects@^1.15.4: - version "1.15.4" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf" - integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw== +follow-redirects@^1.0.0, follow-redirects@^1.15.0: + version "1.15.2" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" + integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== font-awesome@4.7.0: version "4.7.0"