({ + 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 8b6ff5880d3dc3..07a37e3ba1bc3d 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 e0859617f00574..179914b9fb68a6 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 ae73126afde88d..068f21863de6c2 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 764145d72aac1c..871edb89d15aab 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 c4558f4590c5b0..0e5e9838f420b2 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 3177adefdf49ba..b820b63b461b37 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/utils/fetch_all.ts b/src/plugins/discover/public/application/main/utils/fetch_all.ts index 289ad9e336b042..16f2a1c50de560 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 70773d2db521fa..d3f8ccd8f990d2 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/discover_grid_flyout/discover_grid_flyout.test.tsx b/src/plugins/discover/public/components/discover_grid_flyout/discover_grid_flyout.test.tsx index 8d4e9bbee4ae10..485a3d2f8a4fe7 100644 --- a/src/plugins/discover/public/components/discover_grid_flyout/discover_grid_flyout.test.tsx +++ b/src/plugins/discover/public/components/discover_grid_flyout/discover_grid_flyout.test.tsx @@ -7,8 +7,8 @@ */ import React from 'react'; +import { EuiButtonIcon, EuiContextMenuItem, EuiPopover } from '@elastic/eui'; import { findTestSubject } from '@elastic/eui/lib/test'; -import { EuiFlexItem } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import type { Query, AggregateQuery } from '@kbn/es-query'; import { DiscoverGridFlyout, DiscoverGridFlyoutProps } from './discover_grid_flyout'; @@ -36,6 +36,22 @@ jest.mock('../../customizations', () => ({ useDiscoverCustomization: jest.fn(), })); +let mockBreakpointSize: string | null = null; + +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + useIsWithinBreakpoints: jest.fn((breakpoints: string[]) => { + if (mockBreakpointSize && breakpoints.includes(mockBreakpointSize)) { + return true; + } + + return original.useIsWithinBreakpoints(breakpoints); + }), + }; +}); + const waitNextTick = () => new Promise((resolve) => setTimeout(resolve, 0)); const waitNextUpdate = async (component: ReactWrapper) => { @@ -227,7 +243,7 @@ describe('Discover flyout', function () { const singleDocumentView = findTestSubject(component, 'docTableRowAction'); expect(singleDocumentView.length).toBeFalsy(); const flyoutTitle = findTestSubject(component, 'docTableRowDetailsTitle'); - expect(flyoutTitle.text()).toBe('Expanded row'); + expect(flyoutTitle.text()).toBe('Row'); }); describe('with applied customizations', () => { @@ -246,17 +262,32 @@ describe('Discover flyout', function () { describe('when actions are customized', () => { it('should display actions added by getActionItems', async () => { + mockBreakpointSize = 'xl'; mockFlyoutCustomization.actions = { getActionItems: jest.fn(() => [ { id: 'action-item-1', enabled: true, - Content: () => Action 1, + label: 'Action 1', + iconType: 'document', + dataTestSubj: 'customActionItem1', + onClick: jest.fn(), }, { id: 'action-item-2', enabled: true, - Content: () => Action 2, + label: 'Action 2', + iconType: 'document', + dataTestSubj: 'customActionItem2', + onClick: jest.fn(), + }, + { + id: 'action-item-3', + enabled: false, + label: 'Action 3', + iconType: 'document', + dataTestSubj: 'customActionItem3', + onClick: jest.fn(), }, ]), }; @@ -268,6 +299,88 @@ describe('Discover flyout', function () { expect(action1.text()).toBe('Action 1'); expect(action2.text()).toBe('Action 2'); + expect(findTestSubject(component, 'customActionItem3').exists()).toBe(false); + mockBreakpointSize = null; + }); + + it('should display multiple actions added by getActionItems', async () => { + mockFlyoutCustomization.actions = { + getActionItems: jest.fn(() => + Array.from({ length: 5 }, (_, i) => ({ + id: `action-item-${i}`, + enabled: true, + label: `Action ${i}`, + iconType: 'document', + dataTestSubj: `customActionItem${i}`, + onClick: jest.fn(), + })) + ), + }; + + const { component } = await mountComponent({}); + expect( + findTestSubject(component, 'docViewerFlyoutActions') + .find(EuiButtonIcon) + .map((button) => button.prop('data-test-subj')) + ).toEqual([ + 'docTableRowAction', + 'customActionItem0', + 'customActionItem1', + 'docViewerMoreFlyoutActionsButton', + ]); + + act(() => { + findTestSubject(component, 'docViewerMoreFlyoutActionsButton').simulate('click'); + }); + + component.update(); + + expect( + component + .find(EuiPopover) + .find(EuiContextMenuItem) + .map((button) => button.prop('data-test-subj')) + ).toEqual(['customActionItem2', 'customActionItem3', 'customActionItem4']); + }); + + it('should display multiple actions added by getActionItems in mobile view', async () => { + mockBreakpointSize = 's'; + + mockFlyoutCustomization.actions = { + getActionItems: jest.fn(() => + Array.from({ length: 3 }, (_, i) => ({ + id: `action-item-${i}`, + enabled: true, + label: `Action ${i}`, + iconType: 'document', + dataTestSubj: `customActionItem${i}`, + onClick: jest.fn(), + })) + ), + }; + + const { component } = await mountComponent({}); + expect(findTestSubject(component, 'docViewerFlyoutActions').length).toBe(0); + + act(() => { + findTestSubject(component, 'docViewerMobileActionsButton').simulate('click'); + }); + + component.update(); + + expect( + component + .find(EuiPopover) + .find(EuiContextMenuItem) + .map((button) => button.prop('data-test-subj')) + ).toEqual([ + 'docTableRowAction', + 'customActionItem0', + 'customActionItem1', + 'customActionItem2', + ]); + + mockBreakpointSize = null; }); it('should allow disabling default actions', async () => { diff --git a/src/plugins/discover/public/components/discover_grid_flyout/discover_grid_flyout.tsx b/src/plugins/discover/public/components/discover_grid_flyout/discover_grid_flyout.tsx index 58d60466e17b08..40d47e1292f92f 100644 --- a/src/plugins/discover/public/components/discover_grid_flyout/discover_grid_flyout.tsx +++ b/src/plugins/discover/public/components/discover_grid_flyout/discover_grid_flyout.tsx @@ -8,6 +8,7 @@ import React, { useMemo, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; import type { DataView } from '@kbn/data-views-plugin/public'; import { EuiFlexGroup, @@ -29,6 +30,7 @@ import { useDiscoverServices } from '../../hooks/use_discover_services'; import { isTextBasedQuery } from '../../application/main/utils/is_text_based_query'; import { useFlyoutActions } from './use_flyout_actions'; import { useDiscoverCustomization } from '../../customizations'; +import { DiscoverGridFlyoutActions } from './discover_grid_flyout_actions'; export interface DiscoverGridFlyoutProps { savedSearchId?: string; @@ -189,14 +191,13 @@ export function DiscoverGridFlyout({ ); const defaultFlyoutTitle = isPlainRecord - ? i18n.translate('discover.grid.tableRow.textBasedDetailHeading', { - defaultMessage: 'Expanded row', + ? i18n.translate('discover.grid.tableRow.docViewerTextBasedDetailHeading', { + defaultMessage: 'Row', }) - : i18n.translate('discover.grid.tableRow.detailHeading', { - defaultMessage: 'Expanded document', + : i18n.translate('discover.grid.tableRow.docViewerDetailHeading', { + defaultMessage: 'Document', }); const flyoutTitle = flyoutCustomization?.title ?? defaultFlyoutTitle; - const flyoutSize = flyoutCustomization?.size ?? 'm'; return ( @@ -209,17 +210,24 @@ export function DiscoverGridFlyout({ ownFocus={false} > - -

{flyoutTitle}

-
- - - {!isPlainRecord && - flyoutActions.map((action) => action.enabled && )} + + +

{flyoutTitle}

+
+
{activePage !== -1 && ( )}
+ {isPlainRecord || !flyoutActions.length ? null : ( + <> + + + + )}
{bodyContent} diff --git a/src/plugins/discover/public/components/discover_grid_flyout/discover_grid_flyout_actions.tsx b/src/plugins/discover/public/components/discover_grid_flyout/discover_grid_flyout_actions.tsx new file mode 100644 index 00000000000000..a9b168ef7ae8e3 --- /dev/null +++ b/src/plugins/discover/public/components/discover_grid_flyout/discover_grid_flyout_actions.tsx @@ -0,0 +1,201 @@ +/* + * 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, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { slice } from 'lodash'; +import { css } from '@emotion/react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiContextMenuItemIcon, + useIsWithinBreakpoints, + EuiText, + EuiButtonEmpty, + EuiButtonIcon, + EuiPopoverProps, + EuiToolTip, + useEuiTheme, +} from '@elastic/eui'; +import type { FlyoutActionItem } from '../../customizations'; + +const MAX_VISIBLE_ACTIONS_BEFORE_THE_FOLD = 3; + +export interface DiscoverGridFlyoutActionsProps { + flyoutActions: FlyoutActionItem[]; +} + +export function DiscoverGridFlyoutActions({ flyoutActions }: DiscoverGridFlyoutActionsProps) { + const { euiTheme } = useEuiTheme(); + const [isMoreFlyoutActionsPopoverOpen, setIsMoreFlyoutActionsPopoverOpen] = + useState(false); + const isMobileScreen = useIsWithinBreakpoints(['xs', 's']); + const isLargeScreen = useIsWithinBreakpoints(['xl']); + + if (isMobileScreen) { + return ( + setIsMoreFlyoutActionsPopoverOpen(!isMoreFlyoutActionsPopoverOpen)} + > + {i18n.translate('discover.grid.tableRow.mobileFlyoutActionsButton', { + defaultMessage: 'Actions', + })} + + } + isOpen={isMoreFlyoutActionsPopoverOpen} + closePopover={() => setIsMoreFlyoutActionsPopoverOpen(false)} + /> + ); + } + + const visibleFlyoutActions = slice(flyoutActions, 0, MAX_VISIBLE_ACTIONS_BEFORE_THE_FOLD); + const remainingFlyoutActions = slice( + flyoutActions, + MAX_VISIBLE_ACTIONS_BEFORE_THE_FOLD, + flyoutActions.length + ); + const showFlyoutIconsOnly = + remainingFlyoutActions.length > 0 || (!isLargeScreen && visibleFlyoutActions.length > 1); + + return ( + + + + + {i18n.translate('discover.grid.tableRow.actionsLabel', { + defaultMessage: 'Actions', + })} + : + + + + {visibleFlyoutActions.map((action) => ( + + {showFlyoutIconsOnly ? ( + + + + ) : ( + + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + {action.label} + + + )} + + ))} + {remainingFlyoutActions.length > 0 && ( + + + setIsMoreFlyoutActionsPopoverOpen(!isMoreFlyoutActionsPopoverOpen)} + /> + + } + isOpen={isMoreFlyoutActionsPopoverOpen} + closePopover={() => setIsMoreFlyoutActionsPopoverOpen(false)} + /> + + )} + + ); +} + +function FlyoutActionsPopover({ + flyoutActions, + button, + isOpen, + closePopover, +}: { + flyoutActions: DiscoverGridFlyoutActionsProps['flyoutActions']; + button: EuiPopoverProps['button']; + isOpen: EuiPopoverProps['isOpen']; + closePopover: EuiPopoverProps['closePopover']; +}) { + return ( + + ( + + {action.label} + + ))} + /> + + ); +} diff --git a/src/plugins/discover/public/components/discover_grid_flyout/use_flyout_actions.tsx b/src/plugins/discover/public/components/discover_grid_flyout/use_flyout_actions.tsx index fb364995b1c215..e0df28e468003d 100644 --- a/src/plugins/discover/public/components/discover_grid_flyout/use_flyout_actions.tsx +++ b/src/plugins/discover/public/components/discover_grid_flyout/use_flyout_actions.tsx @@ -6,35 +6,18 @@ * Side Public License, v 1. */ -import React from 'react'; -import { - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiHideFor, - EuiIconTip, - EuiText, -} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FlyoutCustomization } from '../../customizations'; +import { FlyoutActionItem, FlyoutCustomization } from '../../customizations'; import { UseNavigationProps, useNavigationProps } from '../../hooks/use_navigation_props'; interface UseFlyoutActionsParams extends UseNavigationProps { actions?: FlyoutCustomization['actions']; } -interface FlyoutActionProps { - onClick: React.MouseEventHandler; - href: string; -} - -const staticViewDocumentItem = { - id: 'viewDocument', - enabled: true, - Content: () => , -}; - -export const useFlyoutActions = ({ actions, ...props }: UseFlyoutActionsParams) => { +export const useFlyoutActions = ({ + actions, + ...props +}: UseFlyoutActionsParams): { flyoutActions: FlyoutActionItem[] } => { const { dataView } = props; const { singleDocHref, contextViewHref, onOpenSingleDoc, onOpenContextView } = useNavigationProps(props); @@ -45,95 +28,35 @@ export const useFlyoutActions = ({ actions, ...props }: UseFlyoutActionsParams) } = actions?.defaultActions ?? {}; const customActions = [...(actions?.getActionItems?.() ?? [])]; - const flyoutActions = [ + const flyoutActions: FlyoutActionItem[] = [ { id: 'singleDocument', enabled: !viewSingleDocument.disabled, - Content: () => , + dataTestSubj: 'docTableRowAction', + iconType: 'document', + href: singleDocHref, + onClick: onOpenSingleDoc, + label: i18n.translate('discover.grid.tableRow.viewSingleDocumentLinkLabel', { + defaultMessage: 'View single document', + }), }, { id: 'surroundingDocument', enabled: Boolean(!viewSurroundingDocument.disabled && dataView.isTimeBased() && dataView.id), - Content: () => , + dataTestSubj: 'docTableRowAction', + iconType: 'documents', + href: contextViewHref, + onClick: onOpenContextView, + label: i18n.translate('discover.grid.tableRow.viewSurroundingDocumentsLinkLabel', { + defaultMessage: 'View surrounding documents', + }), + helpText: i18n.translate('discover.grid.tableRow.viewSurroundingDocumentsHover', { + defaultMessage: + 'Inspect documents that occurred before and after this document. Only pinned filters remain active in the Surrounding documents view.', + }), }, ...customActions, ]; - const hasEnabledActions = flyoutActions.some((action) => action.enabled); - - if (hasEnabledActions) { - flyoutActions.unshift(staticViewDocumentItem); - } - - return { flyoutActions, hasEnabledActions }; -}; - -const ViewDocument = () => { - return ( - - - - - {i18n.translate('discover.grid.tableRow.viewText', { - defaultMessage: 'View:', - })} - - - - - ); -}; - -const SingleDocument = (props: FlyoutActionProps) => { - return ( - - - {i18n.translate('discover.grid.tableRow.viewSingleDocumentLinkTextSimple', { - defaultMessage: 'Single document', - })} - - - ); -}; - -const SurroundingDocuments = (props: FlyoutActionProps) => { - return ( - - - - {i18n.translate('discover.grid.tableRow.viewSurroundingDocumentsLinkTextSimple', { - defaultMessage: 'Surrounding documents', - })} - - - - - - - ); + return { flyoutActions: flyoutActions.filter((action) => action.enabled) }; }; 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 00000000000000..8d84cdcef5a0c4 --- /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 00000000000000..be3e819a5e073f --- /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/unified_histogram/public/hits_counter/index.ts b/src/plugins/discover/public/components/hits_counter/index.ts similarity index 84% rename from src/plugins/unified_histogram/public/hits_counter/index.ts rename to src/plugins/discover/public/components/hits_counter/index.ts index 593608c9cac869..8d7f69c3af2750 100644 --- a/src/plugins/unified_histogram/public/hits_counter/index.ts +++ b/src/plugins/discover/public/components/hits_counter/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { HitsCounter } from './hits_counter'; +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 00000000000000..7586567d3665c3 --- /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 00000000000000..54a41fbb9255b7 --- /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 00000000000000..bd04823affd80e --- /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 7c17e5e1a31ef5..e1788389d3caff 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 79c9213e763951..147486ac6dc6e4 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/public/customizations/customization_service.ts b/src/plugins/discover/public/customizations/customization_service.ts index 15175c8bad1ae4..de3108b9ab53f1 100644 --- a/src/plugins/discover/public/customizations/customization_service.ts +++ b/src/plugins/discover/public/customizations/customization_service.ts @@ -7,7 +7,8 @@ */ import { filter, map, Observable, startWith, Subject } from 'rxjs'; -import type { +import { + DataTableCustomization, FlyoutCustomization, SearchBarCustomization, TopNavCustomization, @@ -18,7 +19,8 @@ export type DiscoverCustomization = | FlyoutCustomization | SearchBarCustomization | TopNavCustomization - | UnifiedHistogramCustomization; + | UnifiedHistogramCustomization + | DataTableCustomization; export type DiscoverCustomizationId = DiscoverCustomization['id']; diff --git a/src/plugins/discover/public/customizations/customization_types/data_table_customisation.ts b/src/plugins/discover/public/customizations/customization_types/data_table_customisation.ts new file mode 100644 index 00000000000000..0fdbebee2ac60a --- /dev/null +++ b/src/plugins/discover/public/customizations/customization_types/data_table_customisation.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { CustomCellRenderer } from '@kbn/unified-data-table'; + +export interface DataTableCustomization { + id: 'data_table'; + customCellRenderer?: CustomCellRenderer; +} diff --git a/src/plugins/discover/public/customizations/customization_types/flyout_customization.ts b/src/plugins/discover/public/customizations/customization_types/flyout_customization.ts index a57a538f216423..794711ba17b178 100644 --- a/src/plugins/discover/public/customizations/customization_types/flyout_customization.ts +++ b/src/plugins/discover/public/customizations/customization_types/flyout_customization.ts @@ -5,10 +5,10 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { EuiFlyoutProps } from '@elastic/eui'; +import { EuiFlyoutProps, IconType } from '@elastic/eui'; import type { DataTableRecord } from '@kbn/discover-utils/types'; import type { DocViewRenderProps } from '@kbn/unified-doc-viewer/types'; -import React, { type ComponentType } from 'react'; +import React, { type ComponentType, MouseEventHandler } from 'react'; export interface FlyoutDefaultActionItem { disabled?: boolean; @@ -21,8 +21,13 @@ export interface FlyoutDefaultActions { export interface FlyoutActionItem { id: string; - Content: React.ElementType; enabled: boolean; + label: string; + helpText?: string; + iconType: IconType; + onClick: (() => void) | MouseEventHandler; + href?: string; + dataTestSubj?: string; } export interface FlyoutContentProps { diff --git a/src/plugins/discover/public/customizations/customization_types/index.ts b/src/plugins/discover/public/customizations/customization_types/index.ts index effb7fccf207cd..a0e9a1cdb098f6 100644 --- a/src/plugins/discover/public/customizations/customization_types/index.ts +++ b/src/plugins/discover/public/customizations/customization_types/index.ts @@ -10,3 +10,4 @@ export * from './flyout_customization'; export * from './search_bar_customization'; export * from './top_nav_customization'; export * from './histogram_customization'; +export * from './data_table_customisation'; diff --git a/src/plugins/discover/public/embeddable/saved_search_grid.tsx b/src/plugins/discover/public/embeddable/saved_search_grid.tsx index 0177a324dd2b8d..f64f000c0bf0bf 100644 --- a/src/plugins/discover/public/embeddable/saved_search_grid.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_grid.tsx @@ -100,6 +100,8 @@ export function DiscoverGridEmbeddable(props: DiscoverGridEmbeddableProps) { maxDocFieldsDisplayed={props.services.uiSettings.get(MAX_DOC_FIELDS_DISPLAYED)} renderDocumentView={renderDocumentView} renderCustomToolbar={renderCustomToolbar} + showColumnTokens + headerRowHeight={3} /> ); diff --git a/src/plugins/discover/tsconfig.json b/src/plugins/discover/tsconfig.json index fe06c932324602..b75f27c9266f85 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/event_annotation_listing/public/components/group_editor_flyout/group_preview.test.tsx b/src/plugins/event_annotation_listing/public/components/group_editor_flyout/group_preview.test.tsx index 386976ac571b60..935aad45570ac0 100644 --- a/src/plugins/event_annotation_listing/public/components/group_editor_flyout/group_preview.test.tsx +++ b/src/plugins/event_annotation_listing/public/components/group_editor_flyout/group_preview.test.tsx @@ -21,51 +21,14 @@ import { TypedLensByValueInput, } from '@kbn/lens-plugin/public'; import { Datatable } from '@kbn/expressions-plugin/common'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import userEvent from '@testing-library/user-event'; import { I18nProvider } from '@kbn/i18n-react'; import { GroupPreview } from './group_preview'; import { LensByValueInput } from '@kbn/lens-plugin/public/embeddable'; import { DATA_LAYER_ID, DATE_HISTOGRAM_COLUMN_ID, getCurrentTimeField } from './lens_attributes'; -import moment from 'moment'; - -class EuiSuperDatePickerTestHarness { - public static get currentCommonlyUsedRange() { - return screen.queryByTestId('superDatePickerShowDatesButton')?.textContent ?? ''; - } - - // TODO - add assertion with date formatting - public static get currentRange() { - if (screen.queryByTestId('superDatePickerShowDatesButton')) { - // showing a commonly-used range - return { from: '', to: '' }; - } - - return { - from: screen.getByTestId('superDatePickerstartDatePopoverButton').textContent, - to: screen.getByTestId('superDatePickerendDatePopoverButton').textContent, - }; - } - - static togglePopover() { - userEvent.click(screen.getByRole('button', { name: 'Date quick select' })); - } - - static async selectCommonlyUsedRange(label: string) { - if (!screen.queryByText('Commonly used')) this.togglePopover(); - - // Using fireEvent here because userEvent erroneously claims that - // pointer-events is set to 'none'. - // - // I have verified that this fixed on the latest version of the @testing-library/user-event package - fireEvent.click(await screen.findByText(label)); - } - - static refresh() { - userEvent.click(screen.getByRole('button', { name: 'Refresh' })); - } -} +import { EuiSuperDatePickerTestHarness } from '@kbn/test-eui-helpers'; describe('group editor preview', () => { const annotation = getDefaultManualAnnotation('my-id', 'some-timestamp'); @@ -186,11 +149,11 @@ describe('group editor preview', () => { // from chart brush userEvent.click(screen.getByTestId('brushEnd')); - const format = 'MMM D, YYYY @ HH:mm:ss.SSS'; // from https://github.com/elastic/eui/blob/6a30eba7c2a154691c96a1d17c8b2f3506d351a3/src/components/date_picker/super_date_picker/super_date_picker.tsx#L222; - expect(EuiSuperDatePickerTestHarness.currentRange).toEqual({ - from: moment(BRUSH_RANGE[0]).format(format), - to: moment(BRUSH_RANGE[1]).format(format), - }); + EuiSuperDatePickerTestHarness.assertCurrentRange( + { from: BRUSH_RANGE[0], to: BRUSH_RANGE[1] }, + expect + ); + expect(getEmbeddableTimeRange()).toEqual({ from: new Date(BRUSH_RANGE[0]).toISOString(), to: new Date(BRUSH_RANGE[1]).toISOString(), diff --git a/src/plugins/event_annotation_listing/tsconfig.json b/src/plugins/event_annotation_listing/tsconfig.json index 8c9efd45594005..e3c77073de1687 100644 --- a/src/plugins/event_annotation_listing/tsconfig.json +++ b/src/plugins/event_annotation_listing/tsconfig.json @@ -41,7 +41,8 @@ "@kbn/core-notifications-browser-mocks", "@kbn/core-notifications-browser", "@kbn/core-saved-objects-api-browser", - "@kbn/content-management-table-list-view-common" + "@kbn/content-management-table-list-view-common", + "@kbn/test-eui-helpers" ], "exclude": [ "target/**/*", 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 b2e2c5ec3f748c..5e49b09b01d248 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 e1de9aa7842d51..a1125a6d118b8c 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/kibana.jsonc b/src/plugins/presentation_util/kibana.jsonc index 91ac6c41943780..f9b659fa61630d 100644 --- a/src/plugins/presentation_util/kibana.jsonc +++ b/src/plugins/presentation_util/kibana.jsonc @@ -8,7 +8,6 @@ "server": true, "browser": true, "requiredPlugins": [ - "savedObjects", "kibanaReact", "contentManagement", "embeddable", @@ -16,6 +15,7 @@ "dataViews", "uiActions" ], - "extraPublicDirs": ["common"] + "extraPublicDirs": ["common"], + "requiredBundles": ["savedObjects"], } } diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index e04e83dc46feba..7e5a19d41d3d35 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 229af7851d8a3c..4509f28a7a61ee 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/__mocks__/suggestions.ts b/src/plugins/unified_histogram/public/__mocks__/suggestions.ts index bed2eee388cde6..9e3a00d3960476 100644 --- a/src/plugins/unified_histogram/public/__mocks__/suggestions.ts +++ b/src/plugins/unified_histogram/public/__mocks__/suggestions.ts @@ -57,26 +57,9 @@ export const currentSuggestionMock = { }, }, ], - allColumns: [ - { - columnId: '81e332d6-ee37-42a8-a646-cea4fc75d2d3', - fieldName: 'Dest', - meta: { - type: 'string', - }, - }, - { - columnId: '5b9b8b76-0836-4a12-b9c0-980c9900502f', - fieldName: 'AvgTicketPrice', - meta: { - type: 'number', - }, - }, - ], timeField: 'timestamp', }, }, - fieldList: [], indexPatternRefs: [], initialContext: { dataViewSpec: { @@ -196,26 +179,9 @@ export const allSuggestionsMock = [ }, }, ], - allColumns: [ - { - columnId: '923f0681-3fe1-4987-aa27-d9c91fb95fa6', - fieldName: 'Dest', - meta: { - type: 'string', - }, - }, - { - columnId: 'b5f41c04-4bca-4abe-ae5c-b1d4d6fb00e0', - fieldName: 'AvgTicketPrice', - meta: { - type: 'number', - }, - }, - ], timeField: 'timestamp', }, }, - fieldList: [], indexPatternRefs: [], initialContext: { dataViewSpec: { 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 c0c20a1e1a80ea..86ec06ba48e677 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 77e00e157d62ba..78df66f50873ef 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 d561a3310ceae5..474da6bce5bf76 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 a64944d73b5fe5..657f27a72c0702 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 956f4ef86f2a5f..a4071b4ac8cfa4 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 { 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 120dc0b3d08840..7696f0f9782b72 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 168db2ca0c4d9f..3c4bd2434e3dd8 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 e5ee2b2c55cd97..00000000000000 --- 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 bf1bf4d6b95cdd..00000000000000 --- 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 13b527be702c1b..5a5bf41ca395d9 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 3135f3c86f4653..b6250f8fa82b7e 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 5bb927747e6693..c16bb2335be240 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 6a6d2d65f6f920..4a8b758f7d86e0 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 0196387633396b..cad20279bfdf01 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 00000000000000..86c17fdc79172f --- /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 00000000000000..1a83a736bb534e --- /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/chart/utils/get_lens_attributes.test.ts b/src/plugins/unified_histogram/public/chart/utils/get_lens_attributes.test.ts index 53ec7350401b77..3c049649d5c206 100644 --- a/src/plugins/unified_histogram/public/chart/utils/get_lens_attributes.test.ts +++ b/src/plugins/unified_histogram/public/chart/utils/get_lens_attributes.test.ts @@ -565,7 +565,6 @@ describe('getLensAttributes', () => { "state": Object { "datasourceStates": Object { "textBased": Object { - "fieldList": Array [], "indexPatternRefs": Array [], "initialContext": Object { "contextualFields": Array [ @@ -644,22 +643,6 @@ describe('getLensAttributes', () => { }, "layers": Object { "46aa21fa-b747-4543-bf90-0b40007c546d": Object { - "allColumns": Array [ - Object { - "columnId": "81e332d6-ee37-42a8-a646-cea4fc75d2d3", - "fieldName": "Dest", - "meta": Object { - "type": "string", - }, - }, - Object { - "columnId": "5b9b8b76-0836-4a12-b9c0-980c9900502f", - "fieldName": "AvgTicketPrice", - "meta": Object { - "type": "number", - }, - }, - ], "columns": Array [ Object { "columnId": "81e332d6-ee37-42a8-a646-cea4fc75d2d3", diff --git a/src/plugins/unified_histogram/public/container/container.tsx b/src/plugins/unified_histogram/public/container/container.tsx index c65f1e2b43c045..fb152a1921e232 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 73a493e167c194..40304a967243ab 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 f96a4b5b7b033e..1a79389e2bc6f4 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 03b350448e9c29..00000000000000 --- 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 b6f1212bfeaedf..00000000000000 --- 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 119356af6f63fe..f74cc8a3c5925f 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 063e1b7ef89a29..ac1053fd7fa3f5 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 a12c8cf46430e5..a10df63e7c328c 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 17eaf65fcde5f0..1a175690eb4471 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} + { - try { - // refreshing the field list - await dataViews.get(newId, false, true); - } catch (e) { - // - } setSelectedDataViewId(newId); setPopoverIsOpen(false); if (isTextBasedLangSelected && !isTextLangTransitionModalDismissed) { diff --git a/src/plugins/unified_search/public/dataview_picker/mocks/dataview.ts b/src/plugins/unified_search/public/dataview_picker/mocks/dataview.ts index 7b8c1318fae8c9..8a608b8a54db56 100644 --- a/src/plugins/unified_search/public/dataview_picker/mocks/dataview.ts +++ b/src/plugins/unified_search/public/dataview_picker/mocks/dataview.ts @@ -121,3 +121,10 @@ export const buildDataViewMock = ({ }; export const dataViewMock = buildDataViewMock({ name: 'the-data-view', fields }); +export const dataViewMockWithTimefield = buildDataViewMock({ + timeFieldName: '@timestamp', + name: 'the-data-view-with-timefield', + fields, +}); + +export const dataViewMockList = [dataViewMock, dataViewMockWithTimefield]; diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/filter_editor.test.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/filter_editor.test.tsx index bb9b9e2aa2a189..19d7486cf83080 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/filter_editor.test.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/filter_editor.test.tsx @@ -12,7 +12,10 @@ import { registerTestBed, TestBed } from '@kbn/test-jest-helpers'; import { coreMock } from '@kbn/core/public/mocks'; import type { FilterEditorProps } from '.'; import { FilterEditor } from '.'; +import { dataViewMockList } from '../../dataview_picker/mocks/dataview'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +const dataMock = dataPluginMock.createStartContract(); jest.mock('@kbn/code-editor', () => { const original = jest.requireActual('@kbn/code-editor'); @@ -50,6 +53,7 @@ describe('', () => { onCancel: jest.fn(), onSubmit: jest.fn(), docLinks: coreMock.createStart().docLinks, + dataViews: dataMock.dataViews, }; testBed = await registerTestBed(FilterEditor, { defaultProps })(); }); @@ -76,4 +80,72 @@ describe('', () => { expect(find('saveFilter').props().disabled).toBe(false); }); }); + describe('handling data view fallback', () => { + let testBed: TestBed; + + beforeEach(async () => { + dataMock.dataViews.get = jest.fn().mockReturnValue(Promise.resolve(dataViewMockList[1])); + const defaultProps: Omit = { + theme: { + euiTheme: {} as unknown as EuiThemeComputed<{}>, + colorMode: 'DARK', + modifications: [], + } as UseEuiTheme<{}>, + filter: { + meta: { + type: 'phase', + index: dataViewMockList[1].id, + } as any, + }, + indexPatterns: [dataViewMockList[0]], + onCancel: jest.fn(), + onSubmit: jest.fn(), + docLinks: coreMock.createStart().docLinks, + dataViews: dataMock.dataViews, + }; + testBed = await registerTestBed(FilterEditor, { defaultProps })(); + }); + + it('renders the right data view to be selected', async () => { + const { exists, component, find } = testBed; + component.update(); + expect(exists('filterIndexPatternsSelect')).toBe(true); + expect(find('filterIndexPatternsSelect').find('input').props().value).toBe( + dataViewMockList[1].getName() + ); + }); + }); + describe('UI renders when data view fallback promise is rejected', () => { + let testBed: TestBed; + + beforeEach(async () => { + dataMock.dataViews.get = jest.fn().mockReturnValue(Promise.reject()); + const defaultProps: Omit = { + theme: { + euiTheme: {} as unknown as EuiThemeComputed<{}>, + colorMode: 'DARK', + modifications: [], + } as UseEuiTheme<{}>, + filter: { + meta: { + type: 'phase', + index: dataViewMockList[1].id, + } as any, + }, + indexPatterns: [dataViewMockList[0]], + onCancel: jest.fn(), + onSubmit: jest.fn(), + docLinks: coreMock.createStart().docLinks, + dataViews: dataMock.dataViews, + }; + testBed = registerTestBed(FilterEditor, { defaultProps })(); + }); + + it('renders the right data view to be selected', async () => { + const { exists, component, find } = await testBed; + component.update(); + expect(exists('filterIndexPatternsSelect')).toBe(true); + expect(find('filterIndexPatternsSelect').find('input').props().value).toBe(''); + }); + }); }); diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/filter_editor.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/filter_editor.tsx index c3c93edb54ffa7..67764134e448a1 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/filter_editor.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/filter_editor.tsx @@ -25,6 +25,7 @@ import { withEuiTheme, EuiTextColor, EuiLink, + EuiLoadingSpinner, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { @@ -43,7 +44,7 @@ import React, { Component } from 'react'; import { i18n } from '@kbn/i18n'; import { XJsonLang } from '@kbn/monaco'; import { DataView } from '@kbn/data-views-plugin/common'; -import { getIndexPatternFromFilter } from '@kbn/data-plugin/public'; +import { DataViewsContract, getIndexPatternFromFilter } from '@kbn/data-plugin/public'; import { CodeEditor } from '@kbn/code-editor'; import { cx } from '@emotion/css'; import { WithEuiThemeProps } from '@elastic/eui/src/services/theme'; @@ -143,42 +144,80 @@ export interface FilterEditorComponentProps { suggestionsAbstraction?: SuggestionsAbstraction; docLinks: DocLinksStart; filtersCount?: number; + dataViews?: DataViewsContract; } export type FilterEditorProps = WithEuiThemeProps & FilterEditorComponentProps; interface State { + indexPatterns: DataView[]; selectedDataView?: DataView; customLabel: string | null; queryDsl: string; isCustomEditorOpen: boolean; localFilter: Filter; + isLoadingDataView?: boolean; } class FilterEditorComponent extends Component { constructor(props: FilterEditorProps) { super(props); - const dataView = this.getIndexPatternFromFilter(); + const dataView = getIndexPatternFromFilter(props.filter, props.indexPatterns); this.state = { + indexPatterns: props.indexPatterns, selectedDataView: dataView, customLabel: props.filter.meta.alias || '', - queryDsl: this.parseFilterToQueryDsl(props.filter), + queryDsl: this.parseFilterToQueryDsl(props.filter, props.indexPatterns), isCustomEditorOpen: this.isUnknownFilterType() || !!this.props.filter?.meta.isMultiIndex, localFilter: dataView ? merge({}, props.filter) : buildEmptyFilter(false), + isLoadingDataView: !Boolean(dataView), }; } componentDidMount() { - const { localFilter, queryDsl, customLabel } = this.state; + const { localFilter, queryDsl, customLabel, selectedDataView } = this.state; this.props.onLocalFilterCreate?.({ filter: localFilter, queryDslFilter: { queryDsl, customLabel }, }); this.props.onLocalFilterUpdate?.(localFilter); + if (!selectedDataView) { + const dataViewId = this.props.filter.meta.index; + if (!dataViewId || !this.props.dataViews) { + this.setState({ isLoadingDataView: false }); + } else { + this.loadDataView(dataViewId, this.props.dataViews); + } + } + } + + /** + * Helper function to load the data view from the index pattern id + * E.g. in Discover there's just one active data view, so filters with different data view id + * Than the currently selected data view need to load the data view from the id to display the filter + * correctly + * @param dataViewId + * @private + */ + private async loadDataView(dataViewId: string, dataViews: DataViewsContract) { + try { + const dataView = await dataViews.get(dataViewId, false); + this.setState({ + selectedDataView: dataView, + isLoadingDataView: false, + indexPatterns: [dataView, ...this.props.indexPatterns], + localFilter: merge({}, this.props.filter), + queryDsl: this.parseFilterToQueryDsl(this.props.filter, this.state.indexPatterns), + }); + } catch (e) { + this.setState({ + isLoadingDataView: false, + }); + } } - private parseFilterToQueryDsl(filter: Filter) { - const dsl = filterToQueryDsl(filter, this.props.indexPatterns); + private parseFilterToQueryDsl(filter: Filter, indexPatterns: DataView[]) { + const dsl = filterToQueryDsl(filter, indexPatterns); return JSON.stringify(dsl, null, 2); } @@ -217,61 +256,67 @@ class FilterEditorComponent extends Component {
- + {this.state.isLoadingDataView ? (
- {this.renderIndexPatternInput()} - - {this.state.isCustomEditorOpen - ? this.renderCustomEditor() - : this.renderFiltersBuilderEditor()} - - - - - +
- - - {/* Adding isolation here fixes this bug https://github.com/elastic/kibana/issues/142211 */} - - - - {this.props.mode === 'add' - ? strings.getAddButtonLabel() - : strings.getUpdateButtonLabel()} - - - - - - - - - - -
+ ) : ( + +
+ {this.renderIndexPatternInput()} + + {this.state.isCustomEditorOpen + ? this.renderCustomEditor() + : this.renderFiltersBuilderEditor()} + + + + + +
+ + + {/* Adding isolation here fixes this bug https://github.com/elastic/kibana/issues/142211 */} + + + + {this.props.mode === 'add' + ? strings.getAddButtonLabel() + : strings.getUpdateButtonLabel()} + + + + + + + + + + +
+ )}
); } @@ -283,8 +328,8 @@ class FilterEditorComponent extends Component { } if ( - this.props.indexPatterns.length <= 1 && - this.props.indexPatterns.find( + this.state.indexPatterns.length <= 1 && + this.state.indexPatterns.find( (indexPattern) => indexPattern === this.getIndexPatternFromFilter() ) ) { @@ -296,15 +341,16 @@ class FilterEditorComponent extends Component { return null; } const { selectedDataView } = this.state; + return ( <> indexPattern.getName()} + getLabel={(indexPattern) => indexPattern?.getName()} onChange={this.onIndexPatternChange} isClearable={false} data-test-subj="filterIndexPatternsSelect" @@ -381,7 +427,7 @@ class FilterEditorComponent extends Component { @@ -447,7 +493,7 @@ class FilterEditorComponent extends Component { } private getIndexPatternFromFilter() { - return getIndexPatternFromFilter(this.props.filter, this.props.indexPatterns); + return getIndexPatternFromFilter(this.props.filter, this.state.indexPatterns); } private isQueryDslValid = (queryDsl: string) => { @@ -526,7 +572,7 @@ class FilterEditorComponent extends Component { return; } - const newIndex = index || this.props.indexPatterns[0].id!; + const newIndex = index || this.state.indexPatterns[0].id!; try { const body = JSON.parse(queryDsl); return buildCustomFilter(newIndex, body, disabled, negate, customLabel || null, $state.store); @@ -592,7 +638,7 @@ class FilterEditorComponent extends Component { const filter = this.props.filter?.meta.type === FILTERS.CUSTOM || // only convert non-custom filters to custom when DSL changes - queryDsl !== this.parseFilterToQueryDsl(this.props.filter) + queryDsl !== this.parseFilterToQueryDsl(this.props.filter, this.state.indexPatterns) ? this.getFilterFromQueryDsl(queryDsl) : { ...this.props.filter, diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.ts b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.ts index f5b28971ec412c..afc91cbe5ddd2f 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.ts +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_editor_utils.ts @@ -7,19 +7,42 @@ */ import dateMath from '@kbn/datemath'; -import { Filter } from '@kbn/es-query'; +import { Filter, RangeFilter, ScriptedRangeFilter, isRangeFilter } from '@kbn/es-query'; import { ES_FIELD_TYPES } from '@kbn/field-types'; import isSemverValid from 'semver/functions/valid'; import { isFilterable, IpAddress } from '@kbn/data-plugin/common'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; -import { FILTER_OPERATORS, Operator } from './filter_operators'; +import { FILTER_OPERATORS, OPERATORS, Operator } from './filter_operators'; export function getFieldFromFilter(filter: Filter, indexPattern?: DataView) { return indexPattern?.fields.find((field) => 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 5bfc6540d37d91..1b54defae5b10b 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 9328ecfa66c50c..e2e2d289d64e71 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 4f35d4a7f2d810..cb24ae53212eeb 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/filter_bar/filter_item/filter_item.tsx b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx index 596a32ea0a2f58..aed639ec76d0d1 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx @@ -34,7 +34,7 @@ import React, { useCallback, } from 'react'; import type { DocLinksStart, IUiSettingsClient } from '@kbn/core/public'; -import { DataView } from '@kbn/data-views-plugin/public'; +import { DataView, DataViewsContract } from '@kbn/data-views-plugin/public'; import { css } from '@emotion/react'; import { getIndexPatternFromFilter, getDisplayValueFromFilter } from '@kbn/data-plugin/public'; import { FilterEditor } from '../filter_editor/filter_editor'; @@ -62,6 +62,7 @@ export interface FilterItemProps extends WithCloseFilterEditorConfirmModalProps readOnly?: boolean; suggestionsAbstraction?: SuggestionsAbstraction; filtersCount?: number; + dataViews?: DataViewsContract; } type FilterPopoverProps = HTMLAttributes & EuiPopoverProps; @@ -399,6 +400,7 @@ function FilterItemComponent(props: FilterItemProps) { suggestionsAbstraction={props.suggestionsAbstraction} docLinks={docLinks} filtersCount={props.filtersCount} + dataViews={props.dataViews} />
, ]} diff --git a/src/plugins/unified_search/public/filter_bar/filter_item/filter_items.tsx b/src/plugins/unified_search/public/filter_bar/filter_item/filter_items.tsx index f0e558f75ba711..941e842d30f6d0 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_item/filter_items.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_item/filter_items.tsx @@ -47,7 +47,7 @@ export interface FilterItemsProps { const FilterItemsUI = React.memo(function FilterItemsUI(props: FilterItemsProps) { const groupRef = useRef(null); const kibana = useKibana(); - const { appName, usageCollection, uiSettings, docLinks } = kibana.services; + const { appName, data, usageCollection, uiSettings, docLinks } = kibana.services; const { readOnly = false } = props; if (!uiSettings) return null; @@ -84,6 +84,7 @@ const FilterItemsUI = React.memo(function FilterItemsUI(props: FilterItemsProps) readOnly={readOnly} suggestionsAbstraction={props.suggestionsAbstraction} filtersCount={props.filters.length} + dataViews={data?.dataViews} /> )); 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 97ea1f364b9d22..b789930dcda8d1 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 7d2cf5dc9c8d05..c3138f7a14e24c 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/unified_search/public/query_string_input/filter_editor_wrapper.tsx b/src/plugins/unified_search/public/query_string_input/filter_editor_wrapper.tsx index 6f801b2a32f04f..cb3094e66260fa 100644 --- a/src/plugins/unified_search/public/query_string_input/filter_editor_wrapper.tsx +++ b/src/plugins/unified_search/public/query_string_input/filter_editor_wrapper.tsx @@ -119,6 +119,7 @@ export const FilterEditorWrapper = React.memo(function FilterEditorWrapper({ filtersForSuggestions={filtersForSuggestions} suggestionsAbstraction={suggestionsAbstraction} docLinks={docLinks} + dataViews={data.dataViews} /> )}
diff --git a/src/plugins/vis_type_markdown/public/markdown_vis.scss b/src/plugins/vis_type_markdown/public/markdown_vis.scss index 97cfc4b151c77e..923888db5652f9 100644 --- a/src/plugins/vis_type_markdown/public/markdown_vis.scss +++ b/src/plugins/vis_type_markdown/public/markdown_vis.scss @@ -16,7 +16,10 @@ flex-grow: 1; } - .mkdEditor { - height: 100%; + .mkdEditor, + .euiFormControlLayout__childrenWrapper, + .euiFormControlLayout--euiTextArea, + .visEditor--markdown__textarea { + height: 100% } } diff --git a/src/plugins/visualizations/kibana.jsonc b/src/plugins/visualizations/kibana.jsonc index 69caa82b50030a..9d1c6c1da0e580 100644 --- a/src/plugins/visualizations/kibana.jsonc +++ b/src/plugins/visualizations/kibana.jsonc @@ -17,7 +17,6 @@ "navigation", "embeddable", "inspector", - "savedObjects", "screenshotMode", "presentationUtil", "dataViews", @@ -40,7 +39,8 @@ "requiredBundles": [ "kibanaUtils", "kibanaReact", - "charts" + "charts", + "savedObjects", ], "extraPublicDirs": [ "common/constants", diff --git a/test/accessibility/apps/discover.ts b/test/accessibility/apps/discover.ts index 3f9a1ef9bce0ca..4deb2acb66d743 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 a041939f2b809e..0210c7d8cc7f25 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 64e9b0e47dc900..ad5563e78f918f 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 2facbc95c93ce1..cdce56db6e856e 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 00000000000000..d471969d3528fc --- /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 a1038b3f7e4eed..d462155a3e0293 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 313c350209930b..cae06dd375b468 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/index.ts b/test/functional/apps/discover/group3/index.ts index 848bdc84def4d1..e2e0706cd6b4a3 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 2b6547152970d0..fd9060f9b9ec85 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/apps/discover/group4/_field_list_new_fields.ts b/test/functional/apps/discover/group4/_field_list_new_fields.ts new file mode 100644 index 00000000000000..3c24bcf613ae41 --- /dev/null +++ b/test/functional/apps/discover/group4/_field_list_new_fields.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 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 security = getService('security'); + const es = getService('es'); + const retry = getService('retry'); + const queryBar = getService('queryBar'); + const PageObjects = getPageObjects(['common', 'discover', 'timePicker', 'unifiedFieldList']); + + describe('Field list new fields in background handling', function () { + before(async () => { + await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); + await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover.json'); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + + await PageObjects.common.navigateToApp('discover'); + await PageObjects.timePicker.setCommonlyUsedTime('This_week'); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await esArchiver.unload('x-pack/test/functional/es_archives/logstash_functional'); + await es.transport.request({ + path: '/my-index-000001', + method: 'DELETE', + }); + }); + + it('Check that new ingested fields are added to the available fields section', async function () { + const initialPattern = 'my-index-'; + await es.transport.request({ + path: '/my-index-000001/_doc', + method: 'POST', + body: { + '@timestamp': new Date().toISOString(), + a: 'GET /search HTTP/1.1 200 1070000', + }, + }); + + await PageObjects.discover.createAdHocDataView(initialPattern, true); + + await retry.waitFor('current data view to get updated', async () => { + return (await PageObjects.discover.getCurrentlySelectedDataView()) === `${initialPattern}*`; + }); + await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); + + expect(await PageObjects.discover.getHitCountInt()).to.be(1); + expect(await PageObjects.unifiedFieldList.getSidebarSectionFieldNames('available')).to.eql([ + '@timestamp', + 'a', + ]); + + await es.transport.request({ + path: '/my-index-000001/_doc', + method: 'POST', + body: { + '@timestamp': new Date().toISOString(), + b: 'GET /search HTTP/1.1 200 1070000', + }, + }); + + await retry.waitFor('the new record was found', async () => { + await queryBar.submitQuery(); + await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); + return (await PageObjects.discover.getHitCountInt()) === 2; + }); + + expect(await PageObjects.unifiedFieldList.getSidebarSectionFieldNames('available')).to.eql([ + '@timestamp', + 'a', + 'b', + ]); + }); + }); +} diff --git a/test/functional/apps/discover/group4/index.ts b/test/functional/apps/discover/group4/index.ts index 1aab3db2bfc436..656a116551db80 100644 --- a/test/functional/apps/discover/group4/index.ts +++ b/test/functional/apps/discover/group4/index.ts @@ -33,5 +33,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_context_encoded_url_params')); loadTestFile(require.resolve('./_hide_announcements')); loadTestFile(require.resolve('./_data_view_edit')); + loadTestFile(require.resolve('./_field_list_new_fields')); }); } diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 3ec60eae8e4079..658e235c77d332 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/versions.json b/versions.json index 8406528bb4428a..ce91f8f76bb7e4 100644 --- a/versions.json +++ b/versions.json @@ -13,16 +13,10 @@ "currentMajor": true, "previousMinor": true }, - { - "version": "8.11.4", - "branch": "8.11", - "currentMajor": true, - "previousMinor": true - }, { "version": "7.17.17", "branch": "7.17", "previousMajor": true } ] -} \ No newline at end of file +} diff --git a/x-pack/examples/lens_embeddable_inline_editing_example/public/flyout.tsx b/x-pack/examples/lens_embeddable_inline_editing_example/public/flyout.tsx index f916abbad18f26..3fde509b112adc 100644 --- a/x-pack/examples/lens_embeddable_inline_editing_example/public/flyout.tsx +++ b/x-pack/examples/lens_embeddable_inline_editing_example/public/flyout.tsx @@ -86,6 +86,7 @@ function InlineEditingContent({ const style = css` padding: 0; position: relative; + height: 100%; } `; @@ -104,6 +105,7 @@ function InlineEditingContent({ `} direction="column" ref={containerRef} + gutterSize="none" /> ); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/README.md b/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/README.md new file mode 100644 index 00000000000000..5a471245e04493 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/README.md @@ -0,0 +1,51 @@ +### Feature Capabilities + +Feature capabilities are an object describing specific capabilities of the assistant, like whether a feature like streaming is enabled, and are defined in the sibling `./index.ts` file within this `kbn-elastic-assistant-common` package. These capabilities can be registered for a given plugin through the assistant server, and so do not need to be plumbed through the `ElasticAssistantProvider`. + +Storage and accessor functions are made available via the `AppContextService`, and exposed to clients via the`/internal/elastic_assistant/capabilities` route, which can be fetched by clients using the `useCapabilities()` UI hook. + +### Registering Capabilities + +To register a capability on plugin start, add the following in the consuming plugin's `start()`, specifying any number of capabilities you would like to explicitly declare: + +```ts +plugins.elasticAssistant.registerFeatures(APP_UI_ID, { + assistantModelEvaluation: config.experimentalFeatures.assistantModelEvaluation, + assistantStreamingEnabled: config.experimentalFeatures.assistantStreamingEnabled, +}); +``` + +### Declaring Feature Capabilities +Default feature capabilities are declared in `x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts`: + +```ts +export type AssistantFeatures = { [K in keyof typeof defaultAssistantFeatures]: boolean }; + +export const defaultAssistantFeatures = Object.freeze({ + assistantModelEvaluation: false, + assistantStreamingEnabled: false, +}); +``` + +### Using Capabilities Client Side +Capabilities can be fetched client side using the `useCapabilities()` hook ala: + +```ts +const { data: capabilities } = useCapabilities({ http, toasts }); +const { assistantModelEvaluation: modelEvaluatorEnabled, assistantStreamingEnabled } = capabilities ?? defaultAssistantFeatures; +``` + +### Using Capabilities Server Side +Or server side within a route (or elsewhere) via the `assistantContext`: + +```ts +const assistantContext = await context.elasticAssistant; +const pluginName = getPluginNameFromRequest({ request, logger }); +const registeredFeatures = assistantContext.getRegisteredFeatures(pluginName); +if (!registeredFeatures.assistantModelEvaluation) { + return response.notFound(); +} +``` + +> [!NOTE] +> Note, just as with [registering arbitrary tools](https://github.com/elastic/kibana/pull/172234), features are registered for a specific plugin, where the plugin name that corresponds to your application is defined in the `x-kbn-context` header of requests made from your application, which may be different than your plugin's registered `APP_ID`. diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts new file mode 100644 index 00000000000000..1d404309f73e39 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/capabilities/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Interface for features available to the elastic assistant + */ +export type AssistantFeatures = { [K in keyof typeof defaultAssistantFeatures]: boolean }; + +/** + * Default features available to the elastic assistant + */ +export const defaultAssistantFeatures = Object.freeze({ + assistantModelEvaluation: false, + assistantStreamingEnabled: false, +}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/index.ts b/x-pack/packages/kbn-elastic-assistant-common/index.ts index f17e13a33af3d4..c64b02160d6e43 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/index.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/index.ts @@ -5,6 +5,9 @@ * 2.0. */ +export { defaultAssistantFeatures } from './impl/capabilities'; +export type { AssistantFeatures } from './impl/capabilities'; + export { getAnonymizedValue } from './impl/data_anonymization/get_anonymized_value'; export { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/capabilities/capabilities.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/capabilities/capabilities.test.tsx new file mode 100644 index 00000000000000..b41d7ac1445549 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/capabilities/capabilities.test.tsx @@ -0,0 +1,43 @@ +/* + * 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 { HttpSetup } from '@kbn/core-http-browser'; + +import { getCapabilities } from './capabilities'; +import { API_ERROR } from '../../translations'; + +jest.mock('@kbn/core-http-browser'); + +const mockHttp = { + fetch: jest.fn(), +} as unknown as HttpSetup; + +describe('Capabilities API tests', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getCapabilities', () => { + it('calls the internal assistant API for fetching assistant capabilities', async () => { + await getCapabilities({ http: mockHttp }); + + expect(mockHttp.fetch).toHaveBeenCalledWith('/internal/elastic_assistant/capabilities', { + method: 'GET', + signal: undefined, + version: '1', + }); + }); + + it('returns API_ERROR when the response status is error', async () => { + (mockHttp.fetch as jest.Mock).mockResolvedValue({ status: API_ERROR }); + + const result = await getCapabilities({ http: mockHttp }); + + expect(result).toEqual({ status: API_ERROR }); + }); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/capabilities/capabilities.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/capabilities/capabilities.tsx new file mode 100644 index 00000000000000..794b89e1775f8b --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/capabilities/capabilities.tsx @@ -0,0 +1,44 @@ +/* + * 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 { HttpSetup, IHttpFetchError } from '@kbn/core-http-browser'; +import { AssistantFeatures } from '@kbn/elastic-assistant-common'; + +export interface GetCapabilitiesParams { + http: HttpSetup; + signal?: AbortSignal | undefined; +} + +export type GetCapabilitiesResponse = AssistantFeatures; + +/** + * API call for fetching assistant capabilities + * + * @param {Object} options - The options object. + * @param {HttpSetup} options.http - HttpSetup + * @param {AbortSignal} [options.signal] - AbortSignal + * + * @returns {Promise} + */ +export const getCapabilities = async ({ + http, + signal, +}: GetCapabilitiesParams): Promise => { + try { + const path = `/internal/elastic_assistant/capabilities`; + + const response = await http.fetch(path, { + method: 'GET', + signal, + version: '1', + }); + + return response as GetCapabilitiesResponse; + } catch (error) { + return error as IHttpFetchError; + } +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/capabilities/use_capabilities.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/capabilities/use_capabilities.test.tsx new file mode 100644 index 00000000000000..c9e60b806d1bf3 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/capabilities/use_capabilities.test.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import React from 'react'; +import { useCapabilities, UseCapabilitiesParams } from './use_capabilities'; + +const statusResponse = { assistantModelEvaluation: true, assistantStreamingEnabled: false }; + +const http = { + fetch: jest.fn().mockResolvedValue(statusResponse), +}; +const toasts = { + addError: jest.fn(), +}; +const defaultProps = { http, toasts } as unknown as UseCapabilitiesParams; + +const createWrapper = () => { + const queryClient = new QueryClient(); + // eslint-disable-next-line react/display-name + return ({ children }: { children: ReactNode }) => ( + {children} + ); +}; + +describe('useFetchRelatedCases', () => { + it(`should make http request to fetch capabilities`, () => { + renderHook(() => useCapabilities(defaultProps), { + wrapper: createWrapper(), + }); + + expect(defaultProps.http.fetch).toHaveBeenCalledWith( + '/internal/elastic_assistant/capabilities', + { + method: 'GET', + version: '1', + signal: new AbortController().signal, + } + ); + expect(toasts.addError).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/capabilities/use_capabilities.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/capabilities/use_capabilities.tsx new file mode 100644 index 00000000000000..5d52a2801fb9ea --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/capabilities/use_capabilities.tsx @@ -0,0 +1,52 @@ +/* + * 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 { UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import type { HttpSetup, IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser'; +import type { IToasts } from '@kbn/core-notifications-browser'; +import { i18n } from '@kbn/i18n'; +import { getCapabilities, GetCapabilitiesResponse } from './capabilities'; + +const CAPABILITIES_QUERY_KEY = ['elastic-assistant', 'capabilities']; + +export interface UseCapabilitiesParams { + http: HttpSetup; + toasts?: IToasts; +} +/** + * Hook for getting the feature capabilities of the assistant + * + * @param {Object} options - The options object. + * @param {HttpSetup} options.http - HttpSetup + * @param {IToasts} options.toasts - IToasts + * + * @returns {useQuery} hook for getting the status of the Knowledge Base + */ +export const useCapabilities = ({ + http, + toasts, +}: UseCapabilitiesParams): UseQueryResult => { + return useQuery({ + queryKey: CAPABILITIES_QUERY_KEY, + queryFn: async ({ signal }) => { + return getCapabilities({ http, signal }); + }, + retry: false, + keepPreviousData: true, + // Deprecated, hoist to `queryCache` w/in `QueryClient. See: https://stackoverflow.com/a/76961109 + onError: (error: IHttpFetchError) => { + if (error.name !== 'AbortError') { + toasts?.addError(error.body && error.body.message ? new Error(error.body.message) : error, { + title: i18n.translate('xpack.elasticAssistant.capabilities.statusError', { + defaultMessage: 'Error fetching capabilities', + }), + }); + } + }, + }); +}; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.test.tsx index 84a2ac40a6f248..a8dc5b1aa1db76 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.test.tsx @@ -6,53 +6,14 @@ */ import { renderHook } from '@testing-library/react-hooks'; -import React from 'react'; -import { AssistantProvider, useAssistantContext } from '.'; -import { httpServiceMock } from '@kbn/core-http-browser-mocks'; -import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; -import { AssistantAvailability } from '../..'; +import { useAssistantContext } from '.'; import { useLocalStorage } from 'react-use'; +import { TestProviders } from '../mock/test_providers/test_providers'; jest.mock('react-use', () => ({ useLocalStorage: jest.fn().mockReturnValue(['456', jest.fn()]), })); -const actionTypeRegistry = actionTypeRegistryMock.create(); -const mockGetInitialConversations = jest.fn(() => ({})); -const mockGetComments = jest.fn(() => []); -const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' }); -const mockAssistantAvailability: AssistantAvailability = { - hasAssistantPrivilege: false, - hasConnectorsAllPrivilege: true, - hasConnectorsReadPrivilege: true, - isAssistantEnabled: true, -}; - -const ContextWrapper: React.FC = ({ children }) => ( - - {children} - -); describe('AssistantContext', () => { beforeEach(() => jest.clearAllMocks()); @@ -66,30 +27,29 @@ describe('AssistantContext', () => { }); test('it should return the httpFetch function', async () => { - const { result } = renderHook(useAssistantContext, { wrapper: ContextWrapper }); - const http = await result.current.http; + const { result } = renderHook(useAssistantContext, { wrapper: TestProviders }); const path = '/path/to/resource'; - await http.fetch(path); + await result.current.http.fetch(path); - expect(mockHttp.fetch).toBeCalledWith(path); + expect(result.current.http.fetch).toBeCalledWith(path); }); test('getConversationId defaults to provided id', async () => { - const { result } = renderHook(useAssistantContext, { wrapper: ContextWrapper }); + const { result } = renderHook(useAssistantContext, { wrapper: TestProviders }); const id = result.current.getConversationId('123'); expect(id).toEqual('123'); }); test('getConversationId uses local storage id when no id is provided ', async () => { - const { result } = renderHook(useAssistantContext, { wrapper: ContextWrapper }); + const { result } = renderHook(useAssistantContext, { wrapper: TestProviders }); const id = result.current.getConversationId(); expect(id).toEqual('456'); }); test('getConversationId defaults to Welcome when no local storage id and no id is provided ', async () => { (useLocalStorage as jest.Mock).mockReturnValue([undefined, jest.fn()]); - const { result } = renderHook(useAssistantContext, { wrapper: ContextWrapper }); + const { result } = renderHook(useAssistantContext, { wrapper: TestProviders }); const id = result.current.getConversationId(); expect(id).toEqual('Welcome'); }); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx index 50a3211f74f3cd..3f3102a4ea6bff 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx @@ -13,6 +13,7 @@ import type { IToasts } from '@kbn/core-notifications-browser'; import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public'; import { useLocalStorage } from 'react-use'; import type { DocLinksStart } from '@kbn/core-doc-links-browser'; +import { defaultAssistantFeatures } from '@kbn/elastic-assistant-common'; import { WELCOME_CONVERSATION_TITLE } from '../assistant/use_conversation/translations'; import { updatePromptContexts } from './helpers'; import type { @@ -37,6 +38,7 @@ import { } from './constants'; import { CONVERSATIONS_TAB, SettingsTabs } from '../assistant/settings/assistant_settings'; import { AssistantAvailability, AssistantTelemetry } from './types'; +import { useCapabilities } from '../assistant/api/capabilities/use_capabilities'; export interface ShowAssistantOverlayProps { showOverlay: boolean; @@ -53,7 +55,6 @@ export interface AssistantProviderProps { actionTypeRegistry: ActionTypeRegistryContract; alertsIndexPattern?: string; assistantAvailability: AssistantAvailability; - assistantStreamingEnabled?: boolean; assistantTelemetry?: AssistantTelemetry; augmentMessageCodeBlocks: (currentConversation: Conversation) => CodeBlockDetails[][]; baseAllow: string[]; @@ -87,7 +88,6 @@ export interface AssistantProviderProps { }) => EuiCommentProps[]; http: HttpSetup; getInitialConversations: () => Record; - modelEvaluatorEnabled?: boolean; nameSpace?: string; setConversations: React.Dispatch>>; setDefaultAllow: React.Dispatch>; @@ -163,7 +163,6 @@ export const AssistantProvider: React.FC = ({ actionTypeRegistry, alertsIndexPattern, assistantAvailability, - assistantStreamingEnabled = false, assistantTelemetry, augmentMessageCodeBlocks, baseAllow, @@ -179,7 +178,6 @@ export const AssistantProvider: React.FC = ({ getComments, http, getInitialConversations, - modelEvaluatorEnabled = false, nameSpace = DEFAULT_ASSISTANT_NAMESPACE, setConversations, setDefaultAllow, @@ -298,6 +296,11 @@ export const AssistantProvider: React.FC = ({ [localStorageLastConversationId] ); + // Fetch assistant capabilities + const { data: capabilities } = useCapabilities({ http, toasts }); + const { assistantModelEvaluation: modelEvaluatorEnabled, assistantStreamingEnabled } = + capabilities ?? defaultAssistantFeatures; + const value = useMemo( () => ({ actionTypeRegistry, 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 fbb0cbddfb7429..726c3eb0dd2684 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 @@ -11,7 +11,7 @@ import { EuiInMemoryTable, Direction, Pagination } from '@elastic/eui'; /** * Returned type for useTableState hook */ -export interface UseTableState { +export interface UseTableState { /** * Callback function which gets called whenever the pagination or sorting state of the table changed */ @@ -36,7 +36,7 @@ export interface UseTableState { * @param {string} initialSortField - field name to sort by default * @param {string} initialSortDirection - default to 'asc' */ -export function useTableState( +export function useTableState( items: T[], initialSortField: string, initialSortDirection: 'asc' | 'desc' = 'asc' diff --git a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx index b2bd63f8101aa1..175380cc5169ad 100644 --- a/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx +++ b/x-pack/packages/security-solution/ecs_data_quality_dashboard/impl/data_quality/mock/test_providers/test_providers.tsx @@ -13,6 +13,7 @@ import { euiDarkVars } from '@kbn/ui-theme'; import React from 'react'; import { ThemeProvider } from 'styled-components'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { DataQualityProvider } from '../../data_quality_panel/data_quality_context'; interface Props { @@ -39,38 +40,52 @@ export const TestProvidersComponent: React.FC = ({ children, isILMAvailab hasConnectorsReadPrivilege: true, isAssistantEnabled: true, }; + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + logger: { + log: jest.fn(), + warn: jest.fn(), + error: () => {}, + }, + }); return ( ({ eui: euiDarkVars, darkMode: true })}> - - + - {children} - - + + {children} + + + ); diff --git a/x-pack/plugins/actions/server/actions_client/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client/actions_client.test.ts index 02c647f17664da..b52ce10ab8c4ac 100644 --- a/x-pack/plugins/actions/server/actions_client/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client/actions_client.test.ts @@ -51,6 +51,7 @@ import { getOAuthClientCredentialsAccessToken } from '../lib/get_oauth_client_cr import { OAuthParams } from '../routes/get_oauth_access_token'; import { eventLogClientMock } from '@kbn/event-log-plugin/server/event_log_client.mock'; import { GetGlobalExecutionKPIParams, GetGlobalExecutionLogParams } from '../../common'; +import { estypes } from '@elastic/elasticsearch'; jest.mock('@kbn/core-saved-objects-utils-server', () => { const actual = jest.requireActual('@kbn/core-saved-objects-utils-server'); @@ -3419,6 +3420,10 @@ describe('getGlobalExecutionLogWithAuth()', () => { executionUuidCardinality: { doc_count: 5, executionUuidCardinality: { value: 5 } }, }, }, + hits: { + total: { value: 5, relation: 'eq' }, + hits: [], + } as estypes.SearchHitsMetadata, }; describe('authorization', () => { test('ensures user is authorised to access logs', async () => { @@ -3474,6 +3479,10 @@ describe('getGlobalExecutionKpiWithAuth()', () => { }, }, }, + hits: { + total: { value: 5, relation: 'eq' }, + hits: [], + } as estypes.SearchHitsMetadata, }; describe('authorization', () => { test('ensures user is authorised to access kpi', async () => { diff --git a/x-pack/plugins/actions/server/lib/get_execution_log_aggregation.test.ts b/x-pack/plugins/actions/server/lib/get_execution_log_aggregation.test.ts index f85d51b5ae2c38..0d889e53ca8c2e 100644 --- a/x-pack/plugins/actions/server/lib/get_execution_log_aggregation.test.ts +++ b/x-pack/plugins/actions/server/lib/get_execution_log_aggregation.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import { fromKueryExpression } from '@kbn/es-query'; import { getExecutionLogAggregation, @@ -485,7 +486,15 @@ describe('getExecutionLogAggregation', () => { describe('formatExecutionLogResult', () => { test('should return empty results if aggregations are undefined', () => { - expect(formatExecutionLogResult({ aggregations: undefined })).toEqual({ + expect( + formatExecutionLogResult({ + aggregations: undefined, + hits: { + total: { value: 0, relation: 'eq' }, + hits: [], + } as estypes.SearchHitsMetadata, + }) + ).toEqual({ total: 0, data: [], }); @@ -494,6 +503,10 @@ describe('formatExecutionLogResult', () => { expect( formatExecutionLogResult({ aggregations: { executionLogAgg: undefined as unknown as ExecutionUuidAggResult }, + hits: { + total: { value: 5, relation: 'eq' }, + hits: [], + } as estypes.SearchHitsMetadata, }) ).toEqual({ total: 0, @@ -554,6 +567,10 @@ describe('formatExecutionLogResult', () => { executionUuidCardinality: { doc_count: 1, executionUuidCardinality: { value: 1 } }, }, }, + hits: { + total: { value: 5, relation: 'eq' }, + hits: [], + } as estypes.SearchHitsMetadata, }; expect(formatExecutionLogResult(results)).toEqual({ data: [ @@ -675,6 +692,10 @@ describe('formatExecutionLogResult', () => { executionUuidCardinality: { doc_count: 2, executionUuidCardinality: { value: 2 } }, }, }, + hits: { + total: { value: 10, relation: 'eq' }, + hits: [], + } as estypes.SearchHitsMetadata, }; expect(formatExecutionLogResult(results)).toEqual({ data: [ @@ -918,6 +939,10 @@ describe('formatExecutionKPIAggBuckets', () => { expect( formatExecutionKPIResult({ aggregations: undefined, + hits: { + total: { value: 0, relation: 'eq' }, + hits: [], + } as estypes.SearchHitsMetadata, }) ).toEqual({ failure: 0, success: 0, unknown: 0, warning: 0 }); }); @@ -951,6 +976,10 @@ describe('formatExecutionKPIAggBuckets', () => { }, }, }, + hits: { + total: { value: 21, relation: 'eq' }, + hits: [], + } as estypes.SearchHitsMetadata, }; expect(formatExecutionKPIResult(results)).toEqual({ 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 3f3785b89f619c..95f1fca184d560 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 @@ -8973,7 +8973,11 @@ Object { } `; -exports[`Alert as data fields checks detect AAD fields changes for: transform_health 1`] = `undefined`; +exports[`Alert as data fields checks detect AAD fields changes for: transform_health 1`] = ` +Object { + "fieldMap": Object {}, +} +`; exports[`Alert as data fields checks detect AAD fields changes for: xpack.ml.anomaly_detection_alert 1`] = ` Object { @@ -9087,7 +9091,11 @@ Object { } `; -exports[`Alert as data fields checks detect AAD fields changes for: xpack.ml.anomaly_detection_jobs_health 1`] = `undefined`; +exports[`Alert as data fields checks detect AAD fields changes for: xpack.ml.anomaly_detection_jobs_health 1`] = ` +Object { + "fieldMap": Object {}, +} +`; exports[`Alert as data fields checks detect AAD fields changes for: xpack.synthetics.alerts.monitorStatus 1`] = ` Object { diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts index bb38fb7a98bfa5..88263ff495b9ac 100644 --- a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import { fromKueryExpression } from '@kbn/es-query'; import { getNumExecutions, @@ -73,7 +74,7 @@ describe('getNumExecutions', () => { new Date('2020-12-02T00:00:00.000Z'), '1s' ) - ).toEqual(1000); + ).toEqual(10000); }); }); @@ -146,38 +147,48 @@ describe('getExecutionLogAggregation', () => { }, aggs: { executionUuidCardinality: { - aggs: { - executionUuidCardinality: { - cardinality: { field: 'kibana.alert.rule.execution.uuid' }, - }, + sum_bucket: { + buckets_path: 'executionUuidCardinalityBuckets>ruleExecution._count', }, - filter: { - bool: { - must: [ - { - bool: { - must: [ - { - match: { - 'event.action': 'execute', - }, - }, - { - match: { - 'event.provider': 'alerting', - }, + }, + executionUuidCardinalityBuckets: { + terms: { + field: 'kibana.alert.rule.execution.uuid', + size: 10000, + order: [{ 'ruleExecution>executeStartTime': 'desc' }], + }, + aggs: { + ruleExecution: { + filter: { + bool: { + must: [ + { + bool: { + must: [ + { + match: { + 'event.action': 'execute', + }, + }, + { + match: { + 'event.provider': 'alerting', + }, + }, + ], }, - ], - }, + }, + ], }, - ], + }, + aggs: { executeStartTime: { min: { field: 'event.start' } } }, }, }, }, executionUuid: { terms: { field: 'kibana.alert.rule.execution.uuid', - size: 1000, + size: 10000, order: [ { 'ruleExecution>executeStartTime': 'asc' }, { 'ruleExecution>executionDuration': 'desc' }, @@ -330,50 +341,60 @@ describe('getExecutionLogAggregation', () => { }, aggs: { executionUuidCardinality: { - aggs: { - executionUuidCardinality: { - cardinality: { field: 'kibana.alert.rule.execution.uuid' }, - }, + sum_bucket: { + buckets_path: 'executionUuidCardinalityBuckets>ruleExecution._count', + }, + }, + executionUuidCardinalityBuckets: { + terms: { + field: 'kibana.alert.rule.execution.uuid', + size: 10000, + order: [{ 'ruleExecution>executeStartTime': 'desc' }], }, - filter: { - bool: { + aggs: { + ruleExecution: { filter: { bool: { - minimum_should_match: 1, - should: [ + filter: { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + test: 'test', + }, + }, + ], + }, + }, + must: [ { - match: { - test: 'test', + bool: { + must: [ + { + match: { + 'event.action': 'execute', + }, + }, + { + match: { + 'event.provider': 'alerting', + }, + }, + ], }, }, ], }, }, - must: [ - { - bool: { - must: [ - { - match: { - 'event.action': 'execute', - }, - }, - { - match: { - 'event.provider': 'alerting', - }, - }, - ], - }, - }, - ], + aggs: { executeStartTime: { min: { field: 'event.start' } } }, }, }, }, executionUuid: { terms: { field: 'kibana.alert.rule.execution.uuid', - size: 1000, + size: 10000, order: [ { 'ruleExecution>executeStartTime': 'asc' }, { 'ruleExecution>executionDuration': 'desc' }, @@ -538,50 +559,60 @@ describe('getExecutionLogAggregation', () => { }, aggs: { executionUuidCardinality: { - aggs: { - executionUuidCardinality: { - cardinality: { field: 'kibana.alert.rule.execution.uuid' }, - }, + sum_bucket: { + buckets_path: 'executionUuidCardinalityBuckets>ruleExecution._count', + }, + }, + executionUuidCardinalityBuckets: { + terms: { + field: 'kibana.alert.rule.execution.uuid', + size: 10000, + order: [{ 'ruleExecution>executeStartTime': 'desc' }], }, - filter: { - bool: { + aggs: { + ruleExecution: { filter: { bool: { - minimum_should_match: 1, - should: [ + filter: { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + test: 'test', + }, + }, + ], + }, + }, + must: [ { - match: { - test: 'test', + bool: { + must: [ + { + match: { + 'event.action': 'execute', + }, + }, + { + match: { + 'event.provider': 'alerting', + }, + }, + ], }, }, ], }, }, - must: [ - { - bool: { - must: [ - { - match: { - 'event.action': 'execute', - }, - }, - { - match: { - 'event.provider': 'alerting', - }, - }, - ], - }, - }, - ], + aggs: { executeStartTime: { min: { field: 'event.start' } } }, }, }, }, executionUuid: { terms: { field: 'kibana.alert.rule.execution.uuid', - size: 1000, + size: 10000, order: [ { 'ruleExecution>executeStartTime': 'asc' }, { 'ruleExecution>executionDuration': 'desc' }, @@ -726,7 +757,12 @@ describe('getExecutionLogAggregation', () => { describe('formatExecutionLogResult', () => { test('should return empty results if aggregations are undefined', () => { - expect(formatExecutionLogResult({ aggregations: undefined })).toEqual({ + expect( + formatExecutionLogResult({ + aggregations: undefined, + hits: { total: { value: 0, relation: 'eq' }, hits: [] }, + }) + ).toEqual({ total: 0, data: [], }); @@ -735,6 +771,7 @@ describe('formatExecutionLogResult', () => { expect( formatExecutionLogResult({ aggregations: { excludeExecuteStart: undefined as unknown as ExecutionUuidAggResult }, + hits: { total: { value: 0, relation: 'eq' }, hits: [] }, }) ).toEqual({ total: 0, @@ -932,12 +969,14 @@ describe('formatExecutionLogResult', () => { ], }, executionUuidCardinality: { - executionUuidCardinality: { - value: 374, - }, + value: 374, }, }, }, + hits: { + total: { value: 875, relation: 'eq' }, + hits: [], + } as estypes.SearchHitsMetadata, }; expect(formatExecutionLogResult(results)).toEqual({ total: 374, @@ -1188,12 +1227,14 @@ describe('formatExecutionLogResult', () => { ], }, executionUuidCardinality: { - executionUuidCardinality: { - value: 374, - }, + value: 374, }, }, }, + hits: { + total: { value: 875, relation: 'eq' }, + hits: [], + } as estypes.SearchHitsMetadata, }; expect(formatExecutionLogResult(results)).toEqual({ total: 374, @@ -1436,12 +1477,14 @@ describe('formatExecutionLogResult', () => { ], }, executionUuidCardinality: { - executionUuidCardinality: { - value: 374, - }, + value: 374, }, }, }, + hits: { + total: { value: 875, relation: 'eq' }, + hits: [], + } as estypes.SearchHitsMetadata, }; expect(formatExecutionLogResult(results)).toEqual({ total: 374, @@ -1689,12 +1732,14 @@ describe('formatExecutionLogResult', () => { ], }, executionUuidCardinality: { - executionUuidCardinality: { - value: 417, - }, + value: 417, }, }, }, + hits: { + total: { value: 875, relation: 'eq' }, + hits: [], + } as estypes.SearchHitsMetadata, }; expect(formatExecutionLogResult(results)).toEqual({ total: 417, @@ -1750,78 +1795,283 @@ describe('formatExecutionLogResult', () => { ], }); }); -}); -describe('getExecutionKPIAggregation', () => { - test('should correctly generate aggregation', () => { - expect(getExecutionKPIAggregation()).toEqual({ - excludeExecuteStart: { - filter: { - bool: { - must_not: [ - { - term: { - 'event.action': 'execute-start', - }, - }, - ], - }, - }, - aggs: { + test('should throw an error when document is above 10,000', () => { + const results = { + aggregations: { + excludeExecuteStart: { + meta: {}, + doc_count: 875, executionUuid: { - terms: { - field: 'kibana.alert.rule.execution.uuid', - order: [ - { - 'ruleExecution>executeStartTime': 'desc', - }, - ], - size: 10000, - }, - aggs: { - executionUuidSorted: { - bucket_sort: { - from: 0, - size: 10000, - gap_policy: 'insert_zeros', + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'ecf7ac4c-1c15-4a1d-818a-cacbf57f6158', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, }, - }, - actionExecution: { - filter: { - bool: { - must: [ - { - bool: { - must: [ - { - match: { - 'event.action': 'execute', - }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + numGeneratedActions: { + value: 5.0, + }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, + outcomeMessageAndMaintenanceWindow: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: '7xKcb38BcntAq5ycFwiu', + _score: 1.0, + _source: { + rule: { id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef', name: 'rule_name' }, + event: { + outcome: 'success', }, - { - match: { - 'event.provider': 'actions', + kibana: { + version: '8.2.0', + alerting: { + outcome: 'success', }, }, - ], + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, }, - }, - ], - }, - }, - aggs: { - actionOutcomes: { - terms: { - field: 'event.outcome', - size: 2, + ], }, }, - }, - }, - ruleExecution: { - filter: { - bool: { - must: [ + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.374e9, + }, + executeStartTime: { + value: 1.646844973039e12, + value_as_string: '2022-03-09T16:56:13.039Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'failure', + doc_count: 5, + }, + ], + }, + }, + }, + { + key: '61bb867b-661a-471f-bf92-23471afa10b3', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + numGeneratedActions: { + value: 5.0, + }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, + outcomeMessageAndMaintenanceWindow: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'zRKbb38BcntAq5ycOwgk', + _score: 1.0, + _source: { + rule: { id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef', name: 'rule_name' }, + event: { + outcome: 'success', + }, + kibana: { + version: '8.2.0', + alert: { + maintenance_window_ids: ['254699b0-dfb2-11ed-bb3d-c91b918d0260'], + }, + alerting: { + outcome: 'success', + }, + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.133e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 4.18e8, + }, + executeStartTime: { + value: 1.646844917518e12, + value_as_string: '2022-03-09T16:55:17.518Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + ], + }, + executionUuidCardinality: { + value: 417, + }, + }, + }, + hits: { + total: { value: 10000, relation: 'gte' }, + hits: [], + } as estypes.SearchHitsMetadata, + }; + expect(() => formatExecutionLogResult(results)).toThrowErrorMatchingInlineSnapshot( + `"Results are limited to 10,000 documents, refine your search to see others."` + ); + }); +}); + +describe('getExecutionKPIAggregation', () => { + test('should correctly generate aggregation', () => { + expect(getExecutionKPIAggregation()).toEqual({ + excludeExecuteStart: { + filter: { + bool: { + must_not: [ + { + term: { + 'event.action': 'execute-start', + }, + }, + ], + }, + }, + aggs: { + executionUuid: { + terms: { + field: 'kibana.alert.rule.execution.uuid', + order: [ + { + 'ruleExecution>executeStartTime': 'desc', + }, + ], + size: 10000, + }, + aggs: { + executionUuidSorted: { + bucket_sort: { + from: 0, + size: 10000, + gap_policy: 'insert_zeros', + }, + }, + actionExecution: { + filter: { + bool: { + must: [ + { + bool: { + must: [ + { + match: { + 'event.action': 'execute', + }, + }, + { + match: { + 'event.provider': 'actions', + }, + }, + ], + }, + }, + ], + }, + }, + aggs: { + actionOutcomes: { + terms: { + field: 'event.outcome', + size: 2, + }, + }, + }, + }, + ruleExecution: { + filter: { + bool: { + must: [ { bool: { must: [ @@ -2254,6 +2504,10 @@ describe('formatExecutionKPIAggBuckets', () => { expect( formatExecutionKPIResult({ aggregations: undefined, + hits: { + total: { value: 875, relation: 'eq' }, + hits: [], + } as estypes.SearchHitsMetadata, }) ).toEqual({ activeAlerts: 0, @@ -2375,6 +2629,10 @@ describe('formatExecutionKPIAggBuckets', () => { }, }, }, + hits: { + total: { value: 875, relation: 'eq' }, + hits: [], + } as estypes.SearchHitsMetadata, }; expect(formatExecutionKPIResult(results)).toEqual({ @@ -2497,6 +2755,10 @@ describe('formatExecutionKPIAggBuckets', () => { }, }, }, + hits: { + total: { value: 875, relation: 'eq' }, + hits: [], + } as estypes.SearchHitsMetadata, }; expect(formatExecutionKPIResult(results)).toEqual({ @@ -2511,4 +2773,209 @@ describe('formatExecutionKPIAggBuckets', () => { triggeredActions: 10, }); }); + + test('should throw an error when document is above 10,000', () => { + const results = { + aggregations: { + excludeExecuteStart: { + meta: {}, + doc_count: 875, + executionUuid: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'ecf7ac4c-1c15-4a1d-818a-cacbf57f6158', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + numGeneratedActions: { + value: 5.0, + }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, + outcomeMessageAndMaintenanceWindow: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: '7xKcb38BcntAq5ycFwiu', + _score: 1.0, + _source: { + rule: { id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef', name: 'rule_name' }, + event: { + outcome: 'success', + }, + kibana: { + version: '8.2.0', + alerting: { + outcome: 'success', + }, + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.374e9, + }, + executeStartTime: { + value: 1.646844973039e12, + value_as_string: '2022-03-09T16:56:13.039Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'failure', + doc_count: 5, + }, + ], + }, + }, + }, + { + key: '61bb867b-661a-471f-bf92-23471afa10b3', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + numGeneratedActions: { + value: 5.0, + }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, + outcomeMessageAndMaintenanceWindow: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'zRKbb38BcntAq5ycOwgk', + _score: 1.0, + _source: { + rule: { id: 'a348a740-9e2c-11ec-bd64-774ed95c43ef', name: 'rule_name' }, + event: { + outcome: 'success', + }, + kibana: { + version: '8.2.0', + alert: { + maintenance_window_ids: ['254699b0-dfb2-11ed-bb3d-c91b918d0260'], + }, + alerting: { + outcome: 'success', + }, + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.133e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 4.18e8, + }, + executeStartTime: { + value: 1.646844917518e12, + value_as_string: '2022-03-09T16:55:17.518Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + ], + }, + executionUuidCardinality: { + value: 417, + }, + }, + }, + hits: { + total: { value: 10000, relation: 'gte' }, + hits: [], + } as estypes.SearchHitsMetadata, + }; + expect(() => formatExecutionKPIResult(results)).toThrowErrorMatchingInlineSnapshot( + `"Results are limited to 10,000 documents, refine your search to see others."` + ); + }); }); diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts index 551dfdce1ef428..30f495efbf0876 100644 --- a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { KueryNode } from '@kbn/es-query'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import Boom from '@hapi/boom'; @@ -14,7 +15,7 @@ import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; import { parseDuration } from '.'; import { IExecutionLog, IExecutionLogResult, EMPTY_EXECUTION_KPI_RESULT } from '../../common'; -const DEFAULT_MAX_BUCKETS_LIMIT = 1000; // do not retrieve more than this number of executions +const DEFAULT_MAX_BUCKETS_LIMIT = 10000; // do not retrieve more than this number of executions. UI limits 1000 to display, but we need to fetch all 10000 to accurately reflect the KPIs const DEFAULT_MAX_KPI_BUCKETS_LIMIT = 10000; const RULE_ID_FIELD = 'rule.id'; @@ -104,9 +105,7 @@ export interface ExecutionUuidKPIAggResult interface ExcludeExecuteStartAggResult extends estypes.AggregationsAggregateBase { executionUuid: ExecutionUuidAggResult; - executionUuidCardinality: { - executionUuidCardinality: estypes.AggregationsCardinalityAggregate; - }; + executionUuidCardinality: estypes.AggregationsCardinalityAggregate; // This is an accurate type even though we're actually using a sum bucket agg } interface ExcludeExecuteStartKpiAggResult extends estypes.AggregationsAggregateBase { @@ -301,21 +300,37 @@ export function getExecutionLogAggregation({ }, aggs: { // Get total number of executions - executionUuidCardinality: { - filter: { - bool: { - ...(dslFilterQuery ? { filter: dslFilterQuery } : {}), - must: [getProviderAndActionFilter('alerting', 'execute')], - }, + executionUuidCardinalityBuckets: { + terms: { + field: EXECUTION_UUID_FIELD, + size: DEFAULT_MAX_BUCKETS_LIMIT, + order: formatSortForTermSort([{ timestamp: { order: 'desc' } }]), }, aggs: { - executionUuidCardinality: { - cardinality: { - field: EXECUTION_UUID_FIELD, + ruleExecution: { + filter: { + bool: { + ...(dslFilterQuery ? { filter: dslFilterQuery } : {}), + must: [getProviderAndActionFilter('alerting', 'execute')], + }, + }, + aggs: { + executeStartTime: { + min: { + field: START_FIELD, + }, + }, }, }, }, }, + // Cardinality aggregation isn't accurate for this use case because we want to limit the cardinality + // to DEFAULT_MAX_BUCKETS_LIMIT. Instead, we sum the buckets and call it a cardinality. + executionUuidCardinality: { + sum_bucket: { + buckets_path: 'executionUuidCardinalityBuckets>ruleExecution._count', + }, + }, executionUuid: { // Bucket by execution UUID terms: { @@ -592,8 +607,31 @@ function formatExecutionKPIAggBuckets(buckets: IExecutionUuidKpiAggBucket[]) { return objToReturn; } +function validTotalHitsLimitationOnExecutionLog(esHitsTotal: estypes.SearchTotalHits) { + if ( + esHitsTotal && + esHitsTotal.relation && + esHitsTotal.value && + esHitsTotal.relation === 'gte' && + esHitsTotal.value === 10000 + ) { + throw Boom.entityTooLarge( + i18n.translate('xpack.alerting.feature.executionLogAggs.limitationQueryMsg', { + defaultMessage: + 'Results are limited to 10,000 documents, refine your search to see others.', + }), + EMPTY_EXECUTION_LOG_RESULT + ); + } +} + export function formatExecutionKPIResult(results: AggregateEventsBySavedObjectResult) { - const { aggregations } = results; + const { aggregations, hits } = results; + + if (hits && hits.total) { + validTotalHitsLimitationOnExecutionLog(hits.total as estypes.SearchTotalHits); + } + if (!aggregations || !aggregations.excludeExecuteStart) { return EMPTY_EXECUTION_KPI_RESULT; } @@ -605,7 +643,11 @@ export function formatExecutionKPIResult(results: AggregateEventsBySavedObjectRe export function formatExecutionLogResult( results: AggregateEventsBySavedObjectResult ): IExecutionLogResult { - const { aggregations } = results; + const { aggregations, hits } = results; + + if (hits && hits.total) { + validTotalHitsLimitationOnExecutionLog(hits.total as estypes.SearchTotalHits); + } if (!aggregations || !aggregations.excludeExecuteStart) { return EMPTY_EXECUTION_LOG_RESULT; @@ -613,7 +655,7 @@ export function formatExecutionLogResult( const aggs = aggregations.excludeExecuteStart as ExcludeExecuteStartAggResult; - const total = aggs.executionUuidCardinality.executionUuidCardinality.value; + const total = aggs.executionUuidCardinality.value; const buckets = aggs.executionUuid.buckets; return { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts index c4419ef8386a57..1d00d74d1ddf8f 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts @@ -339,12 +339,14 @@ const aggregateResults = { ], }, executionUuidCardinality: { - executionUuidCardinality: { - value: 374, - }, + value: 374, }, }, }, + hits: { + total: { value: 875, relation: 'eq' }, + hits: [], + } as estypes.SearchHitsMetadata, }; function getRuleSavedObject(attributes: Partial = {}): SavedObject { 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 b3e36fc8bebb93..4d3186c784447f 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/service_overview/service_overview_instances_table/instance_actions_menu/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/index.tsx index 450c5ec0619719..ff88e61fd5132a 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/index.tsx @@ -19,10 +19,7 @@ import { AllDatasetsLocatorParams, ALL_DATASETS_LOCATOR_ID, } from '@kbn/deeplinks-observability/locators'; -import { - NODE_LOGS_LOCATOR_ID, - NodeLogsLocatorParams, -} from '@kbn/logs-shared-plugin/common'; +import { getLogsLocatorsFromUrlService } from '@kbn/logs-shared-plugin/common'; import { isJavaAgentName } from '../../../../../../common/agent_name'; import { SERVICE_NODE_NAME } from '../../../../../../common/es_fields/apm'; import { useApmPluginContext } from '../../../../../context/apm_plugin/use_apm_plugin_context'; @@ -63,8 +60,7 @@ export function InstanceActionsMenu({ const allDatasetsLocator = share.url.locators.get( ALL_DATASETS_LOCATOR_ID )!; - const nodeLogsLocator = - share.url.locators.get(NODE_LOGS_LOCATOR_ID)!; + const { nodeLogsLocator } = getLogsLocatorsFromUrlService(share.url); if (isPending(status)) { return ( diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts index 8401cc6bbc7442..3f258ea089a15e 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/instance_actions_menu/menu_sections.ts @@ -8,10 +8,10 @@ import { i18n } from '@kbn/i18n'; import { IBasePath } from '@kbn/core/public'; import moment from 'moment'; -import type { LocatorPublic } from '@kbn/share-plugin/public'; import { AllDatasetsLocatorParams } from '@kbn/deeplinks-observability/locators'; +import type { LocatorPublic } from '@kbn/share-plugin/public'; import { NodeLogsLocatorParams } from '@kbn/logs-shared-plugin/common'; -import { getNodeLogsHref } from '../../../../shared/links/observability_logs_link'; +import { findInventoryFields } from '@kbn/metrics-data-access-plugin/common'; import { APIReturnType } from '../../../../../services/rest/create_call_apm_api'; import { getInfraHref } from '../../../../shared/links/infra_link'; import { @@ -58,20 +58,17 @@ export function getMenuSections({ : undefined; const infraMetricsQuery = getInfraMetricsQuery(instanceDetails['@timestamp']); - const podLogsHref = getNodeLogsHref( - 'pod', - podId!, + const podLogsHref = nodeLogsLocator.getRedirectUrl({ + nodeField: findInventoryFields('pod').id, + nodeId: podId!, time, - allDatasetsLocator, - nodeLogsLocator - ); - const containerLogsHref = getNodeLogsHref( - 'container', - containerId!, + }); + + const containerLogsHref = nodeLogsLocator.getRedirectUrl({ + nodeField: findInventoryFields('container').id, + nodeId: containerId!, time, - allDatasetsLocator, - nodeLogsLocator - ); + }); const podActions: Action[] = [ { 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 8926e2155592db..c9d3351ce2ebc0 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 39ad6f4945dff0..35f559d81f9821 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/components/shared/links/observability_logs_link.ts b/x-pack/plugins/apm/public/components/shared/links/observability_logs_link.ts deleted file mode 100644 index 72ae29960942ec..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/links/observability_logs_link.ts +++ /dev/null @@ -1,89 +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 { - LogsLocatorParams, - NodeLogsLocatorParams, -} from '@kbn/logs-shared-plugin/common'; -import { AllDatasetsLocatorParams } from '@kbn/deeplinks-observability/locators'; -import { LocatorPublic } from '@kbn/share-plugin/common'; -import moment from 'moment'; -import { DurationInputObject } from 'moment'; - -type NodeType = 'host' | 'pod' | 'container'; - -const NodeTypeMapping: Record = { - host: 'host.name', - container: 'container.id', - pod: 'kubernetes.pod.uid', -}; - -export const getNodeLogsHref = ( - nodeType: NodeType, - id: string, - time: number | undefined, - allDatasetsLocator: LocatorPublic, - infraNodeLocator?: LocatorPublic -): string => { - if (infraNodeLocator) - return infraNodeLocator?.getRedirectUrl({ - nodeId: id!, - nodeType, - time, - }); - - return allDatasetsLocator.getRedirectUrl({ - query: getNodeQuery(nodeType, id), - ...(time - ? { - timeRange: { - from: getTimeRangeStartFromTime(time), - to: getTimeRangeEndFromTime(time), - }, - } - : {}), - }); -}; - -export const getTraceLogsHref = ( - traceId: string, - time: number | undefined, - allDatasetsLocator: LocatorPublic, - infraLogsLocator: LocatorPublic -): string => { - const query = `trace.id:"${traceId}" OR (not trace.id:* AND "${traceId}")`; - - if (infraLogsLocator) - return infraLogsLocator.getRedirectUrl({ - filter: query, - time, - }); - - return allDatasetsLocator.getRedirectUrl({ - query: { language: 'kuery', query }, - ...(time - ? { - timeRange: { - from: getTimeRangeStartFromTime(time), - to: getTimeRangeEndFromTime(time), - }, - } - : {}), - }); -}; - -const getNodeQuery = (type: NodeType, id: string) => { - return { language: 'kuery', query: `${NodeTypeMapping[type]}: ${id}` }; -}; - -const defaultTimeRangeFromPositionOffset: DurationInputObject = { hours: 1 }; - -const getTimeRangeStartFromTime = (time: number): string => - moment(time).subtract(defaultTimeRangeFromPositionOffset).toISOString(); - -const getTimeRangeEndFromTime = (time: number): string => - moment(time).add(defaultTimeRangeFromPositionOffset).toISOString(); diff --git a/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx b/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx index 41512f00d22b63..88d9e88c5e7baa 100644 --- a/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx @@ -14,7 +14,7 @@ import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_ import { fromQuery, toQuery } from '../links/url_helpers'; // TODO: this should really be imported from EUI -export interface ITableColumn { +export interface ITableColumn { name: ReactNode; actions?: Array>; field?: string; @@ -26,7 +26,7 @@ export interface ITableColumn { render?: (value: any, item: T) => unknown; } -interface Props { +interface Props { items: T[]; columns: Array>; initialPageSize: number; @@ -59,7 +59,7 @@ export type SortFunction = ( sortDirection: 'asc' | 'desc' ) => T[]; -function UnoptimizedManagedTable(props: Props) { +function UnoptimizedManagedTable(props: Props) { const history = useHistory(); const { items, diff --git a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.test.ts b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.test.ts index 7d7a720f27cfcd..dd1cfa389453f4 100644 --- a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.test.ts +++ b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.test.ts @@ -7,11 +7,6 @@ import { createMemoryHistory } from 'history'; import { IBasePath } from '@kbn/core/public'; -import { LocatorPublic } from '@kbn/share-plugin/common'; -import { - LogsLocatorParams, - NodeLogsLocatorParams, -} from '@kbn/logs-shared-plugin/common'; import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; import { getSections } from './sections'; import { @@ -19,7 +14,7 @@ import { ApmRouter, } from '../../routing/apm_route_config'; import { - infraLocatorsMock, + logsLocatorsMock, observabilityLogExplorerLocatorsMock, } from '../../../context/apm_plugin/mock_apm_plugin_context'; @@ -30,11 +25,11 @@ const apmRouter = { } as ApmRouter; const { allDatasetsLocator } = observabilityLogExplorerLocatorsMock; -const { nodeLogsLocator, logsLocator } = infraLocatorsMock; +const { nodeLogsLocator, traceLogsLocator } = logsLocatorsMock; -const expectInfraLocatorsToBeCalled = () => { +const expectLogsLocatorsToBeCalled = () => { expect(nodeLogsLocator.getRedirectUrl).toBeCalledTimes(3); - expect(logsLocator.getRedirectUrl).toBeCalledTimes(1); + expect(traceLogsLocator.getRedirectUrl).toBeCalledTimes(1); }; describe('Transaction action menu', () => { @@ -70,9 +65,7 @@ describe('Transaction action menu', () => { location, apmRouter, allDatasetsLocator, - logsLocator: logsLocator as unknown as LocatorPublic, - nodeLogsLocator: - nodeLogsLocator as unknown as LocatorPublic, + logsLocators: logsLocatorsMock, infraLinksAvailable: false, rangeFrom: 'now-24h', rangeTo: 'now', @@ -121,7 +114,7 @@ describe('Transaction action menu', () => { }, ], ]); - expectInfraLocatorsToBeCalled(); + expectLogsLocatorsToBeCalled(); }); it('shows pod and required sections only', () => { @@ -138,10 +131,8 @@ describe('Transaction action menu', () => { basePath, location, apmRouter, - logsLocator: logsLocator as unknown as LocatorPublic, - nodeLogsLocator: - nodeLogsLocator as unknown as LocatorPublic, allDatasetsLocator, + logsLocators: logsLocatorsMock, infraLinksAvailable: true, rangeFrom: 'now-24h', rangeTo: 'now', @@ -209,7 +200,7 @@ describe('Transaction action menu', () => { }, ], ]); - expectInfraLocatorsToBeCalled(); + expectLogsLocatorsToBeCalled(); }); it('shows host and required sections only', () => { @@ -226,10 +217,8 @@ describe('Transaction action menu', () => { basePath, location, apmRouter, - logsLocator: logsLocator as unknown as LocatorPublic, - nodeLogsLocator: - nodeLogsLocator as unknown as LocatorPublic, allDatasetsLocator, + logsLocators: logsLocatorsMock, infraLinksAvailable: true, rangeFrom: 'now-24h', rangeTo: 'now', @@ -296,6 +285,6 @@ describe('Transaction action menu', () => { }, ], ]); - expectInfraLocatorsToBeCalled(); + expectLogsLocatorsToBeCalled(); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts index 09f742ad1254ec..398a657d067147 100644 --- a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts +++ b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/sections.ts @@ -11,10 +11,8 @@ import { IBasePath } from '@kbn/core/public'; import { isEmpty, pickBy } from 'lodash'; import moment from 'moment'; import url from 'url'; -import { - LogsLocatorParams, - NodeLogsLocatorParams, -} from '@kbn/logs-shared-plugin/common'; +import type { getLogsLocatorsFromUrlService } from '@kbn/logs-shared-plugin/common'; +import { findInventoryFields } from '@kbn/metrics-data-access-plugin/common'; import { LocatorPublic } from '@kbn/share-plugin/common'; import { AllDatasetsLocatorParams } from '@kbn/deeplinks-observability/locators'; import type { ProfilingLocators } from '@kbn/observability-shared-plugin/public'; @@ -27,10 +25,6 @@ import { fromQuery } from '../links/url_helpers'; import { SectionRecord, getNonEmptySections, Action } from './sections_helper'; import { HOST_NAME, TRACE_ID } from '../../../../common/es_fields/apm'; import { ApmRouter } from '../../routing/apm_route_config'; -import { - getNodeLogsHref, - getTraceLogsHref, -} from '../links/observability_logs_link'; function getInfraMetricsQuery(transaction: Transaction) { const timestamp = new Date(transaction['@timestamp']).getTime(); @@ -53,8 +47,7 @@ export const getSections = ({ rangeTo, environment, allDatasetsLocator, - logsLocator, - nodeLogsLocator, + logsLocators, dataViewId, }: { transaction?: Transaction; @@ -67,8 +60,7 @@ export const getSections = ({ rangeTo: string; environment: Environment; allDatasetsLocator: LocatorPublic; - logsLocator: LocatorPublic; - nodeLogsLocator: LocatorPublic; + logsLocators: ReturnType; dataViewId?: string; }) => { if (!transaction) return []; @@ -95,33 +87,26 @@ export const getSections = ({ }); // Logs hrefs - const podLogsHref = getNodeLogsHref( - 'pod', - podId!, + const podLogsHref = logsLocators.nodeLogsLocator.getRedirectUrl({ + nodeField: findInventoryFields('pod').id, + nodeId: podId!, time, - allDatasetsLocator, - nodeLogsLocator - ); - const containerLogsHref = getNodeLogsHref( - 'container', - containerId!, + }); + const containerLogsHref = logsLocators.nodeLogsLocator.getRedirectUrl({ + nodeField: findInventoryFields('container').id, + nodeId: containerId!, time, - allDatasetsLocator, - nodeLogsLocator - ); - const hostLogsHref = getNodeLogsHref( - 'host', - hostName!, + }); + const hostLogsHref = logsLocators.nodeLogsLocator.getRedirectUrl({ + nodeField: findInventoryFields('host').id, + nodeId: hostName!, time, - allDatasetsLocator, - nodeLogsLocator - ); - const traceLogsHref = getTraceLogsHref( - transaction.trace.id!, + }); + + const traceLogsHref = logsLocators.traceLogsLocator.getRedirectUrl({ + traceId: transaction.trace.id!, time, - allDatasetsLocator, - logsLocator - ); + }); const podActions: Action[] = [ { diff --git a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/transaction_action_menu.test.tsx b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/transaction_action_menu.test.tsx index be78efeb870eef..ce9d81a52eb326 100644 --- a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/transaction_action_menu.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/transaction_action_menu.test.tsx @@ -13,13 +13,14 @@ import { License } from '@kbn/licensing-plugin/common/license'; import { LOGS_LOCATOR_ID, NODE_LOGS_LOCATOR_ID, + TRACE_LOGS_LOCATOR_ID, } from '@kbn/logs-shared-plugin/common'; import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; import { ApmPluginContextValue } from '../../../context/apm_plugin/apm_plugin_context'; import { mockApmPluginContextValue, MockApmPluginContextWrapper, - infraLocatorsMock, + logsLocatorsMock, } from '../../../context/apm_plugin/mock_apm_plugin_context'; import { LicenseContext } from '../../../context/license/license_context'; import * as hooks from '../../../hooks/use_fetcher'; @@ -43,11 +44,15 @@ const apmContextMock = { locators: { get: (id: string) => { if (id === LOGS_LOCATOR_ID) { - return infraLocatorsMock.logsLocator; + return logsLocatorsMock.logsLocator; } if (id === NODE_LOGS_LOCATOR_ID) { - return infraLocatorsMock.nodeLogsLocator; + return logsLocatorsMock.nodeLogsLocator; + } + + if (id === TRACE_LOGS_LOCATOR_ID) { + return logsLocatorsMock.traceLogsLocator; } }, }, @@ -102,9 +107,9 @@ const renderTransaction = async (transaction: Record) => { return rendered; }; -const expectInfraLocatorsToBeCalled = () => { - expect(infraLocatorsMock.nodeLogsLocator.getRedirectUrl).toBeCalled(); - expect(infraLocatorsMock.logsLocator.getRedirectUrl).toBeCalled(); +const expectLogsLocatorsToBeCalled = () => { + expect(logsLocatorsMock.nodeLogsLocator.getRedirectUrl).toBeCalled(); + expect(logsLocatorsMock.traceLogsLocator.getRedirectUrl).toBeCalled(); }; let useAdHocApmDataViewSpy: jest.SpyInstance; @@ -144,10 +149,10 @@ describe('TransactionActionMenu ', () => { expect(findByText('View transaction in Discover')).not.toBeNull(); }); - it('should call infra locators getRedirectUrl function', async () => { + it('should call logs locators getRedirectUrl function', async () => { await renderTransaction(Transactions.transactionWithMinimalData); - expectInfraLocatorsToBeCalled(); + expectLogsLocatorsToBeCalled(); }); describe('when there is no pod id', () => { @@ -169,10 +174,10 @@ describe('TransactionActionMenu ', () => { }); describe('when there is a pod id', () => { - it('should call infra locators getRedirectUrl function', async () => { + it('should call logs locators getRedirectUrl function', async () => { await renderTransaction(Transactions.transactionWithKubernetesData); - expectInfraLocatorsToBeCalled(); + expectLogsLocatorsToBeCalled(); }); it('renders the pod metrics link', async () => { @@ -206,11 +211,11 @@ describe('TransactionActionMenu ', () => { }); }); - describe('should call infra locators getRedirectUrl function', () => { + describe('should call logs locators getRedirectUrl function', () => { it('renders the Container logs link', async () => { await renderTransaction(Transactions.transactionWithContainerData); - expectInfraLocatorsToBeCalled(); + expectLogsLocatorsToBeCalled(); }); it('renders the Container metrics link', async () => { @@ -245,10 +250,10 @@ describe('TransactionActionMenu ', () => { }); describe('when there is a hostname', () => { - it('should call infra locators getRedirectUrl function', async () => { + it('should call logs locators getRedirectUrl function', async () => { await renderTransaction(Transactions.transactionWithHostData); - expectInfraLocatorsToBeCalled(); + expectLogsLocatorsToBeCalled(); }); it('renders the Host metrics link', async () => { diff --git a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/transaction_action_menu.tsx b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/transaction_action_menu.tsx index 21cfea70c4e314..fe3cd90222ef43 100644 --- a/x-pack/plugins/apm/public/components/shared/transaction_action_menu/transaction_action_menu.tsx +++ b/x-pack/plugins/apm/public/components/shared/transaction_action_menu/transaction_action_menu.tsx @@ -25,13 +25,8 @@ import { AllDatasetsLocatorParams, ALL_DATASETS_LOCATOR_ID, } from '@kbn/deeplinks-observability/locators'; -import { - LOGS_LOCATOR_ID, - LogsLocatorParams, - NODE_LOGS_LOCATOR_ID, - NodeLogsLocatorParams, -} from '@kbn/logs-shared-plugin/common'; import type { ProfilingLocators } from '@kbn/observability-shared-plugin/public'; +import { getLogsLocatorsFromUrlService } from '@kbn/logs-shared-plugin/common'; import { useAnyOfApmParams } from '../../../hooks/use_apm_params'; import { ApmFeatureFlagName } from '../../../../common/apm_feature_flags'; import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; @@ -144,10 +139,7 @@ function ActionMenuSections({ const allDatasetsLocator = share.url.locators.get( ALL_DATASETS_LOCATOR_ID )!; - const logsLocator = - share.url.locators.get(LOGS_LOCATOR_ID)!; - const nodeLogsLocator = - share.url.locators.get(NODE_LOGS_LOCATOR_ID)!; + const logsLocators = getLogsLocatorsFromUrlService(share.url); const infraLinksAvailable = useApmFeatureFlag( ApmFeatureFlagName.InfraUiAvailable @@ -173,8 +165,7 @@ function ActionMenuSections({ rangeTo, environment, allDatasetsLocator, - logsLocator, - nodeLogsLocator, + logsLocators, dataViewId: dataView?.id, }); diff --git a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx index 61710babd1dacd..f6f45a273de45d 100644 --- a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx @@ -13,6 +13,11 @@ import { merge } from 'lodash'; import { coreMock } from '@kbn/core/public/mocks'; import { UrlService } from '@kbn/share-plugin/common/url_service'; import { createObservabilityRuleTypeRegistryMock } from '@kbn/observability-plugin/public'; +import { + LogsLocatorParams, + NodeLogsLocatorParams, + TraceLogsLocatorParams, +} from '@kbn/logs-shared-plugin/common'; import { UI_SETTINGS } from '@kbn/data-plugin/common'; import { MlLocatorDefinition } from '@kbn/ml-plugin/public'; import { enableComparisonByDefault } from '@kbn/observability-plugin/public'; @@ -131,9 +136,10 @@ export const observabilityLogExplorerLocatorsMock = { singleDatasetLocator: sharePluginMock.createLocator(), }; -export const infraLocatorsMock = { - nodeLogsLocator: sharePluginMock.createLocator(), - logsLocator: sharePluginMock.createLocator(), +export const logsLocatorsMock = { + logsLocator: sharePluginMock.createLocator(), + nodeLogsLocator: sharePluginMock.createLocator(), + traceLogsLocator: sharePluginMock.createLocator(), }; const mockCorePlugins = { diff --git a/x-pack/plugins/apm/public/hooks/use_breakpoints.ts b/x-pack/plugins/apm/public/hooks/use_breakpoints.ts index 9ec8b20bb472d7..5e991cc477762f 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 729a2c16dd65e9..c33ff0881dbe6f 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/kibana.jsonc b/x-pack/plugins/canvas/kibana.jsonc index 7e4d0fcff071db..1f6a3bf5554b45 100644 --- a/x-pack/plugins/canvas/kibana.jsonc +++ b/x-pack/plugins/canvas/kibana.jsonc @@ -38,7 +38,6 @@ "reporting", "spaces", "usageCollection", - "savedObjects", ], "requiredBundles": [ "kibanaReact", diff --git a/x-pack/plugins/cases/public/components/actions/assignees/edit_assignees_flyout.test.tsx b/x-pack/plugins/cases/public/components/actions/assignees/edit_assignees_flyout.test.tsx index 15ab25f9db2238..fec9999cbbcfca 100644 --- a/x-pack/plugins/cases/public/components/actions/assignees/edit_assignees_flyout.test.tsx +++ b/x-pack/plugins/cases/public/components/actions/assignees/edit_assignees_flyout.test.tsx @@ -35,7 +35,7 @@ describe('EditAssigneesFlyout', () => { jest.clearAllMocks(); appMock = createAppMockRenderer(); - useBulkGetUserProfilesMock.mockReturnValue({ data: userProfilesMap }); + useBulkGetUserProfilesMock.mockReturnValue({ data: userProfilesMap, isLoading: false }); useSuggestUserProfilesMock.mockReturnValue({ data: userProfiles, isLoading: false }); }); diff --git a/x-pack/plugins/cases/public/components/actions/tags/edit_tags_flyout.test.tsx b/x-pack/plugins/cases/public/components/actions/tags/edit_tags_flyout.test.tsx index a167d88f1bf62d..d72d89bd909bec 100644 --- a/x-pack/plugins/cases/public/components/actions/tags/edit_tags_flyout.test.tsx +++ b/x-pack/plugins/cases/public/components/actions/tags/edit_tags_flyout.test.tsx @@ -7,18 +7,19 @@ import React from '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 { basicCase } from '../../../containers/mock'; -import { waitForComponentToUpdate } from '../../../common/test_utils'; +import { basicCase, tags } from '../../../containers/mock'; +import { useGetTags } from '../../../containers/use_get_tags'; import { EditTagsFlyout } from './edit_tags_flyout'; -import { waitFor } from '@testing-library/react'; -jest.mock('../../../containers/api'); +jest.mock('../../../containers/use_get_tags'); + +const useGetTagsMock = useGetTags as jest.Mock; -// Failing: See https://github.com/elastic/kibana/issues/174176 -// Failing: See https://github.com/elastic/kibana/issues/174177 -describe.skip('EditTagsFlyout', () => { +describe('EditTagsFlyout', () => { let appMock: AppMockRenderer; /** @@ -32,64 +33,57 @@ describe.skip('EditTagsFlyout', () => { onSaveTags: jest.fn(), }; + useGetTagsMock.mockReturnValue({ isLoading: false, data: tags }); + beforeEach(() => { - appMock = createAppMockRenderer(); jest.clearAllMocks(); + appMock = createAppMockRenderer(); }); it('renders correctly', async () => { - const result = appMock.render(); - - expect(result.getByTestId('cases-edit-tags-flyout')).toBeInTheDocument(); - expect(result.getByTestId('cases-edit-tags-flyout-title')).toBeInTheDocument(); - expect(result.getByTestId('cases-edit-tags-flyout-cancel')).toBeInTheDocument(); - expect(result.getByTestId('cases-edit-tags-flyout-submit')).toBeInTheDocument(); + appMock.render(); - await waitForComponentToUpdate(); + expect(await screen.findByTestId('cases-edit-tags-flyout')).toBeInTheDocument(); + expect(await screen.findByTestId('cases-edit-tags-flyout-title')).toBeInTheDocument(); + expect(await screen.findByTestId('cases-edit-tags-flyout-cancel')).toBeInTheDocument(); + expect(await screen.findByTestId('cases-edit-tags-flyout-submit')).toBeInTheDocument(); }); it('calls onClose when pressing the cancel button', async () => { - const result = appMock.render(); + appMock.render(); - userEvent.click(result.getByTestId('cases-edit-tags-flyout-cancel')); - expect(props.onClose).toHaveBeenCalled(); + userEvent.click(await screen.findByTestId('cases-edit-tags-flyout-cancel')); - await waitForComponentToUpdate(); + await waitFor(() => { + expect(props.onClose).toHaveBeenCalled(); + }); }); it('calls onSaveTags when pressing the save selection button', async () => { - const result = appMock.render(); - - await waitForComponentToUpdate(); + appMock.render(); - await waitFor(() => { - expect(result.getByText('coke')).toBeInTheDocument(); - }); + expect(await screen.findByText('coke')).toBeInTheDocument(); - userEvent.click(result.getByText('coke')); - userEvent.click(result.getByTestId('cases-edit-tags-flyout-submit')); + userEvent.click(await screen.findByText('coke')); + userEvent.click(await screen.findByTestId('cases-edit-tags-flyout-submit')); - expect(props.onSaveTags).toHaveBeenCalledWith({ - selectedItems: ['pepsi'], - unSelectedItems: ['coke'], + await waitFor(() => { + expect(props.onSaveTags).toHaveBeenCalledWith({ + selectedItems: ['pepsi'], + unSelectedItems: ['coke'], + }); }); }); it('shows the case title when selecting one case', async () => { - const result = appMock.render(); - - expect(result.getByText(basicCase.title)).toBeInTheDocument(); + appMock.render(); - await waitForComponentToUpdate(); + expect(await screen.findByText(basicCase.title)).toBeInTheDocument(); }); it('shows the number of total selected cases in the title when selecting multiple cases', async () => { - const result = appMock.render( - - ); - - expect(result.getByText('Selected cases: 2')).toBeInTheDocument(); + appMock.render(); - await waitForComponentToUpdate(); + expect(await screen.findByText('Selected cases: 2')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cases/public/components/create/submit_button.test.tsx b/x-pack/plugins/cases/public/components/create/submit_button.test.tsx index dc6e7f62746e8f..a68b05bde58951 100644 --- a/x-pack/plugins/cases/public/components/create/submit_button.test.tsx +++ b/x-pack/plugins/cases/public/components/create/submit_button.test.tsx @@ -6,81 +6,55 @@ */ import React from 'react'; -import { mount } from 'enzyme'; -import { waitFor } from '@testing-library/react'; +import { waitFor, screen } from '@testing-library/react'; -import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { SubmitCaseButton } from './submit_button'; -import type { FormProps } from './schema'; -import { schema } from './schema'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { FormTestComponent } from '../../common/test_utils'; +import userEvent from '@testing-library/user-event'; -// FLAKY: https://github.com/elastic/kibana/issues/174376 -describe.skip('SubmitCaseButton', () => { +describe('SubmitCaseButton', () => { + let appMockRender: AppMockRenderer; const onSubmit = jest.fn(); - const MockHookWrapperComponent: React.FC = ({ children }) => { - const { form } = useForm({ - defaultValue: { title: 'My title' }, - schema: { - title: schema.title, - }, - onSubmit, - }); - - return
{children}
; - }; - beforeEach(() => { jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); }); - it('it renders', async () => { - const wrapper = mount( - + it('renders', async () => { + appMockRender.render( + - + ); - expect(wrapper.find(`[data-test-subj="create-case-submit"]`).exists()).toBeTruthy(); + expect(await screen.findByTestId('create-case-submit')).toBeInTheDocument(); }); - it('it submits', async () => { - const wrapper = mount( - + it('submits', async () => { + appMockRender.render( + - + ); - wrapper.find(`button[data-test-subj="create-case-submit"]`).first().simulate('click'); - await waitFor(() => expect(onSubmit).toBeCalled()); - }); - it('it disables when submitting', async () => { - const wrapper = mount( - - - - ); + userEvent.click(await screen.findByTestId('create-case-submit')); - wrapper.find(`button[data-test-subj="create-case-submit"]`).first().simulate('click'); - await waitFor(() => - expect( - wrapper.find(`[data-test-subj="create-case-submit"]`).first().prop('isDisabled') - ).toBeTruthy() - ); + await waitFor(() => expect(onSubmit).toBeCalled()); }); - it('it is loading when submitting', async () => { - const wrapper = mount( - + it('disables when submitting', async () => { + appMockRender.render( + - + ); - wrapper.find(`button[data-test-subj="create-case-submit"]`).first().simulate('click'); - await waitFor(() => - expect( - wrapper.find(`[data-test-subj="create-case-submit"]`).first().prop('isLoading') - ).toBeTruthy() - ); + const button = await screen.findByTestId('create-case-submit'); + userEvent.click(button); + + await waitFor(() => expect(button).toBeDisabled()); }); }); 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 636fa61b8d6f0b..e4d6641d6b751f 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 @@ -60,7 +60,16 @@ const defaultProps = { editorRef, }; -describe('EditableMarkdown', () => { +// FLAKY: https://github.com/elastic/kibana/issues/171177 +// FLAKY: https://github.com/elastic/kibana/issues/171178 +// FLAKY: https://github.com/elastic/kibana/issues/171179 +// FLAKY: https://github.com/elastic/kibana/issues/171180 +// FLAKY: https://github.com/elastic/kibana/issues/171181 +// FLAKY: https://github.com/elastic/kibana/issues/171182 +// FLAKY: https://github.com/elastic/kibana/issues/171183 +// FLAKY: https://github.com/elastic/kibana/issues/171184 +// FLAKY: https://github.com/elastic/kibana/issues/171185 +describe.skip('EditableMarkdown', () => { const MockHookWrapperComponent: React.FC<{ testProviderProps?: unknown }> = ({ children, testProviderProps = {}, 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 79636d52572baa..982484f11ed476 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 @@ -17,7 +17,12 @@ import { } from '../../../common/mock'; import { AlertPropertyActions } from './alert_property_actions'; -describe('AlertPropertyActions', () => { +// FLAKY: https://github.com/elastic/kibana/issues/174667 +// FLAKY: https://github.com/elastic/kibana/issues/174668 +// FLAKY: https://github.com/elastic/kibana/issues/174669 +// FLAKY: https://github.com/elastic/kibana/issues/174670 +// FLAKY: https://github.com/elastic/kibana/issues/174671 +describe.skip('AlertPropertyActions', () => { let appMock: AppMockRenderer; const props = { 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 7f082536e03b7a..8cbc69e30039fc 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 @@ -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 { @@ -19,10 +19,6 @@ import { RegisteredAttachmentsPropertyActions } from './registered_attachments_p import { AttachmentActionType } from '../../../client/attachment_framework/types'; // FLAKY: https://github.com/elastic/kibana/issues/174384 -// FLAKY: https://github.com/elastic/kibana/issues/174385 -// FLAKY: https://github.com/elastic/kibana/issues/174386 -// FLAKY: https://github.com/elastic/kibana/issues/174387 -// FLAKY: https://github.com/elastic/kibana/issues/174388 describe.skip('RegisteredAttachmentsPropertyActions', () => { let appMock: AppMockRenderer; @@ -34,82 +30,86 @@ describe.skip('RegisteredAttachmentsPropertyActions', () => { }; 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(await screen.findByTestId('property-actions-user-action-ellipses')); - userEvent.click(result.getByTestId('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-trash')).toBeInTheDocument(); + expect((await screen.findByTestId('property-actions-user-action-group')).children.length).toBe( + 1 + ); + + expect(screen.queryByTestId('property-actions-user-action-trash')).toBeInTheDocument(); }); it('renders the modal info 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-trash')).toBeInTheDocument(); + expect(await screen.findByTestId('property-actions-user-action-trash')).toBeInTheDocument(); - userEvent.click(result.getByTestId('property-actions-user-action-trash')); + userEvent.click(await screen.findByTestId('property-actions-user-action-trash')); - await waitFor(() => { - expect(result.queryByTestId('property-actions-confirm-modal')).toBeInTheDocument(); - }); + expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument(); - expect(result.getByTestId('confirmModalTitleText')).toHaveTextContent('Delete attachment'); - expect(result.getByText('Delete')).toBeInTheDocument(); + expect(await screen.findByTestId('confirmModalTitleText')).toHaveTextContent( + 'Delete attachment' + ); + + expect(await screen.findByText('Delete')).toBeInTheDocument(); }); it('remove attachments 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-trash')).toBeInTheDocument(); + expect(await screen.findByTestId('property-actions-user-action-trash')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('property-actions-user-action-trash')); - userEvent.click(result.getByTestId('property-actions-user-action-trash')); + expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument(); + + userEvent.click(screen.getByText('Delete')); await waitFor(() => { - expect(result.queryByTestId('property-actions-confirm-modal')).toBeInTheDocument(); + expect(props.onDelete).toHaveBeenCalled(); }); - - userEvent.click(result.getByText('Delete')); - 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 not show the property actions when hideDefaultActions is enabled', async () => { - 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(); }); it('renders correctly registered attachments', async () => { @@ -123,16 +123,19 @@ describe.skip('RegisteredAttachmentsPropertyActions', () => { }, ]; - 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(2); - expect(result.queryByTestId('property-actions-user-action-download')).toBeInTheDocument(); + expect((await screen.findByTestId('property-actions-user-action-group')).children.length).toBe( + 2 + ); + + expect(await screen.findByTestId('property-actions-user-action-download')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cases/public/components/user_actions_activity_bar/filter_activity.tsx b/x-pack/plugins/cases/public/components/user_actions_activity_bar/filter_activity.tsx index f3730a24cbad83..b6e3c2639aef5c 100644 --- a/x-pack/plugins/cases/public/components/user_actions_activity_bar/filter_activity.tsx +++ b/x-pack/plugins/cases/public/components/user_actions_activity_bar/filter_activity.tsx @@ -28,7 +28,7 @@ const MyEuiFilterGroup = styled(EuiFilterGroup)` const FilterAllButton = styled(EuiFilterButton)` &, - & .euiFilterButton__textShift { + & .euiFilterButton__text { min-width: 28px; } `; diff --git a/x-pack/plugins/cloud_security_posture/common/types/rules/v4.ts b/x-pack/plugins/cloud_security_posture/common/types/rules/v4.ts index 994e09d9b9c2c5..19711c7e7eb13f 100644 --- a/x-pack/plugins/cloud_security_posture/common/types/rules/v4.ts +++ b/x-pack/plugins/cloud_security_posture/common/types/rules/v4.ts @@ -6,7 +6,6 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; -import { SavedObjectsUpdateResponse } from '@kbn/core-saved-objects-api-server'; import { BenchmarksCisId } from '../latest'; export type { cspBenchmarkRuleMetadataSchema, @@ -159,6 +158,6 @@ export const cspSettingsSchema = schema.object({ export type CspSettings = TypeOf; export interface BulkActionBenchmarkRulesResponse { - newCspSettings: SavedObjectsUpdateResponse; - disabledRules: string[]; + updatedBenchmarkRulesStates: CspBenchmarkRulesStates; + disabledDetectionRules: string[]; } diff --git a/x-pack/plugins/cloud_security_posture/common/utils/rules_states.ts b/x-pack/plugins/cloud_security_posture/common/utils/rules_states.ts new file mode 100644 index 00000000000000..c129b00bb831df --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/common/utils/rules_states.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types'; +import { CspBenchmarkRulesStates } from '../types/latest'; + +export const buildMutedRulesFilter = ( + rulesStates: CspBenchmarkRulesStates +): QueryDslQueryContainer[] => { + const mutedRules = Object.fromEntries( + Object.entries(rulesStates).filter(([key, value]) => value.muted === true) + ); + + const mutedRulesFilterQuery = Object.keys(mutedRules).map((key) => { + const rule = mutedRules[key]; + return { + bool: { + must: [ + { term: { 'rule.benchmark.id': rule.benchmark_id } }, + { term: { 'rule.benchmark.version': rule.benchmark_version } }, + { term: { 'rule.benchmark.rule_number': rule.rule_number } }, + ], + }, + }; + }); + + return mutedRulesFilterQuery; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/utils.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/utils.ts index c715b6a90b4ca9..6628cc7711a827 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/utils.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_data_table/utils.ts @@ -7,7 +7,7 @@ import type { EuiBasicTableProps, Pagination } from '@elastic/eui'; -type TablePagination = NonNullable['pagination']>; +type TablePagination = NonNullable['pagination']>; export const getPaginationTableParams = ( params: TablePagination & Pick, 'pageIndex' | 'pageSize'>, 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 index 2d3f9b1c7605cf..a74abccba1e18b 100644 --- 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 @@ -35,7 +35,7 @@ const getBaseQuery = ({ } }; -type TablePagination = NonNullable['pagination']>; +type TablePagination = NonNullable['pagination']>; export const getPaginationTableParams = ( params: TablePagination & Pick, 'pageIndex' | 'pageSize'>, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_get_benchmark_rules_state_api.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_get_benchmark_rules_state_api.ts new file mode 100644 index 00000000000000..a0c957907c0af6 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_get_benchmark_rules_state_api.ts @@ -0,0 +1,27 @@ +/* + * 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 { CspBenchmarkRulesStates } from '../../../../common/types/latest'; +import { + CSP_GET_BENCHMARK_RULES_STATE_API_CURRENT_VERSION, + CSP_GET_BENCHMARK_RULES_STATE_ROUTE_PATH, +} from '../../../../common/constants'; +import { useKibana } from '../../../common/hooks/use_kibana'; + +const getRuleStatesKey = 'get_rules_state_key'; + +export const useGetCspBenchmarkRulesStatesApi = () => { + const { http } = useKibana().services; + return useQuery( + [getRuleStatesKey], + () => + http.get(CSP_GET_BENCHMARK_RULES_STATE_ROUTE_PATH, { + version: CSP_GET_BENCHMARK_RULES_STATE_API_CURRENT_VERSION, + }) + ); +}; 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 0c0aee860d344c..5584b1eae08a6b 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 @@ -22,6 +22,9 @@ import { } from '../../../../common/constants'; import { MAX_FINDINGS_TO_LOAD } from '../../../common/constants'; import { showErrorToast } from '../../../common/utils/show_error_toast'; +import { useGetCspBenchmarkRulesStatesApi } from './use_get_benchmark_rules_state_api'; +import { CspBenchmarkRulesStates } from '../../../../common/types/latest'; +import { buildMutedRulesFilter } from '../../../../common/utils/rules_states'; interface UseFindingsOptions extends FindingsBaseEsQuery { sort: string[][]; @@ -42,31 +45,40 @@ interface FindingsAggs { count: estypes.AggregationsMultiBucketAggregateBase; } -export const getFindingsQuery = ({ query, sort }: UseFindingsOptions, pageParam: any) => ({ - index: CSP_LATEST_FINDINGS_DATA_VIEW, - sort: getMultiFieldsSort(sort), - size: MAX_FINDINGS_TO_LOAD, - aggs: getFindingsCountAggQuery(), - ignore_unavailable: false, - query: { - ...query, - bool: { - ...query?.bool, - filter: [ - ...(query?.bool?.filter ?? []), - { - range: { - '@timestamp': { - gte: `now-${LATEST_FINDINGS_RETENTION_POLICY}`, - lte: 'now', +export const getFindingsQuery = ( + { query, sort }: UseFindingsOptions, + rulesStates: CspBenchmarkRulesStates, + pageParam: any +) => { + const mutedRulesFilterQuery = buildMutedRulesFilter(rulesStates); + + return { + index: CSP_LATEST_FINDINGS_DATA_VIEW, + sort: getMultiFieldsSort(sort), + size: MAX_FINDINGS_TO_LOAD, + aggs: getFindingsCountAggQuery(), + ignore_unavailable: false, + query: { + ...query, + bool: { + ...query?.bool, + filter: [ + ...(query?.bool?.filter ?? []), + { + range: { + '@timestamp': { + gte: `now-${LATEST_FINDINGS_RETENTION_POLICY}`, + lte: 'now', + }, }, }, - }, - ], + ], + must_not: mutedRulesFilterQuery, + }, }, - }, - ...(pageParam ? { search_after: pageParam } : {}), -}); + ...(pageParam ? { search_after: pageParam } : {}), + }; +}; const getMultiFieldsSort = (sort: string[][]) => { return sort.map(([id, direction]) => { @@ -111,6 +123,8 @@ export const useLatestFindings = (options: UseFindingsOptions) => { data, notifications: { toasts }, } = useKibana().services; + const { data: rulesStates } = useGetCspBenchmarkRulesStatesApi(); + return useInfiniteQuery( ['csp_findings', { params: options }], async ({ pageParam }) => { @@ -118,7 +132,7 @@ export const useLatestFindings = (options: UseFindingsOptions) => { rawResponse: { hits, aggregations }, } = await lastValueFrom( data.search.search({ - params: getFindingsQuery(options, pageParam), + params: getFindingsQuery(options, rulesStates!, pageParam), // ruleStates always exists since it under the `enabled` dependency. }) ); if (!aggregations) throw new Error('expected aggregations to be an defined'); @@ -132,7 +146,7 @@ export const useLatestFindings = (options: UseFindingsOptions) => { }; }, { - enabled: options.enabled, + enabled: options.enabled && !!rulesStates, keepPreviousData: true, onError: (err: Error) => showErrorToast(toasts, err), getNextPageParam: (lastPage) => { 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 9d092de673edf0..7b1f10c406e155 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 @@ -31,6 +31,8 @@ import { } from './constants'; import { useCloudSecurityGrouping } from '../../../components/cloud_security_grouping'; import { getFilters } from '../utils/get_filters'; +import { useGetCspBenchmarkRulesStatesApi } from './use_get_benchmark_rules_state_api'; +import { buildMutedRulesFilter } from '../../../../common/utils/rules_states'; const getTermAggregation = (key: keyof FindingsGroupingAggregation, field: string) => ({ [key]: { @@ -154,6 +156,9 @@ export const useLatestFindingsGrouping = ({ groupStatsRenderer, }); + const { data: rulesStates } = useGetCspBenchmarkRulesStatesApi(); + const mutedRulesFilterQuery = rulesStates ? buildMutedRulesFilter(rulesStates) : []; + const groupingQuery = getGroupingQuery({ additionalFilters: query ? [query] : [], groupByField: selectedGroup, @@ -184,8 +189,16 @@ export const useLatestFindingsGrouping = ({ ], }); + const filteredGroupingQuery = { + ...groupingQuery, + query: { + ...groupingQuery.query, + bool: { ...groupingQuery.query.bool, must_not: mutedRulesFilterQuery }, + }, + }; + const { data, isFetching } = useGroupedFindings({ - query: groupingQuery, + query: filteredGroupingQuery, enabled: !isNoneSelected, }); 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 index 468934d6acee31..2a39550a3c7d4d 100644 --- 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 @@ -46,7 +46,7 @@ export const PageTitleText = ({ title }: { title: React.ReactNode }) => ( ); -export const getExpandColumn = ({ +export const getExpandColumn = ({ onClick, }: { onClick(item: T): void; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_table_panel.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_table_panel.tsx index 79dd2b60f8db2e..8906688efdae86 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_table_panel.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerability_dashboard/vulnerability_table_panel.tsx @@ -23,7 +23,7 @@ import { } from './vulnerability_table_panel.config'; import { ChartPanel } from '../../components/chart_panel'; -export interface VulnerabilityDashboardTableProps { +export interface VulnerabilityDashboardTableProps { tableType: DASHBOARD_TABLE_TYPES; columns: Array>; items: T[]; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/bulk_action.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/bulk_action.ts index b31bc731e05236..a89c37d4397163 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/bulk_action.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/bulk_action.ts @@ -8,7 +8,6 @@ import { transformError } from '@kbn/securitysolution-es-utils'; import { CspBenchmarkRulesBulkActionRequestSchema, - CspBenchmarkRulesStates, cspBenchmarkRulesBulkActionRequestSchema, CspBenchmarkRulesBulkActionResponse, } from '../../../../common/types/rules/v4'; @@ -77,16 +76,13 @@ export const defineBulkActionCspBenchmarkRulesRoute = (router: CspRouter) => cspContext.logger ); - const updatedBenchmarkRules: CspBenchmarkRulesStates = - handlerResponse.newCspSettings.attributes.rules!; - const body: CspBenchmarkRulesBulkActionResponse = { - updated_benchmark_rules: updatedBenchmarkRules, + updated_benchmark_rules: handlerResponse.updatedBenchmarkRulesStates, message: 'The bulk operation has been executed successfully.', }; - if (requestBody.action === 'mute' && handlerResponse.disabledRules) { - body.disabled_detection_rules = handlerResponse.disabledRules; + if (requestBody.action === 'mute' && handlerResponse.disabledDetectionRules) { + body.disabled_detection_rules = handlerResponse.disabledDetectionRules; } return response.ok({ body }); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/utils.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/utils.ts index 5365d476a0e8d0..646fe1814f9a76 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/utils.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/utils.ts @@ -5,10 +5,7 @@ * 2.0. */ -import type { - SavedObjectsClientContract, - SavedObjectsUpdateResponse, -} from '@kbn/core-saved-objects-api-server'; +import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; import type { FindResult, RulesClient } from '@kbn/alerting-plugin/server'; import type { RuleParams } from '@kbn/alerting-plugin/server/application/rule/types'; import type { @@ -105,17 +102,22 @@ export const muteDetectionRules = async ( return disabledDetectionRules; }; -export const updateRulesStates = async ( +export const updateBenchmarkRulesStates = async ( encryptedSoClient: SavedObjectsClientContract, newRulesStates: CspBenchmarkRulesStates -): Promise> => { - return await encryptedSoClient.update( +): Promise => { + if (!Object.keys(newRulesStates).length) { + return {}; + } + + const response = await encryptedSoClient.update( INTERNAL_CSP_SETTINGS_SAVED_OBJECT_TYPE, INTERNAL_CSP_SETTINGS_SAVED_OBJECT_ID, { rules: newRulesStates }, // if there is no saved object yet, insert a new SO { upsert: { rules: newRulesStates } } ); + return response.attributes.rules || {}; }; export const setRulesStates = ( diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/v1.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/v1.ts index a2f5c5c98b72d2..8cdc014f522673 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/v1.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/bulk_action/v1.ts @@ -12,7 +12,7 @@ import { getBenchmarkRules, muteDetectionRules, setRulesStates, - updateRulesStates, + updateBenchmarkRulesStates, } from './utils'; import type { BulkActionBenchmarkRulesResponse, @@ -43,9 +43,12 @@ export const bulkActionBenchmarkRulesHandler = async ( ); const newRulesStates = setRulesStates(rulesKeys, muteStatesMap[action], rulesToUpdate); - const newCspSettings = await updateRulesStates(encryptedSoClient, newRulesStates); + const updatedBenchmarkRulesStates = await updateBenchmarkRulesStates( + encryptedSoClient, + newRulesStates + ); const disabledDetectionRules = action === 'mute' ? await muteDetectionRules(soClient, detectionRulesClient, rulesIds) : []; - return { newCspSettings, disabledRules: disabledDetectionRules }; + return { updatedBenchmarkRulesStates, disabledDetectionRules }; }; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/get_states/v1.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/get_states/v1.ts index bf849be225bc1b..4d28b995cbdaf9 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/get_states/v1.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/get_states/v1.ts @@ -15,6 +15,7 @@ import { INTERNAL_CSP_SETTINGS_SAVED_OBJECT_ID, INTERNAL_CSP_SETTINGS_SAVED_OBJECT_TYPE, } from '../../../../common/constants'; +import { buildMutedRulesFilter } from '../../../../common/utils/rules_states'; export const createCspSettingObject = async (soClient: SavedObjectsClientContract) => { return soClient.create( @@ -48,26 +49,10 @@ export const getCspBenchmarkRulesStatesHandler = async ( } }; -export const buildMutedRulesFilter = async ( - encryptedSoClient: ISavedObjectsRepository +export const getMutedRulesFilterQuery = async ( + encryptedSoClient: ISavedObjectsRepository | SavedObjectsClientContract ): Promise => { const rulesStates = await getCspBenchmarkRulesStatesHandler(encryptedSoClient); - const mutedRules = Object.fromEntries( - Object.entries(rulesStates).filter(([key, value]) => value.muted === true) - ); - - const mutedRulesFilterQuery = Object.keys(mutedRules).map((key) => { - const rule = mutedRules[key]; - return { - bool: { - must: [ - { term: { 'rule.benchmark.id': rule.benchmark_id } }, - { term: { 'rule.benchmark.version': rule.benchmark_version } }, - { term: { 'rule.benchmark.rule_number': rule.rule_number } }, - ], - }, - }; - }); - + const mutedRulesFilterQuery = buildMutedRulesFilter(rulesStates); return mutedRulesFilterQuery; }; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts index 8ba6d0449be6a2..e7ae84b0ceb879 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts @@ -81,6 +81,7 @@ export const defineGetBenchmarksRoute = (router: CspRouter) => const cspBenchmarks = await getBenchmarksV2( esClient, cspContext.soClient, + cspContext.encryptedSavedObjects, cspContext.logger ); return response.ok({ diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/v2.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/v2.ts index 5e0a4c50143654..55e32fdb45a8f0 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/v2.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/v2.ts @@ -7,7 +7,7 @@ import { QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types'; import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import type { Logger } from '@kbn/core/server'; +import type { ElasticsearchClient, Logger } from '@kbn/core/server'; import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; import { CSP_BENCHMARK_RULE_SAVED_OBJECT_TYPE, @@ -18,10 +18,12 @@ import { CspBenchmarkRule, Benchmark } from '../../../common/types/latest'; import { getClusters } from '../compliance_dashboard/get_clusters'; import { getStats } from '../compliance_dashboard/get_stats'; import { getSafePostureTypeRuntimeMapping } from '../../../common/runtime_mappings/get_safe_posture_type_runtime_mapping'; +import { getMutedRulesFilterQuery } from '../benchmark_rules/get_states/v1'; export const getBenchmarksData = async ( soClient: SavedObjectsClientContract, - esClient: any, + encryptedSoClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, logger: Logger ): Promise => { // Returns a list of benchmark based on their Version and Benchmark ID @@ -53,6 +55,7 @@ export const getBenchmarksData = async ( }); const benchmarkAgg: any = benchmarksResponse.aggregations; + const rulesFilter = await getMutedRulesFilterQuery(encryptedSoClient); const { id: pitId } = await esClient.openPointInTime({ index: LATEST_FINDINGS_INDEX_DEFAULT_NS, @@ -78,6 +81,7 @@ export const getBenchmarksData = async ( { term: { 'rule.benchmark.version': benchmarkVersion } }, { term: { safe_posture_type: postureType } }, ], + must_not: rulesFilter, }, }; const benchmarkScore = await getStats(esClient, query, pitId, runtimeMappings, logger); @@ -106,11 +110,12 @@ export const getBenchmarksData = async ( }; export const getBenchmarks = async ( - esClient: any, + esClient: ElasticsearchClient, soClient: SavedObjectsClientContract, + encryptedSoClient: SavedObjectsClientContract, logger: Logger ) => { - const benchmarks = await getBenchmarksData(soClient, esClient, logger); + const benchmarks = await getBenchmarksData(soClient, encryptedSoClient, esClient, logger); const getBenchmarkResponse = { items: benchmarks, }; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts index 614f85dcf93741..4c9294248e1e76 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts @@ -24,6 +24,7 @@ import { CspRouter } from '../../types'; import { getTrends, Trends } from './get_trends'; import { BenchmarkWithoutTrend, getBenchmarks } from './get_benchmarks'; import { toBenchmarkDocFieldKey } from '../../lib/mapping_field_util'; +import { getMutedRulesFilterQuery } from '../benchmark_rules/get_states/v1'; export interface KeyDocCount { key: TKey; @@ -152,6 +153,8 @@ export const defineGetComplianceDashboardRoute = (router: CspRouter) => try { const esClient = cspContext.esClient.asCurrentUser; + const encryptedSoClient = cspContext.encryptedSavedObjects; + const filteredRules = await getMutedRulesFilterQuery(encryptedSoClient); const { id: pitId } = await esClient.openPointInTime({ index: LATEST_FINDINGS_INDEX_DEFAULT_NS, @@ -167,6 +170,7 @@ export const defineGetComplianceDashboardRoute = (router: CspRouter) => const query: QueryDslQueryContainer = { bool: { filter: [{ term: { safe_posture_type: policyTemplate } }], + must_not: filteredRules, }, }; diff --git a/x-pack/plugins/cloud_security_posture/server/tasks/findings_stats_task.ts b/x-pack/plugins/cloud_security_posture/server/tasks/findings_stats_task.ts index a0727ae4344a75..df9c1c73224c28 100644 --- a/x-pack/plugins/cloud_security_posture/server/tasks/findings_stats_task.ts +++ b/x-pack/plugins/cloud_security_posture/server/tasks/findings_stats_task.ts @@ -15,7 +15,7 @@ import { SearchRequest } from '@kbn/data-plugin/common'; import { ElasticsearchClient } from '@kbn/core/server'; import { QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types'; import type { ISavedObjectsRepository, Logger } from '@kbn/core/server'; -import { buildMutedRulesFilter } from '../routes/benchmark_rules/get_states/v1'; +import { getMutedRulesFilterQuery } from '../routes/benchmark_rules/get_states/v1'; import { getSafePostureTypeRuntimeMapping } from '../../common/runtime_mappings/get_safe_posture_type_runtime_mapping'; import { getIdentifierRuntimeMapping } from '../../common/runtime_mappings/get_identifier_runtime_mapping'; import { FindingsStatsTaskResult, ScoreByPolicyTemplateBucket, VulnSeverityAggs } from './types'; @@ -382,7 +382,7 @@ export const aggregateLatestFindings = async ( try { const startAggTime = performance.now(); - const rulesFilter = await buildMutedRulesFilter(encryptedSoClient); + const rulesFilter = await getMutedRulesFilterQuery(encryptedSoClient); const customScoreIndexQueryResult = await esClient.search( getScoreQuery(rulesFilter) diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/document_count_content/total_count_header.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/document_count_content/total_count_header.tsx index e681e6c85efcbe..e69966992dd840 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/document_count_content/total_count_header.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/document_count_content/total_count_header.tsx @@ -26,7 +26,7 @@ export const TotalCountHeader = ({ }: { id?: string; totalCount: number; - label?: string; + label?: React.ReactElement | string; loading?: boolean; approximate?: boolean; }) => { diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx index e873beb97d1644..71da03d5938bb8 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx @@ -51,7 +51,7 @@ const FIELD_NAME = 'fieldName'; export type ItemIdToExpandedRowMap = Record; type DataVisualizerTableItem = FieldVisConfig | FileBasedFieldVisConfig; -interface DataVisualizerTableProps { +interface DataVisualizerTableProps { items: T[]; pageState: DataVisualizerTableState; updatePageState: (update: DataVisualizerTableState) => void; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/use_table_settings.ts b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/use_table_settings.ts index 778aaa3697c7b8..ed0a2752cd33fd 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/use_table_settings.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/use_table_settings.ts @@ -12,13 +12,13 @@ import type { DataVisualizerTableState } from '../../../../../common/types'; const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; -interface UseTableSettingsReturnValue { +interface UseTableSettingsReturnValue { onTableChange: EuiBasicTableProps['onChange']; pagination: Pagination; sorting: { sort: PropertySort }; } -export function useTableSettings( +export function useTableSettings( items: TypeOfItem[], pageState: DataVisualizerTableState, updatePageState: (update: DataVisualizerTableState) => void diff --git a/x-pack/plugins/data_visualizer/public/application/data_drift/charts/data_drift_distribution_chart.tsx b/x-pack/plugins/data_visualizer/public/application/data_drift/charts/data_drift_distribution_chart.tsx index 0541d786c5af8e..2440a669e763b2 100644 --- a/x-pack/plugins/data_visualizer/public/application/data_drift/charts/data_drift_distribution_chart.tsx +++ b/x-pack/plugins/data_visualizer/public/application/data_drift/charts/data_drift_distribution_chart.tsx @@ -13,11 +13,11 @@ import { Position, ScaleType, Settings, - LEGACY_LIGHT_THEME, AreaSeries, CurveType, + PartialTheme, } from '@elastic/charts'; -import React from 'react'; +import React, { useMemo } from 'react'; import { FIELD_FORMAT_IDS } from '@kbn/field-formats-plugin/common'; import { i18n } from '@kbn/i18n'; import { useStorage } from '@kbn/ml-local-storage'; @@ -33,9 +33,9 @@ import { DV_DATA_DRIFT_DISTRIBUTION_CHART_TYPE, } from '../../index_data_visualizer/types/storage'; import { DATA_DRIFT_COMPARISON_CHART_TYPE } from '../../index_data_visualizer/types/data_drift'; -import { useCurrentEuiTheme } from '../../common/hooks/use_current_eui_theme'; +import { useDataVisualizerKibana } from '../../kibana_context'; -const CHART_HEIGHT = 200; +const CHART_HEIGHT = 150; const showAsAreaChartOption = i18n.translate( 'xpack.dataVisualizer.dataDrift.showAsAreaChartOptionLabel', @@ -75,7 +75,9 @@ export const DataDriftDistributionChart = ({ secondaryType: string; domain?: Feature['domain']; }) => { - const euiTheme = useCurrentEuiTheme(); + const { + services: { charts }, + } = useDataVisualizerKibana(); const xAxisFormatter = useFieldFormatter(getFieldFormatType(secondaryType)); const yAxisFormatter = useFieldFormatter(FIELD_FORMAT_IDS.NUMBER); @@ -88,13 +90,23 @@ export const DataDriftDistributionChart = ({ DATA_DRIFT_COMPARISON_CHART_TYPE.AREA ); + const chartBaseTheme = charts.theme.useChartsBaseTheme(); + const chartThemeOverrides = useMemo(() => { + return { + background: { + color: 'transparent', + }, + }; + }, []); + if (!item || item.comparisonDistribution.length === 0) return ; const { featureName, fieldType, comparisonDistribution: data } = item; return ( -
+
- - - - - - {comparisonChartType === DATA_DRIFT_COMPARISON_CHART_TYPE.BAR ? ( - { - const key = identifier.seriesKeys[0]; - return key === COMPARISON_LABEL ? colors.comparisonColor : colors.referenceColor; - }} +
+ + + + - ) : ( - { - const key = identifier.seriesKeys[0]; - return key === COMPARISON_LABEL ? colors.comparisonColor : colors.referenceColor; - }} + - )} - + {comparisonChartType === DATA_DRIFT_COMPARISON_CHART_TYPE.BAR ? ( + { + const key = identifier.seriesKeys[0]; + return key === COMPARISON_LABEL ? colors.comparisonColor : colors.referenceColor; + }} + /> + ) : ( + { + const key = identifier.seriesKeys[0]; + return key === COMPARISON_LABEL ? colors.comparisonColor : colors.referenceColor; + }} + /> + )} + +
); }; diff --git a/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_hint.tsx b/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_hint.tsx new file mode 100644 index 00000000000000..4bb2bcd59d918f --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_hint.tsx @@ -0,0 +1,57 @@ +/* + * 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 { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; + +const ANALYZE_DATA_DRIFT_LABEL = i18n.translate( + 'xpack.dataVisualizer.dataDrift.analyzeDataDriftLabel', + { + defaultMessage: 'Analyze data drift', + } +); + +export const DataDriftPromptHint = ({ + refresh, + canAnalyzeDataDrift, +}: { + refresh: () => void; + canAnalyzeDataDrift: boolean; +}) => { + return ( + +

+ +

+ + + {ANALYZE_DATA_DRIFT_LABEL} + + + } + data-test-subj="dataDriftRunAnalysisEmptyPrompt" + /> + ); +}; diff --git a/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_page.tsx b/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_page.tsx index 2c45fd37a6858d..00555f856f6d6c 100644 --- a/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_page.tsx +++ b/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_page.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useEffect, useState, FC, useMemo } from 'react'; +import React, { useCallback, useEffect, useState, FC, useMemo, useRef } from 'react'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { @@ -17,6 +17,7 @@ import { EuiSpacer, EuiPageHeader, EuiHorizontalRule, + EuiBadge, } from '@elastic/eui'; import type { WindowParameters } from '@kbn/aiops-utils'; @@ -35,6 +36,8 @@ import moment from 'moment'; import { css } from '@emotion/react'; import type { SearchQueryLanguage } from '@kbn/ml-query-utils'; import { i18n } from '@kbn/i18n'; +import { cloneDeep } from 'lodash'; +import type { SingleBrushWindowParameters } from './document_count_chart_single_brush/single_brush'; import type { InitialSettings } from './use_data_drift_result'; import { useDataDriftStateManagerContext } from './use_state_manager'; import { useData } from '../common/hooks/use_data'; @@ -51,7 +54,7 @@ import { DataDriftView } from './data_drift_view'; import { COMPARISON_LABEL, REFERENCE_LABEL } from './constants'; import { SearchPanelContent } from '../index_data_visualizer/components/search_panel/search_bar'; import { useSearch } from '../common/hooks/use_search'; -import { DocumentCountWithDualBrush } from './document_count_with_dual_brush'; +import { DocumentCountWithBrush } from './document_count_with_brush'; const dataViewTitleHeader = css({ minWidth: '300px', @@ -88,8 +91,8 @@ export const PageHeader: FC = () => { ); const hasValidTimeField = useMemo( - () => dataView.timeFieldName !== undefined && dataView.timeFieldName !== '', - [dataView.timeFieldName] + () => dataView && dataView.timeFieldName !== undefined && dataView.timeFieldName !== '', + [dataView] ); return ( @@ -126,16 +129,23 @@ export const PageHeader: FC = () => { ); }; -const getDataDriftDataLabel = (label: string, indexPattern?: string) => - i18n.translate('xpack.dataVisualizer.dataDrift.dataLabel', { - defaultMessage: '{label} data', - values: { label }, - }) + (indexPattern ? `: ${indexPattern}` : ''); - +const getDataDriftDataLabel = (label: string, indexPattern?: string) => ( + <> + {label} + {' ' + + i18n.translate('xpack.dataVisualizer.dataDrift.dataLabel', { + defaultMessage: 'data', + }) + + (indexPattern ? `: ${indexPattern}` : '')} + +); interface Props { initialSettings: InitialSettings; } +const isBarBetween = (start: number, end: number, min: number, max: number) => { + return start >= min && end <= max; +}; export const DataDriftPage: FC = ({ initialSettings }) => { const { services: { data: dataService }, @@ -248,49 +258,92 @@ export const DataDriftPage: FC = ({ initialSettings }) => { const colors = { referenceColor: euiTheme.euiColorVis2, comparisonColor: euiTheme.euiColorVis1, + overlapColor: '#490771', }; - const [windowParameters, setWindowParameters] = useState(); + const [brushRanges, setBrushRanges] = useState(); + + // Ref to keep track of previous values + const brushRangesRef = useRef>({}); + const [initialAnalysisStart, setInitialAnalysisStart] = useState< - number | WindowParameters | undefined + number | SingleBrushWindowParameters | undefined >(); const [isBrushCleared, setIsBrushCleared] = useState(true); - function brushSelectionUpdate(d: WindowParameters, force: boolean) { - if (!isBrushCleared || force) { - setWindowParameters(d); - } - if (force) { - setIsBrushCleared(false); - } - } + const referenceBrushSelectionUpdate = useCallback( + function referenceBrushSelectionUpdate(d: SingleBrushWindowParameters, force: boolean) { + if (!isBrushCleared || force) { + const clone = cloneDeep(brushRangesRef.current); + clone.baselineMin = d.min; + clone.baselineMax = d.max; + brushRangesRef.current = clone; + setBrushRanges(clone as WindowParameters); + } + if (force) { + setIsBrushCleared(false); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [brushRanges, isBrushCleared] + ); + + const comparisonBrushSelectionUpdate = useCallback( + function comparisonBrushSelectionUpdate(d: SingleBrushWindowParameters, force: boolean) { + if (!isBrushCleared || force) { + const clone = cloneDeep(brushRangesRef.current); + clone.deviationMin = d.min; + clone.deviationMax = d.max; + + brushRangesRef.current = clone; + + setBrushRanges(clone as WindowParameters); + } + if (force) { + setIsBrushCleared(false); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [brushRanges, isBrushCleared] + ); function clearSelection() { - setWindowParameters(undefined); + setBrushRanges(undefined); setIsBrushCleared(true); setInitialAnalysisStart(undefined); } const barStyleAccessor = useCallback( (datum: DataSeriesDatum) => { - if (!windowParameters) return null; - - const start = datum.x; - const end = - (typeof datum.x === 'string' ? parseInt(datum.x, 10) : datum.x) + - (documentCountStats?.interval ?? 0); - - if (start >= windowParameters.baselineMin && end <= windowParameters.baselineMax) { - return colors.referenceColor; - } - if (start >= windowParameters.deviationMin && end <= windowParameters.deviationMax) { - return colors.comparisonColor; - } + if (!brushRanges) return null; + + const start = typeof datum.x === 'string' ? parseInt(datum.x, 10) : datum.x; + const end = start + (documentCountStats?.interval ?? 0); + + const isBetweenReference = isBarBetween( + start, + end, + brushRanges.baselineMin, + brushRanges.baselineMax + ); + const isBetweenDeviation = isBarBetween( + start, + end, + brushRanges.deviationMin, + brushRanges.deviationMax + ); + if (isBetweenReference && isBetweenDeviation) return colors.overlapColor; + if (isBetweenReference) return colors.referenceColor; + if (isBetweenDeviation) return colors.comparisonColor; return null; }, // eslint-disable-next-line react-hooks/exhaustive-deps - [JSON.stringify({ windowParameters, colors })] + [JSON.stringify({ brushRanges, colors })] + ); + const hasValidTimeField = useMemo( + () => dataView && dataView.timeFieldName !== undefined && dataView.timeFieldName !== '', + [dataView] ); const referenceIndexPatternLabel = initialSettings?.reference @@ -317,12 +370,12 @@ export const DataDriftPage: FC = ({ initialSettings }) => { - = ({ initialSettings }) => { sampleProbability={sampleProbability} initialAnalysisStart={initialAnalysisStart} barStyleAccessor={barStyleAccessor} - baselineBrush={{ + brush={{ label: REFERENCE_LABEL, annotationStyle: { strokeWidth: 0, @@ -341,24 +394,15 @@ export const DataDriftPage: FC = ({ initialSettings }) => { }, badgeWidth: 80, }} - deviationBrush={{ - label: COMPARISON_LABEL, - annotationStyle: { - strokeWidth: 0, - stroke: colors.comparisonColor, - fill: colors.comparisonColor, - opacity: 0.5, - }, - badgeWidth: 90, - }} stateManager={referenceStateManager} /> - = ({ initialSettings }) => { sampleProbability={documentStatsProd.sampleProbability} initialAnalysisStart={initialAnalysisStart} barStyleAccessor={barStyleAccessor} - baselineBrush={{ - label: REFERENCE_LABEL, - annotationStyle: { - strokeWidth: 0, - stroke: colors.referenceColor, - fill: colors.referenceColor, - opacity: 0.5, - }, - badgeWidth: 80, - }} - deviationBrush={{ + brush={{ label: COMPARISON_LABEL, annotationStyle: { strokeWidth: 0, @@ -398,12 +432,13 @@ export const DataDriftPage: FC = ({ initialSettings }) => { initialSettings={initialSettings} isBrushCleared={isBrushCleared} onReset={clearSelection} - windowParameters={windowParameters} + windowParameters={brushRanges} dataView={dataView} searchString={searchString ?? ''} searchQueryLanguage={searchQueryLanguage} lastRefresh={lastRefresh} onRefresh={forceRefresh} + hasValidTimeField={hasValidTimeField} /> diff --git a/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_view.tsx b/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_view.tsx index dc4193fe8a331c..459710517d8de5 100644 --- a/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_view.tsx +++ b/x-pack/plugins/data_visualizer/public/application/data_drift/data_drift_view.tsx @@ -16,6 +16,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { EuiSwitchEvent } from '@elastic/eui/src/components/form/switch/switch'; import { useTableState } from '@kbn/ml-in-memory-table'; import type { SearchQueryLanguage } from '@kbn/ml-query-utils'; +import { isPopulatedObject } from '@kbn/ml-is-populated-object'; import { kbnTypeToSupportedType } from '../common/util/field_types_utils'; import { getDataComparisonType, @@ -24,6 +25,7 @@ import { } from './use_data_drift_result'; import type { DataDriftField, Feature, TimeRange } from './types'; import { DataDriftOverviewTable } from './data_drift_overview_table'; +import { DataDriftPromptHint } from './data_drift_hint'; const showOnlyDriftedFieldsOptionLabel = i18n.translate( 'xpack.dataVisualizer.dataDrift.showOnlyDriftedFieldsOptionLabel', @@ -41,6 +43,7 @@ interface DataDriftViewProps { lastRefresh: number; onRefresh: () => void; initialSettings: InitialSettings; + hasValidTimeField: boolean; } // Data drift view export const DataDriftView = ({ @@ -53,6 +56,7 @@ export const DataDriftView = ({ lastRefresh, onRefresh, initialSettings, + hasValidTimeField, }: DataDriftViewProps) => { const [showDataComparisonOnly, setShowDataComparisonOnly] = useState(false); @@ -60,6 +64,18 @@ export const DataDriftView = ({ WindowParameters | undefined >(windowParameters); + const canAnalyzeDataDrift = useMemo(() => { + return ( + !hasValidTimeField || + isPopulatedObject(windowParameters, [ + 'baselineMin', + 'baselineMax', + 'deviationMin', + 'deviationMax', + ]) + ); + }, [windowParameters, hasValidTimeField]); + const [fetchInfo, setFetchIno] = useState< | { fields: DataDriftField[]; @@ -153,32 +169,40 @@ export const DataDriftView = ({ const requiresWindowParameters = dataView?.isTimeBased() && windowParameters === undefined; - return requiresWindowParameters ? ( - - - - } - titleSize="xs" - body={ -

- -

- } - data-test-subj="dataDriftNoWindowParametersEmptyPrompt" - /> - ) : ( + const showRunAnalysisHint = result.status === 'not_initiated'; + + if (requiresWindowParameters) { + return ( + + + + } + titleSize="xs" + body={ +

+ +

+ } + data-test-subj="dataDriftNoWindowParametersEmptyPrompt" + /> + ); + } + if (showRunAnalysisHint) { + return ; + } + return (
void; + +/** + * Props for document count chart + */ +export interface DocumentCountChartProps { + id?: string; + /** List of Kibana services that are required as dependencies */ + dependencies: { + data: DataPublicPluginStart; + charts: ChartsPluginStart; + fieldFormats: FieldFormatsStart; + uiSettings: IUiSettingsClient; + }; + /** Optional callback for handling brush selection updates */ + brushSelectionUpdateHandler?: BrushSelectionUpdateHandler; + /** Optional width */ + width?: number; + /** Optional chart height */ + height?: number; + /** Data chart points */ + chartPoints: LogRateHistogramItem[]; + /** Data chart points split */ + chartPointsSplit?: LogRateHistogramItem[]; + /** Start time range for the chart */ + timeRangeEarliest: number; + /** Ending time range for the chart */ + timeRangeLatest: number; + /** Time interval for the document count buckets */ + interval: number; + /** Label to name the adjustedChartPointsSplit histogram */ + chartPointsSplitLabel: string; + /** Whether or not brush has been reset */ + isBrushCleared: boolean; + /** Timestamp for start of initial analysis */ + autoAnalysisStart?: number | SingleBrushWindowParameters; + /** Optional style to override bar chart */ + barStyleAccessor?: BarStyleAccessor; + /** Optional color override for the default bar color for charts */ + barColorOverride?: string; + /** Optional color override for the highlighted bar color for charts */ + barHighlightColorOverride?: string; + /** Optional settings override for the 'brush' brush */ + brush?: BrushSettings; + /** Optional data-test-subject */ + dataTestSubj?: string; +} + +const SPEC_ID = 'document_count'; + +enum VIEW_MODE { + ZOOM = 'zoom', + BRUSH = 'brush', +} + +export interface SingleBrushWindowParameters { + /** Time range minimum value */ + min: number; + /** Time range maximum value */ + max: number; +} + +/** + * Document count chart with draggable brushes to select time ranges + * by default use `Baseline` and `Deviation` for the badge names + * + * @param props DocumentCountChart component props + * @returns The DocumentCountChart component. + */ +export const DocumentCountChartWithBrush: FC = (props) => { + const { + id, + dataTestSubj, + dependencies, + brushSelectionUpdateHandler, + width, + height, + chartPoints, + chartPointsSplit, + timeRangeEarliest, + timeRangeLatest, + interval, + chartPointsSplitLabel, + isBrushCleared, + autoAnalysisStart, + barColorOverride, + barStyleAccessor, + barHighlightColorOverride, + brush = {}, + } = props; + + const { data, uiSettings, fieldFormats, charts } = dependencies; + + const chartBaseTheme = charts.theme.useChartsBaseTheme(); + + const xAxisFormatter = fieldFormats.deserialize({ id: 'date' }); + const useLegacyTimeAxis = uiSettings.get('visualization:useLegacyTimeAxis', false); + + const overallSeriesName = i18n.translate('xpack.dataVisualizer.dataDriftt.seriesLabel', { + defaultMessage: 'document count', + }); + + const overallSeriesNameWithSplit = i18n.translate( + 'xpack.dataVisualizer.dataDrifttSplit.seriesLabel', + { + defaultMessage: 'Other document count', + } + ); + + // TODO Let user choose between ZOOM and BRUSH mode. + const [viewMode] = useState(VIEW_MODE.BRUSH); + + const hasNoData = useMemo( + () => + (chartPoints === undefined || chartPoints.length < 1) && + (chartPointsSplit === undefined || + (Array.isArray(chartPointsSplit) && chartPointsSplit.length < 1)), + [chartPoints, chartPointsSplit] + ); + + const adjustedChartPoints = useMemo(() => { + // Display empty chart when no data in range and no split data to show + if (hasNoData) return [{ time: timeRangeEarliest, value: 0 }]; + + // If chart has only one bucket + // it won't show up correctly unless we add an extra data point + if (chartPoints.length === 1) { + return [ + ...chartPoints, + { time: interval ? Number(chartPoints[0].time) + interval : timeRangeEarliest, value: 0 }, + ]; + } + return chartPoints; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chartPoints, timeRangeEarliest, timeRangeLatest, interval]); + + const adjustedChartPointsSplit = useMemo(() => { + // Display empty chart when no data in range + if (hasNoData) return [{ time: timeRangeEarliest, value: 0 }]; + + // If chart has only one bucket + // it won't show up correctly unless we add an extra data point + if (Array.isArray(chartPointsSplit) && chartPointsSplit.length === 1) { + return [ + ...chartPointsSplit, + { + time: interval ? Number(chartPointsSplit[0].time) + interval : timeRangeEarliest, + value: 0, + }, + ]; + } + return chartPointsSplit; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chartPointsSplit, timeRangeEarliest, timeRangeLatest, interval]); + + const snapTimestamps = useMemo(() => { + const timestamps: number[] = []; + let n = timeRangeEarliest; + + while (n <= timeRangeLatest + interval) { + timestamps.push(n); + n += interval; + } + + return timestamps; + }, [timeRangeEarliest, timeRangeLatest, interval]); + + const timefilterUpdateHandler = useCallback( + (range: TimeFilterRange) => { + data.query.timefilter.timefilter.setTime({ + from: moment(range.from).toISOString(), + to: moment(range.to).toISOString(), + mode: 'absolute', + }); + }, + [data] + ); + + const onBrushEnd = ({ x }: XYBrushEvent) => { + if (!x) { + return; + } + const [from, to] = x; + timefilterUpdateHandler({ from, to }); + }; + + const timeZone = getTimeZone(uiSettings); + + const [originalWindowParameters, setOriginalWindowParameters] = useState< + SingleBrushWindowParameters | undefined + >(); + const [windowParameters, setWindowParameters] = useState< + SingleBrushWindowParameters | undefined + >(); + + const triggerAnalysis = useCallback( + (startRange: number | SingleBrushWindowParameters) => { + if (viewMode === VIEW_MODE.ZOOM && typeof startRange === 'number') { + const range: TimeFilterRange = { + from: startRange, + to: startRange + interval, + }; + + timefilterUpdateHandler(range); + } else if (viewMode === VIEW_MODE.BRUSH) { + if ( + originalWindowParameters === undefined && + windowParameters === undefined && + adjustedChartPoints !== undefined + ) { + const wp = + typeof startRange === 'number' + ? getSingleBrushWindowParameters( + startRange + interval / 2, + timeRangeEarliest, + timeRangeLatest + interval + ) + : startRange; + const wpSnap = getSnappedSingleBrushWindowParameters(wp, snapTimestamps); + setOriginalWindowParameters(wpSnap); + setWindowParameters(wpSnap); + + if (brushSelectionUpdateHandler !== undefined) { + brushSelectionUpdateHandler(wpSnap, true); + } + } + } + }, + [ + interval, + timeRangeEarliest, + timeRangeLatest, + snapTimestamps, + originalWindowParameters, + setWindowParameters, + brushSelectionUpdateHandler, + adjustedChartPoints, + timefilterUpdateHandler, + viewMode, + windowParameters, + ] + ); + + const onElementClick: ElementClickListener = useCallback( + ([elementData]) => { + if (brushSelectionUpdateHandler === undefined) { + return; + } + const startRange = (elementData as XYChartElementEvent)[0].x; + + triggerAnalysis(startRange); + }, + [triggerAnalysis, brushSelectionUpdateHandler] + ); + + useEffect(() => { + if (autoAnalysisStart !== undefined) { + triggerAnalysis(autoAnalysisStart); + } + }, [triggerAnalysis, autoAnalysisStart]); + + useEffect(() => { + if (isBrushCleared && originalWindowParameters !== undefined) { + setOriginalWindowParameters(undefined); + setWindowParameters(undefined); + } + }, [isBrushCleared, originalWindowParameters]); + + function onWindowParametersChange(wp: SingleBrushWindowParameters) { + if (brushSelectionUpdateHandler === undefined) { + return; + } + setWindowParameters(wp); + brushSelectionUpdateHandler(wp, false); + } + + const [mlBrushWidth, setMlBrushWidth] = useState(); + const [mlBrushMarginLeft, setMlBrushMarginLeft] = useState(); + + useEffect(() => { + if (viewMode !== VIEW_MODE.BRUSH) { + setOriginalWindowParameters(undefined); + setWindowParameters(undefined); + } + }, [viewMode]); + + const isBrushVisible = + originalWindowParameters && + windowParameters && + mlBrushMarginLeft && + mlBrushWidth && + mlBrushWidth > 0; + + const barColor = barColorOverride ? [barColorOverride] : undefined; + const barHighlightColor = barHighlightColorOverride ? [barHighlightColorOverride] : ['orange']; + + return ( + <> + {isBrushVisible ? ( +
+
+ +
+
+ ) : ( + + + + )} +
+ + { + setMlBrushMarginLeft(projection.left); + setMlBrushWidth(projection.width); + }} + baseTheme={chartBaseTheme} + debugState={window._echDebugStateFlag ?? false} + showLegend={false} + showLegendExtra={false} + locale={i18n.getLocale()} + /> + + xAxisFormatter.convert(value)} + labelFormat={useLegacyTimeAxis ? undefined : () => ''} + timeAxisLayerCount={useLegacyTimeAxis ? 0 : 2} + style={useLegacyTimeAxis ? {} : MULTILAYER_TIME_AXIS_STYLE} + /> + {adjustedChartPoints?.length && ( + + )} + {adjustedChartPointsSplit?.length && ( + + )} + {windowParameters && ( + <> + + + )} + +
+ + ); +}; diff --git a/x-pack/plugins/data_visualizer/public/application/data_drift/document_count_chart_single_brush/index.tsx b/x-pack/plugins/data_visualizer/public/application/data_drift/document_count_chart_single_brush/index.tsx new file mode 100644 index 00000000000000..6f21d833c22062 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/data_drift/document_count_chart_single_brush/index.tsx @@ -0,0 +1,7 @@ +/* + * 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 { DocumentCountChartWithBrush } from './document_count_chart_singular'; diff --git a/x-pack/plugins/data_visualizer/public/application/data_drift/document_count_chart_single_brush/single_brush.tsx b/x-pack/plugins/data_visualizer/public/application/data_drift/document_count_chart_single_brush/single_brush.tsx new file mode 100644 index 00000000000000..b6b3a32f628f19 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/data_drift/document_count_chart_single_brush/single_brush.tsx @@ -0,0 +1,347 @@ +/* + * 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, { useEffect, useRef, type FC } from 'react'; + +import * as d3Brush from 'd3-brush'; +import * as d3Scale from 'd3-scale'; +import * as d3Selection from 'd3-selection'; +import * as d3Transition from 'd3-transition'; +import { useEuiTheme } from '@elastic/eui'; + +// TODO Consolidate with similar component `DualBrush` in +// x-pack/packages/ml/aiops_components/src/dual_brush/dual_brush.tsx + +const { brush, brushSelection, brushX } = d3Brush; +const { scaleLinear } = d3Scale; +const { select: d3Select } = d3Selection; +// Import fix to apply correct types for the use of d3.select(this).transition() +d3Select.prototype.transition = d3Transition.transition; + +export const getSingleBrushWindowParameters = ( + clickTime: number, + minTime: number, + maxTime: number +): SingleBrushWindowParameters => { + // Workout ideal bounds for the brush when user clicks on the chart + const totalWindow = maxTime - minTime; + + // min brush window + const minDeviationWindow = 10 * 60 * 1000; // 10min + + // work out bounds as done in the original notebooks, + // with the brush window aiming to be a 1/10 + // of the size of the total window and the baseline window + // being 3.5/10 of the total window. + const brushWindow = Math.max(totalWindow / 10, minDeviationWindow); + + const brushMin = clickTime - brushWindow / 2; + const brushMax = clickTime + brushWindow / 2; + + return { + min: Math.round(brushMin), + max: Math.round(brushMax), + }; +}; +export const getSnappedSingleBrushWindowParameters = ( + windowParameters: SingleBrushWindowParameters, + snapTimestamps: number[] +): SingleBrushWindowParameters => { + const snappedBaselineMin = snapTimestamps.reduce((pts, cts) => { + if (Math.abs(cts - windowParameters.min) < Math.abs(pts - windowParameters.min)) { + return cts; + } + return pts; + }, snapTimestamps[0]); + const baselineMaxTimestamps = snapTimestamps.filter((ts) => ts > snappedBaselineMin); + + const snappedBaselineMax = baselineMaxTimestamps.reduce((pts, cts) => { + if (Math.abs(cts - windowParameters.max) < Math.abs(pts - windowParameters.max)) { + return cts; + } + return pts; + }, baselineMaxTimestamps[0]); + + return { + min: snappedBaselineMin, + max: snappedBaselineMax, + }; +}; + +export interface SingleBrushWindowParameters { + /** Time range minimum value */ + min: number; + /** Time range maximum value */ + max: number; +} + +const d3 = { + brush, + brushSelection, + brushX, + scaleLinear, + select: d3Select, + transition: d3Transition, +}; + +const isBrushXSelection = (arg: unknown): arg is [number, number] => { + return ( + Array.isArray(arg) && + arg.length === 2 && + typeof arg[0] === 'number' && + typeof arg[1] === 'number' + ); +}; + +interface SingleBrush { + id: string; + brush: d3Brush.BrushBehavior; + start: number; + end: number; +} + +const BRUSH_HEIGHT = 20; +const BRUSH_MARGIN = 4; +const BRUSH_HANDLE_SIZE = 4; +const BRUSH_HANDLE_ROUNDED_CORNER = 2; + +/** + * Props for the SingleBrush React Component + */ +interface SingleBrushProps { + /** + * Unique id for the brush, as it's possible to have multiple instances + */ + + id?: string; + /** + * Min and max numeric timestamps for the two brushes + */ + windowParameters: SingleBrushWindowParameters; + /** + * Min timestamp for x domain + */ + min: number; + /** + * Max timestamp for x domain + */ + max: number; + /** + * Callback function whenever the brush changes + */ + onChange?: ( + windowParameters: SingleBrushWindowParameters, + windowPxParameters: SingleBrushWindowParameters + ) => void; + /** + * Margin left + */ + marginLeft: number; + /** + * Nearest timestamps to snap to the brushes to + */ + snapTimestamps?: number[]; + /** + * Width + */ + width: number; +} + +/** + * SingleBrush React Component + * Single brush component that overlays the document count chart + * + * @param props SingleBrushProps component props + * @returns The SingleBrush component. + */ +export const SingleBrush: FC = (props) => { + const { + id: brushId = '', + windowParameters, + min, + max, + onChange, + marginLeft, + snapTimestamps, + width, + } = props; + const d3BrushContainer = useRef(null); + const brushes = useRef([]); + + // We need to pass props to refs here because the d3-brush code doesn't consider + // native React prop changes. The brush code does its own check whether these props changed then. + // The initialized brushes might otherwise act on stale data. + const widthRef = useRef(width); + const minRef = useRef(min); + const maxRef = useRef(max); + const snapTimestampsRef = useRef(snapTimestamps); + + const { min: baselineMin, max: baselineMax } = windowParameters; + const { euiTheme } = useEuiTheme(); + const handleFillColor = euiTheme.colors.darkShade; + + useEffect(() => { + if (d3BrushContainer.current && width > 0) { + const gBrushes = d3.select(d3BrushContainer.current); + + function newBrush(id: string, start: number, end: number) { + brushes.current.push({ + id, + brush: d3.brushX().handleSize(BRUSH_HANDLE_SIZE).on('end', brushend), + start, + end, + }); + + function brushend(this: d3Selection.BaseType) { + const currentWidth = widthRef.current; + + const x = d3 + .scaleLinear() + .domain([minRef.current, maxRef.current]) + .rangeRound([0, currentWidth]); + + const px2ts = (px: number) => Math.round(x.invert(px)); + const xMin = x(minRef.current) ?? 0; + const xMax = x(maxRef.current) ?? 0; + const minExtentPx = Math.round((xMax - xMin) / 100); + + const baselineBrush = d3.select(`#data-drift-${brushId}`); + const baselineSelection = d3.brushSelection(baselineBrush.node() as SVGGElement); + + if (!isBrushXSelection(baselineSelection)) { + return; + } + + const newWindowParameters = { + min: px2ts(baselineSelection[0]), + max: px2ts(baselineSelection[1]), + }; + + if (id === `${brushId}` && baselineSelection) { + const newBaselineMax = baselineSelection[1]; + const newBaselineMin = Math.min(baselineSelection[0], newBaselineMax - minExtentPx); + newWindowParameters.min = px2ts(newBaselineMin); + newWindowParameters.max = px2ts(newBaselineMax); + } + + const snappedWindowParameters = snapTimestampsRef.current + ? getSnappedSingleBrushWindowParameters(newWindowParameters, snapTimestampsRef.current) + : newWindowParameters; + + const newBrushPx = { + min: x(snappedWindowParameters.min) ?? 0, + max: x(snappedWindowParameters.max) ?? 0, + }; + + if ( + id === `${brushId}` && + (baselineSelection[0] !== newBrushPx.min || baselineSelection[1] !== newBrushPx.max) + ) { + d3.select(this) + .transition() + .duration(200) + // @ts-expect-error call doesn't allow the brush move function + .call(brushes.current[0].brush.move, [newBrushPx.min, newBrushPx.max]); + } + + brushes.current[0].start = snappedWindowParameters.min; + brushes.current[0].end = snappedWindowParameters.max; + + if (onChange) { + onChange( + { + min: snappedWindowParameters.min, + max: snappedWindowParameters.max, + }, + { min: newBrushPx.min, max: newBrushPx.max } + ); + } + drawBrushes(); + } + } + + function drawBrushes() { + const mlBrushSelection = gBrushes + .selectAll('.brush') + .data(brushes.current, (d) => (d as SingleBrush).id); + + // Set up new brushes + mlBrushSelection + .enter() + .insert('g', '.brush') + .attr('class', 'brush') + .attr('id', (b: SingleBrush) => { + return 'data-drift-' + b.id; + }) + .attr('data-test-subj', (b: SingleBrush) => { + return 'dataDriftBrush-' + b.id.charAt(0).toUpperCase() + b.id.slice(1); + }) + .each((brushObject: SingleBrush, i, n) => { + const x = d3.scaleLinear().domain([min, max]).rangeRound([0, widthRef.current]); + // Ensure brush style is applied + brushObject.brush.extent([ + [0, BRUSH_MARGIN], + [widthRef.current, BRUSH_HEIGHT - BRUSH_MARGIN], + ]); + + brushObject.brush(d3.select(n[i])); + const xStart = x(brushObject.start) ?? 0; + const xEnd = x(brushObject.end) ?? 0; + brushObject.brush.move(d3.select(n[i]), [xStart, xEnd]); + }); + + // disable drag-select to reset a brush's selection + mlBrushSelection + .attr('class', 'brush') + .selectAll('.overlay') + .attr('width', widthRef.current) + .style('pointer-events', 'none'); + + mlBrushSelection + .selectAll('.handle') + .attr('fill', handleFillColor) + .attr('rx', BRUSH_HANDLE_ROUNDED_CORNER) + .attr('ry', BRUSH_HANDLE_ROUNDED_CORNER); + + mlBrushSelection.exit().remove(); + } + + if (brushes.current.length !== 1) { + widthRef.current = width; + newBrush(`${brushId}`, baselineMin, baselineMax); + } + + drawBrushes(); + } + }, [ + min, + max, + width, + baselineMin, + baselineMax, + snapTimestamps, + onChange, + brushId, + handleFillColor, + ]); + + return ( + <> + {width > 0 && ( + + + + )} + + ); +}; diff --git a/x-pack/plugins/data_visualizer/public/application/data_drift/document_count_with_dual_brush.tsx b/x-pack/plugins/data_visualizer/public/application/data_drift/document_count_with_brush.tsx similarity index 87% rename from x-pack/plugins/data_visualizer/public/application/data_drift/document_count_with_dual_brush.tsx rename to x-pack/plugins/data_visualizer/public/application/data_drift/document_count_with_brush.tsx index 210d364ce7aa68..482c1aee7c07d7 100644 --- a/x-pack/plugins/data_visualizer/public/application/data_drift/document_count_with_dual_brush.tsx +++ b/x-pack/plugins/data_visualizer/public/application/data_drift/document_count_with_brush.tsx @@ -5,33 +5,28 @@ * 2.0. */ -import type { WindowParameters, LogRateHistogramItem } from '@kbn/aiops-utils'; +import type { LogRateHistogramItem } from '@kbn/aiops-utils'; import React, { FC } from 'react'; -import { DocumentCountChart } from '@kbn/aiops-components'; import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; -import type { BrushSelectionUpdateHandler, DocumentCountChartProps } from '@kbn/aiops-components'; +import type { BrushSettings } from '@kbn/aiops-components'; import { RandomSampler } from '@kbn/ml-random-sampler-utils'; import type { Filter } from '@kbn/es-query'; import useObservable from 'react-use/lib/useObservable'; import { map } from 'rxjs/operators'; - import { isDefined } from '@kbn/ml-is-defined'; +import type { BarStyleAccessor } from '@elastic/charts'; +import type { SingleBrushWindowParameters } from './document_count_chart_single_brush/single_brush'; import { type DataDriftStateManager, useDataDriftStateManagerContext } from './use_state_manager'; import { useDataVisualizerKibana } from '../kibana_context'; import { type DocumentCountStats } from '../../../common/types/field_stats'; import { TotalCountHeader } from '../common/components/document_count_content/total_count_header'; import { SamplingMenu } from '../common/components/random_sampling_menu/random_sampling_menu'; import { getDataTestSubject } from '../common/util/get_data_test_subject'; -export interface DocumentCountContentProps - extends Omit< - DocumentCountChartProps, - | 'dependencies' - | 'chartPoints' - | 'timeRangeEarliest' - | 'timeRangeLatest' - | 'interval' - | 'chartPointsSplitLabel' - > { +import { DocumentCountChartWithBrush } from './document_count_chart_single_brush'; +import type { BrushSelectionUpdateHandler } from './document_count_chart_single_brush/document_count_chart_singular'; + +export interface DocumentCountContentProps { + brush?: BrushSettings; brushSelectionUpdateHandler?: BrushSelectionUpdateHandler; documentCountStats?: DocumentCountStats; documentCountStatsSplit?: DocumentCountStats; @@ -39,22 +34,24 @@ export interface DocumentCountContentProps isBrushCleared: boolean; totalCount: number; sampleProbability: number; - initialAnalysisStart?: number | WindowParameters; + initialAnalysisStart?: number | SingleBrushWindowParameters; /** Optional color override for the default bar color for charts */ barColorOverride?: string; /** Optional color override for the highlighted bar color for charts */ barHighlightColorOverride?: string; - windowParameters?: WindowParameters; - incomingInitialAnalysisStart?: number | WindowParameters; + incomingInitialAnalysisStart?: number | SingleBrushWindowParameters; randomSampler: RandomSampler; reload: () => void; approximate: boolean; stateManager: DataDriftStateManager; - label?: string; + label?: React.ReactElement | string; + /** Optional unique id */ id?: string; + /** Optional style to override bar chart */ + barStyleAccessor?: BarStyleAccessor; } -export const DocumentCountWithDualBrush: FC = ({ +export const DocumentCountWithBrush: FC = ({ id, randomSampler, reload, @@ -68,7 +65,6 @@ export const DocumentCountWithDualBrush: FC = ({ initialAnalysisStart, barColorOverride, barHighlightColorOverride, - windowParameters, incomingInitialAnalysisStart, stateManager, label, @@ -194,7 +190,8 @@ export const DocumentCountWithDualBrush: FC = ({ {documentCountStats.interval !== undefined && ( - path: KNOWLEDGE_BASE, query: { resource }, }); + +export const getGetCapabilitiesRequest = () => + requestMock.create({ + method: 'get', + path: CAPABILITIES, + }); + +export const getPostEvaluateRequest = ({ + body, + query, +}: { + body: PostEvaluateBodyInputs; + query: PostEvaluatePathQueryInputs; +}) => + requestMock.create({ + body, + method: 'post', + path: EVALUATE, + query, + }); diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts index 750c13debb3fd5..3273cbf50b83d2 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts @@ -24,6 +24,7 @@ export const createMockClients = () => { clusterClient: core.elasticsearch.client, elasticAssistant: { actions: actionsClientMock.create(), + getRegisteredFeatures: jest.fn(), getRegisteredTools: jest.fn(), logger: loggingSystemMock.createLogger(), telemetry: coreMock.createSetup().analytics, @@ -74,6 +75,7 @@ const createElasticAssistantRequestContextMock = ( ): jest.Mocked => { return { actions: clients.elasticAssistant.actions as unknown as ActionsPluginStart, + getRegisteredFeatures: jest.fn(), getRegisteredTools: jest.fn(), logger: clients.elasticAssistant.logger, telemetry: clients.elasticAssistant.telemetry, diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/server.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/server.ts index 7ac44e1beedf1d..f08e66d1b5e80f 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/server.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/server.ts @@ -5,35 +5,59 @@ * 2.0. */ import { httpServiceMock } from '@kbn/core/server/mocks'; -import type { RequestHandler, RouteConfig, KibanaRequest } from '@kbn/core/server'; -import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server'; +import type { + RequestHandler, + RouteConfig, + KibanaRequest, + RequestHandlerContext, +} from '@kbn/core/server'; import { requestMock } from './request'; import { responseMock as responseFactoryMock } from './response'; import { requestContextMock } from './request_context'; import { responseAdapter } from './test_adapters'; +import type { RegisteredVersionedRoute } from '@kbn/core-http-router-server-mocks'; interface Route { - config: RouteConfig; + validate: RouteConfig< + unknown, + unknown, + unknown, + 'get' | 'post' | 'delete' | 'patch' | 'put' + >['validate']; handler: RequestHandler; } -const getRoute = (routerMock: MockServer['router']): Route => { - const routeCalls = [ - ...routerMock.get.mock.calls, - ...routerMock.post.mock.calls, - ...routerMock.put.mock.calls, - ...routerMock.patch.mock.calls, - ...routerMock.delete.mock.calls, - ]; - - const [route] = routeCalls; - if (!route) { - throw new Error('No route registered!'); +const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete'] as const; + +const getClassicRoute = (routerMock: MockServer['router']): Route | undefined => { + const method = HTTP_METHODS.find((m) => routerMock[m].mock.calls.length > 0); + if (!method) { + return undefined; } - const [config, handler] = route; - return { config, handler }; + const [config, handler] = routerMock[method].mock.calls[0]; + return { validate: config.validate, handler }; +}; + +const getVersionedRoute = (router: MockServer['router']): Route => { + const method = HTTP_METHODS.find((m) => router.versioned[m].mock.calls.length > 0); + if (!method) { + throw new Error('No route registered!'); + } + const config = router.versioned[method].mock.calls[0][0]; + const routePath = config.path; + + const route: RegisteredVersionedRoute = router.versioned.getRoute(method, routePath); + const firstVersion = Object.values(route.versions)[0]; + + return { + validate: + firstVersion.config.validate === false + ? false + : firstVersion.config.validate.request || false, + handler: firstVersion.handler, + }; }; const buildResultMock = () => ({ ok: jest.fn((x) => x), badRequest: jest.fn((x) => x) }); @@ -63,7 +87,7 @@ class MockServer { } private getRoute(): Route { - return getRoute(this.router); + return getClassicRoute(this.router) ?? getVersionedRoute(this.router); } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -72,7 +96,7 @@ class MockServer { } private validateRequest(request: KibanaRequest): KibanaRequest { - const validations = this.getRoute().config.validate; + const validations = this.getRoute().validate; if (!validations) { return request; } @@ -88,6 +112,7 @@ class MockServer { return validatedRequest; } } + const createMockServer = () => new MockServer(); export const serverMock = { diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/test_adapters.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/test_adapters.ts index a3fde9d64212f5..7b163138c3c2c8 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/test_adapters.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/test_adapters.ts @@ -43,6 +43,8 @@ const buildResponses = (method: Method, calls: MockCall[]): ResponseCall[] => { status: call.statusCode, body: call.body, })); + case 'notFound': + return calls.map(() => ({ status: 404, body: undefined })); default: throw new Error(`Encountered unexpected call to response.${method}`); } diff --git a/x-pack/plugins/elastic_assistant/server/plugin.ts b/x-pack/plugins/elastic_assistant/server/plugin.ts index f142df46beb8b1..bbc2c63381fc92 100755 --- a/x-pack/plugins/elastic_assistant/server/plugin.ts +++ b/x-pack/plugins/elastic_assistant/server/plugin.ts @@ -18,6 +18,7 @@ import { } from '@kbn/core/server'; import { once } from 'lodash'; +import { AssistantFeatures } from '@kbn/elastic-assistant-common'; import { events } from './lib/telemetry/event_based_telemetry'; import { AssistantTool, @@ -36,11 +37,17 @@ import { postEvaluateRoute, postKnowledgeBaseRoute, } from './routes'; -import { appContextService, GetRegisteredTools } from './services/app_context'; +import { + appContextService, + GetRegisteredFeatures, + GetRegisteredTools, +} from './services/app_context'; +import { getCapabilitiesRoute } from './routes/capabilities/get_capabilities_route'; interface CreateRouteHandlerContextParams { core: CoreSetup; logger: Logger; + getRegisteredFeatures: GetRegisteredFeatures; getRegisteredTools: GetRegisteredTools; telemetry: AnalyticsServiceSetup; } @@ -63,6 +70,7 @@ export class ElasticAssistantPlugin private createRouteHandlerContext = ({ core, logger, + getRegisteredFeatures, getRegisteredTools, telemetry, }: CreateRouteHandlerContextParams): IContextProvider< @@ -74,6 +82,7 @@ export class ElasticAssistantPlugin return { actions: pluginsStart.actions, + getRegisteredFeatures, getRegisteredTools, logger, telemetry, @@ -89,6 +98,9 @@ export class ElasticAssistantPlugin this.createRouteHandlerContext({ core: core as CoreSetup, logger: this.logger, + getRegisteredFeatures: (pluginName: string) => { + return appContextService.getRegisteredFeatures(pluginName); + }, getRegisteredTools: (pluginName: string) => { return appContextService.getRegisteredTools(pluginName); }, @@ -112,40 +124,37 @@ export class ElasticAssistantPlugin postActionsConnectorExecuteRoute(router, getElserId); // Evaluate postEvaluateRoute(router, getElserId); + // Capabilities + getCapabilitiesRoute(router); return { actions: plugins.actions, + getRegisteredFeatures: (pluginName: string) => { + return appContextService.getRegisteredFeatures(pluginName); + }, getRegisteredTools: (pluginName: string) => { return appContextService.getRegisteredTools(pluginName); }, }; } - public start(core: CoreStart, plugins: ElasticAssistantPluginStartDependencies) { + public start( + core: CoreStart, + plugins: ElasticAssistantPluginStartDependencies + ): ElasticAssistantPluginStart { this.logger.debug('elasticAssistant: Started'); appContextService.start({ logger: this.logger }); return { - /** - * Actions plugin start contract - */ actions: plugins.actions, - - /** - * Get the registered tools for a given plugin name. - * @param pluginName - */ + getRegisteredFeatures: (pluginName: string) => { + return appContextService.getRegisteredFeatures(pluginName); + }, getRegisteredTools: (pluginName: string) => { return appContextService.getRegisteredTools(pluginName); }, - - /** - * Register tools to be used by the Elastic Assistant for a given plugin. Use the plugin name that - * corresponds to your application as defined in the `x-kbn-context` header of requests made from your - * application. - * - * @param pluginName - * @param tools - */ + registerFeatures: (pluginName: string, features: Partial) => { + return appContextService.registerFeatures(pluginName, features); + }, registerTools: (pluginName: string, tools: AssistantTool[]) => { return appContextService.registerTools(pluginName, tools); }, diff --git a/x-pack/plugins/elastic_assistant/server/routes/capabilities/get_capabilities_route.test.ts b/x-pack/plugins/elastic_assistant/server/routes/capabilities/get_capabilities_route.test.ts new file mode 100644 index 00000000000000..b0437bbcb7209f --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/capabilities/get_capabilities_route.test.ts @@ -0,0 +1,47 @@ +/* + * 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 { getCapabilitiesRoute } from './get_capabilities_route'; +import { serverMock } from '../../__mocks__/server'; +import { requestContextMock } from '../../__mocks__/request_context'; +import { getGetCapabilitiesRequest } from '../../__mocks__/request'; +import { getPluginNameFromRequest } from '../helpers'; + +jest.mock('../helpers'); + +describe('Get Capabilities Route', () => { + let server: ReturnType; + let { context } = requestContextMock.createTools(); + + beforeEach(() => { + server = serverMock.create(); + ({ context } = requestContextMock.createTools()); + + getCapabilitiesRoute(server.router); + }); + + describe('Status codes', () => { + it('returns 200 with capabilities', async () => { + const response = await server.inject( + getGetCapabilitiesRequest(), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(200); + }); + + it('returns 500 if an error is thrown in fetching capabilities', async () => { + (getPluginNameFromRequest as jest.Mock).mockImplementation(() => { + throw new Error('Mocked error'); + }); + const response = await server.inject( + getGetCapabilitiesRequest(), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(500); + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/capabilities/get_capabilities_route.ts b/x-pack/plugins/elastic_assistant/server/routes/capabilities/get_capabilities_route.ts new file mode 100644 index 00000000000000..46fc486b82a485 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/capabilities/get_capabilities_route.ts @@ -0,0 +1,60 @@ +/* + * 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 { IKibanaResponse, IRouter } from '@kbn/core/server'; +import { transformError } from '@kbn/securitysolution-es-utils'; + +import { CAPABILITIES } from '../../../common/constants'; +import { ElasticAssistantRequestHandlerContext } from '../../types'; + +import { GetCapabilitiesResponse } from '../../schemas/capabilities/get_capabilities_route.gen'; +import { buildResponse } from '../../lib/build_response'; +import { DEFAULT_PLUGIN_NAME, getPluginNameFromRequest } from '../helpers'; + +/** + * Get the assistant capabilities for the requesting plugin + * + * @param router IRouter for registering routes + */ +export const getCapabilitiesRoute = (router: IRouter) => { + router.versioned + .get({ + access: 'internal', + path: CAPABILITIES, + options: { + tags: ['access:elasticAssistant'], + }, + }) + .addVersion( + { + version: '1', + validate: {}, + }, + async (context, request, response): Promise> => { + const resp = buildResponse(response); + const assistantContext = await context.elasticAssistant; + const logger = assistantContext.logger; + + try { + const pluginName = getPluginNameFromRequest({ + request, + defaultPluginName: DEFAULT_PLUGIN_NAME, + logger, + }); + const registeredFeatures = assistantContext.getRegisteredFeatures(pluginName); + + return response.ok({ body: registeredFeatures }); + } catch (err) { + const error = transformError(err); + return resp.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.test.ts b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.test.ts new file mode 100644 index 00000000000000..3ae64f1d89f3b3 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.test.ts @@ -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 { postEvaluateRoute } from './post_evaluate'; +import { serverMock } from '../../__mocks__/server'; +import { requestContextMock } from '../../__mocks__/request_context'; +import { getPostEvaluateRequest } from '../../__mocks__/request'; +import { + PostEvaluateBodyInputs, + PostEvaluatePathQueryInputs, +} from '../../schemas/evaluate/post_evaluate'; + +const defaultBody: PostEvaluateBodyInputs = { + dataset: undefined, + evalPrompt: undefined, +}; + +const defaultQueryParams: PostEvaluatePathQueryInputs = { + agents: 'agents', + datasetName: undefined, + evaluationType: undefined, + evalModel: undefined, + models: 'models', + outputIndex: '.kibana-elastic-ai-assistant-', + projectName: undefined, + runName: undefined, +}; + +describe('Post Evaluate Route', () => { + let server: ReturnType; + let { context } = requestContextMock.createTools(); + const mockGetElser = jest.fn().mockResolvedValue('.elser_model_2'); + + beforeEach(() => { + server = serverMock.create(); + ({ context } = requestContextMock.createTools()); + + postEvaluateRoute(server.router, mockGetElser); + }); + + describe('Capabilities', () => { + it('returns a 404 if evaluate feature is not registered', async () => { + context.elasticAssistant.getRegisteredFeatures.mockReturnValueOnce({ + assistantModelEvaluation: false, + assistantStreamingEnabled: false, + }); + + const response = await server.inject( + getPostEvaluateRequest({ body: defaultBody, query: defaultQueryParams }), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(404); + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts index ff3291f6b703f9..aa041175b75ee0 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts @@ -29,6 +29,7 @@ import { } from '../../lib/model_evaluator/output_index/utils'; import { fetchLangSmithDataset, getConnectorName, getLangSmithTracer, getLlmType } from './utils'; import { RequestBody } from '../../lib/langchain/types'; +import { DEFAULT_PLUGIN_NAME, getPluginNameFromRequest } from '../helpers'; /** * To support additional Agent Executors from the UI, add them to this map @@ -53,11 +54,22 @@ export const postEvaluateRoute = ( query: buildRouteValidation(PostEvaluatePathQuery), }, }, - // TODO: Limit route based on experimental feature async (context, request, response) => { const assistantContext = await context.elasticAssistant; const logger = assistantContext.logger; const telemetry = assistantContext.telemetry; + + // Validate evaluation feature is enabled + const pluginName = getPluginNameFromRequest({ + request, + defaultPluginName: DEFAULT_PLUGIN_NAME, + logger, + }); + const registeredFeatures = assistantContext.getRegisteredFeatures(pluginName); + if (!registeredFeatures.assistantModelEvaluation) { + return response.notFound(); + } + try { const evaluationId = uuidv4(); const { diff --git a/x-pack/plugins/elastic_assistant/server/routes/helpers.ts b/x-pack/plugins/elastic_assistant/server/routes/helpers.ts index 99d4493c16cca0..a418827c4829d2 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/helpers.ts @@ -7,10 +7,9 @@ import { KibanaRequest } from '@kbn/core-http-server'; import { Logger } from '@kbn/core/server'; -import { RequestBody } from '../lib/langchain/types'; interface GetPluginNameFromRequestParams { - request: KibanaRequest; + request: KibanaRequest; defaultPluginName: string; logger?: Logger; } diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.ts index 6cc683fd4d8b83..c61887a436267d 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/get_knowledge_base_status.ts @@ -22,6 +22,7 @@ import { ESQL_DOCS_LOADED_QUERY, ESQL_RESOURCE, KNOWLEDGE_BASE_INDEX_PATTERN } f * Get the status of the Knowledge Base index, pipeline, and resources (collection of documents) * * @param router IRouter for registering routes + * @param getElser Function to get the default Elser ID */ export const getKnowledgeBaseStatusRoute = ( router: IRouter, diff --git a/x-pack/plugins/elastic_assistant/server/schemas/capabilities/get_capabilities_route.gen.ts b/x-pack/plugins/elastic_assistant/server/schemas/capabilities/get_capabilities_route.gen.ts new file mode 100644 index 00000000000000..609d83fb0b9313 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/schemas/capabilities/get_capabilities_route.gen.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + */ + +export type GetCapabilitiesResponse = z.infer; +export const GetCapabilitiesResponse = z.object({ + assistantModelEvaluation: z.boolean(), + assistantStreamingEnabled: z.boolean(), +}); diff --git a/x-pack/plugins/elastic_assistant/server/schemas/capabilities/get_capabilities_route.schema.yaml b/x-pack/plugins/elastic_assistant/server/schemas/capabilities/get_capabilities_route.schema.yaml new file mode 100644 index 00000000000000..6278d83411d106 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/schemas/capabilities/get_capabilities_route.schema.yaml @@ -0,0 +1,41 @@ +openapi: 3.0.0 +info: + title: Get Capabilities API endpoint + version: '1' +paths: + /internal/elastic_assistant/capabilities: + get: + operationId: GetCapabilities + x-codegen-enabled: true + description: Get Elastic Assistant capabilities for the requesting plugin + summary: Get Elastic Assistant capabilities + tags: + - Capabilities API + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + assistantModelEvaluation: + type: boolean + assistantStreamingEnabled: + type: boolean + required: + - assistantModelEvaluation + - assistantStreamingEnabled + '400': + description: Generic Error + content: + application/json: + schema: + type: object + properties: + statusCode: + type: number + error: + type: string + message: + type: string diff --git a/x-pack/plugins/elastic_assistant/server/schemas/evaluate/post_evaluate.ts b/x-pack/plugins/elastic_assistant/server/schemas/evaluate/post_evaluate.ts index c9c0ee1f00e519..f520bf9bf93b6b 100644 --- a/x-pack/plugins/elastic_assistant/server/schemas/evaluate/post_evaluate.ts +++ b/x-pack/plugins/elastic_assistant/server/schemas/evaluate/post_evaluate.ts @@ -35,6 +35,8 @@ export const PostEvaluatePathQuery = t.type({ runName: t.union([t.string, t.undefined]), }); +export type PostEvaluatePathQueryInputs = t.TypeOf; + export type DatasetItem = t.TypeOf; export const DatasetItem = t.type({ id: t.union([t.string, t.undefined]), diff --git a/x-pack/plugins/elastic_assistant/server/services/app_context.test.ts b/x-pack/plugins/elastic_assistant/server/services/app_context.test.ts index 621995d3452be5..9c9c7ea0dd2fb1 100644 --- a/x-pack/plugins/elastic_assistant/server/services/app_context.test.ts +++ b/x-pack/plugins/elastic_assistant/server/services/app_context.test.ts @@ -8,6 +8,7 @@ import { appContextService, ElasticAssistantAppContext } from './app_context'; import { loggerMock } from '@kbn/logging-mocks'; import { AssistantTool } from '../types'; +import { AssistantFeatures, defaultAssistantFeatures } from '@kbn/elastic-assistant-common'; // Mock Logger const mockLogger = loggerMock.create(); @@ -48,6 +49,19 @@ describe('AppContextService', () => { expect(appContextService.getRegisteredTools('super').length).toBe(0); }); + + it('should return default registered features when stopped ', () => { + appContextService.start(mockAppContext); + appContextService.registerFeatures('super', { + assistantModelEvaluation: true, + assistantStreamingEnabled: true, + }); + appContextService.stop(); + + expect(appContextService.getRegisteredFeatures('super')).toEqual( + expect.objectContaining(defaultAssistantFeatures) + ); + }); }); describe('registering tools', () => { @@ -84,4 +98,81 @@ describe('AppContextService', () => { expect(appContextService.getRegisteredTools(pluginName).length).toEqual(1); }); }); + + describe('registering features', () => { + it('should register and get features for a single plugin', () => { + const pluginName = 'pluginName'; + const features: AssistantFeatures = { + assistantModelEvaluation: true, + assistantStreamingEnabled: true, + }; + + appContextService.start(mockAppContext); + appContextService.registerFeatures(pluginName, features); + + // Check if getRegisteredFeatures returns the correct tools + const retrievedFeatures = appContextService.getRegisteredFeatures(pluginName); + expect(retrievedFeatures).toEqual(features); + }); + + it('should register and get features for multiple plugins', () => { + const pluginOne = 'plugin1'; + const featuresOne: AssistantFeatures = { + assistantModelEvaluation: true, + assistantStreamingEnabled: false, + }; + const pluginTwo = 'plugin2'; + const featuresTwo: AssistantFeatures = { + assistantModelEvaluation: false, + assistantStreamingEnabled: true, + }; + + appContextService.start(mockAppContext); + appContextService.registerFeatures(pluginOne, featuresOne); + appContextService.registerFeatures(pluginTwo, featuresTwo); + + expect(appContextService.getRegisteredFeatures(pluginOne)).toEqual(featuresOne); + expect(appContextService.getRegisteredFeatures(pluginTwo)).toEqual(featuresTwo); + }); + + it('should update features if registered again', () => { + const pluginName = 'pluginName'; + const featuresOne: AssistantFeatures = { + assistantModelEvaluation: true, + assistantStreamingEnabled: false, + }; + const featuresTwo: AssistantFeatures = { + assistantModelEvaluation: false, + assistantStreamingEnabled: true, + }; + + appContextService.start(mockAppContext); + appContextService.registerFeatures(pluginName, featuresOne); + appContextService.registerFeatures(pluginName, featuresTwo); + + expect(appContextService.getRegisteredFeatures(pluginName)).toEqual(featuresTwo); + }); + + it('should return default features if pluginName not present', () => { + appContextService.start(mockAppContext); + + expect(appContextService.getRegisteredFeatures('super')).toEqual( + expect.objectContaining(defaultAssistantFeatures) + ); + }); + + it('allows registering a subset of all available features', () => { + const pluginName = 'pluginName'; + const featuresSubset: Partial = { + assistantModelEvaluation: true, + }; + + appContextService.start(mockAppContext); + appContextService.registerFeatures(pluginName, featuresSubset); + + expect(appContextService.getRegisteredFeatures(pluginName)).toEqual( + expect.objectContaining({ ...defaultAssistantFeatures, ...featuresSubset }) + ); + }); + }); }); diff --git a/x-pack/plugins/elastic_assistant/server/services/app_context.ts b/x-pack/plugins/elastic_assistant/server/services/app_context.ts index bd7a7c0cc3203b..cb425540635d98 100644 --- a/x-pack/plugins/elastic_assistant/server/services/app_context.ts +++ b/x-pack/plugins/elastic_assistant/server/services/app_context.ts @@ -6,11 +6,14 @@ */ import type { Logger } from '@kbn/core/server'; -import type { AssistantTool } from '../types'; +import { defaultAssistantFeatures, AssistantFeatures } from '@kbn/elastic-assistant-common'; +import { AssistantTool } from '../types'; export type PluginName = string; export type RegisteredToolsStorage = Map>; +export type RegisteredFeaturesStorage = Map; export type GetRegisteredTools = (pluginName: string) => AssistantTool[]; +export type GetRegisteredFeatures = (pluginName: string) => AssistantFeatures; export interface ElasticAssistantAppContext { logger: Logger; } @@ -23,6 +26,7 @@ export interface ElasticAssistantAppContext { class AppContextService { private logger: Logger | undefined; private registeredTools: RegisteredToolsStorage = new Map>(); + private registeredFeatures: RegisteredFeaturesStorage = new Map(); public start(appContext: ElasticAssistantAppContext) { this.logger = appContext.logger; @@ -30,6 +34,7 @@ class AppContextService { public stop() { this.registeredTools.clear(); + this.registeredFeatures.clear(); } /** @@ -44,7 +49,7 @@ class AppContextService { this.logger?.debug(`tools: ${tools.map((tool) => tool.name).join(', ')}`); if (!this.registeredTools.has(pluginName)) { - this.logger?.debug('plugin has no tools, making new set'); + this.logger?.debug('plugin has no tools, initializing...'); this.registeredTools.set(pluginName, new Set()); } tools.forEach((tool) => this.registeredTools.get(pluginName)?.add(tool)); @@ -64,6 +69,51 @@ class AppContextService { return tools; } + + /** + * Register features to be used by the Elastic Assistant + * + * @param pluginName + * @param features + */ + public registerFeatures(pluginName: string, features: Partial) { + this.logger?.debug('AppContextService:registerFeatures'); + this.logger?.debug(`pluginName: ${pluginName}`); + this.logger?.debug( + `features: ${Object.entries(features) + .map(([feature, enabled]) => `${feature}:${enabled}`) + .join(', ')}` + ); + + if (!this.registeredFeatures.has(pluginName)) { + this.logger?.debug('plugin has no features, initializing...'); + this.registeredFeatures.set(pluginName, defaultAssistantFeatures); + } + + const registeredFeatures = this.registeredFeatures.get(pluginName); + if (registeredFeatures != null) { + this.registeredFeatures.set(pluginName, { ...registeredFeatures, ...features }); + } + } + + /** + * Get the registered features + * + * @param pluginName + */ + public getRegisteredFeatures(pluginName: string): AssistantFeatures { + const features = this.registeredFeatures?.get(pluginName) ?? defaultAssistantFeatures; + + this.logger?.debug('AppContextService:getRegisteredFeatures'); + this.logger?.debug(`pluginName: ${pluginName}`); + this.logger?.debug( + `features: ${Object.entries(features) + .map(([feature, enabled]) => `${feature}:${enabled}`) + .join(', ')}` + ); + + return features; + } } export const appContextService = new AppContextService(); diff --git a/x-pack/plugins/elastic_assistant/server/types.ts b/x-pack/plugins/elastic_assistant/server/types.ts index c45966b9b80a24..dafb6ad6b9bb3a 100755 --- a/x-pack/plugins/elastic_assistant/server/types.ts +++ b/x-pack/plugins/elastic_assistant/server/types.ts @@ -20,8 +20,9 @@ import { type MlPluginSetup } from '@kbn/ml-plugin/server'; import { Tool } from 'langchain/dist/tools/base'; import { RetrievalQAChain } from 'langchain/chains'; import { ElasticsearchClient } from '@kbn/core/server'; +import { AssistantFeatures } from '@kbn/elastic-assistant-common'; import { RequestBody } from './lib/langchain/types'; -import type { GetRegisteredTools } from './services/app_context'; +import type { GetRegisteredFeatures, GetRegisteredTools } from './services/app_context'; export const PLUGIN_ID = 'elasticAssistant' as const; @@ -32,15 +33,37 @@ export interface ElasticAssistantPluginSetup { /** The plugin start interface */ export interface ElasticAssistantPluginStart { + /** + * Actions plugin start contract. + */ actions: ActionsPluginStart; /** - * Register tools to be used by the elastic assistant + * Register features to be used by the elastic assistant. + * + * Note: Be sure to use the pluginName that is sent in the request headers by your plugin to ensure it is extracted + * and the correct features are available. See {@link getPluginNameFromRequest} for more details. + * + * @param pluginName Name of the plugin the features should be registered to + * @param features Partial to be registered with for the given plugin + */ + registerFeatures: (pluginName: string, features: Partial) => void; + /** + * Get the registered features for a given plugin name. + * @param pluginName Name of the plugin to get the features for + */ + getRegisteredFeatures: GetRegisteredFeatures; + /** + * Register tools to be used by the elastic assistant. + * + * Note: Be sure to use the pluginName that is sent in the request headers by your plugin to ensure it is extracted + * and the correct tools are selected. See {@link getPluginNameFromRequest} for more details. + * * @param pluginName Name of the plugin the tool should be registered to * @param tools AssistantTools to be registered with for the given plugin */ registerTools: (pluginName: string, tools: AssistantTool[]) => void; /** - * Get the registered tools + * Get the registered tools for a given plugin name. * @param pluginName Name of the plugin to get the tools for */ getRegisteredTools: GetRegisteredTools; @@ -56,6 +79,7 @@ export interface ElasticAssistantPluginStartDependencies { export interface ElasticAssistantApiRequestHandlerContext { actions: ActionsPluginStart; + getRegisteredFeatures: GetRegisteredFeatures; getRegisteredTools: GetRegisteredTools; logger: Logger; telemetry: AnalyticsServiceSetup; diff --git a/x-pack/plugins/elastic_assistant/tsconfig.json b/x-pack/plugins/elastic_assistant/tsconfig.json index 2fd22f015ad8da..dfca7893b20365 100644 --- a/x-pack/plugins/elastic_assistant/tsconfig.json +++ b/x-pack/plugins/elastic_assistant/tsconfig.json @@ -17,7 +17,6 @@ "@kbn/core", "@kbn/core-http-server", "@kbn/licensing-plugin", - "@kbn/core-http-request-handler-context-server", "@kbn/securitysolution-es-utils", "@kbn/securitysolution-io-ts-utils", "@kbn/actions-plugin", @@ -34,6 +33,8 @@ "@kbn/ml-plugin", "@kbn/apm-utils", "@kbn/core-analytics-server", + "@kbn/elastic-assistant-common", + "@kbn/core-http-router-server-mocks", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts index 0c092b9d806b3f..4388276117fc74 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea_logic/kibana_logic.mock.ts @@ -34,6 +34,7 @@ export const mockKibanaValues = { }, config: { host: 'http://localhost:3002' }, data: dataPluginMock.createStartContract(), + esConfig: { elasticsearch_host: 'https://your_deployment_url' }, guidedOnboarding: {}, history: mockHistory, isCloud: false, diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.tsx index 19457083db2ccf..9666ceb8eac2fc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.tsx @@ -48,7 +48,7 @@ import { import { AnalyticsCollectionExplorerCallout } from './analytics_collection_explorer_callout'; -interface TableSetting { +interface TableSetting { columns: Array< EuiBasicTableColumn & { render?: (euiTheme: UseEuiTheme['euiTheme']) => EuiTableFieldDataColumnType['render']; diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.tsx index 3bad1189a0181a..1cf9f91f08db89 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.tsx @@ -95,7 +95,7 @@ const tabsByFilter: Record> ], }; -interface TableSetting { +interface TableSetting { columns: Array< EuiBasicTableColumn & { render?: (euiTheme: UseEuiTheme['euiTheme']) => EuiTableFieldDataColumnType['render']; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_panel.tsx index b3adf5b62b32f6..d3a1cad6f2d83a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_panel.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_panel.tsx @@ -36,6 +36,7 @@ export const IgnoredQueriesPanel: React.FC = () => { loadIgnoredQueries(); }, [meta.page.current]); + // @ts-expect-error - EuiBasicTable wants an array of objects, but will accept strings if coerced const columns: Array> = [ { render: (query: string) => query, @@ -108,8 +109,10 @@ export const IgnoredQueriesPanel: React.FC = () => { hasBorder > { const cloudContext = useCloudDetails(); const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); + const { esConfig } = useValues(KibanaLogic); const codeArgs: LanguageDefinitionSnippetArguments = { apiKey: '', cloudId: cloudContext.cloudId, - url: cloudContext.elasticsearchUrl || ELASTICSEARCH_URL_PLACEHOLDER, + url: esConfig.elasticsearch_host, }; const { makeRequest } = useActions(FetchApiKeysAPILogic); const { data } = useValues(FetchApiKeysAPILogic); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/e5_multilingual/create_e5_multilingual_model_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/e5_multilingual/create_e5_multilingual_model_api_logic.ts deleted file mode 100644 index e4b50c70bfc84b..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/e5_multilingual/create_e5_multilingual_model_api_logic.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. - */ -import { Actions, createApiLogic } from '../../../../shared/api_logic/create_api_logic'; -import { HttpLogic } from '../../../../shared/http'; - -export interface CreateE5MultilingualModelArgs { - modelId: string; -} - -export interface CreateE5MultilingualModelResponse { - deploymentState: string; - modelId: string; -} - -export const createE5MultilingualModel = async ({ - modelId, -}: CreateE5MultilingualModelArgs): Promise => { - const route = `/internal/enterprise_search/ml/models/${modelId}`; - return await HttpLogic.values.http.post(route); -}; - -export const CreateE5MultilingualModelApiLogic = createApiLogic( - ['create_e5_multilingual_model_api_logic'], - createE5MultilingualModel, - { showErrorFlash: false } -); - -export type CreateE5MultilingualModelApiLogicActions = Actions< - CreateE5MultilingualModelArgs, - CreateE5MultilingualModelResponse ->; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/e5_multilingual/fetch_e5_multilingual_model_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/e5_multilingual/fetch_e5_multilingual_model_api_logic.ts deleted file mode 100644 index 88918a56fb3317..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/e5_multilingual/fetch_e5_multilingual_model_api_logic.ts +++ /dev/null @@ -1,37 +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 { Actions, createApiLogic } from '../../../../shared/api_logic/create_api_logic'; -import { HttpLogic } from '../../../../shared/http'; - -export interface FetchE5MultilingualModelArgs { - modelId: string; -} - -export interface FetchE5MultilingualModelResponse { - deploymentState: string; - modelId: string; - targetAllocationCount: number; - nodeAllocationCount: number; - threadsPerAllocation: number; -} - -export const fetchE5MultilingualModelStatus = async ({ modelId }: FetchE5MultilingualModelArgs) => { - return await HttpLogic.values.http.get( - `/internal/enterprise_search/ml/models/${modelId}` - ); -}; - -export const FetchE5MultilingualModelApiLogic = createApiLogic( - ['fetch_e5_multilingual_model_api_logic'], - fetchE5MultilingualModelStatus, - { showErrorFlash: false } -); - -export type FetchE5MultilingualModelApiLogicActions = Actions< - FetchE5MultilingualModelArgs, - FetchE5MultilingualModelResponse ->; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/e5_multilingual/start_e5_multilingual_model_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/e5_multilingual/start_e5_multilingual_model_api_logic.ts deleted file mode 100644 index 9b0e1520da710d..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/ml_models/e5_multilingual/start_e5_multilingual_model_api_logic.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. - */ -import { Actions, createApiLogic } from '../../../../shared/api_logic/create_api_logic'; -import { HttpLogic } from '../../../../shared/http'; - -export interface StartE5MultilingualModelArgs { - modelId: string; -} - -export interface StartE5MultilingualModelResponse { - deploymentState: string; - modelId: string; -} - -export const startE5MultilingualModel = async ({ - modelId, -}: StartE5MultilingualModelArgs): Promise => { - const route = `/internal/enterprise_search/ml/models/${modelId}/deploy`; - return await HttpLogic.values.http.post(route); -}; - -export const StartE5MultilingualModelApiLogic = createApiLogic( - ['start_e5_multilingual_model_api_logic'], - startE5MultilingualModel, - { showErrorFlash: false } -); - -export type StartE5MultilingualModelApiLogicActions = Actions< - StartE5MultilingualModelArgs, - StartE5MultilingualModelResponse ->; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors_table.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors_table.tsx index 973955e68f0109..3f58137b5dbd94 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors_table.tsx @@ -20,7 +20,7 @@ import { import { i18n } from '@kbn/i18n'; -import { Connector } from '@kbn/search-connectors'; +import { Connector, ConnectorStatus } from '@kbn/search-connectors'; import { Meta } from '../../../../../common/types/pagination'; @@ -114,9 +114,9 @@ export const ConnectorsTable: React.FC = ({ defaultMessage: 'Ingestion status', } ), - render: (connector: Connector) => { - const label = connectorStatusToText(connector.status); - return {label}; + render: (connectorStatus: ConnectorStatus) => { + const label = connectorStatusToText(connectorStatus); + return {label}; }, truncateText: true, }, diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/deploy_model.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/deploy_model.test.tsx deleted file mode 100644 index 739e709ea10f0d..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/deploy_model.test.tsx +++ /dev/null @@ -1,81 +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 { setMockValues } from '../../../../../../__mocks__/kea_logic'; - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { EuiButton } from '@elastic/eui'; - -import { DeployModel } from './deploy_model'; -import { E5MultilingualDismissButton } from './e5_multilingual_callout'; - -const DEFAULT_VALUES = { - startE5MultilingualModelError: undefined, - isCreateButtonDisabled: false, - isModelDownloadInProgress: false, - isModelDownloaded: false, - isModelStarted: false, - isStartButtonDisabled: false, -}; - -describe('DeployModel', () => { - beforeEach(() => { - jest.clearAllMocks(); - setMockValues(DEFAULT_VALUES); - }); - it('renders deploy button', () => { - const wrapper = shallow( - {}} - ingestionMethod="crawler" - isCreateButtonDisabled={false} - isDismissable={false} - /> - ); - expect(wrapper.find(EuiButton).length).toBe(1); - const button = wrapper.find(EuiButton); - expect(button.prop('disabled')).toBe(false); - }); - it('renders disabled deploy button if it is set to disabled', () => { - const wrapper = shallow( - {}} - ingestionMethod="crawler" - isCreateButtonDisabled - isDismissable={false} - /> - ); - expect(wrapper.find(EuiButton).length).toBe(1); - const button = wrapper.find(EuiButton); - expect(button.prop('disabled')).toBe(true); - }); - it('renders dismiss button if it is set to dismissable', () => { - const wrapper = shallow( - {}} - ingestionMethod="crawler" - isCreateButtonDisabled={false} - isDismissable - /> - ); - expect(wrapper.find(E5MultilingualDismissButton).length).toBe(1); - }); - it('does not render dismiss button if it is set to non-dismissable', () => { - const wrapper = shallow( - {}} - ingestionMethod="crawler" - isCreateButtonDisabled={false} - isDismissable={false} - /> - ); - expect(wrapper.find(E5MultilingualDismissButton).length).toBe(0); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/deploy_model.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/deploy_model.tsx deleted file mode 100644 index 6d616a453e9f7a..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/deploy_model.tsx +++ /dev/null @@ -1,120 +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 { useActions } from 'kea'; - -import { - EuiBadge, - EuiButton, - EuiCallOut, - EuiFlexGroup, - EuiFlexItem, - EuiLink, - EuiText, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedHTMLMessage, FormattedMessage } from '@kbn/i18n-react'; - -import { E5MultilingualCallOutState, E5MultilingualDismissButton } from './e5_multilingual_callout'; -import { E5MultilingualCalloutLogic } from './e5_multilingual_callout_logic'; - -export const DeployModel = ({ - dismiss, - ingestionMethod, - isCreateButtonDisabled, - isDismissable, -}: Pick< - E5MultilingualCallOutState, - 'dismiss' | 'ingestionMethod' | 'isCreateButtonDisabled' | 'isDismissable' ->) => { - const { createE5MultilingualModel } = useActions(E5MultilingualCalloutLogic); - - return ( - - - - - - - - - - - -

- {i18n.translate( - 'xpack.enterpriseSearch.content.index.pipelines.e5MultilingualCallOut.title', - { defaultMessage: 'Improve your results with E5' } - )} -

-
-
- {isDismissable && ( - - - - )} -
-
- - - - - - - - - - - createE5MultilingualModel()} - > - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.pipelines.e5MultilingualCallOut.deployButton.label', - { - defaultMessage: 'Deploy', - } - )} - - - - - - - - - - - -
-
- ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/e5_multilingual_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/e5_multilingual_callout.test.tsx deleted file mode 100644 index ad9f99960ad048..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/e5_multilingual_callout.test.tsx +++ /dev/null @@ -1,77 +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 { setMockValues } from '../../../../../../__mocks__/kea_logic'; - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { DeployModel } from './deploy_model'; -import { E5MultilingualCallOut } from './e5_multilingual_callout'; -import { E5MultilingualErrors } from './e5_multilingual_errors'; -import { ModelDeployed } from './model_deployed'; -import { ModelDeploymentInProgress } from './model_deployment_in_progress'; -import { ModelStarted } from './model_started'; - -const DEFAULT_VALUES = { - isCreateButtonDisabled: false, - isModelDownloadInProgress: false, - isModelDownloaded: false, - isModelStarted: false, - isStartButtonDisabled: false, -}; - -describe('E5MultilingualCallOut', () => { - beforeEach(() => { - jest.clearAllMocks(); - setMockValues(DEFAULT_VALUES); - }); - it('renders error panel instead of normal panel if there are some errors', () => { - setMockValues({ - ...DEFAULT_VALUES, - e5MultilingualError: { - title: 'Error with E5 Multilingual deployment', - message: 'Mocked error message', - }, - }); - - const wrapper = shallow(); - expect(wrapper.find(E5MultilingualErrors).length).toBe(1); - }); - it('renders panel with deployment instructions if the model is not deployed', () => { - const wrapper = shallow(); - expect(wrapper.find(DeployModel).length).toBe(1); - }); - it('renders panel with deployment in progress status if the model is being deployed', () => { - setMockValues({ - ...DEFAULT_VALUES, - isModelDownloadInProgress: true, - }); - - const wrapper = shallow(); - expect(wrapper.find(ModelDeploymentInProgress).length).toBe(1); - }); - it('renders panel with deployment in progress status if the model has been deployed', () => { - setMockValues({ - ...DEFAULT_VALUES, - isModelDownloaded: true, - }); - - const wrapper = shallow(); - expect(wrapper.find(ModelDeployed).length).toBe(1); - }); - it('renders panel with deployment in progress status if the model has been started', () => { - setMockValues({ - ...DEFAULT_VALUES, - isModelStarted: true, - }); - - const wrapper = shallow(); - expect(wrapper.find(ModelStarted).length).toBe(1); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/e5_multilingual_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/e5_multilingual_callout.tsx deleted file mode 100644 index bfe92e5b7c96e6..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/e5_multilingual_callout.tsx +++ /dev/null @@ -1,150 +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 { useCallback, useState } from 'react'; - -import { useValues } from 'kea'; - -import { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui'; - -import { i18n } from '@kbn/i18n'; - -import { KibanaLogic } from '../../../../../../shared/kibana'; - -import { useLocalStorage } from '../../../../../../shared/use_local_storage'; - -import { IndexViewLogic } from '../../../index_view_logic'; - -import { TRAINED_MODELS_PATH } from '../utils'; - -import { DeployModel } from './deploy_model'; -import { E5MultilingualCalloutLogic } from './e5_multilingual_callout_logic'; -import { E5MultilingualErrors } from './e5_multilingual_errors'; -import { ModelDeployed } from './model_deployed'; -import { ModelDeploymentInProgress } from './model_deployment_in_progress'; -import { ModelStarted } from './model_started'; - -export interface E5MultilingualCallOutState { - dismiss: () => void; - ingestionMethod: string; - isCompact: boolean; - isCreateButtonDisabled: boolean; - isDismissable: boolean; - isSingleThreaded: boolean; - isStartButtonDisabled: boolean; - show: boolean; -} - -export interface E5MultilingualCallOutProps { - isCompact?: boolean; - isDismissable?: boolean; -} - -export const E5_MULTILINGUAL_CALL_OUT_DISMISSED_KEY = - 'enterprise-search-e5-multilingual-callout-dismissed'; - -export const E5MultilingualDismissButton = ({ - dismiss, -}: Pick) => { - return ( - - ); -}; - -export const FineTuneModelsButton: React.FC = () => ( - - KibanaLogic.values.navigateToUrl(TRAINED_MODELS_PATH, { - shouldNotCreateHref: true, - }) - } - > - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.pipelines.e5MultilingualCallOut.fineTuneModelButton', - { - defaultMessage: 'Fine-tune performance', - } - )} - -); - -export const E5MultilingualCallOut: React.FC = (props) => { - const isCompact = props.isCompact !== undefined ? props.isCompact : false; - const isDismissable = props.isDismissable !== undefined ? props.isDismissable : false; - - const [calloutDismissed, setCalloutDismissed] = useLocalStorage( - E5_MULTILINGUAL_CALL_OUT_DISMISSED_KEY, - false - ); - - const [show, setShow] = useState(() => { - if (!isDismissable) return true; - return !calloutDismissed; - }); - - const dismiss = useCallback(() => { - setShow(false); - setCalloutDismissed(true); - }, []); - - const { ingestionMethod } = useValues(IndexViewLogic); - const { - isCreateButtonDisabled, - isModelDownloadInProgress, - isModelDownloaded, - isModelRunningSingleThreaded, - isModelStarted, - e5MultilingualError, - isStartButtonDisabled, - } = useValues(E5MultilingualCalloutLogic); - - if (e5MultilingualError) return ; - - if (!show) return null; - - if (isModelDownloadInProgress) { - return ; - } else if (isModelDownloaded) { - return ( - - ); - } else if (isModelStarted) { - return ( - - ); - } - - return ( - - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/e5_multilingual_callout_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/e5_multilingual_callout_logic.test.ts deleted file mode 100644 index 0ea1d546d407f9..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/e5_multilingual_callout_logic.test.ts +++ /dev/null @@ -1,469 +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 { LogicMounter } from '../../../../../../__mocks__/kea_logic'; - -import { HttpResponse } from '@kbn/core/public'; - -import { ErrorResponse, HttpError, Status } from '../../../../../../../../common/types/api'; -import { MlModelDeploymentState } from '../../../../../../../../common/types/ml'; -import { CreateE5MultilingualModelApiLogic } from '../../../../../api/ml_models/e5_multilingual/create_e5_multilingual_model_api_logic'; -import { FetchE5MultilingualModelApiLogic } from '../../../../../api/ml_models/e5_multilingual/fetch_e5_multilingual_model_api_logic'; -import { StartE5MultilingualModelApiLogic } from '../../../../../api/ml_models/e5_multilingual/start_e5_multilingual_model_api_logic'; - -import { - E5MultilingualCalloutLogic, - E5MultilingualCalloutValues, - getE5MultilingualError, -} from './e5_multilingual_callout_logic'; - -const DEFAULT_VALUES: E5MultilingualCalloutValues = { - createE5MultilingualModelError: undefined, - createE5MultilingualModelStatus: Status.IDLE, - createdE5MultilingualModel: undefined, - fetchE5MultilingualModelError: undefined, - isCreateButtonDisabled: false, - isModelDownloadInProgress: false, - isModelDownloaded: false, - isModelRunningSingleThreaded: false, - isModelStarted: false, - isPollingE5MultilingualModelActive: false, - isStartButtonDisabled: false, - startE5MultilingualModelError: undefined, - startE5MultilingualModelStatus: Status.IDLE, - e5MultilingualModel: undefined, - e5MultilingualModelPollTimeoutId: null, - e5MultilingualError: null, -}; - -jest.useFakeTimers(); - -describe('E5MultilingualCalloutLogic', () => { - const { mount } = new LogicMounter(E5MultilingualCalloutLogic); - const { mount: mountCreateE5MultilingualModelApiLogic } = new LogicMounter( - CreateE5MultilingualModelApiLogic - ); - const { mount: mountFetchE5MultilingualModelApiLogic } = new LogicMounter( - FetchE5MultilingualModelApiLogic - ); - const { mount: mountStartE5MultilingualModelApiLogic } = new LogicMounter( - StartE5MultilingualModelApiLogic - ); - - beforeEach(() => { - jest.clearAllMocks(); - mountCreateE5MultilingualModelApiLogic(); - mountFetchE5MultilingualModelApiLogic(); - mountStartE5MultilingualModelApiLogic(); - mount(); - }); - - it('has expected default values', () => { - expect(E5MultilingualCalloutLogic.values).toEqual(DEFAULT_VALUES); - }); - - describe('getE5MultilingualError', () => { - const error = { - body: { - error: 'some-error', - message: 'some-error-message', - statusCode: 500, - }, - } as HttpError; - it('returns null if there is no error', () => { - expect(getE5MultilingualError(undefined, undefined, undefined)).toBe(null); - }); - it('uses the correct title and message from a create error', () => { - expect(getE5MultilingualError(error, undefined, undefined)).toEqual({ - title: 'Error with E5 Multilingual deployment', - message: error.body?.message, - }); - }); - it('uses the correct title and message from a fetch error', () => { - expect(getE5MultilingualError(undefined, error, undefined)).toEqual({ - title: 'Error fetching E5 Multilingual model', - message: error.body?.message, - }); - }); - it('uses the correct title and message from a start error', () => { - expect(getE5MultilingualError(undefined, undefined, error)).toEqual({ - title: 'Error starting E5 Multilingual deployment', - message: error.body?.message, - }); - }); - }); - - describe('listeners', () => { - describe('createE5MultilingualModelPollingTimeout', () => { - const duration = 5000; - it('sets polling timeout', () => { - jest.spyOn(global, 'setTimeout'); - jest.spyOn(E5MultilingualCalloutLogic.actions, 'setE5MultilingualModelPollingId'); - - E5MultilingualCalloutLogic.actions.createE5MultilingualModelPollingTimeout(duration); - - expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), duration); - expect( - E5MultilingualCalloutLogic.actions.setE5MultilingualModelPollingId - ).toHaveBeenCalled(); - }); - it('clears polling timeout if it is set', () => { - mount({ - ...DEFAULT_VALUES, - e5MultilingualModelPollTimeoutId: 'timeout-id', - }); - - jest.spyOn(global, 'clearTimeout'); - - E5MultilingualCalloutLogic.actions.createE5MultilingualModelPollingTimeout(duration); - - expect(clearTimeout).toHaveBeenCalledWith('timeout-id'); - }); - }); - - describe('createE5MultilingualModelSuccess', () => { - it('sets createdE5MultilingualModel', () => { - jest.spyOn(E5MultilingualCalloutLogic.actions, 'fetchE5MultilingualModel'); - jest.spyOn(E5MultilingualCalloutLogic.actions, 'startPollingE5MultilingualModel'); - - E5MultilingualCalloutLogic.actions.createE5MultilingualModelSuccess({ - deploymentState: MlModelDeploymentState.Downloading, - modelId: 'mock-model-id', - }); - - expect(E5MultilingualCalloutLogic.actions.fetchE5MultilingualModel).toHaveBeenCalled(); - expect( - E5MultilingualCalloutLogic.actions.startPollingE5MultilingualModel - ).toHaveBeenCalled(); - }); - }); - - describe('fetchE5MultilingualModelSuccess', () => { - const data = { - deploymentState: MlModelDeploymentState.Downloading, - modelId: 'mock-model-id', - targetAllocationCount: 1, - nodeAllocationCount: 1, - threadsPerAllocation: 1, - }; - - it('starts polling when the model is downloading and polling is not active', () => { - mount({ - ...DEFAULT_VALUES, - }); - jest.spyOn(E5MultilingualCalloutLogic.actions, 'startPollingE5MultilingualModel'); - - E5MultilingualCalloutLogic.actions.fetchE5MultilingualModelSuccess(data); - - expect( - E5MultilingualCalloutLogic.actions.startPollingE5MultilingualModel - ).toHaveBeenCalled(); - }); - it('sets polling timeout when the model is downloading and polling is active', () => { - mount({ - ...DEFAULT_VALUES, - e5MultilingualModelPollTimeoutId: 'timeout-id', - }); - jest.spyOn(E5MultilingualCalloutLogic.actions, 'createE5MultilingualModelPollingTimeout'); - - E5MultilingualCalloutLogic.actions.fetchE5MultilingualModelSuccess(data); - - expect( - E5MultilingualCalloutLogic.actions.createE5MultilingualModelPollingTimeout - ).toHaveBeenCalled(); - }); - it('stops polling when the model is downloaded and polling is active', () => { - mount({ - ...DEFAULT_VALUES, - e5MultilingualModelPollTimeoutId: 'timeout-id', - }); - jest.spyOn(E5MultilingualCalloutLogic.actions, 'stopPollingE5MultilingualModel'); - - E5MultilingualCalloutLogic.actions.fetchE5MultilingualModelSuccess({ - deploymentState: MlModelDeploymentState.Downloaded, - modelId: 'mock-model-id', - targetAllocationCount: 1, - nodeAllocationCount: 1, - threadsPerAllocation: 1, - }); - - expect( - E5MultilingualCalloutLogic.actions.stopPollingE5MultilingualModel - ).toHaveBeenCalled(); - }); - }); - - describe('fetchE5MultilingualModelError', () => { - it('stops polling if it is active', () => { - mount({ - ...DEFAULT_VALUES, - e5MultilingualModelPollTimeoutId: 'timeout-id', - }); - jest.spyOn(E5MultilingualCalloutLogic.actions, 'createE5MultilingualModelPollingTimeout'); - - E5MultilingualCalloutLogic.actions.fetchE5MultilingualModelError({ - body: { - error: '', - message: 'some error', - statusCode: 500, - }, - } as HttpResponse); - - expect( - E5MultilingualCalloutLogic.actions.createE5MultilingualModelPollingTimeout - ).toHaveBeenCalled(); - }); - }); - - describe('startPollingE5MultilingualModel', () => { - it('sets polling timeout', () => { - jest.spyOn(E5MultilingualCalloutLogic.actions, 'createE5MultilingualModelPollingTimeout'); - - E5MultilingualCalloutLogic.actions.startPollingE5MultilingualModel(); - - expect( - E5MultilingualCalloutLogic.actions.createE5MultilingualModelPollingTimeout - ).toHaveBeenCalled(); - }); - it('clears polling timeout if it is set', () => { - mount({ - ...DEFAULT_VALUES, - e5MultilingualModelPollTimeoutId: 'timeout-id', - }); - - jest.spyOn(global, 'clearTimeout'); - - E5MultilingualCalloutLogic.actions.startPollingE5MultilingualModel(); - - expect(clearTimeout).toHaveBeenCalledWith('timeout-id'); - }); - }); - - describe('startE5MultilingualModelSuccess', () => { - it('sets startedE5MultilingualModel', () => { - jest.spyOn(E5MultilingualCalloutLogic.actions, 'fetchE5MultilingualModel'); - - E5MultilingualCalloutLogic.actions.startE5MultilingualModelSuccess({ - deploymentState: MlModelDeploymentState.FullyAllocated, - modelId: 'mock-model-id', - }); - - expect(E5MultilingualCalloutLogic.actions.fetchE5MultilingualModel).toHaveBeenCalled(); - }); - }); - - describe('stopPollingE5MultilingualModel', () => { - it('clears polling timeout and poll timeout ID if it is set', () => { - mount({ - ...DEFAULT_VALUES, - e5MultilingualModelPollTimeoutId: 'timeout-id', - }); - - jest.spyOn(global, 'clearTimeout'); - jest.spyOn(E5MultilingualCalloutLogic.actions, 'clearE5MultilingualModelPollingId'); - - E5MultilingualCalloutLogic.actions.stopPollingE5MultilingualModel(); - - expect(clearTimeout).toHaveBeenCalledWith('timeout-id'); - expect( - E5MultilingualCalloutLogic.actions.clearE5MultilingualModelPollingId - ).toHaveBeenCalled(); - }); - }); - }); - - describe('reducers', () => { - describe('e5MultilingualModelPollTimeoutId', () => { - it('gets cleared on clearE5MultilingualModelPollingId', () => { - E5MultilingualCalloutLogic.actions.clearE5MultilingualModelPollingId(); - - expect(E5MultilingualCalloutLogic.values.e5MultilingualModelPollTimeoutId).toBe(null); - }); - it('gets set on setE5MultilingualModelPollingId', () => { - const timeout = setTimeout(() => {}, 500); - E5MultilingualCalloutLogic.actions.setE5MultilingualModelPollingId(timeout); - - expect(E5MultilingualCalloutLogic.values.e5MultilingualModelPollTimeoutId).toEqual(timeout); - }); - }); - }); - - describe('selectors', () => { - describe('isCreateButtonDisabled', () => { - it('is set to false if the fetch model API is idle', () => { - CreateE5MultilingualModelApiLogic.actions.apiReset(); - expect(E5MultilingualCalloutLogic.values.isCreateButtonDisabled).toBe(false); - }); - it('is set to true if the fetch model API is not idle', () => { - CreateE5MultilingualModelApiLogic.actions.apiSuccess({ - deploymentState: MlModelDeploymentState.Downloading, - modelId: 'mock-model-id', - }); - expect(E5MultilingualCalloutLogic.values.isCreateButtonDisabled).toBe(true); - }); - }); - - describe('e5MultilingualError', () => { - const error = { - body: { - error: 'Error with E5 Multilingual deployment', - message: 'Mocked error message', - statusCode: 500, - }, - } as HttpError; - - it('returns null when there are no errors', () => { - CreateE5MultilingualModelApiLogic.actions.apiReset(); - FetchE5MultilingualModelApiLogic.actions.apiReset(); - StartE5MultilingualModelApiLogic.actions.apiReset(); - expect(E5MultilingualCalloutLogic.values.e5MultilingualError).toBe(null); - }); - it('returns extracted error for create', () => { - CreateE5MultilingualModelApiLogic.actions.apiError(error); - expect(E5MultilingualCalloutLogic.values.e5MultilingualError).toStrictEqual({ - title: 'Error with E5 Multilingual deployment', - message: 'Mocked error message', - }); - }); - it('returns extracted error for fetch', () => { - FetchE5MultilingualModelApiLogic.actions.apiError(error); - expect(E5MultilingualCalloutLogic.values.e5MultilingualError).toStrictEqual({ - title: 'Error fetching E5 Multilingual model', - message: 'Mocked error message', - }); - }); - it('returns extracted error for start', () => { - StartE5MultilingualModelApiLogic.actions.apiError(error); - expect(E5MultilingualCalloutLogic.values.e5MultilingualError).toStrictEqual({ - title: 'Error starting E5 Multilingual deployment', - message: 'Mocked error message', - }); - }); - }); - - describe('isModelDownloadInProgress', () => { - it('is set to true if the model is downloading', () => { - FetchE5MultilingualModelApiLogic.actions.apiSuccess({ - deploymentState: MlModelDeploymentState.Downloading, - modelId: 'mock-model-id', - targetAllocationCount: 1, - nodeAllocationCount: 1, - threadsPerAllocation: 1, - }); - expect(E5MultilingualCalloutLogic.values.isModelDownloadInProgress).toBe(true); - }); - it('is set to false if the model is downloading', () => { - FetchE5MultilingualModelApiLogic.actions.apiSuccess({ - deploymentState: MlModelDeploymentState.Started, - modelId: 'mock-model-id', - targetAllocationCount: 1, - nodeAllocationCount: 1, - threadsPerAllocation: 1, - }); - expect(E5MultilingualCalloutLogic.values.isModelDownloadInProgress).toBe(false); - }); - }); - - describe('isModelDownloaded', () => { - it('is set to true if the model is downloaded', () => { - FetchE5MultilingualModelApiLogic.actions.apiSuccess({ - deploymentState: MlModelDeploymentState.Downloaded, - modelId: 'mock-model-id', - targetAllocationCount: 1, - nodeAllocationCount: 1, - threadsPerAllocation: 1, - }); - expect(E5MultilingualCalloutLogic.values.isModelDownloaded).toBe(true); - }); - it('is set to false if the model is not downloaded', () => { - FetchE5MultilingualModelApiLogic.actions.apiSuccess({ - deploymentState: MlModelDeploymentState.NotDeployed, - modelId: 'mock-model-id', - targetAllocationCount: 1, - nodeAllocationCount: 1, - threadsPerAllocation: 1, - }); - expect(E5MultilingualCalloutLogic.values.isModelDownloaded).toBe(false); - }); - }); - - describe('isModelRunningSingleThreaded', () => { - it('is set to true if the model has 1 target allocation and 1 thread', () => { - FetchE5MultilingualModelApiLogic.actions.apiSuccess({ - deploymentState: MlModelDeploymentState.FullyAllocated, - modelId: 'mock-model-id', - targetAllocationCount: 1, - nodeAllocationCount: 1, - threadsPerAllocation: 1, - }); - expect(E5MultilingualCalloutLogic.values.isModelRunningSingleThreaded).toBe(true); - }); - it('is set to false if the model has multiple target allocations', () => { - FetchE5MultilingualModelApiLogic.actions.apiSuccess({ - deploymentState: MlModelDeploymentState.FullyAllocated, - modelId: 'mock-model-id', - targetAllocationCount: 2, - nodeAllocationCount: 2, - threadsPerAllocation: 1, - }); - expect(E5MultilingualCalloutLogic.values.isModelRunningSingleThreaded).toBe(false); - }); - it('is set to false if the model runs on multiple threads', () => { - FetchE5MultilingualModelApiLogic.actions.apiSuccess({ - deploymentState: MlModelDeploymentState.FullyAllocated, - modelId: 'mock-model-id', - targetAllocationCount: 1, - nodeAllocationCount: 1, - threadsPerAllocation: 4, - }); - expect(E5MultilingualCalloutLogic.values.isModelRunningSingleThreaded).toBe(false); - }); - }); - - describe('isModelStarted', () => { - it('is set to true if the model is started', () => { - FetchE5MultilingualModelApiLogic.actions.apiSuccess({ - deploymentState: MlModelDeploymentState.FullyAllocated, - modelId: 'mock-model-id', - targetAllocationCount: 1, - nodeAllocationCount: 1, - threadsPerAllocation: 1, - }); - expect(E5MultilingualCalloutLogic.values.isModelStarted).toBe(true); - }); - it('is set to false if the model is not started', () => { - FetchE5MultilingualModelApiLogic.actions.apiSuccess({ - deploymentState: MlModelDeploymentState.NotDeployed, - modelId: 'mock-model-id', - targetAllocationCount: 1, - nodeAllocationCount: 1, - threadsPerAllocation: 1, - }); - expect(E5MultilingualCalloutLogic.values.isModelStarted).toBe(false); - }); - }); - - describe('isPollingE5MultilingualModelActive', () => { - it('is set to false if polling is not active', () => { - mount({ - ...DEFAULT_VALUES, - e5MultilingualModelPollTimeoutId: null, - }); - - expect(E5MultilingualCalloutLogic.values.isPollingE5MultilingualModelActive).toBe(false); - }); - it('is set to true if polling is active', () => { - mount({ - ...DEFAULT_VALUES, - e5MultilingualModelPollTimeoutId: 'timeout-id', - }); - - expect(E5MultilingualCalloutLogic.values.isPollingE5MultilingualModelActive).toBe(true); - }); - }); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/e5_multilingual_callout_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/e5_multilingual_callout_logic.ts deleted file mode 100644 index 4c2a9acb453bf9..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/e5_multilingual_callout_logic.ts +++ /dev/null @@ -1,308 +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 { kea, MakeLogicType } from 'kea'; - -import { i18n } from '@kbn/i18n'; - -import { HttpError, Status } from '../../../../../../../../common/types/api'; -import { MlModelDeploymentState } from '../../../../../../../../common/types/ml'; -import { getErrorsFromHttpResponse } from '../../../../../../shared/flash_messages/handle_api_errors'; - -import { - CreateE5MultilingualModelApiLogic, - CreateE5MultilingualModelApiLogicActions, - CreateE5MultilingualModelResponse, -} from '../../../../../api/ml_models/e5_multilingual/create_e5_multilingual_model_api_logic'; -import { - FetchE5MultilingualModelApiLogic, - FetchE5MultilingualModelApiLogicActions, - FetchE5MultilingualModelResponse, -} from '../../../../../api/ml_models/e5_multilingual/fetch_e5_multilingual_model_api_logic'; -import { - StartE5MultilingualModelApiLogic, - StartE5MultilingualModelApiLogicActions, -} from '../../../../../api/ml_models/e5_multilingual/start_e5_multilingual_model_api_logic'; - -const FETCH_E5_MULTILINGUAL_MODEL_POLLING_DURATION = 5000; // 5 seconds -const FETCH_E5_MULTILINGUAL_MODEL_POLLING_DURATION_ON_FAILURE = 30000; // 30 seconds -const E5_MULTILINGUAL_MODEL_ID = '.multilingual-e5-small'; - -interface E5MultilingualCalloutActions { - clearE5MultilingualModelPollingId: () => void; - createE5MultilingualModel: () => void; - createE5MultilingualModelMakeRequest: CreateE5MultilingualModelApiLogicActions['makeRequest']; - createE5MultilingualModelPollingTimeout: (duration: number) => { duration: number }; - createE5MultilingualModelSuccess: CreateE5MultilingualModelApiLogicActions['apiSuccess']; - fetchE5MultilingualModel: () => void; - fetchE5MultilingualModelMakeRequest: FetchE5MultilingualModelApiLogicActions['makeRequest']; - fetchE5MultilingualModelError: FetchE5MultilingualModelApiLogicActions['apiError']; - fetchE5MultilingualModelSuccess: FetchE5MultilingualModelApiLogicActions['apiSuccess']; - setE5MultilingualModelPollingId: (pollTimeoutId: ReturnType) => { - pollTimeoutId: ReturnType; - }; - startPollingE5MultilingualModel: () => void; - startE5MultilingualModel: () => void; - startE5MultilingualModelMakeRequest: StartE5MultilingualModelApiLogicActions['makeRequest']; - startE5MultilingualModelSuccess: StartE5MultilingualModelApiLogicActions['apiSuccess']; - stopPollingE5MultilingualModel: () => void; - e5MultilingualModel: FetchE5MultilingualModelApiLogicActions['apiSuccess']; -} - -export interface E5MultilingualCalloutError { - title: string; - message: string; -} - -export interface E5MultilingualCalloutValues { - createE5MultilingualModelError: HttpError | undefined; - createE5MultilingualModelStatus: Status; - createdE5MultilingualModel: CreateE5MultilingualModelResponse | undefined; - fetchE5MultilingualModelError: HttpError | undefined; - isCreateButtonDisabled: boolean; - isModelDownloadInProgress: boolean; - isModelDownloaded: boolean; - isModelRunningSingleThreaded: boolean; - isModelStarted: boolean; - isPollingE5MultilingualModelActive: boolean; - isStartButtonDisabled: boolean; - startE5MultilingualModelError: HttpError | undefined; - startE5MultilingualModelStatus: Status; - e5MultilingualModel: FetchE5MultilingualModelResponse | undefined; - e5MultilingualModelPollTimeoutId: null | ReturnType; - e5MultilingualError: E5MultilingualCalloutError | null; -} - -/** - * Extracts the topmost error in precedence order (create > start > fetch). - * @param createError - * @param fetchError - * @param startError - * @returns the extracted error or null if there is no error - */ -export const getE5MultilingualError = ( - createError: HttpError | undefined, - fetchError: HttpError | undefined, - startError: HttpError | undefined -) => { - return createError !== undefined - ? { - title: i18n.translate( - 'xpack.enterpriseSearch.content.indices.pipelines.e5MultilingualCreateError.title', - { - defaultMessage: 'Error with E5 Multilingual deployment', - } - ), - message: getErrorsFromHttpResponse(createError)[0], - } - : startError !== undefined - ? { - title: i18n.translate( - 'xpack.enterpriseSearch.content.indices.pipelines.e5MultilingualStartError.title', - { - defaultMessage: 'Error starting E5 Multilingual deployment', - } - ), - message: getErrorsFromHttpResponse(startError)[0], - } - : fetchError !== undefined - ? { - title: i18n.translate( - 'xpack.enterpriseSearch.content.indices.pipelines.e5MultilingualFetchError.title', - { - defaultMessage: 'Error fetching E5 Multilingual model', - } - ), - message: getErrorsFromHttpResponse(fetchError)[0], - } - : null; -}; - -export const E5MultilingualCalloutLogic = kea< - MakeLogicType ->({ - actions: { - clearE5MultilingualModelPollingId: true, - createE5MultilingualModelPollingTimeout: (duration) => ({ duration }), - setE5MultilingualModelPollingId: (pollTimeoutId: ReturnType) => ({ - pollTimeoutId, - }), - startPollingE5MultilingualModel: true, - stopPollingE5MultilingualModel: true, - createE5MultilingualModel: true, - fetchE5MultilingualModel: true, - startE5MultilingualModel: true, - }, - connect: { - actions: [ - CreateE5MultilingualModelApiLogic, - [ - 'makeRequest as createE5MultilingualModelMakeRequest', - 'apiSuccess as createE5MultilingualModelSuccess', - 'apiError as createE5MultilingualModelError', - ], - FetchE5MultilingualModelApiLogic, - [ - 'makeRequest as fetchE5MultilingualModelMakeRequest', - 'apiSuccess as fetchE5MultilingualModelSuccess', - 'apiError as fetchE5MultilingualModelError', - ], - StartE5MultilingualModelApiLogic, - [ - 'makeRequest as startE5MultilingualModelMakeRequest', - 'apiSuccess as startE5MultilingualModelSuccess', - 'apiError as startE5MultilingualModelError', - ], - ], - values: [ - CreateE5MultilingualModelApiLogic, - [ - 'data as createdE5MultilingualModel', - 'status as createE5MultilingualModelStatus', - 'error as createE5MultilingualModelError', - ], - FetchE5MultilingualModelApiLogic, - ['data as e5MultilingualModel', 'error as fetchE5MultilingualModelError'], - StartE5MultilingualModelApiLogic, - ['status as startE5MultilingualModelStatus', 'error as startE5MultilingualModelError'], - ], - }, - events: ({ actions, values }) => ({ - afterMount: () => { - actions.fetchE5MultilingualModel(); - }, - beforeUnmount: () => { - if (values.e5MultilingualModelPollTimeoutId !== null) { - actions.stopPollingE5MultilingualModel(); - } - }, - }), - listeners: ({ actions, values }) => ({ - createE5MultilingualModel: () => - actions.createE5MultilingualModelMakeRequest({ modelId: E5_MULTILINGUAL_MODEL_ID }), - fetchE5MultilingualModel: () => - actions.fetchE5MultilingualModelMakeRequest({ modelId: E5_MULTILINGUAL_MODEL_ID }), - startE5MultilingualModel: () => - actions.startE5MultilingualModelMakeRequest({ modelId: E5_MULTILINGUAL_MODEL_ID }), - createE5MultilingualModelPollingTimeout: ({ duration }) => { - if (values.e5MultilingualModelPollTimeoutId !== null) { - clearTimeout(values.e5MultilingualModelPollTimeoutId); - } - const timeoutId = setTimeout(() => { - actions.fetchE5MultilingualModel(); - }, duration); - actions.setE5MultilingualModelPollingId(timeoutId); - }, - createE5MultilingualModelSuccess: () => { - actions.fetchE5MultilingualModel(); - actions.startPollingE5MultilingualModel(); - }, - fetchE5MultilingualModelError: () => { - if (values.isPollingE5MultilingualModelActive) { - actions.createE5MultilingualModelPollingTimeout( - FETCH_E5_MULTILINGUAL_MODEL_POLLING_DURATION_ON_FAILURE - ); - } - }, - fetchE5MultilingualModelSuccess: (data) => { - if (data?.deploymentState === MlModelDeploymentState.Downloading) { - if (!values.isPollingE5MultilingualModelActive) { - actions.startPollingE5MultilingualModel(); - } else { - actions.createE5MultilingualModelPollingTimeout( - FETCH_E5_MULTILINGUAL_MODEL_POLLING_DURATION - ); - } - } else if ( - data?.deploymentState === MlModelDeploymentState.Downloaded && - values.isPollingE5MultilingualModelActive - ) { - actions.stopPollingE5MultilingualModel(); - } - }, - startPollingE5MultilingualModel: () => { - if (values.e5MultilingualModelPollTimeoutId !== null) { - clearTimeout(values.e5MultilingualModelPollTimeoutId); - } - actions.createE5MultilingualModelPollingTimeout(FETCH_E5_MULTILINGUAL_MODEL_POLLING_DURATION); - }, - startE5MultilingualModelSuccess: () => { - actions.fetchE5MultilingualModel(); - }, - stopPollingE5MultilingualModel: () => { - if (values.e5MultilingualModelPollTimeoutId !== null) { - clearTimeout(values.e5MultilingualModelPollTimeoutId); - actions.clearE5MultilingualModelPollingId(); - } - }, - }), - path: ['enterprise_search', 'content', 'e5_multilingual_callout_logic'], - reducers: { - e5MultilingualModelPollTimeoutId: [ - null, - { - clearE5MultilingualModelPollingId: () => null, - setE5MultilingualModelPollingId: (_, { pollTimeoutId }) => pollTimeoutId, - }, - ], - }, - selectors: ({ selectors }) => ({ - isCreateButtonDisabled: [ - () => [selectors.createE5MultilingualModelStatus], - (status: Status) => status !== Status.IDLE && status !== Status.ERROR, - ], - isModelDownloadInProgress: [ - () => [selectors.e5MultilingualModel], - (data: FetchE5MultilingualModelResponse) => - data?.deploymentState === MlModelDeploymentState.Downloading, - ], - isModelDownloaded: [ - () => [selectors.e5MultilingualModel], - (data: FetchE5MultilingualModelResponse) => - data?.deploymentState === MlModelDeploymentState.Downloaded, - ], - isModelStarted: [ - () => [selectors.e5MultilingualModel], - (data: FetchE5MultilingualModelResponse) => - data?.deploymentState === MlModelDeploymentState.Starting || - data?.deploymentState === MlModelDeploymentState.Started || - data?.deploymentState === MlModelDeploymentState.FullyAllocated, - ], - isPollingE5MultilingualModelActive: [ - () => [selectors.e5MultilingualModelPollTimeoutId], - (pollingTimeoutId: E5MultilingualCalloutValues['e5MultilingualModelPollTimeoutId']) => - pollingTimeoutId !== null, - ], - e5MultilingualError: [ - () => [ - selectors.createE5MultilingualModelError, - selectors.fetchE5MultilingualModelError, - selectors.startE5MultilingualModelError, - ], - ( - createE5MultilingualError: E5MultilingualCalloutValues['createE5MultilingualModelError'], - fetchE5MultilingualError: E5MultilingualCalloutValues['fetchE5MultilingualModelError'], - startE5MultilingualError: E5MultilingualCalloutValues['startE5MultilingualModelError'] - ) => - getE5MultilingualError( - createE5MultilingualError, - fetchE5MultilingualError, - startE5MultilingualError - ), - ], - isStartButtonDisabled: [ - () => [selectors.startE5MultilingualModelStatus], - (status: Status) => status !== Status.IDLE && status !== Status.ERROR, - ], - isModelRunningSingleThreaded: [ - () => [selectors.e5MultilingualModel], - (data: FetchE5MultilingualModelResponse) => - // Running single threaded if model has max 1 deployment on 1 node with 1 thread - data?.targetAllocationCount * data?.threadsPerAllocation <= 1, - ], - }), -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/e5_multilingual_errors.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/e5_multilingual_errors.test.tsx deleted file mode 100644 index d218c8d111a2bd..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/e5_multilingual_errors.test.tsx +++ /dev/null @@ -1,34 +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 { setMockValues } from '../../../../../../__mocks__/kea_logic'; - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { EuiCallOut } from '@elastic/eui'; - -import { E5MultilingualErrors } from './e5_multilingual_errors'; - -describe('E5MultilingualErrors', () => { - beforeEach(() => { - jest.clearAllMocks(); - setMockValues({}); - }); - const error = { - title: 'some-error-title', - message: 'some-error-message', - }; - it('extracts error panel with the given title and message', () => { - const wrapper = shallow(); - expect(wrapper.find(EuiCallOut).length).toBe(1); - expect(wrapper.find(EuiCallOut).prop('title')).toEqual(error.title); - expect(wrapper.find(EuiCallOut).find('p').length).toBe(1); - expect(wrapper.find(EuiCallOut).find('p').text()).toEqual(error.message); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/e5_multilingual_errors.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/e5_multilingual_errors.tsx deleted file mode 100644 index 78038fee5d122b..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/e5_multilingual_errors.tsx +++ /dev/null @@ -1,37 +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 { EuiCallOut } from '@elastic/eui'; - -import { i18n } from '@kbn/i18n'; - -import { EuiLinkTo } from '../../../../../../shared/react_router_helpers'; - -import { SendEnterpriseSearchTelemetry } from '../../../../../../shared/telemetry'; - -import { ML_NOTIFICATIONS_PATH } from '../../../../../routes'; - -export function E5MultilingualErrors({ error }: { error: { title: string; message: string } }) { - return ( - <> - - -

{error.message}

- - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.pipelines.e5MultilingualCreateError.mlNotificationsLink', - { - defaultMessage: 'Machine Learning notifications', - } - )} - -
- - ); -} diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/model_deployed.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/model_deployed.test.tsx deleted file mode 100644 index 58df830f43e2d3..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/model_deployed.test.tsx +++ /dev/null @@ -1,81 +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 { setMockValues } from '../../../../../../__mocks__/kea_logic'; - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { EuiButton } from '@elastic/eui'; - -import { E5MultilingualDismissButton } from './e5_multilingual_callout'; -import { ModelDeployed } from './model_deployed'; - -const DEFAULT_VALUES = { - startE5MultilingualModelError: undefined, - isCreateButtonDisabled: false, - isModelDownloadInProgress: false, - isModelDownloaded: false, - isModelStarted: false, - isStartButtonDisabled: false, -}; - -describe('ModelDeployed', () => { - beforeEach(() => { - jest.clearAllMocks(); - setMockValues(DEFAULT_VALUES); - }); - it('renders start button', () => { - const wrapper = shallow( - {}} - ingestionMethod="crawler" - isDismissable={false} - isStartButtonDisabled={false} - /> - ); - expect(wrapper.find(EuiButton).length).toBe(1); - const button = wrapper.find(EuiButton); - expect(button.prop('disabled')).toBe(false); - }); - it('renders disabled start button if it is set to disabled', () => { - const wrapper = shallow( - {}} - ingestionMethod="crawler" - isDismissable={false} - isStartButtonDisabled - /> - ); - expect(wrapper.find(EuiButton).length).toBe(1); - const button = wrapper.find(EuiButton); - expect(button.prop('disabled')).toBe(true); - }); - it('renders dismiss button if it is set to dismissable', () => { - const wrapper = shallow( - {}} - ingestionMethod="crawler" - isDismissable - isStartButtonDisabled={false} - /> - ); - expect(wrapper.find(E5MultilingualDismissButton).length).toBe(1); - }); - it('does not render dismiss button if it is set to non-dismissable', () => { - const wrapper = shallow( - {}} - ingestionMethod="crawler" - isDismissable={false} - isStartButtonDisabled={false} - /> - ); - expect(wrapper.find(E5MultilingualDismissButton).length).toBe(0); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/model_deployed.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/model_deployed.tsx deleted file mode 100644 index 20444dd5f70548..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/model_deployed.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 { useActions } from 'kea'; - -import { - EuiButton, - EuiCallOut, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiSpacer, - EuiText, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -import { - E5MultilingualCallOutState, - E5MultilingualDismissButton, - FineTuneModelsButton, -} from './e5_multilingual_callout'; -import { E5MultilingualCalloutLogic } from './e5_multilingual_callout_logic'; - -export const ModelDeployed = ({ - dismiss, - ingestionMethod, - isDismissable, - isStartButtonDisabled, -}: Pick< - E5MultilingualCallOutState, - 'dismiss' | 'ingestionMethod' | 'isDismissable' | 'isStartButtonDisabled' ->) => { - const { startE5MultilingualModel } = useActions(E5MultilingualCalloutLogic); - - return ( - - - - - - - - - -

- {i18n.translate( - 'xpack.enterpriseSearch.content.index.pipelines.e5MultilingualCallOut.deployedTitle', - { defaultMessage: 'Your E5 model has deployed but not started.' } - )} -

-
-
- {isDismissable && ( - - - - )} -
-
- - -

- {i18n.translate( - 'xpack.enterpriseSearch.content.index.pipelines.e5MultilingualCallOut.deployedBody', - { - defaultMessage: - 'You may start the model in a single-threaded configuration for testing, or tune the performance for a production environment.', - } - )} -

-
-
- - - - - - - startE5MultilingualModel()} - > - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.pipelines.e5MultilingualCallOut.startModelButton.label', - { - defaultMessage: 'Start single-threaded', - } - )} - - - - - - - -
-
- ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/model_deployment_in_progress.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/model_deployment_in_progress.test.tsx deleted file mode 100644 index 1594824aa1a855..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/model_deployment_in_progress.test.tsx +++ /dev/null @@ -1,39 +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 { setMockValues } from '../../../../../../__mocks__/kea_logic'; - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { E5MultilingualDismissButton } from './e5_multilingual_callout'; -import { ModelDeploymentInProgress } from './model_deployment_in_progress'; - -const DEFAULT_VALUES = { - startE5MultilingualModelError: undefined, - isCreateButtonDisabled: false, - isModelDownloadInProgress: false, - isModelDownloaded: false, - isModelStarted: false, - isStartButtonDisabled: false, -}; - -describe('ModelDeploymentInProgress', () => { - beforeEach(() => { - jest.clearAllMocks(); - setMockValues(DEFAULT_VALUES); - }); - it('renders dismiss button if it is set to dismissable', () => { - const wrapper = shallow( {}} isDismissable />); - expect(wrapper.find(E5MultilingualDismissButton).length).toBe(1); - }); - it('does not render dismiss button if it is set to non-dismissable', () => { - const wrapper = shallow( {}} isDismissable={false} />); - expect(wrapper.find(E5MultilingualDismissButton).length).toBe(0); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/model_deployment_in_progress.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/model_deployment_in_progress.tsx deleted file mode 100644 index 7f7627a6f03fcc..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/model_deployment_in_progress.tsx +++ /dev/null @@ -1,58 +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 { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -import { E5MultilingualCallOutState, E5MultilingualDismissButton } from './e5_multilingual_callout'; - -export const ModelDeploymentInProgress = ({ - dismiss, - isDismissable, -}: Pick) => ( - - - - - - - - - -

- {i18n.translate( - 'xpack.enterpriseSearch.content.index.pipelines.e5MultilingualCallOut.deployingTitle', - { defaultMessage: 'Your E5 model is deploying.' } - )} -

-
-
- {isDismissable && ( - - - - )} -
-
- - -

- {i18n.translate( - 'xpack.enterpriseSearch.content.index.pipelines.e5MultilingualCallOut.deployingBody', - { - defaultMessage: - 'You can continue creating your pipeline with other uploaded models in the meantime.', - } - )} -

-
-
-
-
-); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/model_started.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/model_started.test.tsx deleted file mode 100644 index 80563cef58cc44..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/model_started.test.tsx +++ /dev/null @@ -1,57 +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 { setMockValues } from '../../../../../../__mocks__/kea_logic'; - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { EuiText } from '@elastic/eui'; - -import { E5MultilingualDismissButton, FineTuneModelsButton } from './e5_multilingual_callout'; -import { ModelStarted } from './model_started'; - -const DEFAULT_VALUES = { - startE5MultilingualModelError: undefined, - isCreateButtonDisabled: false, - isModelDownloadInProgress: false, - isModelDownloaded: false, - isModelStarted: false, - isStartButtonDisabled: false, -}; - -describe('ModelStarted', () => { - beforeEach(() => { - jest.clearAllMocks(); - setMockValues(DEFAULT_VALUES); - }); - it('renders dismiss button if it is set to dismissable', () => { - const wrapper = shallow( - {}} isCompact={false} isDismissable isSingleThreaded /> - ); - expect(wrapper.find(E5MultilingualDismissButton).length).toBe(1); - }); - it('does not render dismiss button if it is set to non-dismissable', () => { - const wrapper = shallow( - {}} isCompact={false} isDismissable={false} isSingleThreaded /> - ); - expect(wrapper.find(E5MultilingualDismissButton).length).toBe(0); - }); - it('renders fine-tune button if the model is running single-threaded', () => { - const wrapper = shallow( - {}} isCompact={false} isDismissable isSingleThreaded /> - ); - expect(wrapper.find(FineTuneModelsButton).length).toBe(1); - }); - it('does not render description if it is set to compact', () => { - const wrapper = shallow( - {}} isCompact isDismissable isSingleThreaded /> - ); - expect(wrapper.find(EuiText).length).toBe(1); // Title only - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/model_started.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/model_started.tsx deleted file mode 100644 index c6a444373886c0..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/e5_multilingual_callout/model_started.tsx +++ /dev/null @@ -1,135 +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 { - EuiButtonEmpty, - EuiCallOut, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiText, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -import { KibanaLogic } from '../../../../../../shared/kibana'; - -import { TRAINED_MODELS_PATH } from '../utils'; - -import { - E5MultilingualCallOutState, - E5MultilingualDismissButton, - FineTuneModelsButton, -} from './e5_multilingual_callout'; - -export const ModelStarted = ({ - dismiss, - isCompact, - isDismissable, - isSingleThreaded, -}: Pick< - E5MultilingualCallOutState, - 'dismiss' | 'isCompact' | 'isDismissable' | 'isSingleThreaded' ->) => ( - - - - - - - - - -

- {isSingleThreaded - ? isCompact - ? i18n.translate( - 'xpack.enterpriseSearch.content.index.pipelines.e5MultilingualCallOut.startedSingleThreadedTitleCompact', - { defaultMessage: 'Your E5 model is running single-threaded.' } - ) - : i18n.translate( - 'xpack.enterpriseSearch.content.index.pipelines.e5MultilingualCallOut.startedSingleThreadedTitle', - { defaultMessage: 'Your E5 model has started single-threaded.' } - ) - : isCompact - ? i18n.translate( - 'xpack.enterpriseSearch.content.index.pipelines.e5MultilingualCallOut.startedTitleCompact', - { defaultMessage: 'Your E5 model is running.' } - ) - : i18n.translate( - 'xpack.enterpriseSearch.content.index.pipelines.e5MultilingualCallOut.startedTitle', - { defaultMessage: 'Your E5 model has started.' } - )} -

-
-
- {isDismissable && ( - - - - )} -
-
- {!isCompact && ( - <> - - -

- {isSingleThreaded - ? i18n.translate( - 'xpack.enterpriseSearch.content.index.pipelines.e5MultilingualCallOut.startedSingleThreadedBody', - { - defaultMessage: - 'This single-threaded configuration is great for testing your custom inference pipelines, however performance should be fine-tuned for production.', - } - ) - : i18n.translate( - 'xpack.enterpriseSearch.content.index.pipelines.e5MultilingualCallOut.startedBody', - { - defaultMessage: 'Enjoy the power of E5 in your custom Inference pipeline.', - } - )} -

-
-
- - - - {isSingleThreaded ? ( - - ) : ( - - KibanaLogic.values.navigateToUrl(TRAINED_MODELS_PATH, { - shouldNotCreateHref: true, - }) - } - > - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.pipelines.e5MultilingualCallOut.viewModelsButton', - { - defaultMessage: 'View details', - } - )} - - )} - - - - - )} -
-
-); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout/deploy_model.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout/deploy_model.tsx index 74ce840252a8b9..4320fff515b4ed 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout/deploy_model.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/text_expansion_callout/deploy_model.tsx @@ -19,7 +19,7 @@ import { EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedHTMLMessage, FormattedMessage } from '@kbn/i18n-react'; +import { FormattedMessage, FormattedHTMLMessage } from '@kbn/i18n-react'; import { docLinks } from '../../../../../../shared/doc_links'; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/utils/connector_status_helpers.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/utils/connector_status_helpers.ts index dafe4f8cc20413..1704c2d79af077 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/utils/connector_status_helpers.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/utils/connector_status_helpers.ts @@ -30,6 +30,12 @@ export function connectorStatusToText(connectorStatus: ConnectorStatus): string { defaultMessage: 'Configured' } ); } + if (connectorStatus === ConnectorStatus.CONNECTED) { + return i18n.translate( + 'xpack.enterpriseSearch.content.searchIndices.ingestionStatus.connected.label', + { defaultMessage: 'Connected' } + ); + } return i18n.translate( 'xpack.enterpriseSearch.content.searchIndices.ingestionStatus.incomplete.label', diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index 55362dbfa84301..7820ab340b0f9a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -22,7 +22,7 @@ import { Router } from '@kbn/shared-ux-router'; import { DEFAULT_PRODUCT_FEATURES } from '../../common/constants'; import { ClientConfigType, InitialAppData, ProductAccess } from '../../common/types'; -import { PluginsStart, ClientData } from '../plugin'; +import { PluginsStart, ClientData, ESConfig } from '../plugin'; import { externalUrl } from './shared/enterprise_search_url'; import { mountFlashMessagesLogic, Toasts } from './shared/flash_messages'; @@ -50,7 +50,7 @@ export const renderApp = ( params: AppMountParameters; plugins: PluginsStart; }, - { config, data }: { config: ClientConfigType; data: ClientData } + { config, data, esConfig }: { config: ClientConfigType; data: ClientData; esConfig: ESConfig } ) => { const { access, @@ -106,6 +106,7 @@ export const renderApp = ( charts, cloud, config, + esConfig, data: plugins.data, guidedOnboarding, history, @@ -198,5 +199,5 @@ export const renderHeaderActions = ( , kibanaHeaderEl ); - return () => ReactDOM.unmountComponentAtNode(kibanaHeaderEl); + return () => ReactDOM.render(<>, kibanaHeaderEl); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/api_key/api_key_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/api_key/api_key_panel.tsx index 5556b284d8d4a7..c5d1034f488d81 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/api_key/api_key_panel.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/api_key/api_key_panel.tsx @@ -25,7 +25,6 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { ELASTICSEARCH_URL_PLACEHOLDER } from '@kbn/search-api-panels/constants'; import { FetchApiKeysAPILogic } from '../../enterprise_search_overview/api/fetch_api_keys_logic'; import { KibanaLogic } from '../kibana'; @@ -37,16 +36,16 @@ const COPIED_LABEL = i18n.translate('xpack.enterpriseSearch.overview.apiKey.copi }); export const ApiKeyPanel: React.FC = () => { - const { cloud, navigateToUrl } = useValues(KibanaLogic); + const { cloud, esConfig, navigateToUrl } = useValues(KibanaLogic); const { makeRequest } = useActions(FetchApiKeysAPILogic); const { data } = useValues(FetchApiKeysAPILogic); const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); + const elasticsearchEndpoint = esConfig.elasticsearch_host; useEffect(() => makeRequest({}), []); const apiKeys = data?.api_keys || []; const cloudId = cloud?.cloudId; - const elasticsearchEndpoint = cloud?.elasticsearchUrl || ELASTICSEARCH_URL_PLACEHOLDER; return ( <> diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/getting_started/languages/constants.ts b/x-pack/plugins/enterprise_search/public/applications/shared/getting_started/languages/constants.ts index b0b122fa01b5c8..4e7036600eaa94 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/getting_started/languages/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/getting_started/languages/constants.ts @@ -6,5 +6,4 @@ */ export const API_KEY_PLACEHOLDER = 'your_api_key'; -export const ELASTICSEARCH_URL_PLACEHOLDER = 'https://your_deployment_url'; export const INDEX_NAME_PLACEHOLDER = 'index_name'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts index cf7e92dbd34758..201e91b439bc0b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts @@ -19,13 +19,16 @@ import { IUiSettingsClient, } from '@kbn/core/public'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; + import { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public'; import { LensPublicStart } from '@kbn/lens-plugin/public'; import { MlPluginStart } from '@kbn/ml-plugin/public'; +import { ELASTICSEARCH_URL_PLACEHOLDER } from '@kbn/search-api-panels/constants'; import { AuthenticatedUser, SecurityPluginStart } from '@kbn/security-plugin/public'; import { SharePluginStart } from '@kbn/share-plugin/public'; import { ClientConfigType, ProductAccess, ProductFeatures } from '../../../../common/types'; +import { ESConfig } from '../../../plugin'; import { HttpLogic } from '../http'; import { createHref, CreateHrefOptions } from '../react_router_helpers'; @@ -40,6 +43,7 @@ export interface KibanaLogicProps { cloud?: CloudSetup; config: ClientConfigType; data: DataPublicPluginStart; + esConfig: ESConfig; guidedOnboarding?: GuidedOnboardingPluginStart; history: ScopedHistory; isSidebarEnabled: boolean; @@ -75,6 +79,7 @@ export const KibanaLogic = kea>({ cloud: [props.cloud || {}, {}], config: [props.config || {}, {}], data: [props.data, {}], + esConfig: [props.esConfig || { elasticsearch_host: ELASTICSEARCH_URL_PLACEHOLDER }, {}], guidedOnboarding: [props.guidedOnboarding, {}], history: [props.history, {}], isSidebarEnabled: [props.isSidebarEnabled, {}], diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/endpoints_header_action.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/endpoints_header_action.tsx index bbb27c342c9922..35f1d28378c744 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/endpoints_header_action.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/endpoints_header_action.tsx @@ -33,7 +33,6 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { ELASTICSEARCH_URL_PLACEHOLDER } from '@kbn/search-api-panels/constants'; import { FetchApiKeysAPILogic } from '../../enterprise_search_overview/api/fetch_api_keys_logic'; import { CreateApiKeyFlyout } from '../api_key/create_api_key_flyout'; @@ -43,7 +42,7 @@ import { EndpointIcon } from './endpoint_icon'; export const EndpointsHeaderAction: React.FC = ({ children }) => { const [isPopoverOpen, setPopoverOpen] = useState(false); - const { cloud, navigateToUrl } = useValues(KibanaLogic); + const { cloud, esConfig, navigateToUrl } = useValues(KibanaLogic); const { makeRequest } = useActions(FetchApiKeysAPILogic); const { data } = useValues(FetchApiKeysAPILogic); const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); @@ -57,7 +56,7 @@ export const EndpointsHeaderAction: React.FC = ({ children }) => { const apiKeys = data?.api_keys || []; const cloudId = cloud?.cloudId; - const elasticsearchEndpoint = cloud?.elasticsearchUrl || ELASTICSEARCH_URL_PLACEHOLDER; + const elasticsearchEndpoint = esConfig.elasticsearch_host; const button = ( setPopoverOpen(!isPopoverOpen)}> diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.tsx index 2553a12dc17c51..f0cff6a29880ed 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/page_template.tsx @@ -76,7 +76,7 @@ export const EnterpriseSearchPageTemplateWrapper: React.FC = renderHeaderActions(EndpointsHeaderAction); } return () => { - renderHeaderActions(); + renderHeaderActions(undefined); }; }, []); return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/test_helpers/test_utils.test_helper.tsx b/x-pack/plugins/enterprise_search/public/applications/test_helpers/test_utils.test_helper.tsx index b26052f72e80c0..cab4a7c04b368c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/test_helpers/test_utils.test_helper.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/test_helpers/test_utils.test_helper.tsx @@ -48,6 +48,9 @@ export const mockKibanaProps: KibanaLogicProps = { }, }, data: dataPluginMock.createStartContract(), + esConfig: { + elasticsearch_host: 'https://your_deployment_url', + }, guidedOnboarding: {}, history: mockHistory, isSidebarEnabled: true, diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index ce116ee52c2a62..e7b9b862f3d5db 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -23,6 +23,7 @@ import type { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import { LensPublicStart } from '@kbn/lens-plugin/public'; import { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import { MlPluginStart } from '@kbn/ml-plugin/public'; +import { ELASTICSEARCH_URL_PLACEHOLDER } from '@kbn/search-api-panels/constants'; import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public'; import { SharePluginStart } from '@kbn/share-plugin/public'; @@ -69,17 +70,29 @@ export interface PluginsStart { ml: MlPluginStart; } +export interface ESConfig { + elasticsearch_host: string; +} + export class EnterpriseSearchPlugin implements Plugin { private config: ClientConfigType; + private esConfig: ESConfig; constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.get(); + this.esConfig = { elasticsearch_host: ELASTICSEARCH_URL_PLACEHOLDER }; } private data: ClientData = {} as ClientData; private async getInitialData(http: HttpSetup) { - if (!this.config.host && this.config.canDeployEntSearch) return; // No API to call + try { + this.esConfig = await http.get('/internal/enterprise_search/es_config'); + } catch { + this.esConfig = { elasticsearch_host: ELASTICSEARCH_URL_PLACEHOLDER }; + } + + if (!this.config.host) return; // No API to call if (this.hasInitialized) return; // We've already made an initial call try { @@ -113,7 +126,12 @@ export class EnterpriseSearchPlugin implements Plugin { private getPluginData() { // Small helper for grouping plugin data related args together - return { config: this.config, data: this.data, isSidebarEnabled: this.isSidebarEnabled }; + return { + config: this.config, + data: this.data, + esConfig: this.esConfig, + isSidebarEnabled: this.isSidebarEnabled, + }; } private hasInitialized: boolean = false; diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts b/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts index 623b851b9ab7ce..dba33ade883244 100644 --- a/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts +++ b/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts @@ -10,6 +10,7 @@ import { loggingSystemMock } from '@kbn/core/server/mocks'; import { mlPluginServerMock } from '@kbn/ml-plugin/server/mocks'; import { ConfigType } from '..'; +import { GlobalConfigService } from '../services/global_config_service'; export const mockLogger = loggingSystemMock.createLogger().get(); @@ -36,6 +37,7 @@ export const mockConfig = { export const mockDependencies = { // Mock router should be handled on a per-test basis config: mockConfig, + globalConfigService: new GlobalConfigService(), log: mockLogger, enterpriseSearchRequestHandler: mockRequestHandler as any, ml: mockMl, diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts index e19cdc71532159..ab4b27ed1f1c27 100644 --- a/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts @@ -7,6 +7,8 @@ import { spacesMock } from '@kbn/spaces-plugin/server/mocks'; +import { GlobalConfigService } from '../services/global_config_service'; + import { checkAccess } from './check_access'; jest.mock('./enterprise_search_config_api', () => ({ @@ -51,6 +53,7 @@ describe('checkAccess', () => { canDeployEntSearch: true, host: 'http://localhost:3002', }, + globalConfigService: new GlobalConfigService(), security: mockSecurity, spaces: mockSpaces, } as any; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts index ae6f7b46076538..fb17854f6f6745 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts @@ -19,6 +19,8 @@ jest.mock('@kbn/repo-info', () => ({ import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { GlobalConfigService } from '../services/global_config_service'; + import { callEnterpriseSearchConfigAPI, warnMismatchedVersions, @@ -37,6 +39,7 @@ describe('callEnterpriseSearchConfigAPI', () => { }; const mockDependencies = { config: mockConfig, + globalConfigService: new GlobalConfigService(), request: mockRequest, log: loggingSystemMock.create().get(), } as any; diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 1132de4fbcccfd..7a764e9ef6fb5d 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -77,6 +77,7 @@ import { appSearchTelemetryType } from './saved_objects/app_search/telemetry'; import { enterpriseSearchTelemetryType } from './saved_objects/enterprise_search/telemetry'; import { workplaceSearchTelemetryType } from './saved_objects/workplace_search/telemetry'; +import { GlobalConfigService } from './services/global_config_service'; import { uiSettings as enterpriseSearchUISettings } from './ui_settings'; import { getSearchResultProvider } from './utils/search_result_provider'; @@ -104,9 +105,8 @@ export interface PluginsStart { export interface RouteDependencies { config: ConfigType; enterpriseSearchRequestHandler: IEnterpriseSearchRequestHandler; - getSavedObjectsService?(): SavedObjectsServiceStart; - + globalConfigService: GlobalConfigService; log: Logger; ml?: MlPluginSetup; router: IRouter; @@ -115,6 +115,7 @@ export interface RouteDependencies { export class EnterpriseSearchPlugin implements Plugin { private readonly config: ConfigType; private readonly logger: Logger; + private readonly globalConfigService: GlobalConfigService; /** * Exposed services @@ -122,11 +123,19 @@ export class EnterpriseSearchPlugin implements Plugin { constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.get(); + this.globalConfigService = new GlobalConfigService(); this.logger = initializerContext.logger.get(); } public setup( - { capabilities, http, savedObjects, getStartServices, uiSettings }: CoreSetup, + { + capabilities, + elasticsearch, + http, + savedObjects, + getStartServices, + uiSettings, + }: CoreSetup, { usageCollection, security, @@ -139,6 +148,7 @@ export class EnterpriseSearchPlugin implements Plugin { cloud, }: PluginsSetup ) { + this.globalConfigService.setup(elasticsearch.legacy.config$, cloud); const config = this.config; const log = this.logger; const PLUGIN_IDS = [ @@ -185,7 +195,14 @@ export class EnterpriseSearchPlugin implements Plugin { async (request: KibanaRequest) => { const [, { spaces }] = await getStartServices(); - const dependencies = { config, security, spaces, request, log, ml }; + const dependencies = { + config, + security, + spaces, + request, + log, + ml, + }; const { hasAppSearchAccess, hasWorkplaceSearchAccess } = await checkAccess(dependencies); const showEnterpriseSearch = @@ -228,7 +245,14 @@ export class EnterpriseSearchPlugin implements Plugin { */ const router = http.createRouter(); const enterpriseSearchRequestHandler = new EnterpriseSearchRequestHandler({ config, log }); - const dependencies = { router, config, log, enterpriseSearchRequestHandler, ml }; + const dependencies = { + router, + config, + globalConfigService: this.globalConfigService, + log, + enterpriseSearchRequestHandler, + ml, + }; registerConfigDataRoute(dependencies); if (config.canDeployEntSearch) registerAppSearchRoutes(dependencies); diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/config_data.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/config_data.ts index e65941cb7f20e6..7983c2c88b5d93 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/config_data.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/config_data.ts @@ -18,7 +18,12 @@ const errorMessage = i18n.translate( } ); -export function registerConfigDataRoute({ router, config, log }: RouteDependencies) { +export function registerConfigDataRoute({ + router, + config, + log, + globalConfigService, +}: RouteDependencies) { router.get( { path: '/internal/enterprise_search/config_data', @@ -45,4 +50,17 @@ export function registerConfigDataRoute({ router, config, log }: RouteDependenci } }) ); + + router.get( + { + path: '/internal/enterprise_search/es_config', + validate: false, + }, + elasticsearchErrorHandler(log, async (context, request, response) => { + return response.ok({ + body: { elasticsearch_host: globalConfigService.elasticsearchUrl }, + headers: { 'content-type': 'application/json' }, + }); + }) + ); } diff --git a/x-pack/plugins/enterprise_search/server/services/global_config_service.ts b/x-pack/plugins/enterprise_search/server/services/global_config_service.ts new file mode 100644 index 00000000000000..ecb2edac986440 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/services/global_config_service.ts @@ -0,0 +1,57 @@ +/* + * 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 { Observable, Subscription } from 'rxjs'; + +import { CloudSetup } from '@kbn/cloud-plugin/server'; +import { ElasticsearchConfig } from '@kbn/core/server'; + +export class GlobalConfigService { + /** + * + */ + + private cloudUrl?: string; + + /** + * An observable that emits elasticsearch config. + */ + private config$?: Observable; + + /** + * A reference to the subscription to the elasticsearch observable + */ + private configSub?: Subscription; + + public get elasticsearchUrl(): string { + return this.cloudUrl + ? this.cloudUrl + : this.globalConfigElasticsearchUrl || 'https://your_deployment_url'; + } + + /** + * The elasticsearch config value at a given point in time. + */ + private globalConfigElasticsearchUrl?: string; + + setup(config$: Observable, cloud: CloudSetup) { + this.cloudUrl = cloud.elasticsearchUrl; + this.config$ = config$; + this.configSub = this.config$.subscribe((config) => { + const rawHost = config.hosts[0]; + // strip username, password, URL params and other potentially sensitive info from hosts URL + const hostUrl = new URL(rawHost); + this.globalConfigElasticsearchUrl = `${hostUrl.origin}${hostUrl.pathname}`; + }); + } + + stop() { + if (this.configSub) { + this.configSub.unsubscribe(); + } + } +} diff --git a/x-pack/plugins/enterprise_search/server/utils/get_sync_jobs_queries.ts b/x-pack/plugins/enterprise_search/server/utils/get_sync_jobs_queries.ts index 26c0b42edfde00..9dcdc5b44192c3 100644 --- a/x-pack/plugins/enterprise_search/server/utils/get_sync_jobs_queries.ts +++ b/x-pack/plugins/enterprise_search/server/utils/get_sync_jobs_queries.ts @@ -295,14 +295,7 @@ export const getConnectedCountQuery = (isCrawler?: boolean) => { }, { term: { - 'connector.service_type': CRAWLER_SERVICE_TYPE, - }, - }, - { - range: { - last_seen: { - gte: moment().subtract(30, 'minutes').toISOString(), - }, + service_type: CRAWLER_SERVICE_TYPE, }, }, ], @@ -321,7 +314,7 @@ export const getConnectedCountQuery = (isCrawler?: boolean) => { bool: { must_not: { term: { - 'connector.service_type': CRAWLER_SERVICE_TYPE, + service_type: CRAWLER_SERVICE_TYPE, }, }, }, @@ -366,21 +359,10 @@ export const getIncompleteCountQuery = (isCrawler?: boolean) => { if (isCrawler) { return { bool: { - should: [ - { - bool: { - must_not: { - terms: { - status: [ConnectorStatus.CONNECTED, ConnectorStatus.ERROR], - }, - }, - }, - }, + must_not: [ { - range: { - last_seen: { - lt: moment().subtract(30, 'minutes').toISOString(), - }, + terms: { + status: [ConnectorStatus.CONNECTED, ConnectorStatus.ERROR], }, }, ], diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts index a147d80f6639d2..3cf30df38c9615 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts @@ -673,6 +673,13 @@ describe('aggregateEventsBySavedObject', () => { ], }, }, + hits: { + hits: [], + total: { + relation: 'eq', + value: 0, + }, + }, }); }); }); @@ -771,6 +778,13 @@ describe('aggregateEventsWithAuthFilter', () => { ], }, }, + hits: { + hits: [], + total: { + relation: 'eq', + value: 0, + }, + }, }); }); @@ -919,6 +933,13 @@ describe('aggregateEventsWithAuthFilter', () => { ], }, }, + hits: { + hits: [], + total: { + relation: 'eq', + value: 0, + }, + }, }); }); }); diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts index 1eb1219621c370..530374c770bbc9 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts @@ -82,6 +82,7 @@ export type AggregateEventsOptionsBySavedObjectFilter = QueryOptionsEventsBySave }; export interface AggregateEventsBySavedObjectResult { + hits?: estypes.SearchHitsMetadata; aggregations: Record | undefined; } @@ -455,12 +456,13 @@ export class ClusterClientAdapter({ + const { aggregations, hits } = await esClient.search({ index, body, }); return { aggregations, + hits, }; } catch (err) { throw new Error( @@ -488,19 +490,20 @@ export class ClusterClientAdapter({ + const { aggregations, hits } = await esClient.search({ index, body, }); return { aggregations, + hits, }; } catch (err) { - throw new Error( + this.logger.debug( `querying for Event Log by for type "${type}" and auth filter failed with: ${err.message}` ); + throw err; } } } diff --git a/x-pack/plugins/exploratory_view/public/components/shared/filter_value_label/filter_value_label.tsx b/x-pack/plugins/exploratory_view/public/components/shared/filter_value_label/filter_value_label.tsx index 934c30c8061f4f..af540d4e1f60b9 100644 --- a/x-pack/plugins/exploratory_view/public/components/shared/filter_value_label/filter_value_label.tsx +++ b/x-pack/plugins/exploratory_view/public/components/shared/filter_value_label/filter_value_label.tsx @@ -77,7 +77,7 @@ export function FilterValueLabel({ const filter = buildFilterLabel({ field, value, label, dataView, negate }); const { - services: { uiSettings, docLinks }, + services: { uiSettings, docLinks, dataViews }, } = useKibana(); return dataView ? ( @@ -101,6 +101,7 @@ export function FilterValueLabel({ 'editFilter', 'disableFilter', ]} + dataViews={dataViews} /> ) : null; } diff --git a/x-pack/plugins/fleet/dev_docs/developing_kibana_and_fleet_server.md b/x-pack/plugins/fleet/dev_docs/developing_kibana_and_fleet_server.md new file mode 100644 index 00000000000000..376d6cbb739e68 --- /dev/null +++ b/x-pack/plugins/fleet/dev_docs/developing_kibana_and_fleet_server.md @@ -0,0 +1,418 @@ +# Developing Kibana and Fleet Server simulatanously + +Many times, a contributor to Fleet will only need to make changes to [Fleet Server](https://github.com/elastic/fleet-server) or [Kibana](https://github.com/elastic/kibana) - not both. But, there are times when end-to-end changes across both componenents are necessary. To facilitate this, we've created a guide to help you get up and running with a local development environment that includes both Kibana and Fleet Server. This is a more involved process than setting up either component on its own. + +This guide seeks to get you up and running with the following stack components for local development: + +- Kibana (from source) +- Elasticsearch (from a snapshot) +- Fleet Server (from source, in standalone mode) + +Getting this development environment up and running will allow you to make changes to both Kibana and Fleet Server simultaneously to develop and test features end-to-end. + +Before continuing, please review the developer documentation for both Fleet Server and Kibana. Make sure your local workstation can run each service independently. This means setting up Go, Node.js, Yarn, resolving dependencies, etc. + +- https://github.com/elastic/fleet-server?tab=readme-ov-file#development +- https://github.com/elastic/kibana/blob/main/CONTRIBUTING.md + +You can either run your local stack over HTTPS or HTTP. Each has its own set of instructions below. An HTTPS environment more closely resembles production, but requires a bit more setup. HTTPS may be necessary when developing certain features related to certificates. An HTTP environment is easier to set up, and will generally be the fastest and easiest way to get up and running for end-to-end development. + +_This guide expects you have forked + cloned the https://github.com/elastic/fleet-server and https://github.com/elastic/kibana repoisitories locally._ + +## Running a local stack over HTTP + +1. In your `kibana` directory, run a local Elasticsearch server from the latest snapshot build by running: + +```bash +# Set a data path to prevent blowing away your Elasticsearch data between server restarts +$ yarn es snapshot --license trial -E path.data=/tmp/es-data +``` + +2. Add the following to your `kibana.dev.yml` + +```yaml +server.basePath: '/some-base-path' # Optional, if used, sets basePath in kibana url e.g. https://localhost:5601/some-base-path/app/fleet +server.versioned.versionResolution: oldest +elasticsearch.hosts: [http://localhost:9200] + +# Optional - set up an APM service in a cloud.elastic.co cluster to send your dev logs, traces, etc +# Can be helpful for troubleshooting and diagnosting performance issues +# elastic.apm: +# active: true +# serverUrl: +# secretToken: some-token +# breakdownMetrics: true +# transactionSampleRate: 0.1 + +logging: + loggers: + - name: plugins.fleet + appenders: [console] + level: debug + +# Allows enrolling agents when standalone Fleet Server is in use +xpack.fleet.internal.fleetServerStandalone: true + +xpack.fleet.fleetServerHosts: + - id: localhost + name: Localhost + host_urls: ['http://localhost:8220'] + # If you want to run a Fleet Server containers via Docker, use this Fleet Server host + - id: docker + name: Docker Internal Gateway + host_urls: ['http://host.docker.internal:8220'] + is_default: true + +xpack.fleet.packages: + - name: fleet_server + version: latest + +xpack.fleet.outputs: + - id: preconfigured-localhost-output + name: Localhost Output + type: elasticsearch + hosts: ['https://localhost:9200'] + is_default: true + + # If you enroll agents via Docker, use this output so they can output to your local + # Elasticsearch cluster + - id: preconfigured-docker-output + name: Docker Output + type: elasticsearch + hosts: ['https://host.docker.internal:9200'] + +xpack.fleet.agentPolicies: + - name: Fleet Server Policy + id: fleet-server-policy + is_default_fleet_server: true + package_policies: + - package: + name: fleet_server + name: Fleet Server + id: fleet_server + inputs: + - type: fleet-server + keep_enabled: true + vars: + - name: host + value: 0.0.0.0 + frozen: true + - name: port + value: 8220 + frozen: true +``` + +4. Navigate to https://localhost:5601/app/fleet and click "Add Fleet Server" +5. Ensure your `localhost` Fleet Server host is selected and generate a service token +6. Copy the service token somewhere convenient - you'll need it to run Fleet Server below +7. In a new terminal session, navigate to your `fleet-server` directory +8. Create a `fleet-server.dev.yml` file if one doesn't exist. This file is git ignored, so we can make our configuration changes directly instead of having to use environment variables or accidentally tracking changes to `fleet-server.yml`. + +```bash +$ cp fleet-server.yml fleet-server.dev.yml +``` + +9. Add the following to your `fleet-server.dev.yml` file + +```yaml +output: + elasticsearch: + hosts: 'http://localhost:9200' + # Copy the service token from the Fleet onboarding UI in Kibana + service_token: 'your_service_token' + +fleet: + agent: + id: '${FLEET_SERVER_AGENT_ID:dev-fleet-server}' + +inputs: + - type: fleet-server + policy.id: '${FLEET_SERVER_POLICY_ID:fleet-server-policy}' + +logging: + to_stderr: true # Force the logging output to stderr + pretty: true + level: '${LOG_LEVEL:DEBUG}' + +# Enables the stats endpoint under by default. +# Additional stats can be found under and +http.enabled: true +#http.host: +#http.port: 5601 +``` + +10. Run the following in your `fleet-server` directory to build and run your local Fleet Server + +```bash +# Create standalone dev build +$ DEV=true SNAPSHOT=true make release-darwin/amd64 + +# Run dev build, provide your fingerprint and service token from before +# Replace 8.13.0-SNAPSHOT with the latest version on main +$ ./build/binaries/fleet-server-8.13.0-SNAPSHOT-darwin-x86_64/fleet-server -c fleet-server.dev.yml +``` + +Now you should have a local ES snapshot running on http://localhost:9200, a local Kibana running on http://localhost:5601, and a local Fleet Server running on http://localhost:8220. You can now navigate to http://localhost:5601/app/fleet and [enroll agents](#enroll-agents). + +## Running a local stack over HTTPS + +The instructions for HTTPS are largely the same, with a few key differences: + +1. You'll need to provide the `--ssl` flag to your ES + Kibana commands, e.g. + +```bash +# In your `kibana` directory +$ yarn es snapshot --license trial --ssl -E path.data=/tmp/es-data +$ yarn start --ssl +``` + +2. Change various URLs in `kibana.dev.yml` to use `https` instead of `http`, and add a `ca_trusted_fingerprint` calculated from the `ca.crt` certificate in Kibana's dev utils package. Your `kibana.dev.yml` should be the same as above, with the following changes: + +```yaml +server.basePath: '/some-base-path' # Optional, if used, sets basePath in kibana url e.g. https://localhost:5601/some-base-path/app/fleet +server.versioned.versionResolution: oldest +elasticsearch.hosts: [https://localhost:9200] # <-- Updated to https + +# Optional - set up an APM service in a cloud.elastic.co cluster to send your dev logs, traces, etc +# Can be helpful for troubleshooting and diagnosting performance issues +# elastic.apm: +# active: true +# serverUrl: +# secretToken: some-token +# breakdownMetrics: true +# transactionSampleRate: 0.1 + +logging: + loggers: + - name: plugins.fleet + appenders: [console] + level: debug + +# Allows enrolling agents when standalone Fleet Server is in use +xpack.fleet.internal.fleetServerStandalone: true + +xpack.fleet.fleetServerHosts: + - id: localhost + name: Localhost + # Make sure this is `https` since we're running our local Fleet Server with SSL enabled + host_urls: ['https://localhost:8220'] # <-- Updated to https + is_default: true + # If you want to run a Fleet Server in Docker, use this Fleet Server host + - id: docker + name: Docker Internal Gateway + host_urls: ['https://host.docker.internal:8220'] # <-- Updated to https + +xpack.fleet.packages: + - name: fleet_server + version: latest + +xpack.fleet.outputs: + - id: preconfigured-localhost-output + name: Localhost Output + type: elasticsearch + hosts: ['https://localhost:9200'] # <-- Updated to https + ca_trusted_fingerprint: 'f71f73085975fd977339a1909ebfe2df40db255e0d5bb56fc37246bf383ffc84' # <-- Added + is_default: true + + # If you enroll agents via Docker, use this output so they can output to your local + # Elasticsearch cluster + - id: preconfigured-docker-output + name: Docker Output + type: elasticsearch + hosts: ['https://host.docker.internal:9200'] # <-- Updated to https + ca_trusted_fingerprint: 'f71f73085975fd977339a1909ebfe2df40db255e0d5bb56fc37246bf383ffc84' # <-- Added + +xpack.fleet.agentPolicies: + - name: Fleet Server Policy + id: fleet-server-policy + is_default_fleet_server: true + package_policies: + - package: + name: fleet_server + name: Fleet Server + id: fleet_server + inputs: + - type: fleet-server + keep_enabled: true + vars: + - name: host + value: 0.0.0.0 + frozen: true + - name: port + value: 8220 + frozen: true +``` + +3. Update your `fleet-server.dev.yml` to look as follows + +```yaml +# This config is intended to be used with a stand-alone fleet-server instance for development. +output: + elasticsearch: + hosts: 'https://localhost:9200' # <-- Updated to https + # Copy the service token from the Fleet onboarding UI in Kibana + service_token: 'your_service_token' + # Fingerprint of the ca.crt certificate in Kibana's dev utils package + ssl.ca_trusted_fingerprint: 'f71f73085975fd977339a1909ebfe2df40db255e0d5bb56fc37246bf383ffc84' + +fleet: + agent: + id: '${FLEET_SERVER_AGENT_ID:dev-fleet-server}' + +inputs: + - type: fleet-server + policy.id: '${FLEET_SERVER_POLICY_ID:fleet-server-policy}' + # Enable SSL, point at Kibana's self-signed certs + server: + ssl: + enabled: true + certificate: ../kibana/packages/kbn-dev-utils/certs/fleet_server.crt + key: ../kibana/packages/kbn-dev-utils/certs/fleet_server.key + key_passphrase: ../kibana/packages/kbn-dev-utils/certs/fleet_server.key + +logging: + to_stderr: true # Force the logging output to stderr + pretty: true + level: '${LOG_LEVEL:DEBUG}' + +# Enables the stats endpoint under by default. +# Additional stats can be found under and +http.enabled: true +#http.host: +#http.port: 5601 +``` + +With these changes in place, the process to start up your local stack is the same as above. + +## Enroll agents + +Once you have your local stack up and running, you can enroll agents to test your changes end-to-end. There are a few ways to do this. The fastest is to spin up a Docker container running Elastic Agent, e.g. + +```bash +docker run --add-host host.docker.internal:host-gateway \ + --env FLEET_ENROLL=1 --env FLEET_INSECURE=true\ + --env FLEET_URL=https://localhost:8220 \ + --env FLEET_ENROLLMENT_TOKEN=enrollment_token \ + docker.elastic.co/beats/elastic-agent:8.13.0-SNAPSHOT # <-- Update this version as needed +``` + +You can also create a `run-dockerized-agent.sh` file as below to make this process easier. This script will run a Docker container with Elastic Agent and enroll it to your local Fleet Server. You can also use it to run a Dockerized Fleet Server container if you don't need to develop Fleet Server locally. + +```bash +#!/usr/bin/env bash + +# Name this file `run-dockerized-agent.sh` and place it somewhere convenient. Make sure to run `chmod +x` on it to make it executable. + +# This script is used to run a instance of Elastic Agent in a Docker container. +# Ref.: https://www.elastic.co/guide/en/fleet/current/elastic-agent-container.html + +# To run a Fleet server: ./run_dockerized_agent.sh fleet_server +# To run an agent: ./run_dockerized_agent agent -e -v -t + +# NB: this script assumes a Fleet server policy with id "fleet-server-policy" is already created. + +CMD=$1 + +while [ $# -gt 0 ]; do + case $1 in + -e | --enrollment-token) ENROLLMENT_TOKEN=$2 ;; + -v | --version) ELASTIC_AGENT_VERSION=$2 ;; + -t | --tags) TAGS=$2 ;; + esac + shift +done + +DEFAULT_ELASTIC_AGENT_VERSION=8.13.0-SNAPSHOT # update as needed + +# Needed for Fleet Server +ELASTICSEARCH_HOST=http://host.docker.internal:9200 # should match Fleet settings or xpack.fleet.agents.elasticsearch.hosts in kibana.dev.yml +KIBANA_HOST=http://host.docker.internal:5601 +KIBANA_BASE_PATH=kyle # should match server.basePath in kibana.dev.yml +FLEET_SERVER_POLICY_ID=fleet-server-policy # as defined in kibana.dev.yml + +# Needed for agent +FLEET_SERVER_URL=https://host.docker.internal:8220 + +printArgs() { + if [[ $ELASTIC_AGENT_VERSION == "" ]]; then + ELASTIC_AGENT_VERSION=$DEFAULT_ELASTIC_AGENT_VERSION + echo "No Elastic Agent version specified, setting to $ELASTIC_AGENT_VERSION (default)" + else + echo "Received Elastic Agent version $ELASTIC_AGENT_VERSION" + fi + + if [[ $ENROLLMENT_TOKEN == "" ]]; then + echo "Warning: no enrollment token provided!" + else + echo "Received enrollment token: ${ENROLLMENT_TOKEN}" + fi + + if [[ $TAGS != "" ]]; then + echo "Received tags: ${TAGS}" + fi +} + +echo "--- Elastic Agent Container Runner ---" + +if [[ $CMD == "fleet_server" ]]; then + echo "Starting Fleet Server container..." + + printArgs + + docker run \ + -e ELASTICSEARCH_HOST=${ELASTICSEARCH_HOST} \ + -e KIBANA_HOST=${KIBANA_HOST}/${KIBANA_BASE_PATH} \ + -e KIBANA_USERNAME=elastic \ + -e KIBANA_PASSWORD=changeme \ + -e KIBANA_FLEET_SETUP=1 \ + -e FLEET_INSECURE=1 \ + -e FLEET_SERVER_ENABLE=1 \ + -e FLEET_SERVER_POLICY_ID=${FLEET_SERVER_POLICY_ID} \ + -e ELASTIC_AGENT_TAGS=${TAGS} \ + -p 8220:8220 \ + --rm docker.elastic.co/beats/elastic-agent:${ELASTIC_AGENT_VERSION} + +elif [[ $CMD == "agent" ]]; then + echo "Starting Elastic Agent container..." + + printArgs + + docker run \ + -e FLEET_URL=${FLEET_SERVER_URL} \ + -e FLEET_ENROLL=1 \ + -e FLEET_ENROLLMENT_TOKEN=${ENROLLMENT_TOKEN} \ + -e FLEET_INSECURE=1 \ + -e ELASTIC_AGENT_TAGS=${TAGS} \ + --rm docker.elastic.co/beats/elastic-agent:${ELASTIC_AGENT_VERSION} + +elif [[ $CMD == "help" ]]; then + echo "Usage: ./run_elastic_agent.sh -e -v -t " + +elif [[ $CMD == "" ]]; then + echo "Command missing. Available commands: agent, fleet_server, help" + +else + echo "Invalid command: $CMD" +fi +``` + +Another option is to use a lightweight virtualization provider like https://multipass.run/ and enrolling agents using an enrollment token generated via Fleet UI. You will need to add a Fleet Server Host entry + Output to your Fleet settings that corresponds with your Multipass bridge network interface, similar to how we've set up Docker above. + +_To do: add specific docs for enrolling Multipass agents and link here_ + +## Running in serverless mode + +If you want to run your local stack in serverless mode, you'll only need to alter the commands used to start Elasticsearch and Kibana. Fleet Server does not require any changes outside of what's listed above to run in a serverless context. From your Kibana, start a serverless Elasticsearch snapshot, and then run Kibana as either a security or observability project. + +```bash +# Start Elasticsearch in serverless mode +yarn es serverless --kill + +# Run kibana as a security project +yarn serverless-security + +# Run kibana as an observability project +yarn serverless-oblt +``` + +Once running, you can login with the username `elastic_serverless` or `system_indices_superuser` and the password `changeme`. 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 15f4fc928eada8..1fa4b2176f1ab7 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 686934377fdf3b..0060258fe905fb 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/agent_policy/details_page/components/package_policies/package_policies_table.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/package_policies_table.tsx index 6f835651e6dbf9..934fee64b4043f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/package_policies_table.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/package_policies/package_policies_table.tsx @@ -238,6 +238,7 @@ export const PackagePoliciesTable: React.FunctionComponent = ({ return canWriteIntegrationPolicies ? ( { @@ -17,6 +18,7 @@ export const Policy = memo(() => { params: { packagePolicyId }, } = useRouteMatch<{ packagePolicyId: string }>(); + const { search } = useLocation(); const { data: packagePolicyData } = useGetOnePackagePolicyQuery(packagePolicyId); const extensionView = useUIExtension( @@ -24,10 +26,21 @@ export const Policy = memo(() => { 'package-policy-edit' ); + const qs = new URLSearchParams(search); + const fromQs = qs.get('from'); + + let from: EditPackagePolicyFrom | undefined; + + if (fromQs && fromQs === 'fleet-policy-list') { + from = 'edit'; + } else { + from = 'package-edit'; + } + return ( ); diff --git a/x-pack/plugins/fleet/public/components/package_policy_actions_menu.tsx b/x-pack/plugins/fleet/public/components/package_policy_actions_menu.tsx index 24525ce709efd2..e2f581d38d1db3 100644 --- a/x-pack/plugins/fleet/public/components/package_policy_actions_menu.tsx +++ b/x-pack/plugins/fleet/public/components/package_policy_actions_menu.tsx @@ -24,12 +24,14 @@ export const PackagePolicyActionsMenu: React.FunctionComponent<{ showAddAgent?: boolean; defaultIsOpen?: boolean; upgradePackagePolicyHref?: string; + from?: 'fleet-policy-list' | undefined; }> = ({ agentPolicy, packagePolicy, showAddAgent, upgradePackagePolicyHref, defaultIsOpen = false, + from, }) => { const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false); const { getHref } = useLink(); @@ -80,9 +82,9 @@ export const PackagePolicyActionsMenu: React.FunctionComponent<{ data-test-subj="PackagePolicyActionsEditItem" disabled={!canWriteIntegrationPolicies || !agentPolicy} icon="pencil" - href={getHref('integration_policy_edit', { + href={`${getHref('integration_policy_edit', { packagePolicyId: packagePolicy.id, - })} + })}${from ? `?from=${from}` : ''}`} key="packagePolicyEdit" > { if (error instanceof ConcurrentInstallOperationError) { return 409; } + if (error instanceof PackageSavedObjectConflictError) { + return 409; + } if (error instanceof PackagePolicyNameExistsError) { return 409; } diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index ce7245672e623e..6a69581c11965b 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -43,6 +43,7 @@ export class PackageInvalidArchiveError extends FleetError {} export class PackageRemovalError extends FleetError {} export class PackageESError extends FleetError {} export class ConcurrentInstallOperationError extends FleetError {} +export class PackageSavedObjectConflictError extends FleetError {} export class KibanaSOReferenceError extends FleetError {} export class PackageAlreadyInstalledError extends FleetError {} 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 96bda0ed31ae8c..1ed3290625141b 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 50dc1263ddf07c..8c0220b4a1d175 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 3e97594ee959f4..ab6d125f396853 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/epm/packages/_install_package.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.test.ts index d6d653fd98c4e0..ce416a6277313e 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.test.ts @@ -14,7 +14,7 @@ import { savedObjectsClientMock, elasticsearchServiceMock } from '@kbn/core/serv import { loggerMock } from '@kbn/logging-mocks'; import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; -import { ConcurrentInstallOperationError } from '../../../errors'; +import { ConcurrentInstallOperationError, PackageSavedObjectConflictError } from '../../../errors'; import type { Installation } from '../../../../common'; @@ -254,7 +254,6 @@ describe('_installPackage', () => { }); describe('when package is stuck in `installing`', () => { - afterEach(() => {}); const mockInstalledPackageSo: SavedObject = { id: 'mocked-package', attributes: { @@ -387,4 +386,56 @@ describe('_installPackage', () => { }); }); }); + + it('surfaces saved object conflicts error', () => { + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: false, + disableProxies: false, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + + mockedInstallKibanaAssetsAndReferences.mockRejectedValueOnce( + new PackageSavedObjectConflictError('test') + ); + + expect( + _installPackage({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext: { + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + } as any, + assetsMap: new Map(), + paths: [], + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }) + ).rejects.toThrowError(PackageSavedObjectConflictError); + }); }); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts index 6c30d3a8d332df..e182fd8721075d 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts @@ -45,7 +45,7 @@ import { installTransforms } from '../elasticsearch/transform/install'; import { installMlModel } from '../elasticsearch/ml_model'; import { installIlmForDataStream } from '../elasticsearch/datastream_ilm/install'; import { saveArchiveEntriesFromAssetsMap } from '../archive/storage'; -import { ConcurrentInstallOperationError } from '../../../errors'; +import { ConcurrentInstallOperationError, PackageSavedObjectConflictError } from '../../../errors'; import { appContextService, packagePolicyService } from '../..'; import { auditLoggingService } from '../../audit_logging'; @@ -387,10 +387,12 @@ export async function _installPackage({ return [...installedKibanaAssetsRefs, ...esReferences]; } catch (err) { if (SavedObjectsErrorHelpers.isConflictError(err)) { - throw new ConcurrentInstallOperationError( - `Concurrent installation or upgrade of ${pkgName || 'unknown'}-${ + throw new PackageSavedObjectConflictError( + `Saved Object conflict encountered while installing ${pkgName || 'unknown'}-${ pkgVersion || 'unknown' - } detected, aborting. Original error: ${err.message}` + }. There may be a conflicting Saved Object saved to another Space. Original error: ${ + err.message + }` ); } else { throw err; 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 330007e23963d1..511c3928f81fe8 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 @@ -77,14 +77,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 +176,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) diff --git a/x-pack/plugins/graph/kibana.jsonc b/x-pack/plugins/graph/kibana.jsonc index 7e6093df5813cf..3c299bbeb4a2b0 100644 --- a/x-pack/plugins/graph/kibana.jsonc +++ b/x-pack/plugins/graph/kibana.jsonc @@ -14,7 +14,6 @@ "licensing", "data", "navigation", - "savedObjects", "unifiedSearch", "inspector", "savedObjectsManagement", @@ -28,7 +27,8 @@ ], "requiredBundles": [ "kibanaUtils", - "kibanaReact" + "kibanaReact", + "savedObjects" ] } } diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap index aeae7b28f0b880..f9e7a78b3b44c0 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap +++ b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap @@ -4,14 +4,14 @@ exports[`policy table changes pages when a pagination link is clicked on 1`] = ` Array [ "testy0", "testy1", - "testy10", - "testy100", "testy101", "testy103", - "testy104", "testy11", "testy13", - "testy14", + "testy17", + "testy19", + "testy23", + "testy25", ] `; @@ -19,14 +19,14 @@ exports[`policy table changes pages when a pagination link is clicked on 2`] = ` Array [ "testy0", "testy1", - "testy10", - "testy100", "testy101", "testy103", - "testy104", "testy11", "testy13", - "testy14", + "testy17", + "testy19", + "testy23", + "testy25", ] `; @@ -122,15 +122,15 @@ Array [ exports[`policy table sorts when linked index templates header is clicked 2`] = ` Array [ "testy0", - "testy2", - "testy4", - "testy8", - "testy10", - "testy14", - "testy16", - "testy20", - "testy22", - "testy26", + "testy1", + "testy5", + "testy7", + "testy11", + "testy13", + "testy17", + "testy19", + "testy23", + "testy25", ] `; @@ -152,60 +152,60 @@ Array [ exports[`policy table sorts when linked indices header is clicked 2`] = ` Array [ "testy0", - "testy2", - "testy4", - "testy8", - "testy10", - "testy14", - "testy16", - "testy20", - "testy22", - "testy26", + "testy1", + "testy5", + "testy7", + "testy11", + "testy13", + "testy17", + "testy19", + "testy23", + "testy25", ] `; exports[`policy table sorts when modified date header is clicked 1`] = ` Array [ "testy0", - "testy104", "testy103", "testy101", - "testy100", - "testy98", "testy97", "testy95", - "testy94", - "testy92", + "testy91", + "testy89", + "testy85", + "testy83", + "testy79", ] `; exports[`policy table sorts when modified date header is clicked 2`] = ` Array [ "testy1", - "testy2", - "testy4", "testy5", "testy7", - "testy8", - "testy10", "testy11", "testy13", - "testy14", + "testy17", + "testy19", + "testy23", + "testy25", + "testy29", ] `; exports[`policy table sorts when name header is clicked 1`] = ` Array [ - "testy98", "testy97", "testy95", - "testy94", - "testy92", "testy91", "testy89", - "testy88", - "testy86", "testy85", + "testy83", + "testy79", + "testy77", + "testy73", + "testy71", ] `; @@ -213,13 +213,13 @@ exports[`policy table sorts when name header is clicked 2`] = ` Array [ "testy0", "testy1", - "testy2", - "testy4", "testy5", "testy7", - "testy8", - "testy10", "testy11", "testy13", + "testy17", + "testy19", + "testy23", + "testy25", ] `; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx index 7c28270dd419b3..b7278f79b0668e 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx @@ -43,6 +43,7 @@ const testPolicy = { const isUsedByAnIndex = (i: number) => i % 2 === 0; const isDesignatedManagedPolicy = (i: number) => i > 0 && i % 3 === 0; +const isDeprecatedPolicy = (i: number) => i > 0 && i % 2 === 0; const policies: PolicyFromES[] = [testPolicy]; for (let i = 1; i < 105; i++) { @@ -54,6 +55,7 @@ for (let i = 1; i < 105; i++) { name: `testy${i}`, policy: { name: `testy${i}`, + deprecated: i % 2 === 0, phases: {}, ...(isDesignatedManagedPolicy(i) ? { @@ -96,6 +98,7 @@ const getPolicies = (rendered: ReactWrapper) => { version, name, isManagedPolicy: isDesignatedManagedPolicy(version), + isDeprecatedPolicy: isDeprecatedPolicy(version), isUsedByAnIndex: isUsedByAnIndex(version), }; }); @@ -198,6 +201,24 @@ describe('policy table', () => { }); }); + test('shows deprecated policies with Deprecated badges', () => { + const rendered = mountWithIntl(component); + + // Initially the switch is off so we should not see any deprecated policies + let deprecatedPolicies = findTestSubject(rendered, 'deprecatedPolicyBadge'); + expect(deprecatedPolicies.length).toBe(0); + + // Enable filtering by deprecated policies + const searchInput = rendered.find('.euiFieldSearch').first(); + (searchInput.instance() as unknown as HTMLInputElement).value = 'is:policy.deprecated'; + searchInput.simulate('keyup', { key: 'Enter', keyCode: 13, which: 13 }); + rendered.update(); + + // Now we should see all deprecated policies + deprecatedPolicies = findTestSubject(rendered, 'deprecatedPolicyBadge'); + expect(deprecatedPolicies.length).toBeGreaterThan(0); + }); + test('filters based on content of search input', () => { const rendered = mountWithIntl(component); const searchInput = rendered.find('.euiFieldSearch').first(); diff --git a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts index c241ce6b5c58d1..916381c41c0837 100644 --- a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts +++ b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts @@ -18,6 +18,7 @@ export type PhaseWithDownsample = 'hot' | 'warm' | 'cold'; export interface SerializedPolicy { name: string; phases: Phases; + deprecated?: boolean; _meta?: Record; } diff --git a/x-pack/plugins/index_lifecycle_management/integration_tests/edit_policy/constants.ts b/x-pack/plugins/index_lifecycle_management/integration_tests/edit_policy/constants.ts index a41bea1cb84152..9232c82717b77a 100644 --- a/x-pack/plugins/index_lifecycle_management/integration_tests/edit_policy/constants.ts +++ b/x-pack/plugins/index_lifecycle_management/integration_tests/edit_policy/constants.ts @@ -226,6 +226,7 @@ export const POLICY_MANAGED_BY_ES: PolicyFromES = { modifiedDate: Date.now().toString(), policy: { name: POLICY_NAME, + deprecated: true, phases: { hot: { min_age: '0ms', diff --git a/x-pack/plugins/index_lifecycle_management/integration_tests/edit_policy/features/edit_warning.test.ts b/x-pack/plugins/index_lifecycle_management/integration_tests/edit_policy/features/edit_warning.test.ts index 65f9144a9a9642..8b5ac731539556 100644 --- a/x-pack/plugins/index_lifecycle_management/integration_tests/edit_policy/features/edit_warning.test.ts +++ b/x-pack/plugins/index_lifecycle_management/integration_tests/edit_policy/features/edit_warning.test.ts @@ -67,6 +67,19 @@ describe(' edit warning', () => { expect(exists('editManagedPolicyCallOut')).toBe(true); }); + test('an edit warning callout is shown for a deprecated policy', async () => { + httpRequestsMockHelpers.setLoadPolicies([POLICY_MANAGED_BY_ES]); + + await act(async () => { + testBed = await initTestBed(httpSetup); + }); + const { exists, component } = testBed; + component.update(); + + expect(exists('editWarning')).toBe(true); + expect(exists('editPolicyWithDeprecation')).toBe(true); + }); + test('no indices link if no indices', async () => { httpRequestsMockHelpers.setLoadPolicies([ { ...getDefaultHotPhasePolicy(POLICY_NAME), indices: [] }, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/components/index_templates_flyout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/components/index_templates_flyout.tsx index e78679776f59e6..51a164c9c98388 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/components/index_templates_flyout.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/components/index_templates_flyout.tsx @@ -55,6 +55,7 @@ export const IndexTemplatesFlyout: FunctionComponent = ({ = ({ 'xpack.indexLifecycleMgmt.policyTable.indexTemplatesTable.nameHeader', { defaultMessage: 'Index template name' } ), + // @ts-expect-error - EuiInMemoryTable wants an array of objects, but will accept strings if coerced render: (value: string) => { return ( { indexTemplatesLink ); const isManagedPolicy = policy?._meta?.managed; + const isDeprecatedPolicy = policy?.deprecated; return ( <> @@ -102,6 +103,30 @@ export const EditWarning: FunctionComponent = () => { )} + {isDeprecatedPolicy && ( + <> + + } + color="warning" + iconType="warning" + data-test-subj="editPolicyWithDeprecation" + > +

+ +

+
+ + + )} +

= ({ policies }) => { + const [query, setQuery] = useState(''); + const history = useHistory(); const { services: { getUrlForApp }, @@ -85,9 +102,27 @@ export const PolicyTable: React.FunctionComponent = ({ policies }) => { false ); const { setListAction } = usePolicyListContext(); + + const handleOnChange: EuiSearchBarProps['onChange'] = ({ queryText, error }) => { + if (!error) { + setQuery(queryText); + } + }; + const searchOptions = useMemo( () => ({ + query, + onChange: handleOnChange, box: { incremental: true, 'data-test-subj': 'ilmSearchBar' }, + filters: [ + { + type: 'is', + field: 'policy.deprecated', + name: i18n.translate('xpack.indexLifecycleMgmt.policyTable.isDeprecatedFilterLabel', { + defaultMessage: 'Deprecated', + }), + }, + ], toolsRight: ( = ({ policies }) => { ), }), - [managedPoliciesVisible, setManagedPoliciesVisible] + [managedPoliciesVisible, setManagedPoliciesVisible, query] ); const filteredPolicies = useMemo(() => { - return managedPoliciesVisible + let result = managedPoliciesVisible ? policies : policies.filter((item) => !item.policy?._meta?.managed); - }, [policies, managedPoliciesVisible]); + + // When the query includes 'is:policy.deprecated', we want to show deprecated policies. + // Otherwise hide them all since they wont be supported in the future. + if (query.includes('is:policy.deprecated')) { + result = result.filter((item) => item.policy?.deprecated); + } else { + result = result.filter((item) => !item.policy?.deprecated); + } + + return result; + }, [policies, managedPoliciesVisible, query]); const columns: Array> = [ { @@ -124,6 +169,8 @@ export const PolicyTable: React.FunctionComponent = ({ policies }) => { sortable: true, render: (value: string, item) => { const isManaged = item.policy?._meta?.managed; + const isDeprecated = item.policy?.deprecated; + return ( <> = ({ policies }) => { {value} + {isDeprecated && ( + <> +   + + + {deprecatedPolicyTooltips.badge} + + + + )} + {isManaged && ( <>   @@ -262,7 +320,7 @@ export const PolicyTable: React.FunctionComponent = ({ policies }) => { direction: 'asc', }, }} - search={searchOptions} + search={searchOptions as EuiInMemoryTableProps['search']} tableLayout="auto" items={filteredPolicies} columns={columns} diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/index.ts index 1fad4283424828..897a120242033d 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/index.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/index.ts @@ -8,7 +8,7 @@ import './mocks'; export type { TestBed } from '@kbn/test-jest-helpers'; -export { nextTick, getRandomString, findTestSubject } from '@kbn/test-jest-helpers'; +export { getRandomString, findTestSubject } from '@kbn/test-jest-helpers'; export { setupEnvironment, diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts index 52ac8313ef4d0c..eaea7f04568d48 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts @@ -109,4 +109,8 @@ export type TestSubjects = | 'dataRetentionEnabledField.input' | 'enrichPoliciesInsuficientPrivileges' | 'dataRetentionDetail' - | 'createIndexSaveButton'; + | 'createIndexSaveButton' + | 'createIndexMessage' + | 'indicesSearch' + | 'noIndicesMessage' + | 'clearIndicesSearch'; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts index 49216eb285498d..ef3bfabf7b9c9e 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts @@ -7,7 +7,7 @@ import { act } from 'react-dom/test-utils'; -import { setupEnvironment, nextTick } from '../helpers'; +import { setupEnvironment } from '../helpers'; import { HomeTestBed, setup } from './home.helpers'; describe('', () => { @@ -18,14 +18,10 @@ describe('', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadIndicesResponse([]); - testBed = await setup(httpSetup); - await act(async () => { - const { component } = testBed; - - await nextTick(); - component.update(); + testBed = await setup(httpSetup); }); + testBed.component.update(); }); test('should set the correct app title', () => { @@ -69,13 +65,11 @@ describe('', () => { httpRequestsMockHelpers.setLoadTemplatesResponse({ templates: [], legacyTemplates: [] }); - actions.selectHomeTab('templatesTab'); - await act(async () => { - await nextTick(); - component.update(); + actions.selectHomeTab('templatesTab'); }); + component.update(); expect(exists('indicesList')).toBe(false); expect(exists('templateList')).toBe(true); }); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts index 91a74ff0f558b9..cd250756d3dd2c 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts @@ -39,12 +39,12 @@ export interface IndicesTestBed extends TestBed { clickIncludeHiddenIndicesToggle: () => void; clickDataStreamAt: (index: number) => Promise; dataStreamLinkExistsAt: (index: number) => boolean; - clickManageContextMenuButton: () => void; - clickContextMenuOption: (optionDataTestSubject: string) => void; - clickModalConfirm: () => void; - clickCreateIndexButton: () => void; - clickCreateIndexCancelButton: () => void; - clickCreateIndexSaveButton: () => void; + clickManageContextMenuButton: () => Promise; + clickContextMenuOption: (optionDataTestSubject: string) => Promise; + clickModalConfirm: () => Promise; + clickCreateIndexButton: () => Promise; + clickCreateIndexCancelButton: () => Promise; + clickCreateIndexSaveButton: () => Promise; }; findDataStreamDetailPanel: () => ReactWrapper; findDataStreamDetailPanelTitle: () => string; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.tsx similarity index 85% rename from x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts rename to x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.tsx index f7990a3288f043..b1b3c3f2014a08 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.tsx @@ -5,10 +5,28 @@ * 2.0. */ +/* + * Mocking EuiSearchBar because its onChange is not firing during tests + */ +import { EuiSearchBoxProps } from '@elastic/eui/src/components/search_bar/search_box'; + +jest.mock('@elastic/eui/lib/components/search_bar/search_box', () => { + return { + EuiSearchBox: (props: EuiSearchBoxProps) => ( + ) => { + props.onSearch(event.target.value); + }} + /> + ), + }; +}); +import React from 'react'; import { act } from 'react-dom/test-utils'; import { API_BASE_PATH, INTERNAL_API_BASE_PATH } from '../../../common'; -import { setupEnvironment, nextTick } from '../helpers'; +import { setupEnvironment } from '../helpers'; import { IndicesTestBed, setup } from './indices_tab.helpers'; import { createDataStreamPayload, createNonDataStreamIndex } from './data_streams_tab.helpers'; @@ -97,16 +115,12 @@ describe('', () => { createDataStreamPayload({ name: 'dataStream1' }) ); - testBed = await setup(httpSetup, { - history: createMemoryHistory(), - }); - await act(async () => { - const { component } = testBed; - - await nextTick(); - component.update(); + testBed = await setup(httpSetup, { + history: createMemoryHistory(), + }); }); + testBed.component.update(); }); test('navigates to the data stream in the Data Streams tab', async () => { @@ -168,6 +182,55 @@ describe('', () => { expect(testBed.actions.findIndexDetailsPageTitle()).toContain(indexName); }); + describe('empty list component', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadIndicesResponse([]); + await act(async () => { + testBed = await setup(httpSetup); + }); + testBed.component.update(); + }); + + test('renders the default empty list content', () => { + expect(testBed.exists('createIndexMessage')).toBe(true); + }); + + it('displays an empty list content if set via extensions service', async () => { + httpRequestsMockHelpers.setLoadIndicesResponse([]); + await act(async () => { + testBed = await setup(httpSetup, { + services: { + extensionsService: { + _emptyListContent: { + renderContent: () => { + return

Empty list content
; + }, + }, + }, + }, + }); + }); + testBed.component.update(); + + expect(testBed.component.text()).toContain('Empty list content'); + }); + + it('renders "no indices found" prompt for search', async () => { + const { find, component, exists } = testBed; + await act(async () => { + find('indicesSearch').simulate('change', { target: { value: 'non-existing-index' } }); + }); + component.update(); + + expect(exists('noIndicesMessage')).toBe(true); + + find('clearIndicesSearch').simulate('click'); + component.update(); + + expect(exists('noIndicesMessage')).toBe(false); + }); + }); + describe('index actions', () => { const indexNameA = 'testIndexA'; const indexNameB = 'testIndexB'; @@ -416,16 +479,12 @@ describe('', () => { }, ]); - testBed = await setup(httpSetup, { - history: createMemoryHistory(), - }); - await act(async () => { - const { component } = testBed; - - await nextTick(); - component.update(); + testBed = await setup(httpSetup, { + history: createMemoryHistory(), + }); }); + testBed.component.update(); }); test('shows the create index button', async () => { @@ -441,7 +500,7 @@ describe('', () => { expect(exists('createIndexNameFieldText')).toBe(true); - await await actions.clickCreateIndexCancelButton(); + await actions.clickCreateIndexCancelButton(); expect(exists('createIndexNameFieldText')).toBe(false); }); diff --git a/x-pack/plugins/index_management/public/application/components/no_match/no_match.tsx b/x-pack/plugins/index_management/public/application/components/no_match/no_match.tsx index e972a0ff6948dc..6f7c79724763e4 100644 --- a/x-pack/plugins/index_management/public/application/components/no_match/no_match.tsx +++ b/x-pack/plugins/index_management/public/application/components/no_match/no_match.tsx @@ -6,13 +6,86 @@ */ import React from 'react'; +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { ExtensionsService } from '../../../services'; +import { CreateIndexButton } from '../../sections/home/index_list/create_index/create_index_button'; -export const NoMatch = () => ( -
- void; + filter: string; + resetFilter: () => void; + extensionsService: ExtensionsService; +}) => { + if (filter) { + return ( + + + + } + body={ +

+ +

+ } + actions={ + + + + } + /> + ); + } + + if (extensionsService.emptyListContent) { + return extensionsService.emptyListContent.renderContent({ + createIndexButton: , + }); + } + + return ( + + + + } + body={ +

+ +

+ } + actions={} /> -
-); + ); +}; diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/create_index/create_index_modal.tsx b/x-pack/plugins/index_management/public/application/sections/home/index_list/create_index/create_index_modal.tsx index c54aaf0b123745..dc74454b62a5c2 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/create_index/create_index_modal.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/create_index/create_index_modal.tsx @@ -51,14 +51,14 @@ export const CreateIndexModal = ({ closeModal, loadIndices }: CreateIndexModalPr const { error } = await createIndex(indexName); setIsSaving(false); if (!error) { - loadIndices(); - closeModal(); notificationService.showSuccessToast( i18n.translate('xpack.idxMgmt.createIndex.successfullyCreatedIndexMessage', { defaultMessage: 'Successfully created index: {indexName}', values: { indexName }, }) ); + closeModal(); + loadIndices(); return; } setCreateError(error.message); 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 7a15abef3a16fb..000c34988310ba 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 @@ -258,14 +258,13 @@ export class IndexTable extends Component { return indexOfUnselectedItem === -1; }; - buildHeader(config) { + buildHeader(headers) { const { sortField, isSortAscending } = this.props; - const headers = getHeaders({ showIndexStats: config.enableIndexStats }); return Object.entries(headers).map(([fieldName, label]) => { const isSorted = sortField === fieldName; // we only want to make index name column 25% width when there are more columns displayed const widthClassName = - fieldName === 'name' && config.enableIndexStats ? 'indTable__header__width' : ''; + fieldName === 'name' && Object.keys(headers).length > 2 ? 'indTable__header__width' : ''; return ( {({ services, config }) => { const { extensionsService } = services; - + const headers = getHeaders({ showIndexStats: config.enableIndexStats }); + const columnsCount = Object.keys(headers).length + 1; return ( @@ -593,6 +601,7 @@ export class IndexTable extends Component { defaultMessage: 'Search', } ), + 'data-test-subj': 'indicesSearch', }} aria-label={i18n.translate( 'xpack.idxMgmt.indexTable.systemIndicesSearchIndicesAriaLabel', @@ -631,43 +640,54 @@ export class IndexTable extends Component { - {indices.length > 0 ? ( -
- - - - - - - - - - - - {this.buildHeader(config)} - - - {this.buildRows(services, config)} - -
- ) : ( - - )} +
+ + + + + + + + + + + + {this.buildHeader(headers)} + + + + {indices.length > 0 ? ( + this.buildRows(services, config) + ) : ( + + + filterChanged('')} + extensionsService={extensionsService} + /> + + + )} + + +
diff --git a/x-pack/plugins/index_management/public/services/extensions_service.mock.ts b/x-pack/plugins/index_management/public/services/extensions_service.mock.ts index 8f4968ad35e410..616e81af9a7af7 100644 --- a/x-pack/plugins/index_management/public/services/extensions_service.mock.ts +++ b/x-pack/plugins/index_management/public/services/extensions_service.mock.ts @@ -16,6 +16,7 @@ const createServiceMock = (): ExtensionsSetupMock => ({ addBanner: jest.fn(), addFilter: jest.fn(), addToggle: jest.fn(), + setEmptyListContent: jest.fn(), addIndexDetailsTab: jest.fn(), setIndexOverviewContent: jest.fn(), setIndexMappingsContent: jest.fn(), diff --git a/x-pack/plugins/index_management/public/services/extensions_service.ts b/x-pack/plugins/index_management/public/services/extensions_service.ts index 1eb68e9a0b7465..955b92ab160e53 100644 --- a/x-pack/plugins/index_management/public/services/extensions_service.ts +++ b/x-pack/plugins/index_management/public/services/extensions_service.ts @@ -27,6 +27,12 @@ export interface IndexBadge { color: EuiBadgeProps['color']; } +export interface EmptyListContent { + renderContent: (args: { + createIndexButton: ReturnType; + }) => ReturnType; +} + export interface ExtensionsSetup { // adds an option to the "manage index" menu addAction(action: any): void; @@ -38,6 +44,8 @@ export interface ExtensionsSetup { addBadge(badge: IndexBadge): void; // adds a toggle to the indices list addToggle(toggle: any): void; + // set the content to render when the indices list is empty + setEmptyListContent(content: EmptyListContent): void; // adds a tab to the index details page addIndexDetailsTab(tab: IndexDetailsTab): void; // sets content to render instead of the code block on the overview tab of the index page @@ -63,6 +71,7 @@ export class ExtensionsService { }, ]; private _toggles: any[] = []; + private _emptyListContent: EmptyListContent | null = null; private _indexDetailsTabs: IndexDetailsTab[] = []; private _indexOverviewContent: IndexContent | null = null; private _indexMappingsContent: IndexContent | null = null; @@ -75,6 +84,7 @@ export class ExtensionsService { addBanner: this.addBanner.bind(this), addFilter: this.addFilter.bind(this), addToggle: this.addToggle.bind(this), + setEmptyListContent: this.setEmptyListContent.bind(this), addIndexDetailsTab: this.addIndexDetailsTab.bind(this), setIndexOverviewContent: this.setIndexOverviewContent.bind(this), setIndexMappingsContent: this.setIndexMappingsContent.bind(this), @@ -103,6 +113,14 @@ export class ExtensionsService { this._toggles.push(toggle); } + private setEmptyListContent(content: EmptyListContent) { + if (this._emptyListContent) { + throw new Error(`The empty list content has already been set.`); + } else { + this._emptyListContent = content; + } + } + private addIndexDetailsTab(tab: IndexDetailsTab) { this._indexDetailsTabs.push(tab); } @@ -143,6 +161,10 @@ export class ExtensionsService { return this._toggles; } + public get emptyListContent() { + return this._emptyListContent; + } + public get indexDetailsTabs() { return this._indexDetailsTabs; } diff --git a/x-pack/plugins/infra/common/locators/helpers.ts b/x-pack/plugins/infra/common/locators/helpers.ts index 582499407bb405..d067ea15e7ebe0 100644 --- a/x-pack/plugins/infra/common/locators/helpers.ts +++ b/x-pack/plugins/infra/common/locators/helpers.ts @@ -13,10 +13,8 @@ import { LogViewReference, ResolvedLogView, LogsLocatorParams, - NodeLogsLocatorParams, } from '@kbn/logs-shared-plugin/common'; import { flowRight } from 'lodash'; -import { findInventoryFields } from '@kbn/metrics-data-access-plugin/common'; import type { InfraClientCoreSetup } from '../../public/types'; import { MESSAGE_FIELD, TIMESTAMP_FIELD } from '../constants'; import type { TimeRange } from '../time'; @@ -33,15 +31,6 @@ interface LocationToDiscoverParams { logView?: LogViewReference; } -export const createNodeLogsQuery = (params: NodeLogsLocatorParams) => { - const { nodeType, nodeId, filter } = params; - - const nodeFilter = `${findInventoryFields(nodeType).id}: ${nodeId}`; - const query = filter ? `(${nodeFilter}) and (${filter})` : nodeFilter; - - return query; -}; - export const createSearchString = ({ time, timeRange, diff --git a/x-pack/plugins/infra/common/locators/index.ts b/x-pack/plugins/infra/common/locators/index.ts index d84c42a6dc21e1..914334d2df97ca 100644 --- a/x-pack/plugins/infra/common/locators/index.ts +++ b/x-pack/plugins/infra/common/locators/index.ts @@ -5,13 +5,13 @@ * 2.0. */ -import type { LogsLocator } from './logs_locator'; -import type { NodeLogsLocator } from './node_logs_locator'; +import type { InfraLogsLocator } from './logs_locator'; +import type { InfraNodeLogsLocator } from './node_logs_locator'; export * from './logs_locator'; export * from './node_logs_locator'; export interface InfraLocators { - logsLocator: LogsLocator; - nodeLogsLocator: NodeLogsLocator; + logsLocator?: InfraLogsLocator; + nodeLogsLocator?: InfraNodeLogsLocator; } diff --git a/x-pack/plugins/infra/common/locators/locators.test.ts b/x-pack/plugins/infra/common/locators/locators.test.ts index 607fd41b1bab65..7996380e3268bc 100644 --- a/x-pack/plugins/infra/common/locators/locators.test.ts +++ b/x-pack/plugins/infra/common/locators/locators.test.ts @@ -6,8 +6,8 @@ */ import { v4 as uuidv4 } from 'uuid'; -import { LogsLocatorDefinition, LogsLocatorDependencies } from './logs_locator'; -import { NodeLogsLocatorDefinition } from './node_logs_locator'; +import { InfraLogsLocatorDefinition, InfraLogsLocatorDependencies } from './logs_locator'; +import { InfraNodeLogsLocatorDefinition } from './node_logs_locator'; import { coreMock } from '@kbn/core/public/mocks'; import { findInventoryFields } from '@kbn/metrics-data-access-plugin/common'; import moment from 'moment'; @@ -19,11 +19,11 @@ import { } from '@kbn/logs-shared-plugin/common'; const setupLogsLocator = async () => { - const deps: LogsLocatorDependencies = { + const deps: InfraLogsLocatorDependencies = { core: coreMock.createSetup(), }; - const logsLocator = new LogsLocatorDefinition(deps); - const nodeLogsLocator = new NodeLogsLocatorDefinition(deps); + const logsLocator = new InfraLogsLocatorDefinition(deps); + const nodeLogsLocator = new InfraNodeLogsLocatorDefinition(deps); return { logsLocator, @@ -33,8 +33,9 @@ const setupLogsLocator = async () => { describe('Infra Locators', () => { const APP_ID = 'logs'; - const nodeType = 'host'; const FILTER_QUERY = 'trace.id:1234'; + const nodeType = 'host'; + const nodeField = findInventoryFields(nodeType).id; const nodeId = uuidv4(); const time = 1550671089404; const from = 1676815089000; @@ -124,7 +125,7 @@ describe('Infra Locators', () => { it('should create a link to Node Logs with no state', async () => { const params: NodeLogsLocatorParams = { nodeId, - nodeType, + nodeField, time, }; const { nodeLogsLocator } = await setupLogsLocator(); @@ -139,7 +140,7 @@ describe('Infra Locators', () => { it('should allow specifying specific logPosition', async () => { const params: NodeLogsLocatorParams = { nodeId, - nodeType, + nodeField, time, }; const { nodeLogsLocator } = await setupLogsLocator(); @@ -152,7 +153,7 @@ describe('Infra Locators', () => { it('should allow specifying specific filter', async () => { const params: NodeLogsLocatorParams = { nodeId, - nodeType, + nodeField, time, filter: FILTER_QUERY, }; @@ -166,7 +167,7 @@ describe('Infra Locators', () => { it('should allow specifying specific view id', async () => { const params: NodeLogsLocatorParams = { nodeId, - nodeType, + nodeField, time, logView: { ...DEFAULT_LOG_VIEW, logViewId: 'test' }, }; @@ -180,7 +181,7 @@ describe('Infra Locators', () => { it('should allow specifying specific time range', async () => { const params: NodeLogsLocatorParams = { nodeId, - nodeType, + nodeField, time, from, to, @@ -196,7 +197,7 @@ describe('Infra Locators', () => { it('should return correct structured url', async () => { const params: NodeLogsLocatorParams = { nodeId, - nodeType, + nodeField, time, logView: DEFAULT_LOG_VIEW, filter: FILTER_QUERY, @@ -237,7 +238,7 @@ const constructLogPosition = (time: number = 1550671089404) => { }; const constructLogFilter = ({ - nodeType, + nodeField, nodeId, filter, timeRange, @@ -246,7 +247,7 @@ const constructLogFilter = ({ let finalFilter = filter || ''; if (nodeId) { - const nodeFilter = `${findInventoryFields(nodeType!).id}: ${nodeId}`; + const nodeFilter = `${nodeField}: ${nodeId}`; finalFilter = filter ? `(${nodeFilter}) and (${filter})` : nodeFilter; } diff --git a/x-pack/plugins/infra/common/locators/logs_locator.ts b/x-pack/plugins/infra/common/locators/logs_locator.ts index e481c0d53d3904..952a6b4704aea9 100644 --- a/x-pack/plugins/infra/common/locators/logs_locator.ts +++ b/x-pack/plugins/infra/common/locators/logs_locator.ts @@ -6,19 +6,19 @@ */ import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; -import { LOGS_LOCATOR_ID, LogsLocatorParams } from '@kbn/logs-shared-plugin/common'; +import { INFRA_LOGS_LOCATOR_ID, LogsLocatorParams } from '@kbn/logs-shared-plugin/common'; import type { InfraClientCoreSetup } from '../../public/types'; -export type LogsLocator = LocatorPublic; +export type InfraLogsLocator = LocatorPublic; -export interface LogsLocatorDependencies { +export interface InfraLogsLocatorDependencies { core: InfraClientCoreSetup; } -export class LogsLocatorDefinition implements LocatorDefinition { - public readonly id = LOGS_LOCATOR_ID; +export class InfraLogsLocatorDefinition implements LocatorDefinition { + public readonly id = INFRA_LOGS_LOCATOR_ID; - constructor(protected readonly deps: LogsLocatorDependencies) {} + constructor(protected readonly deps: InfraLogsLocatorDependencies) {} public readonly getLocation = async (params: LogsLocatorParams) => { const { createSearchString } = await import('./helpers'); diff --git a/x-pack/plugins/infra/common/locators/node_logs_locator.ts b/x-pack/plugins/infra/common/locators/node_logs_locator.ts index c8c53ec69292eb..d5bfe4d7ac9367 100644 --- a/x-pack/plugins/infra/common/locators/node_logs_locator.ts +++ b/x-pack/plugins/infra/common/locators/node_logs_locator.ts @@ -6,20 +6,24 @@ */ import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; -import { NODE_LOGS_LOCATOR_ID, NodeLogsLocatorParams } from '@kbn/logs-shared-plugin/common'; -import type { LogsLocatorDependencies } from './logs_locator'; +import { + INFRA_NODE_LOGS_LOCATOR_ID, + NodeLogsLocatorParams, + createNodeLogsQuery, +} from '@kbn/logs-shared-plugin/common'; +import type { InfraLogsLocatorDependencies } from './logs_locator'; -export type NodeLogsLocator = LocatorPublic; +export type InfraNodeLogsLocator = LocatorPublic; -export type NodeLogsLocatorDependencies = LogsLocatorDependencies; +export type InfraNodeLogsLocatorDependencies = InfraLogsLocatorDependencies; -export class NodeLogsLocatorDefinition implements LocatorDefinition { - public readonly id = NODE_LOGS_LOCATOR_ID; +export class InfraNodeLogsLocatorDefinition implements LocatorDefinition { + public readonly id = INFRA_NODE_LOGS_LOCATOR_ID; - constructor(protected readonly deps: NodeLogsLocatorDependencies) {} + constructor(protected readonly deps: InfraNodeLogsLocatorDependencies) {} public readonly getLocation = async (params: NodeLogsLocatorParams) => { - const { createNodeLogsQuery, createSearchString } = await import('./helpers'); + const { createSearchString } = await import('./helpers'); const query = createNodeLogsQuery(params); diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/log_threshold_rule_type.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/log_threshold_rule_type.tsx index 1f2e8731a85df9..f87558de360b7b 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/log_threshold_rule_type.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/log_threshold_rule_type.tsx @@ -6,9 +6,9 @@ */ import { i18n } from '@kbn/i18n'; +import { UrlService } from '@kbn/share-plugin/common/url_service'; +import { getLogsLocatorsFromUrlService } from '@kbn/logs-shared-plugin/common'; import { ObservabilityRuleTypeModel } from '@kbn/observability-plugin/public'; -import type { LocatorPublic } from '@kbn/share-plugin/public'; -import type { LogsLocatorParams } from '@kbn/logs-shared-plugin/common'; import { LOG_DOCUMENT_COUNT_RULE_TYPE_ID, PartialRuleParams, @@ -44,7 +44,7 @@ const logThresholdDefaultRecoveryMessage = i18n.translate( export function createLogThresholdRuleType( core: InfraClientCoreSetup, - logsLocator: LocatorPublic + urlService: UrlService ): ObservabilityRuleTypeModel { const ruleParamsExpression = createLazyComponentWithKibanaContext( core, @@ -56,6 +56,8 @@ export function createLogThresholdRuleType( () => import('./components/alert_details_app_section') ); + const { logsLocator } = getLogsLocatorsFromUrlService(urlService); + return { id: LOG_DOCUMENT_COUNT_RULE_TYPE_ID, description: i18n.translate('xpack.infra.logs.alertFlyout.alertDescription', { diff --git a/x-pack/plugins/infra/public/components/asset_details/tabs/logs/logs.tsx b/x-pack/plugins/infra/public/components/asset_details/tabs/logs/logs.tsx index 1999c6604f5530..cec2bb6f5e3e51 100644 --- a/x-pack/plugins/infra/public/components/asset_details/tabs/logs/logs.tsx +++ b/x-pack/plugins/infra/public/components/asset_details/tabs/logs/logs.tsx @@ -12,7 +12,11 @@ import { i18n } from '@kbn/i18n'; import { EuiFieldSearch, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; import { LogStream } from '@kbn/logs-shared-plugin/public'; -import { DEFAULT_LOG_VIEW, LogViewReference } from '@kbn/logs-shared-plugin/common'; +import { + DEFAULT_LOG_VIEW, + getLogsLocatorsFromUrlService, + LogViewReference, +} from '@kbn/logs-shared-plugin/common'; import { findInventoryFields } from '@kbn/metrics-data-access-plugin/common'; import { useKibanaContextForPlugin } from '../../../../hooks/use_kibana'; import { InfraLoadingPanel } from '../../../loading'; @@ -34,7 +38,7 @@ export const Logs = () => { const { loading: logViewLoading, reference: logViewReference } = logs ?? {}; const { services } = useKibanaContextForPlugin(); - const { locators } = services; + const { nodeLogsLocator } = getLogsLocatorsFromUrlService(services.share.url); const [textQuery, setTextQuery] = useState(urlState?.logsSearch ?? ''); const [textQueryDebounced, setTextQueryDebounced] = useState(urlState?.logsSearch ?? ''); @@ -77,21 +81,14 @@ export const Logs = () => { ); const logsUrl = useMemo(() => { - return locators.nodeLogsLocator.getRedirectUrl({ - nodeType: asset.type, + return nodeLogsLocator.getRedirectUrl({ + nodeField: findInventoryFields(asset.type).id, nodeId: asset.name, time: state.startTimestamp, filter: textQueryDebounced, logView, }); - }, [ - locators.nodeLogsLocator, - asset.name, - asset.type, - state.startTimestamp, - textQueryDebounced, - logView, - ]); + }, [nodeLogsLocator, asset.name, asset.type, state.startTimestamp, textQueryDebounced, logView]); return ( diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.tsx index 663df4c0f4d1a6..16f13171f7106d 100644 --- a/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_logs.tsx @@ -7,7 +7,7 @@ import { useEffect } from 'react'; import { useLocation, useParams } from 'react-router-dom'; -import { DEFAULT_LOG_VIEW } from '@kbn/logs-shared-plugin/common'; +import { DEFAULT_LOG_VIEW, getLogsLocatorsFromUrlService } from '@kbn/logs-shared-plugin/common'; import { getFilterFromLocation, getTimeFromLocation } from './query_params'; import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; @@ -16,14 +16,15 @@ export const RedirectToLogs = () => { const location = useLocation(); const { - services: { locators }, + services: { share }, } = useKibanaContextForPlugin(); + const { logsLocator } = getLogsLocatorsFromUrlService(share.url); const filter = getFilterFromLocation(location); const time = getTimeFromLocation(location); useEffect(() => { - locators.logsLocator.navigate( + logsLocator.navigate( { time, filter, @@ -31,7 +32,7 @@ export const RedirectToLogs = () => { }, { replace: true } ); - }, [filter, locators.logsLocator, logViewId, time]); + }, [filter, logsLocator, logViewId, time]); return null; }; diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx index 0eade78931ed04..0be958882cedbd 100644 --- a/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx @@ -7,8 +7,8 @@ import { useEffect } from 'react'; import { RouteComponentProps } from 'react-router-dom'; -import { DEFAULT_LOG_VIEW } from '@kbn/logs-shared-plugin/common'; -import { InventoryItemType } from '@kbn/metrics-data-access-plugin/common'; +import { DEFAULT_LOG_VIEW, getLogsLocatorsFromUrlService } from '@kbn/logs-shared-plugin/common'; +import { findInventoryFields, InventoryItemType } from '@kbn/metrics-data-access-plugin/common'; import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; import { getFilterFromLocation, getTimeFromLocation } from './query_params'; @@ -26,24 +26,25 @@ export const RedirectToNodeLogs = ({ location, }: RedirectToNodeLogsType) => { const { - services: { locators }, + services: { share }, } = useKibanaContextForPlugin(); + const { nodeLogsLocator } = getLogsLocatorsFromUrlService(share.url); const filter = getFilterFromLocation(location); const time = getTimeFromLocation(location); useEffect(() => { - locators.nodeLogsLocator.navigate( + nodeLogsLocator.navigate( { + nodeField: findInventoryFields(nodeType).id, nodeId, - nodeType, time, filter, logView: { type: 'log-view-reference', logViewId }, }, { replace: true } ); - }, [filter, locators.nodeLogsLocator, logViewId, nodeId, nodeType, time]); + }, [filter, nodeLogsLocator, logViewId, nodeId, nodeType, time]); return null; }; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_link_to_stream.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_link_to_stream.tsx index cd2537418e46c3..d7fbd4dbf1be9b 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_link_to_stream.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_link_to_stream.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; import { EuiButtonEmpty } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { LogViewReference } from '@kbn/logs-shared-plugin/common'; +import { getLogsLocatorsFromUrlService, LogViewReference } from '@kbn/logs-shared-plugin/common'; import { useKibanaContextForPlugin } from '../../../../../../hooks/use_kibana'; interface LogsLinkToStreamProps { @@ -20,12 +20,13 @@ interface LogsLinkToStreamProps { export const LogsLinkToStream = ({ startTime, endTime, query, logView }: LogsLinkToStreamProps) => { const { services } = useKibanaContextForPlugin(); - const { locators } = services; + const { share } = services; + const { logsLocator } = getLogsLocatorsFromUrlService(share.url); return ( = withTheme const inventoryModel = findInventoryModel(nodeType); const nodeDetailFrom = currentTime - inventoryModel.metrics.defaultTimeRangeInSeconds * 1000; const { services } = useKibanaContextForPlugin(); - const { application, share, locators } = services; + const { application, share } = services; + const { nodeLogsLocator } = getLogsLocatorsFromUrlService(share.url); const uiCapabilities = application?.capabilities; // Due to the changing nature of the fields between APM and this UI, // We need to have some exceptions until 7.0 & ECS is finalized. Reference @@ -109,8 +111,8 @@ export const NodeContextMenu: React.FC = withTheme defaultMessage: '{inventoryName} logs', values: { inventoryName: inventoryModel.singularDisplayName }, }), - href: locators.nodeLogsLocator.getRedirectUrl({ - nodeType, + href: nodeLogsLocator.getRedirectUrl({ + nodeField: findInventoryFields(nodeType).id, nodeId: node.id, time: currentTime, }), diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index 7d717cf9057e47..f89d99a43a57b7 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -27,8 +27,8 @@ import { LOG_STREAM_EMBEDDABLE } from './components/log_stream/log_stream_embedd import { LogStreamEmbeddableFactoryDefinition } from './components/log_stream/log_stream_embeddable_factory'; import { type InfraLocators, - LogsLocatorDefinition, - NodeLogsLocatorDefinition, + InfraLogsLocatorDefinition, + InfraNodeLogsLocatorDefinition, } from '../common/locators'; import { createMetricsFetchData, createMetricsHasData } from './metrics_overview_fetchers'; import { registerFeatures } from './register_feature'; @@ -179,13 +179,15 @@ export class Plugin implements InfraClientPluginClass { ); // Register Locators - const logsLocator = pluginsSetup.share.url.locators.create(new LogsLocatorDefinition({ core })); - const nodeLogsLocator = pluginsSetup.share.url.locators.create( - new NodeLogsLocatorDefinition({ core }) - ); + const logsLocator = this.config.featureFlags.logsUIEnabled + ? pluginsSetup.share.url.locators.create(new InfraLogsLocatorDefinition({ core })) + : undefined; + const nodeLogsLocator = this.config.featureFlags.logsUIEnabled + ? pluginsSetup.share.url.locators.create(new InfraNodeLogsLocatorDefinition({ core })) + : undefined; pluginsSetup.observability.observabilityRuleTypeRegistry.register( - createLogThresholdRuleType(core, logsLocator) + createLogThresholdRuleType(core, pluginsSetup.share.url) ); if (this.config.featureFlags.logsUIEnabled) { 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 0fe5f148a25d07..57638b61db1a9a 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,18 +15,17 @@ import { mockAllSuggestions, } from '../../../mocks'; import { suggestionsApi } from '../../../lens_suggestions_api'; -import { fetchDataFromAggregateQuery } from '../../../datasources/text_based/fetch_data_from_aggregate_query'; import { getSuggestions } from './helpers'; const mockSuggestionApi = suggestionsApi as jest.Mock; -const mockFetchData = fetchDataFromAggregateQuery as jest.Mock; +const mockFetchData = fetchFieldsFromESQL as jest.Mock; jest.mock('../../../lens_suggestions_api', () => ({ suggestionsApi: jest.fn(() => mockAllSuggestions), })); -jest.mock('../../../datasources/text_based/fetch_data_from_aggregate_query', () => ({ - fetchDataFromAggregateQuery: jest.fn(() => { +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 faecb37ba7fd7a..4555f3f8a576d2 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,19 +7,15 @@ 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 { fetchDataFromAggregateQuery } from '../../../datasources/text_based/fetch_data_from_aggregate_query'; import { suggestionsApi } from '../../../lens_suggestions_api'; -export const getQueryColumns = async ( - query: AggregateQuery, - dataView: DataView, - deps: LensPluginStartDependencies -) => { +export const getQueryColumns = async (query: AggregateQuery, deps: LensPluginStartDependencies) => { // Fetching only columns for ES|QL for performance reasons with limit 0 // Important note: ES doesnt return the warnings for 0 limit, // I am skipping them in favor of performance now @@ -28,12 +24,7 @@ export const getQueryColumns = async ( if ('esql' in performantQuery && performantQuery.esql) { performantQuery.esql = `${performantQuery.esql} | limit 0`; } - const table = await fetchDataFromAggregateQuery( - performantQuery, - dataView, - deps.data, - deps.expressions - ); + const table = await fetchFieldsFromESQL(performantQuery, deps.expressions); return table?.columns; }; @@ -65,7 +56,7 @@ export const getSuggestions = async ( if (dataView.fields.getByName('@timestamp')?.type === 'date' && !dataViewSpec) { dataView.timeFieldName = '@timestamp'; } - const columns = await getQueryColumns(query, dataView, deps); + const columns = await getQueryColumns(query, deps); const context = { dataViewSpec: dataView?.toSpec(), fieldName: '', diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx index 1aae97977d7141..b17c1313a8df0f 100644 --- a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx @@ -28,6 +28,7 @@ import type { AggregateQuery, Query } from '@kbn/es-query'; import { TextBasedLangEditor } from '@kbn/text-based-languages/public'; import { DefaultInspectorAdapters } from '@kbn/expressions-plugin/common'; import { buildExpression } from '../../../editor_frame_service/editor_frame/expression_helpers'; +import { MAX_NUM_OF_COLUMNS } from '../../../datasources/text_based/utils'; import { useLensSelector, selectFramePublicAPI, @@ -76,6 +77,7 @@ export function LensEditConfigurationFlyout({ const [errors, setErrors] = useState(); const [isInlineFlyoutVisible, setIsInlineFlyoutVisible] = useState(true); const [isLayerAccordionOpen, setIsLayerAccordionOpen] = useState(true); + const [suggestsLimitedColumns, setSuggestsLimitedColumns] = useState(false); const [isSuggestionsAccordionOpen, setIsSuggestionsAccordionOpen] = useState(false); const datasourceState = attributes.state.datasourceStates[datasourceId]; const activeDatasource = datasourceMap[datasourceId]; @@ -87,7 +89,6 @@ export function LensEditConfigurationFlyout({ visualizationMap[visualization.activeId ?? attributes.visualizationType]; const framePublicAPI = useLensSelector((state) => selectFramePublicAPI(state, datasourceMap)); - const suggestsLimitedColumns = activeDatasource?.suggestsLimitedColumns?.(datasourceState); const layers = useMemo( () => activeDatasource.getLayers(datasourceState), @@ -101,6 +102,11 @@ export function LensEditConfigurationFlyout({ const adaptersTables = previousAdapters.current?.tables?.tables as Record; const [table] = Object.values(adaptersTables || {}); if (table) { + // there are cases where a query can return a big amount of columns + // at this case we don't suggest all columns in a table but the first + // MAX_NUM_OF_COLUMNS + const columns = Object.keys(table.rows?.[0]) ?? []; + setSuggestsLimitedColumns(columns.length >= MAX_NUM_OF_COLUMNS); layers.forEach((layer) => { activeData[layer] = table; }); diff --git a/x-pack/plugins/lens/public/data_views_service/loader.test.ts b/x-pack/plugins/lens/public/data_views_service/loader.test.ts index f91d236986b11d..4c648a7782896c 100644 --- a/x-pack/plugins/lens/public/data_views_service/loader.test.ts +++ b/x-pack/plugins/lens/public/data_views_service/loader.test.ts @@ -5,8 +5,13 @@ * 2.0. */ -import { DataViewsContract } from '@kbn/data-views-plugin/public'; -import { ensureIndexPattern, loadIndexPatternRefs, loadIndexPatterns } from './loader'; +import { DataViewsContract, DataViewField } from '@kbn/data-views-plugin/public'; +import { + ensureIndexPattern, + loadIndexPatternRefs, + loadIndexPatterns, + buildIndexPatternField, +} from './loader'; import { sampleIndexPatterns, mockDataViewsService } from './mocks'; import { documentField } from '../datasources/form_based/document_field'; @@ -313,4 +318,32 @@ describe('loader', () => { expect(onError).not.toHaveBeenCalled(); }); }); + + describe('buildIndexPatternField', () => { + it('should return a field with the correct name and derived parameters', async () => { + const field = buildIndexPatternField({ + name: 'foo', + displayName: 'Foo', + type: 'string', + aggregatable: true, + searchable: true, + } as DataViewField); + expect(field.name).toEqual('foo'); + expect(field.meta).toEqual(false); + expect(field.runtime).toEqual(false); + }); + it('should return return the right meta field value', async () => { + const field = buildIndexPatternField( + { + name: 'meta', + displayName: 'Meta', + type: 'string', + aggregatable: true, + searchable: true, + } as DataViewField, + new Set(['meta']) + ); + expect(field.meta).toEqual(true); + }); + }); }); diff --git a/x-pack/plugins/lens/public/data_views_service/loader.ts b/x-pack/plugins/lens/public/data_views_service/loader.ts index 784c97d832e349..8a52146991b8da 100644 --- a/x-pack/plugins/lens/public/data_views_service/loader.ts +++ b/x-pack/plugins/lens/public/data_views_service/loader.ts @@ -6,7 +6,12 @@ */ import { isFieldLensCompatible } from '@kbn/visualization-ui-components'; -import type { DataViewsContract, DataView, DataViewSpec } from '@kbn/data-views-plugin/public'; +import { + DataViewsContract, + DataView, + DataViewSpec, + DataViewField, +} from '@kbn/data-views-plugin/public'; import { keyBy } from 'lodash'; import { IndexPattern, IndexPatternField, IndexPatternMap, IndexPatternRef } from '../types'; import { documentField } from '../datasources/form_based/document_field'; @@ -32,46 +37,7 @@ export function convertDataViewIntoLensIndexPattern( const metaKeys = new Set(dataView.metaFields); const newFields = dataView.fields .filter(isFieldLensCompatible) - .map((field): IndexPatternField => { - // Convert the getters on the index pattern service into plain JSON - const base = { - name: field.name, - displayName: field.displayName, - type: field.type, - aggregatable: field.aggregatable, - filterable: field.filterable, - searchable: field.searchable, - meta: metaKeys.has(field.name), - esTypes: field.esTypes, - scripted: field.scripted, - isMapped: field.isMapped, - customLabel: field.customLabel, - runtimeField: field.runtimeField, - runtime: Boolean(field.runtimeField), - timeSeriesDimension: field.timeSeriesDimension, - timeSeriesMetric: field.timeSeriesMetric, - timeSeriesRollup: field.isRolledUpField, - partiallyApplicableFunctions: field.isRolledUpField - ? { - percentile: true, - percentile_rank: true, - median: true, - last_value: true, - unique_count: true, - standard_deviation: true, - } - : undefined, - }; - - // Simplifies tests by hiding optional properties instead of undefined - return base.scripted - ? { - ...base, - lang: field.lang, - script: field.script, - } - : base; - }) + .map((field) => buildIndexPatternField(field, metaKeys)) .concat(documentField); const { typeMeta, title, name, timeFieldName, fieldFormatMap } = dataView; @@ -113,6 +79,51 @@ export function convertDataViewIntoLensIndexPattern( }; } +export function buildIndexPatternField( + field: DataViewField, + metaKeys?: Set +): IndexPatternField { + const meta = metaKeys ? metaKeys.has(field.name) : false; + // Convert the getters on the index pattern service into plain JSON + const base = { + name: field.name, + displayName: field.displayName, + type: field.type, + aggregatable: field.aggregatable, + filterable: field.filterable, + searchable: field.searchable, + meta, + esTypes: field.esTypes, + scripted: field.scripted, + isMapped: field.isMapped, + customLabel: field.customLabel, + runtimeField: field.runtimeField, + runtime: Boolean(field.runtimeField), + timeSeriesDimension: field.timeSeriesDimension, + timeSeriesMetric: field.timeSeriesMetric, + timeSeriesRollup: field.isRolledUpField, + partiallyApplicableFunctions: field.isRolledUpField + ? { + percentile: true, + percentile_rank: true, + median: true, + last_value: true, + unique_count: true, + standard_deviation: true, + } + : undefined, + }; + + // Simplifies tests by hiding optional properties instead of undefined + return base.scripted + ? { + ...base, + lang: field.lang, + script: field.script, + } + : base; +} + export async function loadIndexPatternRefs( dataViews: MinimalDataViewsContract ): Promise { diff --git a/x-pack/plugins/lens/public/datasources/form_based/datapanel.tsx b/x-pack/plugins/lens/public/datasources/form_based/datapanel.tsx index 53a26693b7a7a0..5d7e928c955941 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/datapanel.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/datapanel.tsx @@ -13,7 +13,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { CoreStart } from '@kbn/core/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { type DataView } from '@kbn/data-plugin/common'; +import { type DataView, DataViewField, FieldSpec } from '@kbn/data-plugin/common'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public'; import { VISUALIZE_GEO_FIELD_TRIGGER } from '@kbn/ui-actions-plugin/public'; @@ -28,6 +28,9 @@ import { useGroupedFields, } from '@kbn/unified-field-list'; import { ChartsPluginSetup } from '@kbn/charts-plugin/public'; +import useLatest from 'react-use/lib/useLatest'; +import { isFieldLensCompatible } from '@kbn/visualization-ui-components'; +import { buildIndexPatternField } from '../../data_views_service/loader'; import type { DatasourceDataPanelProps, FramePublicAPI, @@ -249,18 +252,20 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ } }, []); - const { fieldListFiltersProps, fieldListGroupedProps } = useGroupedFields({ - dataViewId: currentIndexPatternId, - allFields, - services: { - dataViews, - core, - }, - isAffectedByGlobalFilter: Boolean(filters.length), - onSupportedFieldFilter, - onSelectedFieldFilter, - onOverrideFieldGroupDetails, - }); + const { fieldListFiltersProps, fieldListGroupedProps, hasNewFields } = + useGroupedFields({ + dataViewId: currentIndexPatternId, + allFields, + services: { + dataViews, + core, + }, + isAffectedByGlobalFilter: Boolean(filters.length), + onSupportedFieldFilter, + onSelectedFieldFilter, + onOverrideFieldGroupDetails, + getNewFieldsBySpec, + }); const closeFieldEditor = useRef<() => void | undefined>(); @@ -273,7 +278,7 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ }; }, []); - const refreshFieldList = useCallback(async () => { + const refreshFieldList = useLatest(async () => { if (currentIndexPattern) { const newlyMappedIndexPattern = await indexPatternService.loadIndexPatterns({ patterns: [currentIndexPattern.id], @@ -289,13 +294,13 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ } // start a new session so all charts are refreshed data.search.session.start(); - }, [ - indexPatternService, - currentIndexPattern, - onIndexPatternRefresh, - frame.dataViews.indexPatterns, - data.search.session, - ]); + }); + + useEffect(() => { + if (hasNewFields) { + refreshFieldList.current(); + } + }, [hasNewFields, refreshFieldList]); const editField = useMemo( () => @@ -309,7 +314,7 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ fieldName, onSave: () => { if (indexPatternInstance.isPersisted()) { - refreshFieldList(); + refreshFieldList.current(); refetchFieldsExistenceInfo(indexPatternInstance.id); } else { indexPatternService.replaceDataViewId(indexPatternInstance); @@ -341,7 +346,7 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ fieldName, onDelete: () => { if (indexPatternInstance.isPersisted()) { - refreshFieldList(); + refreshFieldList.current(); refetchFieldsExistenceInfo(indexPatternInstance.id); } else { indexPatternService.replaceDataViewId(indexPatternInstance); @@ -408,4 +413,16 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ ); }; +function getNewFieldsBySpec(spec: FieldSpec[], dataView: DataView | null) { + const metaKeys = dataView ? new Set(dataView.metaFields) : undefined; + + return spec.reduce((result: IndexPatternField[], fieldSpec: FieldSpec) => { + const field = new DataViewField(fieldSpec); + if (isFieldLensCompatible(field)) { + result.push(buildIndexPatternField(field, metaKeys)); + } + return result; + }, []); +} + export const MemoizedDataPanel = memo(InnerFormBasedDataPanel); diff --git a/x-pack/plugins/lens/public/datasources/text_based/datapanel.test.tsx b/x-pack/plugins/lens/public/datasources/text_based/components/datapanel.test.tsx similarity index 93% rename from x-pack/plugins/lens/public/datasources/text_based/datapanel.test.tsx rename to x-pack/plugins/lens/public/datasources/text_based/components/datapanel.test.tsx index 001e2d5b99af86..a3867515358ddf 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/components/datapanel.test.tsx @@ -23,13 +23,14 @@ import { EuiHighlight, EuiToken } from '@elastic/eui'; import { type TextBasedDataPanelProps, TextBasedDataPanel } from './datapanel'; import { coreMock } from '@kbn/core/public/mocks'; -import type { TextBasedPrivateState } from './types'; +import type { TextBasedPrivateState } from '../types'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks'; -import { createIndexPatternServiceMock } from '../../mocks/data_views_service_mock'; -import { createMockFramePublicAPI } from '../../mocks'; -import { DataViewsState } from '../../state_management'; +import { createIndexPatternServiceMock } from '../../../mocks/data_views_service_mock'; +import { createMockFramePublicAPI } from '../../../mocks'; +import { DataViewsState } from '../../../state_management'; +import { addColumnsToCache } from '../fieldlist_cache'; const fieldsFromQuery = [ { @@ -105,8 +106,7 @@ const initialState: TextBasedPrivateState = { first: { index: '1', columns: [], - allColumns: [], - query: { sql: 'SELECT * FROM foo' }, + query: { esql: 'FROM foo' }, }, }, indexPatternRefs: [ @@ -114,9 +114,10 @@ const initialState: TextBasedPrivateState = { { id: '2', title: 'my-fake-restricted-pattern' }, { id: '3', title: 'my-compatible-pattern' }, ], - fieldList: fieldsFromQuery, }; +addColumnsToCache({ esql: 'FROM my-fake-index-pattern' }, fieldsFromQuery); + function getFrameAPIMock({ indexPatterns, ...rest @@ -189,7 +190,7 @@ describe('TextBased Query Languages Data Panel', () => { fromDate: 'now-7d', toDate: 'now', }, - query: { sql: 'SELECT * FROM my-fake-index-pattern' } as unknown as Query, + query: { esql: 'FROM my-fake-index-pattern' } as unknown as Query, filters: [], showNoDataPopover: jest.fn(), dropOntoWorkspace: jest.fn(), diff --git a/x-pack/plugins/lens/public/datasources/text_based/datapanel.tsx b/x-pack/plugins/lens/public/datasources/text_based/components/datapanel.tsx similarity index 92% rename from x-pack/plugins/lens/public/datasources/text_based/datapanel.tsx rename to x-pack/plugins/lens/public/datasources/text_based/components/datapanel.tsx index 113125484cddfd..bda5df0e34882d 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/datapanel.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/components/datapanel.tsx @@ -24,10 +24,11 @@ import { GetCustomFieldType, useGroupedFields, } from '@kbn/unified-field-list'; -import type { DatasourceDataPanelProps } from '../../types'; -import type { TextBasedPrivateState } from './types'; -import { getStateFromAggregateQuery } from './utils'; -import { FieldItem } from '../common/field_item'; +import type { DatasourceDataPanelProps } from '../../../types'; +import type { TextBasedPrivateState } from '../types'; +import { getStateFromAggregateQuery } from '../utils'; +import { FieldItem } from '../../common/field_item'; +import { getColumnsFromCache } from '../fieldlist_cache'; const getCustomFieldType: GetCustomFieldType = (field) => field?.meta.type; @@ -67,15 +68,13 @@ export function TextBasedDataPanel({ expressions, frameDataViews ); - setDataHasLoaded(true); setState(stateFromQuery); } } fetchData(); }, [data, dataViews, expressions, prevQuery, query, setState, state, frame.dataViews]); - - const { fieldList } = state; + const fieldList = isOfAggregateQueryType(query) ? getColumnsFromCache(query) : []; const onSelectedFieldFilter = useCallback( (field: DatatableColumn): boolean => { diff --git a/x-pack/plugins/lens/public/datasources/text_based/components/dimension_editor.tsx b/x-pack/plugins/lens/public/datasources/text_based/components/dimension_editor.tsx new file mode 100644 index 00000000000000..8971696fc90282 --- /dev/null +++ b/x-pack/plugins/lens/public/datasources/text_based/components/dimension_editor.tsx @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow } from '@elastic/eui'; +import { euiThemeVars } from '@kbn/ui-theme'; +import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; +import type { DatasourceDimensionEditorProps, DataType } from '../../../types'; +import { FieldSelect } from './field_select'; +import type { TextBasedPrivateState } from '../types'; +import { retrieveLayerColumnsFromCache, getColumnsFromCache } from '../fieldlist_cache'; + +export type TextBasedDimensionEditorProps = + DatasourceDimensionEditorProps & { + expressions: ExpressionsStart; + }; + +export function TextBasedDimensionEditor(props: TextBasedDimensionEditorProps) { + const query = props.state.layers[props.layerId]?.query; + + const allColumns = retrieveLayerColumnsFromCache( + props.state.layers[props.layerId]?.columns ?? [], + query + ); + const allFields = query ? getColumnsFromCache(query) : []; + const hasNumberTypeColumns = allColumns?.some((c) => c?.meta?.type === 'number'); + const fields = allFields.map((col) => { + return { + id: col.id, + name: col.name, + meta: col?.meta ?? { type: 'number' }, + compatible: + props.isMetricDimension && hasNumberTypeColumns + ? props.filterOperations({ + dataType: col?.meta?.type as DataType, + isBucketed: Boolean(col?.meta?.type !== 'number'), + scale: 'ordinal', + }) + : true, + }; + }); + const selectedField = allColumns?.find((column) => column.columnId === props.columnId); + + return ( + <> + + { + const meta = fields?.find((f) => f.name === choice.field)?.meta; + const newColumn = { + columnId: props.columnId, + fieldName: choice.field, + meta, + }; + return props.setState( + !selectedField + ? { + ...props.state, + layers: { + ...props.state.layers, + [props.layerId]: { + ...props.state.layers[props.layerId], + columns: [...props.state.layers[props.layerId].columns, newColumn], + }, + }, + } + : { + ...props.state, + layers: { + ...props.state.layers, + [props.layerId]: { + ...props.state.layers[props.layerId], + columns: props.state.layers[props.layerId].columns.map((col) => + col.columnId !== props.columnId + ? col + : { ...col, fieldName: choice.field, meta } + ), + }, + }, + } + ); + }} + /> + + {props.dataSectionExtra && ( +
+ {props.dataSectionExtra} +
+ )} + + ); +} 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 new file mode 100644 index 00000000000000..f6062068cee774 --- /dev/null +++ b/x-pack/plugins/lens/public/datasources/text_based/components/dimension_trigger.tsx @@ -0,0 +1,65 @@ +/* + * 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, { 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'; +import type { TextBasedPrivateState } from '../types'; +import { + getColumnsFromCache, + addColumnsToCache, + retrieveLayerColumnsFromCache, +} from '../fieldlist_cache'; + +export type TextBasedDimensionTrigger = DatasourceDimensionTriggerProps & { + columnLabelMap: Record; + expressions: ExpressionsStart; +}; + +export function TextBasedDimensionTrigger(props: TextBasedDimensionTrigger) { + const [dataHasLoaded, setDataHasLoaded] = useState(false); + const query = props.state.layers[props.layerId]?.query; + useEffect(() => { + // in case the columns are not in the cache, I refetch them + async function fetchColumns() { + const fieldList = query ? getColumnsFromCache(query) : []; + + if (fieldList.length === 0 && query) { + const table = await fetchFieldsFromESQL(query, props.expressions); + if (table) { + addColumnsToCache(query, table.columns); + } + } + setDataHasLoaded(true); + } + fetchColumns(); + }, [props.expressions, query]); + const allColumns = dataHasLoaded + ? retrieveLayerColumnsFromCache(props.state.layers[props.layerId]?.columns ?? [], query) + : []; + const selectedField = allColumns?.find((column) => column.columnId === props.columnId); + let customLabel: string | undefined = props.columnLabelMap[props.columnId]; + if (!customLabel) { + customLabel = selectedField?.fieldName; + } + return ( + + ); +} diff --git a/x-pack/plugins/lens/public/datasources/text_based/field_select.test.tsx b/x-pack/plugins/lens/public/datasources/text_based/components/field_select.test.tsx similarity index 100% rename from x-pack/plugins/lens/public/datasources/text_based/field_select.test.tsx rename to x-pack/plugins/lens/public/datasources/text_based/components/field_select.test.tsx diff --git a/x-pack/plugins/lens/public/datasources/text_based/field_select.tsx b/x-pack/plugins/lens/public/datasources/text_based/components/field_select.tsx similarity index 96% rename from x-pack/plugins/lens/public/datasources/text_based/field_select.tsx rename to x-pack/plugins/lens/public/datasources/text_based/components/field_select.tsx index 3561697900bb4d..3dd28dba557626 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/field_select.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/components/field_select.tsx @@ -10,8 +10,8 @@ import { EuiComboBoxOptionOption, EuiComboBoxProps } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { DatatableColumn } from '@kbn/expressions-plugin/public'; import { FieldPicker, FieldOptionValue, FieldOption } from '@kbn/visualization-ui-components'; -import type { TextBasedLayerColumn } from './types'; -import type { DataType } from '../../types'; +import type { TextBasedLayerColumn } from '../types'; +import type { DataType } from '../../../types'; export interface FieldOptionCompatible extends DatatableColumn { compatible: boolean; diff --git a/x-pack/plugins/lens/public/datasources/text_based/dnd/get_drop_props.test.tsx b/x-pack/plugins/lens/public/datasources/text_based/dnd/get_drop_props.test.tsx index ca9d48c17cbb83..b7f90853b6610a 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/dnd/get_drop_props.test.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/dnd/get_drop_props.test.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import type { DatatableColumn } from '@kbn/expressions-plugin/common'; import { DatasourceDimensionDropHandlerProps } from '../../../types'; import { getDropProps } from './get_drop_props'; import { @@ -13,21 +13,22 @@ import { column3, numericDraggedColumn, fieldList, - fieldListNonNumericOnly, notNumericDraggedField, numericDraggedField, } from './mocks'; import { TextBasedPrivateState } from '../types'; +import { addColumnsToCache } from '../fieldlist_cache'; const defaultProps = { state: { layers: { first: { columns: [column1, column2, column3], - allColumns: [...fieldList, column1, column2, column3], + query: { + esql: 'from foo', + }, }, }, - fieldList, }, source: numericDraggedColumn, target: { @@ -43,7 +44,19 @@ const defaultProps = { }, }, } as unknown as DatasourceDimensionDropHandlerProps; - +const allColumns = [...fieldList, column1, column2, column3].map((f) => { + return { + id: f.columnId, + name: f.fieldName, + meta: f?.meta, + }; +}) as DatatableColumn[]; +addColumnsToCache( + { + esql: 'from foo', + }, + allColumns +); describe('Text-based: getDropProps', () => { it('should return undefined if source and target belong to different layers', () => { const props = { @@ -83,10 +96,8 @@ describe('Text-based: getDropProps', () => { layers: { first: { columns: [column1, column2, column3], - allColumns: [...fieldListNonNumericOnly, column1, column2, column3], }, }, - fieldList: fieldListNonNumericOnly, }, source: notNumericDraggedField, } as unknown as DatasourceDimensionDropHandlerProps; diff --git a/x-pack/plugins/lens/public/datasources/text_based/dnd/get_drop_props.tsx b/x-pack/plugins/lens/public/datasources/text_based/dnd/get_drop_props.tsx index 78e1c98f3a301c..bbbf7869849c45 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/dnd/get_drop_props.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/dnd/get_drop_props.tsx @@ -11,6 +11,7 @@ import type { TextBasedPrivateState } from '../types'; import type { GetDropPropsArgs } from '../../../types'; import { isDraggedField, isOperationFromTheSameGroup } from '../../../utils'; import { canColumnBeDroppedInMetricDimension } from '../utils'; +import { retrieveLayerColumnsFromCache } from '../fieldlist_cache'; export const getDropProps = ( props: GetDropPropsArgs @@ -20,9 +21,10 @@ export const getDropProps = ( return; } const layer = state.layers[target.layerId]; + const allColumns = retrieveLayerColumnsFromCache(layer.columns, layer.query); const targetColumn = layer.columns.find((f) => f.columnId === target.columnId); - const targetField = layer.allColumns.find((f) => f.columnId === target.columnId); - const sourceField = layer.allColumns.find((f) => f.columnId === source.id); + const targetField = allColumns.find((f) => f.columnId === target.columnId); + const sourceField = allColumns.find((f) => f.columnId === source.id); if (isDraggedField(source)) { const nextLabel = source.humanData.label; @@ -46,12 +48,12 @@ export const getDropProps = ( } const sourceFieldCanMoveToMetricDimension = canColumnBeDroppedInMetricDimension( - layer.allColumns, + allColumns, sourceField?.meta?.type ); const targetFieldCanMoveToMetricDimension = canColumnBeDroppedInMetricDimension( - layer.allColumns, + allColumns, targetField?.meta?.type ); diff --git a/x-pack/plugins/lens/public/datasources/text_based/dnd/mocks.tsx b/x-pack/plugins/lens/public/datasources/text_based/dnd/mocks.tsx index 90a37acab10436..08424acc6b7ad9 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/dnd/mocks.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/dnd/mocks.tsx @@ -152,14 +152,12 @@ export const defaultProps = { first: { index: 'indexId', query: { - sql: 'SELECT * FROM "kibana_sample_data_ecommerce"', + esql: 'FROM "kibana_sample_data_ecommerce"', }, columns: [column1, column2, column3], - allColumns: [...fieldList, column1, column2, column3], errors: [], }, }, - fieldList, indexPatternRefs: [], }, source: numericDraggedColumn, diff --git a/x-pack/plugins/lens/public/datasources/text_based/dnd/on_drop.test.ts b/x-pack/plugins/lens/public/datasources/text_based/dnd/on_drop.test.ts index 9bcb4f6545cdee..025a51ff14c51e 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/dnd/on_drop.test.ts +++ b/x-pack/plugins/lens/public/datasources/text_based/dnd/on_drop.test.ts @@ -6,12 +6,26 @@ */ import { DropType } from '@kbn/dom-drag-drop'; +import type { DatatableColumn } from '@kbn/expressions-plugin/common'; import { onDrop } from './on_drop'; import { column1, column2, column3, emptyDimensionTarget, defaultProps, fieldList } from './mocks'; import { DatasourceDimensionDropHandlerProps } from '../../../types'; import { TextBasedPrivateState } from '../types'; +import { addColumnsToCache } from '../fieldlist_cache'; describe('onDrop', () => { + addColumnsToCache( + { + esql: 'FROM "kibana_sample_data_ecommerce"', + }, + fieldList.map((f) => { + return { + id: f.columnId, + name: f.fieldName, + meta: f?.meta, + } as DatatableColumn; + }) + ); it('should return false if dropType is not in the list', () => { const props = { ...defaultProps, @@ -34,7 +48,6 @@ describe('onDrop', () => { layers: { first: expect.objectContaining({ columns: expectedColumns, - allColumns: [...fieldList, ...expectedColumns], }), }, }) @@ -51,7 +64,6 @@ describe('onDrop', () => { layers: { first: expect.objectContaining({ columns: expectedColumns, - allColumns: [...fieldList, ...expectedColumns], }), }, }) @@ -69,7 +81,6 @@ describe('onDrop', () => { layers: { first: expect.objectContaining({ columns: expectedColumns, - allColumns: [...fieldList, ...expectedColumns], }), }, }) @@ -114,7 +125,6 @@ describe('onDrop', () => { layers: { first: expect.objectContaining({ columns: expectedColumns, - allColumns: [...fieldList, ...expectedColumns], }), }, }) @@ -142,7 +152,6 @@ describe('onDrop', () => { layers: { first: expect.objectContaining({ columns: expectedColumns, - allColumns: [...fieldList, ...expectedColumns], }), }, }) @@ -159,7 +168,6 @@ describe('onDrop', () => { layers: { first: expect.objectContaining({ columns: expectedColumns, - allColumns: [...fieldList, ...expectedColumns], }), }, }) diff --git a/x-pack/plugins/lens/public/datasources/text_based/dnd/on_drop.ts b/x-pack/plugins/lens/public/datasources/text_based/dnd/on_drop.ts index 22c3d5b5f44360..5dc90cd4b4a216 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/dnd/on_drop.ts +++ b/x-pack/plugins/lens/public/datasources/text_based/dnd/on_drop.ts @@ -9,6 +9,7 @@ import type { TextBasedLayerColumn, TextBasedPrivateState } from '../types'; import { reorderElements } from '../../../utils'; import { DatasourceDimensionDropHandlerProps, isOperation } from '../../../types'; import { removeColumn } from '../remove_column'; +import { retrieveLayerColumnsFromCache } from '../fieldlist_cache'; export const onDrop = (props: DatasourceDimensionDropHandlerProps) => { const { dropType, state, source, target } = props; @@ -28,31 +29,28 @@ export const onDrop = (props: DatasourceDimensionDropHandlerProps f.columnId === source.id); - const targetField = layer.allColumns.find((f) => f.columnId === target.columnId); + const allColumns = retrieveLayerColumnsFromCache(layer.columns, layer.query); + const sourceField = allColumns.find((f) => f.columnId === source.id); + const targetField = allColumns.find((f) => f.columnId === target.columnId); const newColumn = { columnId: target.columnId, fieldName: sourceField?.fieldName ?? '', meta: sourceField?.meta, }; let columns: TextBasedLayerColumn[] | undefined; - let allColumns: TextBasedLayerColumn[] | undefined; switch (dropType) { case 'field_add': case 'duplicate_compatible': case 'replace_duplicate_compatible': columns = [...layer.columns.filter((c) => c.columnId !== target.columnId), newColumn]; - allColumns = [...layer.allColumns.filter((c) => c.columnId !== target.columnId), newColumn]; break; case 'field_replace': case 'replace_compatible': columns = layer.columns.map((c) => (c.columnId === target.columnId ? newColumn : c)); - allColumns = layer.allColumns.map((c) => (c.columnId === target.columnId ? newColumn : c)); break; case 'move_compatible': columns = [...layer.columns, newColumn]; - allColumns = [...layer.allColumns, newColumn]; break; case 'swap_compatible': const swapTwoColumns = (c: TextBasedLayerColumn) => @@ -66,18 +64,16 @@ export const onDrop = (props: DatasourceDimensionDropHandlerProps f.columnId === target.columnId); const sourceColumn = layer.columns.find((f) => f.columnId === source.id); if (!targetColumn || !sourceColumn) return; columns = reorderElements(layer.columns, targetColumn, sourceColumn); - allColumns = reorderElements(layer.allColumns, targetColumn, sourceColumn); break; } - if (!columns || !allColumns) return; + if (!columns) return; const newState = { ...props.state, diff --git a/x-pack/plugins/lens/public/datasources/text_based/fieldlist_cache.ts b/x-pack/plugins/lens/public/datasources/text_based/fieldlist_cache.ts new file mode 100644 index 00000000000000..693a949ef8cd78 --- /dev/null +++ b/x-pack/plugins/lens/public/datasources/text_based/fieldlist_cache.ts @@ -0,0 +1,36 @@ +/* + * 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 AggregateQuery, getAggregateQueryMode } from '@kbn/es-query'; +import type { DatatableColumn } from '@kbn/expressions-plugin/public'; +import { getAllColumns } from './utils'; +import type { TextBasedLayerColumn } from './types'; + +const cachedColumns = new Map(); + +const getKey = (query: AggregateQuery) => { + const language = getAggregateQueryMode(query); + const queryString: string = query[language]; + return queryString.replaceAll('\n', '').trim(); +}; + +export const addColumnsToCache = (query: AggregateQuery, list: DatatableColumn[]) => { + const trimmedQuery = getKey(query); + cachedColumns.set(trimmedQuery, list); +}; + +export const getColumnsFromCache = (query: AggregateQuery) => { + const trimmedQuery = getKey(query); + return cachedColumns.get(trimmedQuery) ?? []; +}; + +export const retrieveLayerColumnsFromCache = ( + existingColumns: TextBasedLayerColumn[], + query?: AggregateQuery +): TextBasedLayerColumn[] => { + const columnsFromCache = query ? getColumnsFromCache(query) : []; + return getAllColumns(existingColumns, columnsFromCache); +}; diff --git a/x-pack/plugins/lens/public/datasources/text_based/remove_column.ts b/x-pack/plugins/lens/public/datasources/text_based/remove_column.ts index bd10678d9d1602..6d28b85b7becf3 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/remove_column.ts +++ b/x-pack/plugins/lens/public/datasources/text_based/remove_column.ts @@ -20,7 +20,6 @@ export const removeColumn: Datasource['removeColumn'] = ( [layerId]: { ...prevState.layers[layerId], columns: prevState.layers[layerId].columns.filter((col) => col.columnId !== columnId), - allColumns: prevState.layers[layerId].allColumns.filter((col) => col.columnId !== columnId), }, }, }; diff --git a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.test.ts b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.test.ts index 604e45f72ec84f..22dac664394502 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.test.ts +++ b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.test.ts @@ -14,7 +14,6 @@ import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { getTextBasedDatasource } from './text_based_languages'; import { generateId } from '../../id_generator'; import { DatasourcePublicAPI, Datasource, FramePublicAPI } from '../../types'; - jest.mock('../../id_generator'); const fieldsOne = [ @@ -106,28 +105,10 @@ describe('Textbased Data Source', () => { }, }, ], - allColumns: [ - { - columnId: 'col1', - fieldName: 'Test 1', - meta: { - type: 'number', - }, - }, - ], index: 'foo', query: { esql: 'FROM foo' }, }, }, - fieldList: [ - { - id: 'col1', - name: 'Test 1', - meta: { - type: 'number', - }, - }, - ], } as unknown as TextBasedPrivateState; }); @@ -217,15 +198,6 @@ describe('Textbased Data Source', () => { layers: { a: { columns: [], - allColumns: [ - { - columnId: 'col1', - fieldName: 'Test 1', - meta: { - type: 'string', - }, - }, - ], query: { esql: 'FROM foo' }, index: 'foo', }, @@ -262,15 +234,6 @@ describe('Textbased Data Source', () => { newLayer: { index: 'foo', query: { esql: 'FROM foo' }, - allColumns: [ - { - columnId: 'col1', - fieldName: 'Test 1', - meta: { - type: 'number', - }, - }, - ], columns: [], }, }, @@ -287,15 +250,6 @@ describe('Textbased Data Source', () => { layers: { a: { columns: [], - allColumns: [ - { - columnId: 'col1', - fieldName: 'Test 1', - meta: { - type: 'number', - }, - }, - ], query: { esql: 'FROM foo' }, index: 'foo', }, @@ -308,7 +262,6 @@ describe('Textbased Data Source', () => { describe('#createEmptyLayer', () => { it('creates state with empty layers', () => { expect(TextBasedDatasource.createEmptyLayer('index-pattern-id')).toEqual({ - fieldList: [], layers: {}, indexPatternRefs: [], }); @@ -337,22 +290,6 @@ describe('Textbased Data Source', () => { }, }, ], - allColumns: [ - { - columnId: 'a', - fieldName: 'Test 1', - meta: { - type: 'number', - }, - }, - { - columnId: 'b', - fieldName: 'Test 2', - meta: { - type: 'number', - }, - }, - ], query: { esql: 'FROM foo' }, index: 'foo', }, @@ -385,7 +322,7 @@ describe('Textbased Data Source', () => { layers: {}, initialContext: { textBasedColumns: textBasedQueryColumns, - query: { sql: 'SELECT * FROM "foo"' }, + query: { esql: 'FROM "foo"' }, dataViewSpec: { title: 'foo', id: '1', @@ -402,7 +339,6 @@ describe('Textbased Data Source', () => { expect(suggestions[0].state).toEqual({ ...state, initialContext: undefined, - fieldList: textBasedQueryColumns, indexPatternRefs: [ { id: '1', @@ -412,23 +348,6 @@ describe('Textbased Data Source', () => { ], layers: { newid: { - allColumns: [ - { - columnId: 'bytes', - fieldName: 'bytes', - inMetricDimension: true, - meta: { - type: 'number', - }, - }, - { - columnId: 'dest', - fieldName: 'dest', - meta: { - type: 'string', - }, - }, - ], columns: [ { columnId: 'bytes', @@ -448,7 +367,7 @@ describe('Textbased Data Source', () => { ], index: '1', query: { - sql: 'SELECT * FROM "foo"', + esql: 'FROM "foo"', }, }, }, @@ -554,7 +473,6 @@ describe('Textbased Data Source', () => { expect(suggestions[0].state).toEqual({ ...state, initialContext: undefined, - fieldList: textBasedQueryColumns, indexPatternRefs: [ { id: '1', @@ -564,24 +482,6 @@ describe('Textbased Data Source', () => { ], layers: { newid: { - allColumns: [ - { - columnId: '@timestamp', - fieldName: '@timestamp', - inMetricDimension: true, - meta: { - type: 'date', - }, - }, - { - columnId: 'dest', - fieldName: 'dest', - inMetricDimension: true, - meta: { - type: 'string', - }, - }, - ], columns: [ { columnId: '@timestamp', @@ -635,58 +535,6 @@ describe('Textbased Data Source', () => { }); }); - describe('#suggestsLimitedColumns', () => { - it('should return true if query returns big number of columns', () => { - const fieldList = [ - { - id: 'a', - name: 'Test 1', - meta: { - type: 'number', - }, - }, - { - id: 'b', - name: 'Test 2', - meta: { - type: 'number', - }, - }, - { - id: 'c', - name: 'Test 3', - meta: { - type: 'date', - }, - }, - { - id: 'd', - name: 'Test 4', - meta: { - type: 'string', - }, - }, - { - id: 'e', - name: 'Test 5', - meta: { - type: 'string', - }, - }, - ]; - const state = { - fieldList, - layers: { - a: { - query: { esql: 'from foo' }, - index: 'foo', - }, - }, - } as unknown as TextBasedPrivateState; - expect(TextBasedDatasource?.suggestsLimitedColumns?.(state)).toBeTruthy(); - }); - }); - describe('#getUserMessages', () => { it('should use the results of getUserMessages directly when single layer', () => { const state = { @@ -708,22 +556,6 @@ describe('Textbased Data Source', () => { }, }, ], - allColumns: [ - { - columnId: 'a', - fieldName: 'Test 1', - meta: { - type: 'number', - }, - }, - { - columnId: 'b', - fieldName: 'Test 2', - meta: { - type: 'number', - }, - }, - ], errors: [new Error('error 1'), new Error('error 2')], query: { esql: 'FROM foo' }, index: 'foo', @@ -791,22 +623,6 @@ describe('Textbased Data Source', () => { }, }, ], - allColumns: [ - { - columnId: 'a', - fieldName: 'Test 1', - meta: { - type: 'number', - }, - }, - { - columnId: 'b', - fieldName: 'Test 2', - meta: { - type: 'number', - }, - }, - ], query: { esql: 'FROM foo' }, index: '1', }, @@ -838,22 +654,6 @@ describe('Textbased Data Source', () => { }, }, ], - allColumns: [ - { - columnId: 'a', - fieldName: 'Test 1', - meta: { - type: 'number', - }, - }, - { - columnId: 'b', - fieldName: 'Test 2', - meta: { - type: 'number', - }, - }, - ], query: { esql: 'FROM foo' }, index: '1', }, @@ -896,22 +696,6 @@ describe('Textbased Data Source', () => { }, }, ], - allColumns: [ - { - columnId: 'a', - fieldName: 'Test 1', - meta: { - type: 'number', - }, - }, - { - columnId: 'b', - fieldName: 'Test 2', - meta: { - type: 'number', - }, - }, - ], query: { esql: 'FROM foo' }, index: '1', }, @@ -1009,22 +793,6 @@ describe('Textbased Data Source', () => { index: 'foo', }, }, - fieldList: [ - { - id: 'col1', - name: 'Test 1', - meta: { - type: 'number', - }, - }, - { - id: 'col2', - name: 'Test 2', - meta: { - type: 'number', - }, - }, - ], } as unknown as TextBasedPrivateState; publicAPI = TextBasedDatasource.getPublicAPI({ @@ -1039,25 +807,17 @@ describe('Textbased Data Source', () => { }); it('should return only the columns that exist on the query', () => { - const state = { - ...baseState, - fieldList: [ - { - id: 'col2', - name: 'Test 2', - meta: { - type: 'number', - }, - }, - ], - } as unknown as TextBasedPrivateState; - publicAPI = TextBasedDatasource.getPublicAPI({ - state, + state: baseState, layerId: 'a', indexPatterns, }); - expect(publicAPI.getTableSpec()).toEqual([]); + expect(publicAPI.getTableSpec()).toEqual([ + { + columnId: 'col1', + fields: ['Test 1'], + }, + ]); }); }); diff --git a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx index bc46f0b4076d9f..0e78704ae95f2f 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx @@ -8,19 +8,17 @@ import React from 'react'; import { CoreStart } from '@kbn/core/public'; -import { i18n } from '@kbn/i18n'; import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import { AggregateQuery, isOfAggregateQueryType, getAggregateQueryMode } from '@kbn/es-query'; import type { SavedObjectReference } from '@kbn/core/public'; -import { EuiFormRow } from '@elastic/eui'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { DataViewsPublicPluginStart, DataView } from '@kbn/data-views-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { euiThemeVars } from '@kbn/ui-theme'; -import { DimensionTrigger } from '@kbn/visualization-ui-components'; import memoizeOne from 'memoize-one'; import { isEqual } from 'lodash'; -import { TextBasedDataPanel } from './datapanel'; +import { TextBasedDataPanel } from './components/datapanel'; +import { TextBasedDimensionEditor } from './components/dimension_editor'; +import { TextBasedDimensionTrigger } from './components/dimension_trigger'; import { toExpression } from './to_expression'; import { DatasourceDimensionEditorProps, @@ -40,12 +38,16 @@ import type { TextBasedLayerColumn, TextBasedField, } from './types'; -import { FieldSelect } from './field_select'; import type { Datasource } from '../../types'; import { getUniqueLabelGenerator, nonNullable } from '../../utils'; import { onDrop, getDropProps } from './dnd'; import { removeColumn } from './remove_column'; import { canColumnBeUsedBeInMetricDimension, MAX_NUM_OF_COLUMNS } from './utils'; +import { + getColumnsFromCache, + addColumnsToCache, + retrieveLayerColumnsFromCache, +} from './fieldlist_cache'; function getLayerReferenceName(layerId: string) { return `textBasedLanguages-datasource-layer-${layerId}`; @@ -78,6 +80,7 @@ export function getTextBasedDatasource({ }) { const getSuggestionsForState = (state: TextBasedPrivateState) => { return Object.entries(state.layers)?.map(([id, layer]) => { + const allColumns = retrieveLayerColumnsFromCache(layer.columns, layer.query); return { state: { ...state, @@ -89,7 +92,7 @@ export function getTextBasedDatasource({ columns: layer.columns?.map((f) => { const inMetricDimension = canColumnBeUsedBeInMetricDimension( - layer.allColumns, + allColumns, f?.meta?.type ); return { @@ -141,12 +144,13 @@ export function getTextBasedDatasource({ }; }); + addColumnsToCache(context.query, textBasedQueryColumns); + const index = context.dataViewSpec.id ?? context.dataViewSpec.title; const query = context.query; const updatedState = { ...state, initialContext: undefined, - fieldList: textBasedQueryColumns, ...(context.dataViewSpec.id ? { indexPatternRefs: [ @@ -164,7 +168,6 @@ export function getTextBasedDatasource({ index, query, columns: newColumns.slice(0, MAX_NUM_OF_COLUMNS) ?? [], - allColumns: newColumns ?? [], timeField: context.dataViewSpec.timeFieldName, }, }, @@ -275,7 +278,6 @@ export function getTextBasedDatasource({ insertLayer(state: TextBasedPrivateState, newLayerId: string) { const layer = Object.values(state?.layers)?.[0]; const query = layer?.query; - const columns = layer?.allColumns ?? []; const index = layer?.index ?? (JSON.parse(localStorage.getItem('lens-settings') || '{}').indexPatternId || @@ -284,7 +286,7 @@ export function getTextBasedDatasource({ ...state, layers: { ...state.layers, - [newLayerId]: blankLayer(index, query, columns), + [newLayerId]: blankLayer(index, query), }, }; }, @@ -292,7 +294,6 @@ export function getTextBasedDatasource({ return { indexPatternRefs: [], layers: {}, - fieldList: [], }; }, @@ -316,7 +317,6 @@ export function getTextBasedDatasource({ newState: { ...state, layers: newLayers, - fieldList: state.fieldList, }, }; }, @@ -337,13 +337,6 @@ export function getTextBasedDatasource({ getLayers(state: TextBasedPrivateState) { return state && state.layers ? Object.keys(state?.layers) : []; }, - // there are cases where a query can return a big amount of columns - // at this case we don't suggest all columns in a table but the first - // MAX_NUM_OF_COLUMNS - suggestsLimitedColumns(state: TextBasedPrivateState) { - const fieldsList = state?.fieldList ?? []; - return fieldsList.length >= MAX_NUM_OF_COLUMNS; - }, isTimeBased: (state, indexPatterns) => { if (!state) return false; const { layers } = state; @@ -388,24 +381,11 @@ export function getTextBasedDatasource({ DimensionTriggerComponent: (props: DatasourceDimensionTriggerProps) => { const columnLabelMap = TextBasedDatasource.uniqueLabels(props.state, props.indexPatterns); - const layer = props.state.layers[props.layerId]; - const selectedField = layer?.allColumns?.find((column) => column.columnId === props.columnId); - let customLabel: string | undefined = columnLabelMap[props.columnId]; - if (!customLabel) { - customLabel = selectedField?.fieldName; - } - return ( - ); }, @@ -421,95 +401,7 @@ export function getTextBasedDatasource({ }, DimensionEditorComponent: (props: DatasourceDimensionEditorProps) => { - const fields = props.state.fieldList; - const allColumns = props.state.layers[props.layerId]?.allColumns; - const selectedField = allColumns?.find((column) => column.columnId === props.columnId); - const hasNumberTypeColumns = allColumns?.some((c) => c?.meta?.type === 'number'); - - const updatedFields = fields?.map((f) => { - return { - ...f, - compatible: - props.isMetricDimension && hasNumberTypeColumns - ? props.filterOperations({ - dataType: f.meta.type as DataType, - isBucketed: Boolean(f?.meta?.type !== 'number'), - scale: 'ordinal', - }) - : true, - }; - }); - return ( - <> - - { - const meta = fields?.find((f) => f.name === choice.field)?.meta; - const newColumn = { - columnId: props.columnId, - fieldName: choice.field, - meta, - }; - return props.setState( - !selectedField - ? { - ...props.state, - layers: { - ...props.state.layers, - [props.layerId]: { - ...props.state.layers[props.layerId], - columns: [...props.state.layers[props.layerId].columns, newColumn], - allColumns: [ - ...props.state.layers[props.layerId].allColumns, - newColumn, - ], - }, - }, - } - : { - ...props.state, - layers: { - ...props.state.layers, - [props.layerId]: { - ...props.state.layers[props.layerId], - columns: props.state.layers[props.layerId].columns.map((col) => - col.columnId !== props.columnId - ? col - : { ...col, fieldName: choice.field, meta } - ), - allColumns: props.state.layers[props.layerId].allColumns.map((col) => - col.columnId !== props.columnId - ? col - : { ...col, fieldName: choice.field, meta } - ), - }, - }, - } - ); - }} - /> - - {props.dataSectionExtra && ( -
- {props.dataSectionExtra} -
- )} - - ); + return ; }, LayerPanelComponent: (props: DatasourceLayerPanelProps) => { @@ -539,12 +431,8 @@ export function getTextBasedDatasource({ datasourceId: 'textBased', getTableSpec: () => { - const columns = state.layers[layerId]?.columns.filter((c) => { - const columnExists = state?.fieldList?.some((f) => f.name === c?.fieldName); - if (columnExists) return c; - }); return ( - columns.map((column) => ({ + state.layers[layerId]?.columns.map((column) => ({ columnId: column.columnId, fields: [column.fieldName], })) || [] @@ -552,7 +440,7 @@ export function getTextBasedDatasource({ }, getOperationForColumnId: (columnId: string) => { const layer = state.layers[layerId]; - const column = layer?.allColumns?.find((c) => c.columnId === columnId); + const column = layer?.columns?.find((c) => c.columnId === columnId); const columnLabelMap = TextBasedDatasource.uniqueLabels(state, indexPatterns); if (column) { @@ -592,7 +480,10 @@ export function getTextBasedDatasource({ }; }, getDatasourceSuggestionsForField(state, draggedField) { - const field = state.fieldList?.find((f) => f.id === (draggedField as TextBasedField).id); + const layers = Object.values(state.layers); + const query = layers?.[0]?.query; + const fieldList = query ? getColumnsFromCache(query) : []; + const field = fieldList?.find((f) => f.id === (draggedField as TextBasedField).id); if (!field) return []; return Object.entries(state.layers)?.map(([id, layer]) => { const newId = generateId(); @@ -609,7 +500,6 @@ export function getTextBasedDatasource({ [id]: { ...state.layers[id], columns: [...layer.columns, newColumn], - allColumns: [...layer.allColumns, newColumn], }, }, }, @@ -692,11 +582,10 @@ export function getTextBasedDatasource({ return TextBasedDatasource; } -function blankLayer(index: string, query?: AggregateQuery, columns?: TextBasedLayerColumn[]) { +function blankLayer(index: string, query?: AggregateQuery) { return { index, query, columns: [], - allColumns: columns ?? [], }; } diff --git a/x-pack/plugins/lens/public/datasources/text_based/types.ts b/x-pack/plugins/lens/public/datasources/text_based/types.ts index 67c652450366cb..452f9a8cc59da5 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/types.ts +++ b/x-pack/plugins/lens/public/datasources/text_based/types.ts @@ -26,7 +26,6 @@ export interface TextBasedLayer { query: AggregateQuery | undefined; table?: Datatable; columns: TextBasedLayerColumn[]; - allColumns: TextBasedLayerColumn[]; timeField?: string; errors?: Error[]; } @@ -34,7 +33,6 @@ export interface TextBasedLayer { export interface TextBasedPersistedState { layers: Record; initialContext?: VisualizeFieldContext | VisualizeEditorContext; - fieldList?: DatatableColumn[]; } export type TextBasedPrivateState = TextBasedPersistedState & { diff --git a/x-pack/plugins/lens/public/datasources/text_based/utils.test.ts b/x-pack/plugins/lens/public/datasources/text_based/utils.test.ts index 3a01a7ba9efea7..2c8adac00b8080 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/utils.test.ts +++ b/x-pack/plugins/lens/public/datasources/text_based/utils.test.ts @@ -181,17 +181,15 @@ describe('Text based languages utils', () => { const state = { layers: { first: { - allColumns: [], columns: [], query: undefined, index: '', }, }, indexPatternRefs: [], - fieldList: [], initialContext: { textBasedColumns: textBasedQueryColumns, - query: { sql: 'SELECT * FROM "foo"' }, + query: { esql: 'from foo' }, fieldName: '', dataViewSpec: { title: 'foo', @@ -205,7 +203,7 @@ describe('Text based languages utils', () => { const expressionsMock = expressionsPluginMock.createStartContract(); const updatedState = await getStateFromAggregateQuery( state, - { sql: 'SELECT * FROM my-fake-index-pattern' }, + { esql: 'FROM my-fake-index-pattern' }, { ...dataViewsMock, getIdsWithTitle: jest.fn().mockReturnValue( @@ -239,7 +237,7 @@ describe('Text based languages utils', () => { expect(updatedState).toStrictEqual({ initialContext: { textBasedColumns: textBasedQueryColumns, - query: { sql: 'SELECT * FROM "foo"' }, + query: { esql: 'from foo' }, fieldName: '', dataViewSpec: { title: 'foo', @@ -247,29 +245,6 @@ describe('Text based languages utils', () => { name: 'Foo', }, }, - fieldList: [ - { - name: 'timestamp', - id: 'timestamp', - meta: { - type: 'date', - }, - }, - { - name: 'bytes', - id: 'bytes', - meta: { - type: 'number', - }, - }, - { - name: 'memory', - id: 'memory', - meta: { - type: 'number', - }, - }, - ], indexPatternRefs: [ { id: '3', @@ -294,34 +269,11 @@ describe('Text based languages utils', () => { ], layers: { first: { - allColumns: [ - { - fieldName: 'timestamp', - columnId: 'timestamp', - meta: { - type: 'date', - }, - }, - { - fieldName: 'bytes', - columnId: 'bytes', - meta: { - type: 'number', - }, - }, - { - fieldName: 'memory', - columnId: 'memory', - meta: { - type: 'number', - }, - }, - ], columns: [], errors: [], index: '4', query: { - sql: 'SELECT * FROM my-fake-index-pattern', + esql: 'FROM my-fake-index-pattern', }, timeField: 'timeField', }, @@ -333,17 +285,15 @@ describe('Text based languages utils', () => { const state = { layers: { first: { - allColumns: [], columns: [], query: undefined, index: '', }, }, indexPatternRefs: [], - fieldList: [], initialContext: { textBasedColumns: textBasedQueryColumns, - query: { sql: 'SELECT * FROM "foo"' }, + query: { esql: 'from foo' }, fieldName: '', dataViewSpec: { title: 'foo', @@ -357,7 +307,7 @@ describe('Text based languages utils', () => { const expressionsMock = expressionsPluginMock.createStartContract(); const updatedState = await getStateFromAggregateQuery( state, - { sql: 'SELECT * FROM my-fake-index-*' }, + { esql: 'FROM my-fake-index-*' }, { ...dataViewsMock, getIdsWithTitle: jest.fn().mockReturnValue( @@ -396,7 +346,7 @@ describe('Text based languages utils', () => { expect(updatedState).toStrictEqual({ initialContext: { textBasedColumns: textBasedQueryColumns, - query: { sql: 'SELECT * FROM "foo"' }, + query: { esql: 'from foo' }, fieldName: '', dataViewSpec: { title: 'foo', @@ -404,29 +354,6 @@ describe('Text based languages utils', () => { name: 'Foo', }, }, - fieldList: [ - { - name: 'timestamp', - id: 'timestamp', - meta: { - type: 'date', - }, - }, - { - name: 'bytes', - id: 'bytes', - meta: { - type: 'number', - }, - }, - { - name: 'memory', - id: 'memory', - meta: { - type: 'number', - }, - }, - ], indexPatternRefs: [ { id: '3', @@ -451,34 +378,11 @@ describe('Text based languages utils', () => { ], layers: { first: { - allColumns: [ - { - fieldName: 'timestamp', - columnId: 'timestamp', - meta: { - type: 'date', - }, - }, - { - fieldName: 'bytes', - columnId: 'bytes', - meta: { - type: 'number', - }, - }, - { - fieldName: 'memory', - columnId: 'memory', - meta: { - type: 'number', - }, - }, - ], columns: [], errors: [], index: 'adHoc-id', query: { - sql: 'SELECT * FROM my-fake-index-*', + esql: 'FROM my-fake-index-*', }, timeField: '@timestamp', }, diff --git a/x-pack/plugins/lens/public/datasources/text_based/utils.ts b/x-pack/plugins/lens/public/datasources/text_based/utils.ts index ecf4fbcd12ff21..856e608d347e1b 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/utils.ts +++ b/x-pack/plugins/lens/public/datasources/text_based/utils.ts @@ -19,6 +19,7 @@ import { fetchDataFromAggregateQuery } from './fetch_data_from_aggregate_query'; import type { IndexPatternRef, TextBasedPrivateState, TextBasedLayerColumn } from './types'; import type { DataViewsState } from '../../state_management'; +import { addColumnsToCache } from './fieldlist_cache'; export const MAX_NUM_OF_COLUMNS = 5; @@ -86,7 +87,6 @@ export async function getStateFromAggregateQuery( // get the id of the dataview let dataViewId = indexPatternRefs.find((r) => r.title === indexPattern)?.id ?? ''; let columnsFromQuery: DatatableColumn[] = []; - let allColumns: TextBasedLayerColumn[] = []; let timeFieldName; try { const dataView = await dataViews.create({ @@ -109,7 +109,7 @@ export async function getStateFromAggregateQuery( timeFieldName = dataView.timeFieldName; const table = await fetchDataFromAggregateQuery(query, dataView, data, expressions); columnsFromQuery = table?.columns ?? []; - allColumns = getAllColumns(state.layers[newLayerId].allColumns, columnsFromQuery); + addColumnsToCache(query, columnsFromQuery); } catch (e) { errors.push(e); } @@ -120,7 +120,6 @@ export async function getStateFromAggregateQuery( index: dataViewId, query, columns: state.layers[newLayerId].columns ?? [], - allColumns, timeField: timeFieldName, errors, }, @@ -129,7 +128,6 @@ export async function getStateFromAggregateQuery( return { ...tempState, - fieldList: columnsFromQuery ?? [], indexPatternRefs, initialContext: context, }; diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index ff1af09019c1bb..0a4c47d82a601e 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -1507,6 +1507,10 @@ export class Embeddable } public getIsEditable() { + // for ES|QL, editing is allowed only if the advanced setting is on + if (Boolean(this.isTextBasedLanguage()) && !this.deps.uiSettings.get('discover:enableESQL')) { + return false; + } return ( this.deps.capabilities.canSaveVisualizations || (!this.inputIsRefType(this.getInput()) && diff --git a/x-pack/plugins/lens/public/lens_suggestions_api.test.ts b/x-pack/plugins/lens/public/lens_suggestions_api.test.ts index 82271e56aa98af..dc03340bd6698c 100644 --- a/x-pack/plugins/lens/public/lens_suggestions_api.test.ts +++ b/x-pack/plugins/lens/public/lens_suggestions_api.test.ts @@ -70,7 +70,7 @@ describe('suggestionsApi', () => { }; const suggestions = suggestionsApi({ context, dataView, datasourceMap, visualizationMap }); expect(datasourceMap.textBased.getDatasourceSuggestionsForVisualizeField).toHaveBeenCalledWith( - { layers: {}, fieldList: [], indexPatternRefs: [], initialContext: context }, + { layers: {}, indexPatternRefs: [], initialContext: context }, 'index1', '', { index1: { id: 'index1' } } diff --git a/x-pack/plugins/lens/public/lens_suggestions_api.ts b/x-pack/plugins/lens/public/lens_suggestions_api.ts index cddcf5ade4cf30..a6b48ac84e8bd6 100644 --- a/x-pack/plugins/lens/public/lens_suggestions_api.ts +++ b/x-pack/plugins/lens/public/lens_suggestions_api.ts @@ -37,7 +37,6 @@ export const suggestionsApi = ({ isLoading: false, state: { layers: {}, - fieldList: [], indexPatternRefs: [], initialContext: context, }, diff --git a/x-pack/plugins/lens/public/mocks/suggestions_mock.ts b/x-pack/plugins/lens/public/mocks/suggestions_mock.ts index 0ed32fbfd84da8..c371f283ad964b 100644 --- a/x-pack/plugins/lens/public/mocks/suggestions_mock.ts +++ b/x-pack/plugins/lens/public/mocks/suggestions_mock.ts @@ -56,22 +56,6 @@ export const currentSuggestionMock = { }, }, ], - allColumns: [ - { - columnId: '81e332d6-ee37-42a8-a646-cea4fc75d2d3', - fieldName: 'Dest', - meta: { - type: 'string', - }, - }, - { - columnId: '5b9b8b76-0836-4a12-b9c0-980c9900502f', - fieldName: 'AvgTicketPrice', - meta: { - type: 'number', - }, - }, - ], timeField: 'timestamp', }, }, @@ -195,22 +179,6 @@ export const mockAllSuggestions = [ }, }, ], - allColumns: [ - { - columnId: '923f0681-3fe1-4987-aa27-d9c91fb95fa6', - fieldName: 'Dest', - meta: { - type: 'string', - }, - }, - { - columnId: 'b5f41c04-4bca-4abe-ae5c-b1d4d6fb00e0', - fieldName: 'AvgTicketPrice', - meta: { - type: 'number', - }, - }, - ], timeField: 'timestamp', }, }, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index e5c9fad96d6cad..53bb59c0a5459e 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -511,8 +511,6 @@ export interface Datasource { ) => Promise; injectReferencesToLayers?: (state: T, references?: SavedObjectReference[]) => T; - - suggestsLimitedColumns?: (state: T) => boolean; } export interface DatasourceFixAction { diff --git a/x-pack/plugins/lens/public/visualizations/partition/suggestions.ts b/x-pack/plugins/lens/public/visualizations/partition/suggestions.ts index eec86da39d6060..0e66291c068dab 100644 --- a/x-pack/plugins/lens/public/visualizations/partition/suggestions.ts +++ b/x-pack/plugins/lens/public/visualizations/partition/suggestions.ts @@ -239,12 +239,7 @@ export function suggestions({ ], }, previewIcon: PartitionChartsMeta.treemap.icon, - // hide treemap suggestions from bottom bar, but keep them for chart switcher - hide: - table.changeType === 'reduced' || - !state || - hasIntervalScale(groups) || - (state && state.shape === PieChartTypes.TREEMAP), + hide: table.changeType === 'reduced' || hasIntervalScale(groups), }); } @@ -292,11 +287,7 @@ export function suggestions({ ], }, previewIcon: PartitionChartsMeta.mosaic.icon, - hide: - groups.length !== 2 || - table.changeType === 'reduced' || - hasIntervalScale(groups) || - (state && state.shape === 'mosaic'), + hide: groups.length !== 2 || table.changeType === 'reduced' || hasIntervalScale(groups), }); } @@ -341,11 +332,7 @@ export function suggestions({ ], }, previewIcon: PartitionChartsMeta.waffle.icon, - hide: - groups.length !== 1 || - table.changeType === 'reduced' || - hasIntervalScale(groups) || - (state && state.shape === 'waffle'), + hide: groups.length !== 1 || table.changeType === 'reduced' || hasIntervalScale(groups), }); } @@ -359,7 +346,12 @@ export function suggestions({ .sort((a, b) => b.score - a.score) .map((suggestion) => ({ ...suggestion, - hide: shouldHideSuggestion || incompleteConfiguration || suggestion.hide, + hide: + // avoid to suggest the same shape if already used + (state && state.shape === suggestion.state.shape) || + shouldHideSuggestion || + incompleteConfiguration || + suggestion.hide, incomplete: incompleteConfiguration, })); } diff --git a/x-pack/plugins/lens/tsconfig.json b/x-pack/plugins/lens/tsconfig.json index 350cd1ad19a9ef..ddffc5c8114c84 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/log_explorer/common/constants.ts b/x-pack/plugins/log_explorer/common/constants.ts index a73f304a76a5f9..b9e85258b9d9cb 100644 --- a/x-pack/plugins/log_explorer/common/constants.ts +++ b/x-pack/plugins/log_explorer/common/constants.ts @@ -12,6 +12,8 @@ export const TIMESTAMP_FIELD = '@timestamp'; export const HOST_NAME_FIELD = 'host.name'; export const LOG_LEVEL_FIELD = 'log.level'; export const MESSAGE_FIELD = 'message'; +export const ERROR_MESSAGE_FIELD = 'error.message'; +export const EVENT_ORIGINAL_FIELD = 'event.original'; export const SERVICE_NAME_FIELD = 'service.name'; export const TRACE_ID_FIELD = 'trace.id'; @@ -27,6 +29,9 @@ export const LOG_FILE_PATH_FIELD = 'log.file.path'; export const DATASTREAM_NAMESPACE_FIELD = 'data_stream.namespace'; export const DATASTREAM_DATASET_FIELD = 'data_stream.dataset'; +// Virtual column fields +export const CONTENT_FIELD = 'content'; + // Sizing export const DATA_GRID_COLUMN_WIDTH_SMALL = 240; export const DATA_GRID_COLUMN_WIDTH_MEDIUM = 320; @@ -42,7 +47,7 @@ export const DEFAULT_COLUMNS = [ width: DATA_GRID_COLUMN_WIDTH_MEDIUM, }, { - field: MESSAGE_FIELD, + field: CONTENT_FIELD, }, ]; export const DEFAULT_ROWS_PER_PAGE = 100; diff --git a/x-pack/plugins/log_explorer/public/components/common/copy_button.tsx b/x-pack/plugins/log_explorer/public/components/common/copy_button.tsx new file mode 100644 index 00000000000000..fe02a7a8727201 --- /dev/null +++ b/x-pack/plugins/log_explorer/public/components/common/copy_button.tsx @@ -0,0 +1,28 @@ +/* + * 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, EuiFlexItem, copyToClipboard } from '@elastic/eui'; +import React from 'react'; +import { copyValueAriaText, copyValueText } from './translations'; + +export const CopyButton = ({ property, value }: { property: string; value: string }) => { + const ariaCopyValueText = copyValueAriaText(property); + + return ( + + copyToClipboard(value)} + data-test-subj={`dataTableCellAction_copyToClipboardAction_${property}`} + > + {copyValueText} + + + ); +}; diff --git a/x-pack/plugins/log_explorer/public/components/common/filter_in_button.tsx b/x-pack/plugins/log_explorer/public/components/common/filter_in_button.tsx new file mode 100644 index 00000000000000..e2f43d1b0c5fc4 --- /dev/null +++ b/x-pack/plugins/log_explorer/public/components/common/filter_in_button.tsx @@ -0,0 +1,40 @@ +/* + * 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, EuiFlexItem } from '@elastic/eui'; +import React from 'react'; +import { generateFilters } from '@kbn/data-plugin/public'; +import { filterForText, actionFilterForText } from './translations'; +import { useVirtualColumnServiceContext } from '../../hooks/use_virtual_column_services'; + +export const FilterInButton = ({ property, value }: { property: string; value: string }) => { + const ariaFilterForText = actionFilterForText(value); + const serviceContext = useVirtualColumnServiceContext(); + const filterManager = serviceContext?.data.query.filterManager; + const dataView = serviceContext.dataView; + + const onFilterForAction = () => { + if (filterManager != null) { + const filter = generateFilters(filterManager, property, [value], '+', dataView); + filterManager.addFilters(filter); + } + }; + + return ( + + + {filterForText} + + + ); +}; diff --git a/x-pack/plugins/log_explorer/public/components/common/filter_out_button.tsx b/x-pack/plugins/log_explorer/public/components/common/filter_out_button.tsx new file mode 100644 index 00000000000000..9291e17cc44fd1 --- /dev/null +++ b/x-pack/plugins/log_explorer/public/components/common/filter_out_button.tsx @@ -0,0 +1,40 @@ +/* + * 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, EuiFlexItem } from '@elastic/eui'; +import React from 'react'; +import { generateFilters } from '@kbn/data-plugin/public'; +import { filterOutText, actionFilterOutText } from './translations'; +import { useVirtualColumnServiceContext } from '../../hooks/use_virtual_column_services'; + +export const FilterOutButton = ({ property, value }: { property: string; value: string }) => { + const ariaFilterOutText = actionFilterOutText(value); + const serviceContext = useVirtualColumnServiceContext(); + const filterManager = serviceContext?.data.query.filterManager; + const dataView = serviceContext.dataView; + + const onFilterOutAction = () => { + if (filterManager != null) { + const filter = generateFilters(filterManager, property, [value], '-', dataView); + filterManager.addFilters(filter); + } + }; + + return ( + + + {filterOutText} + + + ); +}; diff --git a/x-pack/plugins/log_explorer/public/components/common/log_level.tsx b/x-pack/plugins/log_explorer/public/components/common/log_level.tsx new file mode 100644 index 00000000000000..3f2b2ed1a71a4b --- /dev/null +++ b/x-pack/plugins/log_explorer/public/components/common/log_level.tsx @@ -0,0 +1,56 @@ +/* + * 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 { useEuiTheme } from '@elastic/eui'; +import { FlyoutDoc } from '../flyout_detail/types'; +import { ChipWithPopover } from './popover_chip'; +import * as constants from '../../../common/constants'; + +const LEVEL_DICT = { + error: 'danger', + warn: 'warning', + info: 'primary', + debug: 'accent', +} as const; + +interface LogLevelProps { + level: FlyoutDoc['log.level']; + dataTestSubj?: string; + renderInFlyout?: boolean; +} + +export function LogLevel({ level, dataTestSubj, renderInFlyout = false }: LogLevelProps) { + const { euiTheme } = useEuiTheme(); + if (!level) return null; + const levelColor = LEVEL_DICT[level as keyof typeof LEVEL_DICT] + ? euiTheme.colors[LEVEL_DICT[level as keyof typeof LEVEL_DICT]] + : null; + + if (renderInFlyout) { + return ( + + ); + } + + return ( + + ); +} diff --git a/x-pack/plugins/log_explorer/public/components/common/popover_chip.tsx b/x-pack/plugins/log_explorer/public/components/common/popover_chip.tsx new file mode 100644 index 00000000000000..e56ca010b6a6ba --- /dev/null +++ b/x-pack/plugins/log_explorer/public/components/common/popover_chip.tsx @@ -0,0 +1,143 @@ +/* + * 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, useState } from 'react'; +import { + EuiBadge, + type EuiBadgeProps, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPopover, + useEuiFontSize, + EuiPopoverFooter, + EuiText, + EuiButtonIcon, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { closeCellActionPopoverText, openCellActionPopoverAriaText } from './translations'; +import { FilterInButton } from './filter_in_button'; +import { FilterOutButton } from './filter_out_button'; +import { CopyButton } from './copy_button'; +import { dynamic } from '../../utils/dynamic'; +const DataTablePopoverCellValue = dynamic( + () => import('@kbn/unified-data-table/src/components/data_table_cell_value') +); + +interface ChipWithPopoverProps { + /** + * ECS mapping for the key + */ + property: string; + /** + * Value for the mapping, which will be displayed + */ + text: string; + dataTestSubj?: string; + leftSideIcon?: EuiBadgeProps['iconType']; + rightSideIcon?: EuiBadgeProps['iconType']; + borderColor?: string | null; + style?: React.CSSProperties; + shouldRenderPopover?: boolean; +} + +export function ChipWithPopover({ + property, + text, + dataTestSubj = `dataTablePopoverChip_${property}`, + leftSideIcon, + rightSideIcon, + borderColor, + style, + shouldRenderPopover = true, +}: ChipWithPopoverProps) { + const xsFontSize = useEuiFontSize('xs').fontSize; + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const handleChipClick = useCallback(() => { + if (!shouldRenderPopover) return; + setIsPopoverOpen(!isPopoverOpen); + }, [isPopoverOpen, shouldRenderPopover]); + + const closePopover = () => setIsPopoverOpen(false); + + const chipContent = ( + + + {leftSideIcon && ( + + + + )} + {text} + + + ); + + return ( + + + +
+ + + {property} {text} + + +
+
+ + + +
+ + + + + + + + + + + +
+ ); +} diff --git a/x-pack/plugins/log_explorer/public/components/flyout_detail/translations.ts b/x-pack/plugins/log_explorer/public/components/common/translations.ts similarity index 78% rename from x-pack/plugins/log_explorer/public/components/flyout_detail/translations.ts rename to x-pack/plugins/log_explorer/public/components/common/translations.ts index e7a5f154a7befa..e0f92a9a14b82f 100644 --- a/x-pack/plugins/log_explorer/public/components/flyout_detail/translations.ts +++ b/x-pack/plugins/log_explorer/public/components/common/translations.ts @@ -7,8 +7,8 @@ import { i18n } from '@kbn/i18n'; -export const flyoutMessageLabel = i18n.translate('xpack.logExplorer.flyoutDetail.label.message', { - defaultMessage: 'Message', +export const flyoutContentLabel = i18n.translate('xpack.logExplorer.flyoutDetail.label.message', { + defaultMessage: 'Content breakdown', }); export const flyoutServiceLabel = i18n.translate('xpack.logExplorer.flyoutDetail.label.service', { @@ -122,22 +122,30 @@ export const flyoutShipperLabel = i18n.translate('xpack.logExplorer.flyoutDetail defaultMessage: 'Shipper', }); -export const flyoutHoverActionFilterForText = (text: unknown) => +export const actionFilterForText = (text: string) => i18n.translate('xpack.logExplorer.flyoutDetail.value.hover.filterFor', { defaultMessage: 'Filter for this {value}', values: { - value: text as string, + value: text, }, }); -export const flyoutHoverActionFilterOutText = (text: unknown) => +export const actionFilterOutText = (text: string) => i18n.translate('xpack.logExplorer.flyoutDetail.value.hover.filterOut', { defaultMessage: 'Filter out this {value}', values: { - value: text as string, + value: text, }, }); +export const filterOutText = i18n.translate('xpack.logExplorer.popoverAction.filterOut', { + defaultMessage: 'Filter out', +}); + +export const filterForText = i18n.translate('xpack.logExplorer.popoverAction.filterFor', { + defaultMessage: 'Filter for', +}); + export const flyoutHoverActionFilterForFieldPresentText = i18n.translate( 'xpack.logExplorer.flyoutDetail.value.hover.filterForFieldPresent', { @@ -159,6 +167,18 @@ export const flyoutHoverActionCopyToClipboardText = i18n.translate( } ); +export const copyValueText = i18n.translate('xpack.logExplorer.popoverAction.copyValue', { + defaultMessage: 'Copy value', +}); + +export const copyValueAriaText = (fieldName: string) => + i18n.translate('xpack.logExplorer.popoverAction.copyValueAriaText', { + defaultMessage: 'Copy value of {fieldName}', + values: { + fieldName, + }, + }); + export const flyoutAccordionShowMoreText = (count: number) => i18n.translate('xpack.logExplorer.flyoutDetail.section.showMore', { defaultMessage: '+ {hiddenCount} more', @@ -166,3 +186,17 @@ export const flyoutAccordionShowMoreText = (count: number) => hiddenCount: count, }, }); + +export const openCellActionPopoverAriaText = i18n.translate( + 'xpack.logExplorer.popoverAction.openPopover', + { + defaultMessage: 'Open popover', + } +); + +export const closeCellActionPopoverText = i18n.translate( + 'xpack.logExplorer.popoverAction.closePopover', + { + defaultMessage: 'Close popover', + } +); diff --git a/x-pack/plugins/log_explorer/public/components/flyout_detail/flyout_detail.tsx b/x-pack/plugins/log_explorer/public/components/flyout_detail/flyout_detail.tsx index 07e6b3cc6629ad..a16a9b638c1ad1 100644 --- a/x-pack/plugins/log_explorer/public/components/flyout_detail/flyout_detail.tsx +++ b/x-pack/plugins/log_explorer/public/components/flyout_detail/flyout_detail.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { LogExplorerFlyoutContentProps } from './types'; -import { useDocDetail } from './use_doc_detail'; +import { useDocDetail } from '../../hooks/use_doc_detail'; import { FlyoutHeader } from './flyout_header'; import { FlyoutHighlights } from './flyout_highlights'; import { DiscoverActionsProvider } from '../../hooks/use_discover_action'; diff --git a/x-pack/plugins/log_explorer/public/components/flyout_detail/flyout_header.tsx b/x-pack/plugins/log_explorer/public/components/flyout_detail/flyout_header.tsx index f49e3d90039498..3d099452b2c945 100644 --- a/x-pack/plugins/log_explorer/public/components/flyout_detail/flyout_header.tsx +++ b/x-pack/plugins/log_explorer/public/components/flyout_detail/flyout_header.tsx @@ -6,30 +6,57 @@ */ import React from 'react'; -import { EuiCodeBlock, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiToken, + EuiText, + EuiAccordion, + useGeneratedHtmlId, + EuiTitle, +} from '@elastic/eui'; import { FlyoutDoc } from './types'; -import { getDocDetailHeaderRenderFlags } from './use_doc_detail'; -import { LogLevel } from './sub_components/log_level'; +import { getMessageWithFallbacks } from '../../hooks/use_doc_detail'; +import { LogLevel } from '../common/log_level'; import { Timestamp } from './sub_components/timestamp'; import * as constants from '../../../common/constants'; -import { flyoutMessageLabel } from './translations'; +import { flyoutContentLabel } from '../common/translations'; import { HoverActionPopover } from './sub_components/hover_popover_action'; export function FlyoutHeader({ doc }: { doc: FlyoutDoc }) { - const { hasTimestamp, hasLogLevel, hasMessage, hasBadges, hasFlyoutHeader } = - getDocDetailHeaderRenderFlags(doc); + const hasTimestamp = Boolean(doc[constants.TIMESTAMP_FIELD]); + const hasLogLevel = Boolean(doc[constants.LOG_LEVEL_FIELD]); + const hasBadges = hasTimestamp || hasLogLevel; + const { field, value } = getMessageWithFallbacks(doc); + const hasMessageField = field && value; + const hasFlyoutHeader = hasMessageField || hasBadges; + + const accordionId = useGeneratedHtmlId({ + prefix: flyoutContentLabel, + }); + + const accordionTitle = ( + +

{flyoutContentLabel}

+
+ ); const logLevelAndTimestamp = ( {hasBadges && ( - {hasLogLevel && ( + {doc[constants.LOG_LEVEL_FIELD] && ( - + )} @@ -43,47 +70,64 @@ export function FlyoutHeader({ doc }: { doc: FlyoutDoc }) { ); - return hasFlyoutHeader ? ( - - {hasMessage ? ( - - - - + const contentField = hasMessageField && ( + + + + + + + + + - - - {flyoutMessageLabel} - - + + {field} + - {logLevelAndTimestamp} - - - {doc[constants.MESSAGE_FIELD]} - - + {logLevelAndTimestamp} - ) : ( - logLevelAndTimestamp - )} - + + + + {value} + + + + + + ); + + return hasFlyoutHeader ? ( + + + {hasMessageField ? contentField : logLevelAndTimestamp} + + ) : null; } diff --git a/x-pack/plugins/log_explorer/public/components/flyout_detail/flyout_highlights.tsx b/x-pack/plugins/log_explorer/public/components/flyout_detail/flyout_highlights.tsx index 43b690807e3aad..2841109bb8ea9c 100644 --- a/x-pack/plugins/log_explorer/public/components/flyout_detail/flyout_highlights.tsx +++ b/x-pack/plugins/log_explorer/public/components/flyout_detail/flyout_highlights.tsx @@ -31,7 +31,7 @@ import { infraAccordionTitle, otherAccordionTitle, serviceAccordionTitle, -} from './translations'; +} from '../common/translations'; import { HighlightSection } from './sub_components/highlight_section'; import { HighlightContainer } from './sub_components/highlight_container'; import { useFlyoutColumnWidth } from '../../hooks/use_flyouot_column_width'; diff --git a/x-pack/plugins/log_explorer/public/components/flyout_detail/sub_components/highlight_field.tsx b/x-pack/plugins/log_explorer/public/components/flyout_detail/sub_components/highlight_field.tsx index 7e039497a9bd2c..21e595d706c9ef 100644 --- a/x-pack/plugins/log_explorer/public/components/flyout_detail/sub_components/highlight_field.tsx +++ b/x-pack/plugins/log_explorer/public/components/flyout_detail/sub_components/highlight_field.tsx @@ -7,10 +7,8 @@ import { EuiFlexGroup, EuiFlexItem, EuiText, EuiTextTruncate } from '@elastic/eui'; import React, { ReactNode } from 'react'; -import { ValuesType } from 'utility-types'; import { dynamic } from '../../../utils/dynamic'; import { HoverActionPopover } from './hover_popover_action'; -import { LogDocument } from '../types'; const HighlightFieldDescription = dynamic(() => import('./highlight_field_description')); @@ -19,7 +17,7 @@ interface HighlightFieldProps { formattedValue: string; icon?: ReactNode; label: string | ReactNode; - value: ValuesType; + value?: string; width: number; } @@ -32,7 +30,7 @@ export function HighlightField({ width, ...props }: HighlightFieldProps) { - return formattedValue ? ( + return formattedValue && value ? ( @@ -47,7 +45,7 @@ export function HighlightField({ - + ; + value: string; title?: string; anchorPosition?: PopoverAnchorPosition; + display?: EuiPopoverProps['display']; } export const HoverActionPopover = ({ @@ -32,6 +32,7 @@ export const HoverActionPopover = ({ field, value, anchorPosition = 'upCenter', + display = 'inline-block', }: HoverPopoverActionProps) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const leaveTimer = useRef(null); @@ -60,6 +61,7 @@ export const HoverActionPopover = ({ anchorPosition={anchorPosition} panelPaddingSize="s" panelStyle={{ minWidth: '24px' }} + display={display} > {title && ( diff --git a/x-pack/plugins/log_explorer/public/components/flyout_detail/sub_components/log_level.tsx b/x-pack/plugins/log_explorer/public/components/flyout_detail/sub_components/log_level.tsx deleted file mode 100644 index 88bc8bfe3aff6f..00000000000000 --- a/x-pack/plugins/log_explorer/public/components/flyout_detail/sub_components/log_level.tsx +++ /dev/null @@ -1,32 +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 { EuiBadge, type EuiBadgeProps } from '@elastic/eui'; -import { FlyoutDoc } from '../types'; - -const LEVEL_DICT: Record = { - error: 'danger', - warn: 'warning', - info: 'primary', - default: 'default', -}; - -interface LogLevelProps { - level: FlyoutDoc['log.level']; -} - -export function LogLevel({ level }: LogLevelProps) { - if (!level) return null; - const levelColor = LEVEL_DICT[level] ?? LEVEL_DICT.default; - - return ( - - {level} - - ); -} diff --git a/x-pack/plugins/log_explorer/public/components/virtual_columns/content.tsx b/x-pack/plugins/log_explorer/public/components/virtual_columns/content.tsx new file mode 100644 index 00000000000000..3f356f44b3a026 --- /dev/null +++ b/x-pack/plugins/log_explorer/public/components/virtual_columns/content.tsx @@ -0,0 +1,145 @@ +/* + * 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 { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import type { DataGridCellValueElementProps } from '@kbn/unified-data-table'; +import { getShouldShowFieldHandler } from '@kbn/discover-utils'; +import { i18n } from '@kbn/i18n'; +import type { DataTableRecord } from '@kbn/discover-utils/src/types'; +import { useDocDetail, getMessageWithFallbacks } from '../../hooks/use_doc_detail'; +import { LogDocument, LogExplorerDiscoverServices } from '../../controller'; +import { LogLevel } from '../common/log_level'; +import * as constants from '../../../common/constants'; +import { dynamic } from '../../utils/dynamic'; +import { VirtualColumnServiceProvider } from '../../hooks/use_virtual_column_services'; + +const SourceDocument = dynamic( + () => import('@kbn/unified-data-table/src/components/source_document') +); + +const DiscoverSourcePopoverContent = dynamic( + () => import('@kbn/unified-data-table/src/components/source_popover_content') +); + +const LogMessage = ({ field, value }: { field?: string; value: string }) => { + const renderFieldPrefix = field && field !== constants.MESSAGE_FIELD; + return ( + + {renderFieldPrefix && ( + + + {field} + + + )} + + + {value} + + + + ); +}; + +const SourcePopoverContent = ({ + row, + columnId, + closePopover, +}: { + row: DataTableRecord; + columnId: string; + closePopover: () => void; +}) => { + const closeButton = ( + + ); + return ( + + ); +}; + +const Content = ({ + row, + dataView, + fieldFormats, + isDetails, + columnId, + closePopover, +}: DataGridCellValueElementProps) => { + const parsedDoc = useDocDetail(row as LogDocument, { dataView }); + const { field, value } = getMessageWithFallbacks(parsedDoc); + const renderLogMessage = field && value; + + const shouldShowFieldHandler = useMemo(() => { + const dataViewFields = dataView.fields.getAll().map((fld) => fld.name); + return getShouldShowFieldHandler(dataViewFields, dataView, true); + }, [dataView]); + + if (isDetails && !renderLogMessage) { + return ; + } + + return ( + + {parsedDoc[constants.LOG_LEVEL_FIELD] && ( + + + + )} + + {renderLogMessage ? ( + + ) : ( + + )} + + + ); +}; + +export const renderContent = + ({ data }: { data: LogExplorerDiscoverServices['data'] }) => + (props: DataGridCellValueElementProps) => { + const { dataView } = props; + const virtualColumnServices = { + data, + dataView, + }; + return ( + + + + ); + }; diff --git a/x-pack/plugins/log_explorer/public/controller/controller_customizations.ts b/x-pack/plugins/log_explorer/public/controller/controller_customizations.ts index 4f10b2e39be440..5430d0aebdd075 100644 --- a/x-pack/plugins/log_explorer/public/controller/controller_customizations.ts +++ b/x-pack/plugins/log_explorer/public/controller/controller_customizations.ts @@ -29,6 +29,8 @@ export interface LogDocument extends DataTableRecord { '@timestamp': string; 'log.level'?: [string]; message?: [string]; + 'error.message'?: string; + 'event.original'?: string; 'host.name'?: string; 'service.name'?: string; @@ -51,6 +53,8 @@ export interface FlyoutDoc { '@timestamp': string; 'log.level'?: string; message?: string; + 'error.message'?: string; + 'event.original'?: string; 'host.name'?: string; 'service.name'?: string; diff --git a/x-pack/plugins/log_explorer/public/customizations/custom_cell_renderer.tsx b/x-pack/plugins/log_explorer/public/customizations/custom_cell_renderer.tsx new file mode 100644 index 00000000000000..8c33a55221d812 --- /dev/null +++ b/x-pack/plugins/log_explorer/public/customizations/custom_cell_renderer.tsx @@ -0,0 +1,16 @@ +/* + * 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 { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { CONTENT_FIELD } from '../../common/constants'; +import { renderContent } from '../components/virtual_columns/content'; + +export const createCustomCellRenderer = ({ data }: { data: DataPublicPluginStart }) => { + return { + [CONTENT_FIELD]: renderContent({ data }), + }; +}; diff --git a/x-pack/plugins/log_explorer/public/customizations/custom_flyout_content.tsx b/x-pack/plugins/log_explorer/public/customizations/custom_flyout_content.tsx index a1461f4de5fcaf..73402f4aba1af9 100644 --- a/x-pack/plugins/log_explorer/public/customizations/custom_flyout_content.tsx +++ b/x-pack/plugins/log_explorer/public/customizations/custom_flyout_content.tsx @@ -12,7 +12,7 @@ import { FlyoutDetail } from '../components/flyout_detail/flyout_detail'; import { LogExplorerFlyoutContentProps } from '../components/flyout_detail'; import { LogDocument, useLogExplorerControllerContext } from '../controller'; -export const CustomFlyoutContent = ({ +const CustomFlyoutContent = ({ filter, onAddColumn, onRemoveColumn, diff --git a/x-pack/plugins/log_explorer/public/customizations/log_explorer_profile.tsx b/x-pack/plugins/log_explorer/public/customizations/log_explorer_profile.tsx index 60f21be11f948c..113f470a988dcf 100644 --- a/x-pack/plugins/log_explorer/public/customizations/log_explorer_profile.tsx +++ b/x-pack/plugins/log_explorer/public/customizations/log_explorer_profile.tsx @@ -18,6 +18,7 @@ import type { LogExplorerStartDeps } from '../types'; import { dynamic } from '../utils/dynamic'; import { useKibanaContextForPluginProvider } from '../utils/use_kibana'; import { createCustomSearchBar } from './custom_search_bar'; +import { createCustomCellRenderer } from './custom_cell_renderer'; const LazyCustomDatasetFilters = dynamic(() => import('./custom_dataset_filters')); const LazyCustomDatasetSelector = dynamic(() => import('./custom_dataset_selector')); @@ -81,6 +82,11 @@ export const createLogExplorerProfileCustomizations = }), }); + customizations.set({ + id: 'data_table', + customCellRenderer: createCustomCellRenderer({ data }), + }); + /** * Hide New, Open and Save settings to prevent working with saved views. */ diff --git a/x-pack/plugins/log_explorer/public/components/flyout_detail/use_doc_detail.ts b/x-pack/plugins/log_explorer/public/hooks/use_doc_detail.ts similarity index 73% rename from x-pack/plugins/log_explorer/public/components/flyout_detail/use_doc_detail.ts rename to x-pack/plugins/log_explorer/public/hooks/use_doc_detail.ts index 2a6baca186ae3d..64bb8ffd1f8dd0 100644 --- a/x-pack/plugins/log_explorer/public/components/flyout_detail/use_doc_detail.ts +++ b/x-pack/plugins/log_explorer/public/hooks/use_doc_detail.ts @@ -5,9 +5,13 @@ * 2.0. */ import { formatFieldValue } from '@kbn/discover-utils'; -import * as constants from '../../../common/constants'; -import { useKibanaContextForPlugin } from '../../utils/use_kibana'; -import { FlyoutDoc, LogExplorerFlyoutContentProps, LogDocument } from './types'; +import * as constants from '../../common/constants'; +import { useKibanaContextForPlugin } from '../utils/use_kibana'; +import { + FlyoutDoc, + LogExplorerFlyoutContentProps, + LogDocument, +} from '../components/flyout_detail/types'; export function useDocDetail( doc: LogDocument, @@ -33,6 +37,12 @@ export function useDocDetail( const level = levelArray && levelArray.length ? levelArray[0]?.toLowerCase() : undefined; const messageArray = doc.flattened[constants.MESSAGE_FIELD]; const message = messageArray && messageArray.length ? messageArray[0] : undefined; + const errorMessageArray = doc.flattened[constants.ERROR_MESSAGE_FIELD]; + const errorMessage = + errorMessageArray && errorMessageArray.length ? errorMessageArray[0] : undefined; + const eventOriginalArray = doc.flattened[constants.EVENT_ORIGINAL_FIELD]; + const eventOriginal = + eventOriginalArray && eventOriginalArray.length ? eventOriginalArray[0] : undefined; const timestamp = formatField(constants.TIMESTAMP_FIELD); // Service Highlights @@ -61,6 +71,8 @@ export function useDocDetail( [constants.LOG_LEVEL_FIELD]: level, [constants.TIMESTAMP_FIELD]: timestamp, [constants.MESSAGE_FIELD]: message, + [constants.ERROR_MESSAGE_FIELD]: errorMessage, + [constants.EVENT_ORIGINAL_FIELD]: eventOriginal, [constants.SERVICE_NAME_FIELD]: serviceName, [constants.TRACE_ID_FIELD]: traceId, [constants.HOST_NAME_FIELD]: hostname, @@ -78,20 +90,19 @@ export function useDocDetail( }; } -export const getDocDetailHeaderRenderFlags = (doc: FlyoutDoc) => { - const hasTimestamp = Boolean(doc[constants.TIMESTAMP_FIELD]); - const hasLogLevel = Boolean(doc[constants.LOG_LEVEL_FIELD]); - const hasMessage = Boolean(doc[constants.MESSAGE_FIELD]); +export const getMessageWithFallbacks = (doc: FlyoutDoc) => { + const rankingOrder = [ + constants.MESSAGE_FIELD, + constants.ERROR_MESSAGE_FIELD, + constants.EVENT_ORIGINAL_FIELD, + ] as const; - const hasBadges = hasTimestamp || hasLogLevel; + for (const rank of rankingOrder) { + if (doc[rank] !== undefined && doc[rank] !== null) { + return { field: rank, value: doc[rank] }; + } + } - const hasFlyoutHeader = hasBadges || hasMessage; - - return { - hasTimestamp, - hasLogLevel, - hasMessage, - hasBadges, - hasFlyoutHeader, - }; + // If none of the ranks (fallbacks) are present + return { field: undefined }; }; diff --git a/x-pack/plugins/log_explorer/public/hooks/use_hover_actions.tsx b/x-pack/plugins/log_explorer/public/hooks/use_hover_actions.tsx index 71fd5103242c9d..d8459215dc3669 100644 --- a/x-pack/plugins/log_explorer/public/hooks/use_hover_actions.tsx +++ b/x-pack/plugins/log_explorer/public/hooks/use_hover_actions.tsx @@ -6,21 +6,19 @@ */ import { useMemo, useState } from 'react'; -import { ValuesType } from 'utility-types'; import { copyToClipboard, IconType } from '@elastic/eui'; import { flyoutHoverActionCopyToClipboardText, flyoutHoverActionFilterForFieldPresentText, - flyoutHoverActionFilterForText, - flyoutHoverActionFilterOutText, + actionFilterForText, + actionFilterOutText, flyoutHoverActionToggleColumnText, -} from '../components/flyout_detail/translations'; +} from '../components/common/translations'; import { useDiscoverActionsContext } from './use_discover_action'; -import { LogDocument } from '../components/flyout_detail'; interface HoverActionProps { field: string; - value: ValuesType; + value: string; } export interface HoverActionType { @@ -32,8 +30,8 @@ export interface HoverActionType { } export const useHoverActions = ({ field, value }: HoverActionProps): HoverActionType[] => { - const filterForText = flyoutHoverActionFilterForText(value); - const filterOutText = flyoutHoverActionFilterOutText(value); + const filterForText = actionFilterForText(value); + const filterOutText = actionFilterOutText(value); const actions = useDiscoverActionsContext(); const [columnAdded, setColumnAdded] = useState(false); diff --git a/x-pack/plugins/log_explorer/public/hooks/use_virtual_column_services.tsx b/x-pack/plugins/log_explorer/public/hooks/use_virtual_column_services.tsx new file mode 100644 index 00000000000000..8071b08d80a15c --- /dev/null +++ b/x-pack/plugins/log_explorer/public/hooks/use_virtual_column_services.tsx @@ -0,0 +1,21 @@ +/* + * 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 createContainer from 'constate'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { LogExplorerDiscoverServices } from '../controller'; + +export interface UseVirtualColumnServices { + services: { + data: LogExplorerDiscoverServices['data']; + dataView: DataView; + }; +} + +const useVirtualColumns = ({ services }: UseVirtualColumnServices) => services; + +export const [VirtualColumnServiceProvider, useVirtualColumnServiceContext] = + createContainer(useVirtualColumns); diff --git a/x-pack/plugins/logs_shared/common/index.ts b/x-pack/plugins/logs_shared/common/index.ts index 99fd7c1166863d..f6b1e9ea27e438 100644 --- a/x-pack/plugins/logs_shared/common/index.ts +++ b/x-pack/plugins/logs_shared/common/index.ts @@ -60,5 +60,13 @@ export { } from './http_api'; // Locators -export { LOGS_LOCATOR_ID, NODE_LOGS_LOCATOR_ID } from './locators'; -export type { LogsLocatorParams, NodeLogsLocatorParams } from './locators'; +export { + LOGS_LOCATOR_ID, + TRACE_LOGS_LOCATOR_ID, + NODE_LOGS_LOCATOR_ID, + INFRA_LOGS_LOCATOR_ID, + INFRA_NODE_LOGS_LOCATOR_ID, + getLogsLocatorsFromUrlService, +} from './locators'; +export type { LogsLocatorParams, NodeLogsLocatorParams, TraceLogsLocatorParams } from './locators'; +export { createNodeLogsQuery } from './locators/helpers'; diff --git a/x-pack/plugins/logs_shared/common/locators/get_logs_locators.ts b/x-pack/plugins/logs_shared/common/locators/get_logs_locators.ts new file mode 100644 index 00000000000000..5c403c2bcb5b09 --- /dev/null +++ b/x-pack/plugins/logs_shared/common/locators/get_logs_locators.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 { UrlService } from '@kbn/share-plugin/common/url_service'; + +import { LogsLocatorParams, NodeLogsLocatorParams, TraceLogsLocatorParams } from './types'; +import { LOGS_LOCATOR_ID } from './logs_locator'; +import { NODE_LOGS_LOCATOR_ID } from './node_logs_locator'; +import { TRACE_LOGS_LOCATOR_ID } from './trace_logs_locator'; + +export const getLogsLocatorsFromUrlService = (urlService: UrlService) => { + const logsLocator = urlService.locators.get(LOGS_LOCATOR_ID)!; + const nodeLogsLocator = urlService.locators.get(NODE_LOGS_LOCATOR_ID)!; + const traceLogsLocator = urlService.locators.get(TRACE_LOGS_LOCATOR_ID)!; + + return { + logsLocator, + traceLogsLocator, + nodeLogsLocator, + }; +}; diff --git a/x-pack/plugins/logs_shared/common/locators/helpers.ts b/x-pack/plugins/logs_shared/common/locators/helpers.ts new file mode 100644 index 00000000000000..ae25c8abb3c188 --- /dev/null +++ b/x-pack/plugins/logs_shared/common/locators/helpers.ts @@ -0,0 +1,43 @@ +/* + * 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 moment, { DurationInputObject } from 'moment'; +import { LogsLocatorParams, NodeLogsLocatorParams, TraceLogsLocatorParams } from './types'; + +export const getLogsQuery = (params: LogsLocatorParams) => { + const { filter } = params; + + return filter ? { language: 'kuery', query: filter } : undefined; +}; + +export const createNodeLogsQuery = (params: NodeLogsLocatorParams) => { + const { nodeField, nodeId, filter } = params; + + const nodeFilter = `${nodeField}: ${nodeId}`; + return filter ? `(${nodeFilter}) and (${filter})` : nodeFilter; +}; + +export const getNodeQuery = (params: NodeLogsLocatorParams) => { + return { language: 'kuery', query: createNodeLogsQuery(params) }; +}; + +export const getTraceQuery = (params: TraceLogsLocatorParams) => { + const { traceId, filter } = params; + + const traceFilter = `trace.id:"${traceId}" OR (not trace.id:* AND "${traceId}")`; + const query = filter ? `(${traceFilter}) and (${filter})` : traceFilter; + + return { language: 'kuery', query }; +}; + +const defaultTimeRangeFromPositionOffset: DurationInputObject = { hours: 1 }; + +export const getTimeRangeStartFromTime = (time: number): string => + moment(time).subtract(defaultTimeRangeFromPositionOffset).toISOString(); + +export const getTimeRangeEndFromTime = (time: number): string => + moment(time).add(defaultTimeRangeFromPositionOffset).toISOString(); diff --git a/x-pack/plugins/logs_shared/common/locators/index.ts b/x-pack/plugins/logs_shared/common/locators/index.ts index d680977f29f891..2cbe5cc2d6ba32 100644 --- a/x-pack/plugins/logs_shared/common/locators/index.ts +++ b/x-pack/plugins/logs_shared/common/locators/index.ts @@ -6,4 +6,14 @@ */ export * from './logs_locator'; +export * from './trace_logs_locator'; export * from './node_logs_locator'; +export * from './infra'; +export * from './get_logs_locators'; + +export type { + LogsSharedLocators, + LogsLocatorParams, + NodeLogsLocatorParams, + TraceLogsLocatorParams, +} from './types'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/translations.ts b/x-pack/plugins/logs_shared/common/locators/infra.ts similarity index 61% rename from x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/translations.ts rename to x-pack/plugins/logs_shared/common/locators/infra.ts index 10f5e3faafd00c..c9351c375d03ff 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/translations.ts +++ b/x-pack/plugins/logs_shared/common/locators/infra.ts @@ -5,8 +5,6 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; +export const INFRA_LOGS_LOCATOR_ID = 'INFRA_LOGS_LOCATOR'; -export const TIMELINE = i18n.translate('xpack.securitySolution.flyout.button.timeline', { - defaultMessage: 'timeline', -}); +export const INFRA_NODE_LOGS_LOCATOR_ID = 'INFRA_NODE_LOGS_LOCATOR'; diff --git a/x-pack/plugins/logs_shared/common/locators/logs_locator.ts b/x-pack/plugins/logs_shared/common/locators/logs_locator.ts index bfd53276ab3e94..2ebb343e10bbc0 100644 --- a/x-pack/plugins/logs_shared/common/locators/logs_locator.ts +++ b/x-pack/plugins/logs_shared/common/locators/logs_locator.ts @@ -5,19 +5,40 @@ * 2.0. */ -import { SerializableRecord } from '@kbn/utility-types'; -import type { TimeRange } from './time_range'; -import type { LogViewReference } from '../log_views/types'; +import { ALL_DATASETS_LOCATOR_ID, AllDatasetsLocatorParams } from '@kbn/deeplinks-observability'; +import { LocatorDefinition } from '@kbn/share-plugin/common'; +import { LocatorClient } from '@kbn/share-plugin/common/url_service'; + +import { INFRA_LOGS_LOCATOR_ID } from './infra'; +import { LogsLocatorParams } from './types'; +import { getLogsQuery, getTimeRangeEndFromTime, getTimeRangeStartFromTime } from './helpers'; export const LOGS_LOCATOR_ID = 'LOGS_LOCATOR'; -export interface LogsLocatorParams extends SerializableRecord { - /** Defines log position */ - time?: number; - /** - * Optionally set the time range in the time picker. - */ - timeRange?: TimeRange; - filter?: string; - logView?: LogViewReference; +export class LogsLocatorDefinition implements LocatorDefinition { + public readonly id = LOGS_LOCATOR_ID; + + constructor(private readonly locators: LocatorClient) {} + + public readonly getLocation = async (params: LogsLocatorParams) => { + const infraLogsLocator = this.locators.get(INFRA_LOGS_LOCATOR_ID); + if (infraLogsLocator) { + return infraLogsLocator.getLocation(params); + } + + const allDatasetsLocator = + this.locators.get(ALL_DATASETS_LOCATOR_ID)!; + const { time } = params; + return allDatasetsLocator.getLocation({ + query: getLogsQuery(params), + ...(time + ? { + timeRange: { + from: getTimeRangeStartFromTime(time), + to: getTimeRangeEndFromTime(time), + }, + } + : {}), + }); + }; } diff --git a/x-pack/plugins/logs_shared/common/locators/node_logs_locator.ts b/x-pack/plugins/logs_shared/common/locators/node_logs_locator.ts index 188c4d1e5a9666..e5288630334b81 100644 --- a/x-pack/plugins/logs_shared/common/locators/node_logs_locator.ts +++ b/x-pack/plugins/logs_shared/common/locators/node_logs_locator.ts @@ -5,12 +5,45 @@ * 2.0. */ -import type { InventoryItemType } from './types'; -import type { LogsLocatorParams } from './logs_locator'; +import { + AllDatasetsLocatorParams, + ALL_DATASETS_LOCATOR_ID, +} from '@kbn/deeplinks-observability/locators'; +import { LocatorClient, LocatorDefinition } from '@kbn/share-plugin/common/url_service'; + +import { NodeLogsLocatorParams } from './types'; +import { INFRA_NODE_LOGS_LOCATOR_ID } from './infra'; +import { getNodeQuery, getTimeRangeStartFromTime, getTimeRangeEndFromTime } from './helpers'; export const NODE_LOGS_LOCATOR_ID = 'NODE_LOGS_LOCATOR'; -export interface NodeLogsLocatorParams extends LogsLocatorParams { - nodeId: string; - nodeType: InventoryItemType; +export class NodeLogsLocatorDefinition implements LocatorDefinition { + public readonly id = NODE_LOGS_LOCATOR_ID; + + constructor(private readonly locators: LocatorClient) {} + + public readonly getLocation = async (params: NodeLogsLocatorParams) => { + const infraNodeLogsLocator = this.locators.get( + INFRA_NODE_LOGS_LOCATOR_ID + ); + + if (infraNodeLogsLocator) { + return infraNodeLogsLocator.getLocation(params); + } + + const allDatasetsLocator = + this.locators.get(ALL_DATASETS_LOCATOR_ID)!; + const { time } = params; + return allDatasetsLocator.getLocation({ + query: getNodeQuery(params), + ...(time + ? { + timeRange: { + from: getTimeRangeStartFromTime(time), + to: getTimeRangeEndFromTime(time), + }, + } + : {}), + }); + }; } diff --git a/x-pack/plugins/logs_shared/common/locators/trace_logs_locator.ts b/x-pack/plugins/logs_shared/common/locators/trace_logs_locator.ts new file mode 100644 index 00000000000000..a62155aaaf4d17 --- /dev/null +++ b/x-pack/plugins/logs_shared/common/locators/trace_logs_locator.ts @@ -0,0 +1,47 @@ +/* + * 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 { ALL_DATASETS_LOCATOR_ID, AllDatasetsLocatorParams } from '@kbn/deeplinks-observability'; +import { LocatorDefinition } from '@kbn/share-plugin/common'; +import { LocatorClient } from '@kbn/share-plugin/common/url_service'; +import { INFRA_LOGS_LOCATOR_ID } from './infra'; +import { LogsLocatorParams, TraceLogsLocatorParams } from './types'; + +import { getTraceQuery, getTimeRangeEndFromTime, getTimeRangeStartFromTime } from './helpers'; + +export const TRACE_LOGS_LOCATOR_ID = 'TRACE_LOGS_LOCATOR'; + +export class TraceLogsLocatorDefinition implements LocatorDefinition { + public readonly id = TRACE_LOGS_LOCATOR_ID; + + constructor(private readonly locators: LocatorClient) {} + + public readonly getLocation = async (params: TraceLogsLocatorParams) => { + const infraLogsLocator = this.locators.get(INFRA_LOGS_LOCATOR_ID); + if (infraLogsLocator) { + return infraLogsLocator.getLocation({ + ...params, + filter: getTraceQuery(params).query, + }); + } + + const { time } = params; + const allDatasetsLocator = + this.locators.get(ALL_DATASETS_LOCATOR_ID)!; + return allDatasetsLocator.getLocation({ + query: getTraceQuery(params), + ...(time + ? { + timeRange: { + from: getTimeRangeStartFromTime(time), + to: getTimeRangeEndFromTime(time), + }, + } + : {}), + }); + }; +} diff --git a/x-pack/plugins/logs_shared/common/locators/types.ts b/x-pack/plugins/logs_shared/common/locators/types.ts index af6ec963098a04..07c50590b3efbb 100644 --- a/x-pack/plugins/logs_shared/common/locators/types.ts +++ b/x-pack/plugins/logs_shared/common/locators/types.ts @@ -5,16 +5,33 @@ * 2.0. */ -import * as rt from 'io-ts'; +import { SerializableRecord } from '@kbn/utility-types'; +import { LocatorPublic } from '@kbn/share-plugin/common'; +import { LogViewReference } from '../log_views/types'; +import { TimeRange } from './time_range'; -export const ItemTypeRT = rt.keyof({ - host: null, - pod: null, - container: null, - awsEC2: null, - awsS3: null, - awsSQS: null, - awsRDS: null, -}); +export interface LogsLocatorParams extends SerializableRecord { + /** Defines log position */ + time?: number; + /** + * Optionally set the time range in the time picker. + */ + timeRange?: TimeRange; + filter?: string; + logView?: LogViewReference; +} -export type InventoryItemType = rt.TypeOf; +export interface TraceLogsLocatorParams extends LogsLocatorParams { + traceId: string; +} + +export interface NodeLogsLocatorParams extends LogsLocatorParams { + nodeField: string; + nodeId: string; +} + +export interface LogsSharedLocators { + logsLocator: LocatorPublic; + nodeLogsLocator: LocatorPublic; + traceLogsLocator: LocatorPublic; +} diff --git a/x-pack/plugins/logs_shared/kibana.jsonc b/x-pack/plugins/logs_shared/kibana.jsonc index b78503b140a71b..fc8dcf0e64d96c 100644 --- a/x-pack/plugins/logs_shared/kibana.jsonc +++ b/x-pack/plugins/logs_shared/kibana.jsonc @@ -13,7 +13,8 @@ "dataViews", "usageCollection", "observabilityShared", - "observabilityAIAssistant" + "observabilityAIAssistant", + "share" ], "requiredBundles": ["kibanaUtils", "kibanaReact"], "extraPublicDirs": ["common"] diff --git a/x-pack/plugins/logs_shared/public/plugin.ts b/x-pack/plugins/logs_shared/public/plugin.ts index 092e95570db7fb..1d6063c34eed3f 100644 --- a/x-pack/plugins/logs_shared/public/plugin.ts +++ b/x-pack/plugins/logs_shared/public/plugin.ts @@ -6,9 +6,19 @@ */ import { CoreStart } from '@kbn/core/public'; +import { + LogsLocatorDefinition, + NodeLogsLocatorDefinition, + TraceLogsLocatorDefinition, +} from '../common/locators'; import { createLogAIAssistant } from './components/log_ai_assistant'; import { LogViewsService } from './services/log_views'; -import { LogsSharedClientPluginClass, LogsSharedClientStartDeps } from './types'; +import { + LogsSharedClientCoreSetup, + LogsSharedClientPluginClass, + LogsSharedClientSetupDeps, + LogsSharedClientStartDeps, +} from './types'; export class LogsSharedPlugin implements LogsSharedClientPluginClass { private logViews: LogViewsService; @@ -17,10 +27,27 @@ export class LogsSharedPlugin implements LogsSharedClientPluginClass { this.logViews = new LogViewsService(); } - public setup() { + public setup(_: LogsSharedClientCoreSetup, pluginsSetup: LogsSharedClientSetupDeps) { const logViews = this.logViews.setup(); - return { logViews }; + const logsLocator = pluginsSetup.share.url.locators.create( + new LogsLocatorDefinition(pluginsSetup.share.url.locators) + ); + const nodeLogsLocator = pluginsSetup.share.url.locators.create( + new NodeLogsLocatorDefinition(pluginsSetup.share.url.locators) + ); + + const traceLogsLocator = pluginsSetup.share.url.locators.create( + new TraceLogsLocatorDefinition(pluginsSetup.share.url.locators) + ); + + const locators = { + logsLocator, + nodeLogsLocator, + traceLogsLocator, + }; + + return { logViews, locators }; } public start(core: CoreStart, plugins: LogsSharedClientStartDeps) { diff --git a/x-pack/plugins/logs_shared/public/types.ts b/x-pack/plugins/logs_shared/public/types.ts index 2a2e9c1cf742db..da5d2ec49e6224 100644 --- a/x-pack/plugins/logs_shared/public/types.ts +++ b/x-pack/plugins/logs_shared/public/types.ts @@ -5,18 +5,14 @@ * 2.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 { CoreSetup, CoreStart, Plugin as PluginClass } from '@kbn/core/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { ObservabilityAIAssistantPluginStart } from '@kbn/observability-ai-assistant-plugin/public'; +import { SharePluginSetup } from '@kbn/share-plugin/public'; import { UiActionsStart } from '@kbn/ui-actions-plugin/public'; + +import { LogsSharedLocators } from '../common/locators'; import type { LogAIAssistantProps } from './components/log_ai_assistant/log_ai_assistant'; // import type { OsqueryPluginStart } from '../../osquery/public'; import { LogViewsServiceSetup, LogViewsServiceStart } from './services/log_views'; @@ -24,6 +20,7 @@ import { LogViewsServiceSetup, LogViewsServiceStart } from './services/log_views // Our own setup and start contract values export interface LogsSharedClientSetupExports { logViews: LogViewsServiceSetup; + locators: LogsSharedLocators; } export interface LogsSharedClientStartExports { @@ -31,8 +28,9 @@ export interface LogsSharedClientStartExports { LogAIAssistant: (props: Omit) => JSX.Element; } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface LogsSharedClientSetupDeps {} +export interface LogsSharedClientSetupDeps { + share: SharePluginSetup; +} export interface LogsSharedClientStartDeps { data: DataPublicPluginStart; diff --git a/x-pack/plugins/logs_shared/tsconfig.json b/x-pack/plugins/logs_shared/tsconfig.json index b7c17b46c9a627..8d06a68a708737 100644 --- a/x-pack/plugins/logs_shared/tsconfig.json +++ b/x-pack/plugins/logs_shared/tsconfig.json @@ -26,6 +26,8 @@ "@kbn/datemath", "@kbn/core-http-browser", "@kbn/ui-actions-plugin", - "@kbn/observability-ai-assistant-plugin" + "@kbn/observability-ai-assistant-plugin", + "@kbn/deeplinks-observability", + "@kbn/share-plugin" ] } diff --git a/x-pack/plugins/maps/kibana.jsonc b/x-pack/plugins/maps/kibana.jsonc index b6bf08329fb44e..fb0472100e1c2e 100644 --- a/x-pack/plugins/maps/kibana.jsonc +++ b/x-pack/plugins/maps/kibana.jsonc @@ -27,7 +27,6 @@ "dashboard", "embeddable", "mapsEms", - "savedObjects", "share", "presentationUtil", "contentManagement" @@ -50,7 +49,8 @@ "usageCollection", "unifiedSearch", "fieldFormats", - "textBasedLanguages" + "textBasedLanguages", + "savedObjects", ], "extraPublicDirs": [ "common" diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_table_settings.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_table_settings.ts index 98bad60f8cd2e1..2a896b73556a59 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_table_settings.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_table_settings.ts @@ -13,7 +13,7 @@ const PAGE_SIZE_OPTIONS = [10, 25, 50]; // Copying from EUI EuiBasicTable types as type is not correctly picked up for table's onChange // Can be removed when https://github.com/elastic/eui/issues/4011 is addressed in EUI -export interface Criteria { +export interface Criteria { page?: { index: number; size: number; @@ -23,14 +23,14 @@ export interface Criteria { direction: Direction; }; } -export interface CriteriaWithPagination extends Criteria { +export interface CriteriaWithPagination extends Criteria { page: { index: number; size: number; }; } -interface UseTableSettingsReturnValue { +interface UseTableSettingsReturnValue { onTableChange: EuiBasicTableProps['onChange']; pagination: Required>; sorting: { @@ -41,7 +41,7 @@ interface UseTableSettingsReturnValue { }; } -export function useTableSettings( +export function useTableSettings( totalItemCount: number, pageState: ListingPageUrlState, updatePageState: (update: Partial) => void diff --git a/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts b/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts index 2b53fb7a4c7c4e..014c3dd365b6d4 100644 --- a/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts +++ b/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts @@ -16,9 +16,13 @@ import type { ActionGroup, AlertInstanceContext, AlertInstanceState, + RecoveredActionGroupId, RuleTypeState, } from '@kbn/alerting-plugin/common'; +import { AlertsClientError, DEFAULT_AAD_CONFIG } from '@kbn/alerting-plugin/server'; import type { RuleExecutorOptions } from '@kbn/alerting-plugin/server'; +import type { DefaultAlert } from '@kbn/alerts-as-data-utils'; +import { ALERT_REASON } from '@kbn/rule-data-utils'; import { ML_ALERT_TYPES } from '../../../common/constants/alerts'; import { PLUGIN_ID } from '../../../common/constants/app'; import { MINIMUM_FULL_LICENSE } from '../../../common/license'; @@ -90,7 +94,8 @@ export type JobsHealthExecutorOptions = RuleExecutorOptions< Record, Record, AnomalyDetectionJobsHealthAlertContext, - AnomalyDetectionJobRealtimeIssue + AnomalyDetectionJobRealtimeIssue, + DefaultAlert >; export function registerJobsMonitoringRuleType({ @@ -104,7 +109,9 @@ export function registerJobsMonitoringRuleType({ RuleTypeState, AlertInstanceState, AnomalyDetectionJobsHealthAlertContext, - AnomalyDetectionJobRealtimeIssue + AnomalyDetectionJobRealtimeIssue, + RecoveredActionGroupId, + DefaultAlert >({ id: ML_ALERT_TYPES.AD_JOBS_HEALTH, name: i18n.translate('xpack.ml.jobsHealthAlertingRule.name', { @@ -142,12 +149,19 @@ export function registerJobsMonitoringRuleType({ minimumLicenseRequired: MINIMUM_FULL_LICENSE, isExportable: true, doesSetRecoveryContext: true, + alerts: DEFAULT_AAD_CONFIG, async executor(options) { const { services, rule: { name }, } = options; + const { alertsClient } = services; + + if (!alertsClient) { + throw new AlertsClientError(); + } + const fakeRequest = {} as KibanaRequest; const { getTestsResults } = mlServicesProviders.jobsHealthServiceProvider( services.savedObjectsClient, @@ -165,19 +179,30 @@ export function registerJobsMonitoringRuleType({ .join(', ')}` ); - unhealthyTests.forEach(({ name: alertInstanceName, context }) => { - const alertInstance = services.alertFactory.create(alertInstanceName); - alertInstance.scheduleActions(ANOMALY_DETECTION_JOB_REALTIME_ISSUE, context); + unhealthyTests.forEach(({ name: alertName, context }) => { + alertsClient.report({ + id: alertName, + actionGroup: ANOMALY_DETECTION_JOB_REALTIME_ISSUE, + context, + payload: { + [ALERT_REASON]: context.message, + }, + }); }); } // Set context for recovered alerts - const { getRecoveredAlerts } = services.alertFactory.done(); - for (const recoveredAlert of getRecoveredAlerts()) { - const recoveredAlertId = recoveredAlert.getId(); + for (const recoveredAlert of alertsClient.getRecoveredAlerts()) { + const recoveredAlertId = recoveredAlert.alert.getId(); const testResult = executionResult.find((v) => v.name === recoveredAlertId); if (testResult) { - recoveredAlert.setContext(testResult.context); + alertsClient.setAlertData({ + id: recoveredAlertId, + context: testResult.context, + payload: { + [ALERT_REASON]: testResult.context.message, + }, + }); } } diff --git a/x-pack/plugins/monitoring/dev_docs/how_to/work_with_packages.md b/x-pack/plugins/monitoring/dev_docs/how_to/work_with_packages.md index f1c5eff8d07b88..4ab2349c332da4 100644 --- a/x-pack/plugins/monitoring/dev_docs/how_to/work_with_packages.md +++ b/x-pack/plugins/monitoring/dev_docs/how_to/work_with_packages.md @@ -61,6 +61,8 @@ A package can define the services it needs to monitor for development and automa This can be done by creating a `docker-compose` file under the package `_dev/deploy` directory, then running `elastic-package service up -v` in the package folder. An example is the [elasticsearch package](https://github.com/elastic/integrations/tree/main/packages/elasticsearch/_dev/deploy/docker) that starts a service which generates every types of logs with the help of a script executing queries. +**Note** that the container started with `elastic-package service up` will run in its own network and elastic-agent running with `elastic-package stack up` needs to specify the full docker service name to reach it. For example, if you want to collect metrics from the [elasticsearch instance](https://github.com/elastic/integrations/blob/main/packages/elasticsearch/_dev/deploy/docker/docker-compose.yml#L17) started with `elastic-package service up` you can configure elasticsearch integration to reach it at `http://elastic-package-service-elasticsearch-1:9200` (this may vary depending on OS/docker version). Alternatively you can reach the service on localhost via the forwarded port `http://host.docker.internal:9201`. + ### Collecting logs To collect logs elastic-agent needs access to the raw files. Let's see how that works taking `elasticsearch` package as an example. diff --git a/x-pack/plugins/monitoring/dev_docs/reference/data_collection_modes.md b/x-pack/plugins/monitoring/dev_docs/reference/data_collection_modes.md index b34bc550a0a8ff..ea585fda0306ee 100644 --- a/x-pack/plugins/monitoring/dev_docs/reference/data_collection_modes.md +++ b/x-pack/plugins/monitoring/dev_docs/reference/data_collection_modes.md @@ -145,7 +145,7 @@ Beats also doesn't have filebeat module or recommended configuration, but the lo ### Package-driven collection -See [working with packages](../howto/work_with_packages.md) for details on how to develop and test Stack Monitoring packages. +See [working with packages](../how_to/work_with_packages.md) for details on how to develop and test Stack Monitoring packages. When using package-driven collection, each component in your Elastic stack is given a corresponding fleet package (also known as "integration"). diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ingest_pipeline_modal.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ingest_pipeline_modal.tsx index 1a9f4000493058..29207177d93ec8 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ingest_pipeline_modal.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ingest_pipeline_modal.tsx @@ -43,7 +43,10 @@ export const ingestPipelineTabOnClick = async ( }); if (!dashboardFound) { - const installPackage = () => services.http!.post('/api/fleet/epm/packages/elasticsearch'); + const installPackage = () => + services.http!.post('/api/fleet/epm/packages/elasticsearch', { + headers: { 'Elastic-Api-Version': '2023-10-31' }, + }); const ref = services.overlays!.openModal( toMountPoint( diff --git a/x-pack/plugins/observability/common/index.ts b/x-pack/plugins/observability/common/index.ts index 3bd302b262fbc8..106eea543760f3 100644 --- a/x-pack/plugins/observability/common/index.ts +++ b/x-pack/plugins/observability/common/index.ts @@ -35,6 +35,7 @@ export { enableInfrastructureProfilingIntegration, enableAwsLambdaMetrics, enableAgentExplorerView, + apmEnableTableSearchBar, apmAWSLambdaPriceFactor, apmAWSLambdaRequestCostPerMillion, apmEnableServiceMetrics, diff --git a/x-pack/plugins/observability/common/ui_settings_keys.ts b/x-pack/plugins/observability/common/ui_settings_keys.ts index b647fc8361db85..028103f56a2071 100644 --- a/x-pack/plugins/observability/common/ui_settings_keys.ts +++ b/x-pack/plugins/observability/common/ui_settings_keys.ts @@ -21,6 +21,7 @@ export const enableInfrastructureProfilingIntegration = 'observability:enableInfrastructureProfilingIntegration'; export const enableAwsLambdaMetrics = 'observability:enableAwsLambdaMetrics'; export const enableAgentExplorerView = 'observability:apmAgentExplorerView'; +export const apmEnableTableSearchBar = 'observability:apmEnableTableSearchBar'; export const apmAWSLambdaPriceFactor = 'observability:apmAWSLambdaPriceFactor'; export const apmAWSLambdaRequestCostPerMillion = 'observability:apmAWSLambdaRequestCostPerMillion'; export const enableCriticalPath = 'observability:apmEnableCriticalPath'; diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index 3cf3874ad1f444..9403f67a8d59bd 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -40,6 +40,7 @@ export { apmServiceGroupMaxNumberOfServices, enableInfrastructureHostsView, enableAgentExplorerView, + apmEnableTableSearchBar, } from '../common/ui_settings_keys'; export { alertsLocatorID, diff --git a/x-pack/plugins/observability/server/ui_settings.ts b/x-pack/plugins/observability/server/ui_settings.ts index f52aedfd893beb..f58b6dd1f9683a 100644 --- a/x-pack/plugins/observability/server/ui_settings.ts +++ b/x-pack/plugins/observability/server/ui_settings.ts @@ -20,6 +20,7 @@ import { apmTraceExplorerTab, apmLabsButton, enableAgentExplorerView, + apmEnableTableSearchBar, enableAwsLambdaMetrics, apmAWSLambdaPriceFactor, apmAWSLambdaRequestCostPerMillion, @@ -284,6 +285,23 @@ export const uiSettings: Record = { 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/docs/esql-abs.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-abs.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-abs.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-abs.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-acos.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-acos.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-acos.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-acos.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-asin.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-asin.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-asin.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-asin.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-atan.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-atan.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-atan.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-atan.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-atan2.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-atan2.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-atan2.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-atan2.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-auto_bucket.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-auto_bucket.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-auto_bucket.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-auto_bucket.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-avg.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-avg.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-avg.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-avg.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-case.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-case.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-case.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-case.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-ceil.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-ceil.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-ceil.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-ceil.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-coalesce.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-coalesce.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-coalesce.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-coalesce.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-concat.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-concat.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-concat.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-concat.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-cos.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-cos.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-cos.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-cos.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-cosh.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-cosh.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-cosh.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-cosh.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-count.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-count.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-count.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-count.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-count_distinct.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-count_distinct.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-count_distinct.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-count_distinct.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-date_extract.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-date_extract.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-date_extract.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-date_extract.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-date_format.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-date_format.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-date_format.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-date_format.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-date_parse.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-date_parse.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-date_parse.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-date_parse.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-date_trunc.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-date_trunc.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-date_trunc.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-date_trunc.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-dissect.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-dissect.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-dissect.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-dissect.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-drop.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-drop.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-drop.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-drop.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-e.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-e.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-e.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-e.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-enrich.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-enrich.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-enrich.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-enrich.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-eval.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-eval.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-eval.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-eval.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-floor.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-floor.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-floor.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-floor.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-from.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-from.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-from.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-from.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-greatest.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-greatest.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-greatest.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-greatest.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-grok.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-grok.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-grok.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-grok.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-keep.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-keep.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-keep.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-keep.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-least.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-least.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-least.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-least.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-left.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-left.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-left.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-left.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-length.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-length.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-length.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-length.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-limit.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-limit.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-limit.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-limit.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-limitations.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-limitations.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-limitations.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-limitations.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-log10.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-log10.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-log10.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-log10.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-ltrim.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-ltrim.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-ltrim.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-ltrim.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-max.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-max.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-max.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-max.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-median.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-median.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-median.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-median.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-median_absolute_deviation.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-median_absolute_deviation.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-median_absolute_deviation.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-median_absolute_deviation.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-min.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-min.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-min.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-min.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-mv_avg.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-mv_avg.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-mv_avg.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-mv_avg.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-mv_concat.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-mv_concat.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-mv_concat.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-mv_concat.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-mv_count.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-mv_count.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-mv_count.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-mv_count.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-mv_dedupe.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-mv_dedupe.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-mv_dedupe.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-mv_dedupe.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-mv_expand.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-mv_expand.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-mv_expand.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-mv_expand.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-mv_max.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-mv_max.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-mv_max.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-mv_max.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-mv_median.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-mv_median.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-mv_median.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-mv_median.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-mv_min.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-mv_min.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-mv_min.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-mv_min.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-mv_sum.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-mv_sum.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-mv_sum.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-mv_sum.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-now.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-now.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-now.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-now.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-numeric-fields.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-numeric-fields.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-numeric-fields.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-numeric-fields.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-operators.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-operators.txt similarity index 91% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-operators.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-operators.txt index 29204aad6a3f68..dc1eca3a0fc5c3 100644 --- a/x-pack/plugins/observability_ai_assistant/server/functions/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_ai_assistant/server/functions/esql/docs/esql-overview.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-overview.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-overview.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-overview.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-percentile.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-percentile.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-percentile.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-percentile.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-pi.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-pi.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-pi.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-pi.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-pow.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-pow.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-pow.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-pow.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-processing-commands.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-processing-commands.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-processing-commands.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-processing-commands.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-rename.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-rename.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-rename.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-rename.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-replace.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-replace.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-replace.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-replace.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-right.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-right.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-right.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-right.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-round.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-round.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-round.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-round.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-row.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-row.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-row.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-row.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-rtrim.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-rtrim.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-rtrim.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-rtrim.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-show.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-show.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-show.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-show.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-sin.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-sin.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-sin.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-sin.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-sinh.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-sinh.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-sinh.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-sinh.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-sort.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-sort.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-sort.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-sort.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-source-commands.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-source-commands.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-source-commands.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-source-commands.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-split.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-split.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-split.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-split.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-sqrt.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-sqrt.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-sqrt.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-sqrt.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-stats.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-stats.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-stats.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-stats.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-substring.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-substring.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-substring.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-substring.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-sum.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-sum.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-sum.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-sum.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-syntax.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-syntax.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-syntax.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-syntax.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-tan.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-tan.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-tan.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-tan.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-tanh.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-tanh.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-tanh.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-tanh.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-tau.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-tau.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-tau.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-tau.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_boolean.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_boolean.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_boolean.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_boolean.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_cartesianpoint.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_cartesianpoint.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_cartesianpoint.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_cartesianpoint.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_datetime.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_datetime.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_datetime.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_datetime.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_degrees.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_degrees.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_degrees.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_degrees.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_double.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_double.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_double.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_double.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_geopoint.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_geopoint.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_geopoint.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_geopoint.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_integer.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_integer.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_integer.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_integer.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_ip.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_ip.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_ip.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_ip.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_long.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_long.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_long.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_long.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_radians.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_radians.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_radians.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_radians.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_string.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_string.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_string.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_string.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_unsigned_long.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_unsigned_long.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_unsigned_long.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_unsigned_long.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_version.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_version.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-to_version.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-to_version.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-trim.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-trim.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-trim.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-trim.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-where.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-where.txt similarity index 100% rename from x-pack/plugins/observability_ai_assistant/server/functions/esql/docs/esql-where.txt rename to x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-where.txt diff --git a/x-pack/plugins/observability_ai_assistant/server/functions/esql/index.ts b/x-pack/plugins/observability_ai_assistant/server/functions/esql/index.ts index bbadaa82a8fb3c..5aea8913bf1155 100644 --- a/x-pack/plugins/observability_ai_assistant/server/functions/esql/index.ts +++ b/x-pack/plugins/observability_ai_assistant/server/functions/esql/index.ts @@ -30,7 +30,7 @@ const loadSystemMessage = once(async () => { }); const loadEsqlDocs = once(async () => { - const dir = Path.join(__dirname, './docs'); + const dir = Path.join(__dirname, './esql_docs'); const files = (await readdir(dir)).filter((file) => Path.extname(file) === '.txt'); if (!files.length) { 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 19e84284b671be..c4d5edee7d3af1 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 e989089f491ebd..843aca4c258eaa 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/osquery/common/constants.ts b/x-pack/plugins/osquery/common/constants.ts index 5887d783d4ce48..966d00f708be1f 100644 --- a/x-pack/plugins/osquery/common/constants.ts +++ b/x-pack/plugins/osquery/common/constants.ts @@ -6,7 +6,6 @@ */ export const DEFAULT_MAX_TABLE_QUERY_SIZE = 10000; -export const DEFAULT_DARK_MODE = 'theme:darkMode'; export const OSQUERY_INTEGRATION_NAME = 'osquery_manager'; export const BASE_PATH = '/app/osquery'; diff --git a/x-pack/plugins/osquery/common/index.ts b/x-pack/plugins/osquery/common/index.ts index bec9e75f07ef40..a0f314bf273d6c 100644 --- a/x-pack/plugins/osquery/common/index.ts +++ b/x-pack/plugins/osquery/common/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -export { DEFAULT_DARK_MODE, OSQUERY_INTEGRATION_NAME, BASE_PATH } from './constants'; +export { OSQUERY_INTEGRATION_NAME, BASE_PATH } from './constants'; export const PLUGIN_ID = 'osquery'; export const PLUGIN_NAME = 'Osquery'; diff --git a/x-pack/plugins/osquery/cypress/e2e/all/alerts_automated_action_results.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/alerts_automated_action_results.cy.ts index ee2635651261cd..4c7c9663b2d40e 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/alerts_automated_action_results.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/alerts_automated_action_results.cy.ts @@ -112,7 +112,7 @@ describe('Alert Flyout Automated Action Results', () => { }); cy.contains(timelineRegex); cy.getBySel('securitySolutionFlyoutNavigationCollapseDetailButton').click(); - cy.getBySel('flyoutBottomBar').contains('Untitled timeline').click(); + cy.getBySel('timeline-bottom-bar').contains('Untitled timeline').click(); cy.contains(filterRegex); }); }); diff --git a/x-pack/plugins/osquery/cypress/e2e/all/alerts_linked_apps.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/alerts_linked_apps.cy.ts index 97dedb2ca6a2b9..f1284bf8b528f1 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/alerts_linked_apps.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/alerts_linked_apps.cy.ts @@ -89,7 +89,7 @@ describe( cy.getBySel(RESULTS_TABLE_BUTTON).should('not.exist'); }); cy.contains('Cancel').click(); - cy.getBySel('flyoutBottomBar').within(() => { + cy.getBySel('timeline-bottom-bar').within(() => { cy.contains(TIMELINE_NAME).click(); }); cy.getBySel('draggableWrapperKeyboardHandler').contains('action_id: "'); diff --git a/x-pack/plugins/osquery/cypress/e2e/all/timelines.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/timelines.cy.ts index 6c2380664ba4d0..1c6ff3b2fd66c6 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/timelines.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/timelines.cy.ts @@ -19,8 +19,8 @@ describe.skip('ALL - Timelines', { tags: ['@ess'] }, () => { it('should substitute osquery parameter on non-alert event take action', () => { cy.visit('/app/security/timelines'); - cy.getBySel('flyoutBottomBar').within(() => { - cy.getBySel('flyoutOverlay').click(); + cy.getBySel('timeline-bottom-bar').within(() => { + cy.getBySel('timeline-bottom-bar-title-button').click(); }); cy.getBySel('timelineQueryInput').type('NOT host.name: "dev-fleet-server.8220"{enter}'); // Filter out alerts diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/coverage_overview/coverage_overview_route.test.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/coverage_overview/coverage_overview_route.test.ts new file mode 100644 index 00000000000000..dd8b2501b90a72 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/coverage_overview/coverage_overview_route.test.ts @@ -0,0 +1,121 @@ +/* + * 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 { left } from 'fp-ts/lib/Either'; +import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; +import { + CoverageOverviewRequestBody, + CoverageOverviewRuleActivity, + CoverageOverviewRuleSource, +} from './coverage_overview_route'; + +describe('Coverage overview request schema', () => { + test('empty object validates', () => { + const payload: CoverageOverviewRequestBody = {}; + + const decoded = CoverageOverviewRequestBody.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('validates with all fields populated', () => { + const payload: CoverageOverviewRequestBody = { + filter: { + activity: [CoverageOverviewRuleActivity.Enabled, CoverageOverviewRuleActivity.Disabled], + source: [CoverageOverviewRuleSource.Custom, CoverageOverviewRuleSource.Prebuilt], + search_term: 'search term', + }, + }; + + const decoded = CoverageOverviewRequestBody.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('does NOT validate with extra fields', () => { + const payload: CoverageOverviewRequestBody & { invalid_field: string } = { + filter: { + activity: [CoverageOverviewRuleActivity.Enabled, CoverageOverviewRuleActivity.Disabled], + source: [CoverageOverviewRuleSource.Custom, CoverageOverviewRuleSource.Prebuilt], + search_term: 'search term', + }, + invalid_field: 'invalid field', + }; + + const decoded = CoverageOverviewRequestBody.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + + expect(getPaths(left(message.errors))).toEqual(['invalid keys "invalid_field"']); + expect(message.schema).toEqual({}); + }); + + test('does NOT validate with invalid filter values', () => { + const payload: CoverageOverviewRequestBody = { + filter: { + // @ts-expect-error + activity: ['Wrong activity field'], + // @ts-expect-error + source: ['Wrong source field'], + search_term: 'search term', + }, + }; + + const decoded = CoverageOverviewRequestBody.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "Wrong activity field" supplied to "filter,activity"', + 'Invalid value "Wrong source field" supplied to "filter,source"', + ]); + expect(message.schema).toEqual({}); + }); + + test('does NOT validate with empty filter arrays', () => { + const payload: CoverageOverviewRequestBody = { + filter: { + activity: [], + source: [], + search_term: 'search term', + }, + }; + + const decoded = CoverageOverviewRequestBody.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "[]" supplied to "filter,activity"', + 'Invalid value "[]" supplied to "filter,source"', + ]); + expect(message.schema).toEqual({}); + }); + + test('does NOT validate with empty search_term', () => { + const payload: CoverageOverviewRequestBody = { + filter: { + search_term: '', + }, + }; + + const decoded = CoverageOverviewRequestBody.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "" supplied to "filter,search_term"', + ]); + expect(message.schema).toEqual({}); + }); +}); 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 4aadb732836761..c6b8f1baf69741 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 e6c262f70626e6..dba0471992729a 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 2c7910166b196e..0402ed1a099230 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -21,7 +21,6 @@ export const allowedExperimentalValues = Object.freeze({ kubernetesEnabled: true, chartEmbeddablesEnabled: true, donutChartEmbeddablesEnabled: false, // Depends on https://github.com/elastic/kibana/issues/136409 item 2 - 6 - alertsPreviewChartEmbeddablesEnabled: false, // Depends on https://github.com/elastic/kibana/issues/136409 item 9 /** * This is used for enabling the end-to-end tests for the security_solution telemetry. * We disable the telemetry since we don't have specific roles or permissions around it and diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/common/index.test.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/common/index.test.ts index 560b36761c0901..98ac71c664a987 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/common/index.test.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/common/index.test.ts @@ -15,4 +15,26 @@ describe('hosts risk search_strategy getHostRiskIndex', () => { it('should properly return user index if space is specified', () => { expect(getUserRiskIndex('testName', true, false)).toEqual('ml_user_risk_score_latest_testName'); }); + + describe('with new risk score module installed', () => { + it('should properly return host index if onlyLatest is false', () => { + expect(getHostRiskIndex('default', false, true)).toEqual('risk-score.risk-score-default'); + }); + + it('should properly return host index if onlyLatest is true', () => { + expect(getHostRiskIndex('default', true, true)).toEqual( + 'risk-score.risk-score-latest-default' + ); + }); + + it('should properly return user index if onlyLatest is false', () => { + expect(getUserRiskIndex('default', false, true)).toEqual('risk-score.risk-score-default'); + }); + + it('should properly return user index if onlyLatest is true', () => { + expect(getUserRiskIndex('default', true, true)).toEqual( + 'risk-score.risk-score-latest-default' + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/common/index.ts index 783533b3e49ff1..12f666c3230b83 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/common/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/risk_score/common/index.ts @@ -23,9 +23,11 @@ export const getHostRiskIndex = ( onlyLatest: boolean = true, isNewRiskScoreModuleInstalled: boolean ): string => { - return isNewRiskScoreModuleInstalled - ? getRiskScoreLatestIndex(spaceId) - : `${RISKY_HOSTS_INDEX_PREFIX}${onlyLatest ? 'latest_' : ''}${spaceId}`; + if (isNewRiskScoreModuleInstalled) { + return onlyLatest ? getRiskScoreLatestIndex(spaceId) : getRiskScoreTimeSeriesIndex(spaceId); + } else { + return `${RISKY_HOSTS_INDEX_PREFIX}${onlyLatest ? 'latest_' : ''}${spaceId}`; + } }; export const getUserRiskIndex = ( @@ -33,11 +35,11 @@ export const getUserRiskIndex = ( onlyLatest: boolean = true, isNewRiskScoreModuleInstalled: boolean ): string => { - return isNewRiskScoreModuleInstalled - ? onlyLatest - ? getRiskScoreLatestIndex(spaceId) - : getRiskScoreTimeSeriesIndex(spaceId) - : `${RISKY_USERS_INDEX_PREFIX}${onlyLatest ? 'latest_' : ''}${spaceId}`; + if (isNewRiskScoreModuleInstalled) { + return onlyLatest ? getRiskScoreLatestIndex(spaceId) : getRiskScoreTimeSeriesIndex(spaceId); + } else { + return `${RISKY_USERS_INDEX_PREFIX}${onlyLatest ? 'latest_' : ''}${spaceId}`; + } }; export const buildHostNamesFilter = (hostNames: string[]) => { diff --git a/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/rule_management/coverage_overview_dashboard.md b/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/rule_management/coverage_overview_dashboard.md index 08f23aceda9b9c..86170a9e4c4050 100644 --- a/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/rule_management/coverage_overview_dashboard.md +++ b/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/rule_management/coverage_overview_dashboard.md @@ -24,9 +24,9 @@ Status: `in progress`. The current test plan matches `Milestone 1 - MVP` of the - **Rule Source**: The filter type defining rule type, current options are `prebuilt`(from elastic prebuilt rules package) and `custom`(created by user) --**Initial filter state**: The filters present on initial page load. Rule activity will be set to `enabled`, rule source will be set to `prebuilt` and `custom` simultaneously. +- **Initial filter state**: The filters present on initial page load. Rule activity will be set to `enabled`, rule source will be set to `prebuilt` and `custom` simultaneously. --**Dashboard containing the rule data**: The normal render of the coverage overview dashboard. Any returned rule data mapped correctly to the tile layout of all the MITRE data in a colored grid +- **Dashboard containing the rule data**: The normal render of the coverage overview dashboard. Any returned rule data mapped correctly to the tile layout of all the MITRE data in a colored grid ### Assumptions diff --git a/x-pack/plugins/security_solution/public/assistant/provider.tsx b/x-pack/plugins/security_solution/public/assistant/provider.tsx index a9f9e14a8d3e0b..43ae8eb6961288 100644 --- a/x-pack/plugins/security_solution/public/assistant/provider.tsx +++ b/x-pack/plugins/security_solution/public/assistant/provider.tsx @@ -22,7 +22,6 @@ import { useAnonymizationStore } from './use_anonymization_store'; import { useAssistantAvailability } from './use_assistant_availability'; import { APP_ID } from '../../common/constants'; import { useAppToasts } from '../common/hooks/use_app_toasts'; -import { useIsExperimentalFeatureEnabled } from '../common/hooks/use_experimental_features'; import { useSignalIndex } from '../detections/containers/detection_engine/alerts/use_signal_index'; const ASSISTANT_TITLE = i18n.translate('xpack.securitySolution.assistant.title', { @@ -39,8 +38,6 @@ export const AssistantProvider: React.FC = ({ children }) => { docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }, } = useKibana().services; const basePath = useBasePath(); - const isModelEvaluationEnabled = useIsExperimentalFeatureEnabled('assistantModelEvaluation'); - const assistantStreamingEnabled = useIsExperimentalFeatureEnabled('assistantStreamingEnabled'); const { conversations, setConversations } = useConversationStore(); const getInitialConversation = useCallback(() => { @@ -78,8 +75,6 @@ export const AssistantProvider: React.FC = ({ children }) => { getInitialConversations={getInitialConversation} getComments={getComments} http={http} - assistantStreamingEnabled={assistantStreamingEnabled} - modelEvaluatorEnabled={isModelEvaluationEnabled} nameSpace={nameSpace} setConversations={setConversations} setDefaultAllow={setDefaultAllow} diff --git a/x-pack/plugins/security_solution/public/common/components/conditions_table/index.tsx b/x-pack/plugins/security_solution/public/common/components/conditions_table/index.tsx index 29d6a8e430d6ae..ace749933d3898 100644 --- a/x-pack/plugins/security_solution/public/common/components/conditions_table/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/conditions_table/index.tsx @@ -18,11 +18,11 @@ const AndOrBadgeContainer = styled(EuiFlexItem)` padding-bottom: ${({ theme }) => theme.eui.euiSizeS}; `; -type ConditionsTableProps = EuiBasicTableProps & { +type ConditionsTableProps = EuiBasicTableProps & { badge: AndOr; }; -export const ConditionsTable = ({ badge, ...props }: ConditionsTableProps) => { +export const ConditionsTable = ({ badge, ...props }: ConditionsTableProps) => { return ( {props.items.length > 1 && ( 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 6cfc5729b726af..47c1d8b478c2d1 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/inspect/use_inspect.tsx b/x-pack/plugins/security_solution/public/common/components/inspect/use_inspect.tsx index e1d60be58acc41..23a81288908c18 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 0320daa2ec338d..ac254714adbb7a 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 761a3e597cadd7..58f1736e137925 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/ml/tables/basic_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/basic_table.tsx index e03ebae502ccd8..7ade79f91801bf 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/basic_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/basic_table.tsx @@ -9,7 +9,7 @@ import styled from 'styled-components'; import type { EuiInMemoryTableProps } from '@elastic/eui'; import { EuiInMemoryTable } from '@elastic/eui'; -type BasicTableType = React.ComponentType>; +type BasicTableType = React.ComponentType>; export const BasicTable: typeof EuiInMemoryTable & { displayName: string } = styled( EuiInMemoryTable as BasicTableType // eslint-disable-line @typescript-eslint/no-explicit-any )` 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 00000000000000..9f5f46fb67f689 --- /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 924b1158593a7a..b3fd18989991c7 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 5527e0eca44d63..930e510ff07faf 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 85b4a11bbc7f99..0e31ae006ddb5a 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 2c4c3ec036034b..79d791a15d7e87 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 92f394006d8e96..c87e941b18c9de 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 69d171467f9960..4883757132bc08 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 6f513e445660e0..b09e1fe2cc46c4 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 273e4d89d1d7a5..1582a0b382c75d 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 760d9e396584ec..80850978383074 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 00000000000000..ca80999a81062b --- /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 36d83e7793e596..68adb1dd8f20ad 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 39e822744922cc..601059cab2c2aa 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 580aad868d5c73..a5845b9bb0fc86 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/common/lib/telemetry/constants.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts index 3ce4c5c6b47ebf..d0bad4b00a2637 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/constants.ts @@ -49,6 +49,9 @@ export enum TelemetryEventTypes { EntityAlertsClicked = 'Entity Alerts Clicked', EntityRiskFiltered = 'Entity Risk Filtered', MLJobUpdate = 'ML Job Update', + AddRiskInputToTimelineClicked = 'Add Risk Input To Timeline Clicked', + ToggleRiskSummaryClicked = 'Toggle Risk Summary Clicked', + RiskInputsExpandedFlyoutOpened = 'Risk Inputs Expanded Flyout Opened', CellActionClicked = 'Cell Action Clicked', AnomaliesCountClicked = 'Anomalies Count Clicked', DataQualityIndexChecked = 'Data Quality Index Checked', diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/index.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/index.ts index 73cec55dabfc11..78968de0601862 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/index.ts @@ -53,3 +53,49 @@ export const entityRiskFilteredEvent: TelemetryEvent = { }, }, }; + +export const toggleRiskSummaryClickedEvent: TelemetryEvent = { + eventType: TelemetryEventTypes.ToggleRiskSummaryClicked, + schema: { + entity: { + type: 'keyword', + _meta: { + description: 'Entity name (host|user)', + optional: false, + }, + }, + action: { + type: 'keyword', + _meta: { + description: 'It defines if the section is opening or closing (show|hide)', + optional: false, + }, + }, + }, +}; + +export const RiskInputsExpandedFlyoutOpenedEvent: TelemetryEvent = { + eventType: TelemetryEventTypes.RiskInputsExpandedFlyoutOpened, + schema: { + entity: { + type: 'keyword', + _meta: { + description: 'Entity name (host|user)', + optional: false, + }, + }, + }, +}; + +export const addRiskInputToTimelineClickedEvent: TelemetryEvent = { + eventType: TelemetryEventTypes.AddRiskInputToTimelineClicked, + schema: { + quantity: { + type: 'integer', + _meta: { + description: 'Quantity of alerts added to timeline', + optional: false, + }, + }, + }, +}; diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/types.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/types.ts index dd0aeb4ce33848..7da80b09cf602e 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/types.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/types.ts @@ -19,10 +19,23 @@ export interface ReportEntityRiskFilteredParams extends EntityParam { selectedSeverity: RiskSeverity; } +export interface ReportToggleRiskSummaryClickedParams extends EntityParam { + action: 'show' | 'hide'; +} + +export type ReportRiskInputsExpandedFlyoutOpenedParams = EntityParam; + +export interface ReportAddRiskInputToTimelineClickedParams { + quantity: number; +} + export type ReportEntityAnalyticsTelemetryEventParams = | ReportEntityDetailsClickedParams | ReportEntityAlertsClickedParams - | ReportEntityRiskFilteredParams; + | ReportEntityRiskFilteredParams + | ReportToggleRiskSummaryClickedParams + | ReportRiskInputsExpandedFlyoutOpenedParams + | ReportAddRiskInputToTimelineClickedParams; export type EntityAnalyticsTelemetryEvent = | { @@ -36,4 +49,16 @@ export type EntityAnalyticsTelemetryEvent = | { eventType: TelemetryEventTypes.EntityRiskFiltered; schema: RootSchema; + } + | { + eventType: TelemetryEventTypes.AddRiskInputToTimelineClicked; + schema: RootSchema; + } + | { + eventType: TelemetryEventTypes.ToggleRiskSummaryClicked; + schema: RootSchema; + } + | { + eventType: TelemetryEventTypes.RiskInputsExpandedFlyoutOpened; + schema: RootSchema; }; diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts index 1a330db7b82c28..21a4be1b562075 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/events/telemetry_events.ts @@ -15,6 +15,9 @@ import { entityAlertsClickedEvent, entityClickedEvent, entityRiskFilteredEvent, + addRiskInputToTimelineClickedEvent, + RiskInputsExpandedFlyoutOpenedEvent, + toggleRiskSummaryClickedEvent, } from './entity_analytics'; import { assistantInvokedEvent, @@ -143,6 +146,9 @@ export const telemetryEvents = [ entityClickedEvent, entityAlertsClickedEvent, entityRiskFilteredEvent, + toggleRiskSummaryClickedEvent, + RiskInputsExpandedFlyoutOpenedEvent, + addRiskInputToTimelineClickedEvent, mlJobUpdateEvent, cellActionClickedEvent, anomaliesCountClickedEvent, diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.mock.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.mock.ts index e797c3a49a8e97..b4a5c9683127e8 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.mock.ts @@ -24,4 +24,7 @@ export const createTelemetryClientMock = (): jest.Mocked = reportDataQualityIndexChecked: jest.fn(), reportDataQualityCheckAllCompleted: jest.fn(), reportBreadcrumbClicked: jest.fn(), + reportToggleRiskSummaryClicked: jest.fn(), + reportRiskInputsExpandedFlyoutOpened: jest.fn(), + reportAddRiskInputToTimelineClicked: jest.fn(), }); diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts index 04d0ebeaa5ae6a..af49eb38436647 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/telemetry_client.ts @@ -24,8 +24,11 @@ import type { ReportAssistantMessageSentParams, ReportAssistantQuickPromptParams, ReportAssistantSettingToggledParams, + ReportRiskInputsExpandedFlyoutOpenedParams, + ReportToggleRiskSummaryClickedParams, } from './types'; import { TelemetryEventTypes } from './constants'; +import type { ReportAddRiskInputToTimelineClickedParams } from './events/entity_analytics/types'; /** * Client which aggregate all the available telemetry tracking functions @@ -107,6 +110,16 @@ export class TelemetryClient implements TelemetryClientStart { this.analytics.reportEvent(TelemetryEventTypes.MLJobUpdate, params); }; + reportToggleRiskSummaryClicked(params: ReportToggleRiskSummaryClickedParams): void { + this.analytics.reportEvent(TelemetryEventTypes.ToggleRiskSummaryClicked, params); + } + reportRiskInputsExpandedFlyoutOpened(params: ReportRiskInputsExpandedFlyoutOpenedParams): void { + this.analytics.reportEvent(TelemetryEventTypes.RiskInputsExpandedFlyoutOpened, params); + } + reportAddRiskInputToTimelineClicked(params: ReportAddRiskInputToTimelineClickedParams): void { + this.analytics.reportEvent(TelemetryEventTypes.AddRiskInputToTimelineClicked, params); + } + public reportCellActionClicked = (params: ReportCellActionClickedParams) => { this.analytics.reportEvent(TelemetryEventTypes.CellActionClicked, params); }; diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/types.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/types.ts index c98e0eafcd4343..cfc80c05e8c701 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/types.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/types.ts @@ -23,10 +23,13 @@ import type { } from './events/data_quality/types'; import type { EntityAnalyticsTelemetryEvent, + ReportAddRiskInputToTimelineClickedParams, ReportEntityAlertsClickedParams, ReportEntityAnalyticsTelemetryEventParams, ReportEntityDetailsClickedParams, ReportEntityRiskFilteredParams, + ReportRiskInputsExpandedFlyoutOpenedParams, + ReportToggleRiskSummaryClickedParams, } from './events/entity_analytics/types'; import type { AssistantTelemetryEvent, @@ -44,6 +47,9 @@ export type { ReportEntityAlertsClickedParams, ReportEntityDetailsClickedParams, ReportEntityRiskFilteredParams, + ReportRiskInputsExpandedFlyoutOpenedParams, + ReportToggleRiskSummaryClickedParams, + ReportAddRiskInputToTimelineClickedParams, } from './events/entity_analytics/types'; export interface TelemetryServiceSetupParams { @@ -95,10 +101,15 @@ export interface TelemetryClientStart { reportAssistantQuickPrompt(params: ReportAssistantQuickPromptParams): void; reportAssistantSettingToggled(params: ReportAssistantSettingToggledParams): void; + // Entity Analytics reportEntityDetailsClicked(params: ReportEntityDetailsClickedParams): void; reportEntityAlertsClicked(params: ReportEntityAlertsClickedParams): void; reportEntityRiskFiltered(params: ReportEntityRiskFilteredParams): void; reportMLJobUpdate(params: ReportMLJobUpdateParams): void; + // Entity Analytics inside Entity Flyout + reportToggleRiskSummaryClicked(params: ReportToggleRiskSummaryClickedParams): void; + reportRiskInputsExpandedFlyoutOpened(params: ReportRiskInputsExpandedFlyoutOpenedParams): void; + reportAddRiskInputToTimelineClicked(params: ReportAddRiskInputToTimelineClickedParams): void; reportCellActionClicked(params: ReportCellActionClickedParams): void; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/risk_score_mapping/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/risk_score_mapping/index.tsx index 66b7a5ff27d123..bbbf4a8740aef0 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/risk_score_mapping/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/risk_score_mapping/index.tsx @@ -51,7 +51,6 @@ interface RiskScoreFieldProps { idAria: string; indices: DataViewBase; isDisabled: boolean; - isActive: boolean; placeholder?: string; } @@ -61,7 +60,6 @@ export const RiskScoreField = ({ idAria, indices, isDisabled, - isActive, placeholder, }: RiskScoreFieldProps) => { const { value, isMappingChecked, mapping } = field.value; @@ -149,39 +147,29 @@ export const RiskScoreField = ({ return ( - { - // TODO: https://github.com/elastic/kibana/issues/161456 - // The About step page contains EuiRange component which does not work properly within memoized parents. - // EUI team suggested not to memoize EuiRange/EuiDualRange: https://github.com/elastic/eui/issues/6846 - // Workaround: We force EuiRange re-rendering by removing/adding it into the DOM. - // NOTE: We should remove this workaround once EUI team fixed EuiRange. - // Related ticket: https://github.com/elastic/kibana/issues/160561 - } - {isActive && ( - - - - )} + + + { } }; -/** - * 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 9d39b68626907e..69eebec3452d56 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 9a490bec1ce25b..80e98a8f288d85 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 7de2f70aa381a4..487fc3a4a4e29e 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 89600fa014099d..00000000000000 --- 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_creation_ui/components/step_about_rule/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.test.tsx index 6c4dfebc6be583..d654eaef9cca71 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.test.tsx @@ -106,7 +106,6 @@ describe('StepAboutRuleComponent', () => { dataViewId={defineStepDefault.dataViewId} timestampOverride={stepAboutDefaultValue.timestampOverride} isLoading={false} - isActive={true} form={aboutStepForm} /> ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.tsx index ef78ac0f408b79..85f5284f834525 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/step_about_rule/index.tsx @@ -50,13 +50,6 @@ interface StepAboutRuleProps extends RuleStepProps { timestampOverride: string; form: FormHook; esqlQuery?: string | undefined; - // TODO: https://github.com/elastic/kibana/issues/161456 - // The About step page contains EuiRange component which does not work properly within memoized parents. - // EUI team suggested not to memoize EuiRange/EuiDualRange: https://github.com/elastic/eui/issues/6846 - // Workaround: We introduced this additional property to be able to do extra re-render on switching to/from the About step page. - // NOTE: We should remove this workaround once EUI team fixed EuiRange. - // Related ticket: https://github.com/elastic/kibana/issues/160561 - isActive: boolean; } interface StepAboutRuleReadOnlyProps { @@ -83,7 +76,6 @@ const StepAboutRuleComponent: FC = ({ index, dataViewId, timestampOverride, - isActive = false, isUpdateView = false, isLoading, form, @@ -188,7 +180,6 @@ const StepAboutRuleComponent: FC = ({ dataTestSubj: 'detectionEngineStepAboutRuleRiskScore', idAria: 'detectionEngineStepAboutRuleRiskScore', isDisabled: isLoading || indexPatternLoading, - isActive, indices: indexPattern, }} /> diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx index a711ab11e0b16c..3700b09c0067a1 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/index.tsx @@ -596,7 +596,6 @@ const CreateRulePageComponent: React.FC = () => { dataViewId={defineStepData.dataViewId} timestampOverride={aboutStepData.timestampOverride} isLoading={isCreateRuleLoading || loading} - isActive={activeStep === RuleStep.aboutRule} form={aboutStepForm} esqlQuery={esqlQueryForAboutStep} /> diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx index d5933f6e6fdd66..ff96fd64f027f8 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx @@ -287,7 +287,6 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => { ) => { + const { addError } = useAppToasts(); + return useQuery( [...COVERAGE_OVERVIEW_QUERY_KEY, filter], async ({ signal }) => { @@ -39,6 +43,11 @@ export const useFetchCoverageOverviewQuery = ( { ...DEFAULT_QUERY_OPTIONS, ...options, + onError: (error) => { + addError(error, { + title: i18n.COVERAGE_OVERVIEW_FETCH_ERROR_TITLE, + }); + }, } ); }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/coverage_overview_dashboard.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/coverage_overview_dashboard.tsx index 11ee8f0d70bbce..fb14abd42ee615 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/coverage_overview_dashboard.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/coverage_overview_dashboard.tsx @@ -40,7 +40,12 @@ const CoverageOverviewDashboardComponent = () => { {data?.mitreTactics.map((tactic) => ( - + diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/rule_source_filter.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/rule_source_filter.tsx index fd1995beb68d71..e55450f0a069ab 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/rule_source_filter.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/rule_source_filter.tsx @@ -87,7 +87,7 @@ const RuleSourceFilterComponent = ({ `} > - +

{technique.name}

{SubtechniqueInfo} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/translations.ts index eb9f06c3504215..f61fa72e7f322b 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/translations.ts @@ -177,3 +177,10 @@ export const CoverageOverviewDashboardInformation = i18n.translate( "Your current coverage of MITRE ATT&CK\u00AE tactics and techniques, based on installed rules. Click a cell to view and enable a technique's rules. Rules must be mapped to the MITRE ATT&CK\u00AE framework to be displayed.", } ); + +export const COVERAGE_OVERVIEW_FETCH_ERROR_TITLE = i18n.translate( + 'xpack.securitySolution.coverageOverviewDashboard.fetchErrorTitle', + { + defaultMessage: 'Failed to fetch coverage overview data', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.test.tsx index 5998073d65cdb6..a1f7b488b1d0a6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.test.tsx @@ -15,6 +15,7 @@ import { AssistantProvider } from '@kbn/elastic-assistant'; import type { AssistantAvailability } from '@kbn/elastic-assistant'; import { httpServiceMock } from '@kbn/core-http-browser-mocks'; import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; jest.mock('../../../../common/lib/kibana'); @@ -32,29 +33,44 @@ const mockAssistantAvailability: AssistantAvailability = { hasConnectorsReadPrivilege: true, isAssistantEnabled: true, }; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + logger: { + log: jest.fn(), + warn: jest.fn(), + error: () => {}, + }, +}); + const ContextWrapper: React.FC = ({ children }) => ( - - {children} - + + + {children} + + ); describe('RuleStatusFailedCallOut', () => { 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 76699742e6b8a3..d86b9b37c568f6 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 b16ff08c6e9197..39365401a68dfe 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/detections/mitre/mitre_tactics_techniques.ts b/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts index 6846462b94c635..abfd92ca074ac5 100644 --- a/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts +++ b/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts @@ -7251,27 +7251,123 @@ export const subtechniques: MitreSubTechnique[] = [ ]; /** - * A full object of Mitre Attack Threat data that is taken directly from the `mitre_tactics_techniques.ts` file + * An array of full Mitre Attack Threat objects that are taken directly from the `mitre_tactics_techniques.ts` file * * Is built alongside and sampled from the data in the file so to always be valid with the most up to date MITRE ATT&CK data */ -export const getMockThreatData = () => ({ - tactic: { - name: 'Credential Access', - id: 'TA0006', - reference: 'https://attack.mitre.org/tactics/TA0006', - }, - technique: { - name: 'OS Credential Dumping', - id: 'T1003', - reference: 'https://attack.mitre.org/techniques/T1003', - tactics: ['credential-access'], +export const getMockThreatData = () => [ + { + tactic: { + name: 'Credential Access', + id: 'TA0006', + reference: 'https://attack.mitre.org/tactics/TA0006', + }, + technique: { + name: 'OS Credential Dumping', + id: 'T1003', + reference: 'https://attack.mitre.org/techniques/T1003', + tactics: ['credential-access'], + }, + subtechnique: { + name: '/etc/passwd and /etc/shadow', + id: 'T1003.008', + reference: 'https://attack.mitre.org/techniques/T1003/008', + tactics: ['credential-access'], + techniqueId: 'T1003', + }, + }, + { + tactic: { + name: 'Credential Access', + id: 'TA0006', + reference: 'https://attack.mitre.org/tactics/TA0006', + }, + technique: { + name: 'Steal or Forge Kerberos Tickets', + id: 'T1558', + reference: 'https://attack.mitre.org/techniques/T1558', + tactics: ['credential-access'], + }, + subtechnique: { + name: 'AS-REP Roasting', + id: 'T1558.004', + reference: 'https://attack.mitre.org/techniques/T1558/004', + tactics: ['credential-access'], + techniqueId: 'T1558', + }, + }, + { + tactic: { + name: 'Persistence', + id: 'TA0003', + reference: 'https://attack.mitre.org/tactics/TA0003', + }, + technique: { + name: 'Boot or Logon Autostart Execution', + id: 'T1547', + reference: 'https://attack.mitre.org/techniques/T1547', + tactics: ['persistence', 'privilege-escalation'], + }, + subtechnique: { + name: 'Active Setup', + id: 'T1547.014', + reference: 'https://attack.mitre.org/techniques/T1547/014', + tactics: ['persistence', 'privilege-escalation'], + techniqueId: 'T1547', + }, + }, + { + tactic: { + name: 'Persistence', + id: 'TA0003', + reference: 'https://attack.mitre.org/tactics/TA0003', + }, + technique: { + name: 'Account Manipulation', + id: 'T1098', + reference: 'https://attack.mitre.org/techniques/T1098', + tactics: ['persistence'], + }, + subtechnique: { + name: 'Additional Cloud Credentials', + id: 'T1098.001', + reference: 'https://attack.mitre.org/techniques/T1098/001', + tactics: ['persistence'], + techniqueId: 'T1098', + }, }, - subtechnique: { - name: '/etc/passwd and /etc/shadow', - id: 'T1003.008', - reference: 'https://attack.mitre.org/techniques/T1003/008', - tactics: ['credential-access'], - techniqueId: 'T1003', +]; + +/** + * An array of specifically chosen Mitre Attack Threat objects that is taken directly from the `mitre_tactics_techniques.ts` file + * + * These objects have identical technique fields but are assigned to different tactics + */ +export const getDuplicateTechniqueThreatData = () => [ + { + tactic: { + name: 'Privilege Escalation', + id: 'TA0004', + reference: 'https://attack.mitre.org/tactics/TA0004', + }, + technique: { + name: 'Event Triggered Execution', + id: 'T1546', + reference: 'https://attack.mitre.org/techniques/T1546', + tactics: ['privilege-escalation', 'persistence'], + }, + }, + { + tactic: { + name: 'Persistence', + id: 'TA0003', + reference: 'https://attack.mitre.org/tactics/TA0003', + }, + technique: { + name: 'Event Triggered Execution', + id: 'T1546', + reference: 'https://attack.mitre.org/techniques/T1546', + tactics: ['privilege-escalation', 'persistence'], + }, }, -}); +]; diff --git a/x-pack/plugins/security_solution/public/detections/mitre/valid_threat_mock.ts b/x-pack/plugins/security_solution/public/detections/mitre/valid_threat_mock.ts index c084a8a01d58ee..f7885a26d119f3 100644 --- a/x-pack/plugins/security_solution/public/detections/mitre/valid_threat_mock.ts +++ b/x-pack/plugins/security_solution/public/detections/mitre/valid_threat_mock.ts @@ -8,7 +8,7 @@ import type { Threats } from '@kbn/securitysolution-io-ts-alerting-types'; import { getMockThreatData } from './mitre_tactics_techniques'; -const { tactic, technique, subtechnique } = getMockThreatData(); +const { tactic, technique, subtechnique } = getMockThreatData()[0]; const { tactics, ...mockTechnique } = technique; const { tactics: subtechniqueTactics, ...mockSubtechnique } = subtechnique; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.tsx index f2a4c4c3a3288c..d3506456e5035d 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.tsx @@ -22,13 +22,16 @@ import { EuiModalHeaderTitle, EuiSuperSelect, EuiText, + EuiTitle, EuiHorizontalRule, + useEuiTheme, } from '@elastic/eui'; import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; +import { css } from '@emotion/react'; import { CRITICALITY_LEVEL_DESCRIPTION, CRITICALITY_LEVEL_TITLE, @@ -45,6 +48,7 @@ interface Props { export const AssetCriticalitySelector: React.FC = ({ entity }) => { const modal = useCriticalityModal(); const criticality = useAssetCriticalityData(entity, modal); + const { euiTheme } = useEuiTheme(); if (criticality.privileges.isLoading || !criticality.privileges.data?.has_all_required) { return null; @@ -52,15 +56,23 @@ export const AssetCriticalitySelector: React.FC = ({ entity }) => { return ( <> - + +

+ +

+
} + buttonProps={{ + css: css` + color: ${euiTheme.colors.primary}; + `, + }} data-test-subj="asset-criticality-selector" > {criticality.query.isLoading || criticality.mutation.isLoading ? ( diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions.ts index a5c89b864b84db..1201275f985ebe 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions.ts @@ -30,7 +30,7 @@ export const useRiskInputActions = (alerts: AlertRawData[], closePopover: () => tableId: TableId.riskInputs, }); - const { cases: casesService } = useKibana().services; + const { cases: casesService, telemetry } = useKibana().services; const createCaseFlyout = casesService?.hooks.useCasesAddToNewCaseFlyout({ onSuccess: noop }); const selectCaseModal = casesService?.hooks.useCasesAddToExistingCaseModal(); @@ -58,7 +58,12 @@ export const useRiskInputActions = (alerts: AlertRawData[], closePopover: () => closePopover(); createCaseFlyout.open({ attachments: caseAttachments }); }, + addToNewTimeline: () => { + telemetry.reportAddRiskInputToTimelineClicked({ + quantity: alerts.length, + }); + closePopover(); timelineAction.onClick( alerts.map((alert: AlertRawData) => { @@ -79,6 +84,14 @@ export const useRiskInputActions = (alerts: AlertRawData[], closePopover: () => ); }, }), - [alerts, caseAttachments, closePopover, createCaseFlyout, selectCaseModal, timelineAction] + [ + alerts, + caseAttachments, + closePopover, + createCaseFlyout, + selectCaseModal, + telemetry, + timelineAction, + ] ); }; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx index 8a15ec3cd435c8..5190fb8955427f 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import type { EuiBasicTableColumn } from '@elastic/eui'; import { useEuiTheme, @@ -21,6 +21,7 @@ import { css } from '@emotion/react'; import { FormattedMessage } from '@kbn/i18n-react'; import { euiThemeVars } from '@kbn/ui-theme'; import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../common/lib/kibana/kibana_react'; import { EntityDetailsLeftPanelTab } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; import type { HostRiskScore, @@ -73,6 +74,7 @@ const RiskSummaryComponent = ({ queryId, openDetailsPanel, }: RiskSummaryProps) => { + const { telemetry } = useKibana().services; const { data } = riskScoreData; const riskData = data && data.length > 0 ? data[0] : undefined; const entityData = getEntityData(riskData); @@ -138,8 +140,21 @@ const RiskSummaryComponent = ({ [alertsData?.length] ); + const onToggle = useCallback( + (isOpen) => { + const entity = isUserRiskData(riskData) ? 'user' : 'host'; + + telemetry.reportToggleRiskSummaryClicked({ + entity, + action: isOpen ? 'show' : 'hide', + }); + }, + [riskData, telemetry] + ); + return ( ({ {}} + hostName={'test-host-name'} /> )) .add('no observed data', () => ( @@ -62,6 +63,7 @@ storiesOf('Components/HostPanelContent', module) scopeId={'test-scopeId'} isDraggable={false} openDetailsPanel={() => {}} + hostName={'test-host-name'} /> )) .add('loading', () => ( @@ -84,5 +86,6 @@ storiesOf('Components/HostPanelContent', module) scopeId={'test-scopeId'} isDraggable={false} openDetailsPanel={() => {}} + hostName={'test-host-name'} /> )); diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx index eb7d5f3fda26d4..88da19ffdabff9 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import { EuiHorizontalRule } from '@elastic/eui'; - import React from 'react'; +import { EuiHorizontalRule } from '@elastic/eui'; +import { AssetCriticalitySelector } from '../../../entity_analytics/components/asset_criticality/asset_criticality_selector'; import { RiskSummary } from '../../../entity_analytics/components/risk_summary_flyout/risk_summary'; import type { RiskScoreState } from '../../../entity_analytics/api/hooks/use_risk_score'; import type { RiskScoreEntity, HostItem } from '../../../../common/search_strategy'; @@ -25,9 +25,11 @@ interface HostPanelContentProps { scopeId: string; isDraggable: boolean; openDetailsPanel: (tab: EntityDetailsLeftPanelTab) => void; + hostName: string; } export const HostPanelContent = ({ + hostName, observedHost, riskScoreState, contextID, @@ -41,16 +43,15 @@ export const HostPanelContent = ({ {riskScoreState.isModuleEnabled && riskScoreState.data?.length !== 0 && ( <> - { - - } - + + )} + { + const { telemetry } = useKibana().services; const { openLeftPanel } = useExpandableFlyoutContext(); const { to, from, isInitializing, setQuery, deleteQuery } = useGlobalTime(); const hostNameFilterQuery = useMemo( @@ -77,6 +79,10 @@ export const HostPanel = ({ contextID, scopeId, hostName, isDraggable }: HostPan const openTabPanel = useCallback( (tab?: EntityDetailsLeftPanelTab) => { + telemetry.reportRiskInputsExpandedFlyoutOpened({ + entity: 'host', + }); + openLeftPanel({ id: HostDetailsPanelKey, params: { @@ -86,7 +92,7 @@ export const HostPanel = ({ contextID, scopeId, hostName, isDraggable }: HostPan }, }); }, - [openLeftPanel, hostName, isRiskScoreExist] + [telemetry, openLeftPanel, hostName, isRiskScoreExist] ); const openDefaultPanel = useCallback(() => openTabPanel(), [openTabPanel]); @@ -121,6 +127,7 @@ export const HostPanel = ({ contextID, scopeId, hostName, isDraggable }: HostPan /> {}} + userName={'test-user-name'} /> )) .add('integration disabled', () => ( @@ -59,6 +60,7 @@ storiesOf('Components/UserPanelContent', module) scopeId={'test-scopeId'} isDraggable={false} openDetailsPanel={() => {}} + userName={'test-user-name'} /> )) .add('no managed data', () => ( @@ -74,6 +76,7 @@ storiesOf('Components/UserPanelContent', module) scopeId={'test-scopeId'} isDraggable={false} openDetailsPanel={() => {}} + userName={'test-user-name'} /> )) .add('no observed data', () => ( @@ -109,6 +112,7 @@ storiesOf('Components/UserPanelContent', module) scopeId={'test-scopeId'} isDraggable={false} openDetailsPanel={() => {}} + userName={'test-user-name'} /> )) .add('loading', () => ( @@ -148,5 +152,6 @@ storiesOf('Components/UserPanelContent', module) scopeId={'test-scopeId'} isDraggable={false} openDetailsPanel={() => {}} + userName={'test-user-name'} /> )); 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 f1f7916d3907c5..89844f3faece76 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,8 @@ import { EuiHorizontalRule } from '@elastic/eui'; import React from 'react'; +import { AssetCriticalitySelector } from '../../../entity_analytics/components/asset_criticality/asset_criticality_selector'; + import { OBSERVED_USER_QUERY_ID } from '../../../explore/users/containers/users/observed_details'; import { RiskSummary } from '../../../entity_analytics/components/risk_summary_flyout/risk_summary'; import type { RiskScoreState } from '../../../entity_analytics/api/hooks/use_risk_score'; @@ -22,6 +24,7 @@ import { useObservedUserItems } from './hooks/use_observed_user_items'; import type { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header'; interface UserPanelContentProps { + userName: string; observedUser: ObservedEntityData; managedUser: ManagedUserData; riskScoreState: RiskScoreState; @@ -32,6 +35,7 @@ interface UserPanelContentProps { } export const UserPanelContent = ({ + userName, observedUser, managedUser, riskScoreState, @@ -51,9 +55,10 @@ export const UserPanelContent = ({ queryId={USER_PANEL_RISK_SCORE_QUERY_ID} openDetailsPanel={openDetailsPanel} /> - + )} + { + const { telemetry } = useKibana().services; const userNameFilterQuery = useMemo( () => (userName ? buildUserNamesFilter([userName]) : undefined), [userName] @@ -80,6 +82,10 @@ export const UserPanel = ({ contextID, scopeId, userName, isDraggable }: UserPan const { openLeftPanel } = useExpandableFlyoutContext(); const openPanelTab = useCallback( (tab?: EntityDetailsLeftPanelTab) => { + telemetry.reportRiskInputsExpandedFlyoutOpened({ + entity: 'user', + }); + openLeftPanel({ id: UserDetailsPanelKey, params: { @@ -92,7 +98,7 @@ export const UserPanel = ({ contextID, scopeId, userName, isDraggable }: UserPan path: tab ? { tab } : undefined, }); }, - [email, openLeftPanel, userName, userRiskData] + [telemetry, email, openLeftPanel, userName, userRiskData] ); const openPanelFirstTab = useCallback(() => openPanelTab(), [openPanelTab]); @@ -134,6 +140,7 @@ export const UserPanel = ({ contextID, scopeId, userName, isDraggable }: UserPan managedUser={managedUser} /> { +describe('Endpoint generated alerts', { tags: ['@ess', '@serverless'] }, () => { let indexedPolicy: IndexedFleetEndpointPolicyResponse; let policy: PolicyData; let createdHost: CreateAndEnrollEndpointHostResponse; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 7988e29a6cfc22..0c9d989ad05712 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -181,7 +181,7 @@ describe('when on the endpoint list page', () => { }); const renderResult = render(); - const timelineFlyout = renderResult.queryByTestId('flyoutOverlay'); + const timelineFlyout = renderResult.queryByTestId('timeline-bottom-bar-title-button'); expect(timelineFlyout).toBeNull(); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx index dda667d4a2b91d..9f13e04f761a77 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx @@ -75,7 +75,7 @@ describe('Policy Details', () => { }); it('should NOT display timeline', async () => { - expect(policyView.find('flyoutOverlay')).toHaveLength(0); + expect(policyView.find('timeline-bottom-bar-title-button')).toHaveLength(0); }); it('should show loader followed by error message', async () => { @@ -136,7 +136,7 @@ describe('Policy Details', () => { it('should NOT display timeline', async () => { policyView = render(); await asyncActions; - expect(policyView.find('flyoutOverlay')).toHaveLength(0); + expect(policyView.find('timeline-bottom-bar-title-button')).toHaveLength(0); }); it('should display back to policy list button and policy title', async () => { 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 937dae7024ba40..44ad5811033933 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 31ed355c1b4756..218d69b4183a77 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/bottom_bar/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.test.tsx new file mode 100644 index 00000000000000..686a631736550b --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.test.tsx @@ -0,0 +1,63 @@ +/* + * 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 { TestProviders } from '../../../common/mock/test_providers'; +import { timelineActions } from '../../store'; +import { TimelineBottomBar } from '.'; +import { TimelineId } from '../../../../common/types'; + +jest.mock('react-redux', () => { + const origin = jest.requireActual('react-redux'); + return { + ...origin, + useDispatch: jest.fn().mockReturnValue(jest.fn()), + }; +}); + +describe('TimelineBottomBar', () => { + test('should render all components for bottom bar', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('timeline-bottom-bar')).toBeInTheDocument(); + expect(getByTestId('timeline-event-count-badge')).toBeInTheDocument(); + expect(getByTestId('timeline-save-status')).toBeInTheDocument(); + expect(getByTestId('timeline-favorite-empty-star')).toBeInTheDocument(); + }); + + test('should not render the event count badge if timeline is open', () => { + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('timeline-event-count-badge')).not.toBeInTheDocument(); + }); + + test('should dispatch show action when clicking on the title', () => { + const spy = jest.spyOn(timelineActions, 'showTimeline'); + + const { getByTestId } = render( + + + + ); + + getByTestId('timeline-bottom-bar-title-button').click(); + + expect(spy).toHaveBeenCalledWith({ + id: TimelineId.test, + show: true, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.tsx new file mode 100644 index 00000000000000..c3b086f7c4c1a2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.tsx @@ -0,0 +1,80 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiLink, EuiPanel } from '@elastic/eui'; +import { useDispatch, useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import type { State } from '../../../common/store'; +import { selectTitleByTimelineById } from '../../store/selectors'; +import { AddTimelineButton } from '../flyout/add_timeline_button'; +import { timelineActions } from '../../store'; +import { TimelineSaveStatus } from '../save_status'; +import { AddToFavoritesButton } from '../timeline/properties/helpers'; +import { TimelineEventsCountBadge } from '../../../common/hooks/use_timeline_events_count'; + +const openTimelineButton = (title: string) => + i18n.translate('xpack.securitySolution.timeline.bottomBar.toggleButtonAriaLabel', { + values: { title }, + defaultMessage: 'Open timeline {title}', + }); + +interface TimelineBottomBarProps { + /** + * Id of the timeline to be displayed in the bottom bar and within the portal + */ + timelineId: string; + /** + * True if the timeline modal is open + */ + show: boolean; +} + +/** + * This component renders the bottom bar for timeline displayed or most of the pages within Security Solution. + */ +export const TimelineBottomBar = React.memo(({ show, timelineId }) => { + const dispatch = useDispatch(); + + const handleToggleOpen = useCallback( + () => dispatch(timelineActions.showTimeline({ id: timelineId, show: true })), + [dispatch, timelineId] + ); + + const title = useSelector((state: State) => selectTitleByTimelineById(state, timelineId)); + + return ( + + + + + + + + + + + {title} + + + {!show && ( // this is a hack because TimelineEventsCountBadge is using react-reverse-portal so the component which is used in multiple places cannot be visible in multiple places at the same time + + + + )} + + + + + + ); +}); + +TimelineBottomBar.displayName = 'TimelineBottomBar'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.test.tsx deleted file mode 100644 index 6c97e250a8e79b..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.test.tsx +++ /dev/null @@ -1,44 +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 { TestProviders } from '../../../../common/mock/test_providers'; -import { FlyoutBottomBar } from '.'; - -describe('FlyoutBottomBar', () => { - test('it renders the expected bottom bar', () => { - render( - - - - ); - - expect(screen.getByTestId('flyoutBottomBar')).toBeInTheDocument(); - }); - - test('it renders the flyout header panel', () => { - render( - - - - ); - - expect(screen.getByTestId('timeline-flyout-header-panel')).toBeInTheDocument(); - }); - - test('it hides the flyout header panel', () => { - render( - - - - ); - - expect(screen.queryByTestId('timeline-flyout-header-panel')).not.toBeInTheDocument(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx deleted file mode 100644 index bc1617d3b9a53f..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx +++ /dev/null @@ -1,27 +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 { FLYOUT_BUTTON_BAR_CLASS_NAME } from '../../timeline/helpers'; -import { FlyoutHeaderPanel } from '../header'; - -interface FlyoutBottomBarProps { - showTimelineHeaderPanel: boolean; - - timelineId: string; -} - -export const FlyoutBottomBar = React.memo( - ({ showTimelineHeaderPanel, timelineId }) => { - return ( -
- {showTimelineHeaderPanel && } -
- ); - } -); - -FlyoutBottomBar.displayName = 'FlyoutBottomBar'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.test.tsx deleted file mode 100644 index 18bf93d0ab6c81..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.test.tsx +++ /dev/null @@ -1,96 +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 { - kibanaObservable, - mockGlobalState, - SUB_PLUGINS_REDUCER, - TestProviders, -} from '../../../../common/mock'; -import React from 'react'; -import type { ActiveTimelinesProps } from './active_timelines'; -import { ActiveTimelines } from './active_timelines'; -import { TimelineId } from '../../../../../common/types'; -import { TimelineType } from '../../../../../common/api/timeline'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { createSecuritySolutionStorageMock } from '@kbn/timelines-plugin/public/mock/mock_local_storage'; -import { createStore } from '../../../../common/store'; - -const { storage } = createSecuritySolutionStorageMock(); - -const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - -const TestComponent = (props: ActiveTimelinesProps) => { - return ( - - - - ); -}; - -describe('ActiveTimelines', () => { - describe('default timeline', () => { - it('should render timeline title as button when minimized', () => { - render( - - ); - - expect(screen.getByLabelText(/Open timeline timeline-test/).nodeName.toLowerCase()).toBe( - 'button' - ); - }); - - it('should render timeline title as text when maximized', () => { - render( - - ); - expect(screen.queryByLabelText(/Open timeline timeline-test/)).toBeFalsy(); - }); - - it('should maximized timeline when clicked on minimized timeline', async () => { - render( - - ); - - fireEvent.click(screen.getByLabelText(/Open timeline timeline-test/)); - - await waitFor(() => { - expect(store.getState().timeline.timelineById.test.show).toBe(true); - }); - }); - }); - - describe('template timeline', () => { - it('should render timeline template title as button when minimized', () => { - render( - - ); - - expect(screen.getByTestId(/timeline-title/)).toHaveTextContent(/Untitled template/); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx deleted file mode 100644 index 9a863a5fe81846..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.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 { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiText } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; -import { isEmpty } from 'lodash/fp'; -import styled from 'styled-components'; - -import { TimelineType } from '../../../../../common/api/timeline'; -import { TimelineEventsCountBadge } from '../../../../common/hooks/use_timeline_events_count'; -import { - ACTIVE_TIMELINE_BUTTON_CLASS_NAME, - focusActiveTimelineButton, -} from '../../timeline/helpers'; -import { UNTITLED_TIMELINE, UNTITLED_TEMPLATE } from '../../timeline/properties/translations'; -import { timelineActions } from '../../../store'; -import * as i18n from './translations'; - -export interface ActiveTimelinesProps { - timelineId: string; - timelineTitle: string; - timelineType: TimelineType; - isOpen: boolean; -} - -const StyledEuiButtonEmpty = styled(EuiButtonEmpty)` - &:active, - &:focus { - background: transparent; - } - > span { - padding: 0; - } -`; - -const TitleConatiner = styled(EuiFlexItem)` - overflow: hidden; - display: inline-block; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const ActiveTimelinesComponent: React.FC = ({ - timelineId, - timelineType, - timelineTitle, - isOpen, -}) => { - const dispatch = useDispatch(); - - const handleToggleOpen = useCallback(() => { - dispatch(timelineActions.showTimeline({ id: timelineId, show: !isOpen })); - focusActiveTimelineButton(); - }, [dispatch, isOpen, timelineId]); - - const title = !isEmpty(timelineTitle) - ? timelineTitle - : timelineType === TimelineType.template - ? UNTITLED_TEMPLATE - : UNTITLED_TIMELINE; - - const titleContent = useMemo(() => { - return ( - - - {isOpen ? ( - -

{title}

-
- ) : ( - <>{title} - )} -
- {!isOpen && ( - - - - )} -
- ); - }, [isOpen, title]); - - if (isOpen) { - return <>{titleContent}; - } - - return ( - - {titleContent} - - ); -}; - -export const ActiveTimelines = React.memo(ActiveTimelinesComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index 90cb542dfe2915..e40fd01bdd3db4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -5,24 +5,28 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiToolTip, EuiButtonIcon } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiToolTip, + EuiButtonIcon, + EuiText, +} from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import { isEmpty, get, pick } from 'lodash/fp'; import { useDispatch, useSelector } from 'react-redux'; -import styled from 'styled-components'; - import { getEsQueryConfig } from '@kbn/data-plugin/common'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; +import { selectTitleByTimelineById } from '../../../store/selectors'; import { createHistoryEntry } from '../../../../common/utils/global_query_string/helpers'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { timelineActions, timelineSelectors } from '../../../store'; import type { State } from '../../../../common/store'; import { useKibana } from '../../../../common/lib/kibana'; import { useSourcererDataView } from '../../../../common/containers/sourcerer'; -import { focusActiveTimelineButton } from '../../timeline/helpers'; import { combineQueries } from '../../../../common/lib/kuery'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; -import { ActiveTimelines } from './active_timelines'; import * as i18n from './translations'; import { TimelineActionMenu } from '../action_menu'; import { AddToFavoritesButton } from '../../timeline/properties/helpers'; @@ -34,13 +38,8 @@ interface FlyoutHeaderPanelProps { timelineId: string; } -const FlyoutHeaderPanelContentFlexGroupContainer = styled(EuiFlexGroup)` - overflow-x: auto; -`; - -const ActiveTimelinesContainer = styled(EuiFlexItem)` - overflow: hidden; -`; +const whiteSpaceNoWrapCSS = { 'white-space': 'nowrap' }; +const autoOverflowXCSS = { 'overflow-x': 'auto' }; const TimelinePanel = euiStyled(EuiPanel)<{ $isOpen?: boolean }>` backgroundColor: ${(props) => props.theme.eui.euiColorEmptyShade}; @@ -54,20 +53,14 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline const { browserFields, indexPattern } = useSourcererDataView(SourcererScopeName.timeline); const { uiSettings } = useKibana().services; const esQueryConfig = useMemo(() => getEsQueryConfig(uiSettings), [uiSettings]); + + const title = useSelector((state: State) => selectTitleByTimelineById(state, timelineId)); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const { activeTab, dataProviders, kqlQuery, title, timelineType, show, filters, kqlMode } = + const { activeTab, dataProviders, kqlQuery, timelineType, show, filters, kqlMode } = useDeepEqualSelector((state) => pick( - [ - 'activeTab', - 'dataProviders', - 'kqlQuery', - 'title', - 'timelineType', - 'show', - 'filters', - 'kqlMode', - ], + ['activeTab', 'dataProviders', 'kqlQuery', 'timelineType', 'show', 'filters', 'kqlMode'], getTimeline(state, timelineId) ?? timelineDefaults ) ); @@ -107,7 +100,6 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline const handleClose = useCallback(() => { createHistoryEntry(); dispatch(timelineActions.showTimeline({ id: timelineId, show: false })); - focusActiveTimelineButton(); }, [dispatch, timelineId]); return ( @@ -119,12 +111,13 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline data-test-subj="timeline-flyout-header-panel" data-show={show} > - @@ -137,14 +130,9 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline - - - + +

{title}

+
@@ -179,7 +167,7 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline
)} - +
); }; 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 54592e6a494cf8..b76565989fb748 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 7504e38db6ddb2..67d0c5a9e45996 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 dc2cca51044974..a7751cfb02d2e2 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 de993c8aa4ff9a..d1392a65192f87 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 2ce97cfbce32e1..6ae4207a46f782 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 d5df4304a08942..0d139449b1ca2c 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 ? ( - + ) : ( = React.memo( + diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/user_details/user_details_flyout.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/user_details/user_details_flyout.tsx index e2963c2e3ca3c7..1366e3474b5ea1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/user_details/user_details_flyout.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/user_details/user_details_flyout.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiFlyoutHeader, EuiFlyoutBody, EuiSpacer } from '@elastic/eui'; +import { EuiFlyoutHeader, EuiFlyoutBody, EuiSpacer, EuiHorizontalRule } from '@elastic/eui'; import React from 'react'; import styled from 'styled-components'; import { AssetCriticalitySelector } from '../../../../entity_analytics/components/asset_criticality/asset_criticality_selector'; @@ -44,6 +44,7 @@ export const UserDetailsFlyout = ({ + diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx index 72be8b6110ae45..0f22f4ea15cacc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx @@ -173,25 +173,6 @@ export const onTimelineTabKeyPressed = ({ } }; -export const ACTIVE_TIMELINE_BUTTON_CLASS_NAME = 'active-timeline-button'; -export const FLYOUT_BUTTON_BAR_CLASS_NAME = 'timeline-flyout-button-bar'; - -/** - * This function focuses the active timeline button on the next tick. Focus - * is updated on the next tick because this function is typically - * invoked in `onClick` handlers that also dispatch Redux actions (that - * in-turn update focus states). - */ -export const focusActiveTimelineButton = () => { - setTimeout(() => { - document - .querySelector( - `div.${FLYOUT_BUTTON_BAR_CLASS_NAME} .${ACTIVE_TIMELINE_BUTTON_CLASS_NAME}` - ) - ?.focus(); - }, 0); -}; - /** * Focuses the utility bar action contained by the provided `containerElement` * when a valid container is provided 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 72e85c77b0dbfa..5e88cf8b63cfe6 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 f39143bbfa7677..4b1c106230fdd7 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/public/timelines/store/selectors.ts b/x-pack/plugins/security_solution/public/timelines/store/selectors.ts index 9a2a732679fae2..496f867b1247f3 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/selectors.ts @@ -6,13 +6,18 @@ */ import { createSelector } from 'reselect'; +import { isEmpty } from 'lodash/fp'; +import { + UNTITLED_TEMPLATE, + UNTITLED_TIMELINE, +} from '../components/timeline/properties/translations'; import { timelineSelectors } from '.'; import { TimelineTabs } from '../../../common/types'; import type { State } from '../../common/store/types'; import type { TimelineModel } from './model'; import type { InsertTimeline, TimelineById } from './types'; -import { TimelineStatus } from '../../../common/api/timeline'; +import { TimelineStatus, TimelineType } from '../../../common/api/timeline'; export const getTimelineShowStatusByIdSelector = () => createSelector(timelineSelectors.selectTimeline, (timeline) => ({ @@ -23,19 +28,28 @@ export const getTimelineShowStatusByIdSelector = () => changed: timeline?.changed ?? false, })); -const selectTimelineById = (state: State): TimelineById => state.timeline.timelineById; +/** + * @deprecated + */ +const timelineByIdState = (state: State): TimelineById => state.timeline.timelineById; const selectCallOutUnauthorizedMsg = (state: State): boolean => state.timeline.showCallOutUnauthorizedMsg; +/** + * @deprecated prefer using selectTimelineById below + */ export const selectTimeline = (state: State, timelineId: string): TimelineModel => state.timeline.timelineById[timelineId]; export const selectInsertTimeline = (state: State): InsertTimeline | null => state.timeline.insertTimeline; +/** + * @deprecated prefer using selectTimelineById below + */ export const timelineByIdSelector = createSelector( - selectTimelineById, + timelineByIdState, (timelineById) => timelineById ); @@ -69,3 +83,45 @@ export const getKqlFilterKuerySelector = () => export const dataProviderVisibilitySelector = () => createSelector(selectTimeline, (timeline) => timeline.isDataProviderVisible); + +/** + * Selector that returns the timelineById slice of state + */ +export const selectTimelineById = createSelector( + (state: State) => state.timeline.timelineById, + (state: State, timelineId: string) => timelineId, + (timelineById, timelineId) => timelineById[timelineId] +); + +/** + * Selector that returns the timeline saved title. + */ +const selectTimelineTitle = createSelector(selectTimelineById, (timeline) => timeline?.title); + +/** + * Selector that returns the timeline type. + */ +const selectTimelineTimelineType = createSelector( + selectTimelineById, + (timeline) => timeline?.timelineType +); + +/** + * Selector that returns the title of a timeline. + * If the timeline has been saved, it will return the saved title. + * If timeline is in template mode, it will return the default 'Untitled template' value; + * If none of the above, it will return the default 'Untitled timeline' value. + */ +export const selectTitleByTimelineById = createSelector( + selectTimelineTitle, + selectTimelineTimelineType, + (savedTitle, timelineType): string => { + if (!isEmpty(savedTitle)) { + return savedTitle; + } + if (timelineType === TimelineType.template) { + return UNTITLED_TEMPLATE; + } + return UNTITLED_TIMELINE; + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/wrapper/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/wrapper/index.test.tsx index a88393fdea99f8..7eac49f7e066d2 100644 --- a/x-pack/plugins/security_solution/public/timelines/wrapper/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/wrapper/index.test.tsx @@ -46,7 +46,7 @@ describe('TimelineWrapper', () => { ); expect(getByTestId('flyout-pane')).toBeInTheDocument(); - expect(getByTestId('flyoutBottomBar')).toBeInTheDocument(); + expect(getByTestId('timeline-bottom-bar')).toBeInTheDocument(); }); it('should render the default timeline state as a bottom bar', () => { @@ -66,7 +66,7 @@ describe('TimelineWrapper', () => { ); - userEvent.click(getByTestId('flyoutOverlay')); + userEvent.click(getByTestId('timeline-bottom-bar-title-button')); expect(mockDispatch).toBeCalledWith( timelineActions.showTimeline({ id: TimelineId.test, show: true }) diff --git a/x-pack/plugins/security_solution/public/timelines/wrapper/index.tsx b/x-pack/plugins/security_solution/public/timelines/wrapper/index.tsx index 7146a50be90495..7b1c0743dbce61 100644 --- a/x-pack/plugins/security_solution/public/timelines/wrapper/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/wrapper/index.tsx @@ -11,12 +11,11 @@ import type { AppLeaveHandler } from '@kbn/core/public'; import { useDispatch } from 'react-redux'; import type { TimelineId } from '../../../common/types'; import { useDeepEqualSelector } from '../../common/hooks/use_selector'; -import { FlyoutBottomBar } from '../components/flyout/bottom_bar'; +import { TimelineBottomBar } from '../components/bottom_bar'; import { Pane } from '../components/flyout/pane'; import { getTimelineShowStatusByIdSelector } from '../store/selectors'; import { useTimelineSavePrompt } from '../../common/hooks/timeline/use_timeline_save_prompt'; import { timelineActions } from '../store'; -import { focusActiveTimelineButton } from '../components/timeline/helpers'; interface TimelineWrapperProps { /** @@ -42,7 +41,6 @@ export const TimelineWrapper: React.FC = React.memo( const handleClose = useCallback(() => { dispatch(timelineActions.showTimeline({ id: timelineId, show: false })); - focusActiveTimelineButton(); }, [dispatch, timelineId]); // pressing the ESC key closes the timeline portal @@ -62,7 +60,7 @@ export const TimelineWrapper: React.FC = React.memo( - + ); diff --git a/x-pack/plugins/security_solution/scripts/extract_tactics_techniques_mitre.js b/x-pack/plugins/security_solution/scripts/extract_tactics_techniques_mitre.js index ee037cae293b21..1f8526538e8c9d 100644 --- a/x-pack/plugins/security_solution/scripts/extract_tactics_techniques_mitre.js +++ b/x-pack/plugins/security_solution/scripts/extract_tactics_techniques_mitre.js @@ -22,6 +22,15 @@ const OUTPUT_DIRECTORY = resolve('public', 'detections', 'mitre'); const MITRE_CONTENT_VERSION = 'ATT&CK-v13.1'; // last updated when preparing for 8.10.3 release const MITRE_CONTENT_URL = `https://raw.githubusercontent.com/mitre/cti/${MITRE_CONTENT_VERSION}/enterprise-attack/enterprise-attack.json`; +/** + * An ID for a technique that exists in multiple tactics. This may change in further updates and on MITRE + * version upgrade, this ID should be double-checked to make sure it still represents these parameters. + * + * We have this in order to cover edge cases with our mock data that can't be achieved by simply generating + * data from the MITRE api. + */ +const MOCK_DUPLICATE_TECHNIQUE_ID = 'T1546'; + const getTacticsOptions = (tactics) => tactics.map((t) => `{ @@ -172,15 +181,37 @@ const extractSubtechniques = (mitreData) => { }; const buildMockThreatData = (tacticsData, techniques, subtechniques) => { - const subtechnique = subtechniques[0]; - const technique = techniques.find((technique) => technique.id === subtechnique.techniqueId); - const tactic = tacticsData.find((tactic) => tactic.shortName === technique.tactics[0]); - - return { - tactic: normalizeTacticsData([tactic])[0], - technique, - subtechnique, - }; + const numberOfThreatsToGenerate = 4; + const mockThreatData = []; + for (let i = 0; i < numberOfThreatsToGenerate; i++) { + const subtechnique = subtechniques[i * 2]; // Double our interval to broaden the subtechnique types we're pulling data from a bit + const technique = techniques.find((technique) => technique.id === subtechnique.techniqueId); + const tactic = tacticsData.find((tactic) => tactic.shortName === technique.tactics[0]); + + mockThreatData.push({ + tactic: normalizeTacticsData([tactic])[0], + technique, + subtechnique, + }); + } + return mockThreatData; +}; + +const buildDuplicateTechniqueMockThreatData = (tacticsData, techniques) => { + const technique = techniques.find((technique) => technique.id === MOCK_DUPLICATE_TECHNIQUE_ID); + const tacticOne = tacticsData.find((tactic) => tactic.shortName === technique.tactics[0]); + const tacticTwo = tacticsData.find((tactic) => tactic.shortName === technique.tactics[1]); + + return [ + { + tactic: normalizeTacticsData([tacticOne])[0], + technique, + }, + { + tactic: normalizeTacticsData([tacticTwo])[0], + technique, + }, + ]; }; async function main() { @@ -224,7 +255,7 @@ async function main() { .replace(/"{/g, '{')}; /** - * A full object of Mitre Attack Threat data that is taken directly from the \`mitre_tactics_techniques.ts\` file + * An array of full Mitre Attack Threat objects that are taken directly from the \`mitre_tactics_techniques.ts\` file * * Is built alongside and sampled from the data in the file so to always be valid with the most up to date MITRE ATT&CK data */ @@ -235,6 +266,19 @@ async function main() { ) .replace(/}"/g, '}') .replace(/"{/g, '{')}); + + /** + * An array of specifically chosen Mitre Attack Threat objects that is taken directly from the \`mitre_tactics_techniques.ts\` file + * + * These objects have identical technique fields but are assigned to different tactics + */ + export const getDuplicateTechniqueThreatData = () => (${JSON.stringify( + buildDuplicateTechniqueMockThreatData(tacticsData, techniques), + null, + 2 + ) + .replace(/}"/g, '}') + .replace(/"{/g, '{')}); `; fs.writeFileSync(`${OUTPUT_DIRECTORY}/mitre_tactics_techniques.ts`, body, 'utf-8'); 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 00000000000000..2c9c54f696c4fe --- /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 97a0aa16a1518d..a5d38b430ec92b 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 f0c359b9e0b0a2..4cffeccc0db3ce 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 2c5eabe612c099..f221a11204bf47 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 433c1c01cf0bca..cbff7156eaf154 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 c9a515f5566c24..602d29ae061abe 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 00000000000000..de90a09248eba5 --- /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 9cdc9189b16fa4..037639464a3e84 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 01154dc06f5f6e..27bdcfe796e766 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -514,6 +514,10 @@ export class Plugin implements ISecuritySolutionPlugin { // Assistant Tool and Feature Registration plugins.elasticAssistant.registerTools(APP_UI_ID, getAssistantTools()); + plugins.elasticAssistant.registerFeatures(APP_UI_ID, { + assistantModelEvaluation: config.experimentalFeatures.assistantModelEvaluation, + assistantStreamingEnabled: config.experimentalFeatures.assistantStreamingEnabled, + }); if (this.lists && plugins.taskManager && plugins.fleet) { // Exceptions, Artifacts and Manifests start diff --git a/x-pack/plugins/serverless_search/public/application/components/languages/curl.ts b/x-pack/plugins/serverless_search/public/application/components/languages/curl.ts index b744c1884ba49a..c679f8d132dddd 100644 --- a/x-pack/plugins/serverless_search/public/application/components/languages/curl.ts +++ b/x-pack/plugins/serverless_search/public/application/components/languages/curl.ts @@ -55,7 +55,7 @@ export API_KEY="${apiKey}"`, -d' { "index" : { "_index" : "${indexName ?? 'index_name'}" } } {"name": "foo", "title": "bar" } -`, +'`, installClient: `# if cURL is not already installed on your system # then install it with the package manager of your choice diff --git a/x-pack/plugins/stack_alerts/kibana.jsonc b/x-pack/plugins/stack_alerts/kibana.jsonc index 5c7bec1a37a0a5..9f2f33abf1f6e3 100644 --- a/x-pack/plugins/stack_alerts/kibana.jsonc +++ b/x-pack/plugins/stack_alerts/kibana.jsonc @@ -16,7 +16,6 @@ "features", "triggersActionsUi", "kibanaReact", - "savedObjects", "data", "dataViews", "kibanaUtils" diff --git a/x-pack/plugins/synthetics/common/constants/capabilities.ts b/x-pack/plugins/synthetics/common/constants/capabilities.ts index 2768c3bf439d63..b182345f1a32f9 100644 --- a/x-pack/plugins/synthetics/common/constants/capabilities.ts +++ b/x-pack/plugins/synthetics/common/constants/capabilities.ts @@ -8,4 +8,3 @@ export const INTEGRATED_SOLUTIONS = ['apm', 'infrastructure', 'logs']; export const DEFAULT_TIMEPICKER_QUICK_RANGES = 'timepicker:quickRanges'; -export const DEFAULT_DARK_MODE = 'theme:darkMode'; diff --git a/x-pack/plugins/synthetics/e2e/synthetics/journeys/management_list.journey.ts b/x-pack/plugins/synthetics/e2e/synthetics/journeys/management_list.journey.ts index 04339190d70657..b3a4db77680e0c 100644 --- a/x-pack/plugins/synthetics/e2e/synthetics/journeys/management_list.journey.ts +++ b/x-pack/plugins/synthetics/e2e/synthetics/journeys/management_list.journey.ts @@ -101,7 +101,7 @@ journey(`MonitorManagementList`, async ({ page, params }) => { }); step('Filter by Frequency', async () => { - const frequencyFilter = page.locator('.euiFilterButton__textShift', { hasText: 'Frequency' }); + const frequencyFilter = page.locator('.euiFilterButton__text', { hasText: 'Frequency' }); const fiveMinuteScheduleOption = page.getByText('Every 5 minutes').first(); await frequencyFilter.click(); diff --git a/x-pack/plugins/threat_intelligence/README.md b/x-pack/plugins/threat_intelligence/README.md index b708a4fecaa3d0..d81fb6611f155a 100755 --- a/x-pack/plugins/threat_intelligence/README.md +++ b/x-pack/plugins/threat_intelligence/README.md @@ -92,7 +92,7 @@ how the test suite is executed & extra options regarding parallelism, retrying e ### How is the Threat Intelligence code loaded in Kibana? The Threat Intelligence plugin is loaded lazily within the [security_solution](https://github.com/elastic/kibana/tree/main/x-pack/plugins/security_solution) plugin, -from `x-pack/plugins/security_solution/public/threat_intelligence` owned by the Protections Experience Team. +from `x-pack/plugins/security_solution/public/threat_intelligence` owned by the Threat Hunting Investigations Team. ## QA and demo for implemented features diff --git a/x-pack/plugins/threat_intelligence/cypress/screens/timeline.ts b/x-pack/plugins/threat_intelligence/cypress/screens/timeline.ts index 67bc60a4486eb2..e9e960db9858b3 100644 --- a/x-pack/plugins/threat_intelligence/cypress/screens/timeline.ts +++ b/x-pack/plugins/threat_intelligence/cypress/screens/timeline.ts @@ -17,7 +17,7 @@ import { } from '../../public/modules/indicators/components/table/test_ids'; export const INDICATORS_TABLE_INVESTIGATE_IN_TIMELINE_BUTTON_ICON = `[data-test-subj="${CELL_INVESTIGATE_IN_TIMELINE_TEST_ID}"]`; -export const UNTITLED_TIMELINE_BUTTON = `[data-test-subj="flyoutOverlay"]`; +export const UNTITLED_TIMELINE_BUTTON = `[data-test-subj="timeline-bottom-bar-title-button"]`; export const INDICATORS_TABLE_CELL_TIMELINE_BUTTON = `[data-test-subj="${CELL_TIMELINE_BUTTON_TEST_ID}"] button`; export const TIMELINE_DATA_PROVIDERS_WRAPPER = `[data-test-subj="dataProviders"]`; export const TIMELINE_DRAGGABLE_ITEM = `[data-test-subj="providerContainer"]`; diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/flyout/fields_table.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/flyout/fields_table.tsx index b6ca2ab9d47fbe..157eb42ec7ff65 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/flyout/fields_table.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/flyout/fields_table.tsx @@ -15,7 +15,7 @@ import { IndicatorValueActions } from './indicator_value_actions'; export interface IndicatorFieldsTableProps { fields: string[]; indicator: Indicator; - search: EuiInMemoryTableProps['search']; + search: EuiInMemoryTableProps['search']; ['data-test-subj']?: string; } @@ -59,13 +59,16 @@ export const IndicatorFieldsTable: VFC = ({ }, ], }, + // @ts-expect-error - EuiBasicTable wants an array of objects, but will accept strings if coerced ] as Array>, [indicator, dataTestSubj] ); return ( { +export interface Criteria { page?: { index: number; size: number; @@ -23,14 +23,14 @@ export interface Criteria { direction: Direction; }; } -export interface CriteriaWithPagination extends Criteria { +export interface CriteriaWithPagination extends Criteria { page: { index: number; size: number; }; } -interface AnalyticsBasicTableSettings { +interface AnalyticsBasicTableSettings { pageIndex: number; pageSize: number; totalItemCount: number; @@ -39,13 +39,13 @@ interface AnalyticsBasicTableSettings { sortDirection: Direction; } -interface UseTableSettingsReturnValue { +interface UseTableSettingsReturnValue { onTableChange: EuiBasicTableProps['onChange']; pagination: Pagination; sorting: { sort: PropertySort }; } -export function useTableSettings( +export function useTableSettings( sortByField: keyof TypeOfItem, items: TypeOfItem[] ): UseTableSettingsReturnValue { diff --git a/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/register_transform_health_rule_type.ts b/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/register_transform_health_rule_type.ts index 3eaa1db46018df..c6450fc243dc27 100644 --- a/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/register_transform_health_rule_type.ts +++ b/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/register_transform_health_rule_type.ts @@ -11,11 +11,14 @@ import type { ActionGroup, AlertInstanceContext, AlertInstanceState, + RecoveredActionGroupId, RuleTypeState, } from '@kbn/alerting-plugin/common'; -import type { RuleType } from '@kbn/alerting-plugin/server'; +import { AlertsClientError, DEFAULT_AAD_CONFIG, RuleType } from '@kbn/alerting-plugin/server'; import type { PluginSetupContract as AlertingSetup } from '@kbn/alerting-plugin/server'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/server'; +import type { DefaultAlert } from '@kbn/alerts-as-data-utils'; +import { ALERT_REASON } from '@kbn/rule-data-utils'; import { PLUGIN, type TransformHealth, TRANSFORM_RULE_TYPE } from '../../../../common/constants'; import { transformHealthRuleParams, TransformHealthRuleParams } from './schema'; import { transformHealthServiceProvider } from './transform_health_service'; @@ -73,7 +76,9 @@ export function getTransformHealthRuleType( RuleTypeState, AlertInstanceState, TransformHealthAlertContext, - TransformIssue + TransformIssue, + RecoveredActionGroupId, + DefaultAlert > { return { id: TRANSFORM_RULE_TYPE.TRANSFORM_HEALTH, @@ -110,12 +115,17 @@ export function getTransformHealthRuleType( minimumLicenseRequired: PLUGIN.MINIMUM_LICENSE_REQUIRED, isExportable: true, doesSetRecoveryContext: true, + alerts: DEFAULT_AAD_CONFIG, async executor(options) { const { - services: { scopedClusterClient, alertFactory, uiSettingsClient }, + services: { scopedClusterClient, alertsClient, uiSettingsClient }, params, } = options; + if (!alertsClient) { + throw new AlertsClientError(); + } + const fieldFormatsRegistry = await getFieldFormatsStart().fieldFormatServiceFactory( uiSettingsClient ); @@ -131,18 +141,29 @@ export function getTransformHealthRuleType( if (unhealthyTests.length > 0) { unhealthyTests.forEach(({ name: alertInstanceName, context }) => { - const alertInstance = alertFactory.create(alertInstanceName); - alertInstance.scheduleActions(TRANSFORM_ISSUE, context); + alertsClient.report({ + id: alertInstanceName, + actionGroup: TRANSFORM_ISSUE, + context, + payload: { + [ALERT_REASON]: context.message, + }, + }); }); } // Set context for recovered alerts - const { getRecoveredAlerts } = alertFactory.done(); - for (const recoveredAlert of getRecoveredAlerts()) { - const recoveredAlertId = recoveredAlert.getId(); + for (const recoveredAlert of alertsClient.getRecoveredAlerts()) { + const recoveredAlertId = recoveredAlert.alert.getId(); const testResult = executionResult.find((v) => v.name === recoveredAlertId); if (testResult) { - recoveredAlert.setContext(testResult.context); + alertsClient.setAlertData({ + id: recoveredAlertId, + context: testResult.context, + payload: { + [ALERT_REASON]: testResult.context.message, + }, + }); } } diff --git a/x-pack/plugins/transform/tsconfig.json b/x-pack/plugins/transform/tsconfig.json index 41f6fa29b8aa0f..faa4e2e67dd53b 100644 --- a/x-pack/plugins/transform/tsconfig.json +++ b/x-pack/plugins/transform/tsconfig.json @@ -72,7 +72,9 @@ "@kbn/data-view-editor-plugin", "@kbn/ml-data-view-utils", "@kbn/ml-creation-wizard-utils", - "@kbn/code-editor" + "@kbn/alerts-as-data-utils", + "@kbn/code-editor", + "@kbn/rule-data-utils" ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 70a03cb0f755aa..0d684afb7809c2 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -572,7 +572,6 @@ "core.euiComboBoxOptionsList.delimiterMessage": "Ajouter les éléments en les séparant par {delimiter}", "core.euiComboBoxOptionsList.noMatchingOptions": "{searchValue} ne correspond à aucune option", "core.euiComboBoxPill.removeSelection": "Retirer {children} de la sélection de ce groupe", - "core.euiControlBar.customScreenReaderAnnouncement": "Il y a un nouveau repère de région appelé {landmarkHeading} avec des commandes de niveau de page à la fin du document.", "core.euiDataGrid.ariaLabel": "{label} ; page {page} sur {pageCount}.", "core.euiDataGrid.ariaLabelledBy": "Page {page} sur {pageCount}.", "core.euiDataGridCell.position": "{columnId}, colonne {col}, ligne {row}", @@ -587,13 +586,6 @@ "core.euiFilterButton.filterBadgeActiveAriaLabel": "{count} filtres actifs", "core.euiFilterButton.filterBadgeAvailableAriaLabel": "{count} filtres disponibles", "core.euiMarkdownEditorFooter.supportedFileTypes": "Fichiers pris en charge : {supportedFileTypes}", - "core.euiNotificationEventMessages.accordionAriaLabelButtonText": "+ {messagesLength} messages pour {eventName}", - "core.euiNotificationEventMessages.accordionButtonText": "+ {messagesLength} en plus", - "core.euiNotificationEventMeta.contextMenuButton": "Menu pour {eventName}", - "core.euiNotificationEventReadButton.markAsReadAria": "Marquer {eventName} comme lu", - "core.euiNotificationEventReadButton.markAsUnreadAria": "Marquer {eventName} comme non lu", - "core.euiNotificationEventReadIcon.readAria": "{eventName} lu", - "core.euiNotificationEventReadIcon.unreadAria": "{eventName} non lu", "core.euiPagination.firstRangeAriaLabel": "Ignorer les pages 2 à {lastPage}", "core.euiPagination.lastRangeAriaLabel": "Ignorer les pages {firstPage} à {lastPage}", "core.euiPagination.pageOfTotalCompressed": "{page} de {total}", @@ -727,8 +719,6 @@ "core.euiComboBoxOptionsList.loadingOptions": "Options de chargement", "core.euiComboBoxOptionsList.noAvailableOptions": "Aucune option n’est disponible.", "core.euiCommonlyUsedTimeRanges.legend": "Couramment utilisées", - "core.euiControlBar.screenReaderAnnouncement": "Il y a un nouveau repère de région avec des commandes de niveau de page à la fin du document.", - "core.euiControlBar.screenReaderHeading": "Commandes de niveau de page", "core.euiDataGrid.screenReaderNotice": "Cette cellule contient du contenu interactif.", "core.euiDataGridCellActions.expandButtonTitle": "Cliquez ou appuyez sur Entrée pour interagir avec le contenu de la cellule.", "core.euiDataGridHeaderCell.actionsPopoverScreenReaderText": "Pour naviguer dans la liste des actions de la colonne, appuyez sur la touche Tab ou sur les flèches vers le haut et vers le bas.", @@ -835,11 +825,6 @@ "core.euiMarkdownEditorToolbar.editor": "Éditeur", "core.euiMarkdownEditorToolbar.previewMarkdown": "Aperçu", "core.euiModal.closeModal": "Ferme cette fenêtre modale.", - "core.euiNotificationEventMessages.accordionHideText": "masquer", - "core.euiNotificationEventReadButton.markAsRead": "Marquer comme lu", - "core.euiNotificationEventReadButton.markAsUnread": "Marquer comme non lu", - "core.euiNotificationEventReadIcon.read": "Lire", - "core.euiNotificationEventReadIcon.unread": "Non lu", "core.euiPagination.collection": "collection", "core.euiPagination.fromEndLabel": "à partir de la fin", "core.euiPagination.last": "Dernier", @@ -2375,12 +2360,8 @@ "discover.grid.flyout.documentNavigation": "Navigation dans le document", "discover.grid.flyout.toastColumnAdded": "La colonne \"{columnName}\" a été ajoutée.", "discover.grid.flyout.toastColumnRemoved": "La colonne \"{columnName}\" a été supprimée.", - "discover.grid.tableRow.detailHeading": "Document développé", "discover.grid.tableRow.textBasedDetailHeading": "Ligne développée", - "discover.grid.tableRow.viewSingleDocumentLinkTextSimple": "Document unique", "discover.grid.tableRow.viewSurroundingDocumentsHover": "Inspectez des documents qui ont été créés avant et après ce document. Seuls les filtres épinglés restent actifs dans la vue Documents relatifs.", - "discover.grid.tableRow.viewSurroundingDocumentsLinkTextSimple": "Documents relatifs", - "discover.grid.tableRow.viewText": "Afficher :", "discover.helpMenu.appName": "Découverte", "discover.inspectorRequestDataTitleDocuments": "Documents", "discover.inspectorRequestDataTitleMoreDocuments": "Plus de documents", @@ -2459,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}", @@ -5661,9 +5645,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 ", @@ -5797,9 +5778,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", @@ -6205,7 +6183,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", @@ -6250,31 +6227,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}", @@ -12343,7 +12307,6 @@ "xpack.dataVisualizer.combinedFieldsForm.mappingsParseError": "Erreur lors de l'analyse des mappings : {error}", "xpack.dataVisualizer.combinedFieldsForm.pipelineParseError": "Erreur lors de l'analyse du pipeline : {error}", "xpack.dataVisualizer.dataDrift.dataComparisonDistributionLabel": "{label} distribution", - "xpack.dataVisualizer.dataDrift.dataLabel": "{label} données", "xpack.dataVisualizer.dataDrift.distributionComparisonChartName": "Comparaison de la distribution de {referenceLabel} et {comparisonLabel} données pour {fieldName}", "xpack.dataVisualizer.dataDrift.progress.loadingComparison": "Chargement des données comparatives pour {fieldsCount} champs.", "xpack.dataVisualizer.dataDrift.progress.loadingReference": "Chargement des données de référence pour {fieldsCount} champs.", @@ -20152,7 +20115,6 @@ "xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel": "Voir les instructions de configuration", "xpack.infra.homePage.settingsTabTitle": "Paramètres", "xpack.infra.homePage.tellUsWhatYouThinkK8sLink": "Dites-nous ce que vous pensez ! (K8s)", - "xpack.observabilityShared.featureFeedbackButton.tellUsWhatYouThinkLink": "Dites-nous ce que vous pensez !", "xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder": "Rechercher des données d'infrastructure… (par exemple host.name:host-1)", "xpack.infra.hostFlyout.explainProcessMessageTitle": "Quel est ce processus ?", "xpack.infra.hosts.searchPlaceholder": "Rechercher dans les hôtes (par ex. cloud.provider:gcp AND system.load.1 > 0.5)", @@ -20946,6 +20908,47 @@ "xpack.infra.waffle.unableToSelectMetricErrorTitle": "Impossible de sélectionner les options ou la valeur pour l'indicateur.", "xpack.infra.waffleTime.autoRefreshButtonLabel": "Actualisation automatique", "xpack.infra.waffleTime.stopRefreshingButtonLabel": "Arrêter l'actualisation", + "xpack.observabilityShared.featureFeedbackButton.tellUsWhatYouThinkLink": "Dites-nous ce que vous pensez !", + "xpack.observabilityShared.inspector.stats.queryTimeValue": "{queryTime} ms", + "xpack.observabilityShared.breadcrumbs.observabilityLinkText": "Observabilité", + "xpack.observabilityShared.inspector.stats.dataViewDescription": "La vue de données qui se connecte aux index Elasticsearch.", + "xpack.observabilityShared.inspector.stats.dataViewLabel": "Vue de données", + "xpack.observabilityShared.inspector.stats.hitsDescription": "Le nombre de documents renvoyés par la requête.", + "xpack.observabilityShared.inspector.stats.hitsLabel": "Résultats", + "xpack.observabilityShared.inspector.stats.hitsTotalDescription": "Le nombre de documents correspondant à la requête.", + "xpack.observabilityShared.inspector.stats.hitsTotalLabel": "Résultats (total)", + "xpack.observabilityShared.inspector.stats.kibanaApiQueryParametersDescription": "Les paramètres de requête utilisés dans la requête d'API Kibana à l'origine de la requête Elasticsearch.", + "xpack.observabilityShared.inspector.stats.kibanaApiQueryParametersLabel": "Paramètres de requête d'API Kibana", + "xpack.observabilityShared.inspector.stats.kibanaApiRouteDescription": "Le chemin de la requête d'API Kibana à l'origine de la requête Elasticsearch.", + "xpack.observabilityShared.inspector.stats.kibanaApiRouteLabel": "Chemin de l’API Kibana", + "xpack.observabilityShared.inspector.stats.queryTimeDescription": "Le temps qu'il a fallu pour traiter la requête. Ne comprend pas le temps nécessaire pour envoyer la requête ni l'analyser dans le navigateur.", + "xpack.observabilityShared.inspector.stats.queryTimeLabel": "Durée de la requête", + "xpack.observabilityShared.navigation.betaBadge": "Bêta", + "xpack.observabilityShared.navigation.experimentalBadgeLabel": "Version d'évaluation technique", + "xpack.observabilityShared.navigation.newBadge": "NOUVEAUTÉ", + "xpack.observabilityShared.pageLayout.sideNavTitle": "Observabilité", + "xpack.observabilityShared.sectionLink.newLabel": "Nouveauté", + "xpack.observabilityShared.technicalPreviewBadgeDescription": "Cette fonctionnalité est en version d'évaluation technique et pourra être modifiée ou retirée complètement dans une future version. Elastic s'efforcera au maximum de corriger tout problème, mais les fonctionnalités en version d'évaluation technique ne sont pas soumises aux accords de niveau de service d'assistance des fonctionnalités officielles en disponibilité générale.", + "xpack.observabilityShared.technicalPreviewBadgeLabel": "Version d'évaluation technique", + "xpack.observabilityShared.tour.alertsStep.imageAltText": "Démonstration des alertes", + "xpack.observabilityShared.tour.alertsStep.tourContent": "Définissez et détectez les conditions qui déclenchent des alertes avec des intégrations de plateformes tierces comme l’e-mail, PagerDuty et Slack.", + "xpack.observabilityShared.tour.alertsStep.tourTitle": "Soyez informé en cas de modification", + "xpack.observabilityShared.tour.endButtonLabel": "Terminer la visite", + "xpack.observabilityShared.tour.guidedSetupStep.tourContent": "La façon la plus facile de continuer avec Elastic Observability est de suivre les prochaines étapes recommandées dans l'assistant de données.", + "xpack.observabilityShared.tour.guidedSetupStep.tourTitle": "Toujours plus avec Elastic Observability", + "xpack.observabilityShared.tour.metricsExplorerStep.imageAltText": "Démonstration de Metrics Explorer", + "xpack.observabilityShared.tour.metricsExplorerStep.tourContent": "Diffusez, regroupez et visualisez les mesures provenant de vos systèmes, du cloud, du réseau et d'autres sources d'infrastructure.", + "xpack.observabilityShared.tour.metricsExplorerStep.tourTitle": "Monitorer l’intégrité de votre infrastructure", + "xpack.observabilityShared.tour.nextButtonLabel": "Suivant", + "xpack.observabilityShared.tour.observabilityOverviewStep.tourContent": "Faites un tour rapide pour découvrir les avantages de disposer de toutes vos données d'observabilité dans une seule suite.", + "xpack.observabilityShared.tour.observabilityOverviewStep.tourTitle": "Bienvenue dans Elastic Observability", + "xpack.observabilityShared.tour.servicesStep.imageAltText": "Démonstration des services", + "xpack.observabilityShared.tour.servicesStep.tourContent": "Détectez et réparez rapidement les problèmes de performances en recueillant des informations détaillées sur vos services.", + "xpack.observabilityShared.tour.servicesStep.tourTitle": "Identifier et résoudre les problèmes d'application", + "xpack.observabilityShared.tour.skipButtonLabel": "Ignorer la visite", + "xpack.observabilityShared.tour.streamStep.imageAltText": "Démonstration du flux de logs", + "xpack.observabilityShared.tour.streamStep.tourContent": "Surveillez, filtrez et inspectez les événements de journal provenant de vos applications, serveurs, machines virtuelles et conteneurs.", + "xpack.observabilityShared.tour.streamStep.tourTitle": "Suivi de vos logs en temps réel", "xpack.metricsData.assetDetails.formulas.cpuUsage": "Utilisation CPU", "xpack.metricsData.assetDetails.formulas.cpuUsage.iowaitLabel": "iowait", "xpack.metricsData.assetDetails.formulas.cpuUsage.irqLabel": "irq", @@ -21915,53 +21918,6 @@ "xpack.lens.xyVisualization.dataTypeFailureXShort": "Type de données incorrect pour {axis}.", "xpack.lens.xyVisualization.dataTypeFailureYLong": "La dimension {label} fournie pour {axis} possède un type de données incorrect. Un nombre est attendu, mais {dataType} trouvé", "xpack.lens.xyVisualization.dataTypeFailureYShort": "Type de données incorrect pour {axis}.", - "lensFormulaDocs.tinymath.absFunction.markdown": "\nCalcule une valeur absolue. Une valeur négative est multipliée par -1, une valeur positive reste identique.\n\nExemple : calculer la distance moyenne par rapport au niveau de la mer \"abs(average(altitude))\"\n ", - "lensFormulaDocs.tinymath.addFunction.markdown": "\nAjoute jusqu'à deux nombres.\nFonctionne également avec le symbole \"+\".\n\nExemple : calculer la somme de deux champs\n\n\"sum(price) + sum(tax)\"\n\nExemple : compenser le compte par une valeur statique\n\n\"add(count(), 5)\"\n ", - "lensFormulaDocs.tinymath.cbrtFunction.markdown": "\nÉtablit la racine carrée de la valeur.\n\nExemple : calculer la longueur du côté à partir du volume\n`cbrt(last_value(volume))`\n ", - "lensFormulaDocs.tinymath.ceilFunction.markdown": "\nArrondit le plafond de la valeur au chiffre supérieur.\n\nExemple : arrondir le prix au dollar supérieur\n`ceil(sum(price))`\n ", - "lensFormulaDocs.tinymath.clampFunction.markdown": "\nÉtablit une limite minimale et maximale pour la valeur.\n\nExemple : s'assurer de repérer les valeurs aberrantes\n```\nclamp(\n average(bytes),\n percentile(bytes, percentile=5),\n percentile(bytes, percentile=95)\n)\n```\n", - "lensFormulaDocs.tinymath.cubeFunction.markdown": "\nCalcule le cube d'un nombre.\n\nExemple : calculer le volume à partir de la longueur du côté\n`cube(last_value(length))`\n ", - "lensFormulaDocs.tinymath.defaultFunction.markdown": "\nRetourne une valeur numérique par défaut lorsque la valeur est nulle.\n\nExemple : Retourne -1 lorsqu'un champ ne contient aucune donnée.\n\"defaults(average(bytes), -1)\"\n", - "lensFormulaDocs.tinymath.divideFunction.markdown": "\nDivise le premier nombre par le deuxième.\nFonctionne également avec le symbole \"/\".\n\nExemple : calculer la marge bénéficiaire\n\"sum(profit) / sum(revenue)\"\n\nExemple : \"divide(sum(bytes), 2)\"\n ", - "lensFormulaDocs.tinymath.eqFunction.markdown": "\nEffectue une comparaison d'égalité entre deux valeurs.\nÀ utiliser en tant que condition pour la fonction de comparaison \"ifelse\".\nFonctionne également avec le symbole \"==\".\n\nExemple : Retourne \"true\" si la moyenne d'octets est égale à la quantité de mémoire moyenne.\n\"average(bytes) == average(memory)\"\n\nExemple : \"eq(sum(bytes), 1000000)\"\n ", - "lensFormulaDocs.tinymath.expFunction.markdown": "\nÉlève *e* à la puissance n.\n\nExemple : calculer la fonction exponentielle naturelle\n\n`exp(last_value(duration))`\n ", - "lensFormulaDocs.tinymath.fixFunction.markdown": "\nPour les valeurs positives, part du bas. Pour les valeurs négatives, part du haut.\n\nExemple : arrondir à zéro\n\"fix(sum(profit))\"\n ", - "lensFormulaDocs.tinymath.floorFunction.markdown": "\nArrondit à la valeur entière inférieure la plus proche.\n\nExemple : arrondir un prix au chiffre inférieur\n\"floor(sum(price))\"\n ", - "lensFormulaDocs.tinymath.gteFunction.markdown": "\nEffectue une comparaison de supériorité entre deux valeurs.\nÀ utiliser en tant que condition pour la fonction de comparaison \"ifelse\".\nFonctionne également avec le symbole \">=\".\n\nExemple : Retourne \"true\" si la moyenne d'octets est supérieure ou égale à la quantité moyenne de mémoire.\n\"average(bytes) >= average(memory)\"\n\nExemple : \"gte(average(bytes), 1000)\"\n ", - "lensFormulaDocs.tinymath.gtFunction.markdown": "\nEffectue une comparaison de supériorité entre deux valeurs.\nÀ utiliser en tant que condition pour la fonction de comparaison \"ifelse\".\nFonctionne également avec le symbole \">\".\n\nExemple : Retourne \"true\" si la moyenne d'octets est supérieure à la quantité moyenne de mémoire.\n\"average(bytes) > average(memory)\"\n\nExemple : \"gt(average(bytes), 1000)\"\n ", - "lensFormulaDocs.tinymath.ifElseFunction.markdown": "\nRetourne une valeur selon si l'élément de condition est \"true\" ou \"false\".\n\nExemple : Revenus moyens par client, mais dans certains cas, l'ID du client n'est pas fourni, et le client est alors compté comme client supplémentaire.\n`sum(total)/(unique_count(customer_id) + ifelse( count() > count(kql='customer_id:*'), 1, 0))`\n ", - "lensFormulaDocs.tinymath.logFunction.markdown": "\nÉtablit un logarithme avec base optionnelle. La base naturelle *e* est utilisée par défaut.\n\nExemple : calculer le nombre de bits nécessaire au stockage de valeurs\n```\nlog(sum(bytes))\nlog(sum(bytes), 2)\n```\n ", - "lensFormulaDocs.tinymath.lteFunction.markdown": "\nEffectue une comparaison d'infériorité ou de supériorité entre deux valeurs.\nÀ utiliser en tant que condition pour la fonction de comparaison \"ifelse\".\nFonctionne également avec le symbole \"<=\".\n\nExemple : Retourne \"true\" si la moyenne d'octets est inférieure ou égale à la quantité moyenne de mémoire.\n\"average(bytes) <= average(memory)\"\n\nExemple : \"lte(average(bytes), 1000)\"\n ", - "lensFormulaDocs.tinymath.ltFunction.markdown": "\nEffectue une comparaison d'infériorité entre deux valeurs.\nÀ utiliser en tant que condition pour la fonction de comparaison \"ifelse\".\nFonctionne également avec le symbole \"<\".\n\nExemple : Retourne \"true\" si la moyenne d'octets est inférieure à la quantité moyenne de mémoire.\n\"average(bytes) <= average(memory)\"\n\nExemple : \"lt(average(bytes), 1000)\"\n ", - "lensFormulaDocs.tinymath.maxFunction.markdown": "\nTrouve la valeur maximale entre deux nombres.\n\nExemple : Trouver le maximum entre deux moyennes de champs\n\"pick_max(average(bytes), average(memory))\"\n ", - "lensFormulaDocs.tinymath.minFunction.markdown": "\nTrouve la valeur minimale entre deux nombres.\n\nExemple : Trouver le minimum entre deux moyennes de champs\n`pick_min(average(bytes), average(memory))`\n ", - "lensFormulaDocs.tinymath.modFunction.markdown": "\nÉtablit le reste après division de la fonction par un nombre.\n\nExemple : calculer les trois derniers chiffres d'une valeur\n\"mod(sum(price), 1000)\"\n ", - "lensFormulaDocs.tinymath.multiplyFunction.markdown": "\nMultiplie deux nombres.\nFonctionne également avec le symbole \"*\".\n\nExemple : calculer le prix après application du taux d'imposition courant\n`sum(bytes) * last_value(tax_rate)`\n\nExemple : calculer le prix après application du taux d'imposition constant\n\"multiply(sum(price), 1.2)\"\n ", - "lensFormulaDocs.tinymath.powFunction.markdown": "\nÉlève la valeur à une puissance spécifique. Le deuxième argument est obligatoire.\n\nExemple : calculer le volume en fonction de la longueur du côté\n\"pow(last_value(length), 3)\"\n ", - "lensFormulaDocs.tinymath.roundFunction.markdown": "\nArrondit à un nombre donné de décimales, 0 étant la valeur par défaut.\n\nExemples : arrondir au centième\n```\nround(sum(bytes))\nround(sum(bytes), 2)\n```\n ", - "lensFormulaDocs.tinymath.sqrtFunction.markdown": "\nÉtablit la racine carrée d'une valeur positive uniquement.\n\nExemple : calculer la longueur du côté en fonction de la surface\n`sqrt(last_value(area))`\n ", - "lensFormulaDocs.tinymath.squareFunction.markdown": "\nÉlève la valeur à la puissance 2.\n\nExemple : calculer l’aire en fonction de la longueur du côté\n`square(last_value(length))`\n ", - "lensFormulaDocs.tinymath.subtractFunction.markdown": "\nSoustrait le premier nombre du deuxième.\nFonctionne également avec le symbole \"-\".\n\nExemple : calculer la plage d'un champ\n\"subtract(max(bytes), min(bytes))\"\n ", - "lensFormulaDocs.documentation.filterRatioDescription.markdown": "### Rapport de filtre :\n\nUtilisez \"kql=''\" pour filtrer un ensemble de documents et le comparer à d'autres documents du même regroupement.\nPar exemple, pour consulter l'évolution du taux d'erreur au fil du temps :\n\n```\ncount(kql='response.status_code > 400') / count()\n```\n ", - "lensFormulaDocs.documentation.percentOfTotalDescription.markdown": "### Pourcentage du total\n\nLes formules peuvent calculer \"overall_sum\" pour tous les regroupements,\nce qui permet de convertir chaque regroupement en un pourcentage du total :\n\n```\nsum(products.base_price) / overall_sum(sum(products.base_price))\n```\n ", - "lensFormulaDocs.documentation.recentChangeDescription.markdown": "### Modification récente\n\nUtilisez \"reducedTimeRange='30m'\" pour ajouter un filtre supplémentaire sur la plage temporelle d'un indicateur aligné avec la fin d'une plage temporelle globale. Vous pouvez l'utiliser pour calculer le degré de modification récente d'une valeur.\n\n```\nmax(system.network.in.bytes, reducedTimeRange=\"30m\")\n - min(system.network.in.bytes, reducedTimeRange=\"30m\")\n```\n ", - "lensFormulaDocs.documentation.weekOverWeekDescription.markdown": "### Semaine après semaine :\n\nUtilisez \"shift='1w'\" pour obtenir la valeur de chaque regroupement\nde la semaine précédente. Le décalage ne doit pas être utilisé avec la fonction *Valeurs les plus élevées*.\n\n```\npercentile(system.network.in.bytes, percentile=99) /\npercentile(system.network.in.bytes, percentile=99, shift='1w')\n```\n ", - "lensFormulaDocs.cardinality.documentation.markdown": "\nCalcule le nombre de valeurs uniques d'un champ donné. Fonctionne pour les nombres, les chaînes, les dates et les valeurs booléennes.\n\nExemple : calculer le nombre de produits différents :\n`unique_count(product.name)`\n\nExemple : calculer le nombre de produits différents du groupe \"clothes\" :\n\"unique_count(product.name, kql='product.group=clothes')\"\n ", - "lensFormulaDocs.count.documentation.markdown": "\nNombre total de documents. Lorsque vous fournissez un champ, le nombre total de valeurs de champ est compté. Lorsque vous utilisez la fonction de décompte pour les champs qui comportent plusieurs valeurs dans un même document, toutes les valeurs sont comptées.\n\n#### Exemples\n\nPour calculer le nombre total de documents, utilisez `count()`.\n\nPour calculer le nombre de produits, utilisez `count(products.id)`.\n\nPour calculer le nombre de documents qui correspondent à un filtre donné, utilisez `count(kql='price > 500')`.\n ", - "lensFormulaDocs.counterRate.documentation.markdown": "\nCalcule le taux d'un compteur toujours croissant. Cette fonction renvoie uniquement des résultats utiles inhérents aux champs d'indicateurs de compteur qui contiennent une mesure quelconque à croissance régulière.\nSi la valeur diminue, elle est interprétée comme une mesure de réinitialisation de compteur. Pour obtenir des résultats plus précis, \"counter_rate\" doit être calculé d’après la valeur \"max\" du champ.\n\nCe calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures.\nIl utilise l'intervalle en cours utilisé dans la formule.\n\nExemple : visualiser le taux d'octets reçus au fil du temps par un serveur Memcached :\n`counter_rate(max(memcached.stats.read.bytes))`\n ", - "lensFormulaDocs.cumulativeSum.documentation.markdown": "\nCalcule la somme cumulée d'un indicateur au fil du temps, en ajoutant toutes les valeurs précédentes d'une série à chaque valeur. Pour utiliser cette fonction, vous devez également configurer une dimension de l'histogramme de dates.\n\nCe calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures.\n\nExemple : visualiser les octets reçus cumulés au fil du temps :\n`cumulative_sum(sum(bytes))`\n ", - "lensFormulaDocs.differences.documentation.markdown": "\nCalcule la différence par rapport à la dernière valeur d'un indicateur au fil du temps. Pour utiliser cette fonction, vous devez également configurer une dimension de l'histogramme de dates.\nLes données doivent être séquentielles pour les différences. Si vos données sont vides lorsque vous utilisez des différences, essayez d'augmenter l'intervalle de l'histogramme de dates.\n\nCe calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures.\n\nExemple : visualiser la modification des octets reçus au fil du temps :\n`differences(sum(bytes))`\n ", - "lensFormulaDocs.lastValue.documentation.markdown": "\nRenvoie la valeur d'un champ du dernier document, triée par le champ d'heure par défaut de la vue de données.\n\nCette fonction permet de récupérer le dernier état d'une entité.\n\nExemple : obtenir le statut actuel du serveur A :\n`last_value(server.status, kql='server.name=\"A\"')`\n ", - "lensFormulaDocs.metric.documentation.markdown": "\nRenvoie l'indicateur {metric} d'un champ. Cette fonction fonctionne uniquement pour les champs numériques.\n\nExemple : obtenir l'indicateur {metric} d'un prix :\n\"{metric}(price)\"\n\nExemple : obtenir l'indicateur {metric} d'un prix pour des commandes du Royaume-Uni :\n\"{metric}(price, kql='location:UK')\"\n ", - "lensFormulaDocs.movingAverage.documentation.markdown": "\nCalcule la moyenne mobile d'un indicateur au fil du temps, en prenant la moyenne des n dernières valeurs pour calculer la valeur actuelle. Pour utiliser cette fonction, vous devez également configurer une dimension de l'histogramme de dates.\nLa valeur de fenêtre par défaut est {defaultValue}.\n\nCe calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures.\n\nPrend un paramètre nommé \"window\" qui spécifie le nombre de dernières valeurs à inclure dans le calcul de la moyenne de la valeur actuelle.\n\nExemple : lisser une ligne de mesures :\n`moving_average(sum(bytes), window=5)`\n ", - "lensFormulaDocs.overall_average.documentation.markdown": "\nCalcule la moyenne d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle.\nD'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes.\n\nSi le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, \"overall_average\" calcule la moyenne pour toutes les dimensions, quelle que soit la fonction utilisée.\n\nExemple : écart par rapport à la moyenne :\n\"sum(bytes) - overall_average(sum(bytes))\"\n ", - "lensFormulaDocs.overall_max.documentation.markdown": "\nCalcule la valeur maximale d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle.\nD'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes.\n\nSi le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, \"overall_max\" calcule la valeur maximale pour toutes les dimensions, quelle que soit la fonction utilisée.\n\nExemple : pourcentage de plage\n\"(sum(bytes) - overall_min(sum(bytes))) / (overall_max(sum(bytes)) - overall_min(sum(bytes)))\"\n ", - "lensFormulaDocs.overall_min.documentation.markdown": "\nCalcule la valeur minimale d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle.\nD'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes.\n\nSi le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, \"overall_min\" calcule la valeur minimale pour toutes les dimensions, quelle que soit la fonction utilisée.\n\nExemple : pourcentage de plage\n\"(sum(bytes) - overall_min(sum(bytes)) / (overall_max(sum(bytes)) - overall_min(sum(bytes)))\"\n ", - "lensFormulaDocs.overall_sum.documentation.markdown": "\nCalcule la somme d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle.\nD'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes.\n\nSi le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, \"overall_sum\" calcule la somme pour toutes les dimensions, quelle que soit la fonction utilisée.\n\nExemple : pourcentage de total\n\"sum(bytes) / overall_sum(sum(bytes))\"\n ", - "lensFormulaDocs.percentile.documentation.markdown": "\nRenvoie le centile spécifié des valeurs d'un champ. Il s'agit de la valeur de n pour cent des valeurs présentes dans les documents.\n\nExemple : obtenir le nombre d'octets supérieurs à 95 % des valeurs :\n`percentile(bytes, percentile=95)`\n ", - "lensFormulaDocs.percentileRanks.documentation.markdown": "\nRetourne le pourcentage de valeurs qui sont en dessous d'une certaine valeur. Par exemple, si une valeur est supérieure à 95 % des valeurs observées, elle est placée au 95e rang centile.\n\nExemple : Obtenir le pourcentage de valeurs qui sont en dessous de 100 :\n\"percentile_rank(bytes, value=100)\"\n ", - "lensFormulaDocs.standardDeviation.documentation.markdown": "\nRetourne la taille de la variation ou de la dispersion du champ. Cette fonction ne s’applique qu’aux champs numériques.\n\n#### Exemples\n\nPour obtenir l'écart type d'un prix, utilisez standard_deviation(price).\n\nPour obtenir la variance du prix des commandes passées au Royaume-Uni, utilisez `square(standard_deviation(price, kql='location:UK'))`.\n ", - "lensFormulaDocs.time_scale.documentation.markdown": "\n\nCette fonction avancée est utile pour normaliser les comptes et les sommes sur un intervalle de temps spécifique. Elle permet l'intégration avec les indicateurs qui sont stockés déjà normalisés sur un intervalle de temps spécifique.\n\nVous pouvez faire appel à cette fonction uniquement si une fonction d'histogramme des dates est utilisée dans le graphique actuel.\n\nExemple : Un rapport comparant un indicateur déjà normalisé à un autre indicateur devant être normalisé.\n\"normalize_by_unit(counter_rate(max(system.diskio.write.bytes)), unit='s') / last_value(apache.status.bytes_per_second)\"\n ", "xpack.lens.AggBasedLabel": "visualisation basée sur l'agrégation", "xpack.lens.app.addToLibrary": "Enregistrer dans la bibliothèque", "xpack.lens.app.cancel": "Annuler", @@ -22139,11 +22095,6 @@ "xpack.lens.fittingFunctionsTitle.lookahead": "Suivant", "xpack.lens.fittingFunctionsTitle.none": "Masquer", "xpack.lens.fittingFunctionsTitle.zero": "Zéro", - "lensFormulaDocs.tinymath.base": "base", - "lensFormulaDocs.boolean": "booléen", - "lensFormulaDocs.tinymath.condition": "condition", - "lensFormulaDocs.tinymath.decimals": "décimales", - "lensFormulaDocs.tinymath.defaultValue": "par défaut", "xpack.lens.formula.disableWordWrapLabel": "Désactiver le renvoi à la ligne des mots", "xpack.lens.formula.editorHelpInlineHideLabel": "Masquer la référence des fonctions", "xpack.lens.formula.editorHelpInlineHideToolTip": "Masquer la référence des fonctions", @@ -22151,35 +22102,12 @@ "xpack.lens.formula.fullScreenEnterLabel": "Développer", "xpack.lens.formula.fullScreenExitLabel": "Réduire", "xpack.lens.formula.kqlExtraArguments": "[kql]?: string, [lucene]?: string", - "lensFormulaDocs.tinymath.left": "gauche", - "lensFormulaDocs.tinymath.max": "max", - "lensFormulaDocs.tinymath.min": "min", - "lensFormulaDocs.number": "numéro", "xpack.lens.formula.reducedTimeRangeExtraArguments": "[reducedTimeRange]?: string", "xpack.lens.formula.requiredArgument": "Obligatoire", - "lensFormulaDocs.tinymath.right": "droite", "xpack.lens.formula.shiftExtraArguments": "[shift]?: string", - "lensFormulaDocs.string": "chaîne", - "lensFormulaDocs.tinymath.value": "valeur", - "lensFormulaDocs.CommonFormulaDocumentation": "Les formules les plus courantes divisent deux valeurs pour produire un pourcentage. Pour obtenir un affichage correct, définissez \"Format de valeur\" sur \"pourcent\".", - "lensFormulaDocs.documentation.columnCalculationSection": "Calculs de colonnes", - "lensFormulaDocs.documentation.columnCalculationSectionDescription": "Ces fonctions sont exécutées pour chaque ligne, mais elles sont fournies avec la colonne entière comme contexte. Elles sont également appelées fonctions de fenêtre.", - "lensFormulaDocs.documentation.comparisonSection": "Comparaison", - "lensFormulaDocs.documentation.comparisonSectionDescription": "Ces fonctions sont utilisées pour effectuer une comparaison de valeurs.", - "lensFormulaDocs.documentation.constantsSection": "Contexte Kibana", - "lensFormulaDocs.documentation.constantsSectionDescription": "Ces fonctions sont utilisées pour récupérer des variables de contexte Kibana, c’est-à-dire l’histogramme de date \"interval\", le \"now\" actuel et le \"time_range\" sélectionné, et pour vous aider à faire des opérations mathématiques de dates.", - "lensFormulaDocs.documentation.elasticsearchSection": "Elasticsearch", - "lensFormulaDocs.documentation.elasticsearchSectionDescription": "Ces fonctions seront exécutées sur les documents bruts pour chaque ligne du tableau résultant, en agrégeant tous les documents correspondant aux dimensions de répartition en une seule valeur.", - "lensFormulaDocs.documentation.filterRatio": "Rapport de filtre", - "lensFormulaDocs.documentation.mathSection": "Mathématique", - "lensFormulaDocs.documentation.mathSectionDescription": "Ces fonctions seront exécutées pour chaque ligne du tableau résultant en utilisant des valeurs uniques de la même ligne calculées à l'aide d'autres fonctions.", - "lensFormulaDocs.documentation.percentOfTotal": "Pourcentage du total", - "lensFormulaDocs.documentation.recentChange": "Modification récente", - "lensFormulaDocs.documentation.weekOverWeek": "Semaine après semaine", "xpack.lens.formulaDocumentationHeading": "Fonctionnement", "xpack.lens.formulaEnableWordWrapLabel": "Activer le renvoi à la ligne des mots", "xpack.lens.formulaExampleMarkdown": "Exemples", - "lensFormulaDocs.frequentlyUsedHeading": "Formules courantes", "xpack.lens.formulaPlaceholderText": "Saisissez une formule en combinant des fonctions avec la fonction mathématique, telle que :", "xpack.lens.fullExtent.niceValues": "Arrondir aux valeurs de \"gentillesse\"", "xpack.lens.functions.collapse.args.byHelpText": "Colonnes selon lesquelles effectuer le regroupement - ces colonnes sont conservées telles quelles", @@ -22238,29 +22166,21 @@ "xpack.lens.indexPattern.allFieldsLabelHelp": "Glissez-déposez les champs disponibles dans l’espace de travail et créez des visualisations. Pour modifier les champs disponibles, sélectionnez une vue de données différente, modifiez vos requêtes ou utilisez une plage temporelle différente. Certains types de champ ne peuvent pas être visualisés dans Lens, y compris les champ de texte intégral et champs géographiques.", "xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning.link": "veuillez consulter la documentation", "xpack.lens.indexPattern.availableFieldsLabel": "Champs disponibles", - "lensFormulaDocs.avg": "Moyenne", "xpack.lens.indexPattern.avg.description": "Agrégation d'indicateurs à valeur unique qui calcule la moyenne des valeurs numériques extraites des documents agrégés", "xpack.lens.indexPattern.avg.quickFunctionDescription": "Valeur moyenne d'un ensemble de champs de nombres.", "xpack.lens.indexPattern.bitsFormatLabel": "Bits (1000)", "xpack.lens.indexPattern.bytesFormatLabel": "Octets (1024)", - "lensFormulaDocs.cardinality": "Compte unique", "xpack.lens.indexPattern.cardinality.documentation.quick": "\nNombre de valeurs uniques pour un champ spécifié de nombre, de chaîne, de date ou booléen.\n ", - "lensFormulaDocs.cardinality.signature": "champ : chaîne", "xpack.lens.indexPattern.changeDataViewTitle": "Vue de données", "xpack.lens.indexPattern.chooseField": "Champ", "xpack.lens.indexPattern.chooseFieldLabel": "Pour utiliser cette fonction, sélectionnez un champ.", "xpack.lens.indexPattern.chooseSubFunction": "Choisir une sous-fonction", "xpack.lens.indexPattern.columnFormatLabel": "Format de valeur", "xpack.lens.indexPattern.compactLabel": "Valeurs compactes", - "lensFormulaDocs.count": "Décompte", "xpack.lens.indexPattern.count.documentation.quick": "\nNombre total de documents. Lorsque vous fournissez un champ, le nombre total de valeurs de champ est compté. Lorsque vous utilisez la fonction de décompte pour les champs qui comportent plusieurs valeurs dans un même document, toutes les valeurs sont comptées.\n ", - "lensFormulaDocs.count.signature": "[champ : chaîne]", "xpack.lens.indexPattern.counterRate": "Taux de compteur", "xpack.lens.indexPattern.counterRate.documentation.quick": "\n Taux de modification sur la durée d'un indicateur de série temporelle qui augmente sans cesse.\n ", - "lensFormulaDocs.counterRate.signature": "indicateur : nombre", "xpack.lens.indexPattern.countOf": "Nombre d'enregistrements", - "lensFormulaDocs.cumulative_sum.signature": "indicateur : nombre", - "lensFormulaDocs.cumulativeSum": "Somme cumulée", "xpack.lens.indexPattern.cumulativeSum.documentation.quick": "\n Somme de toutes les valeurs au fur et à mesure de leur croissance.\n ", "xpack.lens.indexPattern.custom.externalDoc": "Syntaxe de format numérique", "xpack.lens.indexPattern.custom.patternLabel": "Format", @@ -22290,9 +22210,7 @@ "xpack.lens.indexPattern.dateRange.noTimeRange": "L’intervalle de plage temporelle actuel n’est pas disponible", "xpack.lens.indexPattern.decimalPlacesLabel": "Décimales", "xpack.lens.indexPattern.defaultFormatLabel": "Par défaut", - "lensFormulaDocs.derivative": "Différences", "xpack.lens.indexPattern.differences.documentation.quick": "\n Variation entre les valeurs des intervalles suivants.\n ", - "lensFormulaDocs.differences.signature": "indicateur : nombre", "xpack.lens.indexPattern.dimensionEditor.headingAppearance": "Apparence", "xpack.lens.indexPattern.dimensionEditor.headingData": "Données", "xpack.lens.indexPattern.dimensionEditor.headingFormula": "Formule", @@ -22345,30 +22263,22 @@ "xpack.lens.indexPattern.invalidOperationLabel": "Ce champ ne fonctionne pas avec la fonction sélectionnée.", "xpack.lens.indexPattern.invalidReducedTimeRange": "Plage temporelle réduite non valide. Entrez un entier positif suivi par l'une des unités suivantes : s, m, h, d, w, M, y. Par exemple, 3h pour 3 heures", "xpack.lens.indexPattern.invalidTimeShift": "Décalage non valide. Entrez un entier positif suivi par l'une des unités suivantes : s, m, h, d, w, M, y. Par exemple, 3h pour 3 heures", - "lensFormulaDocs.lastValue": "Dernière valeur", "xpack.lens.indexPattern.lastValue.disabled": "Cette fonction requiert la présence d'un champ de date dans la vue de données.", "xpack.lens.indexPattern.lastValue.documentation.quick": "\nValeur d'un champ du dernier document, triée par le champ d'heure par défaut de la vue de données.\n ", "xpack.lens.indexPattern.lastValue.showArrayValues": "Afficher les valeurs de tableau", "xpack.lens.indexPattern.lastValue.showArrayValuesExplanation": "Affiche toutes les valeurs associées à ce champ dans chaque dernier document.", "xpack.lens.indexPattern.lastValue.showArrayValuesWithTopValuesWarning": "Lorsque vous affichez les valeurs de tableau, vous ne pouvez pas utiliser ce champ pour classer les valeurs les plus élevées.", - "lensFormulaDocs.lastValue.signature": "champ : chaîne", "xpack.lens.indexPattern.lastValue.sortField": "Trier par le champ de date", "xpack.lens.indexPattern.lastValue.sortFieldPlaceholder": "Champ de tri", - "lensFormulaDocs.max": "Maximum", "xpack.lens.indexPattern.max.description": "Agrégation d'indicateurs à valeur unique qui renvoie la valeur maximale des valeurs numériques extraites des documents agrégés.", "xpack.lens.indexPattern.max.quickFunctionDescription": "Valeur maximale d'un champ de nombre.", - "lensFormulaDocs.median": "Médiane", "xpack.lens.indexPattern.median.description": "Agrégation d'indicateurs à valeur unique qui calcule la valeur médiane des valeurs numériques extraites des documents agrégés.", "xpack.lens.indexPattern.median.quickFunctionDescription": "Valeur médiane d'un champ de nombre.", "xpack.lens.indexPattern.metaFieldsLabel": "Champs méta", - "lensFormulaDocs.metric.signature": "champ : chaîne", - "lensFormulaDocs.min": "Minimum", "xpack.lens.indexPattern.min.description": "Agrégation d'indicateurs à valeur unique qui renvoie la valeur minimale des valeurs numériques extraites des documents agrégés.", "xpack.lens.indexPattern.min.quickFunctionDescription": "Valeur minimale d'un champ de nombre.", "xpack.lens.indexPattern.missingFieldLabel": "Champ manquant", "xpack.lens.indexPattern.moveToWorkspaceNotAvailable": "Pour visualiser ce champ, veuillez l'ajouter directement au calque souhaité. L'ajout de ce champ à l'espace de travail n'est pas pris en charge avec votre configuration actuelle.", - "lensFormulaDocs.moving_average.signature": "indicateur : nombre, [window] : nombre", - "lensFormulaDocs.movingAverage": "Moyenne mobile", "xpack.lens.indexPattern.movingAverage.basicExplanation": "La moyenne mobile fait glisser une fenêtre sur les données et affiche la valeur moyenne. La moyenne mobile est prise en charge uniquement par les histogrammes des dates.", "xpack.lens.indexPattern.movingAverage.documentation.quick": "\n Moyenne d'une fenêtre mobile de valeurs sur la durée.\n ", "xpack.lens.indexPattern.movingAverage.limitations": "La première valeur de moyenne mobile commence au deuxième élément.", @@ -22384,20 +22294,12 @@ "xpack.lens.indexPattern.noRealMetricError": "Un calque uniquement doté de valeurs statiques n’affichera pas de résultats ; utilisez au moins un indicateur dynamique.", "xpack.lens.indexPattern.notAbsoluteTimeShift": "Décalage non valide.", "xpack.lens.indexPattern.numberFormatLabel": "Nombre", - "lensFormulaDocs.overall_metric": "indicateur : nombre", - "lensFormulaDocs.overallMax": "Max général", - "lensFormulaDocs.overallMin": "Min général", - "lensFormulaDocs.overallSum": "Somme générale", "xpack.lens.indexPattern.percentFormatLabel": "Pourcent", - "lensFormulaDocs.percentile": "Centile", "xpack.lens.indexPattern.percentile.documentation.quick": "\n La plus grande valeur qui est inférieure à n pour cent des valeurs présentes dans tous les documents.\n ", "xpack.lens.indexPattern.percentile.percentileRanksValue": "Valeur des rangs centiles", "xpack.lens.indexPattern.percentile.percentileValue": "Centile", - "lensFormulaDocs.percentile.signature": "champ : chaîne, [percentile] : nombre", - "lensFormulaDocs.percentileRank": "Rang centile", "xpack.lens.indexPattern.percentileRanks.documentation.quick": "\nPourcentage des valeurs inférieures à une valeur spécifique. Par exemple, lorsqu'une valeur est supérieure ou égale à 95 % des valeurs calculées, elle est placée au 95e rang centile.\n ", "xpack.lens.indexPattern.percentileRanks.errorMessage": "La valeur des rangs centiles doit être un nombre", - "lensFormulaDocs.percentileRanks.signature": "champ : chaîne, [valeur] : nombre", "xpack.lens.indexPattern.precisionErrorWarning.accuracyDisabled.shortMessage": "Il peut s'agit d'une approximation. Pour obtenir des résultats plus fins, vous pouvez activer le mode de précision, mais ce mode augmente la charge sur le cluster Elasticsearch.", "xpack.lens.indexPattern.precisionErrorWarning.accuracyEnabled.shortMessage": "Il peut s'agit d'une approximation. Pour obtenir des résultats plus fins, utilisez les filtres ou augmentez le nombre défini pour Valeurs les plus élevées.", "xpack.lens.indexPattern.precisionErrorWarning.ascendingCountPrecisionErrorWarning.shortMessage": "Il peut s'agir d'une valeur approximative selon la façon dont les données sont indexées. Pour obtenir des résultats plus fins, effectuez un tri par rareté.", @@ -22447,7 +22349,6 @@ "xpack.lens.indexPattern.samplingPerLayer.fallbackLayerName": "Calque de données", "xpack.lens.indexPattern.settingsSamplingUnsupported": "La sélection de cette fonction a pour effet de changer l'échantillonnage de ce calque à 100 % afin de garantir un fonctionnement correct.", "xpack.lens.indexPattern.sortField.invalid": "Champ non valide. Vérifiez votre vue de données ou choisissez un autre champ.", - "lensFormulaDocs.standardDeviation": "Écart-type", "xpack.lens.indexPattern.standardDeviation.description": "Agrégation d'indicateurs à valeur unique qui calcule l’écart-type des valeurs numériques extraites des documents agrégés", "xpack.lens.indexPattern.standardDeviation.quickFunctionDescription": "Écart-type des valeurs d'un champ de nombre qui représente la quantité d'écart des valeurs des champs.", "xpack.lens.indexPattern.staticValue.label": "Valeur de la ligne de référence", @@ -22457,7 +22358,6 @@ "xpack.lens.indexPattern.staticValueWarningText": "Pour écraser la valeur statique, sélectionnez une fonction rapide.", "xpack.lens.indexPattern.suffixLabel": "Suffixe", "xpack.lens.indexpattern.suggestions.overTimeLabel": "Sur la durée", - "lensFormulaDocs.sum": "Somme", "xpack.lens.indexPattern.sum.description": "Agrégation d'indicateurs à valeur unique qui récapitule les valeurs numériques extraites des documents agrégés.", "xpack.lens.indexPattern.sum.quickFunctionDescription": "Total des valeurs d'un champ de nombre.", "xpack.lens.indexPattern.switchToRare": "Classer par rareté", @@ -22495,8 +22395,6 @@ "xpack.lens.indexPattern.terms.size": "Nombre de valeurs", "xpack.lens.indexPattern.termsWithMultipleShifts": "Dans un seul calque, il est impossible de combiner des indicateurs avec des décalages temporels différents et des valeurs dynamiques les plus élevées. Utilisez la même valeur de décalage pour tous les indicateurs, ou utilisez des filtres à la place des valeurs les plus élevées.", "xpack.lens.indexPattern.termsWithMultipleShiftsFixActionLabel": "Utiliser des filtres", - "lensFormulaDocs.time_scale": "indicateur : nombre, unité : s|m|h|d|w|M|y", - "lensFormulaDocs.timeScale": "Normaliser par unité", "xpack.lens.indexPattern.timeScale.label": "Normaliser par unité", "xpack.lens.indexPattern.timeScale.missingUnit": "Aucune unité spécifiée pour Normaliser par unité.", "xpack.lens.indexPattern.timeScale.tooltip": "Normalisez les valeurs pour qu'elles soient toujours affichées en tant que taux par unité de temps spécifiée, indépendamment de l'intervalle de dates sous-jacent.", @@ -22941,6 +22839,111 @@ "xpack.lens.xyVisualization.stackedPercentageBarHorizontalLabel": "H. À barres à pourcentages", "xpack.lens.xyVisualization.stackedPercentageBarLabel": "Vertical à barres à pourcentages", "xpack.lens.xyVisualization.xyLabel": "XY", + "lensFormulaDocs.tinymath.absFunction.markdown": "\nCalcule une valeur absolue. Une valeur négative est multipliée par -1, une valeur positive reste identique.\n\nExemple : calculer la distance moyenne par rapport au niveau de la mer \"abs(average(altitude))\"\n ", + "lensFormulaDocs.tinymath.addFunction.markdown": "\nAjoute jusqu'à deux nombres.\nFonctionne également avec le symbole \"+\".\n\nExemple : calculer la somme de deux champs\n\n\"sum(price) + sum(tax)\"\n\nExemple : compenser le compte par une valeur statique\n\n\"add(count(), 5)\"\n ", + "lensFormulaDocs.tinymath.cbrtFunction.markdown": "\nÉtablit la racine carrée de la valeur.\n\nExemple : calculer la longueur du côté à partir du volume\n`cbrt(last_value(volume))`\n ", + "lensFormulaDocs.tinymath.ceilFunction.markdown": "\nArrondit le plafond de la valeur au chiffre supérieur.\n\nExemple : arrondir le prix au dollar supérieur\n`ceil(sum(price))`\n ", + "lensFormulaDocs.tinymath.clampFunction.markdown": "\nÉtablit une limite minimale et maximale pour la valeur.\n\nExemple : s'assurer de repérer les valeurs aberrantes\n```\nclamp(\n average(bytes),\n percentile(bytes, percentile=5),\n percentile(bytes, percentile=95)\n)\n```\n", + "lensFormulaDocs.tinymath.cubeFunction.markdown": "\nCalcule le cube d'un nombre.\n\nExemple : calculer le volume à partir de la longueur du côté\n`cube(last_value(length))`\n ", + "lensFormulaDocs.tinymath.defaultFunction.markdown": "\nRetourne une valeur numérique par défaut lorsque la valeur est nulle.\n\nExemple : Retourne -1 lorsqu'un champ ne contient aucune donnée.\n\"defaults(average(bytes), -1)\"\n", + "lensFormulaDocs.tinymath.divideFunction.markdown": "\nDivise le premier nombre par le deuxième.\nFonctionne également avec le symbole \"/\".\n\nExemple : calculer la marge bénéficiaire\n\"sum(profit) / sum(revenue)\"\n\nExemple : \"divide(sum(bytes), 2)\"\n ", + "lensFormulaDocs.tinymath.eqFunction.markdown": "\nEffectue une comparaison d'égalité entre deux valeurs.\nÀ utiliser en tant que condition pour la fonction de comparaison \"ifelse\".\nFonctionne également avec le symbole \"==\".\n\nExemple : Retourne \"true\" si la moyenne d'octets est égale à la quantité de mémoire moyenne.\n\"average(bytes) == average(memory)\"\n\nExemple : \"eq(sum(bytes), 1000000)\"\n ", + "lensFormulaDocs.tinymath.expFunction.markdown": "\nÉlève *e* à la puissance n.\n\nExemple : calculer la fonction exponentielle naturelle\n\n`exp(last_value(duration))`\n ", + "lensFormulaDocs.tinymath.fixFunction.markdown": "\nPour les valeurs positives, part du bas. Pour les valeurs négatives, part du haut.\n\nExemple : arrondir à zéro\n\"fix(sum(profit))\"\n ", + "lensFormulaDocs.tinymath.floorFunction.markdown": "\nArrondit à la valeur entière inférieure la plus proche.\n\nExemple : arrondir un prix au chiffre inférieur\n\"floor(sum(price))\"\n ", + "lensFormulaDocs.tinymath.gteFunction.markdown": "\nEffectue une comparaison de supériorité entre deux valeurs.\nÀ utiliser en tant que condition pour la fonction de comparaison \"ifelse\".\nFonctionne également avec le symbole \">=\".\n\nExemple : Retourne \"true\" si la moyenne d'octets est supérieure ou égale à la quantité moyenne de mémoire.\n\"average(bytes) >= average(memory)\"\n\nExemple : \"gte(average(bytes), 1000)\"\n ", + "lensFormulaDocs.tinymath.gtFunction.markdown": "\nEffectue une comparaison de supériorité entre deux valeurs.\nÀ utiliser en tant que condition pour la fonction de comparaison \"ifelse\".\nFonctionne également avec le symbole \">\".\n\nExemple : Retourne \"true\" si la moyenne d'octets est supérieure à la quantité moyenne de mémoire.\n\"average(bytes) > average(memory)\"\n\nExemple : \"gt(average(bytes), 1000)\"\n ", + "lensFormulaDocs.tinymath.ifElseFunction.markdown": "\nRetourne une valeur selon si l'élément de condition est \"true\" ou \"false\".\n\nExemple : Revenus moyens par client, mais dans certains cas, l'ID du client n'est pas fourni, et le client est alors compté comme client supplémentaire.\n`sum(total)/(unique_count(customer_id) + ifelse( count() > count(kql='customer_id:*'), 1, 0))`\n ", + "lensFormulaDocs.tinymath.logFunction.markdown": "\nÉtablit un logarithme avec base optionnelle. La base naturelle *e* est utilisée par défaut.\n\nExemple : calculer le nombre de bits nécessaire au stockage de valeurs\n```\nlog(sum(bytes))\nlog(sum(bytes), 2)\n```\n ", + "lensFormulaDocs.tinymath.lteFunction.markdown": "\nEffectue une comparaison d'infériorité ou de supériorité entre deux valeurs.\nÀ utiliser en tant que condition pour la fonction de comparaison \"ifelse\".\nFonctionne également avec le symbole \"<=\".\n\nExemple : Retourne \"true\" si la moyenne d'octets est inférieure ou égale à la quantité moyenne de mémoire.\n\"average(bytes) <= average(memory)\"\n\nExemple : \"lte(average(bytes), 1000)\"\n ", + "lensFormulaDocs.tinymath.ltFunction.markdown": "\nEffectue une comparaison d'infériorité entre deux valeurs.\nÀ utiliser en tant que condition pour la fonction de comparaison \"ifelse\".\nFonctionne également avec le symbole \"<\".\n\nExemple : Retourne \"true\" si la moyenne d'octets est inférieure à la quantité moyenne de mémoire.\n\"average(bytes) <= average(memory)\"\n\nExemple : \"lt(average(bytes), 1000)\"\n ", + "lensFormulaDocs.tinymath.maxFunction.markdown": "\nTrouve la valeur maximale entre deux nombres.\n\nExemple : Trouver le maximum entre deux moyennes de champs\n\"pick_max(average(bytes), average(memory))\"\n ", + "lensFormulaDocs.tinymath.minFunction.markdown": "\nTrouve la valeur minimale entre deux nombres.\n\nExemple : Trouver le minimum entre deux moyennes de champs\n`pick_min(average(bytes), average(memory))`\n ", + "lensFormulaDocs.tinymath.modFunction.markdown": "\nÉtablit le reste après division de la fonction par un nombre.\n\nExemple : calculer les trois derniers chiffres d'une valeur\n\"mod(sum(price), 1000)\"\n ", + "lensFormulaDocs.tinymath.multiplyFunction.markdown": "\nMultiplie deux nombres.\nFonctionne également avec le symbole \"*\".\n\nExemple : calculer le prix après application du taux d'imposition courant\n`sum(bytes) * last_value(tax_rate)`\n\nExemple : calculer le prix après application du taux d'imposition constant\n\"multiply(sum(price), 1.2)\"\n ", + "lensFormulaDocs.tinymath.powFunction.markdown": "\nÉlève la valeur à une puissance spécifique. Le deuxième argument est obligatoire.\n\nExemple : calculer le volume en fonction de la longueur du côté\n\"pow(last_value(length), 3)\"\n ", + "lensFormulaDocs.tinymath.roundFunction.markdown": "\nArrondit à un nombre donné de décimales, 0 étant la valeur par défaut.\n\nExemples : arrondir au centième\n```\nround(sum(bytes))\nround(sum(bytes), 2)\n```\n ", + "lensFormulaDocs.tinymath.sqrtFunction.markdown": "\nÉtablit la racine carrée d'une valeur positive uniquement.\n\nExemple : calculer la longueur du côté en fonction de la surface\n`sqrt(last_value(area))`\n ", + "lensFormulaDocs.tinymath.squareFunction.markdown": "\nÉlève la valeur à la puissance 2.\n\nExemple : calculer l’aire en fonction de la longueur du côté\n`square(last_value(length))`\n ", + "lensFormulaDocs.tinymath.subtractFunction.markdown": "\nSoustrait le premier nombre du deuxième.\nFonctionne également avec le symbole \"-\".\n\nExemple : calculer la plage d'un champ\n\"subtract(max(bytes), min(bytes))\"\n ", + "lensFormulaDocs.documentation.filterRatioDescription.markdown": "### Rapport de filtre :\n\nUtilisez \"kql=''\" pour filtrer un ensemble de documents et le comparer à d'autres documents du même regroupement.\nPar exemple, pour consulter l'évolution du taux d'erreur au fil du temps :\n\n```\ncount(kql='response.status_code > 400') / count()\n```\n ", + "lensFormulaDocs.documentation.percentOfTotalDescription.markdown": "### Pourcentage du total\n\nLes formules peuvent calculer \"overall_sum\" pour tous les regroupements,\nce qui permet de convertir chaque regroupement en un pourcentage du total :\n\n```\nsum(products.base_price) / overall_sum(sum(products.base_price))\n```\n ", + "lensFormulaDocs.documentation.recentChangeDescription.markdown": "### Modification récente\n\nUtilisez \"reducedTimeRange='30m'\" pour ajouter un filtre supplémentaire sur la plage temporelle d'un indicateur aligné avec la fin d'une plage temporelle globale. Vous pouvez l'utiliser pour calculer le degré de modification récente d'une valeur.\n\n```\nmax(system.network.in.bytes, reducedTimeRange=\"30m\")\n - min(system.network.in.bytes, reducedTimeRange=\"30m\")\n```\n ", + "lensFormulaDocs.documentation.weekOverWeekDescription.markdown": "### Semaine après semaine :\n\nUtilisez \"shift='1w'\" pour obtenir la valeur de chaque regroupement\nde la semaine précédente. Le décalage ne doit pas être utilisé avec la fonction *Valeurs les plus élevées*.\n\n```\npercentile(system.network.in.bytes, percentile=99) /\npercentile(system.network.in.bytes, percentile=99, shift='1w')\n```\n ", + "lensFormulaDocs.cardinality.documentation.markdown": "\nCalcule le nombre de valeurs uniques d'un champ donné. Fonctionne pour les nombres, les chaînes, les dates et les valeurs booléennes.\n\nExemple : calculer le nombre de produits différents :\n`unique_count(product.name)`\n\nExemple : calculer le nombre de produits différents du groupe \"clothes\" :\n\"unique_count(product.name, kql='product.group=clothes')\"\n ", + "lensFormulaDocs.count.documentation.markdown": "\nNombre total de documents. Lorsque vous fournissez un champ, le nombre total de valeurs de champ est compté. Lorsque vous utilisez la fonction de décompte pour les champs qui comportent plusieurs valeurs dans un même document, toutes les valeurs sont comptées.\n\n#### Exemples\n\nPour calculer le nombre total de documents, utilisez `count()`.\n\nPour calculer le nombre de produits, utilisez `count(products.id)`.\n\nPour calculer le nombre de documents qui correspondent à un filtre donné, utilisez `count(kql='price > 500')`.\n ", + "lensFormulaDocs.counterRate.documentation.markdown": "\nCalcule le taux d'un compteur toujours croissant. Cette fonction renvoie uniquement des résultats utiles inhérents aux champs d'indicateurs de compteur qui contiennent une mesure quelconque à croissance régulière.\nSi la valeur diminue, elle est interprétée comme une mesure de réinitialisation de compteur. Pour obtenir des résultats plus précis, \"counter_rate\" doit être calculé d’après la valeur \"max\" du champ.\n\nCe calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures.\nIl utilise l'intervalle en cours utilisé dans la formule.\n\nExemple : visualiser le taux d'octets reçus au fil du temps par un serveur Memcached :\n`counter_rate(max(memcached.stats.read.bytes))`\n ", + "lensFormulaDocs.cumulativeSum.documentation.markdown": "\nCalcule la somme cumulée d'un indicateur au fil du temps, en ajoutant toutes les valeurs précédentes d'une série à chaque valeur. Pour utiliser cette fonction, vous devez également configurer une dimension de l'histogramme de dates.\n\nCe calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures.\n\nExemple : visualiser les octets reçus cumulés au fil du temps :\n`cumulative_sum(sum(bytes))`\n ", + "lensFormulaDocs.differences.documentation.markdown": "\nCalcule la différence par rapport à la dernière valeur d'un indicateur au fil du temps. Pour utiliser cette fonction, vous devez également configurer une dimension de l'histogramme de dates.\nLes données doivent être séquentielles pour les différences. Si vos données sont vides lorsque vous utilisez des différences, essayez d'augmenter l'intervalle de l'histogramme de dates.\n\nCe calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures.\n\nExemple : visualiser la modification des octets reçus au fil du temps :\n`differences(sum(bytes))`\n ", + "lensFormulaDocs.lastValue.documentation.markdown": "\nRenvoie la valeur d'un champ du dernier document, triée par le champ d'heure par défaut de la vue de données.\n\nCette fonction permet de récupérer le dernier état d'une entité.\n\nExemple : obtenir le statut actuel du serveur A :\n`last_value(server.status, kql='server.name=\"A\"')`\n ", + "lensFormulaDocs.metric.documentation.markdown": "\nRenvoie l'indicateur {metric} d'un champ. Cette fonction fonctionne uniquement pour les champs numériques.\n\nExemple : obtenir l'indicateur {metric} d'un prix :\n\"{metric}(price)\"\n\nExemple : obtenir l'indicateur {metric} d'un prix pour des commandes du Royaume-Uni :\n\"{metric}(price, kql='location:UK')\"\n ", + "lensFormulaDocs.movingAverage.documentation.markdown": "\nCalcule la moyenne mobile d'un indicateur au fil du temps, en prenant la moyenne des n dernières valeurs pour calculer la valeur actuelle. Pour utiliser cette fonction, vous devez également configurer une dimension de l'histogramme de dates.\nLa valeur de fenêtre par défaut est {defaultValue}.\n\nCe calcul est réalisé séparément pour des séries distinctes définies par des filtres ou des dimensions de valeurs supérieures.\n\nPrend un paramètre nommé \"window\" qui spécifie le nombre de dernières valeurs à inclure dans le calcul de la moyenne de la valeur actuelle.\n\nExemple : lisser une ligne de mesures :\n`moving_average(sum(bytes), window=5)`\n ", + "lensFormulaDocs.overall_average.documentation.markdown": "\nCalcule la moyenne d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle.\nD'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes.\n\nSi le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, \"overall_average\" calcule la moyenne pour toutes les dimensions, quelle que soit la fonction utilisée.\n\nExemple : écart par rapport à la moyenne :\n\"sum(bytes) - overall_average(sum(bytes))\"\n ", + "lensFormulaDocs.overall_max.documentation.markdown": "\nCalcule la valeur maximale d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle.\nD'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes.\n\nSi le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, \"overall_max\" calcule la valeur maximale pour toutes les dimensions, quelle que soit la fonction utilisée.\n\nExemple : pourcentage de plage\n\"(sum(bytes) - overall_min(sum(bytes))) / (overall_max(sum(bytes)) - overall_min(sum(bytes)))\"\n ", + "lensFormulaDocs.overall_min.documentation.markdown": "\nCalcule la valeur minimale d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle.\nD'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes.\n\nSi le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, \"overall_min\" calcule la valeur minimale pour toutes les dimensions, quelle que soit la fonction utilisée.\n\nExemple : pourcentage de plage\n\"(sum(bytes) - overall_min(sum(bytes)) / (overall_max(sum(bytes)) - overall_min(sum(bytes)))\"\n ", + "lensFormulaDocs.overall_sum.documentation.markdown": "\nCalcule la somme d'un indicateur pour tous les points de données d'une série dans le graphique actuel. Une série est définie par une dimension à l'aide d'un histogramme de dates ou d'une fonction d'intervalle.\nD'autres dimensions permettant de répartir les données telles que les valeurs supérieures ou les filtres sont traitées en tant que séries distinctes.\n\nSi le graphique actuel n'utilise aucun histogramme de dates ou aucune fonction d'intervalle, \"overall_sum\" calcule la somme pour toutes les dimensions, quelle que soit la fonction utilisée.\n\nExemple : pourcentage de total\n\"sum(bytes) / overall_sum(sum(bytes))\"\n ", + "lensFormulaDocs.percentile.documentation.markdown": "\nRenvoie le centile spécifié des valeurs d'un champ. Il s'agit de la valeur de n pour cent des valeurs présentes dans les documents.\n\nExemple : obtenir le nombre d'octets supérieurs à 95 % des valeurs :\n`percentile(bytes, percentile=95)`\n ", + "lensFormulaDocs.percentileRanks.documentation.markdown": "\nRetourne le pourcentage de valeurs qui sont en dessous d'une certaine valeur. Par exemple, si une valeur est supérieure à 95 % des valeurs observées, elle est placée au 95e rang centile.\n\nExemple : Obtenir le pourcentage de valeurs qui sont en dessous de 100 :\n\"percentile_rank(bytes, value=100)\"\n ", + "lensFormulaDocs.standardDeviation.documentation.markdown": "\nRetourne la taille de la variation ou de la dispersion du champ. Cette fonction ne s’applique qu’aux champs numériques.\n\n#### Exemples\n\nPour obtenir l'écart type d'un prix, utilisez standard_deviation(price).\n\nPour obtenir la variance du prix des commandes passées au Royaume-Uni, utilisez `square(standard_deviation(price, kql='location:UK'))`.\n ", + "lensFormulaDocs.time_scale.documentation.markdown": "\n\nCette fonction avancée est utile pour normaliser les comptes et les sommes sur un intervalle de temps spécifique. Elle permet l'intégration avec les indicateurs qui sont stockés déjà normalisés sur un intervalle de temps spécifique.\n\nVous pouvez faire appel à cette fonction uniquement si une fonction d'histogramme des dates est utilisée dans le graphique actuel.\n\nExemple : Un rapport comparant un indicateur déjà normalisé à un autre indicateur devant être normalisé.\n\"normalize_by_unit(counter_rate(max(system.diskio.write.bytes)), unit='s') / last_value(apache.status.bytes_per_second)\"\n ", + "lensFormulaDocs.tinymath.base": "base", + "lensFormulaDocs.boolean": "booléen", + "lensFormulaDocs.tinymath.condition": "condition", + "lensFormulaDocs.tinymath.decimals": "décimales", + "lensFormulaDocs.tinymath.defaultValue": "par défaut", + "lensFormulaDocs.tinymath.left": "gauche", + "lensFormulaDocs.tinymath.max": "max", + "lensFormulaDocs.tinymath.min": "min", + "lensFormulaDocs.number": "numéro", + "lensFormulaDocs.tinymath.right": "droite", + "lensFormulaDocs.string": "chaîne", + "lensFormulaDocs.tinymath.value": "valeur", + "lensFormulaDocs.CommonFormulaDocumentation": "Les formules les plus courantes divisent deux valeurs pour produire un pourcentage. Pour obtenir un affichage correct, définissez \"Format de valeur\" sur \"pourcent\".", + "lensFormulaDocs.documentation.columnCalculationSection": "Calculs de colonnes", + "lensFormulaDocs.documentation.columnCalculationSectionDescription": "Ces fonctions sont exécutées pour chaque ligne, mais elles sont fournies avec la colonne entière comme contexte. Elles sont également appelées fonctions de fenêtre.", + "lensFormulaDocs.documentation.comparisonSection": "Comparaison", + "lensFormulaDocs.documentation.comparisonSectionDescription": "Ces fonctions sont utilisées pour effectuer une comparaison de valeurs.", + "lensFormulaDocs.documentation.constantsSection": "Contexte Kibana", + "lensFormulaDocs.documentation.constantsSectionDescription": "Ces fonctions sont utilisées pour récupérer des variables de contexte Kibana, c’est-à-dire l’histogramme de date \"interval\", le \"now\" actuel et le \"time_range\" sélectionné, et pour vous aider à faire des opérations mathématiques de dates.", + "lensFormulaDocs.documentation.elasticsearchSection": "Elasticsearch", + "lensFormulaDocs.documentation.elasticsearchSectionDescription": "Ces fonctions seront exécutées sur les documents bruts pour chaque ligne du tableau résultant, en agrégeant tous les documents correspondant aux dimensions de répartition en une seule valeur.", + "lensFormulaDocs.documentation.filterRatio": "Rapport de filtre", + "lensFormulaDocs.documentation.mathSection": "Mathématique", + "lensFormulaDocs.documentation.mathSectionDescription": "Ces fonctions seront exécutées pour chaque ligne du tableau résultant en utilisant des valeurs uniques de la même ligne calculées à l'aide d'autres fonctions.", + "lensFormulaDocs.documentation.percentOfTotal": "Pourcentage du total", + "lensFormulaDocs.documentation.recentChange": "Modification récente", + "lensFormulaDocs.documentation.weekOverWeek": "Semaine après semaine", + "lensFormulaDocs.frequentlyUsedHeading": "Formules courantes", + "lensFormulaDocs.avg": "Moyenne", + "lensFormulaDocs.cardinality": "Compte unique", + "lensFormulaDocs.cardinality.signature": "champ : chaîne", + "lensFormulaDocs.count": "Décompte", + "lensFormulaDocs.count.signature": "[champ : chaîne]", + "lensFormulaDocs.counterRate.signature": "indicateur : nombre", + "lensFormulaDocs.cumulative_sum.signature": "indicateur : nombre", + "lensFormulaDocs.cumulativeSum": "Somme cumulée", + "lensFormulaDocs.derivative": "Différences", + "lensFormulaDocs.differences.signature": "indicateur : nombre", + "lensFormulaDocs.lastValue": "Dernière valeur", + "lensFormulaDocs.lastValue.signature": "champ : chaîne", + "lensFormulaDocs.max": "Maximum", + "lensFormulaDocs.median": "Médiane", + "lensFormulaDocs.metric.signature": "champ : chaîne", + "lensFormulaDocs.min": "Minimum", + "lensFormulaDocs.moving_average.signature": "indicateur : nombre, [window] : nombre", + "lensFormulaDocs.movingAverage": "Moyenne mobile", + "lensFormulaDocs.overall_metric": "indicateur : nombre", + "lensFormulaDocs.overallMax": "Max général", + "lensFormulaDocs.overallMin": "Min général", + "lensFormulaDocs.overallSum": "Somme générale", + "lensFormulaDocs.percentile": "Centile", + "lensFormulaDocs.percentile.signature": "champ : chaîne, [percentile] : nombre", + "lensFormulaDocs.percentileRank": "Rang centile", + "lensFormulaDocs.percentileRanks.signature": "champ : chaîne, [valeur] : nombre", + "lensFormulaDocs.standardDeviation": "Écart-type", + "lensFormulaDocs.sum": "Somme", + "lensFormulaDocs.time_scale": "indicateur : nombre, unité : s|m|h|d|w|M|y", + "lensFormulaDocs.timeScale": "Normaliser par unité", "xpack.licenseApiGuard.license.errorExpiredMessage": "Vous ne pouvez pas utiliser {pluginName}, car votre licence {licenseType} a expiré.", "xpack.licenseApiGuard.license.errorUnavailableMessage": "Vous ne pouvez pas utiliser {pluginName}, car les informations de la licence sont indisponibles pour le moment.", "xpack.licenseApiGuard.license.errorUnsupportedMessage": "Votre licence {licenseType} ne prend pas en charge {pluginName}. Veuillez mettre à niveau votre licence.", @@ -29496,46 +29499,6 @@ "xpack.observabilityAiAssistant.setupKb": "Améliorez votre expérience en configurant la base de connaissances.", "xpack.observabilityAiAssistant.stopGeneratingButtonLabel": "Arrêter la génération", "xpack.observabilityAiAssistant.technicalPreviewBadgeDescription": "GTP4 est nécessaire pour bénéficier d'une meilleure expérience avec les appels de fonctions (par exemple lors de la réalisation d'analyse de la cause d'un problème, de la visualisation de données et autres). GPT3.5 peut fonctionner pour certains des workflows les plus simples comme les explications d'erreurs ou pour bénéficier d'une expérience comparable à ChatGPT au sein de Kibana à partir du moment où les appels de fonctions ne sont pas fréquents.", - "xpack.observabilityShared.inspector.stats.queryTimeValue": "{queryTime} ms", - "xpack.observabilityShared.breadcrumbs.observabilityLinkText": "Observabilité", - "xpack.observabilityShared.inspector.stats.dataViewDescription": "La vue de données qui se connecte aux index Elasticsearch.", - "xpack.observabilityShared.inspector.stats.dataViewLabel": "Vue de données", - "xpack.observabilityShared.inspector.stats.hitsDescription": "Le nombre de documents renvoyés par la requête.", - "xpack.observabilityShared.inspector.stats.hitsLabel": "Résultats", - "xpack.observabilityShared.inspector.stats.hitsTotalDescription": "Le nombre de documents correspondant à la requête.", - "xpack.observabilityShared.inspector.stats.hitsTotalLabel": "Résultats (total)", - "xpack.observabilityShared.inspector.stats.kibanaApiQueryParametersDescription": "Les paramètres de requête utilisés dans la requête d'API Kibana à l'origine de la requête Elasticsearch.", - "xpack.observabilityShared.inspector.stats.kibanaApiQueryParametersLabel": "Paramètres de requête d'API Kibana", - "xpack.observabilityShared.inspector.stats.kibanaApiRouteDescription": "Le chemin de la requête d'API Kibana à l'origine de la requête Elasticsearch.", - "xpack.observabilityShared.inspector.stats.kibanaApiRouteLabel": "Chemin de l’API Kibana", - "xpack.observabilityShared.inspector.stats.queryTimeDescription": "Le temps qu'il a fallu pour traiter la requête. Ne comprend pas le temps nécessaire pour envoyer la requête ni l'analyser dans le navigateur.", - "xpack.observabilityShared.inspector.stats.queryTimeLabel": "Durée de la requête", - "xpack.observabilityShared.navigation.betaBadge": "Bêta", - "xpack.observabilityShared.navigation.experimentalBadgeLabel": "Version d'évaluation technique", - "xpack.observabilityShared.navigation.newBadge": "NOUVEAUTÉ", - "xpack.observabilityShared.pageLayout.sideNavTitle": "Observabilité", - "xpack.observabilityShared.sectionLink.newLabel": "Nouveauté", - "xpack.observabilityShared.technicalPreviewBadgeDescription": "Cette fonctionnalité est en version d'évaluation technique et pourra être modifiée ou retirée complètement dans une future version. Elastic s'efforcera au maximum de corriger tout problème, mais les fonctionnalités en version d'évaluation technique ne sont pas soumises aux accords de niveau de service d'assistance des fonctionnalités officielles en disponibilité générale.", - "xpack.observabilityShared.technicalPreviewBadgeLabel": "Version d'évaluation technique", - "xpack.observabilityShared.tour.alertsStep.imageAltText": "Démonstration des alertes", - "xpack.observabilityShared.tour.alertsStep.tourContent": "Définissez et détectez les conditions qui déclenchent des alertes avec des intégrations de plateformes tierces comme l’e-mail, PagerDuty et Slack.", - "xpack.observabilityShared.tour.alertsStep.tourTitle": "Soyez informé en cas de modification", - "xpack.observabilityShared.tour.endButtonLabel": "Terminer la visite", - "xpack.observabilityShared.tour.guidedSetupStep.tourContent": "La façon la plus facile de continuer avec Elastic Observability est de suivre les prochaines étapes recommandées dans l'assistant de données.", - "xpack.observabilityShared.tour.guidedSetupStep.tourTitle": "Toujours plus avec Elastic Observability", - "xpack.observabilityShared.tour.metricsExplorerStep.imageAltText": "Démonstration de Metrics Explorer", - "xpack.observabilityShared.tour.metricsExplorerStep.tourContent": "Diffusez, regroupez et visualisez les mesures provenant de vos systèmes, du cloud, du réseau et d'autres sources d'infrastructure.", - "xpack.observabilityShared.tour.metricsExplorerStep.tourTitle": "Monitorer l’intégrité de votre infrastructure", - "xpack.observabilityShared.tour.nextButtonLabel": "Suivant", - "xpack.observabilityShared.tour.observabilityOverviewStep.tourContent": "Faites un tour rapide pour découvrir les avantages de disposer de toutes vos données d'observabilité dans une seule suite.", - "xpack.observabilityShared.tour.observabilityOverviewStep.tourTitle": "Bienvenue dans Elastic Observability", - "xpack.observabilityShared.tour.servicesStep.imageAltText": "Démonstration des services", - "xpack.observabilityShared.tour.servicesStep.tourContent": "Détectez et réparez rapidement les problèmes de performances en recueillant des informations détaillées sur vos services.", - "xpack.observabilityShared.tour.servicesStep.tourTitle": "Identifier et résoudre les problèmes d'application", - "xpack.observabilityShared.tour.skipButtonLabel": "Ignorer la visite", - "xpack.observabilityShared.tour.streamStep.imageAltText": "Démonstration du flux de logs", - "xpack.observabilityShared.tour.streamStep.tourContent": "Surveillez, filtrez et inspectez les événements de journal provenant de vos applications, serveurs, machines virtuelles et conteneurs.", - "xpack.observabilityShared.tour.streamStep.tourTitle": "Suivi de vos logs en temps réel", "xpack.osquery.action.missingPrivileges": "Pour accéder à cette page, demandez à votre administrateur vos privilèges Kibana pour {osquery}.", "xpack.osquery.agentPolicy.confirmModalCalloutDescription": "Fleet a détecté que {agentPolicyCount, plural, one {politique d''agent} many {ces politiques d''agent} other {les politiques d''agent sélectionnées sont}} déjà en cours d'utilisation par certains de vos agents. Suite à cette action, Fleet déploie les mises à jour de tous les agents qui utilisent {agentPolicyCount, plural, one {politique d''agent} many {ces politiques d''agent} other {ces politiques d''agent}}.", "xpack.osquery.agentPolicy.confirmModalCalloutTitle": "Cette action va mettre à jour {agentCount, plural, one {# agent} many {# agents ont été enregistrés} other {# agents}}", @@ -35060,7 +35023,6 @@ "xpack.securitySolution.fleetIntegration.assets.name": "Hôtes", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.description": "Filtre d'événement pour Cloud Security. Créé par l'intégration Elastic Defend.", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.name": "Sessions non interactives", - "xpack.securitySolution.flyout.button.timeline": "chronologie", "xpack.securitySolution.flyout.entities.failRelatedHostsDescription": "Impossible de lancer la recherche sur les hôtes associés", "xpack.securitySolution.flyout.entities.failRelatedUsersDescription": "Impossible de lancer la recherche sur les utilisateurs associés", "xpack.securitySolution.flyout.isolateHost.isolateTitle": "Isoler l'hôte", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index fc7df375241f76..90240386985cf5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -572,7 +572,6 @@ "core.euiComboBoxOptionsList.delimiterMessage": "各項目を{delimiter}で区切って追加", "core.euiComboBoxOptionsList.noMatchingOptions": "{searchValue}はどのオプションにも一致していません", "core.euiComboBoxPill.removeSelection": "このグループの選択項目から{children}を削除", - "core.euiControlBar.customScreenReaderAnnouncement": "ドキュメントの最後には、{landmarkHeading}という新しいリージョンランドマークとページレベルのコントロールがあります。", "core.euiDataGrid.ariaLabel": "{label}; {page}ページ中{pageCount}ページ目。", "core.euiDataGrid.ariaLabelledBy": "{pageCount}ページ中{page}ページ目。", "core.euiDataGridCell.position": "{columnId}、列{col}、行{row}", @@ -587,13 +586,6 @@ "core.euiFilterButton.filterBadgeActiveAriaLabel": "{count}個のアクティブなフィルター", "core.euiFilterButton.filterBadgeAvailableAriaLabel": "{count}個の使用可能なフィルター", "core.euiMarkdownEditorFooter.supportedFileTypes": "サポートされているファイル:{supportedFileTypes}", - "core.euiNotificationEventMessages.accordionAriaLabelButtonText": "+ {eventName}の{messagesLength}メッセージ", - "core.euiNotificationEventMessages.accordionButtonText": "+ 追加の{messagesLength}", - "core.euiNotificationEventMeta.contextMenuButton": "{eventName}のメニュー", - "core.euiNotificationEventReadButton.markAsReadAria": "{eventName}を既読に設定", - "core.euiNotificationEventReadButton.markAsUnreadAria": "{eventName}を未読に設定", - "core.euiNotificationEventReadIcon.readAria": "{eventName}は既読です", - "core.euiNotificationEventReadIcon.unreadAria": "{eventName}は未読です", "core.euiPagination.firstRangeAriaLabel": "ページ2から{lastPage}までをスキップしています", "core.euiPagination.lastRangeAriaLabel": "ページ{firstPage}から{lastPage}までをスキップしています", "core.euiPagination.pageOfTotalCompressed": "{page} / {total}", @@ -741,8 +733,6 @@ "core.euiComboBoxOptionsList.loadingOptions": "オプションを読み込み中", "core.euiComboBoxOptionsList.noAvailableOptions": "利用可能なオプションがありません", "core.euiCommonlyUsedTimeRanges.legend": "頻繁に使用", - "core.euiControlBar.screenReaderAnnouncement": "ドキュメントの最後には、新しいリージョンランドマークとページレベルのコントロールがあります。", - "core.euiControlBar.screenReaderHeading": "ページレベルのコントロール", "core.euiDataGrid.screenReaderNotice": "セルにはインタラクティブコンテンツが含まれます。", "core.euiDataGridCellActions.expandButtonTitle": "クリックするか enter を押すと、セルのコンテンツとインタラクトできます。", "core.euiDataGridHeaderCell.actionsPopoverScreenReaderText": "列アクションのリストを移動するには、Tabまたは上下矢印キーを押します。", @@ -849,11 +839,6 @@ "core.euiMarkdownEditorToolbar.editor": "エディター", "core.euiMarkdownEditorToolbar.previewMarkdown": "プレビュー", "core.euiModal.closeModal": "このモーダルウィンドウを閉じます", - "core.euiNotificationEventMessages.accordionHideText": "非表示", - "core.euiNotificationEventReadButton.markAsRead": "既読に設定", - "core.euiNotificationEventReadButton.markAsUnread": "未読に設定", - "core.euiNotificationEventReadIcon.read": "読み取り", - "core.euiNotificationEventReadIcon.unread": "未読", "core.euiPagination.collection": "収集", "core.euiPagination.fromEndLabel": "最後から", "core.euiPagination.last": "最後", @@ -2389,12 +2374,8 @@ "discover.grid.flyout.documentNavigation": "ドキュメントナビゲーション", "discover.grid.flyout.toastColumnAdded": "列'{columnName}'が追加されました", "discover.grid.flyout.toastColumnRemoved": "列'{columnName}'が削除されました", - "discover.grid.tableRow.detailHeading": "拡張ドキュメント", "discover.grid.tableRow.textBasedDetailHeading": "展開された行", - "discover.grid.tableRow.viewSingleDocumentLinkTextSimple": "1つのドキュメント", "discover.grid.tableRow.viewSurroundingDocumentsHover": "このドキュメントの前後に出現したドキュメントを検査します。周りのドキュメントビューでは、固定されたフィルターのみがアクティブのままです。", - "discover.grid.tableRow.viewSurroundingDocumentsLinkTextSimple": "周りのドキュメント", - "discover.grid.tableRow.viewText": "表示:", "discover.helpMenu.appName": "Discover", "discover.inspectorRequestDataTitleDocuments": "ドキュメント", "discover.inspectorRequestDataTitleMoreDocuments": "その他のドキュメント", @@ -2473,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}を結合しました。", @@ -5676,9 +5660,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 ", @@ -5812,9 +5793,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", @@ -6220,7 +6198,6 @@ "unifiedFieldList.fieldListSidebar.fieldsMobileButtonLabel": "フィールド", "unifiedFieldList.fieldListSidebar.flyoutBackIcon": "戻る", "unifiedFieldList.fieldListSidebar.flyoutHeading": "フィールドリスト", - "unifiedFieldList.fieldListSidebar.indexAndFieldsSectionAriaLabel": "インデックスとフィールド", "unifiedFieldList.fieldListSidebar.toggleSidebarLegend": "サイドバーを切り替える", "unifiedFieldList.fieldNameSearch.filterByNameLabel": "検索フィールド名", "unifiedFieldList.fieldPopover.addExistsFilterLabel": "フィールド表示のフィルター", @@ -6265,31 +6242,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}削除", @@ -12357,7 +12321,6 @@ "xpack.dataVisualizer.combinedFieldsForm.mappingsParseError": "マッピングのパース中にエラーが発生しました:{error}", "xpack.dataVisualizer.combinedFieldsForm.pipelineParseError": "パイプラインのパース中にエラーが発生しました:{error}", "xpack.dataVisualizer.dataDrift.dataComparisonDistributionLabel": "{label}分布", - "xpack.dataVisualizer.dataDrift.dataLabel": "{label}データ", "xpack.dataVisualizer.dataDrift.distributionComparisonChartName": "{fieldName}の{referenceLabel}と{comparisonLabel}データの分布比較", "xpack.dataVisualizer.dataDrift.progress.loadingComparison": "{fieldsCount}フィールドの比較データを読み込んでいます。", "xpack.dataVisualizer.dataDrift.progress.loadingReference": "{fieldsCount} 個のフィールドの参照データを読み込んでいます。", @@ -20165,7 +20128,6 @@ "xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel": "セットアップの手順を表示", "xpack.infra.homePage.settingsTabTitle": "設定", "xpack.infra.homePage.tellUsWhatYouThinkK8sLink": "ご意見をお聞かせください。(K8s)", - "xpack.observabilityShared.featureFeedbackButton.tellUsWhatYouThinkLink": "ご意見をお聞かせください。", "xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder": "インフラストラクチャーデータを検索…(例:host.name:host-1)", "xpack.infra.hostFlyout.explainProcessMessageTitle": "このプロセスの概要", "xpack.infra.hosts.searchPlaceholder": "ホストを検索(例:cloud.provider:gcp AND system.load.1 > 0.5)", @@ -20959,6 +20921,47 @@ "xpack.infra.waffle.unableToSelectMetricErrorTitle": "メトリックのオプションまたは値を選択できません。", "xpack.infra.waffleTime.autoRefreshButtonLabel": "自動更新", "xpack.infra.waffleTime.stopRefreshingButtonLabel": "更新中止", + "xpack.observabilityShared.featureFeedbackButton.tellUsWhatYouThinkLink": "ご意見をお聞かせください。", + "xpack.observabilityShared.inspector.stats.queryTimeValue": "{queryTime}ms", + "xpack.observabilityShared.breadcrumbs.observabilityLinkText": "Observability", + "xpack.observabilityShared.inspector.stats.dataViewDescription": "Elasticsearchインデックスに接続したデータビューです。", + "xpack.observabilityShared.inspector.stats.dataViewLabel": "データビュー", + "xpack.observabilityShared.inspector.stats.hitsDescription": "クエリにより返されたドキュメントの数です。", + "xpack.observabilityShared.inspector.stats.hitsLabel": "ヒット数", + "xpack.observabilityShared.inspector.stats.hitsTotalDescription": "クエリに一致するドキュメントの数です。", + "xpack.observabilityShared.inspector.stats.hitsTotalLabel": "ヒット数(合計)", + "xpack.observabilityShared.inspector.stats.kibanaApiQueryParametersDescription": "Elasticsearch要求を開始したKibana API要求で使用されているクエリパラメーター。", + "xpack.observabilityShared.inspector.stats.kibanaApiQueryParametersLabel": "Kibana APIクエリパラメーター", + "xpack.observabilityShared.inspector.stats.kibanaApiRouteDescription": "Elasticsearch要求を開始したKibana API要求のルート。", + "xpack.observabilityShared.inspector.stats.kibanaApiRouteLabel": "Kibana APIルート", + "xpack.observabilityShared.inspector.stats.queryTimeDescription": "クエリの処理の所要時間です。リクエストの送信やブラウザーでのパースの時間は含まれません。", + "xpack.observabilityShared.inspector.stats.queryTimeLabel": "クエリ時間", + "xpack.observabilityShared.navigation.betaBadge": "ベータ", + "xpack.observabilityShared.navigation.experimentalBadgeLabel": "テクニカルプレビュー", + "xpack.observabilityShared.navigation.newBadge": "新規", + "xpack.observabilityShared.pageLayout.sideNavTitle": "Observability", + "xpack.observabilityShared.sectionLink.newLabel": "新規", + "xpack.observabilityShared.technicalPreviewBadgeDescription": "この機能はテクニカルプレビュー中であり、将来のリリースでは変更されたり完全に削除されたりする場合があります。Elasticは最善の努力を講じてすべての問題の修正に努めますが、テクニカルプレビュー中の機能には正式なGA機能のサポートSLAが適用されません。", + "xpack.observabilityShared.technicalPreviewBadgeLabel": "テクニカルプレビュー", + "xpack.observabilityShared.tour.alertsStep.imageAltText": "アラートデモ", + "xpack.observabilityShared.tour.alertsStep.tourContent": "電子メール、PagerDuty、Slackなどのサードパーティプラットフォーム統合でアラートをトリガーする条件を定義して検出します。", + "xpack.observabilityShared.tour.alertsStep.tourTitle": "変更が発生したときに通知", + "xpack.observabilityShared.tour.endButtonLabel": "ツアーを終了", + "xpack.observabilityShared.tour.guidedSetupStep.tourContent": "Elasticオブザーバビリティに進む最も簡単な方法は、データアシスタントで推奨された次のステップに従うことです。", + "xpack.observabilityShared.tour.guidedSetupStep.tourTitle": "Elasticオブザーバビリティのその他の機能", + "xpack.observabilityShared.tour.metricsExplorerStep.imageAltText": "メトリックエクスプローラーのデモ", + "xpack.observabilityShared.tour.metricsExplorerStep.tourContent": "システム、クラウド、ネットワーク、その他のインフラストラクチャーソースからメトリックをストリーム、グループ化、可視化します。", + "xpack.observabilityShared.tour.metricsExplorerStep.tourTitle": "インフラストラクチャーの正常性を監視", + "xpack.observabilityShared.tour.nextButtonLabel": "次へ", + "xpack.observabilityShared.tour.observabilityOverviewStep.tourContent": "クイックガイドを表示し、オブザーバビリティデータすべてを1つのスタックに格納する利点をご覧ください。", + "xpack.observabilityShared.tour.observabilityOverviewStep.tourTitle": "Elasticオブザーバビリティへようこそ", + "xpack.observabilityShared.tour.servicesStep.imageAltText": "サービスのデモ", + "xpack.observabilityShared.tour.servicesStep.tourContent": "サービスに関する詳細情報を収集し、パフォーマンスの問題をすばやく検出、修正できます。", + "xpack.observabilityShared.tour.servicesStep.tourTitle": "アプリケーションの問題を特定して解決", + "xpack.observabilityShared.tour.skipButtonLabel": "ツアーをスキップ", + "xpack.observabilityShared.tour.streamStep.imageAltText": "ログストリームのデモ", + "xpack.observabilityShared.tour.streamStep.tourContent": "アプリケーション、サーバー、仮想マシン、コネクターからのログイベントを監視、フィルター、検査します。", + "xpack.observabilityShared.tour.streamStep.tourTitle": "リアルタイムでログを追跡", "xpack.metricsData.assetDetails.formulas.cpuUsage": "CPU使用状況", "xpack.metricsData.assetDetails.formulas.cpuUsage.iowaitLabel": "iowait", "xpack.metricsData.assetDetails.formulas.cpuUsage.irqLabel": "irq", @@ -21929,54 +21932,6 @@ "xpack.lens.xyVisualization.dataTypeFailureXShort": "{axis}のデータ型が正しくありません。", "xpack.lens.xyVisualization.dataTypeFailureYLong": "{axis}のディメンション{label}のデータ型が正しくありません。数値が想定されていますが、{dataType}です", "xpack.lens.xyVisualization.dataTypeFailureYShort": "{axis}のデータ型が正しくありません。", - "lensFormulaDocs.tinymath.absFunction.markdown": "\n絶対値を計算します。負の値は-1で乗算されます。正の値は同じままです。\n\n例:海水位までの平均距離を計算します `abs(average(altitude))`\n ", - "lensFormulaDocs.tinymath.addFunction.markdown": "\n2つの数値を加算します。\n+記号も使用できます。\n\n例:2つのフィールドの合計を計算します\n\n`sum(price) + sum(tax)`\n\n例:固定値でカウントをオフセットします\n\n`add(count(), 5)`\n ", - "lensFormulaDocs.tinymath.cbrtFunction.markdown": "\n値の立方根。\n\n例:体積から側面の長さを計算します\n`cbrt(last_value(volume))`\n ", - "lensFormulaDocs.tinymath.ceilFunction.markdown": "\n値の上限(切り上げ)。\n\n例:価格を次のドル単位まで切り上げます\n`ceil(sum(price))`\n ", - "lensFormulaDocs.tinymath.clampFunction.markdown": "\n最小値から最大値までの値を制限します。\n\n例:確実に異常値を特定します\n```\nclamp(\n average(bytes),\n percentile(bytes, percentile=5),\n percentile(bytes, percentile=95)\n)\n```\n", - "lensFormulaDocs.tinymath.cubeFunction.markdown": "\n数値の三乗を計算します。\n\n例:側面の長さから体積を計算します\n`cube(last_value(length))`\n ", - "lensFormulaDocs.tinymath.defaultFunction.markdown": "\n値がヌルのときにデフォルトの数値を返します。\n\n例:フィールドにデータがない場合は、-1を返します\n`defaults(average(bytes), -1)`\n", - "lensFormulaDocs.tinymath.divideFunction.markdown": "\n1番目の数値を2番目の数値で除算します。\n/記号も使用できます\n\n例:利益率を計算します\n`sum(profit) / sum(revenue)`\n\n例:`divide(sum(bytes), 2)`\n ", - "lensFormulaDocs.tinymath.eqFunction.markdown": "\n2つの値で等価性の比較を実行します。\n「ifelse」比較関数の条件として使用されます。\n==記号も使用できます。\n\n例:バイトの平均が平均メモリーと同じ量の場合は、trueを返します。\n`average(bytes) == average(memory)`\n\n例: `eq(sum(bytes), 1000000)`\n ", - "lensFormulaDocs.tinymath.expFunction.markdown": "\n*e*をn乗します。\n\n例:自然指数関数を計算します\n\n`exp(last_value(duration))`\n ", - "lensFormulaDocs.tinymath.fixFunction.markdown": "\n正の値の場合は、下限を取ります。負の値の場合は、上限を取ります。\n\n例:ゼロに向かって端数処理します\n`fix(sum(profit))`\n ", - "lensFormulaDocs.tinymath.floorFunction.markdown": "\n最も近い整数値まで切り捨てます\n\n例:価格を切り捨てます\n`floor(sum(price))`\n ", - "lensFormulaDocs.tinymath.gteFunction.markdown": "\n2つの値で大なりの比較を実行します。\n「ifelse」比較関数の条件として使用されます。\n>=記号も使用できます。\n\n例:バイトの平均がメモリーの平均量以上である場合は、trueを返します\n`average(bytes) >= average(memory)`\n\n例: `gte(average(bytes), 1000)`\n ", - "lensFormulaDocs.tinymath.gtFunction.markdown": "\n2つの値で大なりの比較を実行します。\n「ifelse」比較関数の条件として使用されます。\n>記号も使用できます。\n\n例:バイトの平均がメモリーの平均量より大きい場合は、trueを返します\n`average(bytes) > average(memory)`\n\n例: `gt(average(bytes), 1000)`\n ", - "lensFormulaDocs.tinymath.ifElseFunction.markdown": "\n条件の要素がtrueかfalseかに応じて、値を返します。\n\n例:顧客ごとの平均収益。ただし、場合によっては、顧客IDが提供されないことがあり、その場合は別の顧客としてカウントされます\n`sum(total)/(unique_count(customer_id) + ifelse( count() > count(kql='customer_id:*'), 1, 0))`\n ", - "lensFormulaDocs.tinymath.logFunction.markdown": "\nオプションで底をとる対数。デフォルトでは自然対数の底*e*を使用します。\n\n例:値を格納するために必要なビット数を計算します\n```\nlog(sum(bytes))\nlog(sum(bytes), 2)\n```\n ", - "lensFormulaDocs.tinymath.lteFunction.markdown": "\n2つの値で小なりイコールの比較を実行します。\n「ifelse」比較関数の条件として使用されます。\n<=記号も使用できます。\n\n例:バイトの平均がメモリーの平均量以下である場合は、trueを返します\n`average(bytes) <= average(memory)`\n\n例: `lte(average(bytes), 1000)`\n ", - "lensFormulaDocs.tinymath.ltFunction.markdown": "\n2つの値で小なりの比較を実行します。\n「ifelse」比較関数の条件として使用されます。\n<記号も使用できます。\n\n例:バイトの平均がメモリーの平均量より少ない場合は、trueを返します\n`average(bytes) <= average(memory)`\n\n例: `lt(average(bytes), 1000)`\n ", - "lensFormulaDocs.tinymath.maxFunction.markdown": "\n2つの数値の間の最大値が検出されます。\n\n例:2つのフィールドの平均の最大値が検出されます。\n`pick_max(average(bytes), average(memory))`\n ", - "lensFormulaDocs.tinymath.minFunction.markdown": "\n2つの数値の間の最小値が検出されます。\n\n例:2つのフィールドの平均の最小値が検索されます。\n`pick_min(average(bytes), average(memory))`\n ", - "lensFormulaDocs.tinymath.modFunction.markdown": "\n関数を数値で除算した後の余り\n\n例:値の最後の3ビットを計算します\n`mod(sum(price), 1000)`\n ", - "lensFormulaDocs.tinymath.multiplyFunction.markdown": "\n2つの数値を乗算します。\n*記号も使用できます。\n\n例:現在の税率を入れた価格を計算します\n`sum(bytes) * last_value(tax_rate)`\n\n例:一定の税率を入れた価格を計算します\n`multiply(sum(price), 1.2)`\n ", - "lensFormulaDocs.tinymath.powFunction.markdown": "\n値を特定の乗数で累乗します。2番目の引数は必須です\n\n例:側面の長さに基づいて体積を計算します\n`pow(last_value(length), 3)`\n ", - "lensFormulaDocs.tinymath.roundFunction.markdown": "\n特定の小数位に四捨五入します。デフォルトは0です。\n\n例:セントに四捨五入します\n```\nround(sum(bytes))\nround(sum(bytes), 2)\n```\n ", - "lensFormulaDocs.tinymath.sqrtFunction.markdown": "\n正の値のみの平方根\n\n例:面積に基づいて側面の長さを計算します\n`sqrt(last_value(area))`\n ", - "lensFormulaDocs.tinymath.squareFunction.markdown": "\n値を2乗します\n\n例:側面の長さに基づいて面積を計算します\n`square(last_value(length))`\n ", - "lensFormulaDocs.tinymath.subtractFunction.markdown": "\n2番目の数値から1番目の数値を減算します。\n-記号も使用できます。\n\n例:フィールドの範囲を計算します\n`subtract(max(bytes), min(bytes))`\n ", - "lensFormulaDocs.documentation.filterRatioDescription.markdown": "### フィルター比率:\n\n`kql=''`を使用すると、1つのセットのドキュメントをフィルターして、同じグループの他のドキュメントと比較します。\n例:経時的なエラー率の変化を表示する\n\n```\ncount(kql='response.status_code > 400') / count()\n```\n ", - "lensFormulaDocs.documentation.markdown": "## 仕組み\n\nLens式では、Elasticsearchの集計および数学関数を使用して演算を実行できます\n。主に次の3種類の関数があります。\n\n* `sum(bytes)`などのElasticsearchメトリック\n* 時系列関数は`cumulative_sum()`などのElasticsearchメトリックを入力として使用します\n* `round()`などの数学関数\n\nこれらのすべての関数を使用する式の例:\n\n```\nround(100 * moving_average(\naverage(cpu.load.pct),\nwindow=10,\nkql='datacenter.name: east*'\n))\n```\n\nElasticsearchの関数はフィールド名を取り、フィールドは引用符で囲むこともできます。`sum(bytes)`は\nas `sum('bytes')`.\n\n一部の関数は、`moving_average(count(), window=5)`のような名前付き引数を取ります。\n\nElasticsearchメトリックはKQLまたはLucene構文を使用してフィルターできます。フィルターを追加するには、名前付き\nparameter `kql='field: value'` or `lucene=''`.KQLまたはLuceneクエリを作成するときには、必ず引用符を使用してください\n。検索が引用符で囲まれている場合は、`kql='Women's''のようにバックスラッシュでエスケープします。\n\n数学関数は位置引数を取ることができます。たとえば、pow(count(), 3)はcount() * count() * count()と同じです。\n\n+、-、/、*記号を使用して、基本演算を実行できます。\n ", - "lensFormulaDocs.documentation.percentOfTotalDescription.markdown": "### 合計の割合\n\nすべてのグループで式は`overall_sum`を計算できます。\nこれは各グループを合計の割合に変換できます。\n\n```\nsum(products.base_price) / overall_sum(sum(products.base_price))\n```\n ", - "lensFormulaDocs.documentation.recentChangeDescription.markdown": "### 最近の変更\n\n「reducedTimeRange='30m'」を使用して、グローバル時間範囲の最後と一致するメトリックの時間範囲で、フィルターを追加しました。これにより、どのくらいの値が最近変更されたのかを計算できます。\n\n```\nmax(system.network.in.bytes, reducedTimeRange=\"30m\")\n - min(system.network.in.bytes, reducedTimeRange=\"30m\")\n```\n ", - "lensFormulaDocs.documentation.weekOverWeekDescription.markdown": "### 週単位:\n\n`shift='1w'`を使用すると、前の週から各グループの値を取得します\n。時間シフトは*Top values*関数と使用しないでください。\n\n```\npercentile(system.network.in.bytes, percentile=99) /\npercentile(system.network.in.bytes, percentile=99, shift='1w')\n```\n ", - "lensFormulaDocs.cardinality.documentation.markdown": "\n指定されたフィールドの一意の値の数を計算します。数値、文字列、日付、ブール値で機能します。\n\n例:異なる製品の数を計算します。\n`unique_count(product.name)`\n\n例:「clothes」グループから異なる製品の数を計算します。\n`unique_count(product.name, kql='product.group=clothes')`\n ", - "lensFormulaDocs.count.documentation.markdown": "\nドキュメントの総数。フィールドを入力すると、フィールド値の合計数がカウントされます。1つのドキュメントに複数の値があるフィールドでCount関数を使用すると、すべての値がカウントされます。\n\n#### 例\n\nドキュメントの合計数を計算するには、count()を使用します。\n\nすべての注文書の製品数を計算するには、count(products.id)を使用します。\n\n特定のフィルターと一致するドキュメントの数を計算するには、count(kql='price > 500')を使用します。\n ", - "lensFormulaDocs.counterRate.documentation.markdown": "\n増加し続けるカウンターのレートを計算します。この関数は、経時的に単調に増加する種類の測定を含むカウンターメトリックフィールドでのみ結果を生成します。\n値が小さくなる場合は、カウンターリセットであると解釈されます。最も正確な結果を得るには、フィールドの「max`」で「counter_rate」を計算してください。\n\nこの計算はフィルターで定義された別の系列または上位値のディメンションに対して個別に実行されます。\n式で使用されるときには、現在の間隔を使用します。\n\n例:Memcachedサーバーで経時的に受信されたバイトの比率を可視化します。\n`counter_rate(max(memcached.stats.read.bytes))`\n ", - "lensFormulaDocs.cumulativeSum.documentation.markdown": "\n経時的なメトリックの累計値を計算し、系列のすべての前の値を各値に追加します。この関数を使用するには、日付ヒストグラムディメンションも構成する必要があります。\n\nこの計算はフィルターで定義された別の系列または上位値のディメンションに対して個別に実行されます。\n\n例:経時的に累積された受信バイト数を可視化します。\n`cumulative_sum(sum(bytes))`\n ", - "lensFormulaDocs.differences.documentation.markdown": "\n経時的にメトリックの最後の値に対する差異を計算します。この関数を使用するには、日付ヒストグラムディメンションも構成する必要があります。\n差異ではデータが連続する必要があります。差異を使用するときにデータが空の場合は、データヒストグラム間隔を大きくしてみてください。\n\nこの計算はフィルターで定義された別の系列または上位値のディメンションに対して個別に実行されます。\n\n例:経時的に受信したバイト数の変化を可視化します。\n`differences(sum(bytes))`\n ", - "lensFormulaDocs.lastValue.documentation.markdown": "\n最後のドキュメントからフィールドの値を返し、データビューのデフォルト時刻フィールドで並べ替えます。\n\nこの関数はエンティティの最新の状態を取得する際に役立ちます。\n\n例:サーバーAの現在のステータスを取得:\n`last_value(server.status, kql='server.name=\"A\"')`\n ", - "lensFormulaDocs.metric.documentation.markdown": "\nフィールドの{metric}を返します。この関数は数値フィールドでのみ動作します。\n\n例:価格の{metric}を取得:\n`{metric}(price)`\n\n例:英国からの注文の価格の{metric}を取得:\n`{metric}(price, kql='location:UK')`\n ", - "lensFormulaDocs.movingAverage.documentation.markdown": "\n経時的なメトリックの移動平均を計算します。最後のn番目の値を平均化し、現在の値を計算します。この関数を使用するには、日付ヒストグラムディメンションも構成する必要があります。\nデフォルトウィンドウ値は{defaultValue}です\n\nこの計算はフィルターで定義された別の系列または上位値のディメンションに対して個別に実行されます。\n\n指名パラメーター「window」を取ります。これは現在値の平均計算に含める最後の値の数を指定します。\n\n例:測定の線を平滑化:\n`moving_average(sum(bytes), window=5)`\n ", - "lensFormulaDocs.overall_average.documentation.markdown": "\n現在のグラフの系列のすべてのデータポイントのメトリックの平均を計算します。系列は日付ヒストグラムまたは間隔関数を使用してディメンションによって定義されます。\n上位の値やフィルターなどのデータを分解する他のディメンションは別の系列として処理されます。\n\n日付ヒストグラムまたは間隔関数が現在のグラフで使用されている場合、使用されている関数に関係なく、「overall_average」はすべてのディメンションで平均値を計算します。\n\n例:平均からの収束:\n`sum(bytes) - overall_average(sum(bytes))`\n ", - "lensFormulaDocs.overall_max.documentation.markdown": "\n現在のグラフの系列のすべてのデータポイントのメトリックの最大値を計算します。系列は日付ヒストグラムまたは間隔関数を使用してディメンションによって定義されます。\n上位の値やフィルターなどのデータを分解する他のディメンションは別の系列として処理されます。\n\n日付ヒストグラムまたは間隔関数が現在のグラフで使用されている場合、使用されている関数に関係なく、「overall_max」はすべてのディメンションで最大値を計算します。\n\n例:範囲の割合\n`(sum(bytes) - overall_min(sum(bytes))) / (overall_max(sum(bytes)) - overall_min(sum(bytes)))`\n ", - "lensFormulaDocs.overall_min.documentation.markdown": "\n現在のグラフの系列のすべてのデータポイントのメトリックの最小値を計算します。系列は日付ヒストグラムまたは間隔関数を使用してディメンションによって定義されます。\n上位の値やフィルターなどのデータを分解する他のディメンションは別の系列として処理されます。\n\n日付ヒストグラムまたは間隔関数が現在のグラフで使用されている場合、使用されている関数に関係なく、「overall_min」はすべてのディメンションで最小値を計算します。\n\n例:範囲の割合\n`(sum(bytes) - overall_min(sum(bytes)) / (overall_max(sum(bytes)) - overall_min(sum(bytes)))`\n ", - "lensFormulaDocs.overall_sum.documentation.markdown": "\n現在のグラフの系列のすべてのデータポイントのメトリックの合計を計算します。系列は日付ヒストグラムまたは間隔関数を使用してディメンションによって定義されます。\n上位の値やフィルターなどのデータを分解する他のディメンションは別の系列として処理されます。\n\n日付ヒストグラムまたは間隔関数が現在のグラフで使用されている場合、使用されている関数に関係なく、「overall_sum」はすべてのディメンションで合計値を計算します。\n\n例:合計の割合\n`sum(bytes) / overall_sum(sum(bytes))`\n ", - "lensFormulaDocs.percentile.documentation.markdown": "\nフィールドの値の指定された百分位数を返します。これはドキュメントに出現する値のnパーセントが小さい値です。\n\n例:値の95 %より大きいバイト数を取得:\n`percentile(bytes, percentile=95)`\n ", - "lensFormulaDocs.percentileRanks.documentation.markdown": "\n特定の値未満の値の割合が返されます。たとえば、値が観察された値の95%以上の場合、95パーセンタイルランクであるとされます。\n\n例:100未満の値のパーセンタイルを取得します。\n`percentile_rank(bytes, value=100)`\n ", - "lensFormulaDocs.standardDeviation.documentation.markdown": "\nフィールドの分散または散布度が返されます。この関数は数値フィールドでのみ動作します。\n\n#### 例\n\n価格の標準偏差を取得するには、standard_deviation(price)を使用します。\n\n英国からの注文書の価格の分散を取得するには、square(standard_deviation(price, kql='location:UK'))を使用します。\n ", - "lensFormulaDocs.time_scale.documentation.markdown": "\n\nこの高度な機能は、特定の期間に対してカウントと合計を正規化する際に役立ちます。すでに特定の期間に対して正規化され、保存されたメトリックとの統合が可能です。\n\nこの機能は、現在のグラフで日付ヒストグラム関数が使用されている場合にのみ使用できます。\n\n例:すでに正規化されているメトリックを、正規化が必要な別のメトリックと比較した比率。\n`normalize_by_unit(counter_rate(max(system.diskio.write.bytes)), unit='s') / last_value(apache.status.bytes_per_second)`\n ", "xpack.lens.AggBasedLabel": "集約に基づく可視化", "xpack.lens.app.addToLibrary": "ライブラリに保存", "xpack.lens.app.cancel": "キャンセル", @@ -22154,11 +22109,6 @@ "xpack.lens.fittingFunctionsTitle.lookahead": "次へ", "xpack.lens.fittingFunctionsTitle.none": "非表示", "xpack.lens.fittingFunctionsTitle.zero": "ゼロ", - "lensFormulaDocs.tinymath.base": "基数", - "lensFormulaDocs.boolean": "ブール", - "lensFormulaDocs.tinymath.condition": "条件", - "lensFormulaDocs.tinymath.decimals": "小数点以下", - "lensFormulaDocs.tinymath.defaultValue": "デフォルト", "xpack.lens.formula.disableWordWrapLabel": "単語の折り返しを無効にする", "xpack.lens.formula.editorHelpInlineHideLabel": "関数リファレンスを非表示", "xpack.lens.formula.editorHelpInlineHideToolTip": "関数リファレンスを非表示", @@ -22166,35 +22116,12 @@ "xpack.lens.formula.fullScreenEnterLabel": "拡張", "xpack.lens.formula.fullScreenExitLabel": "縮小", "xpack.lens.formula.kqlExtraArguments": "[kql]?:文字列、[lucene]?:文字列", - "lensFormulaDocs.tinymath.left": "左", - "lensFormulaDocs.tinymath.max": "最高", - "lensFormulaDocs.tinymath.min": "分", - "lensFormulaDocs.number": "数字", "xpack.lens.formula.reducedTimeRangeExtraArguments": "[reducedTimeRange]?: string", "xpack.lens.formula.requiredArgument": "必須", - "lensFormulaDocs.tinymath.right": "右", "xpack.lens.formula.shiftExtraArguments": "[shift]?:文字列", - "lensFormulaDocs.string": "文字列", - "lensFormulaDocs.tinymath.value": "値", - "lensFormulaDocs.CommonFormulaDocumentation": "最も一般的な式は2つの値を分割して割合を生成します。正確に表示するには、[値形式]を[割合]に設定します。", - "lensFormulaDocs.documentation.columnCalculationSection": "列計算", - "lensFormulaDocs.documentation.columnCalculationSectionDescription": "各行でこれらの関数が実行されますが、コンテキストとして列全体が提供されます。これはウィンドウ関数とも呼ばれます。", - "lensFormulaDocs.documentation.comparisonSection": "比較", - "lensFormulaDocs.documentation.comparisonSectionDescription": "これらの関数は値を比較するために使用されます。", - "lensFormulaDocs.documentation.constantsSection": "Kibanaコンテキスト", - "lensFormulaDocs.documentation.constantsSectionDescription": "これらの関数は、Kibanaのコンテキスト変数(日付ヒストグラムの「interval」、現在の「now」、選択した「time_range」)を取得するために使用され、日付の計算処理を行うのに役立ちます。", - "lensFormulaDocs.documentation.elasticsearchSection": "Elasticsearch", - "lensFormulaDocs.documentation.elasticsearchSectionDescription": "これらの関数は結果テーブルの各行の未加工ドキュメントで実行され、内訳ディメンションと一致するすべてのドキュメントを単一の値に集約します。", - "lensFormulaDocs.documentation.filterRatio": "フィルター比率", - "lensFormulaDocs.documentation.mathSection": "数学処理", - "lensFormulaDocs.documentation.mathSectionDescription": "これらの関数は、他の関数で計算された同じ行の単一の値を使用して、結果テーブルの各行で実行されます。", - "lensFormulaDocs.documentation.percentOfTotal": "合計の割合", - "lensFormulaDocs.documentation.recentChange": "最近の変更", - "lensFormulaDocs.documentation.weekOverWeek": "週単位", "xpack.lens.formulaDocumentationHeading": "仕組み", "xpack.lens.formulaEnableWordWrapLabel": "単語の折り返しを有効にする", "xpack.lens.formulaExampleMarkdown": "例", - "lensFormulaDocs.frequentlyUsedHeading": "一般的な式", "xpack.lens.formulaPlaceholderText": "関数を演算と組み合わせて式を入力します。例:", "xpack.lens.fullExtent.niceValues": "切りの良い値に端数処理", "xpack.lens.functions.collapse.args.byHelpText": "グループ化の基準となる列。この列はそのまま保持されます", @@ -22253,29 +22180,20 @@ "xpack.lens.indexPattern.allFieldsLabelHelp": "使用可能なフィールドをワークスペースまでドラッグし、ビジュアライゼーションを作成します。使用可能なフィールドを変更するには、別のデータビューを選択するか、クエリを編集するか、別の時間範囲を使用します。一部のフィールドタイプは、完全なテキストおよびグラフィックフィールドを含む Lens では、ビジュアライゼーションできません。", "xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning.link": "ドキュメントをご覧ください", "xpack.lens.indexPattern.availableFieldsLabel": "利用可能なフィールド", - "lensFormulaDocs.avg": "平均", "xpack.lens.indexPattern.avg.description": "集約されたドキュメントから抽出された数値の平均値を計算する単一値メトリック集約", "xpack.lens.indexPattern.avg.quickFunctionDescription": "数値フィールドの集合の平均値。", "xpack.lens.indexPattern.bitsFormatLabel": "ビット(1000)", "xpack.lens.indexPattern.bytesFormatLabel": "バイト(1024)", - "lensFormulaDocs.cardinality": "ユニークカウント", "xpack.lens.indexPattern.cardinality.documentation.quick": "\n指定した数値、文字列、日付、ブール値フィールドの一意の値の数。\n ", - "lensFormulaDocs.cardinality.signature": "フィールド:文字列", "xpack.lens.indexPattern.changeDataViewTitle": "データビュー", "xpack.lens.indexPattern.chooseField": "フィールド", "xpack.lens.indexPattern.chooseFieldLabel": "この関数を使用するには、フィールドを選択してください。", "xpack.lens.indexPattern.chooseSubFunction": "サブ関数を選択", "xpack.lens.indexPattern.columnFormatLabel": "値の形式", "xpack.lens.indexPattern.compactLabel": "値の圧縮", - "lensFormulaDocs.count": "カウント", "xpack.lens.indexPattern.count.documentation.quick": "\nドキュメントの総数。フィールドを入力すると、フィールド値の合計数がカウントされます。1つのドキュメントに複数の値があるフィールドでCount関数を使用すると、すべての値がカウントされます。\n ", - "lensFormulaDocs.count.signature": "[field: string]", - "lensFormulaDocs.counterRate": "カウンターレート", "xpack.lens.indexPattern.counterRate.documentation.quick": "\n 増加を続ける時系列メトリックの経時的な変化率。\n ", - "lensFormulaDocs.counterRate.signature": "メトリック:数値", "xpack.lens.indexPattern.countOf": "レコード数", - "lensFormulaDocs.cumulative_sum.signature": "メトリック:数値", - "lensFormulaDocs.cumulativeSum": "累積和", "xpack.lens.indexPattern.cumulativeSum.documentation.quick": "\n 経時的に増加するすべての値の合計。\n ", "xpack.lens.indexPattern.custom.externalDoc": "数値書式構文", "xpack.lens.indexPattern.custom.patternLabel": "フォーマット", @@ -22305,9 +22223,7 @@ "xpack.lens.indexPattern.dateRange.noTimeRange": "現在の時間範囲がありません", "xpack.lens.indexPattern.decimalPlacesLabel": "小数点以下", "xpack.lens.indexPattern.defaultFormatLabel": "デフォルト", - "lensFormulaDocs.derivative": "差異", "xpack.lens.indexPattern.differences.documentation.quick": "\n 後続の間隔の値の変化。\n ", - "lensFormulaDocs.differences.signature": "メトリック:数値", "xpack.lens.indexPattern.dimensionEditor.headingAppearance": "見た目", "xpack.lens.indexPattern.dimensionEditor.headingData": "データ", "xpack.lens.indexPattern.dimensionEditor.headingFormula": "式", @@ -22360,30 +22276,22 @@ "xpack.lens.indexPattern.invalidOperationLabel": "選択した関数はこのフィールドで動作しません。", "xpack.lens.indexPattern.invalidReducedTimeRange": "縮小された時間範囲が無効です。正の整数の後に単位s、m、h、d、w、M、yのいずれかを入力します。例:3時間は3hです", "xpack.lens.indexPattern.invalidTimeShift": "無効な時間シフトです。正の整数の後に単位s、m、h、d、w、M、yのいずれかを入力します。例:3時間は3hです", - "lensFormulaDocs.lastValue": "最終値", "xpack.lens.indexPattern.lastValue.disabled": "この関数には、データビューの日付フィールドが必要です", "xpack.lens.indexPattern.lastValue.documentation.quick": "\n最後のドキュメントのフィールドの値。データビューのデフォルト時刻フィールドで並べ替えられます。\n ", "xpack.lens.indexPattern.lastValue.showArrayValues": "ゼロ値を表示", "xpack.lens.indexPattern.lastValue.showArrayValuesExplanation": "各最後のドキュメントのこのフィールドに関連付けられたすべての値を表示します。", "xpack.lens.indexPattern.lastValue.showArrayValuesWithTopValuesWarning": "配列値を表示するときには、このフィールドを使用して上位の値をランク付けできません。", - "lensFormulaDocs.lastValue.signature": "フィールド:文字列", "xpack.lens.indexPattern.lastValue.sortField": "日付フィールドで並べ替え", "xpack.lens.indexPattern.lastValue.sortFieldPlaceholder": "並べ替えフィールド", - "lensFormulaDocs.max": "最高", "xpack.lens.indexPattern.max.description": "集約されたドキュメントから抽出された数値の最大値を返す単一値メトリック集約。", "xpack.lens.indexPattern.max.quickFunctionDescription": "数値フィールドの最大値。", - "lensFormulaDocs.median": "中央", "xpack.lens.indexPattern.median.description": "集約されたドキュメントから抽出された中央値を計算する単一値メトリック集約。", "xpack.lens.indexPattern.median.quickFunctionDescription": "数値フィールドの中央値。", "xpack.lens.indexPattern.metaFieldsLabel": "メタフィールド", - "lensFormulaDocs.metric.signature": "フィールド:文字列", - "lensFormulaDocs.min": "最低", "xpack.lens.indexPattern.min.description": "集約されたドキュメントから抽出された数値の最小値を返す単一値メトリック集約。", "xpack.lens.indexPattern.min.quickFunctionDescription": "数値フィールドの最小値。", "xpack.lens.indexPattern.missingFieldLabel": "見つからないフィールド", "xpack.lens.indexPattern.moveToWorkspaceNotAvailable": "このフィールドを可視化するには、直接任意のレイヤーに追加してください。現在の設定では、このフィールドをワークスペースに追加することはサポートされていません。", - "lensFormulaDocs.moving_average.signature": "メトリック:数値、[window]:数値", - "lensFormulaDocs.movingAverage": "移動平均", "xpack.lens.indexPattern.movingAverage.basicExplanation": "移動平均はデータ全体でウィンドウをスライドし、平均値を表示します。移動平均は日付ヒストグラムでのみサポートされています。", "xpack.lens.indexPattern.movingAverage.documentation.quick": "\n 経時的な値の移動範囲の平均。\n ", "xpack.lens.indexPattern.movingAverage.limitations": "最初の移動平均値は2番目の項目から開始します。", @@ -22399,20 +22307,12 @@ "xpack.lens.indexPattern.noRealMetricError": "静的値のみのレイヤーには結果が表示されません。1つ以上の動的メトリックを使用してください", "xpack.lens.indexPattern.notAbsoluteTimeShift": "無効な時間シフトです。", "xpack.lens.indexPattern.numberFormatLabel": "数字", - "lensFormulaDocs.overall_metric": "メトリック:数値", - "lensFormulaDocs.overallMax": "全体最高", - "lensFormulaDocs.overallMin": "全体最低", - "lensFormulaDocs.overallSum": "全体合計", "xpack.lens.indexPattern.percentFormatLabel": "割合(%)", - "lensFormulaDocs.percentile": "パーセンタイル", "xpack.lens.indexPattern.percentile.documentation.quick": "\n すべてのドキュメントで発生する値のnパーセントよりも小さい最大値。\n ", "xpack.lens.indexPattern.percentile.percentileRanksValue": "パーセンタイル順位値", "xpack.lens.indexPattern.percentile.percentileValue": "パーセンタイル", - "lensFormulaDocs.percentile.signature": "フィールド:文字列、[percentile]:数値", - "lensFormulaDocs.percentileRank": "パーセンタイル順位", "xpack.lens.indexPattern.percentileRanks.documentation.quick": "\n特定の値未満の値の割合。たとえば、値が計算された値の95%以上の場合、95パーセンタイル順位です。\n ", "xpack.lens.indexPattern.percentileRanks.errorMessage": "パーセンタイル順位値は数値でなければなりません", - "lensFormulaDocs.percentileRanks.signature": "フィールド: 文字列, [value]: 数値", "xpack.lens.indexPattern.precisionErrorWarning.accuracyDisabled.shortMessage": "これは近似値の可能性があります。より正確な結果を得るために精度モードを有効にできますが、Elasticsearchクラスターの負荷が大きくなります。", "xpack.lens.indexPattern.precisionErrorWarning.accuracyEnabled.shortMessage": "これは近似値の可能性があります。より正確な結果を得るには、フィルターを使用するか、上位の値の数を増やしてください。", "xpack.lens.indexPattern.precisionErrorWarning.ascendingCountPrecisionErrorWarning.shortMessage": "データのインデックスの作成方法により、近似される場合があります。より正確な結果を得るには、希少性でソートしてください。", @@ -22462,7 +22362,6 @@ "xpack.lens.indexPattern.samplingPerLayer.fallbackLayerName": "データレイヤー", "xpack.lens.indexPattern.settingsSamplingUnsupported": "この関数を選択すると、関数が正常に機能するように、このレイヤーのサンプリングが100%に変更されます。", "xpack.lens.indexPattern.sortField.invalid": "無効なフィールドです。データビューを確認するか、別のフィールドを選択してください。", - "lensFormulaDocs.standardDeviation": "標準偏差", "xpack.lens.indexPattern.standardDeviation.description": "集約されたドキュメントから抽出された数値の標準偏差を計算する単一値メトリック集約", "xpack.lens.indexPattern.standardDeviation.quickFunctionDescription": "フィールド値の変動量である数値フィールドの値の標準偏差。", "xpack.lens.indexPattern.staticValue.label": "基準線値", @@ -22472,7 +22371,6 @@ "xpack.lens.indexPattern.staticValueWarningText": "固定値を上書きするには、クイック関数を選択します", "xpack.lens.indexPattern.suffixLabel": "接尾辞", "xpack.lens.indexpattern.suggestions.overTimeLabel": "一定時間", - "lensFormulaDocs.sum": "合計", "xpack.lens.indexPattern.sum.description": "集約されたドキュメントから抽出された数値を合計する単一値メトリック集約。", "xpack.lens.indexPattern.sum.quickFunctionDescription": "数値フィールドの値の合計量。", "xpack.lens.indexPattern.switchToRare": "希少性でランク", @@ -22510,8 +22408,6 @@ "xpack.lens.indexPattern.terms.size": "値の数", "xpack.lens.indexPattern.termsWithMultipleShifts": "単一のレイヤーでは、メトリックを異なる時間シフトと動的な上位の値と組み合わせることができません。すべてのメトリックで同じ時間シフト値を使用するか、上位の値ではなくフィルターを使用します。", "xpack.lens.indexPattern.termsWithMultipleShiftsFixActionLabel": "フィルターを使用", - "lensFormulaDocs.time_scale": "メトリック:数値、単位:s|m|h|d|w|M|y", - "lensFormulaDocs.timeScale": "単位で正規化", "xpack.lens.indexPattern.timeScale.label": "単位で正規化", "xpack.lens.indexPattern.timeScale.missingUnit": "単位による正規化の単位が指定されていません。", "xpack.lens.indexPattern.timeScale.tooltip": "基本の日付間隔に関係なく、常に指定された時間単位のレートとして表示されるように値を正規化します。", @@ -22956,6 +22852,113 @@ "xpack.lens.xyVisualization.stackedPercentageBarHorizontalLabel": "H.割合棒", "xpack.lens.xyVisualization.stackedPercentageBarLabel": "縦棒割合", "xpack.lens.xyVisualization.xyLabel": "XY", + "lensFormulaDocs.tinymath.absFunction.markdown": "\n絶対値を計算します。負の値は-1で乗算されます。正の値は同じままです。\n\n例:海水位までの平均距離を計算します `abs(average(altitude))`\n ", + "lensFormulaDocs.tinymath.addFunction.markdown": "\n2つの数値を加算します。\n+記号も使用できます。\n\n例:2つのフィールドの合計を計算します\n\n`sum(price) + sum(tax)`\n\n例:固定値でカウントをオフセットします\n\n`add(count(), 5)`\n ", + "lensFormulaDocs.tinymath.cbrtFunction.markdown": "\n値の立方根。\n\n例:体積から側面の長さを計算します\n`cbrt(last_value(volume))`\n ", + "lensFormulaDocs.tinymath.ceilFunction.markdown": "\n値の上限(切り上げ)。\n\n例:価格を次のドル単位まで切り上げます\n`ceil(sum(price))`\n ", + "lensFormulaDocs.tinymath.clampFunction.markdown": "\n最小値から最大値までの値を制限します。\n\n例:確実に異常値を特定します\n```\nclamp(\n average(bytes),\n percentile(bytes, percentile=5),\n percentile(bytes, percentile=95)\n)\n```\n", + "lensFormulaDocs.tinymath.cubeFunction.markdown": "\n数値の三乗を計算します。\n\n例:側面の長さから体積を計算します\n`cube(last_value(length))`\n ", + "lensFormulaDocs.tinymath.defaultFunction.markdown": "\n値がヌルのときにデフォルトの数値を返します。\n\n例:フィールドにデータがない場合は、-1を返します\n`defaults(average(bytes), -1)`\n", + "lensFormulaDocs.tinymath.divideFunction.markdown": "\n1番目の数値を2番目の数値で除算します。\n/記号も使用できます\n\n例:利益率を計算します\n`sum(profit) / sum(revenue)`\n\n例:`divide(sum(bytes), 2)`\n ", + "lensFormulaDocs.tinymath.eqFunction.markdown": "\n2つの値で等価性の比較を実行します。\n「ifelse」比較関数の条件として使用されます。\n==記号も使用できます。\n\n例:バイトの平均が平均メモリーと同じ量の場合は、trueを返します。\n`average(bytes) == average(memory)`\n\n例: `eq(sum(bytes), 1000000)`\n ", + "lensFormulaDocs.tinymath.expFunction.markdown": "\n*e*をn乗します。\n\n例:自然指数関数を計算します\n\n`exp(last_value(duration))`\n ", + "lensFormulaDocs.tinymath.fixFunction.markdown": "\n正の値の場合は、下限を取ります。負の値の場合は、上限を取ります。\n\n例:ゼロに向かって端数処理します\n`fix(sum(profit))`\n ", + "lensFormulaDocs.tinymath.floorFunction.markdown": "\n最も近い整数値まで切り捨てます\n\n例:価格を切り捨てます\n`floor(sum(price))`\n ", + "lensFormulaDocs.tinymath.gteFunction.markdown": "\n2つの値で大なりの比較を実行します。\n「ifelse」比較関数の条件として使用されます。\n>=記号も使用できます。\n\n例:バイトの平均がメモリーの平均量以上である場合は、trueを返します\n`average(bytes) >= average(memory)`\n\n例: `gte(average(bytes), 1000)`\n ", + "lensFormulaDocs.tinymath.gtFunction.markdown": "\n2つの値で大なりの比較を実行します。\n「ifelse」比較関数の条件として使用されます。\n>記号も使用できます。\n\n例:バイトの平均がメモリーの平均量より大きい場合は、trueを返します\n`average(bytes) > average(memory)`\n\n例: `gt(average(bytes), 1000)`\n ", + "lensFormulaDocs.tinymath.ifElseFunction.markdown": "\n条件の要素がtrueかfalseかに応じて、値を返します。\n\n例:顧客ごとの平均収益。ただし、場合によっては、顧客IDが提供されないことがあり、その場合は別の顧客としてカウントされます\n`sum(total)/(unique_count(customer_id) + ifelse( count() > count(kql='customer_id:*'), 1, 0))`\n ", + "lensFormulaDocs.tinymath.logFunction.markdown": "\nオプションで底をとる対数。デフォルトでは自然対数の底*e*を使用します。\n\n例:値を格納するために必要なビット数を計算します\n```\nlog(sum(bytes))\nlog(sum(bytes), 2)\n```\n ", + "lensFormulaDocs.tinymath.lteFunction.markdown": "\n2つの値で小なりイコールの比較を実行します。\n「ifelse」比較関数の条件として使用されます。\n<=記号も使用できます。\n\n例:バイトの平均がメモリーの平均量以下である場合は、trueを返します\n`average(bytes) <= average(memory)`\n\n例: `lte(average(bytes), 1000)`\n ", + "lensFormulaDocs.tinymath.ltFunction.markdown": "\n2つの値で小なりの比較を実行します。\n「ifelse」比較関数の条件として使用されます。\n<記号も使用できます。\n\n例:バイトの平均がメモリーの平均量より少ない場合は、trueを返します\n`average(bytes) <= average(memory)`\n\n例: `lt(average(bytes), 1000)`\n ", + "lensFormulaDocs.tinymath.maxFunction.markdown": "\n2つの数値の間の最大値が検出されます。\n\n例:2つのフィールドの平均の最大値が検出されます。\n`pick_max(average(bytes), average(memory))`\n ", + "lensFormulaDocs.tinymath.minFunction.markdown": "\n2つの数値の間の最小値が検出されます。\n\n例:2つのフィールドの平均の最小値が検索されます。\n`pick_min(average(bytes), average(memory))`\n ", + "lensFormulaDocs.tinymath.modFunction.markdown": "\n関数を数値で除算した後の余り\n\n例:値の最後の3ビットを計算します\n`mod(sum(price), 1000)`\n ", + "lensFormulaDocs.tinymath.multiplyFunction.markdown": "\n2つの数値を乗算します。\n*記号も使用できます。\n\n例:現在の税率を入れた価格を計算します\n`sum(bytes) * last_value(tax_rate)`\n\n例:一定の税率を入れた価格を計算します\n`multiply(sum(price), 1.2)`\n ", + "lensFormulaDocs.tinymath.powFunction.markdown": "\n値を特定の乗数で累乗します。2番目の引数は必須です\n\n例:側面の長さに基づいて体積を計算します\n`pow(last_value(length), 3)`\n ", + "lensFormulaDocs.tinymath.roundFunction.markdown": "\n特定の小数位に四捨五入します。デフォルトは0です。\n\n例:セントに四捨五入します\n```\nround(sum(bytes))\nround(sum(bytes), 2)\n```\n ", + "lensFormulaDocs.tinymath.sqrtFunction.markdown": "\n正の値のみの平方根\n\n例:面積に基づいて側面の長さを計算します\n`sqrt(last_value(area))`\n ", + "lensFormulaDocs.tinymath.squareFunction.markdown": "\n値を2乗します\n\n例:側面の長さに基づいて面積を計算します\n`square(last_value(length))`\n ", + "lensFormulaDocs.tinymath.subtractFunction.markdown": "\n2番目の数値から1番目の数値を減算します。\n-記号も使用できます。\n\n例:フィールドの範囲を計算します\n`subtract(max(bytes), min(bytes))`\n ", + "lensFormulaDocs.documentation.filterRatioDescription.markdown": "### フィルター比率:\n\n`kql=''`を使用すると、1つのセットのドキュメントをフィルターして、同じグループの他のドキュメントと比較します。\n例:経時的なエラー率の変化を表示する\n\n```\ncount(kql='response.status_code > 400') / count()\n```\n ", + "lensFormulaDocs.documentation.markdown": "## 仕組み\n\nLens式では、Elasticsearchの集計および数学関数を使用して演算を実行できます\n。主に次の3種類の関数があります。\n\n* `sum(bytes)`などのElasticsearchメトリック\n* 時系列関数は`cumulative_sum()`などのElasticsearchメトリックを入力として使用します\n* `round()`などの数学関数\n\nこれらのすべての関数を使用する式の例:\n\n```\nround(100 * moving_average(\naverage(cpu.load.pct),\nwindow=10,\nkql='datacenter.name: east*'\n))\n```\n\nElasticsearchの関数はフィールド名を取り、フィールドは引用符で囲むこともできます。`sum(bytes)`は\nas `sum('bytes')`.\n\n一部の関数は、`moving_average(count(), window=5)`のような名前付き引数を取ります。\n\nElasticsearchメトリックはKQLまたはLucene構文を使用してフィルターできます。フィルターを追加するには、名前付き\nparameter `kql='field: value'` or `lucene=''`.KQLまたはLuceneクエリを作成するときには、必ず引用符を使用してください\n。検索が引用符で囲まれている場合は、`kql='Women's''のようにバックスラッシュでエスケープします。\n\n数学関数は位置引数を取ることができます。たとえば、pow(count(), 3)はcount() * count() * count()と同じです。\n\n+、-、/、*記号を使用して、基本演算を実行できます。\n ", + "lensFormulaDocs.documentation.percentOfTotalDescription.markdown": "### 合計の割合\n\nすべてのグループで式は`overall_sum`を計算できます。\nこれは各グループを合計の割合に変換できます。\n\n```\nsum(products.base_price) / overall_sum(sum(products.base_price))\n```\n ", + "lensFormulaDocs.documentation.recentChangeDescription.markdown": "### 最近の変更\n\n「reducedTimeRange='30m'」を使用して、グローバル時間範囲の最後と一致するメトリックの時間範囲で、フィルターを追加しました。これにより、どのくらいの値が最近変更されたのかを計算できます。\n\n```\nmax(system.network.in.bytes, reducedTimeRange=\"30m\")\n - min(system.network.in.bytes, reducedTimeRange=\"30m\")\n```\n ", + "lensFormulaDocs.documentation.weekOverWeekDescription.markdown": "### 週単位:\n\n`shift='1w'`を使用すると、前の週から各グループの値を取得します\n。時間シフトは*Top values*関数と使用しないでください。\n\n```\npercentile(system.network.in.bytes, percentile=99) /\npercentile(system.network.in.bytes, percentile=99, shift='1w')\n```\n ", + "lensFormulaDocs.cardinality.documentation.markdown": "\n指定されたフィールドの一意の値の数を計算します。数値、文字列、日付、ブール値で機能します。\n\n例:異なる製品の数を計算します。\n`unique_count(product.name)`\n\n例:「clothes」グループから異なる製品の数を計算します。\n`unique_count(product.name, kql='product.group=clothes')`\n ", + "lensFormulaDocs.count.documentation.markdown": "\nドキュメントの総数。フィールドを入力すると、フィールド値の合計数がカウントされます。1つのドキュメントに複数の値があるフィールドでCount関数を使用すると、すべての値がカウントされます。\n\n#### 例\n\nドキュメントの合計数を計算するには、count()を使用します。\n\nすべての注文書の製品数を計算するには、count(products.id)を使用します。\n\n特定のフィルターと一致するドキュメントの数を計算するには、count(kql='price > 500')を使用します。\n ", + "lensFormulaDocs.counterRate.documentation.markdown": "\n増加し続けるカウンターのレートを計算します。この関数は、経時的に単調に増加する種類の測定を含むカウンターメトリックフィールドでのみ結果を生成します。\n値が小さくなる場合は、カウンターリセットであると解釈されます。最も正確な結果を得るには、フィールドの「max`」で「counter_rate」を計算してください。\n\nこの計算はフィルターで定義された別の系列または上位値のディメンションに対して個別に実行されます。\n式で使用されるときには、現在の間隔を使用します。\n\n例:Memcachedサーバーで経時的に受信されたバイトの比率を可視化します。\n`counter_rate(max(memcached.stats.read.bytes))`\n ", + "lensFormulaDocs.cumulativeSum.documentation.markdown": "\n経時的なメトリックの累計値を計算し、系列のすべての前の値を各値に追加します。この関数を使用するには、日付ヒストグラムディメンションも構成する必要があります。\n\nこの計算はフィルターで定義された別の系列または上位値のディメンションに対して個別に実行されます。\n\n例:経時的に累積された受信バイト数を可視化します。\n`cumulative_sum(sum(bytes))`\n ", + "lensFormulaDocs.differences.documentation.markdown": "\n経時的にメトリックの最後の値に対する差異を計算します。この関数を使用するには、日付ヒストグラムディメンションも構成する必要があります。\n差異ではデータが連続する必要があります。差異を使用するときにデータが空の場合は、データヒストグラム間隔を大きくしてみてください。\n\nこの計算はフィルターで定義された別の系列または上位値のディメンションに対して個別に実行されます。\n\n例:経時的に受信したバイト数の変化を可視化します。\n`differences(sum(bytes))`\n ", + "lensFormulaDocs.lastValue.documentation.markdown": "\n最後のドキュメントからフィールドの値を返し、データビューのデフォルト時刻フィールドで並べ替えます。\n\nこの関数はエンティティの最新の状態を取得する際に役立ちます。\n\n例:サーバーAの現在のステータスを取得:\n`last_value(server.status, kql='server.name=\"A\"')`\n ", + "lensFormulaDocs.metric.documentation.markdown": "\nフィールドの{metric}を返します。この関数は数値フィールドでのみ動作します。\n\n例:価格の{metric}を取得:\n`{metric}(price)`\n\n例:英国からの注文の価格の{metric}を取得:\n`{metric}(price, kql='location:UK')`\n ", + "lensFormulaDocs.movingAverage.documentation.markdown": "\n経時的なメトリックの移動平均を計算します。最後のn番目の値を平均化し、現在の値を計算します。この関数を使用するには、日付ヒストグラムディメンションも構成する必要があります。\nデフォルトウィンドウ値は{defaultValue}です\n\nこの計算はフィルターで定義された別の系列または上位値のディメンションに対して個別に実行されます。\n\n指名パラメーター「window」を取ります。これは現在値の平均計算に含める最後の値の数を指定します。\n\n例:測定の線を平滑化:\n`moving_average(sum(bytes), window=5)`\n ", + "lensFormulaDocs.overall_average.documentation.markdown": "\n現在のグラフの系列のすべてのデータポイントのメトリックの平均を計算します。系列は日付ヒストグラムまたは間隔関数を使用してディメンションによって定義されます。\n上位の値やフィルターなどのデータを分解する他のディメンションは別の系列として処理されます。\n\n日付ヒストグラムまたは間隔関数が現在のグラフで使用されている場合、使用されている関数に関係なく、「overall_average」はすべてのディメンションで平均値を計算します。\n\n例:平均からの収束:\n`sum(bytes) - overall_average(sum(bytes))`\n ", + "lensFormulaDocs.overall_max.documentation.markdown": "\n現在のグラフの系列のすべてのデータポイントのメトリックの最大値を計算します。系列は日付ヒストグラムまたは間隔関数を使用してディメンションによって定義されます。\n上位の値やフィルターなどのデータを分解する他のディメンションは別の系列として処理されます。\n\n日付ヒストグラムまたは間隔関数が現在のグラフで使用されている場合、使用されている関数に関係なく、「overall_max」はすべてのディメンションで最大値を計算します。\n\n例:範囲の割合\n`(sum(bytes) - overall_min(sum(bytes))) / (overall_max(sum(bytes)) - overall_min(sum(bytes)))`\n ", + "lensFormulaDocs.overall_min.documentation.markdown": "\n現在のグラフの系列のすべてのデータポイントのメトリックの最小値を計算します。系列は日付ヒストグラムまたは間隔関数を使用してディメンションによって定義されます。\n上位の値やフィルターなどのデータを分解する他のディメンションは別の系列として処理されます。\n\n日付ヒストグラムまたは間隔関数が現在のグラフで使用されている場合、使用されている関数に関係なく、「overall_min」はすべてのディメンションで最小値を計算します。\n\n例:範囲の割合\n`(sum(bytes) - overall_min(sum(bytes)) / (overall_max(sum(bytes)) - overall_min(sum(bytes)))`\n ", + "lensFormulaDocs.overall_sum.documentation.markdown": "\n現在のグラフの系列のすべてのデータポイントのメトリックの合計を計算します。系列は日付ヒストグラムまたは間隔関数を使用してディメンションによって定義されます。\n上位の値やフィルターなどのデータを分解する他のディメンションは別の系列として処理されます。\n\n日付ヒストグラムまたは間隔関数が現在のグラフで使用されている場合、使用されている関数に関係なく、「overall_sum」はすべてのディメンションで合計値を計算します。\n\n例:合計の割合\n`sum(bytes) / overall_sum(sum(bytes))`\n ", + "lensFormulaDocs.percentile.documentation.markdown": "\nフィールドの値の指定された百分位数を返します。これはドキュメントに出現する値のnパーセントが小さい値です。\n\n例:値の95 %より大きいバイト数を取得:\n`percentile(bytes, percentile=95)`\n ", + "lensFormulaDocs.percentileRanks.documentation.markdown": "\n特定の値未満の値の割合が返されます。たとえば、値が観察された値の95%以上の場合、95パーセンタイルランクであるとされます。\n\n例:100未満の値のパーセンタイルを取得します。\n`percentile_rank(bytes, value=100)`\n ", + "lensFormulaDocs.standardDeviation.documentation.markdown": "\nフィールドの分散または散布度が返されます。この関数は数値フィールドでのみ動作します。\n\n#### 例\n\n価格の標準偏差を取得するには、standard_deviation(price)を使用します。\n\n英国からの注文書の価格の分散を取得するには、square(standard_deviation(price, kql='location:UK'))を使用します。\n ", + "lensFormulaDocs.time_scale.documentation.markdown": "\n\nこの高度な機能は、特定の期間に対してカウントと合計を正規化する際に役立ちます。すでに特定の期間に対して正規化され、保存されたメトリックとの統合が可能です。\n\nこの機能は、現在のグラフで日付ヒストグラム関数が使用されている場合にのみ使用できます。\n\n例:すでに正規化されているメトリックを、正規化が必要な別のメトリックと比較した比率。\n`normalize_by_unit(counter_rate(max(system.diskio.write.bytes)), unit='s') / last_value(apache.status.bytes_per_second)`\n ", + "lensFormulaDocs.tinymath.base": "基数", + "lensFormulaDocs.boolean": "ブール", + "lensFormulaDocs.tinymath.condition": "条件", + "lensFormulaDocs.tinymath.decimals": "小数点以下", + "lensFormulaDocs.tinymath.defaultValue": "デフォルト", + "lensFormulaDocs.tinymath.left": "左", + "lensFormulaDocs.tinymath.max": "最高", + "lensFormulaDocs.tinymath.min": "分", + "lensFormulaDocs.number": "数字", + "lensFormulaDocs.tinymath.right": "右", + "lensFormulaDocs.string": "文字列", + "lensFormulaDocs.tinymath.value": "値", + "lensFormulaDocs.CommonFormulaDocumentation": "最も一般的な式は2つの値を分割して割合を生成します。正確に表示するには、[値形式]を[割合]に設定します。", + "lensFormulaDocs.documentation.columnCalculationSection": "列計算", + "lensFormulaDocs.documentation.columnCalculationSectionDescription": "各行でこれらの関数が実行されますが、コンテキストとして列全体が提供されます。これはウィンドウ関数とも呼ばれます。", + "lensFormulaDocs.documentation.comparisonSection": "比較", + "lensFormulaDocs.documentation.comparisonSectionDescription": "これらの関数は値を比較するために使用されます。", + "lensFormulaDocs.documentation.constantsSection": "Kibanaコンテキスト", + "lensFormulaDocs.documentation.constantsSectionDescription": "これらの関数は、Kibanaのコンテキスト変数(日付ヒストグラムの「interval」、現在の「now」、選択した「time_range」)を取得するために使用され、日付の計算処理を行うのに役立ちます。", + "lensFormulaDocs.documentation.elasticsearchSection": "Elasticsearch", + "lensFormulaDocs.documentation.elasticsearchSectionDescription": "これらの関数は結果テーブルの各行の未加工ドキュメントで実行され、内訳ディメンションと一致するすべてのドキュメントを単一の値に集約します。", + "lensFormulaDocs.documentation.filterRatio": "フィルター比率", + "lensFormulaDocs.documentation.mathSection": "数学処理", + "lensFormulaDocs.documentation.mathSectionDescription": "これらの関数は、他の関数で計算された同じ行の単一の値を使用して、結果テーブルの各行で実行されます。", + "lensFormulaDocs.documentation.percentOfTotal": "合計の割合", + "lensFormulaDocs.documentation.recentChange": "最近の変更", + "lensFormulaDocs.documentation.weekOverWeek": "週単位", + "lensFormulaDocs.frequentlyUsedHeading": "一般的な式", + "lensFormulaDocs.avg": "平均", + "lensFormulaDocs.cardinality": "ユニークカウント", + "lensFormulaDocs.cardinality.signature": "フィールド:文字列", + "lensFormulaDocs.count": "カウント", + "lensFormulaDocs.count.signature": "[field: string]", + "lensFormulaDocs.counterRate": "カウンターレート", + "lensFormulaDocs.counterRate.signature": "メトリック:数値", + "lensFormulaDocs.cumulative_sum.signature": "メトリック:数値", + "lensFormulaDocs.cumulativeSum": "累積和", + "lensFormulaDocs.derivative": "差異", + "lensFormulaDocs.differences.signature": "メトリック:数値", + "lensFormulaDocs.lastValue": "最終値", + "lensFormulaDocs.lastValue.signature": "フィールド:文字列", + "lensFormulaDocs.max": "最高", + "lensFormulaDocs.median": "中央", + "lensFormulaDocs.metric.signature": "フィールド:文字列", + "lensFormulaDocs.min": "最低", + "lensFormulaDocs.moving_average.signature": "メトリック:数値、[window]:数値", + "lensFormulaDocs.movingAverage": "移動平均", + "lensFormulaDocs.overall_metric": "メトリック:数値", + "lensFormulaDocs.overallMax": "全体最高", + "lensFormulaDocs.overallMin": "全体最低", + "lensFormulaDocs.overallSum": "全体合計", + "lensFormulaDocs.percentile": "パーセンタイル", + "lensFormulaDocs.percentile.signature": "フィールド:文字列、[percentile]:数値", + "lensFormulaDocs.percentileRank": "パーセンタイル順位", + "lensFormulaDocs.percentileRanks.signature": "フィールド: 文字列, [value]: 数値", + "lensFormulaDocs.standardDeviation": "標準偏差", + "lensFormulaDocs.sum": "合計", + "lensFormulaDocs.time_scale": "メトリック:数値、単位:s|m|h|d|w|M|y", + "lensFormulaDocs.timeScale": "単位で正規化", "xpack.licenseApiGuard.license.errorExpiredMessage": "{licenseType} ライセンスが期限切れのため {pluginName} を使用できません。", "xpack.licenseApiGuard.license.errorUnavailableMessage": "現在ライセンス情報が利用できないため {pluginName} を使用できません。", "xpack.licenseApiGuard.license.errorUnsupportedMessage": "ご使用の {licenseType} ライセンスは {pluginName} をサポートしていません。ライセンスをアップグレードしてください。", @@ -29496,46 +29499,6 @@ "xpack.observabilityAiAssistant.setupKb": "ナレッジベースを設定することで、エクスペリエンスが改善されます。", "xpack.observabilityAiAssistant.stopGeneratingButtonLabel": "生成を停止", "xpack.observabilityAiAssistant.technicalPreviewBadgeDescription": "関数呼び出し(根本原因分析やデータの視覚化など)を使用する際に、より一貫性のあるエクスペリエンスを実現するために、GPT4が必要です。GPT3.5は、エラーの説明などのシンプルなワークフローの一部や、頻繁な関数呼び出しの使用が必要とされないKibana内のエクスペリエンスなどのChatGPTで機能します。", - "xpack.observabilityShared.inspector.stats.queryTimeValue": "{queryTime}ms", - "xpack.observabilityShared.breadcrumbs.observabilityLinkText": "Observability", - "xpack.observabilityShared.inspector.stats.dataViewDescription": "Elasticsearchインデックスに接続したデータビューです。", - "xpack.observabilityShared.inspector.stats.dataViewLabel": "データビュー", - "xpack.observabilityShared.inspector.stats.hitsDescription": "クエリにより返されたドキュメントの数です。", - "xpack.observabilityShared.inspector.stats.hitsLabel": "ヒット数", - "xpack.observabilityShared.inspector.stats.hitsTotalDescription": "クエリに一致するドキュメントの数です。", - "xpack.observabilityShared.inspector.stats.hitsTotalLabel": "ヒット数(合計)", - "xpack.observabilityShared.inspector.stats.kibanaApiQueryParametersDescription": "Elasticsearch要求を開始したKibana API要求で使用されているクエリパラメーター。", - "xpack.observabilityShared.inspector.stats.kibanaApiQueryParametersLabel": "Kibana APIクエリパラメーター", - "xpack.observabilityShared.inspector.stats.kibanaApiRouteDescription": "Elasticsearch要求を開始したKibana API要求のルート。", - "xpack.observabilityShared.inspector.stats.kibanaApiRouteLabel": "Kibana APIルート", - "xpack.observabilityShared.inspector.stats.queryTimeDescription": "クエリの処理の所要時間です。リクエストの送信やブラウザーでのパースの時間は含まれません。", - "xpack.observabilityShared.inspector.stats.queryTimeLabel": "クエリ時間", - "xpack.observabilityShared.navigation.betaBadge": "ベータ", - "xpack.observabilityShared.navigation.experimentalBadgeLabel": "テクニカルプレビュー", - "xpack.observabilityShared.navigation.newBadge": "新規", - "xpack.observabilityShared.pageLayout.sideNavTitle": "Observability", - "xpack.observabilityShared.sectionLink.newLabel": "新規", - "xpack.observabilityShared.technicalPreviewBadgeDescription": "この機能はテクニカルプレビュー中であり、将来のリリースでは変更されたり完全に削除されたりする場合があります。Elasticは最善の努力を講じてすべての問題の修正に努めますが、テクニカルプレビュー中の機能には正式なGA機能のサポートSLAが適用されません。", - "xpack.observabilityShared.technicalPreviewBadgeLabel": "テクニカルプレビュー", - "xpack.observabilityShared.tour.alertsStep.imageAltText": "アラートデモ", - "xpack.observabilityShared.tour.alertsStep.tourContent": "電子メール、PagerDuty、Slackなどのサードパーティプラットフォーム統合でアラートをトリガーする条件を定義して検出します。", - "xpack.observabilityShared.tour.alertsStep.tourTitle": "変更が発生したときに通知", - "xpack.observabilityShared.tour.endButtonLabel": "ツアーを終了", - "xpack.observabilityShared.tour.guidedSetupStep.tourContent": "Elasticオブザーバビリティに進む最も簡単な方法は、データアシスタントで推奨された次のステップに従うことです。", - "xpack.observabilityShared.tour.guidedSetupStep.tourTitle": "Elasticオブザーバビリティのその他の機能", - "xpack.observabilityShared.tour.metricsExplorerStep.imageAltText": "メトリックエクスプローラーのデモ", - "xpack.observabilityShared.tour.metricsExplorerStep.tourContent": "システム、クラウド、ネットワーク、その他のインフラストラクチャーソースからメトリックをストリーム、グループ化、可視化します。", - "xpack.observabilityShared.tour.metricsExplorerStep.tourTitle": "インフラストラクチャーの正常性を監視", - "xpack.observabilityShared.tour.nextButtonLabel": "次へ", - "xpack.observabilityShared.tour.observabilityOverviewStep.tourContent": "クイックガイドを表示し、オブザーバビリティデータすべてを1つのスタックに格納する利点をご覧ください。", - "xpack.observabilityShared.tour.observabilityOverviewStep.tourTitle": "Elasticオブザーバビリティへようこそ", - "xpack.observabilityShared.tour.servicesStep.imageAltText": "サービスのデモ", - "xpack.observabilityShared.tour.servicesStep.tourContent": "サービスに関する詳細情報を収集し、パフォーマンスの問題をすばやく検出、修正できます。", - "xpack.observabilityShared.tour.servicesStep.tourTitle": "アプリケーションの問題を特定して解決", - "xpack.observabilityShared.tour.skipButtonLabel": "ツアーをスキップ", - "xpack.observabilityShared.tour.streamStep.imageAltText": "ログストリームのデモ", - "xpack.observabilityShared.tour.streamStep.tourContent": "アプリケーション、サーバー、仮想マシン、コネクターからのログイベントを監視、フィルター、検査します。", - "xpack.observabilityShared.tour.streamStep.tourTitle": "リアルタイムでログを追跡", "xpack.osquery.action.missingPrivileges": "このページにアクセスするには、{osquery} Kibana権限について管理者に確認してください。", "xpack.osquery.agentPolicy.confirmModalCalloutDescription": "選択した{agentPolicyCount, plural, other {エージェントポリシー}}が一部のエージェントですでに使用されていることをFleetが検出しました。このアクションの結果として、Fleetはこの{agentPolicyCount, plural, other {エージェントポリシー}}を使用しているすべてのエージェントに更新をデプロイします。", "xpack.osquery.agentPolicy.confirmModalCalloutTitle": "{agentCount, plural, other {#個のエージェント}}が更新されます", @@ -35059,7 +35022,6 @@ "xpack.securitySolution.fleetIntegration.assets.name": "ホスト", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.description": "クラウドセキュリティのイベントフィルター。Elastic Defend統合によって作成。", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.name": "非インタラクティブセッション", - "xpack.securitySolution.flyout.button.timeline": "タイムライン", "xpack.securitySolution.flyout.entities.failRelatedHostsDescription": "関連するホストで検索を実行できませんでした", "xpack.securitySolution.flyout.entities.failRelatedUsersDescription": "関連するユーザーで検索を実行できませんでした", "xpack.securitySolution.flyout.isolateHost.isolateTitle": "ホストの分離", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e6a5247f6cd9b2..34f09ecbeadf1b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -572,7 +572,6 @@ "core.euiComboBoxOptionsList.delimiterMessage": "添加项目,使用 {delimiter} 分隔各个项", "core.euiComboBoxOptionsList.noMatchingOptions": "{searchValue} 不匹配任何选项", "core.euiComboBoxPill.removeSelection": "将 {children} 从此组中的选择移除", - "core.euiControlBar.customScreenReaderAnnouncement": "有称作 {landmarkHeading} 且页面级别控件位于文档结尾的新地区地标。", "core.euiDataGrid.ariaLabel": "{label};第 {page} 页,共 {pageCount} 页。", "core.euiDataGrid.ariaLabelledBy": "第 {page} 页,共 {pageCount} 页。", "core.euiDataGridCell.position": "{columnId}列 {col}行 {row}", @@ -587,13 +586,6 @@ "core.euiFilterButton.filterBadgeActiveAriaLabel": "{count} 个活动筛选", "core.euiFilterButton.filterBadgeAvailableAriaLabel": "{count} 个可用筛选", "core.euiMarkdownEditorFooter.supportedFileTypes": "支持的文件:{supportedFileTypes}", - "core.euiNotificationEventMessages.accordionAriaLabelButtonText": "+ {eventName} 的 {messagesLength} 条消息", - "core.euiNotificationEventMessages.accordionButtonText": "+ 另外 {messagesLength} 条", - "core.euiNotificationEventMeta.contextMenuButton": "{eventName} 的菜单", - "core.euiNotificationEventReadButton.markAsReadAria": "将 {eventName} 标记为已读", - "core.euiNotificationEventReadButton.markAsUnreadAria": "将 {eventName} 标记为未读", - "core.euiNotificationEventReadIcon.readAria": "{eventName} 已读", - "core.euiNotificationEventReadIcon.unreadAria": "{eventName} 未读", "core.euiPagination.firstRangeAriaLabel": "将跳过第 2 至 {lastPage} 页", "core.euiPagination.lastRangeAriaLabel": "将跳过第 {firstPage} 至 {lastPage} 页", "core.euiPagination.pageOfTotalCompressed": "{total} 的 {page}", @@ -741,8 +733,6 @@ "core.euiComboBoxOptionsList.loadingOptions": "正在加载选项", "core.euiComboBoxOptionsList.noAvailableOptions": "没有任何可用选项", "core.euiCommonlyUsedTimeRanges.legend": "常用", - "core.euiControlBar.screenReaderAnnouncement": "有页面级别控件位于文档结尾的新地区地标。", - "core.euiControlBar.screenReaderHeading": "页面级别控件", "core.euiDataGrid.screenReaderNotice": "单元格包含交互内容。", "core.euiDataGridCellActions.expandButtonTitle": "单击或按 Enter 键以便与单元格内容进行交互", "core.euiDataGridHeaderCell.actionsPopoverScreenReaderText": "要在列操作列表中导航,请按 Tab 键或向上和向下箭头键。", @@ -849,11 +839,6 @@ "core.euiMarkdownEditorToolbar.editor": "编辑器", "core.euiMarkdownEditorToolbar.previewMarkdown": "预览", "core.euiModal.closeModal": "关闭此模式窗口", - "core.euiNotificationEventMessages.accordionHideText": "隐藏", - "core.euiNotificationEventReadButton.markAsRead": "标记为已读", - "core.euiNotificationEventReadButton.markAsUnread": "标记为未读", - "core.euiNotificationEventReadIcon.read": "读取", - "core.euiNotificationEventReadIcon.unread": "未读", "core.euiPagination.collection": "收集", "core.euiPagination.fromEndLabel": "自末尾", "core.euiPagination.last": "最后一个", @@ -2389,12 +2374,8 @@ "discover.grid.flyout.documentNavigation": "文档导航", "discover.grid.flyout.toastColumnAdded": "已添加列“{columnName}”", "discover.grid.flyout.toastColumnRemoved": "已移除列“{columnName}”", - "discover.grid.tableRow.detailHeading": "已展开文档", "discover.grid.tableRow.textBasedDetailHeading": "已展开行", - "discover.grid.tableRow.viewSingleDocumentLinkTextSimple": "单个文档", "discover.grid.tableRow.viewSurroundingDocumentsHover": "检查在此文档之前和之后出现的文档。在周围文档视图中,仅已固定筛选仍处于活动状态。", - "discover.grid.tableRow.viewSurroundingDocumentsLinkTextSimple": "周围文档", - "discover.grid.tableRow.viewText": "视图:", "discover.helpMenu.appName": "Discover", "discover.inspectorRequestDataTitleDocuments": "文档", "discover.inspectorRequestDataTitleMoreDocuments": "更多文档", @@ -2473,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}", @@ -4895,6 +4879,33 @@ "lensFormulaDocs.percentileRanks.documentation.markdown": "\n返回小于某个值的值的百分比。例如,如果某个值大于或等于 95% 的观察值,则称它处于第 95 个百分位等级\n\n例如:获取小于 100 的值的百分比:\n`percentile_rank(bytes, value=100)`\n ", "lensFormulaDocs.standardDeviation.documentation.markdown": "\n返回字段的变量或差量数量。此函数仅适用于数字字段。\n\n#### 示例\n\n要获取价格的标准偏差,请使用 `standard_deviation(price)`。\n\n要获取来自英国的订单的价格方差,请使用 `square(standard_deviation(price, kql='location:UK'))`。\n ", "lensFormulaDocs.time_scale.documentation.markdown": "\n\n此高级函数用于将计数和总和标准化为特定时间间隔。它允许集成所存储的已标准化为特定时间间隔的指标。\n\n此函数只能在当前图表中使用了日期直方图函数时使用。\n\n例如:将已标准化指标与其他需要标准化的指标进行比较的比率。\n`normalize_by_unit(counter_rate(max(system.diskio.write.bytes)), unit='s') / last_value(apache.status.bytes_per_second)`\n ", + "lensFormulaDocs.tinymath.base": "底数", + "lensFormulaDocs.boolean": "布尔值", + "lensFormulaDocs.tinymath.condition": "条件", + "lensFormulaDocs.tinymath.decimals": "小数", + "lensFormulaDocs.tinymath.defaultValue": "默认值", + "lensFormulaDocs.tinymath.left": "左", + "lensFormulaDocs.tinymath.right": "右", + "lensFormulaDocs.tinymath.value": "值", + "lensFormulaDocs.documentation.filterRatio": "筛选比", + "lensFormulaDocs.documentation.mathSection": "数学", + "lensFormulaDocs.documentation.mathSectionDescription": "结果表的每行使用相同行中使用其他函数计算的单值执行这些函数。", + "lensFormulaDocs.documentation.percentOfTotal": "总计的百分比", + "lensFormulaDocs.documentation.recentChange": "最近更改", + "lensFormulaDocs.documentation.weekOverWeek": "周环比", + "lensFormulaDocs.frequentlyUsedHeading": "常用公式", + "lensFormulaDocs.cardinality.signature": "field: string", + "lensFormulaDocs.count.signature": "[字段:字符串]", + "lensFormulaDocs.counterRate.signature": "指标:数字", + "lensFormulaDocs.cumulative_sum.signature": "指标:数字", + "lensFormulaDocs.differences.signature": "指标:数字", + "lensFormulaDocs.lastValue.signature": "field: string", + "lensFormulaDocs.metric.signature": "field: string", + "lensFormulaDocs.moving_average.signature": "指标:数字,[window]:数字", + "lensFormulaDocs.overall_metric": "指标:数字", + "lensFormulaDocs.percentile.signature": "field: string, [percentile]: number", + "lensFormulaDocs.percentileRanks.signature": "字段:字符串,[值]:数字", + "lensFormulaDocs.time_scale": "指标:数字,单位:s|m|h|d|w|M|y", "links.contentManagement.saveModalTitle": "将 {contentId} 面板保存到库", "links.externalLink.editor.urlFormatError": "格式无效。示例:{exampleUrl}", "links.dashboardLink.description": "前往仪表板", @@ -5742,9 +5753,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 ", @@ -5878,9 +5886,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", @@ -6286,7 +6291,6 @@ "unifiedFieldList.fieldListSidebar.fieldsMobileButtonLabel": "字段", "unifiedFieldList.fieldListSidebar.flyoutBackIcon": "返回", "unifiedFieldList.fieldListSidebar.flyoutHeading": "字段列表", - "unifiedFieldList.fieldListSidebar.indexAndFieldsSectionAriaLabel": "索引和字段", "unifiedFieldList.fieldListSidebar.toggleSidebarLegend": "切换侧边栏", "unifiedFieldList.fieldNameSearch.filterByNameLabel": "搜索字段名称", "unifiedFieldList.fieldPopover.addExistsFilterLabel": "筛留存在的字段", @@ -6331,31 +6335,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}", @@ -12424,7 +12415,6 @@ "xpack.dataVisualizer.combinedFieldsForm.mappingsParseError": "解析映射时出错:{error}", "xpack.dataVisualizer.combinedFieldsForm.pipelineParseError": "解析管道时出错:{error}", "xpack.dataVisualizer.dataDrift.dataComparisonDistributionLabel": "{label} 分布", - "xpack.dataVisualizer.dataDrift.dataLabel": "{label} 数据", "xpack.dataVisualizer.dataDrift.distributionComparisonChartName": "{fieldName} 的{referenceLabel}和{comparisonLabel}数据的分布比较", "xpack.dataVisualizer.dataDrift.progress.loadingComparison": "正在加载 {fieldsCount} 个字段的比较数据。", "xpack.dataVisualizer.dataDrift.progress.loadingReference": "正在加载 {fieldsCount} 个字段的参考数据。", @@ -20232,7 +20222,6 @@ "xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel": "查看设置说明", "xpack.infra.homePage.settingsTabTitle": "设置", "xpack.infra.homePage.tellUsWhatYouThinkK8sLink": "告诉我们您的看法!(K8s)", - "xpack.observabilityShared.featureFeedbackButton.tellUsWhatYouThinkLink": "告诉我们您的看法!", "xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder": "搜索基础设施数据……(例如 host.name:host-1)", "xpack.infra.hostFlyout.explainProcessMessageTitle": "此进程是什么?", "xpack.infra.hosts.searchPlaceholder": "搜索主机(例如,cloud.provider:gcp AND system.load.1 > 0.5)", @@ -21026,6 +21015,47 @@ "xpack.infra.waffle.unableToSelectMetricErrorTitle": "无法选择指标选项或指标值。", "xpack.infra.waffleTime.autoRefreshButtonLabel": "自动刷新", "xpack.infra.waffleTime.stopRefreshingButtonLabel": "停止刷新", + "xpack.observabilityShared.featureFeedbackButton.tellUsWhatYouThinkLink": "告诉我们您的看法!", + "xpack.observabilityShared.inspector.stats.queryTimeValue": "{queryTime}ms", + "xpack.observabilityShared.breadcrumbs.observabilityLinkText": "Observability", + "xpack.observabilityShared.inspector.stats.dataViewDescription": "连接到 Elasticsearch 索引的数据视图。", + "xpack.observabilityShared.inspector.stats.dataViewLabel": "数据视图", + "xpack.observabilityShared.inspector.stats.hitsDescription": "查询返回的文档数目。", + "xpack.observabilityShared.inspector.stats.hitsLabel": "命中数", + "xpack.observabilityShared.inspector.stats.hitsTotalDescription": "与查询匹配的文档数目。", + "xpack.observabilityShared.inspector.stats.hitsTotalLabel": "命中数(总数)", + "xpack.observabilityShared.inspector.stats.kibanaApiQueryParametersDescription": "发起 Elasticsearch 请求的 Kibana API 请求中使用的查询参数。", + "xpack.observabilityShared.inspector.stats.kibanaApiQueryParametersLabel": "Kibana API 查询参数", + "xpack.observabilityShared.inspector.stats.kibanaApiRouteDescription": "发起 Elasticsearch 请求的 Kibana API 请求的路由。", + "xpack.observabilityShared.inspector.stats.kibanaApiRouteLabel": "Kibana API 路由", + "xpack.observabilityShared.inspector.stats.queryTimeDescription": "处理查询所花费的时间。不包括发送请求或在浏览器中解析它的时间。", + "xpack.observabilityShared.inspector.stats.queryTimeLabel": "查询时间", + "xpack.observabilityShared.navigation.betaBadge": "公测版", + "xpack.observabilityShared.navigation.experimentalBadgeLabel": "技术预览", + "xpack.observabilityShared.navigation.newBadge": "新建", + "xpack.observabilityShared.pageLayout.sideNavTitle": "Observability", + "xpack.observabilityShared.sectionLink.newLabel": "新建", + "xpack.observabilityShared.technicalPreviewBadgeDescription": "此功能处于技术预览状态,在未来版本中可能会更改或完全移除。Elastic 将尽最大努力来修复任何问题,但处于技术预览状态的功能不受正式 GA 功能支持 SLA 的约束。", + "xpack.observabilityShared.technicalPreviewBadgeLabel": "技术预览", + "xpack.observabilityShared.tour.alertsStep.imageAltText": "告警演示", + "xpack.observabilityShared.tour.alertsStep.tourContent": "通过电子邮件、PagerDuty 和 Slack 等第三方平台集成定义并检测触发告警的条件。", + "xpack.observabilityShared.tour.alertsStep.tourTitle": "发生更改时接收通知", + "xpack.observabilityShared.tour.endButtonLabel": "结束教程", + "xpack.observabilityShared.tour.guidedSetupStep.tourContent": "继续使用 Elastic Observability 的最简便方法,是按照数据助手中推荐的后续步骤操作。", + "xpack.observabilityShared.tour.guidedSetupStep.tourTitle": "Elastic Observability 让您事半功倍", + "xpack.observabilityShared.tour.metricsExplorerStep.imageAltText": "指标浏览器演示", + "xpack.observabilityShared.tour.metricsExplorerStep.tourContent": "流式传输、分组并可视化您的系统、云、网络和其他基础架构源中的指标。", + "xpack.observabilityShared.tour.metricsExplorerStep.tourTitle": "监测基础架构运行状况", + "xpack.observabilityShared.tour.nextButtonLabel": "下一步", + "xpack.observabilityShared.tour.observabilityOverviewStep.tourContent": "学习快速教程以了解在一个堆栈中保存所有 Observability 数据的优势。", + "xpack.observabilityShared.tour.observabilityOverviewStep.tourTitle": "欢迎使用 Elastic Observability", + "xpack.observabilityShared.tour.servicesStep.imageAltText": "服务演示", + "xpack.observabilityShared.tour.servicesStep.tourContent": "通过收集有关服务的详细信息快速查找并修复性能问题。", + "xpack.observabilityShared.tour.servicesStep.tourTitle": "确定并解决应用程序问题", + "xpack.observabilityShared.tour.skipButtonLabel": "跳过教程", + "xpack.observabilityShared.tour.streamStep.imageAltText": "日志流演示", + "xpack.observabilityShared.tour.streamStep.tourContent": "监测、筛选并检查从您的应用程序、服务器、虚拟机和容器中流入的日志事件。", + "xpack.observabilityShared.tour.streamStep.tourTitle": "实时跟踪您的日志", "xpack.metricsData.assetDetails.formulas.cpuUsage": "CPU 使用率", "xpack.metricsData.assetDetails.formulas.cpuUsage.iowaitLabel": "iowait", "xpack.metricsData.assetDetails.formulas.cpuUsage.irqLabel": "irq", @@ -22172,11 +22202,6 @@ "xpack.lens.fittingFunctionsTitle.lookahead": "下一个", "xpack.lens.fittingFunctionsTitle.none": "隐藏", "xpack.lens.fittingFunctionsTitle.zero": "零", - "lensFormulaDocs.tinymath.base": "底数", - "lensFormulaDocs.boolean": "布尔值", - "lensFormulaDocs.tinymath.condition": "条件", - "lensFormulaDocs.tinymath.decimals": "小数", - "lensFormulaDocs.tinymath.defaultValue": "默认值", "xpack.lens.formula.disableWordWrapLabel": "禁用自动换行", "xpack.lens.formula.editorHelpInlineHideLabel": "隐藏函数引用", "xpack.lens.formula.editorHelpInlineHideToolTip": "隐藏函数引用", @@ -22184,22 +22209,12 @@ "xpack.lens.formula.fullScreenEnterLabel": "展开", "xpack.lens.formula.fullScreenExitLabel": "折叠", "xpack.lens.formula.kqlExtraArguments": "[kql]?: string, [lucene]?: string", - "lensFormulaDocs.tinymath.left": "左", "xpack.lens.formula.reducedTimeRangeExtraArguments": "[reducedTimeRange]?: 字符串", "xpack.lens.formula.requiredArgument": "必需", - "lensFormulaDocs.tinymath.right": "右", "xpack.lens.formula.shiftExtraArguments": "[shift]?: string", - "lensFormulaDocs.tinymath.value": "值", - "lensFormulaDocs.documentation.filterRatio": "筛选比", - "lensFormulaDocs.documentation.mathSection": "数学", - "lensFormulaDocs.documentation.mathSectionDescription": "结果表的每行使用相同行中使用其他函数计算的单值执行这些函数。", - "lensFormulaDocs.documentation.percentOfTotal": "总计的百分比", - "lensFormulaDocs.documentation.recentChange": "最近更改", - "lensFormulaDocs.documentation.weekOverWeek": "周环比", "xpack.lens.formulaDocumentationHeading": "运作方式", "xpack.lens.formulaEnableWordWrapLabel": "启用自动换行", "xpack.lens.formulaExampleMarkdown": "示例", - "lensFormulaDocs.frequentlyUsedHeading": "常用公式", "xpack.lens.formulaPlaceholderText": "通过将函数与数学表达式组合来键入公式,如:", "xpack.lens.fullExtent.niceValues": "舍入到优先值", "xpack.lens.functions.collapse.args.byHelpText": "要作为分组依据的列 - 这些列将保持原样", @@ -22263,7 +22278,6 @@ "xpack.lens.indexPattern.bitsFormatLabel": "位 (1000)", "xpack.lens.indexPattern.bytesFormatLabel": "字节 (1024)", "xpack.lens.indexPattern.cardinality.documentation.quick": "\n指定数字、字符串、日期或布尔值字段的唯一值的数目。\n ", - "lensFormulaDocs.cardinality.signature": "field: string", "xpack.lens.indexPattern.changeDataViewTitle": "数据视图", "xpack.lens.indexPattern.chooseField": "字段", "xpack.lens.indexPattern.chooseFieldLabel": "要使用此函数,请选择字段。", @@ -22271,11 +22285,8 @@ "xpack.lens.indexPattern.columnFormatLabel": "值格式", "xpack.lens.indexPattern.compactLabel": "紧凑值", "xpack.lens.indexPattern.count.documentation.quick": "\n文档总数。提供字段时,将计算字段值的总数。将计数函数用于单个文档中具有多个值的字段时,将对所有值计数。\n ", - "lensFormulaDocs.count.signature": "[字段:字符串]", "xpack.lens.indexPattern.counterRate.documentation.quick": "\n 不断增长的时间序列指标一段时间的更改速率。\n ", - "lensFormulaDocs.counterRate.signature": "指标:数字", "xpack.lens.indexPattern.countOf": "记录计数", - "lensFormulaDocs.cumulative_sum.signature": "指标:数字", "xpack.lens.indexPattern.cumulativeSum.documentation.quick": "\n 随时间增长的所有值的总和。\n ", "xpack.lens.indexPattern.custom.externalDoc": "数字格式语法", "xpack.lens.indexPattern.custom.patternLabel": "格式", @@ -22306,7 +22317,6 @@ "xpack.lens.indexPattern.decimalPlacesLabel": "小数", "xpack.lens.indexPattern.defaultFormatLabel": "默认", "xpack.lens.indexPattern.differences.documentation.quick": "\n 后续时间间隔中的值之间的更改情况。\n ", - "lensFormulaDocs.differences.signature": "指标:数字", "xpack.lens.indexPattern.dimensionEditor.headingAppearance": "外观", "xpack.lens.indexPattern.dimensionEditor.headingData": "数据", "xpack.lens.indexPattern.dimensionEditor.headingFormula": "公式", @@ -22364,7 +22374,6 @@ "xpack.lens.indexPattern.lastValue.showArrayValues": "显示数组值", "xpack.lens.indexPattern.lastValue.showArrayValuesExplanation": "显示与最后每个文档中的此字段关联的所有值。", "xpack.lens.indexPattern.lastValue.showArrayValuesWithTopValuesWarning": "显示数组值时,无法使用此字段对排名最前值排名。", - "lensFormulaDocs.lastValue.signature": "field: string", "xpack.lens.indexPattern.lastValue.sortField": "按日期字段排序", "xpack.lens.indexPattern.lastValue.sortFieldPlaceholder": "排序字段", "xpack.lens.indexPattern.max.description": "单值指标聚合,返回从聚合文档提取的数值中的最大值。", @@ -22372,12 +22381,10 @@ "xpack.lens.indexPattern.median.description": "单值指标聚合,计算从聚合文档提取的中值。", "xpack.lens.indexPattern.median.quickFunctionDescription": "数字字段的中值。", "xpack.lens.indexPattern.metaFieldsLabel": "元字段", - "lensFormulaDocs.metric.signature": "field: string", "xpack.lens.indexPattern.min.description": "单值指标聚合,返回从聚合文档提取的数值中的最小值。", "xpack.lens.indexPattern.min.quickFunctionDescription": "数字字段的最小值。", "xpack.lens.indexPattern.missingFieldLabel": "缺失字段", "xpack.lens.indexPattern.moveToWorkspaceNotAvailable": "要可视化此字段,请直接将其添加到所需图层。根据您当前的配置,不支持将此字段添加到工作区。", - "lensFormulaDocs.moving_average.signature": "指标:数字,[window]:数字", "xpack.lens.indexPattern.movingAverage.basicExplanation": "移动平均值在数据上滑动时间窗并显示平均值。仅日期直方图支持移动平均值。", "xpack.lens.indexPattern.movingAverage.documentation.quick": "\n 一段时间中移动窗口值的平均值。\n ", "xpack.lens.indexPattern.movingAverage.limitations": "第一个移动平均值开始于第二项。", @@ -22393,15 +22400,12 @@ "xpack.lens.indexPattern.noRealMetricError": "仅包含静态值的图层将不显示结果,请至少使用一个动态指标", "xpack.lens.indexPattern.notAbsoluteTimeShift": "时间偏移无效。", "xpack.lens.indexPattern.numberFormatLabel": "数字", - "lensFormulaDocs.overall_metric": "指标:数字", "xpack.lens.indexPattern.percentFormatLabel": "百分比", "xpack.lens.indexPattern.percentile.documentation.quick": "\n 小于所有文档中出现值的 n% 的最大值。\n ", "xpack.lens.indexPattern.percentile.percentileRanksValue": "百分位等级值", "xpack.lens.indexPattern.percentile.percentileValue": "百分位数", - "lensFormulaDocs.percentile.signature": "field: string, [percentile]: number", "xpack.lens.indexPattern.percentileRanks.documentation.quick": "\n小于特定值的值的百分比。例如,如果某个值大于或等于 95% 的计算值,则该值处于第 95 个百分位等级。\n ", "xpack.lens.indexPattern.percentileRanks.errorMessage": "百分位等级值必须为数字", - "lensFormulaDocs.percentileRanks.signature": "字段:字符串,[值]:数字", "xpack.lens.indexPattern.precisionErrorWarning.accuracyDisabled.shortMessage": "这可能为近似值。要获得更精确的结果,可以启用准确性模式,但这会增加 Elasticsearch 集群的负载。", "xpack.lens.indexPattern.precisionErrorWarning.accuracyEnabled.shortMessage": "这可能为近似值。要获得更精确的结果,请使用筛选或增加排名最前值的数量。", "xpack.lens.indexPattern.precisionErrorWarning.ascendingCountPrecisionErrorWarning.shortMessage": "这可能为近似值,具体取决于如何索引数据。要获得更精确的结果,请按稀有度排序。", @@ -22497,7 +22501,6 @@ "xpack.lens.indexPattern.terms.size": "值数目", "xpack.lens.indexPattern.termsWithMultipleShifts": "在单个图层中,无法将指标与不同时间偏移和动态排名最前值组合。将相同的时间偏移值用于所有指标或使用筛选,而非排名最前值。", "xpack.lens.indexPattern.termsWithMultipleShiftsFixActionLabel": "使用筛选", - "lensFormulaDocs.time_scale": "指标:数字,单位:s|m|h|d|w|M|y", "xpack.lens.indexPattern.timeScale.label": "按单位标准化", "xpack.lens.indexPattern.timeScale.missingUnit": "没有为按单位标准化指定单位。", "xpack.lens.indexPattern.timeScale.tooltip": "将值标准化为始终显示为每指定时间单位速率,无论基础日期时间间隔是多少。", @@ -29480,46 +29483,6 @@ "xpack.observabilityAiAssistant.setupKb": "通过设置知识库来改进体验。", "xpack.observabilityAiAssistant.stopGeneratingButtonLabel": "停止生成", "xpack.observabilityAiAssistant.technicalPreviewBadgeDescription": "需要 GPT4 以在使用函数调用时(例如,执行根本原因分析、数据可视化等时候)获得更加一致的体验。GPT3.5 可作用于某些更简单的工作流(如解释错误),或在 Kibana 中获得不需要频繁使用函数调用的与 ChatGPT 类似的体验。", - "xpack.observabilityShared.inspector.stats.queryTimeValue": "{queryTime}ms", - "xpack.observabilityShared.breadcrumbs.observabilityLinkText": "Observability", - "xpack.observabilityShared.inspector.stats.dataViewDescription": "连接到 Elasticsearch 索引的数据视图。", - "xpack.observabilityShared.inspector.stats.dataViewLabel": "数据视图", - "xpack.observabilityShared.inspector.stats.hitsDescription": "查询返回的文档数目。", - "xpack.observabilityShared.inspector.stats.hitsLabel": "命中数", - "xpack.observabilityShared.inspector.stats.hitsTotalDescription": "与查询匹配的文档数目。", - "xpack.observabilityShared.inspector.stats.hitsTotalLabel": "命中数(总数)", - "xpack.observabilityShared.inspector.stats.kibanaApiQueryParametersDescription": "发起 Elasticsearch 请求的 Kibana API 请求中使用的查询参数。", - "xpack.observabilityShared.inspector.stats.kibanaApiQueryParametersLabel": "Kibana API 查询参数", - "xpack.observabilityShared.inspector.stats.kibanaApiRouteDescription": "发起 Elasticsearch 请求的 Kibana API 请求的路由。", - "xpack.observabilityShared.inspector.stats.kibanaApiRouteLabel": "Kibana API 路由", - "xpack.observabilityShared.inspector.stats.queryTimeDescription": "处理查询所花费的时间。不包括发送请求或在浏览器中解析它的时间。", - "xpack.observabilityShared.inspector.stats.queryTimeLabel": "查询时间", - "xpack.observabilityShared.navigation.betaBadge": "公测版", - "xpack.observabilityShared.navigation.experimentalBadgeLabel": "技术预览", - "xpack.observabilityShared.navigation.newBadge": "新建", - "xpack.observabilityShared.pageLayout.sideNavTitle": "Observability", - "xpack.observabilityShared.sectionLink.newLabel": "新建", - "xpack.observabilityShared.technicalPreviewBadgeDescription": "此功能处于技术预览状态,在未来版本中可能会更改或完全移除。Elastic 将尽最大努力来修复任何问题,但处于技术预览状态的功能不受正式 GA 功能支持 SLA 的约束。", - "xpack.observabilityShared.technicalPreviewBadgeLabel": "技术预览", - "xpack.observabilityShared.tour.alertsStep.imageAltText": "告警演示", - "xpack.observabilityShared.tour.alertsStep.tourContent": "通过电子邮件、PagerDuty 和 Slack 等第三方平台集成定义并检测触发告警的条件。", - "xpack.observabilityShared.tour.alertsStep.tourTitle": "发生更改时接收通知", - "xpack.observabilityShared.tour.endButtonLabel": "结束教程", - "xpack.observabilityShared.tour.guidedSetupStep.tourContent": "继续使用 Elastic Observability 的最简便方法,是按照数据助手中推荐的后续步骤操作。", - "xpack.observabilityShared.tour.guidedSetupStep.tourTitle": "Elastic Observability 让您事半功倍", - "xpack.observabilityShared.tour.metricsExplorerStep.imageAltText": "指标浏览器演示", - "xpack.observabilityShared.tour.metricsExplorerStep.tourContent": "流式传输、分组并可视化您的系统、云、网络和其他基础架构源中的指标。", - "xpack.observabilityShared.tour.metricsExplorerStep.tourTitle": "监测基础架构运行状况", - "xpack.observabilityShared.tour.nextButtonLabel": "下一步", - "xpack.observabilityShared.tour.observabilityOverviewStep.tourContent": "学习快速教程以了解在一个堆栈中保存所有 Observability 数据的优势。", - "xpack.observabilityShared.tour.observabilityOverviewStep.tourTitle": "欢迎使用 Elastic Observability", - "xpack.observabilityShared.tour.servicesStep.imageAltText": "服务演示", - "xpack.observabilityShared.tour.servicesStep.tourContent": "通过收集有关服务的详细信息快速查找并修复性能问题。", - "xpack.observabilityShared.tour.servicesStep.tourTitle": "确定并解决应用程序问题", - "xpack.observabilityShared.tour.skipButtonLabel": "跳过教程", - "xpack.observabilityShared.tour.streamStep.imageAltText": "日志流演示", - "xpack.observabilityShared.tour.streamStep.tourContent": "监测、筛选并检查从您的应用程序、服务器、虚拟机和容器中流入的日志事件。", - "xpack.observabilityShared.tour.streamStep.tourTitle": "实时跟踪您的日志", "xpack.osquery.action.missingPrivileges": "要访问此页面,请联系管理员获取 {osquery} Kibana 权限。", "xpack.osquery.agentPolicy.confirmModalCalloutDescription": "Fleet 检测到您的部分代理已在使用选定{agentPolicyCount, plural, other {代理策略}}。由于此操作,Fleet 会将更新部署到使用此{agentPolicyCount, plural, other {代理策略}}的所有代理。", "xpack.osquery.agentPolicy.confirmModalCalloutTitle": "此操作将更新 {agentCount, plural, other {# 个代理}}", @@ -35041,7 +35004,6 @@ "xpack.securitySolution.fleetIntegration.assets.name": "主机", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.description": "云安全事件筛选。已由 Elastic Defend 集成创建。", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.name": "非交互式会话", - "xpack.securitySolution.flyout.button.timeline": "时间线", "xpack.securitySolution.flyout.entities.failRelatedHostsDescription": "无法对相关主机执行搜索", "xpack.securitySolution.flyout.entities.failRelatedUsersDescription": "无法对相关用户执行搜索", "xpack.securitySolution.flyout.isolateHost.isolateTitle": "隔离主机", diff --git a/x-pack/plugins/triggers_actions_ui/kibana.jsonc b/x-pack/plugins/triggers_actions_ui/kibana.jsonc index fbb5ac5a8af447..7ee23fc3ede9ef 100644 --- a/x-pack/plugins/triggers_actions_ui/kibana.jsonc +++ b/x-pack/plugins/triggers_actions_ui/kibana.jsonc @@ -16,7 +16,6 @@ "data", "kibanaReact", "kibanaUtils", - "savedObjects", "unifiedSearch", "fieldFormats", "dataViews", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_event_logs.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_event_logs.ts index 627b1b2f9a6a6e..409be866b730b6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_event_logs.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_event_logs.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import datemath from '@kbn/datemath'; import { useKibana } from '../../common/lib/kibana'; @@ -37,7 +37,6 @@ const isGlobal = (props: UseLoadRuleEventLogsProps): props is LoadGlobalExecutio export function useLoadRuleEventLogs(props: UseLoadRuleEventLogsProps) { const { http } = useKibana().services; - const queryFn = useCallback(() => { if (isGlobal(props)) { return loadGlobalExecutionLogAggregations({ @@ -55,16 +54,21 @@ export function useLoadRuleEventLogs(props: UseLoadRuleEventLogsProps) { }); }, [props, http]); - const { data, isLoading, isFetching, refetch } = useQuery({ + const { data, error, isLoading, isFetching, refetch } = useQuery({ queryKey: ['loadRuleEventLog', props], queryFn, onError: props.onError, + retry: 0, refetchOnWindowFocus: false, }); - - return { - data, - isLoading: isLoading || isFetching, - loadEventLogs: refetch, - }; + const hasExceedLogs = useMemo(() => error && error.body.statusCode === 413, [error]); + return useMemo( + () => ({ + data, + hasExceedLogs, + isLoading: isLoading || isFetching, + loadEventLogs: refetch, + }), + [data, hasExceedLogs, isFetching, isLoading, refetch] + ); } 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 5c3a33d5b17c85..32a6b1b85019b1 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/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_kpi.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_kpi.test.tsx index 718222636830a9..7b9398d37e4181 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_kpi.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_kpi.test.tsx @@ -12,7 +12,10 @@ import { loadExecutionKPIAggregations } from '../../../lib/rule_api/load_executi import { loadGlobalExecutionKPIAggregations } from '../../../lib/rule_api/load_global_execution_kpi_aggregations'; import { RuleEventLogListKPI } from './rule_event_log_list_kpi'; import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; +import { useKibana } from '../../../../common/lib'; +import { IToasts } from '@kbn/core/public'; +const addDangerMock = jest.fn(); jest.mock('../../../../common/lib/kibana', () => ({ useKibana: jest.fn().mockReturnValue({ services: { @@ -20,6 +23,7 @@ jest.mock('../../../../common/lib/kibana', () => ({ }, }), })); +const useKibanaMock = useKibana as jest.Mocked; jest.mock('../../../lib/rule_api/load_execution_kpi_aggregations', () => ({ loadExecutionKPIAggregations: jest.fn(), @@ -53,6 +57,9 @@ const loadGlobalExecutionKPIAggregationsMock = describe('rule_event_log_list_kpi', () => { beforeEach(() => { jest.clearAllMocks(); + useKibanaMock().services.notifications.toasts = { + addDanger: addDangerMock, + } as unknown as IToasts; (getIsExperimentalFeatureEnabled as jest.Mock).mockImplementation(() => false); loadExecutionKPIAggregationsMock.mockResolvedValue(mockKpiResponse); loadGlobalExecutionKPIAggregationsMock.mockResolvedValue(mockKpiResponse); @@ -226,4 +233,44 @@ describe('rule_event_log_list_kpi', () => { }) ); }); + + it('Should call addDanger function when an the API throw an error', async () => { + loadGlobalExecutionKPIAggregationsMock.mockRejectedValue({ body: { statusCode: 400 } }); + const wrapper = mountWithIntl( + + ); + // Let the load resolve + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(addDangerMock).toHaveBeenCalled(); + }); + + it('Should NOT call addDanger function when an the API throw a 413 error', async () => { + loadGlobalExecutionKPIAggregationsMock.mockRejectedValue({ body: { statusCode: 413 } }); + const wrapper = mountWithIntl( + + ); + // Let the load resolve + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(addDangerMock).not.toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_kpi.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_kpi.tsx index 63ef7cb5bbc22d..d8606a525d821b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_kpi.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_kpi.tsx @@ -109,6 +109,9 @@ export const RuleEventLogListKPI = (props: RuleEventLogListKPIProps) => { }); setKpi(newKpi); } catch (e) { + if (e.body.statusCode === 413) { + return; + } toasts.addDanger({ title: API_FAILED_MESSAGE, text: e.body?.message ?? e, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_table.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_table.test.tsx index ffcb8f69ee7c41..9c18d74a53fdad 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_table.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_table.test.tsx @@ -95,6 +95,7 @@ describe('rule_event_log_list_table', () => { useLoadRuleEventLogs.mockReturnValue({ data: mockLogResponse, isLoading: false, + hasExceedLogs: false, loadEventLogs: mockLoadEventLog, }); }); @@ -130,6 +131,7 @@ describe('rule_event_log_list_table', () => { useLoadRuleEventLogs.mockReturnValue({ data: [], isLoading: true, + hasExceedLogs: false, loadEventLogs: mockLoadEventLog, }); @@ -254,6 +256,7 @@ describe('rule_event_log_list_table', () => { total: 100, }, isLoading: true, + hasExceedLogs: false, loadEventLogs: mockLoadEventLog, }); }); @@ -306,6 +309,7 @@ describe('rule_event_log_list_table', () => { total: 0, }, isLoading: false, + hasExceedLogs: false, loadEventLogs: mockLoadEventLog, }); render(); @@ -324,6 +328,7 @@ describe('rule_event_log_list_table', () => { total: 1, }, isLoading: false, + hasExceedLogs: false, loadEventLogs: mockLoadEventLog, }); @@ -343,6 +348,7 @@ describe('rule_event_log_list_table', () => { total: 85, }, isLoading: false, + hasExceedLogs: false, loadEventLogs: mockLoadEventLog, }); @@ -368,6 +374,7 @@ describe('rule_event_log_list_table', () => { total: 85, }, isLoading: false, + hasExceedLogs: false, loadEventLogs: mockLoadEventLog, }); @@ -397,7 +404,7 @@ describe('rule_event_log_list_table', () => { outcomeFilter: [], page: 0, perPage: 10, - dateStart: 'now-24h', + dateStart: 'now-15m', dateEnd: 'now', }) ); @@ -460,6 +467,7 @@ describe('rule_event_log_list_table', () => { total: 1100, }, isLoading: false, + hasExceedLogs: false, loadEventLogs: mockLoadEventLog, }); }); @@ -485,6 +493,36 @@ describe('rule_event_log_list_table', () => { }); }); + describe('Show exceed document prompt', () => { + beforeEach(() => { + useLoadRuleEventLogs.mockReturnValue({ + data: { + data: [], + total: 11000, + }, + isLoading: false, + hasExceedLogs: true, + loadEventLogs: mockLoadEventLog, + }); + }); + + it('should show the exceed limit logs prompt normally', async () => { + render(); + + await waitFor(() => { + expect(screen.queryByTestId('exceedLimitLogsCallout')).toBeInTheDocument(); + }); + }); + + it('should hide the logs table', async () => { + render(); + + await waitFor(() => { + expect(screen.queryByTestId('eventLogList')).not.toBeInTheDocument(); + }); + }); + }); + it('renders errored action badges in message rows', async () => { useLoadRuleEventLogs.mockReturnValue({ data: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_table.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_table.tsx index 73eb9f2c5c5369..a7ddc9ff04fb38 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_table.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_event_log_list_table.tsx @@ -19,6 +19,7 @@ import { OnTimeChangeProps, EuiSwitch, EuiDataGridColumn, + EuiCallOut, } from '@elastic/eui'; import { IExecutionLog } from '@kbn/alerting-plugin/common'; import { useKibana } from '../../../../common/lib/kibana'; @@ -150,7 +151,7 @@ export const RuleEventLogListTable = ( }); // Date related states - const [dateStart, setDateStart] = useState('now-24h'); + const [dateStart, setDateStart] = useState('now-15m'); const [dateEnd, setDateEnd] = useState('now'); const [dateFormat] = useState(() => uiSettings?.get('dateFormat')); const [commonlyUsedRanges] = useState(() => { @@ -197,6 +198,9 @@ export const RuleEventLogListTable = ( const onError = useCallback( (e) => { + if (e.body.statusCode === 413) { + return; + } notifications.toasts.addDanger({ title: API_FAILED_MESSAGE, text: e.body?.message ?? e, @@ -205,7 +209,7 @@ export const RuleEventLogListTable = ( [notifications] ); - const { data, isLoading, loadEventLogs } = useLoadRuleEventLogs({ + const { data, isLoading, hasExceedLogs, loadEventLogs } = useLoadRuleEventLogs({ id: ruleId, sort: formattedSort as LoadExecutionLogAggregationsProps['sort'], outcomeFilter: filter, @@ -724,7 +728,19 @@ export const RuleEventLogListTable = ( - {renderList()} + {hasExceedLogs && ( + + } + data-test-subj="exceedLimitLogsCallout" + size="m" + /> + )} + {!hasExceedLogs && renderList()} {isOnLastPage && ( - - Up - + Up diff --git a/x-pack/plugins/uptime/public/legacy_uptime/components/overview/monitor_list/__snapshots__/status_filter.test.tsx.snap b/x-pack/plugins/uptime/public/legacy_uptime/components/overview/monitor_list/__snapshots__/status_filter.test.tsx.snap index 7048df79a12d3c..d25aecb486f62c 100644 --- a/x-pack/plugins/uptime/public/legacy_uptime/components/overview/monitor_list/__snapshots__/status_filter.test.tsx.snap +++ b/x-pack/plugins/uptime/public/legacy_uptime/components/overview/monitor_list/__snapshots__/status_filter.test.tsx.snap @@ -13,15 +13,11 @@ exports[`StatusFilterComponent renders without errors for valid props 1`] = ` class="euiButtonEmpty__content emotion-euiButtonDisplayContent-euiFilterButton__content" > - - All - + All @@ -34,15 +30,11 @@ exports[`StatusFilterComponent renders without errors for valid props 1`] = ` class="euiButtonEmpty__content emotion-euiButtonDisplayContent-euiFilterButton__content" > - - Up - + Up @@ -55,15 +47,11 @@ exports[`StatusFilterComponent renders without errors for valid props 1`] = ` class="euiButtonEmpty__content emotion-euiButtonDisplayContent-euiFilterButton__content" > - - Down - + Down diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/transform_rule_types/transform_health/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/transform_rule_types/transform_health/index.ts index adc93332a0f569..3cb706576efc3b 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/transform_rule_types/transform_health/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/transform_rule_types/transform_health/index.ts @@ -10,6 +10,6 @@ import { FtrProviderContext } from '../../../../../../common/ftr_provider_contex // eslint-disable-next-line import/no-default-export export default function alertingTests({ loadTestFile }: FtrProviderContext) { describe('transform_health', function () { - loadTestFile(require.resolve('./alert')); + loadTestFile(require.resolve('./rule')); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/transform_rule_types/transform_health/alert.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/transform_rule_types/transform_health/rule.ts similarity index 67% rename from x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/transform_rule_types/transform_health/alert.ts rename to x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/transform_rule_types/transform_health/rule.ts index 046ad43ef15149..24efdd26135915 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/transform_rule_types/transform_health/alert.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/transform_rule_types/transform_health/rule.ts @@ -8,19 +8,30 @@ import expect from '@kbn/expect'; import { PutTransformsRequestSchema } from '@kbn/transform-plugin/common/api_schemas/transforms'; import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers'; +import { + ALERT_ACTION_GROUP, + ALERT_INSTANCE_ID, + ALERT_REASON, + ALERT_RULE_CATEGORY, + ALERT_RULE_NAME, + ALERT_RULE_TYPE_ID, + ALERT_RULE_UUID, + ALERT_STATUS, + EVENT_ACTION, +} from '@kbn/rule-data-utils'; import { FtrProviderContext } from '../../../../../../common/ftr_provider_context'; import { getUrlPrefix, ObjectRemover } from '../../../../../../common/lib'; import { Spaces } from '../../../../../scenarios'; -const ACTION_TYPE_ID = '.index'; -const ALERT_TYPE_ID = 'transform_health'; +const CONNECTOR_TYPE_ID = '.index'; +const RULE_TYPE_ID = 'transform_health'; const ES_TEST_INDEX_SOURCE = 'transform-alert:transform-health'; const ES_TEST_INDEX_REFERENCE = '-na-'; const ES_TEST_OUTPUT_INDEX_NAME = `${ES_TEST_INDEX_NAME}-ts-output`; -const ALERT_INTERVAL_SECONDS = 3; +const RULE_INTERVAL_SECONDS = 3; -interface CreateAlertParams { +interface CreateRuleParams { name: string; includeTransforms: string[]; excludeTransforms?: string[] | null; @@ -52,7 +63,7 @@ export function generateTransformConfig(transformId: string): PutTransformsReque } // eslint-disable-next-line import/no-default-export -export default function alertTests({ getService }: FtrProviderContext) { +export default function ruleTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); const retry = getService('retry'); @@ -62,10 +73,15 @@ export default function alertTests({ getService }: FtrProviderContext) { const esTestIndexTool = new ESTestIndexTool(es, retry); const esTestIndexToolOutput = new ESTestIndexTool(es, retry, ES_TEST_OUTPUT_INDEX_NAME); + const esTestIndexToolAAD = new ESTestIndexTool( + es, + retry, + `.internal.alerts-default.alerts-default-000001` + ); - describe('alert', async () => { + describe('rule', async () => { const objectRemover = new ObjectRemover(supertest); - let actionId: string; + let connectorId: string; const transformId = 'test_transform_01'; const destinationIndex = generateDestIndex(transformId); @@ -76,10 +92,12 @@ export default function alertTests({ getService }: FtrProviderContext) { await esTestIndexToolOutput.destroy(); await esTestIndexToolOutput.setup(); + await esTestIndexToolAAD.removeAll(); + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); await transform.testResources.setKibanaTimeZoneToUTC(); - actionId = await createAction(); + connectorId = await createConnector(); await transform.api.createIndices(destinationIndex); await createTransform(transformId); @@ -89,18 +107,19 @@ export default function alertTests({ getService }: FtrProviderContext) { await objectRemover.removeAll(); await esTestIndexTool.destroy(); await esTestIndexToolOutput.destroy(); + await esTestIndexToolAAD.removeAll(); await transform.api.cleanTransformIndices(); }); it('runs correctly', async () => { - await createAlert({ + const ruleId = await createRule({ name: 'Test all transforms', includeTransforms: ['*'], }); await stopTransform(transformId); - log.debug('Checking created alert instances...'); + log.debug('Checking created alerts...'); const docs = await waitForDocs(1); for (const doc of docs) { @@ -109,6 +128,18 @@ export default function alertTests({ getService }: FtrProviderContext) { expect(name).to.be('Test all transforms'); expect(message).to.be('Transform test_transform_01 is not started.'); } + + const aadDocs = await getAllAADDocs(1); + const alertDoc = aadDocs.body.hits.hits[0]._source; + expect(alertDoc[ALERT_REASON]).to.be(`Transform test_transform_01 is not started.`); + expect(alertDoc[ALERT_RULE_CATEGORY]).to.be(`Transform health`); + expect(alertDoc[ALERT_RULE_NAME]).to.be(`Test all transforms`); + expect(alertDoc[ALERT_RULE_TYPE_ID]).to.be(`transform_health`); + expect(alertDoc[ALERT_RULE_UUID]).to.be(ruleId); + expect(alertDoc[EVENT_ACTION]).to.be(`open`); + expect(alertDoc[ALERT_ACTION_GROUP]).to.be(`transform_issue`); + expect(alertDoc[ALERT_INSTANCE_ID]).to.be(`Transform is not started`); + expect(alertDoc[ALERT_STATUS]).to.be(`active`); }); async function waitForDocs(count: number): Promise { @@ -119,15 +150,19 @@ export default function alertTests({ getService }: FtrProviderContext) { ); } + async function getAllAADDocs(count: number): Promise { + return await esTestIndexToolAAD.getAll(count); + } + async function createTransform(id: string) { const config = generateTransformConfig(id); await transform.api.createAndRunTransform(id, config); } - async function createAlert(params: CreateAlertParams): Promise { + async function createRule(params: CreateRuleParams): Promise { log.debug(`Creating an alerting rule "${params.name}"...`); const action = { - id: actionId, + id: connectorId, group: 'transform_issue', params: { documents: [ @@ -143,15 +178,15 @@ export default function alertTests({ getService }: FtrProviderContext) { }, }; - const { status, body: createdAlert } = await supertest + const { status, body: createdRule } = await supertest .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) .set('kbn-xsrf', 'foo') .send({ name: params.name, consumer: 'alerts', enabled: true, - rule_type_id: ALERT_TYPE_ID, - schedule: { interval: `${ALERT_INTERVAL_SECONDS}s` }, + rule_type_id: RULE_TYPE_ID, + schedule: { interval: `${RULE_INTERVAL_SECONDS}s` }, actions: [action], notify_when: 'onActiveAlert', params: { @@ -160,29 +195,29 @@ export default function alertTests({ getService }: FtrProviderContext) { }); // will print the error body, if an error occurred - // if (statusCode !== 200) console.log(createdAlert); + // if (statusCode !== 200) console.log(createdRule); expect(status).to.be(200); - const alertId = createdAlert.id; - objectRemover.add(Spaces.space1.id, alertId, 'rule', 'alerting'); + const ruleId = createdRule.id; + objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting'); - return alertId; + return ruleId; } async function stopTransform(id: string) { await transform.api.stopTransform(id); } - async function createAction(): Promise { - log.debug('Creating an action...'); + async function createConnector(): Promise { + log.debug('Creating a connector...'); // @ts-ignore - const { statusCode, body: createdAction } = await supertest + const { statusCode, body: createdConnector } = await supertest .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`) .set('kbn-xsrf', 'foo') .send({ name: 'index action for transform health FT', - connector_type_id: ACTION_TYPE_ID, + connector_type_id: CONNECTOR_TYPE_ID, config: { index: ES_TEST_OUTPUT_INDEX_NAME, }, @@ -191,9 +226,9 @@ export default function alertTests({ getService }: FtrProviderContext) { expect(statusCode).to.be(200); - log.debug(`Action with id "${createdAction.id}" has been created.`); + log.debug(`Connector with id "${createdConnector.id}" has been created.`); - const resultId = createdAction.id; + const resultId = createdConnector.id; objectRemover.add(Spaces.space1.id, resultId, 'connector', 'actions'); return resultId; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/cases/push_case.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/cases/push_case.ts index 67b501a784462b..2507700be6fef2 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/cases/push_case.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/cases/push_case.ts @@ -515,7 +515,8 @@ export default ({ getService }: FtrProviderContext): void => { }); }); - describe('user profile uid', () => { + // FLAKY: https://github.com/elastic/kibana/issues/157588 + describe.skip('user profile uid', () => { let headers: Record; let superUserWithProfile: User; let superUserInfo: User; diff --git a/x-pack/test/cloud_security_posture_api/config.ts b/x-pack/test/cloud_security_posture_api/config.ts index a1a0714a2d5a6c..bbee66741210ef 100644 --- a/x-pack/test/cloud_security_posture_api/config.ts +++ b/x-pack/test/cloud_security_posture_api/config.ts @@ -20,6 +20,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { require.resolve('./routes/stats.ts'), require.resolve('./routes/csp_benchmark_rules_bulk_update.ts'), require.resolve('./routes/csp_benchmark_rules_get_states.ts'), + require.resolve('./routes/benchmarks.ts'), ], junit: { reportName: 'X-Pack Cloud Security Posture API Tests', diff --git a/x-pack/test/cloud_security_posture_api/routes/benchmarks.ts b/x-pack/test/cloud_security_posture_api/routes/benchmarks.ts new file mode 100644 index 00000000000000..8f52358c331441 --- /dev/null +++ b/x-pack/test/cloud_security_posture_api/routes/benchmarks.ts @@ -0,0 +1,264 @@ +/* + * 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 { + ELASTIC_HTTP_VERSION_HEADER, + X_ELASTIC_INTERNAL_ORIGIN_REQUEST, +} from '@kbn/core-http-common'; +import { + BENCHMARK_SCORE_INDEX_DEFAULT_NS, + CSP_BENCHMARK_RULE_SAVED_OBJECT_TYPE, + LATEST_FINDINGS_INDEX_DEFAULT_NS, +} from '@kbn/cloud-security-posture-plugin/common/constants'; +import expect from '@kbn/expect'; +import Chance from 'chance'; +import { CspBenchmarkRule } from '@kbn/cloud-security-posture-plugin/common/types/latest'; +import { FtrProviderContext } from '../ftr_provider_context'; + +const chance = new Chance(); + +// eslint-disable-next-line import/no-default-export +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + + const retry = getService('retry'); + const es = getService('es'); + const kibanaServer = getService('kibanaServer'); + const supertest = getService('supertest'); + const log = getService('log'); + + const getCspBenchmarkRules = async (benchmarkId: string): Promise => { + const cspBenchmarkRules = await kibanaServer.savedObjects.find({ + type: CSP_BENCHMARK_RULE_SAVED_OBJECT_TYPE, + }); + const requestedBenchmarkRules = cspBenchmarkRules.saved_objects.filter( + (cspBenchmarkRule) => cspBenchmarkRule.attributes.metadata.benchmark.id === benchmarkId + ); + expect(requestedBenchmarkRules.length).greaterThan(0); + + return requestedBenchmarkRules.map((item) => item.attributes); + }; + + const getMockFinding = (rule: CspBenchmarkRule, evaluation: string) => ({ + '@timestamp': '2023-06-29T02:08:44.993Z', + resource: { + id: chance.guid(), + name: `kubelet`, + sub_type: 'lower case sub type', + type: 'k8s_resource_type', + }, + cloud: { + account: { id: 'Another Upper case account id' }, + }, + result: { evaluation }, + rule: { + name: 'Upper case rule name', + id: rule.metadata.id, + section: 'Upper case section', + benchmark: { + id: rule.metadata.benchmark.id, + posture_type: rule.metadata.benchmark.posture_type, + name: rule.metadata.benchmark.name, + version: rule.metadata.benchmark.version, + rule_number: rule.metadata.benchmark.rule_number, + }, + }, + orchestrator: { + cluster: { id: 'Upper case cluster id' }, + }, + }); + + /** + * required before indexing findings + */ + const waitForPluginInitialized = (): Promise => + retry.try(async () => { + log.debug('Check CSP plugin is initialized'); + const response = await supertest + .get('/internal/cloud_security_posture/status?check=init') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .expect(200); + expect(response.body).to.eql({ isPluginInitialized: true }); + log.debug('CSP plugin is initialized'); + }); + + const index = { + addFindings: async (findingsMock: T[]) => { + await Promise.all( + findingsMock.map((findingsDoc) => + es.index({ + index: LATEST_FINDINGS_INDEX_DEFAULT_NS, + body: { ...findingsDoc, '@timestamp': new Date().toISOString() }, + refresh: true, + }) + ) + ); + }, + + addScores: async (scoresMock: T[]) => { + await Promise.all( + scoresMock.map((scoreDoc) => + es.index({ + index: BENCHMARK_SCORE_INDEX_DEFAULT_NS, + body: { ...scoreDoc, '@timestamp': new Date().toISOString() }, + refresh: true, + }) + ) + ); + }, + + removeFindings: async () => { + const indexExists = await es.indices.exists({ index: LATEST_FINDINGS_INDEX_DEFAULT_NS }); + + if (indexExists) { + es.deleteByQuery({ + index: LATEST_FINDINGS_INDEX_DEFAULT_NS, + query: { match_all: {} }, + refresh: true, + }); + } + }, + + removeScores: async () => { + const indexExists = await es.indices.exists({ index: BENCHMARK_SCORE_INDEX_DEFAULT_NS }); + + if (indexExists) { + es.deleteByQuery({ + index: BENCHMARK_SCORE_INDEX_DEFAULT_NS, + query: { match_all: {} }, + refresh: true, + }); + } + }, + + deleteFindingsIndex: async () => { + const indexExists = await es.indices.exists({ index: LATEST_FINDINGS_INDEX_DEFAULT_NS }); + + if (indexExists) { + await es.indices.delete({ index: LATEST_FINDINGS_INDEX_DEFAULT_NS }); + } + }, + }; + + describe('GET /internal/cloud_security_posture/benchmarks', () => { + describe('Get Benchmark API', async () => { + beforeEach(async () => { + await index.removeFindings(); + await kibanaServer.savedObjects.clean({ + types: ['cloud-security-posture-settings'], + }); + await waitForPluginInitialized(); + }); + + it('Verify cspm benchmark score is updated when muting rules', async () => { + const benchmark = 'cis_aws'; + const benchmarkRules = await getCspBenchmarkRules(benchmark); + + const cspmFinding1 = getMockFinding(benchmarkRules[0], 'passed'); + const cspmFinding2 = getMockFinding(benchmarkRules[1], 'failed'); + + await index.addFindings([cspmFinding1, cspmFinding2]); + + const { body: benchmarksBeforeMute } = await supertest + .get('/internal/cloud_security_posture/benchmarks') + .set(ELASTIC_HTTP_VERSION_HEADER, '2') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .set('kbn-xsrf', 'xxxx') + .expect(200); + + const scoreBeforeMute = benchmarksBeforeMute.items.find( + (item: { id: string }) => item.id === benchmark + ); + + expect(scoreBeforeMute.score.postureScore).to.equal(50); + + await supertest + .post(`/internal/cloud_security_posture/rules/_bulk_action`) + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .set('kbn-xsrf', 'xxxx') + .send({ + action: 'mute', + rules: [ + { + benchmark_id: cspmFinding2.rule.benchmark.id, + benchmark_version: cspmFinding2.rule.benchmark.version, + rule_number: cspmFinding2.rule.benchmark.rule_number || '', + rule_id: cspmFinding2.rule.id, + }, + ], + }) + .expect(200); + + const { body: benchmarksAfterMute } = await supertest + .get('/internal/cloud_security_posture/benchmarks') + .set(ELASTIC_HTTP_VERSION_HEADER, '2') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .set('kbn-xsrf', 'xxxx') + .expect(200); + + const scoreAfterMute = benchmarksAfterMute.items.find( + (item: { id: string }) => item.id === benchmark + ); + + expect(scoreAfterMute.score.postureScore).to.equal(100); + }); + + it('Verify kspm benchmark score is updated when muting rules', async () => { + const benchmark = 'cis_k8s'; + const benchmarkRules = await getCspBenchmarkRules(benchmark); + + const kspmFinding1 = getMockFinding(benchmarkRules[0], 'passed'); + const kspmFinding2 = getMockFinding(benchmarkRules[1], 'failed'); + + await index.addFindings([kspmFinding1, kspmFinding2]); + const { body: benchmarksBeforeMute } = await supertest + .get('/internal/cloud_security_posture/benchmarks') + .set(ELASTIC_HTTP_VERSION_HEADER, '2') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .set('kbn-xsrf', 'xxxx') + .expect(200); + + const scoreBeforeMute = benchmarksBeforeMute.items.find( + (item: { id: string }) => item.id === benchmark + ); + + expect(scoreBeforeMute.score.postureScore).to.equal(50); + + await supertest + .post(`/internal/cloud_security_posture/rules/_bulk_action`) + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .set('kbn-xsrf', 'xxxx') + .send({ + action: 'mute', + rules: [ + { + benchmark_id: kspmFinding2.rule.benchmark.id, + benchmark_version: kspmFinding2.rule.benchmark.version, + rule_number: kspmFinding2.rule.benchmark.rule_number || '', + rule_id: kspmFinding2.rule.id, + }, + ], + }) + .expect(200); + + const { body: benchmarksAfterMute } = await supertest + .get('/internal/cloud_security_posture/benchmarks') + .set(ELASTIC_HTTP_VERSION_HEADER, '2') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .set('kbn-xsrf', 'xxxx') + .expect(200); + + const scoreAfterMute = benchmarksAfterMute.items.find( + (item: { id: string }) => item.id === benchmark + ); + + expect(scoreAfterMute.score.postureScore).to.equal(100); + }); + }); + }); +} diff --git a/x-pack/test/cloud_security_posture_api/routes/csp_benchmark_rules_bulk_update.ts b/x-pack/test/cloud_security_posture_api/routes/csp_benchmark_rules_bulk_update.ts index 2c7d0ce250b020..de77b33a66df73 100644 --- a/x-pack/test/cloud_security_posture_api/routes/csp_benchmark_rules_bulk_update.ts +++ b/x-pack/test/cloud_security_posture_api/routes/csp_benchmark_rules_bulk_update.ts @@ -371,6 +371,60 @@ export default function ({ getService }: FtrProviderContext) { expectExpect(body.disabled_detection_rules).toEqual([detectionRule.body.id]); }); + it('Expect to save rules states when requesting to update empty object', async () => { + const rule1 = await getRandomCspBenchmarkRule(); + + await supertest + .post(`/internal/cloud_security_posture/rules/_bulk_action`) + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .set('kbn-xsrf', 'xxxx') + .send({ + action: 'mute', + rules: [ + { + benchmark_id: rule1.metadata.benchmark.id, + benchmark_version: rule1.metadata.benchmark.version, + rule_number: rule1.metadata.benchmark.rule_number || '', + rule_id: rule1.metadata.id, + }, + ], + }) + .expect(200); + + await supertest + .post(`/internal/cloud_security_posture/rules/_bulk_action`) + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .set('kbn-xsrf', 'xxxx') + .send({ + action: 'unmute', + rules: [], + }) + .expect(200); + + const { body } = await supertest + .get(`/internal/cloud_security_posture/rules/_get_states`) + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .set('kbn-xsrf', 'xxxx') + .expect(200); + + expectExpect(body).toEqual( + expectExpect.objectContaining({ + [generateRuleKey(rule1)]: { + muted: true, + benchmark_id: rule1.metadata.benchmark.id, + benchmark_version: rule1.metadata.benchmark.version, + rule_number: rule1.metadata.benchmark.rule_number + ? rule1.metadata.benchmark.rule_number + : '', + rule_id: rule1.metadata.id, + }, + }) + ); + }); + it('set wrong action input', async () => { const rule1 = await getRandomCspBenchmarkRule(); diff --git a/x-pack/test/cloud_security_posture_api/routes/mocks/benchmark_score_mock.ts b/x-pack/test/cloud_security_posture_api/routes/mocks/benchmark_score_mock.ts index 58453003b5b6e5..709cc354ec254b 100644 --- a/x-pack/test/cloud_security_posture_api/routes/mocks/benchmark_score_mock.ts +++ b/x-pack/test/cloud_security_posture_api/routes/mocks/benchmark_score_mock.ts @@ -5,11 +5,11 @@ * 2.0. */ -export const getBenchmarkScoreMockData = (postureType: string) => [ +export const getBenchmarkScoreMockData = (postureType: string, isEnabledRulesScore: boolean) => [ { total_findings: 1, policy_template: postureType, - is_enabled_rules_score: true, + is_enabled_rules_score: isEnabledRulesScore, '@timestamp': '2023-11-22T16:10:55.229268215Z', score_by_cluster_id: { 'Another Upper case account id': { diff --git a/x-pack/test/cloud_security_posture_api/routes/stats.ts b/x-pack/test/cloud_security_posture_api/routes/stats.ts index f558a0f6e7137c..f290c24a645fc1 100644 --- a/x-pack/test/cloud_security_posture_api/routes/stats.ts +++ b/x-pack/test/cloud_security_posture_api/routes/stats.ts @@ -141,7 +141,7 @@ export default function (providerContext: FtrProviderContext) { await index.removeScores(); await waitForPluginInitialized(); - await index.addScores(getBenchmarkScoreMockData('cspm')); + await index.addScores(getBenchmarkScoreMockData('cspm', true)); await index.addFindings([findingsMockData[1]]); }); @@ -186,7 +186,7 @@ export default function (providerContext: FtrProviderContext) { await index.removeScores(); await waitForPluginInitialized(); - await index.addScores(getBenchmarkScoreMockData('kspm')); + await index.addScores(getBenchmarkScoreMockData('kspm', true)); await index.addFindings([findingsMockData[0]]); }); @@ -207,7 +207,77 @@ export default function (providerContext: FtrProviderContext) { }).to.eql(kspmComplianceDashboardDataMockV1); }); - it('should return KSPM benchmarks V2 ', async () => { + it('should return KSPM benchmarks V2', async () => { + const { body: res }: { body: ComplianceDashboardDataV2 } = await kibanaHttpClient + .get(`/internal/cloud_security_posture/stats/kspm`) + .set(ELASTIC_HTTP_VERSION_HEADER, '2') + .set('kbn-xsrf', 'xxxx') + .expect(200); + + const resBenchmarks = removeRealtimeBenchmarkFields(res.benchmarks); + + const trends = removeRealtimeCalculatedFields(res.trend); + + expect({ + ...res, + benchmarks: resBenchmarks, + trend: trends, + }).to.eql(kspmComplianceDashboardDataMockV2); + }); + + it('should return KSPM benchmarks V2', async () => { + const { body: res }: { body: ComplianceDashboardDataV2 } = await kibanaHttpClient + .get(`/internal/cloud_security_posture/stats/kspm`) + .set(ELASTIC_HTTP_VERSION_HEADER, '2') + .set('kbn-xsrf', 'xxxx') + .expect(200); + + const resBenchmarks = removeRealtimeBenchmarkFields(res.benchmarks); + + const trends = removeRealtimeCalculatedFields(res.trend); + + expect({ + ...res, + benchmarks: resBenchmarks, + trend: trends, + }).to.eql(kspmComplianceDashboardDataMockV2); + }); + }); + + describe('Compliance dashboard based on enabled rules', async () => { + beforeEach(async () => { + await index.removeFindings(); + await index.removeScores(); + + await waitForPluginInitialized(); + }); + it('should calculate cspm benchmarks posture score based only on enabled rules', async () => { + await index.addScores(getBenchmarkScoreMockData('cspm', true)); + await index.addScores(getBenchmarkScoreMockData('cspm', false)); + await index.addFindings([findingsMockData[1]]); + + const { body: res }: { body: ComplianceDashboardDataV2 } = await kibanaHttpClient + .get(`/internal/cloud_security_posture/stats/cspm`) + .set(ELASTIC_HTTP_VERSION_HEADER, '2') + .set('kbn-xsrf', 'xxxx') + .expect(200); + + const resBenchmarks = removeRealtimeBenchmarkFields(res.benchmarks); + + const trends = removeRealtimeCalculatedFields(res.trend); + + expect({ + ...res, + benchmarks: resBenchmarks, + trend: trends, + }).to.eql(cspmComplianceDashboardDataMockV2); + }); + + it('should calculate kspm benchmarks posture score based only on enabled rules', async () => { + await index.addScores(getBenchmarkScoreMockData('kspm', true)); + await index.addScores(getBenchmarkScoreMockData('kspm', false)); + await index.addFindings([findingsMockData[0]]); + const { body: res }: { body: ComplianceDashboardDataV2 } = await kibanaHttpClient .get(`/internal/cloud_security_posture/stats/kspm`) .set(ELASTIC_HTTP_VERSION_HEADER, '2') diff --git a/x-pack/test/cloud_security_posture_functional/pages/findings.ts b/x-pack/test/cloud_security_posture_functional/pages/findings.ts index 5f4520277b2f36..f599648d0c2de1 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/findings.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/findings.ts @@ -7,6 +7,12 @@ import expect from '@kbn/expect'; import Chance from 'chance'; +import { CspBenchmarkRule } from '@kbn/cloud-security-posture-plugin/common/types/latest'; +import { CSP_BENCHMARK_RULE_SAVED_OBJECT_TYPE } from '@kbn/cloud-security-posture-plugin/common/constants'; +import { + ELASTIC_HTTP_VERSION_HEADER, + X_ELASTIC_INTERNAL_ORIGIN_REQUEST, +} from '@kbn/core-http-common'; import type { FtrProviderContext } from '../ftr_provider_context'; // eslint-disable-next-line import/no-default-export @@ -15,6 +21,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const filterBar = getService('filterBar'); const testSubjects = getService('testSubjects'); const retry = getService('retry'); + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); const pageObjects = getPageObjects(['common', 'findings', 'header']); const chance = new Chance(); const timeFiveHoursAgo = (Date.now() - 18000000).toString(); @@ -95,13 +103,29 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const ruleName1 = data[0].rule.name; const ruleName2 = data[1].rule.name; + const getCspBenchmarkRules = async (benchmarkId: string): Promise => { + const cspBenchmarkRules = await kibanaServer.savedObjects.find({ + type: CSP_BENCHMARK_RULE_SAVED_OBJECT_TYPE, + }); + const requestedBenchmarkRules = cspBenchmarkRules.saved_objects.filter( + (cspBenchmarkRule) => cspBenchmarkRule.attributes.metadata.benchmark.id === benchmarkId + ); + expect(requestedBenchmarkRules.length).greaterThan(0); + + return requestedBenchmarkRules.map((item) => item.attributes); + }; + describe('Findings Page - DataTable', function () { this.tags(['cloud_security_posture_findings']); let findings: typeof pageObjects.findings; let latestFindingsTable: typeof findings.latestFindingsTable; let distributionBar: typeof findings.distributionBar; - before(async () => { + beforeEach(async () => { + await kibanaServer.savedObjects.clean({ + types: ['cloud-security-posture-settings'], + }); + findings = pageObjects.findings; latestFindingsTable = findings.latestFindingsTable; distributionBar = findings.distributionBar; @@ -121,11 +145,12 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { pageObjects.header.waitUntilLoadingHasFinished(); }); - after(async () => { + afterEach(async () => { await findings.index.remove(); }); - describe('SearchBar', () => { + // FLAKY: https://github.com/elastic/kibana/issues/174472 + describe.skip('SearchBar', () => { it('add filter', async () => { // Filter bar uses the field's customLabel in the DataView await filterBar.addFilter({ field: 'Rule Name', operation: 'is', value: ruleName1 }); @@ -297,5 +322,74 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await testSubjects.missingOrFail(CSP_FIELDS_SELECTOR_MODAL); }); }); + + describe('Findings Page - support muting rules', () => { + it(`verify only enabled rules appears`, async () => { + const passedFindings = data.filter(({ result }) => result.evaluation === 'passed'); + const passedFindingsCount = passedFindings.length; + + const rule = (await getCspBenchmarkRules('cis_k8s'))[0]; + const modifiedFinding = { + ...passedFindings[0], + rule: { + name: 'Upper case rule name1', + id: rule.metadata.id, + section: 'Upper case section1', + benchmark: { + id: rule.metadata.benchmark.id, + posture_type: rule.metadata.benchmark.posture_type, + name: rule.metadata.benchmark.name, + version: rule.metadata.benchmark.version, + rule_number: rule.metadata.benchmark.rule_number, + }, + type: 'process', + }, + }; + + await findings.index.add([modifiedFinding]); + + await findings.navigateToLatestFindingsPage(); + await retry.waitFor( + 'Findings table to be loaded', + async () => (await latestFindingsTable.getRowsCount()) === data.length + 1 + ); + pageObjects.header.waitUntilLoadingHasFinished(); + + await distributionBar.filterBy('passed'); + + expect(await latestFindingsTable.getFindingsCount('passed')).to.eql( + passedFindingsCount + 1 + ); + + await supertest + .post(`/internal/cloud_security_posture/rules/_bulk_action`) + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .set('kbn-xsrf', 'xxxx') + .send({ + action: 'mute', + rules: [ + { + benchmark_id: modifiedFinding.rule.benchmark.id, + benchmark_version: modifiedFinding.rule.benchmark.version, + rule_number: modifiedFinding.rule.benchmark.rule_number || '', + rule_id: modifiedFinding.rule.id, + }, + ], + }) + .expect(200); + + await findings.navigateToLatestFindingsPage(); + await retry.waitFor( + 'Findings table to be loaded', + async () => (await latestFindingsTable.getRowsCount()) === data.length + ); + pageObjects.header.waitUntilLoadingHasFinished(); + + await distributionBar.filterBy('passed'); + + expect(await latestFindingsTable.getFindingsCount('passed')).to.eql(passedFindingsCount); + }); + }); }); } diff --git a/x-pack/test/cloud_security_posture_functional/pages/findings_grouping.ts b/x-pack/test/cloud_security_posture_functional/pages/findings_grouping.ts index 2939f3eed9266e..7f9530ba91d38d 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/findings_grouping.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/findings_grouping.ts @@ -8,12 +8,20 @@ import expect from '@kbn/expect'; import Chance from 'chance'; import { asyncForEach } from '@kbn/std'; +import { CspBenchmarkRule } from '@kbn/cloud-security-posture-plugin/common/types/latest'; +import { CSP_BENCHMARK_RULE_SAVED_OBJECT_TYPE } from '@kbn/cloud-security-posture-plugin/common/constants'; +import { + ELASTIC_HTTP_VERSION_HEADER, + X_ELASTIC_INTERNAL_ORIGIN_REQUEST, +} from '@kbn/core-http-common'; import type { FtrProviderContext } from '../ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function ({ getPageObjects, getService }: FtrProviderContext) { const queryBar = getService('queryBar'); const filterBar = getService('filterBar'); + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); const pageObjects = getPageObjects(['common', 'findings', 'header']); const chance = new Chance(); @@ -116,12 +124,27 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const ruleName1 = data[0].rule.name; + const getCspBenchmarkRules = async (benchmarkId: string): Promise => { + const cspBenchmarkRules = await kibanaServer.savedObjects.find({ + type: CSP_BENCHMARK_RULE_SAVED_OBJECT_TYPE, + }); + const requestedBenchmarkRules = cspBenchmarkRules.saved_objects.filter( + (cspBenchmarkRule) => cspBenchmarkRule.attributes.metadata.benchmark.id === benchmarkId + ); + expect(requestedBenchmarkRules.length).greaterThan(0); + + return requestedBenchmarkRules.map((item) => item.attributes); + }; + describe('Findings Page - Grouping', function () { this.tags(['cloud_security_posture_findings_grouping']); let findings: typeof pageObjects.findings; // let groupSelector: ReturnType; before(async () => { + await kibanaServer.savedObjects.clean({ + types: ['cloud-security-posture-settings'], + }); findings = pageObjects.findings; // Before we start any test we must wait for cloud_security_posture plugin to complete its initialization @@ -434,5 +457,78 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { ); }); }); + describe('Default Grouping - support muting rules', async () => { + it('groups findings by resource after muting rule', async () => { + const findingsCount = data.length; + const resourceGroupCount = Array.from(new Set(data.map((obj) => obj.resource.name))).length; + + const finding = data[0]; + const rule = (await getCspBenchmarkRules('cis_k8s'))[0]; + const modifiedFinding = { + ...finding, + resource: { + ...finding.resource, + name: 'foo', + }, + rule: { + name: 'Upper case rule name1', + id: rule.metadata.id, + section: 'Upper case section1', + benchmark: { + id: rule.metadata.benchmark.id, + posture_type: rule.metadata.benchmark.posture_type, + name: rule.metadata.benchmark.name, + version: rule.metadata.benchmark.version, + rule_number: rule.metadata.benchmark.rule_number, + }, + type: 'process', + }, + }; + + await findings.index.add([modifiedFinding]); + + await findings.navigateToLatestFindingsPage(); + await pageObjects.header.waitUntilLoadingHasFinished(); + + const groupSelector = await findings.groupSelector(); + await groupSelector.openDropDown(); + await groupSelector.setValue('Resource'); + + const grouping = await findings.findingsGrouping(); + + const groupCount = await grouping.getGroupCount(); + expect(groupCount).to.be(`${resourceGroupCount + 1} groups`); + + const unitCount = await grouping.getUnitCount(); + expect(unitCount).to.be(`${findingsCount + 1} findings`); + + await supertest + .post(`/internal/cloud_security_posture/rules/_bulk_action`) + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .set('kbn-xsrf', 'xxxx') + .send({ + action: 'mute', + rules: [ + { + benchmark_id: modifiedFinding.rule.benchmark.id, + benchmark_version: modifiedFinding.rule.benchmark.version, + rule_number: modifiedFinding.rule.benchmark.rule_number || '', + rule_id: modifiedFinding.rule.id, + }, + ], + }) + .expect(200); + + await findings.navigateToLatestFindingsPage(); + await pageObjects.header.waitUntilLoadingHasFinished(); + + const groupCountAfterMute = await grouping.getGroupCount(); + expect(groupCountAfterMute).to.be(`${resourceGroupCount} groups`); + + const unitCountAfterMute = await grouping.getUnitCount(); + expect(unitCountAfterMute).to.be(`${findingsCount} findings`); + }); + }); }); } diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/coverage_overview.ts b/x-pack/test/detection_engine_api_integration/basic/tests/coverage_overview.ts deleted file mode 100644 index b40033b5eb17e0..00000000000000 --- a/x-pack/test/detection_engine_api_integration/basic/tests/coverage_overview.ts +++ /dev/null @@ -1,510 +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 expect from '@kbn/expect'; - -import { - RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL, - ThreatArray, -} from '@kbn/security-solution-plugin/common/api/detection_engine'; -import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { - createPrebuiltRuleAssetSavedObjects, - createRuleAssetSavedObject, - createRule, - deleteAllRules, - getSimpleRule, - installPrebuiltRulesAndTimelines, - createNonSecurityRule, -} from '../../utils'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const log = getService('log'); - const es = getService('es'); - - describe('coverage_overview', () => { - beforeEach(async () => { - await deleteAllRules(supertest, log); - }); - - it('does NOT error when there are no security rules', async () => { - await createNonSecurityRule(supertest); - const rule1 = await createRule(supertest, log, { - ...getSimpleRule(), - threat: generateThreatArray(1), - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({}) - .expect(200); - - expect(body).to.eql({ - coverage: { - T001: [rule1.id], - TA001: [rule1.id], - 'T001.001': [rule1.id], - }, - unmapped_rule_ids: [], - rules_data: { - [rule1.id]: { - activity: 'disabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - - describe('without filters', () => { - it('returns an empty response if there are no rules', async () => { - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({}) - .expect(200); - - expect(body).to.eql({ - coverage: {}, - unmapped_rule_ids: [], - rules_data: {}, - }); - }); - - it('returns response with a single rule mapped to MITRE categories', async () => { - const rule1 = await createRule(supertest, log, { - ...getSimpleRule(), - threat: generateThreatArray(1), - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({}) - .expect(200); - - expect(body).to.eql({ - coverage: { - T001: [rule1.id], - TA001: [rule1.id], - 'T001.001': [rule1.id], - }, - unmapped_rule_ids: [], - rules_data: { - [rule1.id]: { - activity: 'disabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - - it('returns response with an unmapped rule', async () => { - const rule1 = await createRule(supertest, log, { ...getSimpleRule(), threat: undefined }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({}) - .expect(200); - - expect(body).to.eql({ - coverage: {}, - unmapped_rule_ids: [rule1.id], - rules_data: { - [rule1.id]: { - activity: 'disabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - }); - - describe('with filters', () => { - describe('search_term', () => { - it('returns response filtered by tactic', async () => { - await createRule(supertest, log, { - ...getSimpleRule('rule-1'), - threat: generateThreatArray(1), - }); - const expectedRule = await createRule(supertest, log, { - ...getSimpleRule('rule-2'), - threat: generateThreatArray(2), - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({ - filter: { - search_term: 'TA002', - }, - }) - .expect(200); - - expect(body).to.eql({ - coverage: { - T002: [expectedRule.id], - TA002: [expectedRule.id], - 'T002.002': [expectedRule.id], - }, - unmapped_rule_ids: [], - rules_data: { - [expectedRule.id]: { - activity: 'disabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - - it('returns response filtered by technique', async () => { - await createRule(supertest, log, { - ...getSimpleRule('rule-1'), - threat: generateThreatArray(1), - }); - const expectedRule = await createRule(supertest, log, { - ...getSimpleRule('rule-2'), - threat: generateThreatArray(2), - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({ - filter: { - search_term: 'T002', - }, - }) - .expect(200); - - expect(body).to.eql({ - coverage: { - T002: [expectedRule.id], - TA002: [expectedRule.id], - 'T002.002': [expectedRule.id], - }, - unmapped_rule_ids: [], - rules_data: { - [expectedRule.id]: { - activity: 'disabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - - it('returns response filtered by subtechnique', async () => { - await createRule(supertest, log, { - ...getSimpleRule('rule-1'), - threat: generateThreatArray(1), - }); - const expectedRule = await createRule(supertest, log, { - ...getSimpleRule('rule-2'), - threat: generateThreatArray(2), - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({ - filter: { - search_term: 'T002.002', - }, - }) - .expect(200); - - expect(body).to.eql({ - coverage: { - T002: [expectedRule.id], - TA002: [expectedRule.id], - 'T002.002': [expectedRule.id], - }, - unmapped_rule_ids: [], - rules_data: { - [expectedRule.id]: { - activity: 'disabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - - it('returns response filtered by rule name', async () => { - await createRule(supertest, log, getSimpleRule('rule-1')); - const expectedRule = await createRule(supertest, log, { - ...getSimpleRule('rule-2'), - name: 'rule-2', - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({ - filter: { - search_term: 'rule-2', - }, - }) - .expect(200); - - expect(body).to.eql({ - coverage: {}, - unmapped_rule_ids: [expectedRule.id], - rules_data: { - [expectedRule.id]: { - activity: 'disabled', - name: 'rule-2', - }, - }, - }); - }); - - it('returns response filtered by index pattern', async () => { - await createRule(supertest, log, { - ...getSimpleRule('rule-1'), - index: ['index-pattern-1'], - }); - const expectedRule = await createRule(supertest, log, { - ...getSimpleRule('rule-2'), - index: ['index-pattern-2'], - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({ - filter: { - search_term: 'index-pattern-2', - }, - }) - .expect(200); - - expect(body).to.eql({ - coverage: {}, - unmapped_rule_ids: [expectedRule.id], - rules_data: { - [expectedRule.id]: { - activity: 'disabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - }); - - describe('activity', () => { - it('returns response filtered by disabled rules', async () => { - const expectedRule = await createRule(supertest, log, { - ...getSimpleRule('rule-1'), - threat: generateThreatArray(1), - }); - await createRule(supertest, log, { - ...getSimpleRule('rule-2', true), - threat: generateThreatArray(2), - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({ - filter: { - activity: ['disabled'], - }, - }) - .expect(200); - - expect(body).to.eql({ - coverage: { - T001: [expectedRule.id], - TA001: [expectedRule.id], - 'T001.001': [expectedRule.id], - }, - unmapped_rule_ids: [], - rules_data: { - [expectedRule.id]: { - activity: 'disabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - - it('returns response filtered by enabled rules', async () => { - await createRule(supertest, log, { - ...getSimpleRule('rule-1'), - threat: generateThreatArray(1), - }); - const expectedRule = await createRule(supertest, log, { - ...getSimpleRule('rule-2', true), - threat: generateThreatArray(2), - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({ - filter: { - activity: ['enabled'], - }, - }) - .expect(200); - - expect(body).to.eql({ - coverage: { - T002: [expectedRule.id], - TA002: [expectedRule.id], - 'T002.002': [expectedRule.id], - }, - unmapped_rule_ids: [], - rules_data: { - [expectedRule.id]: { - activity: 'enabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - - it('returns all rules if both enabled and disabled filters are specified in the request', async () => { - const expectedRule1 = await createRule(supertest, log, { - ...getSimpleRule('rule-1', false), - name: 'Disabled rule', - threat: generateThreatArray(1), - }); - const expectedRule2 = await createRule(supertest, log, { - ...getSimpleRule('rule-2', true), - name: 'Enabled rule', - threat: generateThreatArray(2), - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({ - filter: { - activity: ['enabled', 'disabled'], - }, - }) - .expect(200); - - expect(body).to.eql({ - coverage: { - T001: [expectedRule1.id], - TA001: [expectedRule1.id], - 'T001.001': [expectedRule1.id], - T002: [expectedRule2.id], - TA002: [expectedRule2.id], - 'T002.002': [expectedRule2.id], - }, - unmapped_rule_ids: [], - rules_data: { - [expectedRule1.id]: { - activity: 'disabled', - name: 'Disabled rule', - }, - [expectedRule2.id]: { - activity: 'enabled', - name: 'Enabled rule', - }, - }, - }); - }); - }); - - describe('source', () => { - it('returns response filtered by custom rules', async () => { - await createPrebuiltRuleAssetSavedObjects(es, [ - createRuleAssetSavedObject({ - rule_id: 'prebuilt-rule-1', - threat: generateThreatArray(1), - }), - ]); - await installPrebuiltRulesAndTimelines(es, supertest); - - const expectedRule = await createRule(supertest, log, { - ...getSimpleRule('rule-1'), - threat: generateThreatArray(2), - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({ - filter: { - source: ['custom'], - }, - }) - .expect(200); - - expect(body).to.eql({ - coverage: { - T002: [expectedRule.id], - TA002: [expectedRule.id], - 'T002.002': [expectedRule.id], - }, - unmapped_rule_ids: [], - rules_data: { - [expectedRule.id]: { - activity: 'disabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - }); - }); - }); -}; - -function generateThreatArray(startIndex: number, count = 1): ThreatArray { - const result: ThreatArray = []; - - for (let i = 0; i < count; ++i) { - const indexName = (i + startIndex).toString().padStart(3, '0'); - - result.push({ - framework: 'MITRE ATT&CK', - tactic: { - id: `TA${indexName}`, - name: `Tactic ${indexName}`, - reference: `http://some-link-${indexName}`, - }, - technique: [ - { - id: `T${indexName}`, - name: `Technique ${indexName}`, - reference: `http://some-technique-link-${indexName}`, - subtechnique: [ - { - id: `T${indexName}.${indexName}`, - name: `Subtechnique ${indexName}`, - reference: `http://some-sub-technique-link-${indexName}`, - }, - ], - }, - ], - }); - } - - return result; -} diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/index.ts b/x-pack/test/detection_engine_api_integration/basic/tests/index.ts index 5b3449707d38f2..3ef462f7add2a0 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/index.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/index.ts @@ -22,6 +22,5 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./patch_rules_bulk')); loadTestFile(require.resolve('./patch_rules')); loadTestFile(require.resolve('./import_timelines')); - loadTestFile(require.resolve('./coverage_overview')); }); }; diff --git a/x-pack/test/detection_engine_api_integration/utils/create_non_security_rule.ts b/x-pack/test/detection_engine_api_integration/utils/create_non_security_rule.ts deleted file mode 100644 index 09bc0f9b81a6d5..00000000000000 --- a/x-pack/test/detection_engine_api_integration/utils/create_non_security_rule.ts +++ /dev/null @@ -1,42 +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 SuperTest from 'supertest'; - -const SIMPLE_APM_RULE_DATA = { - name: 'Test rule', - rule_type_id: 'apm.anomaly', - enabled: false, - consumer: 'alerts', - tags: [], - actions: [], - params: { - windowSize: 30, - windowUnit: 'm', - anomalySeverityType: 'critical', - anomalyDetectorTypes: ['txLatency'], - environment: 'ENVIRONMENT_ALL', - }, - schedule: { - interval: '10m', - }, -}; - -/** - * Created a non security rule. Helpful in tests to verify functionality works with presence of non security rules. - * @param supertest The supertest deps - */ -export async function createNonSecurityRule( - supertest: SuperTest.SuperTest -): Promise { - await supertest - .post('/api/alerting/rule') - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') - .send(SIMPLE_APM_RULE_DATA) - .expect(200); -} diff --git a/x-pack/test/detection_engine_api_integration/utils/index.ts b/x-pack/test/detection_engine_api_integration/utils/index.ts index 1938a069a2f534..baa4be0491625a 100644 --- a/x-pack/test/detection_engine_api_integration/utils/index.ts +++ b/x-pack/test/detection_engine_api_integration/utils/index.ts @@ -31,7 +31,6 @@ export * from './prebuilt_rules/create_prebuilt_rule_saved_objects'; export * from './prebuilt_rules/install_prebuilt_rules_and_timelines'; export * from './get_simple_rule_update'; export * from './get_simple_ml_rule_update'; -export * from './create_non_security_rule'; export * from './get_simple_rule_as_ndjson'; export * from './rule_to_ndjson'; export * from './delete_rule'; 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 cfe9cc7e5f3045..fc8cbd2b38d664 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/group1/smokescreen.ts b/x-pack/test/functional/apps/lens/group1/smokescreen.ts index e33e65741bf667..f4821639309917 100644 --- a/x-pack/test/functional/apps/lens/group1/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/group1/smokescreen.ts @@ -281,7 +281,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(data?.axes?.y?.[1].gridlines.length).to.eql(0); }); - it('should transition from a multi-layer stacked bar to donut chart using suggestions', async () => { + it('should transition from a multi-layer stacked bar to treemap chart using suggestions', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); await PageObjects.lens.goToTimeRange(); @@ -313,10 +313,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); await PageObjects.lens.save('twolayerchart'); - await testSubjects.click('lnsSuggestion-donut > lnsSuggestion'); + await testSubjects.click('lnsSuggestion-treemap > lnsSuggestion'); expect(await PageObjects.lens.getLayerCount()).to.eql(1); - expect(await PageObjects.lens.getDimensionTriggerText('lnsPie_sliceByDimensionPanel')).to.eql( + expect(await PageObjects.lens.getDimensionTriggerText('lnsPie_groupByDimensionPanel')).to.eql( 'Top 5 values of geo.dest' ); expect(await PageObjects.lens.getDimensionTriggerText('lnsPie_sizeByDimensionPanel')).to.eql( diff --git a/x-pack/test/functional/apps/lens/group2/fields_list.ts b/x-pack/test/functional/apps/lens/group2/fields_list.ts index 4e1f771d0b0420..79baafe6100a6e 100644 --- a/x-pack/test/functional/apps/lens/group2/fields_list.ts +++ b/x-pack/test/functional/apps/lens/group2/fields_list.ts @@ -9,13 +9,15 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { - const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']); + const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header', 'timePicker']); const find = getService('find'); const log = getService('log'); const testSubjects = getService('testSubjects'); const filterBar = getService('filterBar'); const fieldEditor = getService('fieldEditor'); const retry = getService('retry'); + const es = getService('es'); + const queryBar = getService('queryBar'); describe('lens fields list tests', () => { for (const datasourceType of ['form-based', 'ad-hoc', 'ad-hoc-no-timefield']) { @@ -48,7 +50,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); }); }); - it('should show all fields as available', async () => { expect( await (await testSubjects.find('lnsIndexPatternAvailableFields-count')).getVisibleText() @@ -231,5 +232,50 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { } }); } + + describe(`update field list test`, () => { + before(async () => { + await es.transport.request({ + path: '/field-update-test/_doc', + method: 'POST', + body: { + '@timestamp': new Date().toISOString(), + oldField: 10, + }, + }); + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.timePicker.setCommonlyUsedTime('This_week'); + + await PageObjects.lens.createAdHocDataView('field-update-test', true); + await retry.try(async () => { + const selectedPattern = await PageObjects.lens.getDataPanelIndexPattern(); + expect(selectedPattern).to.eql('field-update-test*'); + }); + }); + after(async () => { + await es.transport.request({ + path: '/field-update-test', + method: 'DELETE', + }); + }); + + it('should show new fields Available fields', async () => { + await es.transport.request({ + path: '/field-update-test/_doc', + method: 'POST', + body: { + '@timestamp': new Date().toISOString(), + oldField: 10, + newField: 20, + }, + }); + await PageObjects.lens.waitForField('oldField'); + await queryBar.setQuery('oldField: 10'); + await queryBar.submitQuery(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.waitForField('newField'); + }); + }); }); } diff --git a/x-pack/test/functional/apps/lens/group6/annotations.ts b/x-pack/test/functional/apps/lens/group6/annotations.ts index cd9986b29ab033..df2f296c1fa3fa 100644 --- a/x-pack/test/functional/apps/lens/group6/annotations.ts +++ b/x-pack/test/functional/apps/lens/group6/annotations.ts @@ -26,7 +26,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const from = 'Sep 19, 2015 @ 06:31:44.000'; const to = 'Sep 23, 2015 @ 18:31:44.000'; - describe('lens annotations tests', () => { + // FLAKY: https://github.com/elastic/kibana/issues/174653 + describe.skip('lens annotations tests', () => { before(async () => { await PageObjects.common.setTime({ from, to }); }); diff --git a/x-pack/test/functional/apps/ml/data_visualizer/data_drift.ts b/x-pack/test/functional/apps/ml/data_visualizer/data_drift.ts index bec89991044103..a3ef0eddef6c42 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/data_drift.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/data_drift.ts @@ -13,6 +13,7 @@ export const farequoteKQLFiltersSearchTestData = { dateTimeField: '@timestamp', sourceIndexOrSavedSearch: 'ft_farequote_filter_and_kuery', chartClickCoordinates: [0, 0] as [number, number], + comparisonChartClickCoordinates: [1, 1] as [number, number], dataViewName: 'ft_farequote', totalDocCount: '5,674', }; @@ -22,6 +23,7 @@ const dataViewCreationTestData = { isSavedSearch: true, dateTimeField: '@timestamp', chartClickCoordinates: [0, 0] as [number, number], + comparisonChartClickCoordinates: [1, 1] as [number, number], totalDocCount: '86,274', }; @@ -30,7 +32,6 @@ const nonTimeSeriesTestData = { isSavedSearch: false, dateTimeField: '@timestamp', sourceIndexOrSavedSearch: 'ft_ihp_outlier', - chartClickCoordinates: [0, 0] as [number, number], dataViewName: 'ft_ihp_outlier', }; @@ -71,13 +72,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); await ml.dataDrift.assertNoWindowParametersEmptyPromptExists(); - if (testData.chartClickCoordinates) { + if ('chartClickCoordinates' in testData) { await ml.testExecution.logTestStep('clicks the document count chart to start analysis'); + await ml.dataDrift.clickDocumentCountChart('Reference', testData.chartClickCoordinates); + await ml.dataDrift.assertRunAnalysisButtonState(true); await ml.dataDrift.clickDocumentCountChart( - 'dataDriftDocCountChart-Reference', - testData.chartClickCoordinates + 'Comparison', + testData.comparisonChartClickCoordinates ); } + await ml.dataDrift.assertRunAnalysisButtonState(false); await ml.dataDrift.runAnalysis(); } diff --git a/x-pack/test/functional/apps/observability_log_explorer/columns_selection.ts b/x-pack/test/functional/apps/observability_log_explorer/columns_selection.ts index 5da73c3e5ecb5a..60078440ec2a13 100644 --- a/x-pack/test/functional/apps/observability_log_explorer/columns_selection.ts +++ b/x-pack/test/functional/apps/observability_log_explorer/columns_selection.ts @@ -5,33 +5,46 @@ * 2.0. */ import expect from '@kbn/expect'; +import moment from 'moment/moment'; +import { log, timerange } from '@kbn/apm-synthtrace-client'; import { FtrProviderContext } from './config'; -const defaultLogColumns = ['@timestamp', 'service.name', 'host.name', 'message']; +const defaultLogColumns = ['@timestamp', 'service.name', 'host.name', 'content']; export default function ({ getService, getPageObjects }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); const retry = getService('retry'); const PageObjects = getPageObjects(['discover', 'observabilityLogExplorer']); + const synthtrace = getService('logSynthtraceEsClient'); + const dataGrid = getService('dataGrid'); + const testSubjects = getService('testSubjects'); + const from = '2023-12-27T10:24:14.035Z'; + const to = '2023-12-27T10:25:14.091Z'; + const TEST_TIMEOUT = 10 * 1000; // 10 secs - describe('Columns selection initialization and update', () => { + const navigateToLogExplorer = () => + PageObjects.observabilityLogExplorer.navigateTo({ + pageState: { + time: { + from, + to, + mode: 'absolute', + }, + }, + }); + + describe('When the log explorer loads', () => { before(async () => { - await esArchiver.load( - 'x-pack/test/functional/es_archives/observability_log_explorer/data_streams' - ); + await synthtrace.index(generateLogsData({ to })); + await navigateToLogExplorer(); }); after(async () => { - await esArchiver.unload( - 'x-pack/test/functional/es_archives/observability_log_explorer/data_streams' - ); + await synthtrace.clean(); }); - describe('when the log explorer loads', () => { + describe('columns selection initialization and update', () => { it("should initialize the table columns to logs' default selection", async () => { - await PageObjects.observabilityLogExplorer.navigateTo(); - - await retry.try(async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { expect(await PageObjects.discover.getColumnHeaders()).to.eql(defaultLogColumns); }); }); @@ -39,16 +52,21 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should restore the table columns from the URL state if exists', async () => { await PageObjects.observabilityLogExplorer.navigateTo({ pageState: { + time: { + from, + to, + mode: 'absolute', + }, columns: [ { field: 'service.name' }, { field: 'host.name' }, - { field: 'message' }, + { field: 'content' }, { field: 'data_stream.namespace' }, ], }, }); - await retry.try(async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { expect(await PageObjects.discover.getColumnHeaders()).to.eql([ ...defaultLogColumns, 'data_stream.namespace', @@ -56,5 +74,235 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); }); + + describe('render content virtual column properly', async () => { + it('should render log level and log message when present', async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { + const cellElement = await dataGrid.getCellElement(0, 5); + const cellValue = await cellElement.getVisibleText(); + expect(cellValue.includes('info')).to.be(true); + expect(cellValue.includes('A sample log')).to.be(true); + }); + }); + + it('should render log message when present and skip log level when missing', async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { + const cellElement = await dataGrid.getCellElement(1, 5); + const cellValue = await cellElement.getVisibleText(); + expect(cellValue.includes('info')).to.be(false); + expect(cellValue.includes('A sample log')).to.be(true); + }); + }); + + it('should render message from error object when top level message not present', async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { + const cellElement = await dataGrid.getCellElement(2, 5); + const cellValue = await cellElement.getVisibleText(); + expect(cellValue.includes('info')).to.be(true); + expect(cellValue.includes('error.message')).to.be(true); + expect(cellValue.includes('message in error object')).to.be(true); + }); + }); + + it('should render message from event.original when top level message and error.message not present', async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { + const cellElement = await dataGrid.getCellElement(3, 5); + const cellValue = await cellElement.getVisibleText(); + expect(cellValue.includes('info')).to.be(true); + expect(cellValue.includes('event.original')).to.be(true); + expect(cellValue.includes('message in event original')).to.be(true); + }); + }); + + it('should render the whole JSON when neither message, error.message and event.original are present', async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { + const cellElement = await dataGrid.getCellElement(4, 5); + const cellValue = await cellElement.getVisibleText(); + expect(cellValue.includes('info')).to.be(true); + + expect(cellValue.includes('error.message')).to.be(false); + expect(cellValue.includes('event.original')).to.be(false); + + const cellAttribute = await cellElement.findByTestSubject( + 'logExplorerCellDescriptionList' + ); + expect(cellAttribute).not.to.be.empty(); + }); + }); + + it('on cell expansion with no message field should open JSON Viewer', async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { + await dataGrid.clickCellExpandButton(4, 5); + await testSubjects.existOrFail('dataTableExpandCellActionJsonPopover'); + }); + }); + + it('on cell expansion with message field should open regular popover', async () => { + await navigateToLogExplorer(); + await retry.tryForTime(TEST_TIMEOUT, async () => { + await dataGrid.clickCellExpandButton(3, 5); + await testSubjects.existOrFail('euiDataGridExpansionPopover'); + }); + }); + }); + + describe('virtual column cell actions', async () => { + beforeEach(async () => { + await navigateToLogExplorer(); + }); + it('should render a popover with cell actions when a chip on content column is clicked', async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { + const cellElement = await dataGrid.getCellElement(0, 5); + const logLevelChip = await cellElement.findByTestSubject( + 'dataTablePopoverChip_log.level' + ); + await logLevelChip.click(); + // Check Filter In button is present + await testSubjects.existOrFail('dataTableCellAction_addToFilterAction_log.level'); + // Check Filter Out button is present + await testSubjects.existOrFail('dataTableCellAction_removeFromFilterAction_log.level'); + // Check Copy button is present + await testSubjects.existOrFail('dataTableCellAction_copyToClipboardAction_log.level'); + }); + }); + + it('should render the table filtered where log.level value is info when filter in action is clicked', async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { + const cellElement = await dataGrid.getCellElement(0, 5); + const logLevelChip = await cellElement.findByTestSubject( + 'dataTablePopoverChip_log.level' + ); + await logLevelChip.click(); + + // Find Filter In button + const filterInButton = await testSubjects.find( + 'dataTableCellAction_addToFilterAction_log.level' + ); + + await filterInButton.click(); + const rowWithLogLevelInfo = await testSubjects.findAll('dataTablePopoverChip_log.level'); + + expect(rowWithLogLevelInfo.length).to.be(4); + }); + }); + + it('should render the table filtered where log.level value is not info when filter out action is clicked', async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { + const cellElement = await dataGrid.getCellElement(0, 5); + const logLevelChip = await cellElement.findByTestSubject( + 'dataTablePopoverChip_log.level' + ); + await logLevelChip.click(); + + // Find Filter Out button + const filterOutButton = await testSubjects.find( + 'dataTableCellAction_removeFromFilterAction_log.level' + ); + + await filterOutButton.click(); + await testSubjects.missingOrFail('dataTablePopoverChip_log.level'); + }); + }); + }); }); } + +function generateLogsData({ to, count = 1 }: { to: string; count?: number }) { + const logs = timerange(moment(to).subtract(1, 'second'), moment(to)) + .interval('1m') + .rate(1) + .generator((timestamp) => + Array(count) + .fill(0) + .map(() => { + return log.create().message('A sample log').logLevel('info').timestamp(timestamp); + }) + ); + + const logsWithNoLogLevel = timerange( + moment(to).subtract(2, 'second'), + moment(to).subtract(1, 'second') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => + Array(count) + .fill(0) + .map(() => { + return log.create().message('A sample log').timestamp(timestamp); + }) + ); + + const logsWithErrorMessage = timerange( + moment(to).subtract(3, 'second'), + moment(to).subtract(2, 'second') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => + Array(count) + .fill(0) + .map(() => { + return log + .create() + .logLevel('info') + .timestamp(timestamp) + .defaults({ 'error.message': 'message in error object' }); + }) + ); + + const logsWithEventOriginal = timerange( + moment(to).subtract(4, 'second'), + moment(to).subtract(3, 'second') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => + Array(count) + .fill(0) + .map(() => { + return log + .create() + .logLevel('info') + .timestamp(timestamp) + .defaults({ 'event.original': 'message in event original' }); + }) + ); + + const logsWithNoMessage = timerange( + moment(to).subtract(5, 'second'), + moment(to).subtract(4, 'second') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => + Array(count) + .fill(0) + .map(() => { + return log.create().logLevel('info').timestamp(timestamp); + }) + ); + + const logWithNoMessageNoLogLevel = timerange( + moment(to).subtract(6, 'second'), + moment(to).subtract(5, 'second') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => + Array(count) + .fill(0) + .map(() => { + return log.create().timestamp(timestamp); + }) + ); + + return [ + logs, + logsWithNoLogLevel, + logsWithErrorMessage, + logsWithEventOriginal, + logsWithNoMessage, + logWithNoMessageNoLogLevel, + ]; +} diff --git a/x-pack/test/functional/apps/observability_log_explorer/dataset_selection_state.ts b/x-pack/test/functional/apps/observability_log_explorer/dataset_selection_state.ts index 583313ec8cb9ac..964ebc12b320b5 100644 --- a/x-pack/test/functional/apps/observability_log_explorer/dataset_selection_state.ts +++ b/x-pack/test/functional/apps/observability_log_explorer/dataset_selection_state.ts @@ -87,15 +87,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(azureDatasetSelectionTitle).to.be('[Azure Logs] activitylogs'); // Go back to previous page selection - await retry.try(async () => { + await retry.tryForTime(30 * 1000, async () => { await browser.goBack(); const backNavigationDatasetSelectionTitle = await PageObjects.observabilityLogExplorer.getDatasetSelectorButtonText(); expect(backNavigationDatasetSelectionTitle).to.be('All logs'); }); - // Go forward to previous page selection - await retry.try(async () => { + await retry.tryForTime(30 * 1000, async () => { await browser.goForward(); const forwardNavigationDatasetSelectionTitle = await PageObjects.observabilityLogExplorer.getDatasetSelectorButtonText(); diff --git a/x-pack/test/functional/apps/observability_log_explorer/header_menu.ts b/x-pack/test/functional/apps/observability_log_explorer/header_menu.ts index f87edc5fc23a50..44ea416d015960 100644 --- a/x-pack/test/functional/apps/observability_log_explorer/header_menu.ts +++ b/x-pack/test/functional/apps/observability_log_explorer/header_menu.ts @@ -71,7 +71,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { '@timestamp', 'service.name', 'host.name', - 'message', + 'content', ]); }); 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 9c0ab19ac64c4a..18b5004393027b 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/ml/data_drift.ts b/x-pack/test/functional/services/ml/data_drift.ts index b077caafd0beef..8a89c419c11ee0 100644 --- a/x-pack/test/functional/services/ml/data_drift.ts +++ b/x-pack/test/functional/services/ml/data_drift.ts @@ -197,15 +197,17 @@ export function MachineLearningDataDriftProvider({ await this.assertDocCountContent('Comparison'); }, - async assertHistogramBrushesExist() { + async assertHistogramBrushesExist(id: 'Reference' | 'Comparison' = 'Reference') { await retry.tryForTime(5000, async () => { - await testSubjects.existOrFail(`aiopsHistogramBrushes`); - // As part of the interface for the histogram brushes, the button to clear the selection should be present - await testSubjects.existOrFail(`aiopsClearSelectionBadge`); + await testSubjects.existOrFail(`dataDriftBrush-${id}`); }); }, - async clickDocumentCountChart(dataTestSubj: string, chartClickCoordinates: [number, number]) { + async clickDocumentCountChart( + id: 'Reference' | 'Comparison', + chartClickCoordinates: [number, number] + ) { + const dataTestSubj = `dataDriftDocCountChart-${id}`; await elasticChart.waitForRenderComplete(); const el = await elasticChart.getCanvas(dataTestSubj); @@ -215,16 +217,26 @@ export function MachineLearningDataDriftProvider({ .click() .perform(); - await this.assertHistogramBrushesExist(); + await this.assertHistogramBrushesExist(id); }, async assertDataDriftTableExists() { await testSubjects.existOrFail(`mlDataDriftTable`); }, - async runAnalysis() { + async assertRunAnalysisButtonState(disabled: boolean) { await retry.tryForTime(5000, async () => { - await testSubjects.click(`aiopsRerunAnalysisButton`); + const isDisabled = !(await testSubjects.isEnabled('runDataDriftAnalysis')); + expect(isDisabled).to.equal( + disabled, + `Expect runDataDriftAnalysis button disabled state to be ${disabled} (got ${isDisabled})` + ); + }); + }, + + async runAnalysis() { + await retry.tryForTime(10000, async () => { + await testSubjects.click(`runDataDriftAnalysis`); await this.assertDataDriftTableExists(); }); }, diff --git a/x-pack/test/functional/services/transform/discover.ts b/x-pack/test/functional/services/transform/discover.ts index 07bd66cce1780f..02cf7a98310ebe 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/screenshot_creation/apps/response_ops_docs/stack_connectors/index.ts b/x-pack/test/screenshot_creation/apps/response_ops_docs/stack_connectors/index.ts index 5b9afd0ffd32d4..a638ab36805d86 100644 --- a/x-pack/test/screenshot_creation/apps/response_ops_docs/stack_connectors/index.ts +++ b/x-pack/test/screenshot_creation/apps/response_ops_docs/stack_connectors/index.ts @@ -65,6 +65,7 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { loadTestFile(require.resolve('./server_log_connector')); loadTestFile(require.resolve('./servicenow_itom_connector')); loadTestFile(require.resolve('./servicenow_itsm_connector')); + loadTestFile(require.resolve('./servicenow_sir_connector')); loadTestFile(require.resolve('./slack_connector')); loadTestFile(require.resolve('./tines_connector')); loadTestFile(require.resolve('./webhook_connector')); diff --git a/x-pack/test/screenshot_creation/apps/response_ops_docs/stack_connectors/servicenow_sir_connector.ts b/x-pack/test/screenshot_creation/apps/response_ops_docs/stack_connectors/servicenow_sir_connector.ts new file mode 100644 index 00000000000000..4ffb6141875aa1 --- /dev/null +++ b/x-pack/test/screenshot_creation/apps/response_ops_docs/stack_connectors/servicenow_sir_connector.ts @@ -0,0 +1,48 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const commonScreenshots = getService('commonScreenshots'); + const screenshotDirectories = ['response_ops_docs', 'stack_connectors']; + const pageObjects = getPageObjects(['common', 'header']); + const actions = getService('actions'); + const testSubjects = getService('testSubjects'); + + describe('servicenow secops connector', function () { + beforeEach(async () => { + await pageObjects.common.navigateToApp('connectors'); + await pageObjects.header.waitUntilLoadingHasFinished(); + }); + + it('servicenow secops connector screenshots', async () => { + await pageObjects.common.navigateToApp('connectors'); + await pageObjects.header.waitUntilLoadingHasFinished(); + await actions.common.openNewConnectorForm('servicenow-sir'); + await testSubjects.setValue('nameInput', 'ServiceNow SecOps test connector'); + await testSubjects.setValue('credentialsApiUrlFromInput', 'https://dev123.service-now.com'); + await testSubjects.click('input'); + await commonScreenshots.takeScreenshot( + 'servicenow-sir-connector-oauth', + screenshotDirectories, + 1920, + 1600 + ); + await testSubjects.click('input'); + await testSubjects.setValue('connector-servicenow-username-form-input', 'testuser'); + await testSubjects.setValue('connector-servicenow-password-form-input', 'testpassword'); + await commonScreenshots.takeScreenshot( + 'servicenow-sir-connector-basic', + screenshotDirectories, + 1920, + 1400 + ); + await testSubjects.click('euiFlyoutCloseButton'); + }); + }); +} 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 89e6df3c68cd25..a73480c051ee4e 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 3c083278eedce0..4dc18852ec0e9d 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,36 +61,42 @@ "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", "prebuilt_rules_update_prebuilt_rules_package:server:ess": "npm run initialize-server:dr:default prebuilt_rules/update_prebuilt_rules_package ess", - "prebuilt_rules_update_prebuilt_rules_package:runner:ess": "npm run run-tests:dr:default prebuilt_rules/update_prebuilt_rules_package ess essEnvs", + "prebuilt_rules_update_prebuilt_rules_package:runner:ess": "npm run run-tests:dr:default prebuilt_rules/update_prebuilt_rules_package ess essEnv", "rule_execution_logic:server:serverless": "npm run initialize-server:dr:default rule_execution_logic serverless", "rule_execution_logic:runner:serverless": "npm run run-tests:dr:default rule_execution_logic serverless serverlessEnv", @@ -151,11 +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", + "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 4af66d1da4a93b..ae9533d8d3ce29 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 b980aef5f783ad..7176cc1421ec6c 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 8a4199ccfb44da..c920ca94da57b3 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 0b4bcea421c702..a9537d0426c01a 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 6a3fff87611da6..281fa37bb2d5d2 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/basic_essentials_license/rule_management/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/configs/ess.config.ts new file mode 100644 index 00000000000000..eaae21719c7209 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/configs/ess.config.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 { FtrConfigProviderContext } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile( + require.resolve('../../../../../config/ess/config.base.basic') + ); + + return { + ...functionalConfig.getAll(), + testFiles: [require.resolve('..')], + junit: { + reportName: 'Detection Engine ESS/ Rule management API Integration Tests', + }, + }; +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/configs/serverless.config.ts new file mode 100644 index 00000000000000..2e5b2f2b6ac69e --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/configs/serverless.config.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createTestConfig } from '../../../../../config/serverless/config.base.essentials'; + +export default createTestConfig({ + testFiles: [require.resolve('..')], + junit: { + reportName: 'Detection Engine Serverless/ Rule management API Integration Tests', + }, +}); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/coverage_overview.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/coverage_overview.ts new file mode 100644 index 00000000000000..b22b76e4b8670d --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/coverage_overview.ts @@ -0,0 +1,670 @@ +/* + * 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 { + CoverageOverviewRuleActivity, + CoverageOverviewRuleSource, + RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL, + ThreatArray, +} from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { + createPrebuiltRuleAssetSavedObjects, + createRuleAssetSavedObject, + createRule, + deleteAllRules, + installPrebuiltRulesAndTimelines, + installPrebuiltRules, + getCustomQueryRuleParams, + createNonSecurityRule, +} from '../../utils'; +import { getCoverageOverview } from '../../utils/rules/get_coverage_overview'; + +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const log = getService('log'); + const es = getService('es'); + + describe('coverage_overview', () => { + beforeEach(async () => { + await deleteAllRules(supertest, log); + }); + + // ESS only + describe('@ess specific tests', () => { + it('does NOT error when there exist some stack rules in addition to security detection rules', async () => { + await createNonSecurityRule(supertest); + + const rule1 = await createRule( + supertest, + log, + getCustomQueryRuleParams({ threat: generateThreatArray(1) }) + ); + + const body = await getCoverageOverview(supertest); + + expect(body).to.eql({ + coverage: { + T001: [rule1.id], + TA001: [rule1.id], + 'T001.001': [rule1.id], + }, + unmapped_rule_ids: [], + rules_data: { + [rule1.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + }); + + // Both serverless and ESS + describe('@serverless @ess tests', () => { + describe('base cases', () => { + it('returns an empty response if there are no rules', async () => { + const body = await getCoverageOverview(supertest); + + expect(body).to.eql({ + coverage: {}, + unmapped_rule_ids: [], + rules_data: {}, + }); + }); + + it('returns response with a single rule mapped to MITRE categories', async () => { + const rule1 = await createRule( + supertest, + log, + getCustomQueryRuleParams({ threat: generateThreatArray(1) }) + ); + + const body = await getCoverageOverview(supertest); + + expect(body).to.eql({ + coverage: { + T001: [rule1.id], + TA001: [rule1.id], + 'T001.001': [rule1.id], + }, + unmapped_rule_ids: [], + rules_data: { + [rule1.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + + it('returns response with an unmapped rule', async () => { + const rule1 = await createRule( + supertest, + log, + getCustomQueryRuleParams({ threat: undefined }) + ); + + const body = await getCoverageOverview(supertest); + + expect(body).to.eql({ + coverage: {}, + unmapped_rule_ids: [rule1.id], + rules_data: { + [rule1.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + }); + + describe('with filters', () => { + describe('search_term', () => { + it('returns response filtered by tactic', async () => { + await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', threat: generateThreatArray(1) }) + ); + const expectedRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-2', threat: generateThreatArray(2) }) + ); + + const body = await getCoverageOverview(supertest, { + search_term: 'TA002', + }); + + expect(body).to.eql({ + coverage: { + T002: [expectedRule.id], + TA002: [expectedRule.id], + 'T002.002': [expectedRule.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedRule.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + + it('returns response filtered by technique', async () => { + await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', threat: generateThreatArray(1) }) + ); + const expectedRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-2', threat: generateThreatArray(2) }) + ); + + const body = await getCoverageOverview(supertest, { + search_term: 'T002', + }); + + expect(body).to.eql({ + coverage: { + T002: [expectedRule.id], + TA002: [expectedRule.id], + 'T002.002': [expectedRule.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedRule.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + + it('returns response filtered by subtechnique', async () => { + await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', threat: generateThreatArray(1) }) + ); + const expectedRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-2', threat: generateThreatArray(2) }) + ); + + const body = await getCoverageOverview(supertest, { + search_term: 'T002.002', + }); + + expect(body).to.eql({ + coverage: { + T002: [expectedRule.id], + TA002: [expectedRule.id], + 'T002.002': [expectedRule.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedRule.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + + it('returns response filtered by rule name', async () => { + await createRule(supertest, log, getCustomQueryRuleParams({ rule_id: 'rule-1' })); + const expectedRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-2', name: 'rule-2' }) + ); + + const body = await getCoverageOverview(supertest, { + search_term: 'rule-2', + }); + + expect(body).to.eql({ + coverage: {}, + unmapped_rule_ids: [expectedRule.id], + rules_data: { + [expectedRule.id]: { + activity: 'disabled', + name: 'rule-2', + }, + }, + }); + }); + + it('returns response filtered by index pattern', async () => { + await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', index: ['index-pattern-1'] }) + ); + const expectedRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-2', index: ['index-pattern-2'] }) + ); + + const body = await getCoverageOverview(supertest, { + search_term: 'index-pattern-2', + }); + + expect(body).to.eql({ + coverage: {}, + unmapped_rule_ids: [expectedRule.id], + rules_data: { + [expectedRule.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + }); + + describe('activity', () => { + it('returns response filtered by disabled rules', async () => { + const expectedRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', threat: generateThreatArray(1) }) + ); + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-2', + enabled: true, + threat: generateThreatArray(2), + }) + ); + + const body = await getCoverageOverview(supertest, { + activity: [CoverageOverviewRuleActivity.Disabled], + }); + + expect(body).to.eql({ + coverage: { + T001: [expectedRule.id], + TA001: [expectedRule.id], + 'T001.001': [expectedRule.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedRule.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + + it('returns response filtered by enabled rules', async () => { + await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', threat: generateThreatArray(1) }) + ); + const expectedRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-2', + enabled: true, + threat: generateThreatArray(2), + }) + ); + + const body = await getCoverageOverview(supertest, { + activity: [CoverageOverviewRuleActivity.Enabled], + }); + + expect(body).to.eql({ + coverage: { + T002: [expectedRule.id], + TA002: [expectedRule.id], + 'T002.002': [expectedRule.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedRule.id]: { + activity: 'enabled', + name: 'Custom query rule', + }, + }, + }); + }); + + it('returns all rules if both enabled and disabled filters are specified in the request', async () => { + const expectedRule1 = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-1', + enabled: false, + name: 'Disabled rule', + threat: generateThreatArray(1), + }) + ); + const expectedRule2 = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-2', + enabled: true, + name: 'Enabled rule', + threat: generateThreatArray(2), + }) + ); + + const body = await getCoverageOverview(supertest, { + activity: [ + CoverageOverviewRuleActivity.Enabled, + CoverageOverviewRuleActivity.Disabled, + ], + }); + + expect(body).to.eql({ + coverage: { + T001: [expectedRule1.id], + TA001: [expectedRule1.id], + 'T001.001': [expectedRule1.id], + T002: [expectedRule2.id], + TA002: [expectedRule2.id], + 'T002.002': [expectedRule2.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedRule1.id]: { + activity: 'disabled', + name: 'Disabled rule', + }, + [expectedRule2.id]: { + activity: 'enabled', + name: 'Enabled rule', + }, + }, + }); + }); + + it('returns all rules if neither enabled and disabled filters are specified in the request', async () => { + const expectedRule1 = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-1', + enabled: false, + name: 'Disabled rule', + threat: generateThreatArray(1), + }) + ); + const expectedRule2 = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-2', + enabled: true, + name: 'Enabled rule', + threat: generateThreatArray(2), + }) + ); + + const body = await getCoverageOverview(supertest); + + expect(body).to.eql({ + coverage: { + T001: [expectedRule1.id], + TA001: [expectedRule1.id], + 'T001.001': [expectedRule1.id], + T002: [expectedRule2.id], + TA002: [expectedRule2.id], + 'T002.002': [expectedRule2.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedRule1.id]: { + activity: 'disabled', + name: 'Disabled rule', + }, + [expectedRule2.id]: { + activity: 'enabled', + name: 'Enabled rule', + }, + }, + }); + }); + }); + + describe('source', () => { + it('returns response filtered by custom rules', async () => { + await createPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ + rule_id: 'prebuilt-rule-1', + threat: generateThreatArray(1), + }), + ]); + await installPrebuiltRulesAndTimelines(es, supertest); + + const expectedRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', threat: generateThreatArray(2) }) + ); + + const body = await getCoverageOverview(supertest, { + source: [CoverageOverviewRuleSource.Custom], + }); + + expect(body).to.eql({ + coverage: { + T002: [expectedRule.id], + TA002: [expectedRule.id], + 'T002.002': [expectedRule.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedRule.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + + it('returns response filtered by prebuilt rules', async () => { + await createPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ + rule_id: 'prebuilt-rule-1', + threat: generateThreatArray(1), + }), + ]); + const { + results: { created }, + } = await installPrebuiltRules(es, supertest); + const expectedRule = created[0]; + + await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', threat: generateThreatArray(2) }) + ); + + const body = await getCoverageOverview(supertest, { + source: [CoverageOverviewRuleSource.Prebuilt], + }); + + expect(body).to.eql({ + coverage: { + T001: [expectedRule.id], + TA001: [expectedRule.id], + 'T001.001': [expectedRule.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedRule.id]: { + activity: 'disabled', + name: 'Query with a rule id', + }, + }, + }); + }); + + it('returns all rules if both custom and prebuilt filters are specified in the request', async () => { + await createPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ + rule_id: 'prebuilt-rule-1', + threat: generateThreatArray(1), + }), + ]); + const { + results: { created }, + } = await installPrebuiltRules(es, supertest); + const expectedPrebuiltRule = created[0]; + + const expectedCustomRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', threat: generateThreatArray(2) }) + ); + + const body = await getCoverageOverview(supertest, { + source: [CoverageOverviewRuleSource.Prebuilt, CoverageOverviewRuleSource.Custom], + }); + + expect(body).to.eql({ + coverage: { + T001: [expectedPrebuiltRule.id], + TA001: [expectedPrebuiltRule.id], + 'T001.001': [expectedPrebuiltRule.id], + T002: [expectedCustomRule.id], + TA002: [expectedCustomRule.id], + 'T002.002': [expectedCustomRule.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedPrebuiltRule.id]: { + activity: 'disabled', + name: 'Query with a rule id', + }, + [expectedCustomRule.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + + it('returns all rules if neither custom and prebuilt filters are specified in the request', async () => { + await createPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ + rule_id: 'prebuilt-rule-1', + threat: generateThreatArray(1), + }), + ]); + const { + results: { created }, + } = await installPrebuiltRules(es, supertest); + const expectedPrebuiltRule = created[0]; + + const expectedCustomRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', threat: generateThreatArray(2) }) + ); + + const body = await getCoverageOverview(supertest); + + expect(body).to.eql({ + coverage: { + T001: [expectedPrebuiltRule.id], + TA001: [expectedPrebuiltRule.id], + 'T001.001': [expectedPrebuiltRule.id], + T002: [expectedCustomRule.id], + TA002: [expectedCustomRule.id], + 'T002.002': [expectedCustomRule.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedPrebuiltRule.id]: { + activity: 'disabled', + name: 'Query with a rule id', + }, + [expectedCustomRule.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + }); + }); + + describe('error cases', async () => { + it('throws error when request body is not valid', async () => { + const { body } = await supertest + .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '1') + .set('x-elastic-internal-origin', 'foo') + .send({ filter: { source: ['give me all the rules'] } }) + .expect(400); + + expect(body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + '[request body]: Invalid value "give me all the rules" supplied to "filter,source"', + }); + }); + }); + }); + }); +}; + +function generateThreatArray(startIndex: number, count = 1): ThreatArray { + const result: ThreatArray = []; + + for (let i = 0; i < count; ++i) { + const indexName = (i + startIndex).toString().padStart(3, '0'); + + result.push({ + framework: 'MITRE ATT&CK', + tactic: { + id: `TA${indexName}`, + name: `Tactic ${indexName}`, + reference: `http://some-link-${indexName}`, + }, + technique: [ + { + id: `T${indexName}`, + name: `Technique ${indexName}`, + reference: `http://some-technique-link-${indexName}`, + subtechnique: [ + { + id: `T${indexName}.${indexName}`, + name: `Subtechnique ${indexName}`, + reference: `http://some-sub-technique-link-${indexName}`, + }, + ], + }, + ], + }); + } + + return result; +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/index.ts new file mode 100644 index 00000000000000..84d79ee1dd675b --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Rule management API', function () { + loadTestFile(require.resolve('./coverage_overview')); + }); +} 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 e508918b0538db..883267119e1735 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 ea876833ea839f..22a7c56a7c434f 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/actions/update_actions.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/actions/update_actions.ts index 333e76550a7406..3b8689b47fcb8e 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/actions/update_actions.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/actions/update_actions.ts @@ -218,7 +218,7 @@ export default ({ getService }: FtrProviderContext) => { ); await updateRule(supertest, ruleToUpdate); - const status = await getPrebuiltRulesAndTimelinesStatus(supertest); + const status = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(status.rules_not_installed).toBe(0); }); 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 2a8468856732f3..94a2ae73685348 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 9c61a18b25abc1..b4d510ae051745 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 20b52f7be5059f..44718cc8235292 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 15920ab3993b09..775a6da06d9d8d 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 9d03e3503a4809..74bbb3e6fe9a81 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 df64ace832d802..3e030f426d993f 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 114f4e628b7ac8..966c0d58c57f3c 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 80ec0198524b33..4ce0ff0d410598 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 8b19e9b0d8c6da..f58f354407f5f2 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 3e209f3c04e852..f5093ce32ed638 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 5438e929d9b22d..e18b5debbcd513 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 646062b09db91f..735bb46a9a6b08 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 01bb5ebdd21eb5..c9774e4f590a5a 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 3c67f4c7ad06cc..c7a7beb13099d8 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 4a9004910d3b52..04bc56b399b778 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 32e5ca5e8d0616..64763286226ae4 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 1f1e4d91d4a09f..85bfc549a3a341 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 870df90d3e4758..e67d7d9f5fc1e9 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'); @@ -526,7 +526,7 @@ export default ({ getService }: FtrProviderContext) => { }) .expect(200); - const status = await getPrebuiltRulesAndTimelinesStatus(supertest); + const status = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(status.rules_not_installed).toEqual(0); }); }); 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 87c0b1b3c43d87..68bfe2e9314b07 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 db6e8e11082e0d..492c3c13870c67 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/bundled_prebuilt_rules_package/install_latest_bundled_prebuilt_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/bundled_prebuilt_rules_package/install_latest_bundled_prebuilt_rules.ts index 60e0399df53fd6..33f79b1bff8d72 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/bundled_prebuilt_rules_package/install_latest_bundled_prebuilt_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/bundled_prebuilt_rules_package/install_latest_bundled_prebuilt_rules.ts @@ -22,17 +22,17 @@ export default ({ getService }: FtrProviderContext): void => { const es = getService('es'); const supertest = getService('supertest'); const log = getService('log'); + const retry = getService('retry'); - // FLAKY: https://github.com/elastic/kibana/issues/171380 /* This test simulates an air-gapped environment in which the user doesn't have access to EPR. /* We first download the package from the registry as done during build time, and then /* attempt to install it from the local file system. The API response from EPM provides /* us with the information of whether the package was installed from the registry or /* from a package that was bundled with Kibana */ - describe.skip('@ess @serverless @skipInQA install_bundled_prebuilt_rules', () => { + describe('@ess @serverless @skipInQA install_bundled_prebuilt_rules', () => { beforeEach(async () => { await deleteAllRules(supertest, log); - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); }); it('should list `security_detection_engine` as a bundled fleet package in the `fleet_package.json` file', async () => { @@ -52,7 +52,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should install prebuilt rules from the package that comes bundled with Kibana', async () => { // Verify that status is empty before package installation - const statusBeforePackageInstallation = await getPrebuiltRulesStatus(supertest); + const statusBeforePackageInstallation = await getPrebuiltRulesStatus(es, supertest); expect(statusBeforePackageInstallation.stats.num_prebuilt_rules_installed).toBe(0); expect(statusBeforePackageInstallation.stats.num_prebuilt_rules_to_install).toBe(0); expect(statusBeforePackageInstallation.stats.num_prebuilt_rules_to_upgrade).toBe(0); @@ -60,7 +60,8 @@ export default ({ getService }: FtrProviderContext): void => { const bundledInstallResponse = await installPrebuiltRulesPackageByVersion( es, supertest, - '99.0.0' + '99.0.0', + retry ); // As opposed to "registry" @@ -71,7 +72,7 @@ export default ({ getService }: FtrProviderContext): void => { await es.indices.refresh({ index: ALL_SAVED_OBJECT_INDICES }); // Verify that status is updated after package installation - const statusAfterPackageInstallation = await getPrebuiltRulesStatus(supertest); + const statusAfterPackageInstallation = await getPrebuiltRulesStatus(es, supertest); expect(statusAfterPackageInstallation.stats.num_prebuilt_rules_installed).toBe(0); expect(statusAfterPackageInstallation.stats.num_prebuilt_rules_to_install).toBeGreaterThan(0); expect(statusAfterPackageInstallation.stats.num_prebuilt_rules_to_upgrade).toBe(0); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/bundled_prebuilt_rules_package/prerelease_packages.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/bundled_prebuilt_rules_package/prerelease_packages.ts index 448e325892a5fd..83faf92bf7c847 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/bundled_prebuilt_rules_package/prerelease_packages.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/bundled_prebuilt_rules_package/prerelease_packages.ts @@ -22,6 +22,7 @@ export default ({ getService }: FtrProviderContext): void => { const es = getService('es'); const supertest = getService('supertest'); const log = getService('log'); + const retry = getService('retry'); /* This test makes use of the mock packages created in the '/fleet_bundled_packages' folder, /* in order to assert that, in production environments, the latest stable version of the package @@ -33,13 +34,13 @@ export default ({ getService }: FtrProviderContext): void => { describe('@ess @serverless @skipInQA prerelease_packages', () => { beforeEach(async () => { await deleteAllRules(supertest, log); - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); await deletePrebuiltRulesFleetPackage(supertest); }); it('should install latest stable version and ignore prerelease packages', async () => { // Verify that status is empty before package installation - const statusBeforePackageInstallation = await getPrebuiltRulesStatus(supertest); + const statusBeforePackageInstallation = await getPrebuiltRulesStatus(es, supertest); expect(statusBeforePackageInstallation.stats.num_prebuilt_rules_installed).toBe(0); expect(statusBeforePackageInstallation.stats.num_prebuilt_rules_to_install).toBe(0); expect(statusBeforePackageInstallation.stats.num_prebuilt_rules_to_upgrade).toBe(0); @@ -47,7 +48,8 @@ export default ({ getService }: FtrProviderContext): void => { // Install package without specifying version to check if latest stable version is installed const fleetPackageInstallationResponse = await installPrebuiltRulesPackageViaFleetAPI( es, - supertest + supertest, + retry ); expect(fleetPackageInstallationResponse.items.length).toBe(1); @@ -59,7 +61,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(prebuiltRulesFleetPackage.status).toBe(200); // Get status of our prebuilt rules (nothing should be instaled yet) - const statusAfterPackageInstallation = await getPrebuiltRulesStatus(supertest); + const statusAfterPackageInstallation = await getPrebuiltRulesStatus(es, supertest); expect(statusAfterPackageInstallation.stats.num_prebuilt_rules_installed).toBe(0); expect(statusAfterPackageInstallation.stats.num_prebuilt_rules_to_install).toBe(1); // 1 rule in package 99.0.0 expect(statusAfterPackageInstallation.stats.num_prebuilt_rules_to_upgrade).toBe(0); @@ -68,7 +70,7 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRules(es, supertest); // Verify that status is updated after package installation - const statusAfterRulesInstallation = await getPrebuiltRulesStatus(supertest); + const statusAfterRulesInstallation = await getPrebuiltRulesStatus(es, supertest); expect(statusAfterRulesInstallation.stats.num_prebuilt_rules_installed).toBe(1); // 1 rule in package 99.0.0 expect(statusAfterRulesInstallation.stats.num_prebuilt_rules_to_install).toBe(0); expect(statusAfterRulesInstallation.stats.num_prebuilt_rules_to_upgrade).toBe(0); 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 9b056de5b82524..2d96db1382f359 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 29b6ec1c4cc6cb..89bd5f723a9fe4 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/large_prebuilt_rules_package/install_large_prebuilt_rules_package.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/large_prebuilt_rules_package/install_large_prebuilt_rules_package.ts index e059cab5ae64b2..17a6723a53b88f 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/large_prebuilt_rules_package/install_large_prebuilt_rules_package.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/large_prebuilt_rules_package/install_large_prebuilt_rules_package.ts @@ -21,17 +21,20 @@ export default ({ getService }: FtrProviderContext): void => { describe('@ess @serverless @skipInQA install_large_prebuilt_rules_package', () => { beforeEach(async () => { await deleteAllRules(supertest, log); - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); }); afterEach(async () => { await deleteAllRules(supertest, log); - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); }); it('should install a package containing 15000 prebuilt rules without crashing', async () => { // Verify that status is empty before package installation - const statusBeforePackageInstallation = await getPrebuiltRulesAndTimelinesStatus(supertest); + const statusBeforePackageInstallation = await getPrebuiltRulesAndTimelinesStatus( + es, + supertest + ); expect(statusBeforePackageInstallation.rules_installed).toBe(0); expect(statusBeforePackageInstallation.rules_not_installed).toBe(0); expect(statusBeforePackageInstallation.rules_not_updated).toBe(0); @@ -40,7 +43,10 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRulesAndTimelines(es, supertest); // Verify that status is updated after package installation - const statusAfterPackageInstallation = await getPrebuiltRulesAndTimelinesStatus(supertest); + const statusAfterPackageInstallation = await getPrebuiltRulesAndTimelinesStatus( + es, + supertest + ); expect(statusAfterPackageInstallation.rules_installed).toBe(750); expect(statusAfterPackageInstallation.rules_not_installed).toBe(0); expect(statusAfterPackageInstallation.rules_not_updated).toBe(0); 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 7fec27a5d9276f..eebdce7697d3c8 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 89916d26e7a736..91836b39977749 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/management/fleet_integration.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/fleet_integration.ts index 5453ff5b34c773..a9653d7593209d 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/fleet_integration.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/fleet_integration.ts @@ -20,23 +20,26 @@ export default ({ getService }: FtrProviderContext): void => { const es = getService('es'); const supertest = getService('supertest'); const log = getService('log'); + const retry = getService('retry'); describe('@ess @serverless @skipInQA install_prebuilt_rules_from_real_package', () => { beforeEach(async () => { await deletePrebuiltRulesFleetPackage(supertest); await deleteAllRules(supertest, log); - await deleteAllTimelines(es); - await deleteAllPrebuiltRuleAssets(es); + await deleteAllTimelines(es, log); + await deleteAllPrebuiltRuleAssets(es, log); }); /** * Unlike other tests that use mocks, this test uses actual rules from the * package storage and checks that they are installed. */ - // TODO: Fix and unskip https://github.com/elastic/kibana/issues/172107 - it.skip('should install prebuilt rules from the package storage', async () => { + it('should install prebuilt rules from the package storage', async () => { // Verify that status is empty before package installation - const statusBeforePackageInstallation = await getPrebuiltRulesAndTimelinesStatus(supertest); + const statusBeforePackageInstallation = await getPrebuiltRulesAndTimelinesStatus( + es, + supertest + ); expect(statusBeforePackageInstallation.rules_installed).toBe(0); expect(statusBeforePackageInstallation.rules_not_installed).toBe(0); expect(statusBeforePackageInstallation.rules_not_updated).toBe(0); @@ -45,10 +48,14 @@ export default ({ getService }: FtrProviderContext): void => { es, supertest, overrideExistingPackage: true, + retryService: retry, }); // Verify that status is updated after package installation - const statusAfterPackageInstallation = await getPrebuiltRulesAndTimelinesStatus(supertest); + const statusAfterPackageInstallation = await getPrebuiltRulesAndTimelinesStatus( + es, + supertest + ); expect(statusAfterPackageInstallation.rules_installed).toBe(0); expect(statusAfterPackageInstallation.rules_not_installed).toBeGreaterThan(0); expect(statusAfterPackageInstallation.rules_not_updated).toBe(0); @@ -59,7 +66,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(response.rules_updated).toBe(0); // Verify that status is updated after rules installation - const statusAfterRuleInstallation = await getPrebuiltRulesAndTimelinesStatus(supertest); + const statusAfterRuleInstallation = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(statusAfterRuleInstallation.rules_installed).toBe(response.rules_installed); expect(statusAfterRuleInstallation.rules_not_installed).toBe(0); expect(statusAfterRuleInstallation.rules_not_updated).toBe(0); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/get_prebuilt_rules_status.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/get_prebuilt_rules_status.ts index 16dba276169474..eca4e51bcc6f81 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/get_prebuilt_rules_status.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/get_prebuilt_rules_status.ts @@ -31,12 +31,12 @@ export default ({ getService }: FtrProviderContext): void => { describe('@ess @serverless @skipInQA Prebuilt Rules status', () => { describe('get_prebuilt_rules_status', () => { beforeEach(async () => { - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); await deleteAllRules(supertest, log); }); it('should return empty structure when no prebuilt rule assets', async () => { - const { stats } = await getPrebuiltRulesStatus(supertest); + const { stats } = await getPrebuiltRulesStatus(es, supertest); expect(stats).toMatchObject({ num_prebuilt_rules_installed: 0, num_prebuilt_rules_to_install: 0, @@ -48,7 +48,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should not update the prebuilt rule status when a custom rule is added', async () => { await createRule(supertest, log, getSimpleRule()); - const { stats } = await getPrebuiltRulesStatus(supertest); + const { stats } = await getPrebuiltRulesStatus(es, supertest); expect(stats).toMatchObject({ num_prebuilt_rules_installed: 0, num_prebuilt_rules_to_install: 0, @@ -68,7 +68,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should return the number of rules available to install', async () => { await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - const { stats } = await getPrebuiltRulesStatus(supertest); + const { stats } = await getPrebuiltRulesStatus(es, supertest); expect(stats).toMatchObject({ num_prebuilt_rules_installed: 0, num_prebuilt_rules_to_install: RULES_COUNT, @@ -81,7 +81,7 @@ export default ({ getService }: FtrProviderContext): void => { await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); await installPrebuiltRules(es, supertest); - const { stats } = await getPrebuiltRulesStatus(supertest); + const { stats } = await getPrebuiltRulesStatus(es, supertest); expect(stats).toMatchObject({ num_prebuilt_rules_installed: RULES_COUNT, num_prebuilt_rules_to_install: 0, @@ -95,7 +95,7 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRules(es, supertest); await deleteRule(supertest, 'rule-1'); - const { stats } = await getPrebuiltRulesStatus(supertest); + const { stats } = await getPrebuiltRulesStatus(es, supertest); expect(stats).toMatchObject({ num_prebuilt_rules_installed: RULES_COUNT - 1, num_prebuilt_rules_to_install: 1, @@ -110,12 +110,12 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRules(es, supertest); // Clear previous rule assets - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); // Increment the version of one of the installed rules and create the new rule assets ruleAssetSavedObjects[0]['security-rule'].version += 1; await createPrebuiltRuleAssetSavedObjects(es, ruleAssetSavedObjects); - const { stats } = await getPrebuiltRulesStatus(supertest); + const { stats } = await getPrebuiltRulesStatus(es, supertest); expect(stats).toMatchObject({ num_prebuilt_rules_installed: RULES_COUNT, num_prebuilt_rules_to_install: 0, @@ -130,14 +130,14 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRules(es, supertest); // Clear previous rule assets - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); // Increment the version of one of the installed rules and create the new rule assets ruleAssetSavedObjects[0]['security-rule'].version += 1; await createPrebuiltRuleAssetSavedObjects(es, ruleAssetSavedObjects); // Upgrade all rules await upgradePrebuiltRules(es, supertest); - const { stats } = await getPrebuiltRulesStatus(supertest); + const { stats } = await getPrebuiltRulesStatus(es, supertest); expect(stats).toMatchObject({ num_prebuilt_rules_installed: RULES_COUNT, num_prebuilt_rules_to_install: 0, @@ -152,11 +152,11 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRules(es, supertest); // Clear previous rule assets - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); // Recreate the rules without bumping any versions await createPrebuiltRuleAssetSavedObjects(es, ruleAssetSavedObjects); - const { stats } = await getPrebuiltRulesStatus(supertest); + const { stats } = await getPrebuiltRulesStatus(es, supertest); expect(stats).toMatchObject({ num_prebuilt_rules_installed: RULES_COUNT, num_prebuilt_rules_to_install: 0, @@ -179,7 +179,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should return the number of rules available to install', async () => { await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - const { stats } = await getPrebuiltRulesStatus(supertest); + const { stats } = await getPrebuiltRulesStatus(es, supertest); expect(stats).toMatchObject({ num_prebuilt_rules_installed: 0, num_prebuilt_rules_to_install: RULES_COUNT, @@ -192,7 +192,7 @@ export default ({ getService }: FtrProviderContext): void => { await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); await installPrebuiltRules(es, supertest); - const { stats } = await getPrebuiltRulesStatus(supertest); + const { stats } = await getPrebuiltRulesStatus(es, supertest); expect(stats).toMatchObject({ num_prebuilt_rules_installed: RULES_COUNT, num_prebuilt_rules_to_install: 0, @@ -206,7 +206,7 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRules(es, supertest); await deleteRule(supertest, 'rule-1'); - const { stats } = await getPrebuiltRulesStatus(supertest); + const { stats } = await getPrebuiltRulesStatus(es, supertest); expect(stats).toMatchObject({ num_prebuilt_rules_installed: RULES_COUNT - 1, num_prebuilt_rules_to_install: 1, @@ -224,7 +224,7 @@ export default ({ getService }: FtrProviderContext): void => { createRuleAssetSavedObject({ rule_id: 'rule-1', version: 3 }), ]); - const { stats } = await getPrebuiltRulesStatus(supertest); + const { stats } = await getPrebuiltRulesStatus(es, supertest); expect(stats).toMatchObject({ num_prebuilt_rules_installed: RULES_COUNT, num_prebuilt_rules_to_install: 0, @@ -238,14 +238,14 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRules(es, supertest); // Delete the previous versions of rule assets - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); // Add a new rule version await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ createRuleAssetSavedObject({ rule_id: 'rule-1', version: 3 }), ]); - const { stats } = await getPrebuiltRulesStatus(supertest); + const { stats } = await getPrebuiltRulesStatus(es, supertest); expect(stats).toMatchObject({ num_prebuilt_rules_installed: RULES_COUNT, num_prebuilt_rules_to_install: 0, @@ -261,7 +261,7 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRules(es, supertest); // Delete the previous versions of rule assets - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); // Add a new rule version await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ @@ -271,7 +271,7 @@ export default ({ getService }: FtrProviderContext): void => { // Upgrade the rule await upgradePrebuiltRules(es, supertest); - const { stats } = await getPrebuiltRulesStatus(supertest); + const { stats } = await getPrebuiltRulesStatus(es, supertest); expect(stats).toMatchObject({ num_prebuilt_rules_installed: RULES_COUNT, num_prebuilt_rules_to_install: 0, @@ -286,12 +286,12 @@ export default ({ getService }: FtrProviderContext): void => { describe('get_prebuilt_rules_status - legacy', () => { beforeEach(async () => { - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); await deleteAllRules(supertest, log); }); it('should return empty structure when no rules package installed', async () => { - const body = await getPrebuiltRulesAndTimelinesStatus(supertest); + const body = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(body).toMatchObject({ rules_custom_installed: 0, @@ -304,7 +304,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should show that one custom rule is installed when a custom rule is added', async () => { await createRule(supertest, log, getSimpleRule()); - const body = await getPrebuiltRulesAndTimelinesStatus(supertest); + const body = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(body).toMatchObject({ rules_custom_installed: 1, rules_installed: 0, @@ -324,7 +324,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should return the number of rules available to install', async () => { await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - const body = await getPrebuiltRulesAndTimelinesStatus(supertest); + const body = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(body).toMatchObject({ rules_custom_installed: 0, @@ -338,7 +338,7 @@ export default ({ getService }: FtrProviderContext): void => { await createPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); await installPrebuiltRulesAndTimelines(es, supertest); - const body = await getPrebuiltRulesAndTimelinesStatus(supertest); + const body = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(body).toMatchObject({ rules_custom_installed: 0, rules_installed: RULES_COUNT, @@ -352,7 +352,7 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRulesAndTimelines(es, supertest); await deleteRule(supertest, 'rule-1'); - const body = await getPrebuiltRulesAndTimelinesStatus(supertest); + const body = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(body).toMatchObject({ rules_custom_installed: 0, rules_installed: RULES_COUNT - 1, @@ -367,12 +367,12 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRulesAndTimelines(es, supertest); // Clear previous rule assets - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); // Increment the version of one of the installed rules and create the new rule assets ruleAssetSavedObjects[0]['security-rule'].version += 1; await createPrebuiltRuleAssetSavedObjects(es, ruleAssetSavedObjects); - const body = await getPrebuiltRulesAndTimelinesStatus(supertest); + const body = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(body).toMatchObject({ rules_custom_installed: 0, rules_installed: RULES_COUNT, @@ -387,11 +387,11 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRulesAndTimelines(es, supertest); // Clear previous rule assets - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); // Recreate the rules without bumping any versions await createPrebuiltRuleAssetSavedObjects(es, ruleAssetSavedObjects); - const body = await getPrebuiltRulesAndTimelinesStatus(supertest); + const body = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(body).toMatchObject({ rules_custom_installed: 0, rules_installed: RULES_COUNT, @@ -413,7 +413,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should return the number of rules available to install', async () => { await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); - const body = await getPrebuiltRulesAndTimelinesStatus(supertest); + const body = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(body).toMatchObject({ rules_custom_installed: 0, @@ -427,7 +427,7 @@ export default ({ getService }: FtrProviderContext): void => { await createHistoricalPrebuiltRuleAssetSavedObjects(es, getRuleAssetSavedObjects()); await installPrebuiltRulesAndTimelines(es, supertest); - const body = await getPrebuiltRulesAndTimelinesStatus(supertest); + const body = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(body).toMatchObject({ rules_custom_installed: 0, rules_installed: RULES_COUNT, @@ -441,7 +441,7 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRulesAndTimelines(es, supertest); await deleteRule(supertest, 'rule-1'); - const body = await getPrebuiltRulesAndTimelinesStatus(supertest); + const body = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(body).toMatchObject({ rules_custom_installed: 0, rules_installed: RULES_COUNT - 1, @@ -459,7 +459,7 @@ export default ({ getService }: FtrProviderContext): void => { createRuleAssetSavedObject({ rule_id: 'rule-1', version: 3 }), ]); - const body = await getPrebuiltRulesAndTimelinesStatus(supertest); + const body = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(body).toMatchObject({ rules_custom_installed: 0, rules_installed: RULES_COUNT, @@ -473,14 +473,14 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRulesAndTimelines(es, supertest); // Delete the previous versions of rule assets - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); // Add a new rule version await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ createRuleAssetSavedObject({ rule_id: 'rule-1', version: 3 }), ]); - const body = await getPrebuiltRulesAndTimelinesStatus(supertest); + const body = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(body).toMatchObject({ rules_custom_installed: 0, rules_installed: RULES_COUNT, diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/get_prebuilt_timelines_status.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/get_prebuilt_timelines_status.ts index 9acef16bfbeb14..60b1e5e1ba526a 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/get_prebuilt_timelines_status.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/get_prebuilt_timelines_status.ts @@ -16,14 +16,15 @@ import { export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); + const log = getService('log'); describe('@ess @serverless @skipInQA get_prebuilt_timelines_status', () => { beforeEach(async () => { - await deleteAllTimelines(es); + await deleteAllTimelines(es, log); }); it('should return the number of timeline templates available to install', async () => { - const body = await getPrebuiltRulesAndTimelinesStatus(supertest); + const body = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(body).toMatchObject({ timelines_installed: 0, @@ -36,7 +37,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should return the number of installed timeline templates after installing them', async () => { await installPrebuiltRulesAndTimelines(es, supertest); - const body = await getPrebuiltRulesAndTimelinesStatus(supertest); + const body = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(body).toMatchObject({ timelines_installed: expect.any(Number), timelines_not_installed: 0, diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/install_and_upgrade_prebuilt_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/install_and_upgrade_prebuilt_rules.ts index a75c8f87bd783c..4e7ea364f0968b 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/install_and_upgrade_prebuilt_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/install_and_upgrade_prebuilt_rules.ts @@ -30,8 +30,8 @@ export default ({ getService }: FtrProviderContext): void => { describe('@ess @serverless @skipInQA install and upgrade prebuilt rules with mock rule assets', () => { beforeEach(async () => { await deleteAllRules(supertest, log); - await deleteAllTimelines(es); - await deleteAllPrebuiltRuleAssets(es); + await deleteAllTimelines(es, log); + await deleteAllPrebuiltRuleAssets(es, log); }); describe(`rule package without historical versions`, () => { @@ -80,7 +80,7 @@ export default ({ getService }: FtrProviderContext): void => { await deleteRule(supertest, 'rule-1'); // Check that one prebuilt rule is missing - const statusResponse = await getPrebuiltRulesAndTimelinesStatus(supertest); + const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(statusResponse.rules_not_installed).toBe(1); // Call the install prebuilt rules again and check that the missing rule was installed @@ -96,13 +96,13 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRulesAndTimelines(es, supertest); // Clear previous rule assets - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); // Increment the version of one of the installed rules and create the new rule assets ruleAssetSavedObjects[0]['security-rule'].version += 1; await createPrebuiltRuleAssetSavedObjects(es, ruleAssetSavedObjects); // Check that one prebuilt rule status shows that one rule is outdated - const statusResponse = await getPrebuiltRulesAndTimelinesStatus(supertest); + const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(statusResponse.rules_not_updated).toBe(1); // Call the install prebuilt rules again and check that the outdated rule was updated @@ -117,7 +117,7 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRulesAndTimelines(es, supertest); // Check that all prebuilt rules were installed - const statusResponse = await getPrebuiltRulesAndTimelinesStatus(supertest); + const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(statusResponse.rules_not_installed).toBe(0); expect(statusResponse.rules_not_updated).toBe(0); @@ -162,7 +162,7 @@ export default ({ getService }: FtrProviderContext): void => { await deleteRule(supertest, 'rule-1'); // Check that one prebuilt rule is missing - const statusResponse = await getPrebuiltRulesStatus(supertest); + const statusResponse = await getPrebuiltRulesStatus(es, supertest); expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(1); // Call the install prebuilt rules again and check that the missing rule was installed @@ -177,13 +177,13 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRules(es, supertest); // Clear previous rule assets - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); // Increment the version of one of the installed rules and create the new rule assets ruleAssetSavedObjects[0]['security-rule'].version += 1; await createPrebuiltRuleAssetSavedObjects(es, ruleAssetSavedObjects); // Check that one prebuilt rule status shows that one rule is outdated - const statusResponse = await getPrebuiltRulesStatus(supertest); + const statusResponse = await getPrebuiltRulesStatus(es, supertest); expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(0); expect(statusResponse.stats.num_prebuilt_rules_to_upgrade).toBe(1); @@ -199,7 +199,7 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRules(es, supertest); // Check that all prebuilt rules were installed - const statusResponse = await getPrebuiltRulesStatus(supertest); + const statusResponse = await getPrebuiltRulesStatus(es, supertest); expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(0); expect(statusResponse.stats.num_prebuilt_rules_to_upgrade).toBe(0); @@ -258,7 +258,7 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRulesAndTimelines(es, supertest); // Check that all prebuilt rules were installed - const statusResponse = await getPrebuiltRulesAndTimelinesStatus(supertest); + const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(statusResponse.rules_not_installed).toBe(0); // Call the install prebuilt rules again and check that no rules were installed @@ -276,7 +276,7 @@ export default ({ getService }: FtrProviderContext): void => { await deleteRule(supertest, 'rule-1'); // Check that one prebuilt rule is missing - const statusResponse = await getPrebuiltRulesAndTimelinesStatus(supertest); + const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(statusResponse.rules_not_installed).toBe(1); // Call the install prebuilt rules endpoint again and check that the missing rule was installed @@ -296,7 +296,7 @@ export default ({ getService }: FtrProviderContext): void => { ]); // Check that one prebuilt rule status shows that one rule is outdated - const statusResponse = await getPrebuiltRulesAndTimelinesStatus(supertest); + const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(statusResponse.rules_not_updated).toBe(1); // Call the install prebuilt rules again and check that the outdated rule was updated @@ -304,7 +304,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(response.rules_installed).toBe(0); expect(response.rules_updated).toBe(1); - const _statusResponse = await getPrebuiltRulesAndTimelinesStatus(supertest); + const _statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(_statusResponse.rules_not_installed).toBe(0); expect(_statusResponse.rules_not_updated).toBe(0); }); @@ -315,7 +315,7 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRulesAndTimelines(es, supertest); // Clear previous rule assets - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); // Add a new rule version await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ @@ -323,7 +323,7 @@ export default ({ getService }: FtrProviderContext): void => { ]); // Check that one prebuilt rule status shows that one rule is outdated - const statusResponse = await getPrebuiltRulesAndTimelinesStatus(supertest); + const statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(statusResponse.rules_not_updated).toBe(1); expect(statusResponse.rules_not_installed).toBe(0); @@ -332,7 +332,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(response.rules_installed).toBe(0); expect(response.rules_updated).toBe(1); - const _statusResponse = await getPrebuiltRulesAndTimelinesStatus(supertest); + const _statusResponse = await getPrebuiltRulesAndTimelinesStatus(es, supertest); expect(_statusResponse.rules_not_updated).toBe(0); expect(_statusResponse.rules_not_installed).toBe(0); }); @@ -366,7 +366,7 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRules(es, supertest); // Check that all prebuilt rules were installed - const statusResponse = await getPrebuiltRulesStatus(supertest); + const statusResponse = await getPrebuiltRulesStatus(es, supertest); expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(0); // Call the install prebuilt rules again and check that no rules were installed @@ -384,7 +384,7 @@ export default ({ getService }: FtrProviderContext): void => { await deleteRule(supertest, 'rule-1'); // Check that one prebuilt rule is missing - const statusResponse = await getPrebuiltRulesStatus(supertest); + const statusResponse = await getPrebuiltRulesStatus(es, supertest); expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(1); // Call the install prebuilt rules endpoint again and check that the missing rule was installed @@ -404,7 +404,7 @@ export default ({ getService }: FtrProviderContext): void => { ]); // Check that the prebuilt rule status shows that one rule is outdated - const statusResponse = await getPrebuiltRulesStatus(supertest); + const statusResponse = await getPrebuiltRulesStatus(es, supertest); expect(statusResponse.stats.num_prebuilt_rules_to_upgrade).toBe(1); // Call the upgrade prebuilt rules endpoint and check that the outdated rule was updated @@ -412,7 +412,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(response.summary.succeeded).toBe(1); expect(response.summary.total).toBe(1); - const status = await getPrebuiltRulesStatus(supertest); + const status = await getPrebuiltRulesStatus(es, supertest); expect(status.stats.num_prebuilt_rules_to_install).toBe(0); expect(status.stats.num_prebuilt_rules_to_upgrade).toBe(0); }); @@ -423,7 +423,7 @@ export default ({ getService }: FtrProviderContext): void => { await installPrebuiltRules(es, supertest); // Clear previous rule assets - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); // Add a new rule version await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ @@ -431,7 +431,7 @@ export default ({ getService }: FtrProviderContext): void => { ]); // Check that the prebuilt rule status shows that one rule is outdated - const statusResponse = await getPrebuiltRulesStatus(supertest); + const statusResponse = await getPrebuiltRulesStatus(es, supertest); expect(statusResponse.stats.num_prebuilt_rules_to_upgrade).toBe(1); expect(statusResponse.stats.num_prebuilt_rules_to_install).toBe(0); @@ -440,7 +440,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(response.summary.succeeded).toBe(1); expect(response.summary.total).toBe(1); - const status = await getPrebuiltRulesStatus(supertest); + const status = await getPrebuiltRulesStatus(es, supertest); expect(status.stats.num_prebuilt_rules_to_install).toBe(0); expect(status.stats.num_prebuilt_rules_to_upgrade).toBe(0); }); 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 0def0b0f17a5f3..23b22b80b8573c 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 5f6716342c9246..c05eef46de73bd 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/prebuilt_rules/update_prebuilt_rules_package/update_prebuilt_rules_package.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/update_prebuilt_rules_package/update_prebuilt_rules_package.ts index 981bfd71267800..688816569c181a 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/update_prebuilt_rules_package/update_prebuilt_rules_package.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/update_prebuilt_rules_package/update_prebuilt_rules_package.ts @@ -29,6 +29,7 @@ export default ({ getService }: FtrProviderContext): void => { const es = getService('es'); const supertest = getService('supertest'); const log = getService('log'); + const retry = getService('retry'); let currentVersion: string; let previousVersion: string; @@ -89,14 +90,14 @@ export default ({ getService }: FtrProviderContext): void => { beforeEach(async () => { await deleteAllRules(supertest, log); - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); }); it('should allow user to install prebuilt rules from scratch, then install new rules and upgrade existing rules from the new package', async () => { // PART 1: Install prebuilt rules from the previous minor version as the current version // Verify that status is empty before package installation - const statusBeforePackageInstallation = await getPrebuiltRulesStatus(supertest); + const statusBeforePackageInstallation = await getPrebuiltRulesStatus(es, supertest); expect(statusBeforePackageInstallation.stats.num_prebuilt_rules_installed).toBe(0); expect(statusBeforePackageInstallation.stats.num_prebuilt_rules_to_install).toBe(0); expect(statusBeforePackageInstallation.stats.num_prebuilt_rules_to_upgrade).toBe(0); @@ -105,14 +106,15 @@ export default ({ getService }: FtrProviderContext): void => { const installPreviousPackageResponse = await installPrebuiltRulesPackageByVersion( es, supertest, - previousVersion + previousVersion, + retry ); expect(installPreviousPackageResponse._meta.install_source).toBe('registry'); expect(installPreviousPackageResponse.items.length).toBeGreaterThan(0); // Verify that status is updated after the installation of package "N-1" - const statusAfterPackageInstallation = await getPrebuiltRulesStatus(supertest); + const statusAfterPackageInstallation = await getPrebuiltRulesStatus(es, supertest); expect(statusAfterPackageInstallation.stats.num_prebuilt_rules_installed).toBe(0); expect(statusAfterPackageInstallation.stats.num_prebuilt_rules_to_install).toBeGreaterThan(0); expect(statusAfterPackageInstallation.stats.num_prebuilt_rules_to_upgrade).toBe(0); @@ -157,12 +159,13 @@ export default ({ getService }: FtrProviderContext): void => { const installLatestPackageResponse = await installPrebuiltRulesPackageByVersion( es, supertest, - currentVersion + currentVersion, + retry ); expect(installLatestPackageResponse.items.length).toBeGreaterThanOrEqual(0); // Verify status after intallation of the latest package - const statusAfterLatestPackageInstallation = await getPrebuiltRulesStatus(supertest); + const statusAfterLatestPackageInstallation = await getPrebuiltRulesStatus(es, supertest); expect( statusAfterLatestPackageInstallation.stats.num_prebuilt_rules_installed ).toBeGreaterThan(0); 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 92303ddd2445ff..1519833895210a 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 9e4f790d3ded78..14da93e9eb6c23 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 f7e48ac30a6eb9..2c3a0570ea51a3 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 e85103b67cd22f..98b711a5837e7b 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 4fbad71828a44e..2f04f8c18d6b4d 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 3c214b340ab742..4a8c7d24f7b362 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 49ed77a4dc48ec..aad42c2e4ea6c9 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 aa07404205652b..b3d954773b518f 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 bcfbf77ef23e10..95ba7de98eab7e 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 11f644695b9dc1..3c8ba6dd0ba995 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 ed7c4e3d11a71b..89f417d00f5513 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 1966ab101ab0cc..ed325c15dae407 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 10d768152ddc3b..a4f4df78680057 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 85a5814fdf732e..53a8ac37e5abb5 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 9db8143c6ad3c4..214217cdbfe5b4 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 bbf6c6c0e3f7b4..392716ccea85b8 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 1f43395efcd902..0a425d6845878d 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 03af11e239c68b..3b2921f90dce2f 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 8787a51871125a..ad5f546d2fd6cb 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 9aea83afb95d00..aff5d52eeac94d 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 38930bafa564ea..feabae41ebea1a 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 e387a2f840c41c..c0a197e64f2924 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 734583d009ca38..7d97563f4c1b56 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 dce4886bc1ba57..c13702f37bef56 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 d7c645c115082e..2a51b1da2e4448 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 0221afa650a099..ee0cff6c55b86d 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 5be8cda08a16d0..5d9fdbac927df8 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 bde3148c843206..42ec2a27c5d7f6 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 0a58efd57359f5..5af0d9a8814cde 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 aaeb01904e0668..bd63c3150588a5 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 94ea13264eaabc..978d5f2268dee8 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 0f86bfe4d5ebb1..86c288c6daceaa 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 3463518a51af47..a45636b08ea0d5 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_management/get_rule_management_filters.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_management/get_rule_management_filters.ts index 37853b865e16da..79e632c7b0493e 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_management/get_rule_management_filters.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_management/get_rule_management_filters.ts @@ -89,7 +89,7 @@ export default ({ getService }: FtrProviderContext): void => { describe('when there are installed prebuilt rules', () => { beforeEach(async () => { - await deleteAllPrebuiltRuleAssets(es); + await deleteAllPrebuiltRuleAssets(es, log); await installMockPrebuiltRules(supertest, es); }); 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 f8c742a881ded9..30b7daf5d02b31 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 7ed12808c452e7..e95130ab738919 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 d267e6398eca0e..43abe1c3b591bd 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 0bf1bd43ab99cb..94c07f20d7d603 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 06b530c113352c..613ed6b5de9b3d 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 5c1925861aa395..9d0830b927c6e9 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 81c0e718814663..853ffc64438905 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 8c8804bc59c687..4a9740358e9287 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 9b380d3a0a40af..f15ea25fbdd16d 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 a26c3dba358c51..d7a4ba65b98da0 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 dcbaf8b10615ea..6780a639cbd8cd 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 1774ff3ae28eab..fa76a9537ab3a2 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 017b5dec486b19..ea732668ee1552 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 3c6a3e7735a4af..69dc28fddf6b49 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 49c9ffdd817fd8..d3a7a124ae59cc 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 38a5a5a07a9f0e..3338634ea0511c 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 787542036e084c..2626c12f9a8255 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 99bd2458c69a4e..2601dba13f00c7 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 59e01e74c719c5..51ea7037f1d401 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 d8e9843c3eb92d..a2dd062fa0ac33 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/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/index.ts index 0fed526f9ef3f5..415569827b85db 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/index.ts @@ -25,3 +25,4 @@ export * from './wait_for_index_to_populate'; export * from './get_stats'; export * from './get_detection_metrics_from_body'; export * from './get_stats_url'; +export * from './retry'; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/refresh_index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/refresh_index.ts index f888216cb6eed6..16439adb00e367 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/refresh_index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/refresh_index.ts @@ -6,6 +6,7 @@ */ import type { Client } from '@elastic/elasticsearch'; +import { ALL_SAVED_OBJECT_INDICES } from '@kbn/core-saved-objects-server'; /** * Refresh an index, making changes available to search. @@ -17,3 +18,28 @@ export const refreshIndex = async (es: Client, index?: string) => { index, }); }; + +/** + * Refresh an index, making changes available to search. + * Reusable utility which refreshes all saved object indices, to make them available for search, especially + * useful when needing to perform a search on an index that has just been written to. + * + * An example of this when installing the prebuilt detection rules SO of type 'security-rule': + * the savedObjectsClient does this with a call with explicit `refresh: false`. + * So, despite of the fact that the endpoint waits until the prebuilt rule will be + * successfully indexed, it doesn't wait until they become "visible" for subsequent read + * operations. + * + * Additionally, this method clears the cache for all saved object indices. This helps in cases in which + * saved object is read, then written to, and then read again, and the second read returns stale data. + * @param es The Elasticsearch client + */ +export const refreshSavedObjectIndices = async (es: Client) => { + // Refresh indices to prevent a race condition between a write and subsequent read operation. To + // fix it deterministically we have to refresh saved object indices and wait until it's done. + await es.indices.refresh({ index: ALL_SAVED_OBJECT_INDICES }); + + // Additionally, we need to clear the cache to ensure that the next read operation will + // not return stale data. + await es.indices.clearCache({ index: ALL_SAVED_OBJECT_INDICES }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/retry.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/retry.ts new file mode 100644 index 00000000000000..dafd16aaa9f5f5 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/retry.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RetryService } from '@kbn/ftr-common-functional-services'; + +/** + * Retry wrapper for async supertests, with a maximum number of retries. + * You can pass in a function that executes a supertest test, and make assertions + * on the response. If the test fails, it will retry the test the number of retries + * that are passed in. + * + * Example usage: + * ```ts + const fleetResponse = await retry({ + test: async () => { + const testResponse = await supertest + .post(`/api/fleet/epm/packages/security_detection_engine`) + .set('kbn-xsrf', 'xxxx') + .set('elastic-api-version', '2023-10-31') + .type('application/json') + .send({ force: true }) + .expect(200); + expect((testResponse.body as InstallPackageResponse).items).toBeDefined(); + expect((testResponse.body as InstallPackageResponse).items.length).toBeGreaterThan(0); + + return testResponse.body; + }, + retryService, + retries: MAX_RETRIES, + timeout: ATTEMPT_TIMEOUT, + }); + * ``` + * @param test The function containing a test to run + * @param retryService The retry service to use + * @param retries The maximum number of retries + * @param timeout The timeout for each retry + * @param retryDelay The delay between each retry + * @returns The response from the test + */ +export const retry = async ({ + test, + retryService, + retries = 2, + timeout = 30000, + retryDelay = 200, +}: { + test: () => Promise; + retryService: RetryService; + retries?: number; + timeout?: number; + retryDelay?: number; +}): Promise => { + let retryAttempt = 0; + const response = await retryService.tryForTime( + timeout, + async () => { + if (retryAttempt > retries) { + // Log error message if we reached the maximum number of retries + // but don't throw an error, return it to break the retry loop. + return new Error('Reached maximum number of retries for test.'); + } + + retryAttempt = retryAttempt + 1; + + return test(); + }, + undefined, + retryDelay + ); + + // Now throw the error in order to fail the test. + if (response instanceof Error) { + throw response; + } + + return response; +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/retry_delete_by_query_conflicts.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/retry_delete_by_query_conflicts.ts new file mode 100644 index 00000000000000..1d2cd8b24239c0 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/retry_delete_by_query_conflicts.ts @@ -0,0 +1,53 @@ +/* + * 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 { DeleteByQueryResponse } from '@elastic/elasticsearch/lib/api/types'; +import { ToolingLog } from '@kbn/tooling-log'; + +// Number of times to retry when conflicts occur +const RETRY_ATTEMPTS = 2; + +// Delay between retries when conflicts occur +const RETRY_DELAY = 200; + +/* + * Retry an Elasticsearch deleteByQuery operation if it runs into 409 Conflicts, + * up to a maximum number of attempts. + */ +export async function retryIfDeleteByQueryConflicts( + logger: ToolingLog, + name: string, + operation: () => Promise, + retries: number = RETRY_ATTEMPTS, + retryDelay: number = RETRY_DELAY +): Promise { + const operationResult = await operation(); + if (!operationResult.failures || operationResult.failures?.length === 0) { + return operationResult; + } + + for (const failure of operationResult.failures) { + if (failure.status === 409) { + // if no retries left, throw it + if (retries <= 0) { + logger.error(`${name} conflict, exceeded retries`); + throw new Error(`${name} conflict, exceeded retries`); + } + + // Otherwise, delay a bit before retrying + logger.debug(`${name} conflict, retrying ...`); + await waitBeforeNextRetry(retryDelay); + return await retryIfDeleteByQueryConflicts(logger, name, operation, retries - 1); + } + } + + return operationResult; +} + +async function waitBeforeNextRetry(retryDelay: number): Promise { + await new Promise((resolve) => setTimeout(resolve, retryDelay)); +} 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 00000000000000..36804d6c0f50f6 --- /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/create_non_security_rule.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/create_non_security_rule.ts index 89bb2bbea57253..09bc0f9b81a6d5 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/create_non_security_rule.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/create_non_security_rule.ts @@ -18,6 +18,7 @@ const SIMPLE_APM_RULE_DATA = { windowSize: 30, windowUnit: 'm', anomalySeverityType: 'critical', + anomalyDetectorTypes: ['txLatency'], environment: 'ENVIRONMENT_ALL', }, schedule: { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_coverage_overview.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_coverage_overview.ts new file mode 100644 index 00000000000000..f93a29b0ec1495 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_coverage_overview.ts @@ -0,0 +1,28 @@ +/* + * 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 SuperTest from 'supertest'; + +import { + CoverageOverviewFilter, + CoverageOverviewResponse, + RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL, +} from '@kbn/security-solution-plugin/common/api/detection_engine'; + +export const getCoverageOverview = async ( + supertest: SuperTest.SuperTest, + filter?: CoverageOverviewFilter +): Promise => { + const response = await supertest + .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '1') + .set('x-elastic-internal-origin', 'foo') + .send({ filter }) + .expect(200); + + return response.body; +}; 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 90f3ae07871c85..501a5579fbfded 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/detections_response/utils/rules/prebuilt_rules/delete_all_prebuilt_rule_assets.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/delete_all_prebuilt_rule_assets.ts index 899d5ddd7f83f1..179840ee608fc4 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/delete_all_prebuilt_rule_assets.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/delete_all_prebuilt_rule_assets.ts @@ -4,20 +4,26 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import type { ToolingLog } from '@kbn/tooling-log'; import type { Client } from '@elastic/elasticsearch'; import { SECURITY_SOLUTION_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { retryIfDeleteByQueryConflicts } from '../../retry_delete_by_query_conflicts'; /** * Remove all prebuilt rule assets from the security solution savedObjects index * @param es The ElasticSearch handle */ -export const deleteAllPrebuiltRuleAssets = async (es: Client): Promise => { - await es.deleteByQuery({ - index: SECURITY_SOLUTION_SAVED_OBJECT_INDEX, - q: 'type:security-rule', - wait_for_completion: true, - refresh: true, - body: {}, +export const deleteAllPrebuiltRuleAssets = async ( + es: Client, + logger: ToolingLog +): Promise => { + await retryIfDeleteByQueryConflicts(logger, deleteAllPrebuiltRuleAssets.name, async () => { + return await es.deleteByQuery({ + index: SECURITY_SOLUTION_SAVED_OBJECT_INDEX, + q: 'type:security-rule', + wait_for_completion: true, + refresh: true, + body: {}, + }); }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/delete_all_timelines.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/delete_all_timelines.ts index 291cd269580b07..df677656fbd945 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/delete_all_timelines.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/delete_all_timelines.ts @@ -4,20 +4,23 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import type { ToolingLog } from '@kbn/tooling-log'; import type { Client } from '@elastic/elasticsearch'; import { SECURITY_SOLUTION_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { retryIfDeleteByQueryConflicts } from '../../retry_delete_by_query_conflicts'; /** * Remove all timelines from the security solution savedObjects index * @param es The ElasticSearch handle */ -export const deleteAllTimelines = async (es: Client): Promise => { - await es.deleteByQuery({ - index: SECURITY_SOLUTION_SAVED_OBJECT_INDEX, - q: 'type:siem-ui-timeline', - wait_for_completion: true, - refresh: true, - body: {}, +export const deleteAllTimelines = async (es: Client, logger: ToolingLog): Promise => { + await retryIfDeleteByQueryConflicts(logger, deleteAllTimelines.name, async () => { + return await es.deleteByQuery({ + index: SECURITY_SOLUTION_SAVED_OBJECT_INDEX, + q: 'type:siem-ui-timeline', + wait_for_completion: true, + refresh: true, + body: {}, + }); }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/get_prebuilt_rules_and_timelines_status.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/get_prebuilt_rules_and_timelines_status.ts index 2d03e597dc5aff..7f683ca9994bee 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/get_prebuilt_rules_and_timelines_status.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/get_prebuilt_rules_and_timelines_status.ts @@ -10,6 +10,8 @@ import { PREBUILT_RULES_STATUS_URL, } from '@kbn/security-solution-plugin/common/api/detection_engine/prebuilt_rules'; import type SuperTest from 'supertest'; +import type { Client } from '@elastic/elasticsearch'; +import { refreshSavedObjectIndices } from '../../refresh_index'; /** * (LEGACY) @@ -18,8 +20,11 @@ import type SuperTest from 'supertest'; * @param supertest The supertest deps */ export const getPrebuiltRulesAndTimelinesStatus = async ( + es: Client, supertest: SuperTest.SuperTest ): Promise => { + await refreshSavedObjectIndices(es); + const response = await supertest .get(PREBUILT_RULES_STATUS_URL) .set('kbn-xsrf', 'true') diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/get_prebuilt_rules_status.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/get_prebuilt_rules_status.ts index 0f785f8a774538..da044637fc77ba 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/get_prebuilt_rules_status.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/get_prebuilt_rules_status.ts @@ -10,6 +10,8 @@ import { GetPrebuiltRulesStatusResponseBody, } from '@kbn/security-solution-plugin/common/api/detection_engine/prebuilt_rules'; import type SuperTest from 'supertest'; +import type { Client } from '@elastic/elasticsearch'; +import { refreshSavedObjectIndices } from '../../refresh_index'; /** * Helper to retrieve the prebuilt rules status @@ -17,8 +19,11 @@ import type SuperTest from 'supertest'; * @param supertest The supertest deps */ export const getPrebuiltRulesStatus = async ( + es: Client, supertest: SuperTest.SuperTest ): Promise => { + await refreshSavedObjectIndices(es); + const response = await supertest .get(GET_PREBUILT_RULES_STATUS_URL) .set('kbn-xsrf', 'true') diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/install_fleet_package_by_url.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/install_fleet_package_by_url.ts index 259369346cc8b9..988d73660d0ee8 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/install_fleet_package_by_url.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/install_fleet_package_by_url.ts @@ -6,9 +6,15 @@ */ import type { Client } from '@elastic/elasticsearch'; import type SuperTest from 'supertest'; -import { ALL_SAVED_OBJECT_INDICES } from '@kbn/core-saved-objects-server'; import { InstallPackageResponse } from '@kbn/fleet-plugin/common/types'; import { epmRouteService } from '@kbn/fleet-plugin/common'; +import { RetryService } from '@kbn/ftr-common-functional-services'; +import expect from 'expect'; +import { retry } from '../../retry'; +import { refreshSavedObjectIndices } from '../../refresh_index'; + +const MAX_RETRIES = 2; +const ATTEMPT_TIMEOUT = 120000; /** * Installs latest available non-prerelease prebuilt rules package `security_detection_engine`. @@ -21,37 +27,35 @@ import { epmRouteService } from '@kbn/fleet-plugin/common'; export const installPrebuiltRulesPackageViaFleetAPI = async ( es: Client, - supertest: SuperTest.SuperTest + supertest: SuperTest.SuperTest, + retryService: RetryService ): Promise => { - const fleetResponse = await supertest - .post(`/api/fleet/epm/packages/security_detection_engine`) - .set('kbn-xsrf', 'xxxx') - .set('elastic-api-version', '2023-10-31') - .type('application/json') - .send({ force: true }) - .expect(200); + const fleetResponse = await retry({ + test: async () => { + const testResponse = await supertest + .post(`/api/fleet/epm/packages/security_detection_engine`) + .set('kbn-xsrf', 'xxxx') + .set('elastic-api-version', '2023-10-31') + .type('application/json') + .send({ force: true }) + .expect(200); + expect((testResponse.body as InstallPackageResponse).items).toBeDefined(); + expect((testResponse.body as InstallPackageResponse).items.length).toBeGreaterThan(0); - // Before we proceed, we need to refresh saved object indices. - // At the previous step we installed the Fleet package with prebuilt detection rules. - // Prebuilt rules are assets that Fleet indexes as saved objects of a certain type. - // Fleet does this via a savedObjectsClient.import() call with explicit `refresh: false`. - // So, despite of the fact that the endpoint waits until the prebuilt rule assets will be - // successfully indexed, it doesn't wait until they become "visible" for subsequent read - // operations. - // And this is usually what we do next in integration tests: we read these SOs with utility - // function such as getPrebuiltRulesAndTimelinesStatus(). - // Now, the time left until the next refresh can be anything from 0 to the default value, and - // it depends on the time when savedObjectsClient.import() call happens relative to the time of - // the next refresh. Also, probably the refresh time can be delayed when ES is under load? - // Anyway, this can cause race condition between a write and subsequent read operation, and to - // fix it deterministically we have to refresh saved object indices and wait until it's done. - await es.indices.refresh({ index: ALL_SAVED_OBJECT_INDICES }); + return testResponse.body; + }, + retryService, + retries: MAX_RETRIES, + timeout: ATTEMPT_TIMEOUT, + }); - return fleetResponse.body as InstallPackageResponse; + await refreshSavedObjectIndices(es); + + return fleetResponse; }; /** * Installs prebuilt rules package `security_detection_engine`, passing in the version - * of the package as a parameter to the utl. + * of the package as a parameter to the url. * * @param es Elasticsearch client * @param supertest SuperTest instance @@ -62,17 +66,29 @@ export const installPrebuiltRulesPackageViaFleetAPI = async ( export const installPrebuiltRulesPackageByVersion = async ( es: Client, supertest: SuperTest.SuperTest, - version: string + version: string, + retryService: RetryService ): Promise => { - const fleetResponse = await supertest - .post(epmRouteService.getInstallPath('security_detection_engine', version)) - .set('kbn-xsrf', 'xxxx') - .set('elastic-api-version', '2023-10-31') - .type('application/json') - .send({ force: true }) - .expect(200); + const fleetResponse = await retry({ + test: async () => { + const testResponse = await supertest + .post(epmRouteService.getInstallPath('security_detection_engine', version)) + .set('kbn-xsrf', 'xxxx') + .set('elastic-api-version', '2023-10-31') + .type('application/json') + .send({ force: true }) + .expect(200); + expect((testResponse.body as InstallPackageResponse).items).toBeDefined(); + expect((testResponse.body as InstallPackageResponse).items.length).toBeGreaterThan(0); + + return testResponse.body; + }, + retryService, + retries: MAX_RETRIES, + timeout: ATTEMPT_TIMEOUT, + }); - await es.indices.refresh({ index: ALL_SAVED_OBJECT_INDICES }); + await refreshSavedObjectIndices(es); - return fleetResponse.body as InstallPackageResponse; + return fleetResponse as InstallPackageResponse; }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/install_prebuilt_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/install_prebuilt_rules.ts index 308fef271e9875..499f97877bf165 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/install_prebuilt_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/install_prebuilt_rules.ts @@ -12,7 +12,7 @@ import { } from '@kbn/security-solution-plugin/common/api/detection_engine/prebuilt_rules'; import type { Client } from '@elastic/elasticsearch'; import type SuperTest from 'supertest'; -import { ALL_SAVED_OBJECT_INDICES } from '@kbn/core-saved-objects-server'; +import { refreshSavedObjectIndices } from '../../refresh_index'; /** * Installs available prebuilt rules in Kibana. Rules are @@ -47,17 +47,7 @@ export const installPrebuiltRules = async ( .send(payload) .expect(200); - // Before we proceed, we need to refresh saved object indices. - // At the previous step we installed the prebuilt detection rules SO of type 'security-rule'. - // The savedObjectsClient does this with a call with explicit `refresh: false`. - // So, despite of the fact that the endpoint waits until the prebuilt rule will be - // successfully indexed, it doesn't wait until they become "visible" for subsequent read - // operations. - // And this is usually what we do next in integration tests: we read these SOs with utility - // function such as getPrebuiltRulesAndTimelinesStatus(). - // This can cause race conditions between a write and subsequent read operation, and to - // fix it deterministically we have to refresh saved object indices and wait until it's done. - await es.indices.refresh({ index: ALL_SAVED_OBJECT_INDICES }); + await refreshSavedObjectIndices(es); return response.body; }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/install_prebuilt_rules_and_timelines.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/install_prebuilt_rules_and_timelines.ts index 776af6074e07e1..c83e8693f2390a 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/install_prebuilt_rules_and_timelines.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/install_prebuilt_rules_and_timelines.ts @@ -11,7 +11,7 @@ import { } from '@kbn/security-solution-plugin/common/api/detection_engine/prebuilt_rules'; import type { Client } from '@elastic/elasticsearch'; import type SuperTest from 'supertest'; -import { ALL_SAVED_OBJECT_INDICES } from '@kbn/core-saved-objects-server'; +import { refreshSavedObjectIndices } from '../../refresh_index'; /** * (LEGACY) @@ -40,17 +40,7 @@ export const installPrebuiltRulesAndTimelines = async ( .send() .expect(200); - // Before we proceed, we need to refresh saved object indices. - // At the previous step we installed the prebuilt detection rules SO of type 'security-rule'. - // The savedObjectsClient does this with a call with explicit `refresh: false`. - // So, despite of the fact that the endpoint waits until the prebuilt rule will be - // successfully indexed, it doesn't wait until they become "visible" for subsequent read - // operations. - // And this is usually what we do next in integration tests: we read these SOs with utility - // function such as getPrebuiltRulesAndTimelinesStatus(). - // This can cause race condition between a write and subsequent read operation, and to - // fix it deterministically we have to refresh saved object indices and wait until it's done. - await es.indices.refresh({ index: ALL_SAVED_OBJECT_INDICES }); + await refreshSavedObjectIndices(es); return response.body; }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/install_prebuilt_rules_fleet_package.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/install_prebuilt_rules_fleet_package.ts index cc899ecc1dccc4..592406e8c3398f 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/install_prebuilt_rules_fleet_package.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/install_prebuilt_rules_fleet_package.ts @@ -5,10 +5,21 @@ * 2.0. */ -import { ALL_SAVED_OBJECT_INDICES } from '@kbn/core-saved-objects-server'; -import { epmRouteService } from '@kbn/fleet-plugin/common'; +import { + BulkInstallPackageInfo, + BulkInstallPackagesResponse, + epmRouteService, +} from '@kbn/fleet-plugin/common'; import type { Client } from '@elastic/elasticsearch'; +import { InstallPackageResponse } from '@kbn/fleet-plugin/common/types'; import type SuperTest from 'supertest'; +import { RetryService } from '@kbn/ftr-common-functional-services'; +import expect from 'expect'; +import { retry } from '../../retry'; +import { refreshSavedObjectIndices } from '../../refresh_index'; + +const MAX_RETRIES = 2; +const ATTEMPT_TIMEOUT = 120000; /** * Installs the `security_detection_engine` package via fleet API. This will @@ -23,49 +34,71 @@ export const installPrebuiltRulesFleetPackage = async ({ supertest, version, overrideExistingPackage, + retryService, }: { es: Client; supertest: SuperTest.SuperTest; version?: string; overrideExistingPackage: boolean; -}): Promise => { + retryService: RetryService; +}): Promise => { if (version) { // Install a specific version - await supertest - .post(epmRouteService.getInstallPath('security_detection_engine', version)) - .set('kbn-xsrf', 'true') - .send({ - force: overrideExistingPackage, - }) - .expect(200); + const response = await retry({ + test: async () => { + const testResponse = await supertest + .post(epmRouteService.getInstallPath('security_detection_engine', version)) + .set('kbn-xsrf', 'true') + .send({ + force: overrideExistingPackage, + }) + .expect(200); + expect((testResponse.body as InstallPackageResponse).items).toBeDefined(); + expect((testResponse.body as InstallPackageResponse).items.length).toBeGreaterThan(0); + + return testResponse.body; + }, + retryService, + retries: MAX_RETRIES, + timeout: ATTEMPT_TIMEOUT, + }); + + await refreshSavedObjectIndices(es); + + return response; } else { // Install the latest version - await supertest - .post(epmRouteService.getBulkInstallPath()) - .query({ prerelease: true }) - .set('kbn-xsrf', 'true') - .send({ - packages: ['security_detection_engine'], - force: overrideExistingPackage, - }) - .expect(200); - } + const response = await retry({ + test: async () => { + const testResponse = await supertest + .post(epmRouteService.getBulkInstallPath()) + .query({ prerelease: true }) + .set('kbn-xsrf', 'true') + .send({ + packages: ['security_detection_engine'], + force: overrideExistingPackage, + }) + .expect(200); - // Before we proceed, we need to refresh saved object indices. - // At the previous step we installed the Fleet package with prebuilt detection rules. - // Prebuilt rules are assets that Fleet indexes as saved objects of a certain type. - // Fleet does this via a savedObjectsClient.import() call with explicit `refresh: false`. - // So, despite of the fact that the endpoint waits until the prebuilt rule assets will be - // successfully indexed, it doesn't wait until they become "visible" for subsequent read - // operations. - // And this is usually what we do next in integration tests: we read these SOs with utility - // function such as getPrebuiltRulesAndTimelinesStatus(). - // Now, the time left until the next refresh can be anything from 0 to the default value, and - // it depends on the time when savedObjectsClient.import() call happens relative to the time of - // the next refresh. Also, probably the refresh time can be delayed when ES is under load? - // Anyway, this can cause race condition between a write and subsequent read operation, and to - // fix it deterministically we have to refresh saved object indices and wait until it's done. - await es.indices.refresh({ index: ALL_SAVED_OBJECT_INDICES }); + const body = testResponse.body as BulkInstallPackagesResponse; + + // First and only item in the response should be the security_detection_engine package + expect(body.items[0]).toBeDefined(); + expect((body.items[0] as BulkInstallPackageInfo).result.assets).toBeDefined(); + // Endpoint call should have installed at least 1 security-rule asset + expect((body.items[0] as BulkInstallPackageInfo).result.assets?.length).toBeGreaterThan(0); + + return body; + }, + retryService, + retries: MAX_RETRIES, + timeout: ATTEMPT_TIMEOUT, + }); + + await refreshSavedObjectIndices(es); + + return response; + } }; /** diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/upgrade_prebuilt_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/upgrade_prebuilt_rules.ts index caadba2619a741..c22aa9106a272d 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/upgrade_prebuilt_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/prebuilt_rules/upgrade_prebuilt_rules.ts @@ -12,7 +12,7 @@ import { } from '@kbn/security-solution-plugin/common/api/detection_engine/prebuilt_rules'; import type { Client } from '@elastic/elasticsearch'; import type SuperTest from 'supertest'; -import { ALL_SAVED_OBJECT_INDICES } from '@kbn/core-saved-objects-server'; +import { refreshSavedObjectIndices } from '../../refresh_index'; /** * Upgrades available prebuilt rules in Kibana. @@ -43,18 +43,7 @@ export const upgradePrebuiltRules = async ( .send(payload) .expect(200); - // Before we proceed, we need to refresh saved object indices. - // At the previous step we upgraded the prebuilt rules, which, under the hoods, installs new versions - // of the prebuilt detection rules SO of type 'security-rule'. - // The savedObjectsClient does this with a call with explicit `refresh: false`. - // So, despite of the fact that the endpoint waits until the prebuilt rule will be - // successfully indexed, it doesn't wait until they become "visible" for subsequent read - // operations. - // And this is usually what we do next in integration tests: we read these SOs with utility - // function such as getPrebuiltRulesAndTimelinesStatus(). - // This can cause race conditions between a write and subsequent read operation, and to - // fix it deterministically we have to refresh saved object indices and wait until it's done. - await es.indices.refresh({ index: ALL_SAVED_OBJECT_INDICES }); + await refreshSavedObjectIndices(es); return response.body; }; 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 97686465c80737..db1ed95945bafd 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 ccbbcd9dc8cb8e..35f50c7ad9f40b 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 366e0b956e3701..3a0a941f480209 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 bb1410030e0db3..989ebcd4a34f51 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 522c44b41d85ac..0af6ce99fbbb3d 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 7e324d6e29836c..f2e5509441e095 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_api_integration/tsconfig.json b/x-pack/test/security_solution_api_integration/tsconfig.json index 77b7f288a5b00e..18e019202355c4 100644 --- a/x-pack/test/security_solution_api_integration/tsconfig.json +++ b/x-pack/test/security_solution_api_integration/tsconfig.json @@ -40,5 +40,6 @@ "@kbn/datemath", "@kbn/safer-lodash-set", "@kbn/stack-connectors-plugin", + "@kbn/ftr-common-functional-services", ] } diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/sourcerer/sourcerer_timeline.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/sourcerer/sourcerer_timeline.cy.ts index 30e1e39b4cd3e0..d8e1ed1ea33214 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/sourcerer/sourcerer_timeline.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/sourcerer/sourcerer_timeline.cy.ts @@ -109,6 +109,7 @@ describe('Timeline scope', { tags: ['@ess', '@serverless', '@brokenInServerless' }); it('Modifies timeline to alerts only, and switches to different saved timeline without issue', function () { + closeTimeline(); openTimelineById(this.timelineId).then(() => { cy.get(SOURCERER.badgeAlerts).should(`not.exist`); cy.get(SOURCERER.badgeModified).should(`not.exist`); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/coverage_overview/coverage_overview.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/coverage_overview/coverage_overview.cy.ts new file mode 100644 index 00000000000000..6a52134d4d738e --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/coverage_overview/coverage_overview.cy.ts @@ -0,0 +1,439 @@ +/* + * 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 { + getDuplicateTechniqueThreatData, + getMockThreatData, +} from '@kbn/security-solution-plugin/public/detections/mitre/mitre_tactics_techniques'; +import { Threat } from '@kbn/securitysolution-io-ts-alerting-types'; +import { + COVERAGE_OVERVIEW_ENABLE_ALL_DISABLED_BUTTON, + COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES, + COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES, + COVERAGE_OVERVIEW_TACTIC_DISABLED_STATS, + COVERAGE_OVERVIEW_TACTIC_ENABLED_STATS, + COVERAGE_OVERVIEW_TACTIC_PANEL, +} from '../../../../screens/rules_coverage_overview'; +import { createRule } from '../../../../tasks/api_calls/rules'; +import { visit } from '../../../../tasks/navigation'; +import { RULES_COVERAGE_OVERVIEW_URL } from '../../../../urls/rules_management'; +import { createRuleAssetSavedObject } from '../../../../helpers/rules'; +import { getNewRule } from '../../../../objects/rule'; +import { + createAndInstallMockedPrebuiltRules, + preventPrebuiltRulesPackageInstallation, +} from '../../../../tasks/api_calls/prebuilt_rules'; +import { + deleteAlertsAndRules, + deletePrebuiltRulesAssets, +} from '../../../../tasks/api_calls/common'; +import { login } from '../../../../tasks/login'; +import { + enableAllDisabledRules, + filterCoverageOverviewBySearchBar, + openTechniquePanelByName, + openTechniquePanelByNameAndTacticId, + selectCoverageOverviewActivityFilterOption, + selectCoverageOverviewSourceFilterOption, +} from '../../../../tasks/rules_coverage_overview'; + +// Mitre data used in base case tests +const EnabledPrebuiltRuleMitreData = getMockThreatData()[0]; +const DisabledPrebuiltRuleMitreData = getMockThreatData()[1]; +const EnabledCustomRuleMitreData = getMockThreatData()[2]; +const DisabledCustomRuleMitreData = getMockThreatData()[3]; + +// Mitre data used for duplicate technique tests +const DuplicateTechniqueMitreData1 = getDuplicateTechniqueThreatData()[1]; +const DuplicateTechniqueMitreData2 = getDuplicateTechniqueThreatData()[0]; + +const MockEnabledPrebuiltRuleThreat: Threat = { + framework: 'MITRE ATT&CK', + tactic: { + name: EnabledPrebuiltRuleMitreData.tactic.name, + id: EnabledPrebuiltRuleMitreData.tactic.id, + reference: EnabledPrebuiltRuleMitreData.tactic.reference, + }, + technique: [ + { + id: EnabledPrebuiltRuleMitreData.technique.id, + reference: EnabledPrebuiltRuleMitreData.technique.reference, + name: EnabledPrebuiltRuleMitreData.technique.name, + subtechnique: [ + { + id: EnabledPrebuiltRuleMitreData.subtechnique.id, + name: EnabledPrebuiltRuleMitreData.subtechnique.name, + reference: EnabledPrebuiltRuleMitreData.subtechnique.reference, + }, + ], + }, + { + name: EnabledPrebuiltRuleMitreData.technique.name, + id: EnabledPrebuiltRuleMitreData.technique.id, + reference: EnabledPrebuiltRuleMitreData.technique.reference, + subtechnique: [], + }, + ], +}; + +const MockDisabledPrebuiltRuleThreat: Threat = { + framework: 'MITRE ATT&CK', + tactic: { + name: DisabledPrebuiltRuleMitreData.tactic.name, + id: DisabledPrebuiltRuleMitreData.tactic.id, + reference: DisabledPrebuiltRuleMitreData.tactic.reference, + }, + technique: [ + { + id: DisabledPrebuiltRuleMitreData.technique.id, + reference: DisabledPrebuiltRuleMitreData.technique.reference, + name: DisabledPrebuiltRuleMitreData.technique.name, + subtechnique: [ + { + id: DisabledPrebuiltRuleMitreData.subtechnique.id, + name: DisabledPrebuiltRuleMitreData.subtechnique.name, + reference: DisabledPrebuiltRuleMitreData.subtechnique.reference, + }, + ], + }, + ], +}; + +const MockEnabledCustomRuleThreat: Threat = { + framework: 'MITRE ATT&CK', + tactic: { + name: EnabledCustomRuleMitreData.tactic.name, + id: EnabledCustomRuleMitreData.tactic.id, + reference: EnabledCustomRuleMitreData.tactic.reference, + }, + technique: [ + { + id: EnabledCustomRuleMitreData.technique.id, + reference: EnabledCustomRuleMitreData.technique.reference, + name: EnabledCustomRuleMitreData.technique.name, + subtechnique: [ + { + id: EnabledCustomRuleMitreData.subtechnique.id, + name: EnabledCustomRuleMitreData.subtechnique.name, + reference: EnabledCustomRuleMitreData.subtechnique.reference, + }, + ], + }, + ], +}; + +const MockDisabledCustomRuleThreat: Threat = { + framework: 'MITRE ATT&CK', + tactic: { + name: DisabledCustomRuleMitreData.tactic.name, + id: DisabledCustomRuleMitreData.tactic.id, + reference: DisabledCustomRuleMitreData.tactic.reference, + }, + technique: [ + { + id: DisabledCustomRuleMitreData.technique.id, + reference: DisabledCustomRuleMitreData.technique.reference, + name: DisabledCustomRuleMitreData.technique.name, + }, + ], +}; + +const MockCustomRuleDuplicateTechniqueThreat1: Threat = { + framework: 'MITRE ATT&CK', + tactic: { + name: DuplicateTechniqueMitreData1.tactic.name, + id: DuplicateTechniqueMitreData1.tactic.id, + reference: DuplicateTechniqueMitreData1.tactic.reference, + }, + technique: [ + { + id: DuplicateTechniqueMitreData1.technique.id, + reference: DuplicateTechniqueMitreData1.technique.reference, + name: DuplicateTechniqueMitreData1.technique.name, + }, + ], +}; + +const MockCustomRuleDuplicateTechniqueThreat2: Threat = { + framework: 'MITRE ATT&CK', + tactic: { + name: DuplicateTechniqueMitreData2.tactic.name, + id: DuplicateTechniqueMitreData2.tactic.id, + reference: DuplicateTechniqueMitreData2.tactic.reference, + }, + technique: [ + { + id: DuplicateTechniqueMitreData2.technique.id, + reference: DuplicateTechniqueMitreData2.technique.reference, + name: DuplicateTechniqueMitreData2.technique.name, + }, + ], +}; + +const prebuiltRules = [ + createRuleAssetSavedObject({ + name: `Enabled prebuilt rule`, + rule_id: `enabled_prebuilt_rule`, + enabled: true, + threat: [MockEnabledPrebuiltRuleThreat], + }), + createRuleAssetSavedObject({ + name: `Disabled prebuilt rule`, + rule_id: `disabled_prebuilt_rule`, + enabled: false, + threat: [MockDisabledPrebuiltRuleThreat], + }), +]; + +describe('Coverage overview', { tags: ['@ess', '@serverless'] }, () => { + describe('base cases', () => { + beforeEach(() => { + login(); + deleteAlertsAndRules(); + deletePrebuiltRulesAssets(); + preventPrebuiltRulesPackageInstallation(); + createAndInstallMockedPrebuiltRules(prebuiltRules); + createRule( + getNewRule({ + rule_id: 'enabled_custom_rule', + enabled: true, + name: 'Enabled custom rule', + threat: [MockEnabledCustomRuleThreat], + }) + ); + createRule( + getNewRule({ + rule_id: 'disabled_custom_rule', + name: 'Disabled custom rule', + enabled: false, + threat: [MockDisabledCustomRuleThreat], + }) + ); + visit(RULES_COVERAGE_OVERVIEW_URL); + }); + + it('technique panel renders custom and prebuilt rule data on page load', () => { + openTechniquePanelByName(EnabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES).contains('Enabled prebuilt rule'); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES) + .contains('Enabled custom rule') + .should('not.exist'); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES) + .contains('Disabled prebuilt rule') + .should('not.exist'); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES) + .contains('Disabled custom rule') + .should('not.exist'); + cy.get(COVERAGE_OVERVIEW_ENABLE_ALL_DISABLED_BUTTON).should('be.disabled'); + }); + + describe('filtering tests', () => { + it('filters for all data', () => { + selectCoverageOverviewActivityFilterOption('Disabled rules'); // Activates disabled rules filter as it's off by default on page load + + openTechniquePanelByName(EnabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES).contains('Enabled prebuilt rule'); + + openTechniquePanelByName(DisabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES).contains('Disabled prebuilt rule'); + + openTechniquePanelByName(EnabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES).contains('Enabled custom rule'); + + openTechniquePanelByName(DisabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES).contains('Disabled custom rule'); + }); + + it('filters for disabled and prebuilt rules', () => { + selectCoverageOverviewActivityFilterOption('Enabled rules'); // Disables default filter + selectCoverageOverviewActivityFilterOption('Disabled rules'); // Activates disabled rules filter as it's off by default on page load + selectCoverageOverviewSourceFilterOption('Custom rules'); // Disables default filter + + openTechniquePanelByName(EnabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES) + .contains('Enabled prebuilt rule') + .should('not.exist'); + + openTechniquePanelByName(DisabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES).contains('Disabled prebuilt rule'); + + openTechniquePanelByName(EnabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES) + .contains('Enabled custom rule') + .should('not.exist'); + + openTechniquePanelByName(DisabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES) + .contains('Disabled custom rule') + .should('not.exist'); + }); + + it('filters for only prebuilt rules', () => { + selectCoverageOverviewActivityFilterOption('Disabled rules'); // Activates disabled rules filter as it's off by default on page load + selectCoverageOverviewSourceFilterOption('Custom rules'); // Disables default filter + + openTechniquePanelByName(EnabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES).contains('Enabled prebuilt rule'); + + openTechniquePanelByName(DisabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES).contains('Disabled prebuilt rule'); + + openTechniquePanelByName(EnabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES) + .contains('Enabled custom rule') + .should('not.exist'); + + openTechniquePanelByName(DisabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES) + .contains('Disabled custom rule') + .should('not.exist'); + }); + + it('filters for only custom rules', () => { + selectCoverageOverviewActivityFilterOption('Disabled rules'); // Activates disabled rules filter as it's off by default on page load + selectCoverageOverviewSourceFilterOption('Elastic rules'); // Disables default filter + + openTechniquePanelByName(EnabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES) + .contains('Enabled prebuilt rule') + .should('not.exist'); + + openTechniquePanelByName(DisabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES) + .contains('Disabled prebuilt rule') + .should('not.exist'); + + openTechniquePanelByName(EnabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES).contains('Enabled custom rule'); + + openTechniquePanelByName(DisabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES).contains('Disabled custom rule'); + }); + + it('filters for search term', () => { + filterCoverageOverviewBySearchBar('Enabled custom rule'); // Disables default filter + + openTechniquePanelByName(EnabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES) + .contains('Enabled prebuilt rule') + .should('not.exist'); + + openTechniquePanelByName(DisabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES) + .contains('Disabled prebuilt rule') + .should('not.exist'); + + openTechniquePanelByName(EnabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES).contains('Enabled custom rule'); + + openTechniquePanelByName(DisabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES) + .contains('Disabled custom rule') + .should('not.exist'); + }); + }); + + it('enables all disabled rules', () => { + selectCoverageOverviewActivityFilterOption('Disabled rules'); // Activates disabled rules filter as it's off by default on page load + openTechniquePanelByName(DisabledPrebuiltRuleMitreData.technique.name); + enableAllDisabledRules(); + + // Should now render all rules in "enabled" section + openTechniquePanelByName(DisabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES).contains('Disabled prebuilt rule'); + + // Shouldn't render the rules in "disabled" section + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES) + .contains('Disabled prebuilt rule') + .should('not.exist'); + cy.get(COVERAGE_OVERVIEW_ENABLE_ALL_DISABLED_BUTTON).should('be.disabled'); + }); + }); + + describe('with rules that have identical mitre techniques that belong to multiple tactics', () => { + const SharedTechniqueName = DuplicateTechniqueMitreData1.technique.name; + const TacticOfRule1 = DuplicateTechniqueMitreData1.tactic; + const TacticOfRule2 = DuplicateTechniqueMitreData2.tactic; + + beforeEach(() => { + login(); + deleteAlertsAndRules(); + deletePrebuiltRulesAssets(); + createRule( + getNewRule({ + rule_id: 'duplicate_technique_rule_1', + enabled: true, + name: 'Rule under Persistence tactic', + threat: [MockCustomRuleDuplicateTechniqueThreat1], + }) + ); + createRule( + getNewRule({ + rule_id: 'duplicate_technique_rule_2', + name: 'Rule under Privilege Escalation tactic', + enabled: false, + threat: [MockCustomRuleDuplicateTechniqueThreat2], + }) + ); + visit(RULES_COVERAGE_OVERVIEW_URL); + }); + + it('technique panels render unique rule data', () => { + // Tests to make sure each rule only exists in the specific technique and tactic that's assigned to it + + selectCoverageOverviewActivityFilterOption('Disabled rules'); // Activates disabled rules filter as it's off by default on page load + + // Open duplicated technique panel under Persistence tactic + openTechniquePanelByNameAndTacticId(SharedTechniqueName, TacticOfRule1.id); + + // Only rule 1 data is present + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES).contains('Rule under Persistence tactic'); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES) + .contains('Rule under Privilege Escalation tactic') + .should('not.exist'); + + // Open duplicated technique panel under Privilege Escalation tactic + openTechniquePanelByNameAndTacticId(SharedTechniqueName, TacticOfRule2.id); + + // Only rule 2 data is present + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES) + .contains('Rule under Persistence tactic') + .should('not.exist'); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES).contains( + 'Rule under Privilege Escalation tactic' + ); + }); + + it('tactic panels render correct rule stats', () => { + selectCoverageOverviewActivityFilterOption('Disabled rules'); // Activates disabled rules filter as it's off by default on page load + + // Validate rule count stats for the Persistence tactic only show stats based on its own technique + // Enabled rule count + cy.get(COVERAGE_OVERVIEW_TACTIC_PANEL) + .contains(TacticOfRule1.name) + .get(COVERAGE_OVERVIEW_TACTIC_ENABLED_STATS) + .contains('0'); + // Disabled rule count + cy.get(COVERAGE_OVERVIEW_TACTIC_PANEL) + .contains(TacticOfRule1.name) + .get(COVERAGE_OVERVIEW_TACTIC_DISABLED_STATS) + .contains('1'); + + // Validate rule count stats for the Privilege Escalation tactic only show stats based on its own technique + // Enabled rule count + cy.get(COVERAGE_OVERVIEW_TACTIC_PANEL) + .contains(TacticOfRule2.name) + .get(COVERAGE_OVERVIEW_TACTIC_ENABLED_STATS) + .contains('1'); + // Disabled rule count + cy.get(COVERAGE_OVERVIEW_TACTIC_PANEL) + .contains(TacticOfRule2.name) + .get(COVERAGE_OVERVIEW_TACTIC_DISABLED_STATS) + .contains('0'); + }); + }); +}); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_actions/bulk_actions/bulk_edit_rules_actions.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_actions/bulk_actions/bulk_edit_rules_actions.cy.ts index f6df3e59bab932..e4b39d81787519 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_actions/bulk_actions/bulk_edit_rules_actions.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/rule_actions/bulk_actions/bulk_edit_rules_actions.cy.ts @@ -16,7 +16,7 @@ import { createRuleAssetSavedObject } from '../../../../../helpers/rules'; import { RULES_BULK_EDIT_ACTIONS_INFO, RULES_BULK_EDIT_ACTIONS_WARNING, - ADD_RULE_ACTIONS_MENU_ITEM, + BULK_ACTIONS_BTN, } from '../../../../../screens/rules_bulk_actions'; import { actionFormSelector } from '../../../../../screens/common/rule_actions'; @@ -47,7 +47,6 @@ import { submitBulkEditForm, checkOverwriteRuleActionsCheckbox, openBulkEditRuleActionsForm, - openBulkActionsMenu, } from '../../../../../tasks/rules_bulk_actions'; import { login } from '../../../../../tasks/login'; import { visitRulesManagementTable } from '../../../../../tasks/rules_management'; @@ -65,7 +64,6 @@ import { } from '../../../../../objects/rule'; import { createAndInstallMockedPrebuiltRules, - excessivelyInstallAllPrebuiltRules, preventPrebuiltRulesPackageInstallation, } from '../../../../../tasks/api_calls/prebuilt_rules'; @@ -74,10 +72,9 @@ const ruleNameToAssert = 'Custom rule name with actions'; const expectedExistingSlackMessage = 'Existing slack action'; const expectedSlackMessage = 'Slack action test message'; -// TODO: Fix and unskip in Serverless https://github.com/elastic/kibana/issues/171101 describe( 'Detection rules, bulk edit of rule actions', - { tags: ['@ess', '@serverless', '@brokenInServerless', '@brokenInServerlessQA'] }, + { tags: ['@ess', '@serverless', '@brokenInServerlessQA'] }, () => { beforeEach(() => { login(); @@ -148,7 +145,7 @@ describe( context('Restricted action privileges', () => { it("User with no privileges can't add rule actions", () => { - login(ROLES.hunter_no_actions); + login(ROLES.t1_analyst); visitRulesManagementTable(); expectManagementTableRules([ @@ -164,11 +161,7 @@ describe( ]); waitForCallOutToBeShown(MISSING_PRIVILEGES_CALLOUT, 'primary'); - selectAllRules(); - - openBulkActionsMenu(); - - cy.get(ADD_RULE_ACTIONS_MENU_ITEM).should('be.disabled'); + cy.get(BULK_ACTIONS_BTN).should('not.exist'); }); }); @@ -197,8 +190,6 @@ describe( throttleUnit: 'd', }; - excessivelyInstallAllPrebuiltRules(); - getRulesManagementTableRows().then((rows) => { // select both custom and prebuilt rules selectAllRules(); @@ -227,8 +218,6 @@ describe( }); it('Overwrite rule actions in rules', () => { - excessivelyInstallAllPrebuiltRules(); - getRulesManagementTableRows().then((rows) => { // select both custom and prebuilt rules selectAllRules(); 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 60b93650048d99..ecffa9285c2283 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'; @@ -36,8 +35,7 @@ import { enableRiskEngine } 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'); @@ -67,9 +65,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); @@ -111,9 +106,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); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/host_details/risk_tab.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/host_details/risk_tab.cy.ts index 2248b37fa48762..fcb329da8b0d00 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/host_details/risk_tab.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/host_details/risk_tab.cy.ts @@ -11,10 +11,11 @@ import { visitHostDetailsPage } from '../../../tasks/navigation'; import { waitForTableToLoad } from '../../../tasks/common'; import { TABLE_CELL, TABLE_ROWS } from '../../../screens/alerts_details'; import { deleteRiskEngineConfiguration } from '../../../tasks/api_calls/risk_engine'; -import { openRiskInformationFlyout, enableRiskEngine } from '../../../tasks/entity_analytics'; +import { openRiskInformationFlyout, mockRiskEngineEnabled } from '../../../tasks/entity_analytics'; import { ALERTS_COUNT, ALERT_GRID_CELL } from '../../../screens/alerts'; import { RISK_INFORMATION_FLYOUT_HEADER } from '../../../screens/entity_analytics'; import { navigateToHostRiskDetailTab } from '../../../tasks/host_risk'; +import { deleteAlertsAndRules } from '../../../tasks/api_calls/common'; describe('risk tab', { tags: ['@ess', '@serverless'] }, () => { // FLAKY: https://github.com/elastic/kibana/issues/169033 @@ -59,19 +60,18 @@ describe('risk tab', { tags: ['@ess', '@serverless'] }, () => { describe('with new risk score', () => { before(() => { - cy.task('esArchiverLoad', { archiveName: 'risk_scores_new' }); + cy.task('esArchiverLoad', { archiveName: 'risk_scores_new_complete_data' }); cy.task('esArchiverLoad', { archiveName: 'query_alert', useCreate: true, docsOnly: true }); - login(); - enableRiskEngine(); }); beforeEach(() => { + mockRiskEngineEnabled(); login(); }); after(() => { - cy.task('esArchiverUnload', 'risk_scores_new'); - cy.task('esArchiverUnload', 'query_alert'); + cy.task('esArchiverUnload', 'risk_scores_new_complete_data'); + deleteAlertsAndRules(); // esArchiverUnload doesn't work properly when using with `useCreate` and `docsOnly` flags deleteRiskEngineConfiguration(); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/new_entity_flyout.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/new_entity_flyout.cy.ts new file mode 100644 index 00000000000000..a88733b0dd291d --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/new_entity_flyout.cy.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 { + expandFirstAlertHostFlyout, + expandFirstAlertUserFlyout, +} from '../../tasks/asset_criticality/common'; +import { login } from '../../tasks/login'; +import { visitWithTimeRange } from '../../tasks/navigation'; +import { ALERTS_URL } from '../../urls/navigation'; +import { USER_PANEL_HEADER } from '../../screens/hosts/flyout_user_panel'; +import { waitForAlerts } from '../../tasks/alerts'; +import { HOST_PANEL_HEADER } from '../../screens/hosts/flyout_host_panel'; +import { RISK_INPUT_PANEL_HEADER } from '../../screens/flyout_risk_panel'; +import { expandRiskInputsFlyoutPanel } from '../../tasks/risk_scores/risk_inputs_flyout_panel'; +import { mockRiskEngineEnabled } from '../../tasks/entity_analytics'; +import { deleteAlertsAndRules } from '../../tasks/api_calls/common'; + +const USER_NAME = 'user1'; +const SIEM_KIBANA_HOST_NAME = 'Host-fwarau82er'; + +describe( + 'Entity Flyout', + { + tags: ['@ess', '@serverless'], + env: { + ftrConfig: { + kbnServerArgs: [ + `--xpack.securitySolution.enableExperimental=${JSON.stringify([ + 'newUserDetailsFlyout', + 'newHostDetailsFlyout', + ])}`, + ], + }, + }, + }, + () => { + before(() => { + cy.task('esArchiverLoad', { archiveName: 'risk_scores_new_complete_data' }); + cy.task('esArchiverLoad', { archiveName: 'query_alert', useCreate: true, docsOnly: true }); + }); + + after(() => { + cy.task('esArchiverUnload', 'risk_scores_new_complete_data'); + deleteAlertsAndRules(); // esArchiverUnload doesn't work properly when using with `useCreate` and `docsOnly` flags + }); + + beforeEach(() => { + mockRiskEngineEnabled(); + login(); + visitWithTimeRange(ALERTS_URL); + }); + + describe('User details', () => { + it('should display entity flyout and open risk input panel', () => { + waitForAlerts(); + expandFirstAlertUserFlyout(); + + cy.log('header section'); + cy.get(USER_PANEL_HEADER).should('contain.text', USER_NAME); + + cy.log('risk input'); + expandRiskInputsFlyoutPanel(); + cy.get(RISK_INPUT_PANEL_HEADER).should('exist'); + }); + }); + + describe('Host details', () => { + it('should display entity flyout and open risk input panel', () => { + waitForAlerts(); + expandFirstAlertHostFlyout(); + + cy.log('header section'); + cy.get(HOST_PANEL_HEADER).should('contain.text', SIEM_KIBANA_HOST_NAME); + + cy.log('risk input'); + expandRiskInputsFlyoutPanel(); + cy.get(RISK_INPUT_PANEL_HEADER).should('exist'); + }); + }); + } +); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/ransomware_detection.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/ransomware_detection.cy.ts index 3d51fb2f595be2..c8da8308b76a42 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/ransomware_detection.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/ransomware_detection.cy.ts @@ -13,7 +13,7 @@ import { ALERTS_URL, TIMELINES_URL } from '../../../urls/navigation'; import { ALERTS_HISTOGRAM_SERIES, ALERT_RULE_NAME, MESSAGE } from '../../../screens/alerts'; import { TIMELINE_QUERY, TIMELINE_VIEW_IN_ANALYZER } from '../../../screens/timeline'; import { selectAlertsHistogram } from '../../../tasks/alerts'; -import { createTimeline } from '../../../tasks/timelines'; +import { openTimelineUsingToggle } from '../../../tasks/security_main'; import { deleteTimelines } from '../../../tasks/api_calls/common'; describe('Ransomware Detection Alerts', { tags: ['@ess', '@serverless'] }, () => { @@ -53,10 +53,10 @@ describe('Ransomware Detection Alerts', { tags: ['@ess', '@serverless'] }, () => deleteTimelines(); login(); visitWithTimeRange(TIMELINES_URL); - createTimeline(); }); it('should show ransomware entries in timelines table', () => { + openTimelineUsingToggle(); cy.get(TIMELINE_QUERY).type('event.code: "ransomware"{enter}'); // Wait for grid to load, it should have an analyzer icon diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/creation.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/creation.cy.ts index d048eb2155ef6c..fe47cf3a538cbf 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/creation.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/creation.cy.ts @@ -45,18 +45,17 @@ import { addNameToTimelineAndSave, addNameToTimelineAndSaveAsNew, } from '../../../tasks/timeline'; -import { createTimeline } from '../../../tasks/timelines'; +import { waitForTimelinesPanelToBeLoaded } from '../../../tasks/timelines'; import { OVERVIEW_URL, TIMELINE_TEMPLATES_URL, TIMELINES_URL } from '../../../urls/navigation'; -// FLAKY: https://github.com/elastic/kibana/issues/173339 -describe.skip('Timelines', { tags: ['@ess', '@serverless'] }, (): void => { +describe('Timelines', { tags: ['@ess', '@serverless'] }, (): void => { beforeEach(() => { - login(); deleteTimelines(); }); - it('creates a timeline from a template and should have the same query and open the timeline modal', () => { + it('should create a timeline from a template and should have the same query and open the timeline modal', () => { + login(); createTimelineTemplate(getTimeline()); visit(TIMELINE_TEMPLATES_URL); selectCustomTemplates(); @@ -64,46 +63,35 @@ describe.skip('Timelines', { tags: ['@ess', '@serverless'] }, (): void => { clickingOnCreateTimelineFormTemplateBtn(); cy.get(TIMELINE_FLYOUT_WRAPPER).should('have.css', 'visibility', 'visible'); cy.get(TIMELINE_QUERY).should('have.text', getTimeline().query); - closeTimeline(); }); - describe('Toggle create timeline from "New" btn', () => { - context('Privileges: CRUD', () => { - beforeEach(() => { - login(); - visitWithTimeRange(OVERVIEW_URL); - }); - - it('toggle create timeline ', () => { - openTimelineUsingToggle(); - createNewTimeline(); - addNameAndDescriptionToTimeline(getTimeline()); - cy.get(TIMELINE_PANEL).should('be.visible'); - }); - }); - - context('Privileges: READ', () => { - beforeEach(() => { - login(ROLES.t1_analyst); - visitWithTimeRange(OVERVIEW_URL); - }); - - it('should not be able to create/update timeline ', () => { - openTimelineUsingToggle(); - createNewTimeline(); - cy.get(TIMELINE_PANEL).should('be.visible'); - cy.get(SAVE_TIMELINE_ACTION_BTN).should('be.disabled'); - cy.get(SAVE_TIMELINE_ACTION_BTN).first().realHover(); - cy.get(SAVE_TIMELINE_TOOLTIP).should('be.visible'); - cy.get(SAVE_TIMELINE_TOOLTIP).should( - 'have.text', - 'You can use Timeline to investigate events, but you do not have the required permissions to save timelines for future use. If you need to save timelines, contact your Kibana administrator.' - ); - }); - }); + it('should be able to create timeline with crud privileges', () => { + login(); + visitWithTimeRange(OVERVIEW_URL); + openTimelineUsingToggle(); + createNewTimeline(); + addNameAndDescriptionToTimeline(getTimeline()); + cy.get(TIMELINE_PANEL).should('be.visible'); + }); + + it('should not be able to create/update timeline with only read privileges', () => { + login(ROLES.t1_analyst); + visitWithTimeRange(TIMELINES_URL); + waitForTimelinesPanelToBeLoaded(); + openTimelineUsingToggle(); + createNewTimeline(); + cy.get(TIMELINE_PANEL).should('be.visible'); + cy.get(SAVE_TIMELINE_ACTION_BTN).should('be.disabled'); + cy.get(SAVE_TIMELINE_ACTION_BTN).first().realHover(); + cy.get(SAVE_TIMELINE_TOOLTIP).should('be.visible'); + cy.get(SAVE_TIMELINE_TOOLTIP).should( + 'have.text', + 'You can use Timeline to investigate events, but you do not have the required permissions to save timelines for future use. If you need to save timelines, contact your Kibana administrator.' + ); }); - it('creates a timeline by clicking untitled timeline from bottom bar', () => { + it('should create a timeline by clicking untitled timeline from bottom bar', () => { + login(); visitWithTimeRange(OVERVIEW_URL); openTimelineUsingToggle(); addNameAndDescriptionToTimeline(getTimeline()); @@ -126,14 +114,17 @@ describe.skip('Timelines', { tags: ['@ess', '@serverless'] }, (): void => { .should('have.text', getTimeline().notes); }); - it('shows the different timeline states', () => { + it('should show the different timeline states', () => { + login(); visitWithTimeRange(TIMELINES_URL); - createTimeline(); + openTimelineUsingToggle(); // Unsaved cy.get(TIMELINE_PANEL).should('be.visible'); cy.get(TIMELINE_STATUS).should('be.visible'); - cy.get(TIMELINE_STATUS).should('have.text', 'Unsaved'); + cy.get(TIMELINE_STATUS) + .invoke('text') + .should('match', /^Unsaved/); addNameToTimelineAndSave('Test'); @@ -155,10 +146,11 @@ describe.skip('Timelines', { tags: ['@ess', '@serverless'] }, (): void => { }); it('should save timelines as new', () => { + login(); visitWithTimeRange(TIMELINES_URL); cy.get(ROWS).should('have.length', '0'); - createTimeline(); + openTimelineUsingToggle(); addNameToTimelineAndSave('First'); // Offsetting the extra save that is happening in the background 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 776770060e85b1..efa1b50ee81472 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 ea9742a9c08297..e3f9023fe189d1 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/e2e/investigations/timelines/open_timeline.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/open_timeline.cy.ts index e1dc678631124e..6c1ec9b093df76 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/open_timeline.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/open_timeline.cy.ts @@ -6,7 +6,6 @@ */ import { getTimeline } from '../../../objects/timeline'; - import { TIMELINE_TITLE, OPEN_TIMELINE_MODAL } from '../../../screens/timeline'; import { TIMELINES_DESCRIPTION, @@ -15,9 +14,7 @@ import { TIMELINES_FAVORITE, } from '../../../screens/timelines'; import { addNoteToTimeline } from '../../../tasks/api_calls/notes'; - import { createTimeline } from '../../../tasks/api_calls/timelines'; - import { login } from '../../../tasks/login'; import { visit } from '../../../tasks/navigation'; import { @@ -27,44 +24,42 @@ import { pinFirstEvent, refreshTimelinesUntilTimeLinePresent, } from '../../../tasks/timeline'; - import { TIMELINES_URL } from '../../../urls/navigation'; +import { deleteTimelines } from '../../../tasks/api_calls/common'; -describe('Open timeline', { tags: ['@serverless', '@ess'] }, () => { - describe('Open timeline modal', () => { - before(function () { - login(); - visit(TIMELINES_URL); - createTimeline(getTimeline()) - .then((response) => response.body.data.persistTimeline.timeline.savedObjectId) - .then((timelineId: string) => { - refreshTimelinesUntilTimeLinePresent(timelineId) - // This cy.wait is here because we cannot do a pipe on a timeline as that will introduce multiple URL - // request responses and indeterminism since on clicks to activates URL's. - .then(() => cy.wrap(timelineId).as('timelineId')) - // eslint-disable-next-line cypress/no-unnecessary-waiting - .then(() => cy.wait(1000)) - .then(() => - addNoteToTimeline(getTimeline().notes, timelineId).should((response) => - expect(response.status).to.equal(200) - ) +describe('Open timeline modal', { tags: ['@serverless', '@ess'] }, () => { + beforeEach(function () { + deleteTimelines(); + login(); + visit(TIMELINES_URL); + createTimeline(getTimeline()) + .then((response) => response.body.data.persistTimeline.timeline.savedObjectId) + .then((timelineId: string) => { + refreshTimelinesUntilTimeLinePresent(timelineId) + // This cy.wait is here because we cannot do a pipe on a timeline as that will introduce multiple URL + // request responses and indeterminism since on clicks to activates URL's. + .then(() => cy.wrap(timelineId).as('timelineId')) + // eslint-disable-next-line cypress/no-unnecessary-waiting + .then(() => cy.wait(1000)) + .then(() => + addNoteToTimeline(getTimeline().notes, timelineId).should((response) => + expect(response.status).to.equal(200) ) - .then(() => openTimelineById(timelineId)) - .then(() => pinFirstEvent()) - .then(() => markAsFavorite()); - }); - }); + ) + .then(() => openTimelineById(timelineId)) + .then(() => pinFirstEvent()) + .then(() => markAsFavorite()); + }); + }); - it('should display timeline info', function () { - openTimelineFromSettings(); - openTimelineById(this.timelineId); - cy.get(OPEN_TIMELINE_MODAL).should('be.visible'); - cy.contains(getTimeline().title).should('exist'); - cy.get(TIMELINES_DESCRIPTION).last().should('have.text', getTimeline().description); - cy.get(TIMELINES_PINNED_EVENT_COUNT).last().should('have.text', '1'); - cy.get(TIMELINES_NOTES_COUNT).last().should('have.text', '1'); - cy.get(TIMELINES_FAVORITE).last().should('exist'); - cy.get(TIMELINE_TITLE).should('have.text', getTimeline().title); - }); + it('should display timeline info in the open timeline modal', () => { + openTimelineFromSettings(); + cy.get(OPEN_TIMELINE_MODAL).should('be.visible'); + cy.contains(getTimeline().title).should('exist'); + cy.get(TIMELINES_DESCRIPTION).last().should('have.text', getTimeline().description); + cy.get(TIMELINES_PINNED_EVENT_COUNT).last().should('have.text', '1'); + cy.get(TIMELINES_NOTES_COUNT).last().should('have.text', '1'); + cy.get(TIMELINES_FAVORITE).last().should('exist'); + cy.get(TIMELINE_TITLE).should('have.text', getTimeline().title); }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/objects/rule.ts b/x-pack/test/security_solution_cypress/cypress/objects/rule.ts index 681eff67d071ed..5b20fe24b6c7a1 100644 --- a/x-pack/test/security_solution_cypress/cypress/objects/rule.ts +++ b/x-pack/test/security_solution_cypress/cypress/objects/rule.ts @@ -39,27 +39,27 @@ export const getThreatIndexPatterns = (): string[] => ['logs-ti_*']; const getMitre1 = (): Threat => ({ framework: 'MITRE ATT&CK', tactic: { - name: getMockThreatData().tactic.name, - id: getMockThreatData().tactic.id, - reference: getMockThreatData().tactic.reference, + name: getMockThreatData()[0].tactic.name, + id: getMockThreatData()[0].tactic.id, + reference: getMockThreatData()[0].tactic.reference, }, technique: [ { - id: getMockThreatData().technique.id, - reference: getMockThreatData().technique.reference, - name: getMockThreatData().technique.name, + id: getMockThreatData()[0].technique.id, + reference: getMockThreatData()[0].technique.reference, + name: getMockThreatData()[0].technique.name, subtechnique: [ { - id: getMockThreatData().subtechnique.id, - name: getMockThreatData().subtechnique.name, - reference: getMockThreatData().subtechnique.reference, + id: getMockThreatData()[0].subtechnique.id, + name: getMockThreatData()[0].subtechnique.name, + reference: getMockThreatData()[0].subtechnique.reference, }, ], }, { - name: getMockThreatData().technique.name, - id: getMockThreatData().technique.id, - reference: getMockThreatData().technique.reference, + name: getMockThreatData()[0].technique.name, + id: getMockThreatData()[0].technique.id, + reference: getMockThreatData()[0].technique.reference, subtechnique: [], }, ], @@ -68,20 +68,20 @@ const getMitre1 = (): Threat => ({ const getMitre2 = (): Threat => ({ framework: 'MITRE ATT&CK', tactic: { - name: getMockThreatData().tactic.name, - id: getMockThreatData().tactic.id, - reference: getMockThreatData().tactic.reference, + name: getMockThreatData()[1].tactic.name, + id: getMockThreatData()[1].tactic.id, + reference: getMockThreatData()[1].tactic.reference, }, technique: [ { - id: getMockThreatData().technique.id, - reference: getMockThreatData().technique.reference, - name: getMockThreatData().technique.name, + id: getMockThreatData()[1].technique.id, + reference: getMockThreatData()[1].technique.reference, + name: getMockThreatData()[1].technique.name, subtechnique: [ { - id: getMockThreatData().subtechnique.id, - name: getMockThreatData().subtechnique.name, - reference: getMockThreatData().subtechnique.reference, + id: getMockThreatData()[1].subtechnique.id, + name: getMockThreatData()[1].subtechnique.name, + reference: getMockThreatData()[1].subtechnique.reference, }, ], }, diff --git a/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts b/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts index 417e53e86b9527..a30990eccb4035 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts @@ -91,8 +91,6 @@ export const ATTACH_ALERT_TO_CASE_BUTTON = '[data-test-subj="add-to-existing-cas export const ATTACH_TO_NEW_CASE_BUTTON = '[data-test-subj="add-to-new-case-action"]'; -export const USER_COLUMN = '[data-gridcell-column-id="user.name"]'; - export const HOST_RISK_HEADER_COLUMN = '[data-test-subj="dataGridHeaderCell-host.risk.calculated_level"]'; @@ -229,4 +227,5 @@ export const ALERT_DETAILS_ASSIGN_BUTTON = export const ALERT_DETAILS_TAKE_ACTION_BUTTON = '[data-test-subj="take-action-dropdown-btn"]'; +export const USER_COLUMN = '[data-gridcell-column-id="user.name"]'; export const TOOLTIP = '[data-test-subj="message-tool-tip"]'; diff --git a/x-pack/test/security_solution_cypress/cypress/screens/asset_criticality/flyouts.ts b/x-pack/test/security_solution_cypress/cypress/screens/asset_criticality/flyouts.ts index 7fbfb5f3a03e00..276feb83c45a30 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/asset_criticality/flyouts.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/asset_criticality/flyouts.ts @@ -30,4 +30,4 @@ export const HOST_DETAILS_FLYOUT_ASSET_CRITICALITY_MODAL_SAVE_BTN = getDataTestS ); export const OPEN_HOST_FLYOUT_BUTTON = getDataTestSubjectSelector('host-details-button'); -export const OPEN_USER_FLYOUT_BUTTON = getDataTestSubjectSelector('user-details-button'); +export const OPEN_USER_FLYOUT_BUTTON = getDataTestSubjectSelector('users-link-anchor'); 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 6b53754cde44f4..8b2d9ccac1bfa3 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/screens/flyout_risk_panel.ts b/x-pack/test/security_solution_cypress/cypress/screens/flyout_risk_panel.ts new file mode 100644 index 00000000000000..6c72fe439d27f8 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/screens/flyout_risk_panel.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getDataTestSubjectSelector } from '../helpers/common'; + +export const RISK_INPUTS_BUTTON = getDataTestSubjectSelector('riskInputsTitleLink'); +export const RISK_INPUT_PANEL_HEADER = getDataTestSubjectSelector( + 'securitySolutionFlyoutRiskInputsTab' +); diff --git a/x-pack/test/security_solution_cypress/cypress/screens/hosts/flyout_host_panel.ts b/x-pack/test/security_solution_cypress/cypress/screens/hosts/flyout_host_panel.ts new file mode 100644 index 00000000000000..12f0256a24a34c --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/screens/hosts/flyout_host_panel.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getDataTestSubjectSelector } from '../../helpers/common'; + +export const HOST_PANEL_HEADER = getDataTestSubjectSelector('host-panel-header'); diff --git a/x-pack/test/security_solution_cypress/cypress/screens/hosts/flyout_user_panel.ts b/x-pack/test/security_solution_cypress/cypress/screens/hosts/flyout_user_panel.ts new file mode 100644 index 00000000000000..170c87349fe459 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/screens/hosts/flyout_user_panel.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getDataTestSubjectSelector } from '../../helpers/common'; + +export const USER_PANEL_HEADER = getDataTestSubjectSelector('user-panel-header'); diff --git a/x-pack/test/security_solution_cypress/cypress/screens/rules_coverage_overview.ts b/x-pack/test/security_solution_cypress/cypress/screens/rules_coverage_overview.ts new file mode 100644 index 00000000000000..bf696867e61cf9 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/screens/rules_coverage_overview.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const COVERAGE_OVERVIEW_TECHNIQUE_PANEL = + '[data-test-subj="coverageOverviewTechniquePanel"]'; + +export const COVERAGE_OVERVIEW_TECHNIQUE_PANEL_IN_TACTIC_GROUP = (id: string) => + `[data-test-subj="coverageOverviewTacticGroup-${id}"] [data-test-subj="coverageOverviewTechniquePanel"]`; + +export const COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES = + '[data-test-subj="coverageOverviewEnabledRulesList"]'; + +export const COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES = + '[data-test-subj="coverageOverviewDisabledRulesList"]'; + +export const COVERAGE_OVERVIEW_ENABLE_ALL_DISABLED_BUTTON = + '[data-test-subj="enableAllDisabledButton"]'; + +export const COVERAGE_OVERVIEW_ACTIVITY_FILTER_BUTTON = + '[data-test-subj="coverageOverviewRuleActivityFilterButton"]'; + +export const COVERAGE_OVERVIEW_SOURCE_FILTER_BUTTON = + '[data-test-subj="coverageOverviewRuleSourceFilterButton"]'; + +export const COVERAGE_OVERVIEW_FILTER_LIST = '[data-test-subj="coverageOverviewFilterList"]'; + +export const COVERAGE_OVERVIEW_SEARCH_BAR = '[data-test-subj="coverageOverviewFilterSearchBar"]'; + +export const COVERAGE_OVERVIEW_TACTIC_PANEL = '[data-test-subj="coverageOverviewTacticPanel"]'; + +export const COVERAGE_OVERVIEW_TACTIC_ENABLED_STATS = + '[data-test-subj="ruleStatsEnabledRulesCount"]'; + +export const COVERAGE_OVERVIEW_TACTIC_DISABLED_STATS = + '[data-test-subj="ruleStatsDisabledRulesCount"]'; diff --git a/x-pack/test/security_solution_cypress/cypress/screens/security_main.ts b/x-pack/test/security_solution_cypress/cypress/screens/security_main.ts index f68950dbd159ab..6eacc61dd76ad4 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/security_main.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/security_main.ts @@ -9,6 +9,6 @@ export const CLOSE_TIMELINE_BUTTON = '[data-test-subj="close-timeline"]'; export const MAIN_PAGE = '[data-test-subj="kibanaChrome"]'; -export const TIMELINE_TOGGLE_BUTTON = '[data-test-subj="flyoutOverlay"]'; +export const TIMELINE_TOGGLE_BUTTON = '[data-test-subj="timeline-bottom-bar-title-button"]'; -export const TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON = `[data-test-subj="flyoutBottomBar"] ${TIMELINE_TOGGLE_BUTTON}`; +export const TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON = `[data-test-subj="timeline-bottom-bar"] ${TIMELINE_TOGGLE_BUTTON}`; diff --git a/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts b/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts index 2794a111e94c2e..69d3d4020b37fc 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts @@ -30,8 +30,6 @@ export const COMBO_BOX_INPUT = '[data-test-subj="comboBoxInput"]'; export const CREATE_NEW_TIMELINE = '[data-test-subj="timeline-new"]'; -export const CREATE_NEW_TIMELINE_WITH_BORDER = '[data-test-subj="timeline-new-with-border"]'; - export const CREATE_NEW_TIMELINE_TEMPLATE = '[data-test-subj="template-timeline-new"]'; export const DATA_PROVIDERS = '.field-value'; @@ -218,6 +216,8 @@ export const TIMELINE_EXIT_FULL_SCREEN_BUTTON = '[data-test-subj="exit-full-scre export const TIMELINE_FLYOUT_WRAPPER = '[data-test-subj="flyout-pane"]'; +export const TIMELINE_WRAPPER = '[data-test-subj="timeline-wrapper"]'; + export const TIMELINE_FULL_SCREEN_BUTTON = '[data-test-subj="full-screen-active"]'; export const TIMELINE_PROGRESS_BAR = '[data-test-subj="progress-bar"]'; @@ -262,8 +262,7 @@ export const ALERT_TABLE_SEVERITY_HEADER = '[data-gridcell-column-id="kibana.ale export const ALERT_TABLE_FILE_NAME_VALUES = '[data-gridcell-column-id="file.name"][data-test-subj="dataGridRowCell"]'; // empty column for the test data -export const ACTIVE_TIMELINE_BOTTOM_BAR = - '[data-test-subj="flyoutBottomBar"] .active-timeline-button'; +export const ACTIVE_TIMELINE_BOTTOM_BAR = '[data-test-subj="timeline-bottom-bar-title-button"]'; export const GET_TIMELINE_GRID_CELL = (fieldName: string) => `[data-test-subj="draggable-content-${fieldName}"]`; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/asset_criticality/common.ts b/x-pack/test/security_solution_cypress/cypress/tasks/asset_criticality/common.ts index 79979c8a33016a..515bd4061365af 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/asset_criticality/common.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/asset_criticality/common.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { USER_COLUMN } from '../../screens/alerts'; import { OPEN_HOST_FLYOUT_BUTTON, OPEN_USER_FLYOUT_BUTTON, @@ -14,6 +15,7 @@ import { HOST_DETAILS_FLYOUT_ASSET_CRITICALITY_MODAL_SELECT_OPTION, HOST_DETAILS_FLYOUT_ASSET_CRITICALITY_MODAL_SAVE_BTN, } from '../../screens/asset_criticality/flyouts'; +import { scrollAlertTableColumnIntoView } from '../alerts'; /** * Find the first alert row in the alerts table then click on the host name to open the flyout @@ -26,6 +28,7 @@ export const expandFirstAlertHostFlyout = () => { * Find the first alert row in the alerts table then click on the host name to open the flyout */ export const expandFirstAlertUserFlyout = () => { + scrollAlertTableColumnIntoView(USER_COLUMN); cy.get(OPEN_USER_FLYOUT_BUTTON).first().click(); }; 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 31a2ffb2afb5a0..a4e0eafc0018a5 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 @@ -5,6 +5,10 @@ * 2.0. */ +import { + RISK_ENGINE_STATUS_URL, + RISK_SCORE_INDEX_STATUS_API_URL, +} from '@kbn/security-solution-plugin/common/constants'; import { BASIC_TABLE_LOADING } from '../screens/common'; import { ANOMALIES_TABLE_ROWS, @@ -44,10 +48,32 @@ export const riskEngineStatusChange = () => { cy.get(RISK_SCORE_SWITCH).click(); }; +export const mockRiskEngineEnabled = () => { + // mock the risk engine status + cy.intercept('GET', RISK_ENGINE_STATUS_URL, { + statusCode: 200, + body: { + risk_engine_status: 'ENABLED', + legacy_risk_engine_status: 'INSTALLED', + is_max_amount_of_risk_engines_reached: false, + }, + }).as('riskEngineStatus'); + + // mock the risk index status + cy.intercept('GET', `${RISK_SCORE_INDEX_STATUS_API_URL}?indexName=*&entity=*`, { + statusCode: 200, + body: { + isDeprecated: false, + isEnabled: true, + }, + }).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'); }; export const updateRiskEngine = () => { 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 c31196a96a5509..f9e83fb449b773 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/risk_scores/risk_inputs_flyout_panel.ts b/x-pack/test/security_solution_cypress/cypress/tasks/risk_scores/risk_inputs_flyout_panel.ts new file mode 100644 index 00000000000000..4de8d1dc85c0da --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/tasks/risk_scores/risk_inputs_flyout_panel.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RISK_INPUTS_BUTTON } from '../../screens/flyout_risk_panel'; + +/** + * Expand the expandable flyout left section with risk inputs details */ +export const expandRiskInputsFlyoutPanel = () => { + cy.get(RISK_INPUTS_BUTTON).click(); +}; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/rules_coverage_overview.ts b/x-pack/test/security_solution_cypress/cypress/tasks/rules_coverage_overview.ts new file mode 100644 index 00000000000000..5f8f18bb8a36ba --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/tasks/rules_coverage_overview.ts @@ -0,0 +1,51 @@ +/* + * 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 { + COVERAGE_OVERVIEW_ACTIVITY_FILTER_BUTTON, + COVERAGE_OVERVIEW_ENABLE_ALL_DISABLED_BUTTON, + COVERAGE_OVERVIEW_FILTER_LIST, + COVERAGE_OVERVIEW_SEARCH_BAR, + COVERAGE_OVERVIEW_SOURCE_FILTER_BUTTON, + COVERAGE_OVERVIEW_TECHNIQUE_PANEL_IN_TACTIC_GROUP, + COVERAGE_OVERVIEW_TECHNIQUE_PANEL, +} from '../screens/rules_coverage_overview'; +import { LOADING_INDICATOR } from '../screens/security_header'; + +export const openTechniquePanelByName = (label: string) => { + cy.get(COVERAGE_OVERVIEW_TECHNIQUE_PANEL).contains(label).click(); +}; + +export const openTechniquePanelByNameAndTacticId = (label: string, tacticId: string) => { + cy.get(COVERAGE_OVERVIEW_TECHNIQUE_PANEL_IN_TACTIC_GROUP(tacticId)).contains(label).click(); +}; + +export const selectCoverageOverviewActivityFilterOption = (option: string) => { + cy.get(COVERAGE_OVERVIEW_ACTIVITY_FILTER_BUTTON).click(); // open filter popover + cy.get(COVERAGE_OVERVIEW_FILTER_LIST).contains(option).click(); + cy.get(LOADING_INDICATOR).should('not.exist'); + cy.get(COVERAGE_OVERVIEW_ACTIVITY_FILTER_BUTTON).click(); // close filter popover +}; + +export const selectCoverageOverviewSourceFilterOption = (option: string) => { + cy.get(COVERAGE_OVERVIEW_SOURCE_FILTER_BUTTON).click(); // open filter popover + cy.get(COVERAGE_OVERVIEW_FILTER_LIST).contains(option).click(); + cy.get(LOADING_INDICATOR).should('not.exist'); + cy.get(COVERAGE_OVERVIEW_SOURCE_FILTER_BUTTON).click(); // close filter popover +}; + +export const filterCoverageOverviewBySearchBar = (searchTerm: string) => { + cy.get(COVERAGE_OVERVIEW_SEARCH_BAR).type(`${searchTerm}`); + cy.get(COVERAGE_OVERVIEW_SEARCH_BAR).focus(); + cy.get(COVERAGE_OVERVIEW_SEARCH_BAR).realType('{enter}'); +}; + +export const enableAllDisabledRules = () => { + cy.get(COVERAGE_OVERVIEW_ENABLE_ALL_DISABLED_BUTTON).click(); + cy.get(COVERAGE_OVERVIEW_ENABLE_ALL_DISABLED_BUTTON).should('not.exist'); + cy.get(LOADING_INDICATOR).should('not.exist'); +}; 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 15c171676c958b..fbf31d19e5a4c0 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 @@ -5,11 +5,23 @@ * 2.0. */ +import { recurse } from 'cypress-recurse'; import { CLOSE_TIMELINE_BUTTON, TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON } from '../screens/security_main'; -import { TIMELINE_EXIT_FULL_SCREEN_BUTTON, TIMELINE_FULL_SCREEN_BUTTON } from '../screens/timeline'; +import { + TIMELINE_EXIT_FULL_SCREEN_BUTTON, + TIMELINE_FULL_SCREEN_BUTTON, + TIMELINE_WRAPPER, +} from '../screens/timeline'; export const openTimelineUsingToggle = () => { - cy.get(TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON).click(); + recurse( + () => { + cy.get(TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON).click(); + return cy.get(TIMELINE_WRAPPER); + }, + // Retry if somehow the timeline wrapper is still hidden + ($timelineWrapper) => !$timelineWrapper.hasClass('timeline-wrapper--hidden') + ); }; export const closeTimelineUsingCloseButton = () => { @@ -17,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 d9db1f7debcfa5..d8543ec852c17f 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts @@ -84,6 +84,7 @@ import { TIMELINE_SEARCH_OR_FILTER, TIMELINE_KQLMODE_FILTER, TIMELINE_KQLMODE_SEARCH, + TIMELINE_PANEL, } from '../screens/timeline'; import { REFRESH_BUTTON, TIMELINE, TIMELINES_TAB_TEMPLATE } from '../screens/timelines'; @@ -180,9 +181,6 @@ export const addNotesToTimeline = (notes: string) => { cy.get(`${NOTES_TAB_BUTTON} .euiBadge`).should('have.text', `${notesCount + 1}`); }); - - goToQueryTab(); - goToNotesTab(); }; export const addEqlToTimeline = (eql: string) => { @@ -303,7 +301,7 @@ export const clickIdToggleField = () => { }; export const closeTimeline = () => { - cy.get(CLOSE_TIMELINE_BTN).filter(':visible').click(); + cy.get(CLOSE_TIMELINE_BTN).click(); cy.get(QUERY_TAB_BUTTON).should('not.be.visible'); }; @@ -367,12 +365,12 @@ export const saveTimeline = () => { export const markAsFavorite = () => { cy.intercept('PATCH', 'api/timeline/_favorite').as('markedAsFavourite'); - cy.get(STAR_ICON).click({ force: true }); + cy.get(TIMELINE_PANEL).within(() => cy.get(STAR_ICON).click()); cy.wait('@markedAsFavourite'); }; export const openTimelineFieldsBrowser = () => { - cy.get(TIMELINE_FIELDS_BUTTON).first().click({ force: true }); + cy.get(TIMELINE_FIELDS_BUTTON).first().click(); }; export const openTimelineInspectButton = () => { @@ -381,7 +379,6 @@ export const openTimelineInspectButton = () => { }; export const openTimelineFromSettings = () => { - cy.get(OPEN_TIMELINE_ICON).should('be.visible'); cy.get(OPEN_TIMELINE_ICON).click(); }; @@ -401,7 +398,7 @@ export const openTimelineById = (timelineId: string): Cypress.Chainable { @@ -409,7 +406,7 @@ export const openActiveTimeline = () => { }; export const pinFirstEvent = (): Cypress.Chainable> => { - return cy.get(PIN_EVENT).first().click({ force: true }); + return cy.get(PIN_EVENT).first().click(); }; export const populateTimeline = () => { @@ -470,11 +467,11 @@ export const refreshTimelinesUntilTimeLinePresent = ( }; export const clickingOnCreateTimelineFormTemplateBtn = () => { - cy.get(TIMELINE_CREATE_TIMELINE_FROM_TEMPLATE_BTN).click({ force: true }); + cy.get(TIMELINE_CREATE_TIMELINE_FROM_TEMPLATE_BTN).click(); }; export const clickingOnCreateTemplateFromTimelineBtn = () => { - cy.get(TIMELINE_CREATE_TEMPLATE_FROM_TIMELINE_BTN).click({ force: true }); + cy.get(TIMELINE_CREATE_TEMPLATE_FROM_TIMELINE_BTN).click(); }; export const expandEventAction = () => { diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/timelines.ts b/x-pack/test/security_solution_cypress/cypress/tasks/timelines.ts index 607b1d3e679788..338017829e9f09 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/timelines.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/timelines.ts @@ -21,7 +21,6 @@ import { } from '../screens/timelines'; import { SELECT_ALL_CHECKBOX } from '../screens/shared'; import { - CREATE_NEW_TIMELINE_WITH_BORDER, TIMELINE_COLLAPSED_ITEMS_BTN, TIMELINE_CREATE_TIMELINE_FROM_TEMPLATE_BTN, } from '../screens/timeline'; @@ -68,9 +67,6 @@ export const exportSelectedTimelines = () => { cy.get(EXPORT_TIMELINE_ACTION).click(); }; -export const createTimeline = () => - cy.get(CREATE_NEW_TIMELINE_WITH_BORDER).should('be.visible').click(); - export const createTimelineFromFirstTemplateInList = () => { cy.get(TIMELINE_COLLAPSED_ITEMS_BTN).first().click(); cy.get(TIMELINE_CREATE_TIMELINE_FROM_TEMPLATE_BTN).click(); diff --git a/x-pack/test/security_solution_cypress/cypress/urls/rules_management.ts b/x-pack/test/security_solution_cypress/cypress/urls/rules_management.ts index 6b0f4bc2312474..03af67cfe79db9 100644 --- a/x-pack/test/security_solution_cypress/cypress/urls/rules_management.ts +++ b/x-pack/test/security_solution_cypress/cypress/urls/rules_management.ts @@ -7,3 +7,4 @@ export const RULES_MANAGEMENT_URL = '/app/security/rules/management'; export const RULES_MONITORING_URL = '/app/security/rules/monitoring'; +export const RULES_COVERAGE_OVERVIEW_URL = '/app/security/rules_coverage_overview'; 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 3e1b52cb22f5e9..e250700644c15f 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/risk_scores_new_complete_data/data.json b/x-pack/test/security_solution_cypress/es_archives/risk_scores_new_complete_data/data.json new file mode 100644 index 00000000000000..71efdeb5b4c3f4 --- /dev/null +++ b/x-pack/test/security_solution_cypress/es_archives/risk_scores_new_complete_data/data.json @@ -0,0 +1,986 @@ +{ + "type": "doc", + "value": { + "id": "a4cf452c1e0375c3d4412cb550ad1783358468a3b3b777da4829d72c7d6fb74f", + "index": "risk-score.risk-score-latest-default", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "host": { + "name": "siem-kibana", + "risk": { + "calculated_level": "Critical", + "calculated_score_norm": 90, + "id_field": "host.name", + "id_value": "siem-kibana", + "calculated_score": 150, + "category_1_score": 150, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "62895f54816047b9bf82929a61a6c571f41de9c2361670f6ef0136360e006f58", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "New Rule Test", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + }, + { + "id": "e5bf3da3c855486ac7b40fa1aa33e19cf1380e413b79ed76bddf728f8fec4462", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "New Rule Test", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb71f", + "index": "risk-score.risk-score-latest-default", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "host": { + "name": "fake-1", + "risk": { + "calculated_level": "Moderate", + "calculated_score_norm": 50, + "id_field": "host.name", + "id_value": "fake-1", + "calculated_score": 200, + "category_1_score": 200, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1283358468a3b3b777da4829d72c7d6fb72f", + "index": "risk-score.risk-score-latest-default", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "host": { + "name": "fake-2", + "risk": { + "calculated_level": "Moderate", + "calculated_score_norm": 50, + "id_field": "host.name", + "id_value": "fake-2", + "calculated_score": 220, + "category_1_score": 200, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1782358468a3b3b777da4829d72c7d6fb73f", + "index": "risk-score.risk-score-latest-default", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "host": { + "name": "fake-3", + "risk": { + "calculated_level": "Moderate", + "calculated_score_norm": 50, + "id_field": "host.name", + "id_value": "fake-3", + "calculated_score": 220, + "category_1_score": 220, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb745", + "index": "risk-score.risk-score-latest-default", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "host": { + "name": "fake-4", + "risk": { + "calculated_level": "Moderate", + "calculated_score_norm": 50, + "id_field": "host.name", + "id_value": "fake-4", + "calculated_score": 220, + "category_1_score": 200, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb752", + "index": "risk-score.risk-score-latest-default", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "host": { + "name": "fake-5", + "risk": { + "calculated_level": "Moderate", + "calculated_score_norm": 50, + "id_field": "host.name", + "id_value": "fake-5", + "calculated_score": 220, + "category_1_score": 220, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb73B", + "index": "risk-score.risk-score-latest-default", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "host": { + "name": "Host-fwarau82er", + "risk": { + "calculated_level": "Moderate", + "calculated_score_norm": 50, + "id_field": "host.name", + "id_value": "Host-fwarau82er", + "calculated_score": 220, + "category_1_score": 220, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "eabbdefc23da981f2b74ab58b82622a97bb9878caa11bc914e2adfacc94780f1", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Endpoint Security", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d2fb74f", + "index": "risk-score.risk-score-latest-default", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "user": { + "name": "user2", + "risk": { + "calculated_score_norm": 50, + "calculated_level": "Moderate", + "id_field": "user.name", + "id_value": "user2", + "calculated_score": 150, + "category_1_score": 150, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb72f", + "index": "risk-score.risk-score-latest-default", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "user": { + "name": "user3", + "risk": { + "calculated_score_norm": 50, + "calculated_level": "Moderate", + "id_field": "user.name", + "id_value": "user3", + "calculated_score": 150, + "category_1_score": 150, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb73f", + "index": "risk-score.risk-score-latest-default", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "user": { + "name": "user4", + "risk": { + "calculated_score_norm": 50, + "calculated_level": "Moderate", + "id_field": "user.name", + "id_value": "user4", + "calculated_score": 150, + "category_1_score": 150, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb74f", + "index": "risk-score.risk-score-latest-default", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "user": { + "name": "user5", + "risk": { + "calculated_score_norm": 50, + "calculated_level": "Moderate", + "id_field": "user.name", + "id_value": "user6", + "calculated_score": 150, + "category_1_score": 150, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb75f", + "index": "risk-score.risk-score-latest-default", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "user": { + "name": "user6", + "risk": { + "calculated_score_norm": 50, + "calculated_level": "Moderate", + "id_field": "user.name", + "id_value": "user6", + "calculated_score": 220, + "category_1_score": 220, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a4cf452c1e0375c3d4412cb550bd1783358468b3b3b777da4829d72c7d6fb74f", + "index": "risk-score.risk-score-latest-default", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "user": { + "name": "user1", + "risk": { + "calculated_score_norm": 21, + "calculated_level": "Low", + "id_field": "user.name", + "id_value": "user1", + "calculated_score": 150, + "category_1_score": 150, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "eabbdefc23da981f2b74ab58b82622a97bb9878caa11bc914e2adfacc94780f1", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Endpoint Security", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a4cf452c1e0375c3d4412cb550bd1783358468b3123314829d72c7df6fb74", + "index": "risk-score.risk-score-latest-default", + "source": { + "@timestamp": "2021-03-10T14:52:05.766Z", + "user": { + "name": "test", + "risk": { + "calculated_score_norm": 60, + "calculated_level": "High", + "id_field": "user.name", + "id_value": "test", + "calculated_score": 150, + "category_1_score": 150, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "eabbdefc23da981f2b74ab58b82622a97bb9878caa11bc914e2adfacc94780f1-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + + +{ + "type": "doc", + "value": { + "id": "a4cf452c1e0375c3d4412cb550ad1783358468a3b3b777da4829d72c7d6fb74f", + "data_stream": "risk-score.risk-score-default", + "index": ".ds-risk-score.risk-score-default-000001", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "host": { + "name": "siem-kibana", + "risk": { + "calculated_level": "Critical", + "calculated_score_norm": 90, + "id_field": "host.name", + "id_value": "siem-kibana", + "calculated_score": 150, + "category_1_score": 150, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "62895f54816047b9bf82929a61a6c571f41de9c2361670f6ef0136360e006f58", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "New Rule Test", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + }, + { + "id": "e5bf3da3c855486ac7b40fa1aa33e19cf1380e413b79ed76bddf728f8fec4462", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "New Rule Test", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb71f", + "data_stream": "risk-score.risk-score-default", + "index": ".ds-risk-score.risk-score-default-000001", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "host": { + "name": "fake-1", + "risk": { + "calculated_level": "Moderate", + "calculated_score_norm": 50, + "id_field": "host.name", + "id_value": "fake-1", + "calculated_score": 200, + "category_1_score": 200, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1283358468a3b3b777da4829d72c7d6fb72f", + "data_stream": "risk-score.risk-score-default", + "index": ".ds-risk-score.risk-score-default-000001", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "host": { + "name": "fake-2", + "risk": { + "calculated_level": "Moderate", + "calculated_score_norm": 50, + "id_field": "host.name", + "id_value": "fake-2", + "calculated_score": 220, + "category_1_score": 200, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1782358468a3b3b777da4829d72c7d6fb73f", + "data_stream": "risk-score.risk-score-default", + "index": ".ds-risk-score.risk-score-default-000001", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "host": { + "name": "fake-3", + "risk": { + "calculated_level": "Moderate", + "calculated_score_norm": 50, + "id_field": "host.name", + "id_value": "fake-3", + "calculated_score": 220, + "category_1_score": 220, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb745", + "data_stream": "risk-score.risk-score-default", + "index": ".ds-risk-score.risk-score-default-000001", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "host": { + "name": "fake-4", + "risk": { + "calculated_level": "Moderate", + "calculated_score_norm": 50, + "id_field": "host.name", + "id_value": "fake-4", + "calculated_score": 220, + "category_1_score": 200, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb752", + "data_stream": "risk-score.risk-score-default", + "index": ".ds-risk-score.risk-score-default-000001", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "host": { + "name": "fake-5", + "risk": { + "calculated_level": "Moderate", + "calculated_score_norm": 50, + "id_field": "host.name", + "id_value": "fake-5", + "calculated_score": 220, + "category_1_score": 220, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb73B", + "data_stream": "risk-score.risk-score-default", + "index": ".ds-risk-score.risk-score-default-000001", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "host": { + "name": "Host-fwarau82er", + "risk": { + "calculated_level": "Moderate", + "calculated_score_norm": 50, + "id_field": "host.name", + "id_value": "Host-fwarau82er", + "calculated_score": 220, + "category_1_score": 220, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "eabbdefc23da981f2b74ab58b82622a97bb9878caa11bc914e2adfacc94780f1", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Endpoint Security", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d2fb74f", + "data_stream": "risk-score.risk-score-default", + "index": ".ds-risk-score.risk-score-default-000001", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "user": { + "name": "user2", + "risk": { + "calculated_score_norm": 50, + "calculated_level": "Moderate", + "id_field": "user.name", + "id_value": "user2", + "calculated_score": 150, + "category_1_score": 150, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb72f", + "data_stream": "risk-score.risk-score-default", + "index": ".ds-risk-score.risk-score-default-000001", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "user": { + "name": "user3", + "risk": { + "calculated_score_norm": 50, + "calculated_level": "Moderate", + "id_field": "user.name", + "id_value": "user3", + "calculated_score": 150, + "category_1_score": 150, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb73f", + "data_stream": "risk-score.risk-score-default", + "index": ".ds-risk-score.risk-score-default-000001", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "user": { + "name": "user4", + "risk": { + "calculated_score_norm": 50, + "calculated_level": "Moderate", + "id_field": "user.name", + "id_value": "user4", + "calculated_score": 150, + "category_1_score": 150, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb74f", + "data_stream": "risk-score.risk-score-default", + "index": ".ds-risk-score.risk-score-default-000001", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "user": { + "name": "user5", + "risk": { + "calculated_score_norm": 50, + "calculated_level": "Moderate", + "id_field": "user.name", + "id_value": "user6", + "calculated_score": 150, + "category_1_score": 150, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a2cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb75f", + "data_stream": "risk-score.risk-score-default", + "index": ".ds-risk-score.risk-score-default-000001", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "user": { + "name": "user6", + "risk": { + "calculated_score_norm": 50, + "calculated_level": "Moderate", + "id_field": "user.name", + "id_value": "user6", + "calculated_score": 220, + "category_1_score": 220, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "2e17f189-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a4cf452c1e0375c3d4412cb550bd1783358468b3b3b777da4829d72c7d6fb74f", + "data_stream": "risk-score.risk-score-default", + "index": ".ds-risk-score.risk-score-default-000001", + "source": { + "@timestamp": "2021-03-10T14:51:05.766Z", + "user": { + "name": "user1", + "risk": { + "calculated_score_norm": 21, + "calculated_level": "Low", + "id_field": "user.name", + "id_value": "user1", + "calculated_score": 150, + "category_1_score": 150, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "eabbdefc23da981f2b74ab58b82622a97bb9878caa11bc914e2adfacc94780f1", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Endpoint Security", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "a4cf452c1e0375c3d4412cb550bd1783358468b3123314829d72c7df6fb74", + "data_stream": "risk-score.risk-score-default", + "index": ".ds-risk-score.risk-score-default-000001", + "source": { + "@timestamp": "2021-03-10T14:52:05.766Z", + "user": { + "name": "test", + "risk": { + "calculated_score_norm": 60, + "calculated_level": "High", + "id_field": "user.name", + "id_value": "test", + "calculated_score": 150, + "category_1_score": 150, + "category_1_count": 1, + "notes": [], + "inputs": [ + { + "id": "eabbdefc23da981f2b74ab58b82622a97bb9878caa11bc914e2adfacc94780f1-d77d-4537-8d84-592e29334493", + "index": ".internal.alerts-security.alerts-default-000001", + "description": "Alert from Rule: Rule 2", + "category": "category_1", + "risk_score": 70, + "timestamp": "2023-08-14T09:08:18.664Z" + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/x-pack/test/security_solution_cypress/es_archives/risk_scores_new_complete_data/mappings.json b/x-pack/test/security_solution_cypress/es_archives/risk_scores_new_complete_data/mappings.json new file mode 100644 index 00000000000000..7eace06dd4d1ac --- /dev/null +++ b/x-pack/test/security_solution_cypress/es_archives/risk_scores_new_complete_data/mappings.json @@ -0,0 +1,279 @@ +{ + "type": "index", + "value": { + "index": "risk-score.risk-score-latest-default", + "mappings": { + "dynamic": "strict", + "properties": { + "@timestamp": { + "type": "date" + }, + "host": { + "properties": { + "name": { + "type": "keyword" + }, + "risk": { + "properties": { + "calculated_level": { + "type": "keyword" + }, + "calculated_score": { + "type": "float" + }, + "calculated_score_norm": { + "type": "float" + }, + "category_1_count": { + "type": "long" + }, + "category_1_score": { + "type": "float" + }, + "id_field": { + "type": "keyword" + }, + "id_value": { + "type": "keyword" + }, + "inputs": { + "properties": { + "category": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "timestamp": { + "type": "date" + } + } + }, + "notes": { + "type": "keyword" + } + } + } + } + }, + "user": { + "properties": { + "name": { + "type": "keyword" + }, + "risk": { + "properties": { + "calculated_level": { + "type": "keyword" + }, + "calculated_score": { + "type": "float" + }, + "calculated_score_norm": { + "type": "float" + }, + "category_1_count": { + "type": "long" + }, + "category_1_score": { + "type": "float" + }, + "id_field": { + "type": "keyword" + }, + "id_value": { + "type": "keyword" + }, + "inputs": { + "properties": { + "category": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "timestamp": { + "type": "date" + } + } + }, + "notes": { + "type": "keyword" + } + } + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} + +{ + "type": "data_stream", + "value": { + "data_stream": "risk-score.risk-score-default", + "template": { + "_meta": { + "managed": true, + "namespace": "default" + }, + "data_stream": { + "hidden": true + }, + "index_patterns": [ + "risk-score.risk-score-default" + ], + "name": "risk-score.risk-score-default-index-template", + "template": { + "mappings": { + "dynamic": false, + "properties": { + "@timestamp": { + "type": "date" + }, + "host": { + "properties": { + "name": { + "type": "keyword" + }, + "risk": { + "properties": { + "calculated_level": { + "type": "keyword" + }, + "calculated_score": { + "type": "float" + }, + "calculated_score_norm": { + "type": "float" + }, + "category_1_count": { + "type": "long" + }, + "category_1_score": { + "type": "float" + }, + "id_field": { + "type": "keyword" + }, + "id_value": { + "type": "keyword" + }, + "inputs": { + "properties": { + "category": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "timestamp": { + "type": "date" + } + } + }, + "notes": { + "type": "keyword" + } + } + } + } + }, + "user": { + "properties": { + "name": { + "type": "keyword" + }, + "risk": { + "properties": { + "calculated_level": { + "type": "keyword" + }, + "calculated_score": { + "type": "float" + }, + "calculated_score_norm": { + "type": "float" + }, + "category_1_count": { + "type": "long" + }, + "category_1_score": { + "type": "float" + }, + "id_field": { + "type": "keyword" + }, + "id_value": { + "type": "keyword" + }, + "inputs": { + "properties": { + "category": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "timestamp": { + "type": "date" + } + } + }, + "notes": { + "type": "keyword" + } + } + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/x-pack/test/security_solution_ftr/page_objects/timeline/index.ts b/x-pack/test/security_solution_ftr/page_objects/timeline/index.ts index e7f35e31702cc0..83cc2f8bd1f336 100644 --- a/x-pack/test/security_solution_ftr/page_objects/timeline/index.ts +++ b/x-pack/test/security_solution_ftr/page_objects/timeline/index.ts @@ -9,7 +9,7 @@ import { subj as testSubjSelector } from '@kbn/test-subj-selector'; import { DATE_RANGE_OPTION_TO_TEST_SUBJ_MAP } from '@kbn/security-solution-plugin/common/test'; import { FtrService } from '../../../functional/ftr_provider_context'; -const TIMELINE_BOTTOM_BAR_CONTAINER_TEST_SUBJ = 'flyoutBottomBar'; +const TIMELINE_BOTTOM_BAR_CONTAINER_TEST_SUBJ = 'timeline-bottom-bar'; const TIMELINE_CLOSE_BUTTON_TEST_SUBJ = 'close-timeline'; const TIMELINE_MODAL_PAGE_TEST_SUBJ = 'timeline'; const TIMELINE_TAB_QUERY_TEST_SUBJ = 'timeline-tab-content-query'; @@ -17,7 +17,7 @@ const TIMELINE_TAB_QUERY_TEST_SUBJ = 'timeline-tab-content-query'; const TIMELINE_CSS_SELECTOR = Object.freeze({ bottomBarTimelineTitle: `${testSubjSelector( TIMELINE_BOTTOM_BAR_CONTAINER_TEST_SUBJ - )} ${testSubjSelector('timeline-title')}`, + )} ${testSubjSelector('timeline-bottom-bar-title-button')}`, /** The refresh button on the timeline view (top of view, next to the date selector) */ refreshButton: `${testSubjSelector(TIMELINE_TAB_QUERY_TEST_SUBJ)} ${testSubjSelector( 'superDatePickerApplyTimeButton' diff --git a/x-pack/test_serverless/README.md b/x-pack/test_serverless/README.md index 6d7c6e350b9b2d..f90f89a8b3b46e 100644 --- a/x-pack/test_serverless/README.md +++ b/x-pack/test_serverless/README.md @@ -48,10 +48,11 @@ x-pack/test_serverless/ ### Common tests As outlined above, tests in the `common` API integration and functional test suites are -covering functionality that's shared across serverless projects. As a result, these tests -are automatically included in all project specific test configurations and don't have a -dedicated configuration file. We always run in the context of one of the serverless projects -and invoke the corresponding set of tests, which then also includes the `common` tests. +covering functionality that's shared across serverless projects. That's why these tests +don't have a dedicated config file and instead need to be included in project specific +configurations. + +**If you add a new `api_integration` or `functional` `common` sub-directory, remember to add it to the corresponding `common_configs` of all projects (`x-pack/test_serverless/[api_integration|functional]/test_suites/[observability|search|security]/common_configs`).** In case a common test needs to be skipped for one of the projects, there are the following suite tags available to do so: `skipSvlOblt`, `skipSvlSearch`, `skipSvlSec`, which can be @@ -71,6 +72,8 @@ specific test directory and not to `common` with two skips. Note, that `common` tests are invoked three times in a full test run: once per project to make sure the covered shared functionality works correctly in every project. So when writing tests there, be mindful about the test run time. +See also the README files for [Serverless Common API Integration Tests](https://github.com/elastic/kibana/blob/main/x-pack/test_serverless/api_integration/test_suites/common/README.md) and [Serverless Common Functional Tests](https://github.com/elastic/kibana/blob/main/x-pack/test_serverless/functional/test_suites/common/README.md). + ### Shared services and page objects Test services and page objects from `x-pack/test/[api_integration|functional]` diff --git a/x-pack/test_serverless/functional/services/svl_sec_navigation.ts b/x-pack/test_serverless/functional/services/svl_sec_navigation.ts index 85f21940d5ab14..c990258cd80603 100644 --- a/x-pack/test_serverless/functional/services/svl_sec_navigation.ts +++ b/x-pack/test_serverless/functional/services/svl_sec_navigation.ts @@ -19,10 +19,7 @@ export function SvlSecNavigationServiceProvider({ async navigateToLandingPage() { await retry.tryForTime(60 * 1000, async () => { await PageObjects.common.navigateToApp('landingPage'); - // Currently, the security landing page app is not loading correctly. - // Replace '~kbnAppWrapper' with a proper test subject of the landing - // page once it loads successfully. - await testSubjects.existOrFail('~kbnAppWrapper', { timeout: 2000 }); + await testSubjects.existOrFail('welcome-header', { timeout: 2000 }); }); }, }; 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 684278f7e86389..9768eb43e76ca4 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 ccba699ffa7105..cf581ad3edb510 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 0435fdffa7ede4..0504049f89ed5c 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 371ee4debe98f2..6092473ad27bc8 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 @@ -75,7 +75,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); describe('Create index template', () => { - const TEST_TEMPLATE_NAME = `test_template_${Math.random()}`; + const TEST_TEMPLATE_NAME = `test_template_${Date.now()}`; after(async () => { await es.indices.deleteIndexTemplate({ name: TEST_TEMPLATE_NAME }, { ignore: [404] }); diff --git a/x-pack/test_serverless/functional/test_suites/common/visualizations/group1/smokescreen.ts b/x-pack/test_serverless/functional/test_suites/common/visualizations/group1/smokescreen.ts index 5015b4be2250c5..d4337d16db4eab 100644 --- a/x-pack/test_serverless/functional/test_suites/common/visualizations/group1/smokescreen.ts +++ b/x-pack/test_serverless/functional/test_suites/common/visualizations/group1/smokescreen.ts @@ -281,7 +281,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(data?.axes?.y?.[1].gridlines.length).to.eql(0); }); - it('should transition from a multi-layer stacked bar to donut chart using suggestions', async () => { + it('should transition from a multi-layer stacked bar to treemap chart using suggestions', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); await PageObjects.lens.goToTimeRange(); @@ -313,10 +313,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); await PageObjects.lens.save('twolayerchart'); - await testSubjects.click('lnsSuggestion-donut > lnsSuggestion'); + await testSubjects.click('lnsSuggestion-treemap > lnsSuggestion'); expect(await PageObjects.lens.getLayerCount()).to.eql(1); - expect(await PageObjects.lens.getDimensionTriggerText('lnsPie_sliceByDimensionPanel')).to.eql( + expect(await PageObjects.lens.getDimensionTriggerText('lnsPie_groupByDimensionPanel')).to.eql( 'Top 5 values of geo.dest' ); expect(await PageObjects.lens.getDimensionTriggerText('lnsPie_sizeByDimensionPanel')).to.eql( diff --git a/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/columns_selection.ts b/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/columns_selection.ts index d19cb269891d7d..f73a350a501eb0 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/columns_selection.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/columns_selection.ts @@ -5,35 +5,48 @@ * 2.0. */ import expect from '@kbn/expect'; +import { log, timerange } from '@kbn/apm-synthtrace-client'; +import moment from 'moment'; import { FtrProviderContext } from '../../../ftr_provider_context'; -const defaultLogColumns = ['@timestamp', 'service.name', 'host.name', 'message']; +const defaultLogColumns = ['@timestamp', 'service.name', 'host.name', 'content']; export default function ({ getService, getPageObjects }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); const retry = getService('retry'); const PageObjects = getPageObjects(['discover', 'observabilityLogExplorer', 'svlCommonPage']); + const synthtrace = getService('svlLogsSynthtraceClient'); + const dataGrid = getService('dataGrid'); + const testSubjects = getService('testSubjects'); + const from = '2023-12-27T10:24:14.035Z'; + const to = '2023-12-27T10:25:14.091Z'; + const TEST_TIMEOUT = 10 * 1000; // 10 secs - describe('Columns selection initialization and update', () => { + const navigateToLogExplorer = () => + PageObjects.observabilityLogExplorer.navigateTo({ + pageState: { + time: { + from, + to, + mode: 'absolute', + }, + }, + }); + + describe('When the log explorer loads', () => { before(async () => { - await esArchiver.load( - 'x-pack/test/functional/es_archives/observability_log_explorer/data_streams' - ); + await synthtrace.index(generateLogsData({ to })); await PageObjects.svlCommonPage.login(); + await navigateToLogExplorer(); }); after(async () => { + await synthtrace.clean(); await PageObjects.svlCommonPage.forceLogout(); - await esArchiver.unload( - 'x-pack/test/functional/es_archives/observability_log_explorer/data_streams' - ); }); - describe('when the log explorer loads', () => { + describe('columns selection initialization and update', () => { it("should initialize the table columns to logs' default selection", async () => { - await PageObjects.observabilityLogExplorer.navigateTo(); - - await retry.try(async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { expect(await PageObjects.discover.getColumnHeaders()).to.eql(defaultLogColumns); }); }); @@ -41,16 +54,21 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should restore the table columns from the URL state if exists', async () => { await PageObjects.observabilityLogExplorer.navigateTo({ pageState: { + time: { + from, + to, + mode: 'absolute', + }, columns: [ { field: 'service.name' }, { field: 'host.name' }, - { field: 'message' }, + { field: 'content' }, { field: 'data_stream.namespace' }, ], }, }); - await retry.try(async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { expect(await PageObjects.discover.getColumnHeaders()).to.eql([ ...defaultLogColumns, 'data_stream.namespace', @@ -58,5 +76,235 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); }); + + describe('render content virtual column properly', async () => { + it('should render log level and log message when present', async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { + const cellElement = await dataGrid.getCellElement(0, 5); + const cellValue = await cellElement.getVisibleText(); + expect(cellValue.includes('info')).to.be(true); + expect(cellValue.includes('A sample log')).to.be(true); + }); + }); + + it('should render log message when present and skip log level when missing', async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { + const cellElement = await dataGrid.getCellElement(1, 5); + const cellValue = await cellElement.getVisibleText(); + expect(cellValue.includes('info')).to.be(false); + expect(cellValue.includes('A sample log')).to.be(true); + }); + }); + + it('should render message from error object when top level message not present', async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { + const cellElement = await dataGrid.getCellElement(2, 5); + const cellValue = await cellElement.getVisibleText(); + expect(cellValue.includes('info')).to.be(true); + expect(cellValue.includes('error.message')).to.be(true); + expect(cellValue.includes('message in error object')).to.be(true); + }); + }); + + it('should render message from event.original when top level message and error.message not present', async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { + const cellElement = await dataGrid.getCellElement(3, 5); + const cellValue = await cellElement.getVisibleText(); + expect(cellValue.includes('info')).to.be(true); + expect(cellValue.includes('event.original')).to.be(true); + expect(cellValue.includes('message in event original')).to.be(true); + }); + }); + + it('should render the whole JSON when neither message, error.message and event.original are present', async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { + const cellElement = await dataGrid.getCellElement(4, 5); + const cellValue = await cellElement.getVisibleText(); + expect(cellValue.includes('info')).to.be(true); + + expect(cellValue.includes('error.message')).to.be(false); + expect(cellValue.includes('event.original')).to.be(false); + + const cellAttribute = await cellElement.findByTestSubject( + 'logExplorerCellDescriptionList' + ); + expect(cellAttribute).not.to.be.empty(); + }); + }); + + it('on cell expansion with no message field should open JSON Viewer', async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { + await dataGrid.clickCellExpandButton(4, 5); + await testSubjects.existOrFail('dataTableExpandCellActionJsonPopover'); + }); + }); + + it('on cell expansion with message field should open regular popover', async () => { + await navigateToLogExplorer(); + await retry.tryForTime(TEST_TIMEOUT, async () => { + await dataGrid.clickCellExpandButton(3, 5); + await testSubjects.existOrFail('euiDataGridExpansionPopover'); + }); + }); + }); + + describe('virtual column cell actions', async () => { + beforeEach(async () => { + await navigateToLogExplorer(); + }); + it('should render a popover with cell actions when a chip on content column is clicked', async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { + const cellElement = await dataGrid.getCellElement(0, 5); + const logLevelChip = await cellElement.findByTestSubject( + 'dataTablePopoverChip_log.level' + ); + await logLevelChip.click(); + // Check Filter In button is present + await testSubjects.existOrFail('dataTableCellAction_addToFilterAction_log.level'); + // Check Filter Out button is present + await testSubjects.existOrFail('dataTableCellAction_removeFromFilterAction_log.level'); + // Check Copy button is present + await testSubjects.existOrFail('dataTableCellAction_copyToClipboardAction_log.level'); + }); + }); + + it('should render the table filtered where log.level value is info when filter in action is clicked', async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { + const cellElement = await dataGrid.getCellElement(0, 5); + const logLevelChip = await cellElement.findByTestSubject( + 'dataTablePopoverChip_log.level' + ); + await logLevelChip.click(); + + // Find Filter In button + const filterInButton = await testSubjects.find( + 'dataTableCellAction_addToFilterAction_log.level' + ); + + await filterInButton.click(); + const rowWithLogLevelInfo = await testSubjects.findAll('dataTablePopoverChip_log.level'); + + expect(rowWithLogLevelInfo.length).to.be(4); + }); + }); + + it('should render the table filtered where log.level value is not info when filter out action is clicked', async () => { + await retry.tryForTime(TEST_TIMEOUT, async () => { + const cellElement = await dataGrid.getCellElement(0, 5); + const logLevelChip = await cellElement.findByTestSubject( + 'dataTablePopoverChip_log.level' + ); + await logLevelChip.click(); + + // Find Filter Out button + const filterOutButton = await testSubjects.find( + 'dataTableCellAction_removeFromFilterAction_log.level' + ); + + await filterOutButton.click(); + await testSubjects.missingOrFail('dataTablePopoverChip_log.level'); + }); + }); + }); }); } + +function generateLogsData({ to, count = 1 }: { to: string; count?: number }) { + const logs = timerange(moment(to).subtract(1, 'second'), moment(to)) + .interval('1m') + .rate(1) + .generator((timestamp) => + Array(count) + .fill(0) + .map(() => { + return log.create().message('A sample log').logLevel('info').timestamp(timestamp); + }) + ); + + const logsWithNoLogLevel = timerange( + moment(to).subtract(2, 'second'), + moment(to).subtract(1, 'second') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => + Array(count) + .fill(0) + .map(() => { + return log.create().message('A sample log').timestamp(timestamp); + }) + ); + + const logsWithErrorMessage = timerange( + moment(to).subtract(3, 'second'), + moment(to).subtract(2, 'second') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => + Array(count) + .fill(0) + .map(() => { + return log + .create() + .logLevel('info') + .timestamp(timestamp) + .defaults({ 'error.message': 'message in error object' }); + }) + ); + + const logsWithEventOriginal = timerange( + moment(to).subtract(4, 'second'), + moment(to).subtract(3, 'second') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => + Array(count) + .fill(0) + .map(() => { + return log + .create() + .logLevel('info') + .timestamp(timestamp) + .defaults({ 'event.original': 'message in event original' }); + }) + ); + + const logsWithNoMessage = timerange( + moment(to).subtract(5, 'second'), + moment(to).subtract(4, 'second') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => + Array(count) + .fill(0) + .map(() => { + return log.create().logLevel('info').timestamp(timestamp); + }) + ); + + const logWithNoMessageNoLogLevel = timerange( + moment(to).subtract(6, 'second'), + moment(to).subtract(5, 'second') + ) + .interval('1m') + .rate(1) + .generator((timestamp) => + Array(count) + .fill(0) + .map(() => { + return log.create().timestamp(timestamp); + }) + ); + + return [ + logs, + logsWithNoLogLevel, + logsWithErrorMessage, + logsWithEventOriginal, + logsWithNoMessage, + logWithNoMessageNoLogLevel, + ]; +} diff --git a/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/header_menu.ts b/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/header_menu.ts index 0bb8da7a911b95..d245d2aa719118 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/header_menu.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/observability_log_explorer/header_menu.ts @@ -93,7 +93,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { '@timestamp', 'service.name', 'host.name', - 'message', + 'content', ]); }); await retry.try(async () => { @@ -150,9 +150,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await retry.try(async () => { expect(await PageObjects.discover.getColumnHeaders()).not.to.eql([ '@timestamp', + 'content', 'service.name', 'host.name', - 'message', ]); }); diff --git a/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/cases/list_view.ts b/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/cases/list_view.ts index cc1d8b5f789952..4cdb543612035d 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/cases/list_view.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/cases/list_view.ts @@ -10,11 +10,14 @@ import { FtrProviderContext } from '../../../../../ftr_provider_context'; import { navigateToCasesApp } from '../../../../../../shared/lib/cases'; export default function ({ getPageObject, getPageObjects, getService }: FtrProviderContext) { + const cases = getService('cases'); const pageObjects = getPageObjects(['common', 'header', 'svlCommonPage']); const svlCases = getService('svlCases'); const svlCommonScreenshots = getService('svlCommonScreenshots'); const screenshotDirectories = ['response_ops_docs', 'observability_cases']; + const testSubjects = getService('testSubjects'); const owner = OBSERVABILITY_OWNER; + let caseIdMonitoring: string; describe('list view', function () { before(async () => { @@ -36,7 +39,7 @@ export default function ({ getPageObject, getPageObjects, getService }: FtrProvi }) ); - await svlCases.api.createCase( + const caseMonitoring = await svlCases.api.createCase( svlCases.api.getPostCaseRequest(owner, { title: 'Monitor uptime', tags: ['swimlane'], @@ -44,6 +47,7 @@ export default function ({ getPageObject, getPageObjects, getService }: FtrProvi owner, }) ); + caseIdMonitoring = caseMonitoring.id; }); after(async () => { @@ -59,5 +63,33 @@ export default function ({ getPageObject, getPageObjects, getService }: FtrProvi await navigateToCasesApp(getPageObject, getService, owner); await svlCommonScreenshots.takeScreenshot('cases', screenshotDirectories, 1700, 1024); }); + + it('case settings screenshot', async () => { + await navigateToCasesApp(getPageObject, getService, owner); + await testSubjects.click('configure-case-button'); + await svlCommonScreenshots.takeScreenshot('add-case-connector', screenshotDirectories); + }); + + it('case detail screenshot', async () => { + await pageObjects.common.navigateToUrlWithBrowserHistory( + 'observability', + `/cases/${caseIdMonitoring}`, + undefined + ); + await pageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('case-view-title'); + const collapseNav = await testSubjects.find('euiCollapsibleNavButton'); + await collapseNav.click(); + const filesTab = await testSubjects.find('case-view-tab-title-files'); + await filesTab.click(); + await cases.casesFilesTable.addFile(require.resolve('./testfile.png')); + await testSubjects.getVisibleText('cases-files-name-link'); + await svlCommonScreenshots.takeScreenshot( + 'cases-files-tab', + screenshotDirectories, + 1024, + 768 + ); + }); }); } diff --git a/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/cases/testfile.png b/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/cases/testfile.png new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/connectors/index.ts b/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/connectors/index.ts index 4e2f00e35e94d9..2136348a0a86a4 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/connectors/index.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/screenshot_creation/response_ops_docs/connectors/index.ts @@ -8,13 +8,19 @@ import { FtrProviderContext } from '../../../../../ftr_provider_context'; export default function ({ loadTestFile, getService }: FtrProviderContext) { + const actions = getService('actions'); const browser = getService('browser'); + const svlCommonApi = getService('svlCommonApi'); describe('observability connectors', function () { before(async () => { await browser.setWindowSize(1920, 1080); }); + after(async () => { + await actions.api.deleteAllConnectors(svlCommonApi.getInternalRequestHeader()); + }); + loadTestFile(require.resolve('./server_log_connector')); }); } diff --git a/x-pack/test_serverless/functional/test_suites/security/config.screenshots.ts b/x-pack/test_serverless/functional/test_suites/security/config.screenshots.ts new file mode 100644 index 00000000000000..92a46e01b0e4f4 --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/security/config.screenshots.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createTestConfig } from '../../config.base'; + +export default createTestConfig({ + serverlessProject: 'security', + testFiles: [require.resolve('./screenshot_creation')], + junit: { + reportName: 'Serverless Security Screenshot Creation', + }, + + esServerArgs: ['xpack.ml.ad.enabled=false', 'xpack.ml.dfa.enabled=false'], +}); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/navigation.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/navigation.ts index e3c8de72570b54..69bc9d517e7e84 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/navigation.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/navigation.ts @@ -16,6 +16,8 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { const svlCommonNavigation = getPageObject('svlCommonNavigation'); const testSubjects = getService('testSubjects'); const browser = getService('browser'); + const headerPage = getPageObject('header'); + const retry = getService('retry'); describe('navigation', function () { before(async () => { @@ -49,6 +51,7 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { await svlCommonNavigation.search.searchFor('security dashboards'); await svlCommonNavigation.search.clickOnOption(0); await svlCommonNavigation.search.hideSearch(); + await headerPage.waitUntilLoadingHasFinished(); await expect(await browser.getCurrentUrl()).contain('app/security/dashboards'); }); @@ -63,12 +66,17 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { }); it('navigates to cases app', async () => { - await svlCommonNavigation.sidenav.clickLink({ - deepLinkId: 'securitySolutionUI:cases' as AppDeepLinkId, - }); + await retry.tryForTime(30 * 1000, async () => { + // start navigation to the cases app from the landing page + await svlSecNavigation.navigateToLandingPage(); + await svlCommonNavigation.sidenav.clickLink({ + deepLinkId: 'securitySolutionUI:cases' as AppDeepLinkId, + }); + await headerPage.waitUntilLoadingHasFinished(); - expect(await browser.getCurrentUrl()).contain('/app/security/cases'); - await testSubjects.existOrFail('cases-all-title'); + expect(await browser.getCurrentUrl()).contain('/app/security/cases'); + await testSubjects.existOrFail('cases-all-title'); + }); }); }); } diff --git a/x-pack/test_serverless/functional/test_suites/security/screenshot_creation/index.ts b/x-pack/test_serverless/functional/test_suites/security/screenshot_creation/index.ts new file mode 100644 index 00000000000000..ca0d8ab0c191df --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/security/screenshot_creation/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Screenshots - serverless security UI', function () { + loadTestFile(require.resolve('./response_ops_docs')); + }); +} diff --git a/x-pack/test_serverless/functional/test_suites/security/screenshot_creation/response_ops_docs/cases/index.ts b/x-pack/test_serverless/functional/test_suites/security/screenshot_creation/response_ops_docs/cases/index.ts new file mode 100644 index 00000000000000..c2a17b8e8e82da --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/security/screenshot_creation/response_ops_docs/cases/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { FtrProviderContext } from '../../../../../ftr_provider_context'; + +export default function ({ loadTestFile, getService }: FtrProviderContext) { + const browser = getService('browser'); + + describe('security cases', function () { + before(async () => { + await browser.setWindowSize(1920, 1080); + }); + + loadTestFile(require.resolve('./list_view')); + }); +} diff --git a/x-pack/test_serverless/functional/test_suites/security/screenshot_creation/response_ops_docs/cases/list_view.ts b/x-pack/test_serverless/functional/test_suites/security/screenshot_creation/response_ops_docs/cases/list_view.ts new file mode 100644 index 00000000000000..7103bae64f9841 --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/security/screenshot_creation/response_ops_docs/cases/list_view.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SECURITY_SOLUTION_OWNER } from '@kbn/cases-plugin/common'; +import { CaseSeverity } from '@kbn/cases-plugin/common/types/domain'; +import { FtrProviderContext } from '../../../../../ftr_provider_context'; +import { navigateToCasesApp } from '../../../../../../shared/lib/cases'; + +export default function ({ getPageObject, getPageObjects, getService }: FtrProviderContext) { + const cases = getService('cases'); + const pageObjects = getPageObjects(['common', 'header', 'svlCommonPage']); + const svlCases = getService('svlCases'); + const svlCommonScreenshots = getService('svlCommonScreenshots'); + const screenshotDirectories = ['response_ops_docs', 'security_cases']; + const testSubjects = getService('testSubjects'); + const owner = SECURITY_SOLUTION_OWNER; + let caseIdSuspiciousEmail: string; + + describe('list view', function () { + before(async () => { + await svlCases.api.createCase( + svlCases.api.getPostCaseRequest(owner, { + title: 'Unusual processes identified', + tags: ['linux', 'os processes'], + description: 'Test.', + owner, + severity: CaseSeverity.HIGH, + }) + ); + + const caseSuspiciousEmail = await svlCases.api.createCase( + svlCases.api.getPostCaseRequest(owner, { + title: 'Suspicious emails reported', + tags: ['email', 'phishing'], + description: 'Several employees have received suspicious emails from an unknown address.', + owner, + }) + ); + caseIdSuspiciousEmail = caseSuspiciousEmail.id; + + await svlCases.api.createCase( + svlCases.api.getPostCaseRequest(owner, { + title: 'Malware investigation', + tags: ['malware'], + description: 'Test.', + owner, + severity: CaseSeverity.MEDIUM, + }) + ); + }); + + after(async () => { + await svlCases.api.deleteAllCaseItems(); + await pageObjects.svlCommonPage.forceLogout(); + }); + + beforeEach(async () => { + await pageObjects.svlCommonPage.login(); + }); + + it('cases list screenshot', async () => { + await navigateToCasesApp(getPageObject, getService, owner); + await pageObjects.header.waitUntilLoadingHasFinished(); + await svlCommonScreenshots.takeScreenshot('cases-home-page', screenshotDirectories); + }); + + it('case settings screenshot', async () => { + await navigateToCasesApp(getPageObject, getService, owner); + await testSubjects.click('configure-case-button'); + await pageObjects.header.waitUntilLoadingHasFinished(); + await svlCommonScreenshots.takeScreenshot('case-settings', screenshotDirectories); + }); + + it('case detail screenshot', async () => { + await pageObjects.common.navigateToUrlWithBrowserHistory( + 'securitySolution', + `/cases/${caseIdSuspiciousEmail}`, + undefined + ); + await pageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('case-view-title'); + const collapseNav = await testSubjects.find('euiCollapsibleNavButton'); + await collapseNav.click(); + await svlCommonScreenshots.takeScreenshot('cases-ui-open', screenshotDirectories, 1400, 1024); + const filesTab = await testSubjects.find('case-view-tab-title-files'); + await filesTab.click(); + await cases.casesFilesTable.addFile(require.resolve('./testfile.png')); + await testSubjects.getVisibleText('cases-files-name-link'); + await svlCommonScreenshots.takeScreenshot('cases-files', screenshotDirectories, 1400, 1024); + }); + }); +} diff --git a/x-pack/test_serverless/functional/test_suites/security/screenshot_creation/response_ops_docs/cases/testfile.png b/x-pack/test_serverless/functional/test_suites/security/screenshot_creation/response_ops_docs/cases/testfile.png new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/x-pack/test_serverless/functional/test_suites/security/screenshot_creation/response_ops_docs/index.ts b/x-pack/test_serverless/functional/test_suites/security/screenshot_creation/response_ops_docs/index.ts new file mode 100644 index 00000000000000..6e11aad1dab1c2 --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/security/screenshot_creation/response_ops_docs/index.ts @@ -0,0 +1,30 @@ +/* + * 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 { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const browser = getService('browser'); + const ml = getService('ml'); + + describe('response ops docs', function () { + this.tags(['responseOps']); + + before(async () => { + await ml.testResources.setKibanaTimeZoneToUTC(); + await ml.testResources.disableKibanaAnnouncements(); + await browser.setWindowSize(1920, 1080); + }); + + after(async () => { + await ml.testResources.resetKibanaTimeZone(); + await ml.testResources.resetKibanaAnnouncements(); + }); + + loadTestFile(require.resolve('./cases')); + }); +} diff --git a/yarn.lock b/yarn.lock index ddfb556b671f9a..ad3e54a1e5efc1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1740,10 +1740,10 @@ resolved "https://registry.yarnpkg.com/@elastic/eslint-plugin-eui/-/eslint-plugin-eui-0.0.2.tgz#56b9ef03984a05cc213772ae3713ea8ef47b0314" integrity sha512-IoxURM5zraoQ7C8f+mJb9HYSENiZGgRVcG4tLQxE61yHNNRDXtGDWTZh8N1KIHcsqN1CEPETjuzBXkJYF/fDiQ== -"@elastic/eui@91.3.1": - version "91.3.1" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-91.3.1.tgz#2289b1683f0409c043150c39f5fb7233aa7beb16" - integrity sha512-zxnhBaAsykmDqjbZ3i++CFTyv2dKZSe3UUZKSgYj5MaMnbE86uzeWGjhp3gh1b+LxJZP2672MbNZ6qxVOh1vcQ== +"@elastic/eui@92.0.0-backport.0": + version "92.0.0-backport.0" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-92.0.0-backport.0.tgz#201757bef89141dad6f8f719976fa2a0b52be86a" + integrity sha512-ZliSke0YehCbCuHvYkY0BIMg32QeqTMZy+qgGYsh+Bp0UZ4CZn9j5e7LWavfal3+t8HMFCHTk0RUFFXrcepplA== dependencies: "@hello-pangea/dnd" "^16.3.0" "@types/lodash" "^4.14.198" @@ -11868,12 +11868,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.3: - version "1.6.3" - resolved "https://registry.npmjs.org/axios/-/axios-1.6.3.tgz#7f50f23b3aa246eff43c54834272346c396613f4" - integrity sha512-fWyNdeawGam70jXSVlKl+SUNVcL6j6W79CuSIPfi6HnDUmSCH6gyUys/HrqHeA/wU0Az41rRgean494d0Jb+ww== +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== dependencies: - follow-redirects "^1.15.0" + follow-redirects "^1.15.4" form-data "^4.0.0" proxy-from-env "^1.1.0" @@ -17251,7 +17251,7 @@ 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.0: +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==