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

+ ), + } + : {})} + > + {(list, search) => ( + <> + {search && ( + + {search} + + )} + {list} + + )} +
+
+ ); +}; diff --git a/src/plugins/unified_histogram/public/chart/utils/get_lens_attributes.test.ts b/src/plugins/unified_histogram/public/chart/utils/get_lens_attributes.test.ts index 998cd17968049..3c049649d5c20 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 @@ -643,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 c65f1e2b43c04..fb152a1921e23 100644 --- a/src/plugins/unified_histogram/public/container/container.tsx +++ b/src/plugins/unified_histogram/public/container/container.tsx @@ -54,7 +54,7 @@ export type UnifiedHistogramContainerProps = { | 'relativeTimeRange' | 'columns' | 'container' - | 'appendHitsCounter' + | 'renderCustomChartToggleActions' | 'children' | 'onBrushEnd' | 'onFilter' diff --git a/src/plugins/unified_histogram/public/container/services/state_service.test.ts b/src/plugins/unified_histogram/public/container/services/state_service.test.ts index 73a493e167c19..40304a967243a 100644 --- a/src/plugins/unified_histogram/public/container/services/state_service.test.ts +++ b/src/plugins/unified_histogram/public/container/services/state_service.test.ts @@ -211,28 +211,4 @@ describe('UnifiedHistogramStateService', () => { expect(setTopPanelHeight as jest.Mock).not.toHaveBeenCalled(); expect(setBreakdownField as jest.Mock).not.toHaveBeenCalled(); }); - - it('should not update total hits to loading when the current status is partial', () => { - const stateService = createStateService({ - services: unifiedHistogramServicesMock, - initialState: { - ...initialState, - totalHitsStatus: UnifiedHistogramFetchStatus.partial, - }, - }); - let state: UnifiedHistogramState | undefined; - stateService.state$.subscribe((s) => (state = s)); - expect(state).toEqual({ - ...initialState, - totalHitsStatus: UnifiedHistogramFetchStatus.partial, - }); - stateService.setTotalHits({ - totalHitsStatus: UnifiedHistogramFetchStatus.loading, - totalHitsResult: 100, - }); - expect(state).toEqual({ - ...initialState, - totalHitsStatus: UnifiedHistogramFetchStatus.partial, - }); - }); }); diff --git a/src/plugins/unified_histogram/public/container/services/state_service.ts b/src/plugins/unified_histogram/public/container/services/state_service.ts index f96a4b5b7b033..1a79389e2bc6f 100644 --- a/src/plugins/unified_histogram/public/container/services/state_service.ts +++ b/src/plugins/unified_histogram/public/container/services/state_service.ts @@ -217,15 +217,6 @@ export const createStateService = ( totalHitsStatus: UnifiedHistogramFetchStatus; totalHitsResult: number | Error | undefined; }) => { - // If we have a partial result already, we don't - // want to update the total hits back to loading - if ( - state$.getValue().totalHitsStatus === UnifiedHistogramFetchStatus.partial && - totalHits.totalHitsStatus === UnifiedHistogramFetchStatus.loading - ) { - return; - } - updateState(totalHits); }, }; diff --git a/src/plugins/unified_histogram/public/hits_counter/hits_counter.test.tsx b/src/plugins/unified_histogram/public/hits_counter/hits_counter.test.tsx deleted file mode 100644 index 03b350448e9c2..0000000000000 --- a/src/plugins/unified_histogram/public/hits_counter/hits_counter.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { mountWithIntl } from '@kbn/test-jest-helpers'; -import type { ReactWrapper } from 'enzyme'; -import type { HitsCounterProps } from './hits_counter'; -import { HitsCounter } from './hits_counter'; -import { findTestSubject } from '@elastic/eui/lib/test'; -import { EuiLoadingSpinner } from '@elastic/eui'; -import { UnifiedHistogramFetchStatus } from '../types'; - -describe('hits counter', function () { - let props: HitsCounterProps; - let component: ReactWrapper; - - beforeAll(() => { - props = { - hits: { - status: UnifiedHistogramFetchStatus.complete, - total: 2, - }, - }; - }); - - it('expect to render the number of hits', function () { - component = mountWithIntl(); - const hits = findTestSubject(component, 'unifiedHistogramQueryHits'); - expect(hits.text()).toBe('2'); - }); - - it('expect to render 1,899 hits if 1899 hits given', function () { - component = mountWithIntl( - - ); - const hits = findTestSubject(component, 'unifiedHistogramQueryHits'); - expect(hits.text()).toBe('1,899'); - }); - - it('should render the element passed to the append prop', () => { - const appendHitsCounter =
appendHitsCounter
; - component = mountWithIntl(); - expect(findTestSubject(component, 'appendHitsCounter').length).toBe(1); - }); - - it('should render a EuiLoadingSpinner when status is partial', () => { - component = mountWithIntl( - - ); - expect(component.find(EuiLoadingSpinner).length).toBe(1); - }); - - it('should render unifiedHistogramQueryHitsPartial when status is partial', () => { - component = mountWithIntl( - - ); - expect(component.find('[data-test-subj="unifiedHistogramQueryHitsPartial"]').length).toBe(1); - }); - - it('should render unifiedHistogramQueryHits when status is complete', () => { - component = mountWithIntl(); - expect(component.find('[data-test-subj="unifiedHistogramQueryHits"]').length).toBe(1); - }); -}); diff --git a/src/plugins/unified_histogram/public/hits_counter/hits_counter.tsx b/src/plugins/unified_histogram/public/hits_counter/hits_counter.tsx deleted file mode 100644 index b6f1212bfeaed..0000000000000 --- a/src/plugins/unified_histogram/public/hits_counter/hits_counter.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import type { ReactElement } from 'react'; -import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiText, EuiLoadingSpinner } from '@elastic/eui'; -import { FormattedMessage, FormattedNumber } from '@kbn/i18n-react'; -import { i18n } from '@kbn/i18n'; -import { css } from '@emotion/react'; -import type { UnifiedHistogramHitsContext } from '../types'; - -export interface HitsCounterProps { - hits: UnifiedHistogramHitsContext; - append?: ReactElement; -} - -export function HitsCounter({ hits, append }: HitsCounterProps) { - if (!hits.total && hits.status === 'loading') { - return null; - } - - const formattedHits = ( - - - - ); - - const hitsCounterCss = css` - flex-grow: 0; - `; - const hitsCounterTextCss = css` - overflow: hidden; - `; - - return ( - - - - {hits.status === 'partial' && ( - - )} - {hits.status !== 'partial' && ( - - )} - - - {hits.status === 'partial' && ( - - - - )} - {append} - - ); -} diff --git a/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.test.ts b/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.test.ts index 119356af6f63f..f74cc8a3c5925 100644 --- a/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.test.ts +++ b/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.test.ts @@ -137,7 +137,7 @@ describe('useLensSuggestions', () => { currentSuggestion: allSuggestionsMock[0], isOnHistogramMode: true, histogramQuery: { - esql: 'from the-data-view | limit 100 | EVAL timestamp=DATE_TRUNC(30 minute, @timestamp) | stats rows = count(*) by timestamp | rename timestamp as `@timestamp every 30 minute`', + esql: 'from the-data-view | limit 100 | EVAL timestamp=DATE_TRUNC(30 minute, @timestamp) | stats results = count(*) by timestamp | rename timestamp as `@timestamp every 30 minute`', }, suggestionUnsupported: false, }); diff --git a/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.ts b/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.ts index 063e1b7ef89a2..ac1053fd7fa3f 100644 --- a/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.ts +++ b/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.ts @@ -87,7 +87,7 @@ export const useLensSuggestions = ({ const interval = computeInterval(timeRange, data); const language = getAggregateQueryMode(query); const safeQuery = cleanupESQLQueryForLensSuggestions(query[language]); - const esqlQuery = `${safeQuery} | EVAL timestamp=DATE_TRUNC(${interval}, ${dataView.timeFieldName}) | stats rows = count(*) by timestamp | rename timestamp as \`${dataView.timeFieldName} every ${interval}\``; + const esqlQuery = `${safeQuery} | EVAL timestamp=DATE_TRUNC(${interval}, ${dataView.timeFieldName}) | stats results = count(*) by timestamp | rename timestamp as \`${dataView.timeFieldName} every ${interval}\``; const context = { dataViewSpec: dataView?.toSpec(), fieldName: '', @@ -100,8 +100,8 @@ export const useLensSuggestions = ({ }, }, { - id: 'rows', - name: 'rows', + id: 'results', + name: 'results', meta: { type: 'number', }, diff --git a/src/plugins/unified_histogram/public/layout/layout.test.tsx b/src/plugins/unified_histogram/public/layout/layout.test.tsx index a12c8cf46430e..a10df63e7c328 100644 --- a/src/plugins/unified_histogram/public/layout/layout.test.tsx +++ b/src/plugins/unified_histogram/public/layout/layout.test.tsx @@ -10,7 +10,6 @@ import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_ import { mountWithIntl } from '@kbn/test-jest-helpers'; import type { ReactWrapper } from 'enzyme'; import React from 'react'; -import { act } from 'react-dom/test-utils'; import { of } from 'rxjs'; import { Chart } from '../chart'; import { @@ -153,13 +152,6 @@ describe('Layout', () => { height: `${expectedHeight}px`, }); }); - - it('should pass undefined for onResetChartHeight to Chart when layout mode is ResizableLayoutMode.Static', async () => { - const component = await mountComponent({ topPanelHeight: 123 }); - expect(component.find(Chart).prop('onResetChartHeight')).toBeDefined(); - setBreakpoint(component, 's'); - expect(component.find(Chart).prop('onResetChartHeight')).toBeUndefined(); - }); }); describe('topPanelHeight', () => { @@ -167,39 +159,5 @@ describe('Layout', () => { const component = await mountComponent({ topPanelHeight: undefined }); expect(component.find(ResizableLayout).prop('fixedPanelSize')).toBeGreaterThan(0); }); - - it('should reset the fixedPanelSize to the default when onResetChartHeight is called on Chart', async () => { - const component: ReactWrapper = await mountComponent({ - onTopPanelHeightChange: jest.fn((topPanelHeight) => { - component.setProps({ topPanelHeight }); - }), - }); - const defaultTopPanelHeight = component.find(ResizableLayout).prop('fixedPanelSize'); - const newTopPanelHeight = 123; - expect(component.find(ResizableLayout).prop('fixedPanelSize')).not.toBe(newTopPanelHeight); - act(() => { - component.find(ResizableLayout).prop('onFixedPanelSizeChange')!(newTopPanelHeight); - }); - expect(component.find(ResizableLayout).prop('fixedPanelSize')).toBe(newTopPanelHeight); - act(() => { - component.find(Chart).prop('onResetChartHeight')!(); - }); - expect(component.find(ResizableLayout).prop('fixedPanelSize')).toBe(defaultTopPanelHeight); - }); - - it('should pass undefined for onResetChartHeight to Chart when the chart is the default height', async () => { - const component = await mountComponent({ - topPanelHeight: 123, - onTopPanelHeightChange: jest.fn((topPanelHeight) => { - component.setProps({ topPanelHeight }); - }), - }); - expect(component.find(Chart).prop('onResetChartHeight')).toBeDefined(); - act(() => { - component.find(Chart).prop('onResetChartHeight')!(); - }); - component.update(); - expect(component.find(Chart).prop('onResetChartHeight')).toBeUndefined(); - }); }); }); diff --git a/src/plugins/unified_histogram/public/layout/layout.tsx b/src/plugins/unified_histogram/public/layout/layout.tsx index 17eaf65fcde5f..1a175690eb447 100644 --- a/src/plugins/unified_histogram/public/layout/layout.tsx +++ b/src/plugins/unified_histogram/public/layout/layout.tsx @@ -7,7 +7,7 @@ */ import { EuiSpacer, useEuiTheme, useIsWithinBreakpoints } from '@elastic/eui'; -import React, { PropsWithChildren, ReactElement, useMemo, useState } from 'react'; +import React, { PropsWithChildren, ReactElement, useState } from 'react'; import { Observable } from 'rxjs'; import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; import { css } from '@emotion/css'; @@ -26,7 +26,7 @@ import { ResizableLayoutMode, ResizableLayoutDirection, } from '@kbn/resizable-layout'; -import { Chart } from '../chart'; +import { Chart, checkChartAvailability } from '../chart'; import type { UnifiedHistogramChartContext, UnifiedHistogramServices, @@ -39,6 +39,10 @@ import type { } from '../types'; import { useLensSuggestions } from './hooks/use_lens_suggestions'; +const ChartMemoized = React.memo(Chart); + +const chartSpacer = ; + export interface UnifiedHistogramLayoutProps extends PropsWithChildren { /** * Optional class name to add to the layout container @@ -107,9 +111,9 @@ export interface UnifiedHistogramLayoutProps extends PropsWithChildren */ topPanelHeight?: number; /** - * Append a custom element to the right of the hits count + * This element would replace the default chart toggle buttons */ - appendHitsCounter?: ReactElement; + renderCustomChartToggleActions?: () => ReactElement | undefined; /** * Disable automatic refetching based on props changes, and instead wait for a `refetch` message */ @@ -197,7 +201,7 @@ export const UnifiedHistogramLayout = ({ breakdown, container, topPanelHeight, - appendHitsCounter, + renderCustomChartToggleActions, disableAutoFetching, disableTriggers, disabledActions, @@ -234,6 +238,8 @@ export const UnifiedHistogramLayout = ({ }); const chart = suggestionUnsupported ? undefined : originalChart; + const isChartAvailable = checkChartAvailability({ chart, dataView, isPlainRecord }); + const [topPanelNode] = useState(() => createHtmlPortalNode({ attributes: { class: 'eui-fullHeight' } }) ); @@ -263,17 +269,11 @@ export const UnifiedHistogramLayout = ({ const currentTopPanelHeight = topPanelHeight ?? defaultTopPanelHeight; - const onResetChartHeight = useMemo(() => { - return currentTopPanelHeight !== defaultTopPanelHeight && - panelsMode === ResizableLayoutMode.Resizable - ? () => onTopPanelHeightChange?.(undefined) - : undefined; - }, [currentTopPanelHeight, defaultTopPanelHeight, onTopPanelHeightChange, panelsMode]); - return ( <> - } + renderCustomChartToggleActions={renderCustomChartToggleActions} + appendHistogram={chartSpacer} disableAutoFetching={disableAutoFetching} disableTriggers={disableTriggers} disabledActions={disabledActions} input$={input$} - onResetChartHeight={onResetChartHeight} onChartHiddenChange={onChartHiddenChange} onTimeIntervalChange={onTimeIntervalChange} onBreakdownFieldChange={onBreakdownFieldChange} @@ -311,7 +310,11 @@ export const UnifiedHistogramLayout = ({ withDefaultActions={withDefaultActions} /> - {children} + + {React.isValidElement(children) + ? React.cloneElement(children, { isChartAvailable }) + : children} + field.name === filter.meta.key); } +function getRangeOperatorFromFilter({ + meta: { params: { gte, gt, lte, lt } = {}, negate }, +}: RangeFilter | ScriptedRangeFilter) { + if (negate) { + // if filter is negated, always use 'is not between' operator + return OPERATORS.NOT_BETWEEN; + } + const left = gte ?? gt; + const right = lte ?? lt; + + if (left !== undefined && right === undefined) { + return OPERATORS.GREATER_OR_EQUAL; + } + + if (left === undefined && right !== undefined) { + return OPERATORS.LESS; + } + return OPERATORS.BETWEEN; +} + export function getOperatorFromFilter(filter: Filter) { return FILTER_OPERATORS.find((operator) => { + if (isRangeFilter(filter)) { + return getRangeOperatorFromFilter(filter) === operator.id; + } return filter.meta.type === operator.type && filter.meta.negate === operator.negate; }); } diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_operators.ts b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_operators.ts index 5bfc6540d37d9..1b54defae5b10 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_operators.ts +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_operators.ts @@ -32,6 +32,14 @@ export const strings = { i18n.translate('unifiedSearch.filter.filterEditor.isBetweenOperatorOptionLabel', { defaultMessage: 'is between', }), + getIsGreaterOrEqualOperatorOptionLabel: () => + i18n.translate('unifiedSearch.filter.filterEditor.greaterThanOrEqualOptionLabel', { + defaultMessage: 'greater or equal', + }), + getLessThanOperatorOptionLabel: () => + i18n.translate('unifiedSearch.filter.filterEditor.lessThanOrEqualOptionLabel', { + defaultMessage: 'less than', + }), getIsNotBetweenOperatorOptionLabel: () => i18n.translate('unifiedSearch.filter.filterEditor.isNotBetweenOperatorOptionLabel', { defaultMessage: 'is not between', @@ -46,10 +54,24 @@ export const strings = { }), }; +export enum OPERATORS { + LESS = 'less', + GREATER_OR_EQUAL = 'greater_or_equal', + BETWEEN = 'between', + IS = 'is', + NOT_BETWEEN = 'not_between', + IS_NOT = 'is_not', + IS_ONE_OF = 'is_one_of', + IS_NOT_ONE_OF = 'is_not_one_of', + EXISTS = 'exists', + DOES_NOT_EXIST = 'does_not_exist', +} + export interface Operator { message: string; type: FILTERS; negate: boolean; + id: OPERATORS; /** * KbnFieldTypes applicable for operator @@ -67,12 +89,14 @@ export const isOperator = { message: strings.getIsOperatorOptionLabel(), type: FILTERS.PHRASE, negate: false, + id: OPERATORS.IS, }; export const isNotOperator = { message: strings.getIsNotOperatorOptionLabel(), type: FILTERS.PHRASE, negate: true, + id: OPERATORS.IS_NOT, }; export const isOneOfOperator = { @@ -80,6 +104,7 @@ export const isOneOfOperator = { type: FILTERS.PHRASES, negate: false, fieldTypes: ['string', 'number', 'date', 'ip', 'geo_point', 'geo_shape'], + id: OPERATORS.IS_ONE_OF, }; export const isNotOneOfOperator = { @@ -87,12 +112,11 @@ export const isNotOneOfOperator = { type: FILTERS.PHRASES, negate: true, fieldTypes: ['string', 'number', 'date', 'ip', 'geo_point', 'geo_shape'], + id: OPERATORS.IS_NOT_ONE_OF, }; -export const isBetweenOperator = { - message: strings.getIsBetweenOperatorOptionLabel(), +const rangeOperatorsSharedProps = { type: FILTERS.RANGE, - negate: false, field: (field: DataViewField) => { if (['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'].includes(field.type)) return true; @@ -103,30 +127,46 @@ export const isBetweenOperator = { }, }; +export const isBetweenOperator = { + ...rangeOperatorsSharedProps, + message: strings.getIsBetweenOperatorOptionLabel(), + id: OPERATORS.BETWEEN, + negate: false, +}; + +export const isLessThanOperator = { + ...rangeOperatorsSharedProps, + message: strings.getLessThanOperatorOptionLabel(), + id: OPERATORS.LESS, + negate: false, +}; + +export const isGreaterOrEqualOperator = { + ...rangeOperatorsSharedProps, + message: strings.getIsGreaterOrEqualOperatorOptionLabel(), + id: OPERATORS.GREATER_OR_EQUAL, + negate: false, +}; + export const isNotBetweenOperator = { + ...rangeOperatorsSharedProps, message: strings.getIsNotBetweenOperatorOptionLabel(), - type: FILTERS.RANGE, negate: true, - field: (field: DataViewField) => { - if (['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'].includes(field.type)) - return true; - - if (field.type === 'string' && field.esTypes?.includes(ES_FIELD_TYPES.VERSION)) return true; - - return false; - }, + id: OPERATORS.NOT_BETWEEN, }; export const existsOperator = { message: strings.getExistsOperatorOptionLabel(), type: FILTERS.EXISTS, negate: false, + id: OPERATORS.EXISTS, }; export const doesNotExistOperator = { message: strings.getDoesNotExistOperatorOptionLabel(), type: FILTERS.EXISTS, negate: true, + id: OPERATORS.DOES_NOT_EXIST, }; export const FILTER_OPERATORS: Operator[] = [ @@ -134,6 +174,8 @@ export const FILTER_OPERATORS: Operator[] = [ isNotOperator, isOneOfOperator, isNotOneOfOperator, + isGreaterOrEqualOperator, + isLessThanOperator, isBetweenOperator, isNotBetweenOperator, existsOperator, diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/phrase_value_input.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/phrase_value_input.tsx index 9328ecfa66c50..e2e2d289d64e7 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/phrase_value_input.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/phrase_value_input.tsx @@ -19,6 +19,7 @@ import { MIDDLE_TRUNCATION_PROPS, SINGLE_SELECTION_AS_TEXT_PROPS } from './lib/h interface PhraseValueInputProps extends PhraseSuggestorProps { value?: string; onChange: (value: string | number | boolean) => void; + onBlur?: (value: string | number | boolean) => void; intl: InjectedIntl; fullWidth?: boolean; compressed?: boolean; @@ -43,6 +44,7 @@ class PhraseValueInputUI extends PhraseSuggestorUI { id: 'unifiedSearch.filter.filterEditor.valueInputPlaceholder', defaultMessage: 'Enter a value', })} + onBlur={this.props.onBlur} value={this.props.value} onChange={this.props.onChange} field={this.props.field} diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/range_value_input.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/range_value_input.tsx index 4f35d4a7f2d81..cb24ae53212ee 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/range_value_input.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/range_value_input.tsx @@ -11,8 +11,9 @@ import { EuiFormControlLayoutDelimited } from '@elastic/eui'; import { InjectedIntl, injectI18n } from '@kbn/i18n-react'; import { get } from 'lodash'; import React from 'react'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { KibanaReactContextValue, useKibana } from '@kbn/kibana-react-plugin/public'; import type { DataViewField } from '@kbn/data-views-plugin/common'; +import { CoreStart } from '@kbn/core/public'; import { ValueInputType } from './value_input_type'; interface RangeParams { @@ -36,19 +37,22 @@ export function isRangeParams(params: any): params is RangeParams { return Boolean(params && 'from' in params && 'to' in params); } -function RangeValueInputUI(props: Props) { - const kibana = useKibana(); +export const formatDateChange = ( + value: string | number | boolean, + kibana: KibanaReactContextValue> +) => { + if (typeof value !== 'string' && typeof value !== 'number') return value; - const formatDateChange = (value: string | number | boolean) => { - if (typeof value !== 'string' && typeof value !== 'number') return value; + const tzConfig = kibana.services.uiSettings!.get('dateFormat:tz'); + const tz = !tzConfig || tzConfig === 'Browser' ? moment.tz.guess() : tzConfig; + const momentParsedValue = moment(value).tz(tz); + if (momentParsedValue.isValid()) return momentParsedValue?.format('YYYY-MM-DDTHH:mm:ss.SSSZ'); - const tzConfig = kibana.services.uiSettings!.get('dateFormat:tz'); - const tz = !tzConfig || tzConfig === 'Browser' ? moment.tz.guess() : tzConfig; - const momentParsedValue = moment(value).tz(tz); - if (momentParsedValue.isValid()) return momentParsedValue?.format('YYYY-MM-DDTHH:mm:ss.SSSZ'); + return value; +}; - return value; - }; +function RangeValueInputUI(props: Props) { + const kibana = useKibana(); const onFromChange = (value: string | number | boolean) => { if (typeof value !== 'string' && typeof value !== 'number') { @@ -81,7 +85,7 @@ function RangeValueInputUI(props: Props) { value={props.value ? props.value.from : undefined} onChange={onFromChange} onBlur={(value) => { - onFromChange(formatDateChange(value)); + onFromChange(formatDateChange(value, kibana)); }} placeholder={props.intl.formatMessage({ id: 'unifiedSearch.filter.filterEditor.rangeStartInputPlaceholder', @@ -99,7 +103,7 @@ function RangeValueInputUI(props: Props) { value={props.value ? props.value.to : undefined} onChange={onToChange} onBlur={(value) => { - onToChange(formatDateChange(value)); + onToChange(formatDateChange(value, kibana)); }} placeholder={props.intl.formatMessage({ id: 'unifiedSearch.filter.filterEditor.rangeEndInputPlaceholder', diff --git a/src/plugins/unified_search/public/filters_builder/filter_item/filter_item.tsx b/src/plugins/unified_search/public/filters_builder/filter_item/filter_item.tsx index 97ea1f364b9d2..b789930dcda8d 100644 --- a/src/plugins/unified_search/public/filters_builder/filter_item/filter_item.tsx +++ b/src/plugins/unified_search/public/filters_builder/filter_item/filter_item.tsx @@ -105,22 +105,26 @@ export function FilterItem({ const conditionalOperationType = getBooleanRelationType(filter); const { euiTheme } = useEuiTheme(); let field: DataViewField | undefined; - let operator: Operator | undefined; let params: Filter['meta']['params']; const isMaxNesting = isMaxFilterNesting(path); if (!conditionalOperationType) { field = getFieldFromFilter(filter, dataView!); if (field) { - operator = getOperatorFromFilter(filter); params = getFilterParams(filter); } } + const [operator, setOperator] = useState(() => { + if (!conditionalOperationType && field) { + return getOperatorFromFilter(filter); + } + }); const [multiValueFilterParams, setMultiValueFilterParams] = useState< Array >(Array.isArray(params) ? params : []); const onHandleField = useCallback( (selectedField: DataViewField) => { + setOperator(undefined); dispatch({ type: 'updateFilter', payload: { dest: { path, index }, field: selectedField }, @@ -131,6 +135,7 @@ export function FilterItem({ const onHandleOperator = useCallback( (selectedOperator: Operator) => { + setOperator(selectedOperator); dispatch({ type: 'updateFilter', payload: { dest: { path, index }, field, operator: selectedOperator }, diff --git a/src/plugins/unified_search/public/filters_builder/filter_item/params_editor_input.tsx b/src/plugins/unified_search/public/filters_builder/filter_item/params_editor_input.tsx index 7d2cf5dc9c8d0..c3138f7a14e24 100644 --- a/src/plugins/unified_search/public/filters_builder/filter_item/params_editor_input.tsx +++ b/src/plugins/unified_search/public/filters_builder/filter_item/params_editor_input.tsx @@ -11,6 +11,7 @@ import { i18n } from '@kbn/i18n'; import { DataView, DataViewField } from '@kbn/data-views-plugin/common'; import { EuiFieldText } from '@elastic/eui'; import { Filter } from '@kbn/es-query'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; import { PhraseValueInput, PhrasesValuesInput, @@ -19,6 +20,8 @@ import { } from '../../filter_bar/filter_editor'; import type { Operator } from '../../filter_bar/filter_editor'; import { SuggestionsAbstraction } from '../../typeahead/suggestions_component'; +import { OPERATORS } from '../../filter_bar/filter_editor/lib/filter_operators'; +import { formatDateChange } from '../../filter_bar/filter_editor/range_value_input'; export const strings = { getSelectFieldPlaceholderLabel: () => @@ -70,6 +73,7 @@ export function ParamsEditorInput({ filtersForSuggestions, suggestionsAbstraction, }: ParamsEditorInputProps) { + const kibana = useKibana(); switch (operator?.type) { case 'exists': return null; @@ -106,16 +110,51 @@ export function ParamsEditorInput({ /> ); case 'range': - return ( - - ); + switch (operator.id) { + case OPERATORS.GREATER_OR_EQUAL: + return ( + { + onParamsChange({ from: formatDateChange(value, kibana) }); + }} + field={field!} + value={isRangeParams(params) && params.from ? `${params.from}` : undefined} + onChange={(value) => onParamsChange({ from: value })} + fullWidth + invalid={invalid} + disabled={disabled} + /> + ); + case OPERATORS.LESS: + return ( + { + onParamsChange({ to: formatDateChange(value, kibana) }); + }} + compressed + indexPattern={dataView} + field={field!} + value={isRangeParams(params) && params.to ? `${params.to}` : undefined} + onChange={(value) => onParamsChange({ to: value })} + fullWidth + invalid={invalid} + disabled={disabled} + /> + ); + default: + return ( + + ); + } default: const placeholderText = getPlaceholderText(Boolean(field), Boolean(operator?.type)); return ( diff --git a/test/accessibility/apps/discover.ts b/test/accessibility/apps/discover.ts index 3f9a1ef9bce0c..4deb2acb66d74 100644 --- a/test/accessibility/apps/discover.ts +++ b/test/accessibility/apps/discover.ts @@ -159,24 +159,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await a11y.testAppSnapshot(); }); - it('a11y test for chart options panel', async () => { - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await a11y.testAppSnapshot(); - }); - it('a11y test for data grid with hidden chart', async () => { - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.closeHistogramPanel(); await a11y.testAppSnapshot(); - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.openHistogramPanel(); }); it('a11y test for time interval panel', async () => { - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramTimeIntervalPanel'); + await testSubjects.click('unifiedHistogramTimeIntervalSelectorButton'); await a11y.testAppSnapshot(); - await testSubjects.click('contextMenuPanelTitleButton'); - await testSubjects.click('unifiedHistogramChartOptionsToggle'); }); it('a11y test for data grid sort panel', async () => { @@ -205,7 +196,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('a11y test for data grid with collapsed side bar', async () => { await PageObjects.discover.closeSidebar(); await a11y.testAppSnapshot(); - await PageObjects.discover.toggleSidebarCollapse(); + await PageObjects.discover.openSidebar(); }); it('a11y test for adding a field from side bar', async () => { diff --git a/test/functional/apps/discover/group1/_discover.ts b/test/functional/apps/discover/group1/_discover.ts index a041939f2b809..0210c7d8cc7f2 100644 --- a/test/functional/apps/discover/group1/_discover.ts +++ b/test/functional/apps/discover/group1/_discover.ts @@ -114,10 +114,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should show correct initial chart interval of Auto', async function () { await PageObjects.timePicker.setDefaultAbsoluteRange(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.click('unifiedHistogramQueryHits'); // to cancel out tooltips + await testSubjects.click('discoverQueryHits'); // to cancel out tooltips const actualInterval = await PageObjects.discover.getChartInterval(); - const expectedInterval = 'Auto'; + const expectedInterval = 'auto'; expect(actualInterval).to.be(expectedInterval); }); diff --git a/test/functional/apps/discover/group1/_discover_histogram.ts b/test/functional/apps/discover/group1/_discover_histogram.ts index 64e9b0e47dc90..ad5563e78f918 100644 --- a/test/functional/apps/discover/group1/_discover_histogram.ts +++ b/test/functional/apps/discover/group1/_discover_histogram.ts @@ -156,6 +156,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const chartCanvasExist = await elasticChart.canvasExists(); expect(chartCanvasExist).to.be(true); const chartIntervalIconTip = await PageObjects.discover.getChartIntervalWarningIcon(); + expect(chartIntervalIconTip).to.be(false); + }); + it('should visualize monthly data with different years scaled to seconds', async () => { + const from = 'Jan 1, 2010 @ 00:00:00.000'; + const to = 'Mar 21, 2019 @ 00:00:00.000'; + await prepareTest({ from, to }, 'Second'); + const chartCanvasExist = await elasticChart.canvasExists(); + expect(chartCanvasExist).to.be(true); + const chartIntervalIconTip = await PageObjects.discover.getChartIntervalWarningIcon(); expect(chartIntervalIconTip).to.be(true); }); it('should allow hide/show histogram, persisted in url state', async () => { @@ -164,8 +173,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await prepareTest({ from, to }); let canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(true); - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(false); @@ -174,8 +182,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await browser.refresh(); canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(false); - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); @@ -189,8 +196,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await prepareTest({ from, to }); // close chart for saved search - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); let canvasExists: boolean; await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); @@ -212,8 +218,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(canvasExists).to.be(false); // open chart for saved search - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await retry.waitFor(`Discover histogram to be displayed`, async () => { canvasExists = await elasticChart.canvasExists(); return canvasExists; @@ -235,8 +240,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should show permitted hidden histogram state when returning back to discover', async () => { // close chart - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); let canvasExists: boolean; await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); @@ -248,8 +252,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); // open chart - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(true); @@ -266,8 +269,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(canvasExists).to.be(true); // close chart - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(false); @@ -278,8 +280,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('discover'); await PageObjects.discover.waitUntilSearchingHasFinished(); // Make sure the chart is visible - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await PageObjects.discover.waitUntilSearchingHasFinished(); // type an invalid search query, hit refresh await queryBar.setQuery('this is > not valid'); diff --git a/test/functional/apps/discover/group2/_data_grid.ts b/test/functional/apps/discover/group2/_data_grid.ts index 2facbc95c93ce..cdce56db6e856 100644 --- a/test/functional/apps/discover/group2/_data_grid.ts +++ b/test/functional/apps/discover/group2/_data_grid.ts @@ -71,17 +71,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should hide elements beneath the table when in full screen mode regardless of their z-index', async () => { await retry.try(async () => { - expect(await isVisible('unifiedHistogramQueryHits')).to.be(true); + expect(await isVisible('discover-dataView-switch-link')).to.be(true); expect(await isVisible('unifiedHistogramResizableButton')).to.be(true); }); await testSubjects.click('dataGridFullScreenButton'); await retry.try(async () => { - expect(await isVisible('unifiedHistogramQueryHits')).to.be(false); + expect(await isVisible('discover-dataView-switch-link')).to.be(false); expect(await isVisible('unifiedHistogramResizableButton')).to.be(false); }); await testSubjects.click('dataGridFullScreenButton'); await retry.try(async () => { - expect(await isVisible('unifiedHistogramQueryHits')).to.be(true); + expect(await isVisible('discover-dataView-switch-link')).to.be(true); expect(await isVisible('unifiedHistogramResizableButton')).to.be(true); }); }); diff --git a/test/functional/apps/discover/group3/_panels_toggle.ts b/test/functional/apps/discover/group3/_panels_toggle.ts new file mode 100644 index 0000000000000..d471969d3528f --- /dev/null +++ b/test/functional/apps/discover/group3/_panels_toggle.ts @@ -0,0 +1,261 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const testSubjects = getService('testSubjects'); + const monacoEditor = getService('monacoEditor'); + const PageObjects = getPageObjects([ + 'settings', + 'common', + 'discover', + 'header', + 'timePicker', + 'unifiedFieldList', + ]); + const security = getService('security'); + const defaultSettings = { + defaultIndex: 'logstash-*', + hideAnnouncements: true, + }; + + describe('discover panels toggle', function () { + before(async () => { + await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover'); + }); + + after(async () => { + await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover'); + await kibanaServer.uiSettings.replace({}); + await kibanaServer.savedObjects.cleanStandardList(); + }); + + async function checkSidebarAndHistogram({ + shouldSidebarBeOpen, + shouldHistogramBeOpen, + isChartAvailable, + totalHits, + }: { + shouldSidebarBeOpen: boolean; + shouldHistogramBeOpen: boolean; + isChartAvailable: boolean; + totalHits: string; + }) { + expect(await PageObjects.discover.getHitCount()).to.be(totalHits); + + if (shouldSidebarBeOpen) { + expect(await PageObjects.discover.isSidebarPanelOpen()).to.be(true); + await testSubjects.existOrFail('unifiedFieldListSidebar__toggle-collapse'); + await testSubjects.missingOrFail('dscShowSidebarButton'); + } else { + expect(await PageObjects.discover.isSidebarPanelOpen()).to.be(false); + await testSubjects.missingOrFail('unifiedFieldListSidebar__toggle-collapse'); + await testSubjects.existOrFail('dscShowSidebarButton'); + } + + if (isChartAvailable) { + expect(await PageObjects.discover.isChartVisible()).to.be(shouldHistogramBeOpen); + if (shouldHistogramBeOpen) { + await testSubjects.existOrFail('dscPanelsToggleInHistogram'); + await testSubjects.existOrFail('dscHideHistogramButton'); + + await testSubjects.missingOrFail('dscPanelsToggleInPage'); + await testSubjects.missingOrFail('dscShowHistogramButton'); + } else { + await testSubjects.existOrFail('dscPanelsToggleInPage'); + await testSubjects.existOrFail('dscShowHistogramButton'); + + await testSubjects.missingOrFail('dscPanelsToggleInHistogram'); + await testSubjects.missingOrFail('dscHideHistogramButton'); + } + } else { + expect(await PageObjects.discover.isChartVisible()).to.be(false); + await testSubjects.missingOrFail('dscPanelsToggleInHistogram'); + await testSubjects.missingOrFail('dscHideHistogramButton'); + await testSubjects.missingOrFail('dscShowHistogramButton'); + + if (shouldSidebarBeOpen) { + await testSubjects.missingOrFail('dscPanelsToggleInPage'); + } else { + await testSubjects.existOrFail('dscPanelsToggleInPage'); + } + } + } + + function checkPanelsToggle({ + isChartAvailable, + totalHits, + }: { + isChartAvailable: boolean; + totalHits: string; + }) { + it('sidebar can be toggled', async () => { + await checkSidebarAndHistogram({ + shouldSidebarBeOpen: true, + shouldHistogramBeOpen: true, + isChartAvailable, + totalHits, + }); + + await PageObjects.discover.closeSidebar(); + + await checkSidebarAndHistogram({ + shouldSidebarBeOpen: false, + shouldHistogramBeOpen: true, + isChartAvailable, + totalHits, + }); + + await PageObjects.discover.openSidebar(); + + await checkSidebarAndHistogram({ + shouldSidebarBeOpen: true, + shouldHistogramBeOpen: true, + isChartAvailable, + totalHits, + }); + }); + + if (isChartAvailable) { + it('histogram can be toggled', async () => { + await checkSidebarAndHistogram({ + shouldSidebarBeOpen: true, + shouldHistogramBeOpen: true, + isChartAvailable, + totalHits, + }); + + await PageObjects.discover.closeHistogramPanel(); + + await checkSidebarAndHistogram({ + shouldSidebarBeOpen: true, + shouldHistogramBeOpen: false, + isChartAvailable, + totalHits, + }); + + await PageObjects.discover.openHistogramPanel(); + + await checkSidebarAndHistogram({ + shouldSidebarBeOpen: true, + shouldHistogramBeOpen: true, + isChartAvailable, + totalHits, + }); + }); + + it('sidebar and histogram can be toggled', async () => { + await checkSidebarAndHistogram({ + shouldSidebarBeOpen: true, + shouldHistogramBeOpen: true, + isChartAvailable, + totalHits, + }); + + await PageObjects.discover.closeSidebar(); + await PageObjects.discover.closeHistogramPanel(); + + await checkSidebarAndHistogram({ + shouldSidebarBeOpen: false, + shouldHistogramBeOpen: false, + isChartAvailable, + totalHits, + }); + + await PageObjects.discover.openSidebar(); + await PageObjects.discover.openHistogramPanel(); + + await checkSidebarAndHistogram({ + shouldSidebarBeOpen: true, + shouldHistogramBeOpen: true, + isChartAvailable, + totalHits, + }); + }); + } + } + + describe('time based data view', function () { + before(async function () { + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update(defaultSettings); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); + }); + + checkPanelsToggle({ isChartAvailable: true, totalHits: '14,004' }); + }); + + describe('non-time based data view', function () { + before(async function () { + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update(defaultSettings); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.createAdHocDataView('log*', false); + await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); + }); + + checkPanelsToggle({ isChartAvailable: false, totalHits: '14,004' }); + }); + + describe('text-based with histogram chart', function () { + before(async function () { + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update(defaultSettings); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.selectTextBaseLang(); + await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); + }); + + checkPanelsToggle({ isChartAvailable: true, totalHits: '10' }); + }); + + describe('text-based with aggs chart', function () { + before(async function () { + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update(defaultSettings); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.selectTextBaseLang(); + await monacoEditor.setCodeEditorValue( + 'from logstash-* | stats avg(bytes) by extension | limit 100' + ); + await testSubjects.click('querySubmitButton'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); + }); + + checkPanelsToggle({ isChartAvailable: true, totalHits: '5' }); + }); + + describe('text-based without a time field', function () { + before(async function () { + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update(defaultSettings); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.createAdHocDataView('log*', false); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.selectTextBaseLang(); + await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); + }); + + checkPanelsToggle({ isChartAvailable: false, totalHits: '10' }); + }); + }); +} diff --git a/test/functional/apps/discover/group3/_request_counts.ts b/test/functional/apps/discover/group3/_request_counts.ts index a1038b3f7e4ee..d462155a3e029 100644 --- a/test/functional/apps/discover/group3/_request_counts.ts +++ b/test/functional/apps/discover/group3/_request_counts.ts @@ -240,7 +240,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { savedSearch: 'esql test', query1: 'from logstash-* | where bytes > 1000 | stats countB = count(bytes) ', query2: 'from logstash-* | where bytes < 2000 | stats countB = count(bytes) ', - savedSearchesRequests: 4, + savedSearchesRequests: 3, setQuery: (query) => monacoEditor.setCodeEditorValue(query), }); }); diff --git a/test/functional/apps/discover/group3/_sidebar.ts b/test/functional/apps/discover/group3/_sidebar.ts index 313c350209930..cae06dd375b46 100644 --- a/test/functional/apps/discover/group3/_sidebar.ts +++ b/test/functional/apps/discover/group3/_sidebar.ts @@ -273,13 +273,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should collapse when clicked', async function () { - await PageObjects.discover.toggleSidebarCollapse(); - await testSubjects.existOrFail('discover-sidebar'); + await PageObjects.discover.closeSidebar(); + await testSubjects.existOrFail('dscShowSidebarButton'); await testSubjects.missingOrFail('fieldList'); }); it('should expand when clicked', async function () { - await PageObjects.discover.toggleSidebarCollapse(); + await PageObjects.discover.openSidebar(); await testSubjects.existOrFail('discover-sidebar'); await testSubjects.existOrFail('fieldList'); }); diff --git a/test/functional/apps/discover/group3/index.ts b/test/functional/apps/discover/group3/index.ts index 848bdc84def4d..e2e0706cd6b4a 100644 --- a/test/functional/apps/discover/group3/index.ts +++ b/test/functional/apps/discover/group3/index.ts @@ -27,5 +27,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_doc_viewer')); loadTestFile(require.resolve('./_view_mode_toggle')); loadTestFile(require.resolve('./_unsaved_changes_badge')); + loadTestFile(require.resolve('./_panels_toggle')); }); } diff --git a/test/functional/apps/discover/group4/_esql_view.ts b/test/functional/apps/discover/group4/_esql_view.ts index 2b6547152970d..fd9060f9b9ec8 100644 --- a/test/functional/apps/discover/group4/_esql_view.ts +++ b/test/functional/apps/discover/group4/_esql_view.ts @@ -52,7 +52,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await testSubjects.exists('addFilter')).to.be(true); expect(await testSubjects.exists('dscViewModeDocumentButton')).to.be(true); expect(await testSubjects.exists('unifiedHistogramChart')).to.be(true); - expect(await testSubjects.exists('unifiedHistogramQueryHits')).to.be(true); + expect(await testSubjects.exists('discoverQueryHits')).to.be(true); expect(await testSubjects.exists('discoverAlertsButton')).to.be(true); expect(await testSubjects.exists('shareTopNavButton')).to.be(true); expect(await testSubjects.exists('docTableExpandToggleColumn')).to.be(true); @@ -74,7 +74,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await testSubjects.exists('dscViewModeDocumentButton')).to.be(false); // when Lens suggests a table, we render an ESQL based histogram expect(await testSubjects.exists('unifiedHistogramChart')).to.be(true); - expect(await testSubjects.exists('unifiedHistogramQueryHits')).to.be(true); + expect(await testSubjects.exists('discoverQueryHits')).to.be(true); expect(await testSubjects.exists('discoverAlertsButton')).to.be(true); expect(await testSubjects.exists('shareTopNavButton')).to.be(true); expect(await testSubjects.exists('dataGridColumnSortingButton')).to.be(false); diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 3ec60eae8e407..658e235c77d33 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -215,12 +215,26 @@ export class DiscoverPageObject extends FtrService { ); } - public async chooseBreakdownField(field: string) { - await this.comboBox.set('unifiedHistogramBreakdownFieldSelector', field); + public async chooseBreakdownField(field: string, value?: string) { + await this.retry.try(async () => { + await this.testSubjects.click('unifiedHistogramBreakdownSelectorButton'); + await this.testSubjects.existOrFail('unifiedHistogramBreakdownSelectorSelectable'); + }); + + await ( + await this.testSubjects.find('unifiedHistogramBreakdownSelectorSelectorSearch') + ).type(field); + + const option = await this.find.byCssSelector( + `[data-test-subj="unifiedHistogramBreakdownSelectorSelectable"] .euiSelectableListItem[value="${ + value ?? field + }"]` + ); + await option.click(); } public async clearBreakdownField() { - await this.comboBox.clear('unifiedHistogramBreakdownFieldSelector'); + await this.chooseBreakdownField('No breakdown', '__EMPTY_SELECTOR_OPTION__'); } public async chooseLensChart(chart: string) { @@ -248,36 +262,52 @@ export class DiscoverPageObject extends FtrService { } public async toggleChartVisibility() { - await this.testSubjects.moveMouseTo('unifiedHistogramChartOptionsToggle'); - await this.testSubjects.click('unifiedHistogramChartOptionsToggle'); - await this.testSubjects.exists('unifiedHistogramChartToggle'); - await this.testSubjects.click('unifiedHistogramChartToggle'); + if (await this.isChartVisible()) { + await this.testSubjects.click('dscHideHistogramButton'); + } else { + await this.testSubjects.click('dscShowHistogramButton'); + } + await this.header.waitUntilLoadingHasFinished(); + } + + public async openHistogramPanel() { + await this.testSubjects.click('dscShowHistogramButton'); + await this.header.waitUntilLoadingHasFinished(); + } + + public async closeHistogramPanel() { + await this.testSubjects.click('dscHideHistogramButton'); await this.header.waitUntilLoadingHasFinished(); } public async getChartInterval() { - await this.testSubjects.click('unifiedHistogramChartOptionsToggle'); - await this.testSubjects.click('unifiedHistogramTimeIntervalPanel'); - const selectedOption = await this.find.byCssSelector(`.unifiedHistogramIntervalSelected`); - return selectedOption.getVisibleText(); + const button = await this.testSubjects.find('unifiedHistogramTimeIntervalSelectorButton'); + return await button.getAttribute('data-selected-value'); } public async getChartIntervalWarningIcon() { - await this.testSubjects.click('unifiedHistogramChartOptionsToggle'); await this.header.waitUntilLoadingHasFinished(); - return await this.find.existsByCssSelector('.euiToolTipAnchor'); + return await this.find.existsByCssSelector( + '[data-test-subj="unifiedHistogramRendered"] .euiToolTipAnchor' + ); } - public async setChartInterval(interval: string) { - await this.testSubjects.click('unifiedHistogramChartOptionsToggle'); - await this.testSubjects.click('unifiedHistogramTimeIntervalPanel'); - await this.testSubjects.click(`unifiedHistogramTimeInterval-${interval}`); + public async setChartInterval(intervalTitle: string) { + await this.retry.try(async () => { + await this.testSubjects.click('unifiedHistogramTimeIntervalSelectorButton'); + await this.testSubjects.existOrFail('unifiedHistogramTimeIntervalSelectorSelectable'); + }); + + const option = await this.find.byCssSelector( + `[data-test-subj="unifiedHistogramTimeIntervalSelectorSelectable"] .euiSelectableListItem[title="${intervalTitle}"]` + ); + await option.click(); return await this.header.waitUntilLoadingHasFinished(); } public async getHitCount() { await this.header.waitUntilLoadingHasFinished(); - return await this.testSubjects.getVisibleText('unifiedHistogramQueryHits'); + return await this.testSubjects.getVisibleText('discoverQueryHits'); } public async getHitCountInt() { @@ -398,8 +428,12 @@ export class DiscoverPageObject extends FtrService { return await Promise.all(marks.map((mark) => mark.getVisibleText())); } - public async toggleSidebarCollapse() { - return await this.testSubjects.click('unifiedFieldListSidebar__toggle'); + public async openSidebar() { + await this.testSubjects.click('dscShowSidebarButton'); + + await this.retry.waitFor('sidebar to appear', async () => { + return await this.isSidebarPanelOpen(); + }); } public async closeSidebar() { @@ -410,6 +444,13 @@ export class DiscoverPageObject extends FtrService { }); } + public async isSidebarPanelOpen() { + return ( + (await this.testSubjects.exists('fieldList')) && + (await this.testSubjects.exists('unifiedFieldListSidebar__toggle-collapse')) + ); + } + public async editField(field: string) { await this.retry.try(async () => { await this.unifiedFieldList.pressEnterFieldListItemToggle(field); diff --git a/versions.json b/versions.json index 8406528bb4428..ce91f8f76bb7e 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 f916abbad18f2..3fde509b112ad 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 0000000000000..5a471245e0449 --- /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 0000000000000..1d404309f73e3 --- /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 f17e13a33af3d..c64b02160d6e4 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 0000000000000..b41d7ac144554 --- /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 0000000000000..794b89e1775f8 --- /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 0000000000000..c9e60b806d1bf --- /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 0000000000000..5d52a2801fb9e --- /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 84a2ac40a6f24..a8dc5b1aa1db7 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 50a3211f74f3c..3f3102a4ea6bf 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/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 b2bd63f8101aa..175380cc5169a 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/apm/server/routes/services/annotations/index.test.ts b/x-pack/plugins/apm/server/routes/services/annotations/index.test.ts index 729a2c16dd65e..c33ff0881dbe6 100644 --- a/x-pack/plugins/apm/server/routes/services/annotations/index.test.ts +++ b/x-pack/plugins/apm/server/routes/services/annotations/index.test.ts @@ -16,7 +16,8 @@ import { Annotation, AnnotationType } from '../../../../common/annotations'; import { errors } from '@elastic/elasticsearch'; import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client'; -describe('getServiceAnnotations', () => { +// FLAKY: https://github.com/elastic/kibana/issues/169106 +describe.skip('getServiceAnnotations', () => { const storedAnnotations = [ { type: AnnotationType.VERSION, diff --git a/x-pack/plugins/canvas/kibana.jsonc b/x-pack/plugins/canvas/kibana.jsonc index 7e4d0fcff071d..1f6a3bf5554b4 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/markdown_editor/editable_markdown_renderer.test.tsx b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx index 636fa61b8d6f0..e4d6641d6b751 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 79636d52572ba..982484f11ed47 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/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 0000000000000..c129b00bb831d --- /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/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 0000000000000..a0c957907c0af --- /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 0c0aee860d344..5584b1eae08a6 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 9d092de673edf..7b1f10c406e15 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/server/routes/benchmark_rules/get_states/v1.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmark_rules/get_states/v1.ts index 0682c48a70b1f..4d28b995cbdaf 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( @@ -52,22 +53,6 @@ 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/elastic_assistant/common/constants.ts b/x-pack/plugins/elastic_assistant/common/constants.ts index 100aebf395287..5634b0b1881bd 100755 --- a/x-pack/plugins/elastic_assistant/common/constants.ts +++ b/x-pack/plugins/elastic_assistant/common/constants.ts @@ -17,3 +17,6 @@ export const KNOWLEDGE_BASE = `${BASE_PATH}/knowledge_base/{resource?}`; // Model Evaluation export const EVALUATE = `${BASE_PATH}/evaluate`; + +// Capabilities +export const CAPABILITIES = `${BASE_PATH}/capabilities`; diff --git a/x-pack/plugins/elastic_assistant/package.json b/x-pack/plugins/elastic_assistant/package.json index b6f19d2ec7a3c..37a0b9abb02b1 100644 --- a/x-pack/plugins/elastic_assistant/package.json +++ b/x-pack/plugins/elastic_assistant/package.json @@ -5,6 +5,9 @@ "private": true, "license": "Elastic License 2.0", "scripts": { - "evaluate-model": "node ./scripts/model_evaluator" + "evaluate-model": "node ./scripts/model_evaluator", + "openapi:generate": "node scripts/openapi/generate", + "openapi:generate:debug": "node --inspect-brk scripts/openapi/generate", + "openapi:bundle": "node scripts/openapi/bundle" } } \ No newline at end of file diff --git a/x-pack/plugins/elastic_assistant/scripts/openapi/bundle.js b/x-pack/plugins/elastic_assistant/scripts/openapi/bundle.js new file mode 100644 index 0000000000000..15d431a947582 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/scripts/openapi/bundle.js @@ -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. + */ + +require('../../../../../src/setup_node_env'); +const { bundle } = require('@kbn/openapi-bundler'); +const { resolve } = require('path'); + +const ELASTIC_ASSISTANT_ROOT = resolve(__dirname, '../..'); + +bundle({ + rootDir: ELASTIC_ASSISTANT_ROOT, + sourceGlob: './server/schemas/**/*.schema.yaml', + outputFilePath: './target/openapi/elastic_assistant.bundled.schema.yaml', +}); diff --git a/x-pack/plugins/elastic_assistant/scripts/openapi/generate.js b/x-pack/plugins/elastic_assistant/scripts/openapi/generate.js new file mode 100644 index 0000000000000..2863fb25db580 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/scripts/openapi/generate.js @@ -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. + */ + +require('../../../../../src/setup_node_env'); +const { generate } = require('@kbn/openapi-generator'); +const { resolve } = require('path'); + +const ELASTIC_ASSISTANT_ROOT = resolve(__dirname, '../..'); + +generate({ + rootDir: ELASTIC_ASSISTANT_ROOT, + sourceGlob: './server/schemas/**/*.schema.yaml', + templateName: 'zod_operation_schema', +}); diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/request.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/request.ts index deb85a88215cf..930374567533b 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/request.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/request.ts @@ -5,7 +5,11 @@ * 2.0. */ import { httpServerMock } from '@kbn/core/server/mocks'; -import { KNOWLEDGE_BASE } from '../../common/constants'; +import { CAPABILITIES, EVALUATE, KNOWLEDGE_BASE } from '../../common/constants'; +import { + PostEvaluateBodyInputs, + PostEvaluatePathQueryInputs, +} from '../schemas/evaluate/post_evaluate'; export const requestMock = { create: httpServerMock.createKibanaRequest, @@ -31,3 +35,23 @@ export const getDeleteKnowledgeBaseRequest = (resource?: string) => 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 750c13debb3fd..3273cbf50b83d 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 7ac44e1beedf1..f08e66d1b5e80 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 a3fde9d64212f..7b163138c3c2c 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 f142df46beb8b..bbc2c63381fc9 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 0000000000000..b0437bbcb7209 --- /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 0000000000000..46fc486b82a48 --- /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 0000000000000..3ae64f1d89f3b --- /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 ff3291f6b703f..aa041175b75ee 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 99d4493c16cca..a418827c4829d 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 6cc683fd4d8b8..c61887a436267 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 0000000000000..609d83fb0b931 --- /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 0000000000000..6278d83411d10 --- /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 c9c0ee1f00e51..f520bf9bf93b6 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 621995d3452be..9c9c7ea0dd2fb 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 bd7a7c0cc3203..cb425540635d9 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 c45966b9b80a2..dafb6ad6b9bb3 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 2fd22f015ad8d..dfca7893b2036 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/server/utils/get_sync_jobs_queries.ts b/x-pack/plugins/enterprise_search/server/utils/get_sync_jobs_queries.ts index 26c0b42edfde0..9dcdc5b44192c 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/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 0000000000000..376d6cbb739e6 --- /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/graph/kibana.jsonc b/x-pack/plugins/graph/kibana.jsonc index 7e6093df5813c..3c299bbeb4a2b 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/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 0fe5f148a25d0..57638b61db1a9 100644 --- a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.test.ts +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.test.ts @@ -5,6 +5,7 @@ * 2.0. */ import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { fetchFieldsFromESQL } from '@kbn/text-based-editor'; import type { LensPluginStartDependencies } from '../../../plugin'; import { createMockStartDependencies } from '../../../editor_frame_service/mocks'; import { @@ -14,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 faecb37ba7fd7..4555f3f8a576d 100644 --- a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts @@ -7,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 1aae97977d714..b17c1313a8df0 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/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 ca151b9ad21a6..a3867515358dd 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,14 +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 { addColumnsToCache } from './fieldlist_cache'; +import { createIndexPatternServiceMock } from '../../../mocks/data_views_service_mock'; +import { createMockFramePublicAPI } from '../../../mocks'; +import { DataViewsState } from '../../../state_management'; +import { addColumnsToCache } from '../fieldlist_cache'; const fieldsFromQuery = [ { @@ -106,8 +106,7 @@ const initialState: TextBasedPrivateState = { first: { index: '1', columns: [], - allColumns: [], - query: { esql: 'SELECT * FROM foo' }, + query: { esql: 'FROM foo' }, }, }, indexPatternRefs: [ @@ -117,7 +116,7 @@ const initialState: TextBasedPrivateState = { ], }; -addColumnsToCache('SELECT * FROM my-fake-index-pattern', fieldsFromQuery); +addColumnsToCache({ esql: 'FROM my-fake-index-pattern' }, fieldsFromQuery); function getFrameAPIMock({ indexPatterns, @@ -191,7 +190,7 @@ describe('TextBased Query Languages Data Panel', () => { fromDate: 'now-7d', toDate: 'now', }, - query: { esql: '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 89% 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 cca962c1884b2..bda5df0e34882 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/datapanel.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/components/datapanel.tsx @@ -12,7 +12,7 @@ import { isEqual } from 'lodash'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; -import { isOfAggregateQueryType, getAggregateQueryMode } from '@kbn/es-query'; +import { isOfAggregateQueryType } from '@kbn/es-query'; import { DatatableColumn, ExpressionsStart } from '@kbn/expressions-plugin/public'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { @@ -24,11 +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 { getColumnsFromCache } from './fieldlist_cache'; +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; @@ -74,8 +74,7 @@ export function TextBasedDataPanel({ } fetchData(); }, [data, dataViews, expressions, prevQuery, query, setState, state, frame.dataViews]); - const language = isOfAggregateQueryType(query) ? getAggregateQueryMode(query) : null; - const fieldList = language ? getColumnsFromCache(query[language]) : []; + 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 0000000000000..8971696fc9028 --- /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 0000000000000..f6062068cee77 --- /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 3561697900bb4..3dd28dba55762 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 9b368050b0567..b7f90853b6610 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,18 +13,20 @@ 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', + }, }, }, }, @@ -42,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 = { @@ -82,7 +96,6 @@ describe('Text-based: getDropProps', () => { layers: { first: { columns: [column1, column2, column3], - allColumns: [...fieldListNonNumericOnly, column1, column2, column3], }, }, }, 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 78e1c98f3a301..bbbf7869849c4 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 c657290b00759..08424acc6b7ad 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,10 +152,9 @@ 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: [], }, }, 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 bbcca24f3a943..025a51ff14c51 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,13 +81,6 @@ describe('onDrop', () => { layers: { first: expect.objectContaining({ columns: expectedColumns, - allColumns: [ - ...fieldList, - column1, - column2, - column3, - { ...column1, columnId: 'newId' }, - ], }), }, }) @@ -120,7 +125,6 @@ describe('onDrop', () => { layers: { first: expect.objectContaining({ columns: expectedColumns, - allColumns: [...fieldList, ...expectedColumns], }), }, }) @@ -148,7 +152,6 @@ describe('onDrop', () => { layers: { first: expect.objectContaining({ columns: expectedColumns, - allColumns: [...fieldList, ...expectedColumns], }), }, }) @@ -165,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 22c3d5b5f4436..5dc90cd4b4a21 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 index eaf2c201835ab..693a949ef8cd7 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/fieldlist_cache.ts +++ b/x-pack/plugins/lens/public/datasources/text_based/fieldlist_cache.ts @@ -4,16 +4,33 @@ * 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(); -export const addColumnsToCache = (query: string, list: DatatableColumn[]) => { - const trimmedQuery = query.replaceAll('\n', '').trim(); +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: string) => { - const trimmedQuery = query.replaceAll('\n', '').trim(); - return cachedColumns.get(trimmedQuery); +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 2228492dc2b88..6d28b85b7becf 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, }, }, }; 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 032c99a6b5780..22dac66439450 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,15 +105,6 @@ describe('Textbased Data Source', () => { }, }, ], - allColumns: [ - { - columnId: 'col1', - fieldName: 'Test 1', - meta: { - type: 'number', - }, - }, - ], index: 'foo', query: { esql: 'FROM foo' }, }, @@ -208,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', }, @@ -253,15 +234,6 @@ describe('Textbased Data Source', () => { newLayer: { index: 'foo', query: { esql: 'FROM foo' }, - allColumns: [ - { - columnId: 'col1', - fieldName: 'Test 1', - meta: { - type: 'number', - }, - }, - ], columns: [], }, }, @@ -278,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', }, @@ -327,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', }, @@ -375,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', @@ -401,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', @@ -437,7 +367,7 @@ describe('Textbased Data Source', () => { ], index: '1', query: { - sql: 'SELECT * FROM "foo"', + esql: 'FROM "foo"', }, }, }, @@ -552,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', @@ -623,58 +535,6 @@ describe('Textbased Data Source', () => { }); }); - describe('#suggestsLimitedColumns', () => { - it('should return true if query returns big number of columns', () => { - const state = { - totalFields: 5, - layers: { - a: { - query: { esql: 'from foo' }, - index: 'foo', - allColumns: [ - { - 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', - }, - }, - ], - }, - }, - } as unknown as TextBasedPrivateState; - expect(TextBasedDatasource?.suggestsLimitedColumns?.(state)).toBeTruthy(); - }); - }); - describe('#getUserMessages', () => { it('should use the results of getUserMessages directly when single layer', () => { const state = { @@ -696,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', @@ -779,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', }, @@ -826,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', }, @@ -884,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', }, 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 d9be929567bae..0e78704ae95f2 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,13 +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 } from './fieldlist_cache'; +import { + getColumnsFromCache, + addColumnsToCache, + retrieveLayerColumnsFromCache, +} from './fieldlist_cache'; function getLayerReferenceName(layerId: string) { return `textBasedLanguages-datasource-layer-${layerId}`; @@ -79,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, @@ -90,7 +92,7 @@ export function getTextBasedDatasource({ columns: layer.columns?.map((f) => { const inMetricDimension = canColumnBeUsedBeInMetricDimension( - layer.allColumns, + allColumns, f?.meta?.type ); return { @@ -142,8 +144,7 @@ export function getTextBasedDatasource({ }; }); - const language = getAggregateQueryMode(context.query); - addColumnsToCache(context.query[language], textBasedQueryColumns); + addColumnsToCache(context.query, textBasedQueryColumns); const index = context.dataViewSpec.id ?? context.dataViewSpec.title; const query = context.query; @@ -167,7 +168,6 @@ export function getTextBasedDatasource({ index, query, columns: newColumns.slice(0, MAX_NUM_OF_COLUMNS) ?? [], - allColumns: newColumns ?? [], timeField: context.dataViewSpec.timeFieldName, }, }, @@ -278,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 || @@ -287,7 +286,7 @@ export function getTextBasedDatasource({ ...state, layers: { ...state.layers, - [newLayerId]: blankLayer(index, query, columns), + [newLayerId]: blankLayer(index, query), }, }; }, @@ -338,14 +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 layers = Object.values(state.layers); - const allColumns = layers[0].allColumns; - return allColumns.length >= MAX_NUM_OF_COLUMNS; - }, isTimeBased: (state, indexPatterns) => { if (!state) return false; const { layers } = state; @@ -390,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 ( - ); }, @@ -423,101 +401,7 @@ export function getTextBasedDatasource({ }, DimensionEditorComponent: (props: DatasourceDimensionEditorProps) => { - const allColumns = props.state.layers[props.layerId]?.allColumns; - const fields = allColumns.map((col) => { - return { - id: col.columnId, - name: col.fieldName, - meta: col?.meta ?? { type: 'number' }, - }; - }); - 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) => { @@ -556,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) { @@ -598,7 +482,7 @@ export function getTextBasedDatasource({ getDatasourceSuggestionsForField(state, draggedField) { const layers = Object.values(state.layers); const query = layers?.[0]?.query; - const fieldList = query ? getColumnsFromCache(query[getAggregateQueryMode(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]) => { @@ -616,7 +500,6 @@ export function getTextBasedDatasource({ [id]: { ...state.layers[id], columns: [...layer.columns, newColumn], - allColumns: [...layer.allColumns, newColumn], }, }, }, @@ -699,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 8cbf2cdf0d1a1..452f9a8cc59da 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[]; } 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 2c82eb9450f7d..2c8adac00b808 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,7 +181,6 @@ describe('Text based languages utils', () => { const state = { layers: { first: { - allColumns: [], columns: [], query: undefined, index: '', @@ -190,7 +189,7 @@ describe('Text based languages utils', () => { indexPatternRefs: [], initialContext: { textBasedColumns: textBasedQueryColumns, - query: { sql: 'SELECT * FROM "foo"' }, + query: { esql: 'from foo' }, fieldName: '', dataViewSpec: { title: 'foo', @@ -204,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( @@ -238,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', @@ -270,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', }, @@ -309,7 +285,6 @@ describe('Text based languages utils', () => { const state = { layers: { first: { - allColumns: [], columns: [], query: undefined, index: '', @@ -318,7 +293,7 @@ describe('Text based languages utils', () => { indexPatternRefs: [], initialContext: { textBasedColumns: textBasedQueryColumns, - query: { sql: 'SELECT * FROM "foo"' }, + query: { esql: 'from foo' }, fieldName: '', dataViewSpec: { title: 'foo', @@ -332,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( @@ -371,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', @@ -403,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 5486f210bf981..856e608d347e1 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/utils.ts +++ b/x-pack/plugins/lens/public/datasources/text_based/utils.ts @@ -12,7 +12,6 @@ import { type AggregateQuery, getIndexPatternFromSQLQuery, getIndexPatternFromESQLQuery, - getAggregateQueryMode, } from '@kbn/es-query'; import type { DatatableColumn } from '@kbn/expressions-plugin/public'; import { generateId } from '../../id_generator'; @@ -88,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({ @@ -111,9 +109,7 @@ export async function getStateFromAggregateQuery( timeFieldName = dataView.timeFieldName; const table = await fetchDataFromAggregateQuery(query, dataView, data, expressions); columnsFromQuery = table?.columns ?? []; - const language = getAggregateQueryMode(query); - addColumnsToCache(query[language], columnsFromQuery); - allColumns = getAllColumns(state.layers[newLayerId].allColumns, columnsFromQuery); + addColumnsToCache(query, columnsFromQuery); } catch (e) { errors.push(e); } @@ -124,7 +120,6 @@ export async function getStateFromAggregateQuery( index: dataViewId, query, columns: state.layers[newLayerId].columns ?? [], - allColumns, timeField: timeFieldName, errors, }, diff --git a/x-pack/plugins/lens/public/mocks/suggestions_mock.ts b/x-pack/plugins/lens/public/mocks/suggestions_mock.ts index 0ed32fbfd84da..c371f283ad964 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 e5c9fad96d6ca..53bb59c0a5459 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 eec86da39d606..0e66291c068da 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 350cd1ad19a9e..ddffc5c8114c8 100644 --- a/x-pack/plugins/lens/tsconfig.json +++ b/x-pack/plugins/lens/tsconfig.json @@ -103,7 +103,8 @@ "@kbn/lens-formula-docs", "@kbn/visualization-utils", "@kbn/test-eui-helpers", - "@kbn/shared-ux-utility" + "@kbn/shared-ux-utility", + "@kbn/text-based-editor" ], "exclude": [ "target/**/*" diff --git a/x-pack/plugins/log_explorer/common/constants.ts b/x-pack/plugins/log_explorer/common/constants.ts index a73f304a76a5f..b9e85258b9d9c 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 0000000000000..fe02a7a872720 --- /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 0000000000000..e2f43d1b0c5fc --- /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 0000000000000..9291e17cc44fd --- /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 0000000000000..3f2b2ed1a71a4 --- /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 0000000000000..e56ca010b6a6b --- /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 e7a5f154a7bef..e0f92a9a14b82 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 07e6b3cc6629a..a16a9b638c1ad 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 f49e3d9003949..3d099452b2c94 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 43b690807e3aa..2841109bb8ea9 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 7e039497a9bd2..21e595d706c9e 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 88bc8bfe3aff6..0000000000000 --- 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 0000000000000..3f356f44b3a02 --- /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 4f10b2e39be44..5430d0aebdd07 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 0000000000000..8c33a55221d81 --- /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 a1461f4de5fca..73402f4aba1af 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 60f21be11f948..113f470a988dc 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 2a6baca186ae3..64bb8ffd1f8dd 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 71fd5103242c9..d8459215dc366 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 0000000000000..8071b08d80a15 --- /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/observability_ai_assistant/server/functions/esql/esql_docs/esql-operators.txt b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-operators.txt index 29204aad6a3f6..dc1eca3a0fc5c 100644 --- a/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-operators.txt +++ b/x-pack/plugins/observability_ai_assistant/server/functions/esql/esql_docs/esql-operators.txt @@ -124,23 +124,6 @@ an element in a list of literals, fields or expressions: ROW a = 1, b = 4, c = 3 | WHERE c-a IN (3, b / 2, a) -IS_FINITE -IS_FINITE - -Returns a boolean that indicates whether its input is a finite number. -ROW d = 1.0 -| EVAL s = IS_FINITE(d/0) - -IS_INFINITE -IS_INFINITE - -Returns a boolean that indicates whether its input is infinite. -ROW d = 1.0 -| EVAL s = IS_INFINITE(d/0) - -IS_NAN -IS_NAN - Returns a boolean that indicates whether its input is not a number. ROW d = 1.0 | EVAL s = IS_NAN(d) diff --git a/x-pack/plugins/observability_onboarding/e2e/cypress/e2e/logs/custom_logs/install_elastic_agent.cy.ts b/x-pack/plugins/observability_onboarding/e2e/cypress/e2e/logs/custom_logs/install_elastic_agent.cy.ts index 19e84284b671b..c4d5edee7d3af 100644 --- a/x-pack/plugins/observability_onboarding/e2e/cypress/e2e/logs/custom_logs/install_elastic_agent.cy.ts +++ b/x-pack/plugins/observability_onboarding/e2e/cypress/e2e/logs/custom_logs/install_elastic_agent.cy.ts @@ -623,7 +623,9 @@ describe('[Logs onboarding] Custom logs - install elastic agent', () => { cy.getByTestSubj('obltOnboardingExploreLogs').should('exist').click(); cy.url().should('include', '/app/observability-log-explorer'); - cy.get('button').contains('[Mylogs] mylogs').should('exist'); + cy.get('[data-test-subj="datasetSelectorPopoverButton"]') + .contains('[Mylogs] mylogs', { matchCase: false }) + .should('exist'); }); }); }); diff --git a/x-pack/plugins/observability_onboarding/e2e/cypress/support/commands.ts b/x-pack/plugins/observability_onboarding/e2e/cypress/support/commands.ts index e989089f491eb..843aca4c258ea 100644 --- a/x-pack/plugins/observability_onboarding/e2e/cypress/support/commands.ts +++ b/x-pack/plugins/observability_onboarding/e2e/cypress/support/commands.ts @@ -125,30 +125,17 @@ Cypress.Commands.add('deleteIntegration', (integrationName: string) => { cy.request({ log: false, - method: 'GET', + method: 'DELETE', url: `${kibanaUrl}/api/fleet/epm/packages/${integrationName}`, + body: { + force: false, + }, headers: { 'kbn-xsrf': 'e2e_test', + 'Elastic-Api-Version': '1', }, auth: { user: 'editor', pass: 'changeme' }, failOnStatusCode: false, - }).then((response) => { - const status = response.body.item?.status; - if (status === 'installed') { - cy.request({ - log: false, - method: 'DELETE', - url: `${kibanaUrl}/api/fleet/epm/packages/${integrationName}`, - body: { - force: false, - }, - headers: { - 'kbn-xsrf': 'e2e_test', - 'Elastic-Api-Version': '1', - }, - auth: { user: 'editor', pass: 'changeme' }, - }); - } }); }); diff --git a/x-pack/plugins/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 ee2635651261c..4c7c9663b2d40 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 97dedb2ca6a2b..f1284bf8b528f 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 6c2380664ba4d..1c6ff3b2fd66c 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/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 560b36761c090..98ac71c664a98 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 783533b3e49ff..12f666c3230b8 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/public/assistant/provider.tsx b/x-pack/plugins/security_solution/public/assistant/provider.tsx index a9f9e14a8d3e0..43ae8eb696128 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/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 5998073d65cdb..a1f7b488b1d0a 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/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 96a3ccf2c4884..5190fb8955427 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 @@ -200,6 +200,7 @@ const RiskSummaryComponent = ({ { }); 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 dda667d4a2b91..9f13e04f761a7 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/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 0000000000000..686a631736550 --- /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 0000000000000..c3b086f7c4c1a --- /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 6c97e250a8e79..0000000000000 --- 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 bc1617d3b9a53..0000000000000 --- 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 18bf93d0ab6c8..0000000000000 --- 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 9a863a5fe8184..0000000000000 --- 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 90cb542dfe291..e40fd01bdd3db 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/side_panel/event_details/flyout/back_to_alert_details_link.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/back_to_alert_details_link.tsx index 2ce97cfbce32e..6ae4207a46f78 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/back_to_alert_details_link.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/back_to_alert_details_link.tsx @@ -5,34 +5,58 @@ * 2.0. */ -import { EuiButtonEmpty, EuiText, EuiTitle } from '@elastic/eui'; +import { + EuiBetaBadge, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { css } from '@emotion/react'; import React from 'react'; import { ISOLATE_HOST, UNISOLATE_HOST, } from '../../../../../detections/components/host_isolation/translations'; -import { ALERT_DETAILS } from '../translations'; +import { ALERT_DETAILS, TECHNICAL_PREVIEW, TECHNICAL_PREVIEW_DESCRIPTION } from '../translations'; const BackToAlertDetailsLinkComponent = ({ showAlertDetails, + showExperimentalBadge, isolateAction, }: { showAlertDetails: () => void; + showExperimentalBadge?: boolean; isolateAction: 'isolateHost' | 'unisolateHost'; -}) => { - return ( - <> - - -

{ALERT_DETAILS}

-
-
- -

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

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

{ALERT_DETAILS}

+
+
+ + + +

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

+
+
+ + {showExperimentalBadge && ( + + )} + +
+ +); const BackToAlertDetailsLink = React.memo(BackToAlertDetailsLinkComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx index d5df4304a0894..0d139449b1ca2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx @@ -6,8 +6,9 @@ */ import { EuiFlyoutHeader } from '@elastic/eui'; -import React from 'react'; +import React, { useMemo } from 'react'; +import { SENTINEL_ONE_AGENT_ID_FIELD } from '../../../../../common/utils/sentinelone_alert_check'; import type { GetFieldsData } from '../../../../../common/hooks/use_get_fields_data'; import { ExpandableEventTitle } from '../expandable_event'; import { BackToAlertDetailsLink } from './back_to_alert_details_link'; @@ -43,10 +44,19 @@ const FlyoutHeaderContentComponent = ({ refetchFlyoutData, getFieldsData, }: FlyoutHeaderComponentProps) => { + const isSentinelOneAlert = useMemo( + () => !!(isAlert && getFieldsData(SENTINEL_ONE_AGENT_ID_FIELD)?.length), + [getFieldsData, isAlert] + ); + return ( <> {isHostIsolationPanelOpen ? ( - + ) : ( { - 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/store/selectors.ts b/x-pack/plugins/security_solution/public/timelines/store/selectors.ts index 9a2a732679fae..496f867b1247f 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 a88393fdea99f..7eac49f7e066d 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 7146a50be9049..7b1c0743dbce6 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/server/lib/telemetry/__mocks__/fleet_agent_response.ts b/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/fleet_agent_response.ts new file mode 100644 index 0000000000000..2c9c54f696c4f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/fleet_agent_response.ts @@ -0,0 +1,482 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Agent } from '@kbn/fleet-plugin/common'; + +export const stubFleetAgentResponse: { + agents: Agent[]; + total: number; + page: number; + perPage: number; +} = { + agents: [ + { + id: '45112616-62e0-42c5-a8f9-2f8a71a92040', + type: 'PERMANENT', + active: true, + enrolled_at: '2024-01-11T03:39:21.515Z', + upgraded_at: null, + upgrade_started_at: null, + access_api_key_id: 'jY3dWnkBj1tiuAw9pAmq', + default_api_key_id: 'so3dWnkBj1tiuAw9yAm3', + policy_id: '147b2096-bd12-4b7e-a100-061dc11ba799', + last_checkin: '2024-01-11T04:00:35.217Z', + last_checkin_status: 'online', + policy_revision: 2, + packages: [], + sort: [1704944361515, 'Host-roan3tb8c3'], + components: [ + { + id: 'endpoint-0', + type: 'endpoint', + status: 'STARTING', + message: 'Running as external service', + units: [ + { + id: 'endpoint-1', + type: 'input', + status: 'STARTING', + message: 'Protecting machine', + payload: { + extra: 'payload', + }, + }, + { + id: 'shipper', + type: 'output', + status: 'STARTING', + message: 'Connected over GRPC', + payload: { + extra: 'payload', + }, + }, + ], + }, + ], + agent: { + id: '45112616-62e0-42c5-a8f9-2f8a71a92040', + version: '8.13.0', + }, + user_provided_metadata: {}, + local_metadata: { + elastic: { + agent: { + 'build.original': '8.0.0-SNAPSHOT (build: j74oz at 2021-05-07 18:42:49 +0000 UTC)', + id: '45112616-62e0-42c5-a8f9-2f8a71a92040', + log_level: 'info', + snapshot: true, + upgradeable: true, + version: '8.13.0', + }, + }, + host: { + architecture: '4nil08yn9j', + hostname: 'Host-roan3tb8c3', + id: '866c98c0-c323-4f6b-9e4c-8cc4694e4ba7', + ip: ['00.00.000.00'], + mac: ['00-00-00-00-00-00', '00-00-00-00-00-00', '0-00-00-00-00-00'], + name: 'Host-roan3tb8c3', + os: { + name: 'Windows', + full: 'Windows 10', + version: '10.0', + platform: 'Windows', + family: 'windows', + Ext: { + variant: 'Windows Pro', + }, + }, + }, + os: { + family: 'windows', + full: 'Windows 10', + kernel: '10.0.17763.1879 (Build.160101.0800)', + name: 'Windows', + platform: 'Windows', + version: '10.0', + Ext: { + variant: 'Windows Pro', + }, + }, + }, + status: 'online', + }, + { + id: '74550426-040d-4216-a227-599fd3efa91c', + type: 'PERMANENT', + active: true, + enrolled_at: '2024-01-11T03:39:21.512Z', + upgraded_at: null, + upgrade_started_at: null, + access_api_key_id: 'jY3dWnkBj1tiuAw9pAmq', + default_api_key_id: 'so3dWnkBj1tiuAw9yAm3', + policy_id: '16608650-4839-4053-a0eb-6ee9d11ac84d', + last_checkin: '2024-01-11T04:00:35.302Z', + last_checkin_status: 'online', + policy_revision: 2, + packages: [], + sort: [1704944361512, 'Host-vso4lwuc51'], + components: [ + { + id: 'endpoint-0', + type: 'endpoint', + status: 'FAILED', + message: 'Running as external service', + units: [ + { + id: 'endpoint-1', + type: 'input', + status: 'FAILED', + message: 'Protecting machine', + payload: { + error: { + code: -272, + message: 'Unable to connect to Elasticsearch', + }, + }, + }, + { + id: 'shipper', + type: 'output', + status: 'FAILED', + message: 'Connected over GRPC', + payload: { + extra: 'payload', + }, + }, + ], + }, + ], + agent: { + id: '74550426-040d-4216-a227-599fd3efa91c', + version: '8.13.0', + }, + user_provided_metadata: {}, + local_metadata: { + elastic: { + agent: { + 'build.original': '8.0.0-SNAPSHOT (build: 315fp at 2021-05-07 18:42:49 +0000 UTC)', + id: '74550426-040d-4216-a227-599fd3efa91c', + log_level: 'info', + snapshot: true, + upgradeable: true, + version: '8.13.0', + }, + }, + host: { + architecture: '3oem2enr1y', + hostname: 'Host-vso4lwuc51', + id: '3cdfece3-8b4e-4006-a19e-7ab7e953bb38', + ip: ['00.00.000.00'], + mac: ['00-00-00-00-00-00', '00-00-00-00-00-00', '0-00-00-00-00-00'], + name: 'Host-vso4lwuc51', + os: { + name: 'Windows', + full: 'Windows Server 2012', + version: '6.2', + platform: 'Windows', + family: 'windows', + Ext: { + variant: 'Windows Server', + }, + }, + }, + os: { + family: 'windows', + full: 'Windows Server 2012', + kernel: '10.0.17763.1879 (Build.160101.0800)', + name: 'Windows', + platform: 'Windows', + version: '6.2', + Ext: { + variant: 'Windows Server', + }, + }, + }, + status: 'online', + }, + { + id: 'b80bc33e-1c65-41b3-80d6-8f9757552ab1', + type: 'PERMANENT', + active: true, + enrolled_at: '2024-01-11T03:31:22.832Z', + upgraded_at: null, + upgrade_started_at: null, + access_api_key_id: 'jY3dWnkBj1tiuAw9pAmq', + default_api_key_id: 'so3dWnkBj1tiuAw9yAm3', + policy_id: '125f0769-20b4-4604-81ce-f0db812d510b', + last_checkin: '2024-01-11T04:00:36.305Z', + last_checkin_status: 'online', + policy_revision: 2, + packages: [], + sort: [1704943882832, 'Host-y0zwnrnucm'], + components: [ + { + id: 'endpoint-0', + type: 'endpoint', + status: 'STOPPING', + message: 'Running as external service', + units: [ + { + id: 'endpoint-1', + type: 'input', + status: 'STOPPING', + message: 'Protecting machine', + payload: { + extra: 'payload', + }, + }, + { + id: 'shipper', + type: 'output', + status: 'STOPPING', + message: 'Connected over GRPC', + payload: { + extra: 'payload', + }, + }, + ], + }, + ], + agent: { + id: 'b80bc33e-1c65-41b3-80d6-8f9757552ab1', + version: '8.13.0', + }, + user_provided_metadata: {}, + local_metadata: { + elastic: { + agent: { + 'build.original': '8.0.0-SNAPSHOT (build: klkq1 at 2021-05-07 18:42:49 +0000 UTC)', + id: 'b80bc33e-1c65-41b3-80d6-8f9757552ab1', + log_level: 'info', + snapshot: true, + upgradeable: true, + version: '8.13.0', + }, + }, + host: { + architecture: 'ogtqmitmts', + hostname: 'Host-y0zwnrnucm', + id: 'aca58288-ac9b-4ce7-9cef-67692fe10363', + ip: ['00.00.000.00'], + mac: ['00-00-00-00-00-00', '00-00-00-00-00-00', '0-00-00-00-00-00'], + name: 'Host-y0zwnrnucm', + os: { + name: 'macOS', + full: 'macOS Monterey', + version: '12.6.1', + platform: 'macOS', + family: 'Darwin', + Ext: { + variant: 'Darwin', + }, + }, + }, + os: { + family: 'Darwin', + full: 'macOS Monterey', + kernel: '10.0.17763.1879 (Build.160101.0800)', + name: 'macOS', + platform: 'macOS', + version: '12.6.1', + Ext: { + variant: 'Darwin', + }, + }, + }, + status: 'online', + }, + { + id: 'cbd4cda1-3bac-45a7-9914-812d3b9c5f44', + type: 'PERMANENT', + active: true, + enrolled_at: '2024-01-11T03:21:13.662Z', + upgraded_at: null, + upgrade_started_at: null, + access_api_key_id: 'jY3dWnkBj1tiuAw9pAmq', + default_api_key_id: 'so3dWnkBj1tiuAw9yAm3', + policy_id: '004e29f7-3b96-4ce3-8de8-c3024f56eae2', + last_checkin: '2024-01-11T04:00:37.315Z', + last_checkin_status: 'online', + policy_revision: 2, + packages: [], + sort: [1704943273662, 'Host-60j0gd14nc'], + components: [ + { + id: 'endpoint-0', + type: 'endpoint', + status: 'STOPPING', + message: 'Running as external service', + units: [ + { + id: 'endpoint-1', + type: 'input', + status: 'STOPPING', + message: 'Protecting machine', + payload: { + extra: 'payload', + }, + }, + { + id: 'shipper', + type: 'output', + status: 'STOPPING', + message: 'Connected over GRPC', + payload: { + extra: 'payload', + }, + }, + ], + }, + ], + agent: { + id: 'cbd4cda1-3bac-45a7-9914-812d3b9c5f44', + version: '8.13.0', + }, + user_provided_metadata: {}, + local_metadata: { + elastic: { + agent: { + 'build.original': '8.0.0-SNAPSHOT (build: slgsg at 2021-05-07 18:42:49 +0000 UTC)', + id: 'cbd4cda1-3bac-45a7-9914-812d3b9c5f44', + log_level: 'info', + snapshot: true, + upgradeable: true, + version: '8.13.0', + }, + }, + host: { + architecture: '3cgyqy4tx4', + hostname: 'Host-60j0gd14nc', + id: 'e76f684a-1f5c-4082-9a21-145d34c2d901', + ip: ['00.00.000.00'], + mac: ['00-00-00-00-00-00', '00-00-00-00-00-00', '0-00-00-00-00-00'], + name: 'Host-60j0gd14nc', + os: { + name: 'Windows', + full: 'Windows Server 2012', + version: '6.2', + platform: 'Windows', + family: 'windows', + Ext: { + variant: 'Windows Server', + }, + }, + }, + os: { + family: 'windows', + full: 'Windows Server 2012', + kernel: '10.0.17763.1879 (Build.160101.0800)', + name: 'Windows', + platform: 'Windows', + version: '6.2', + Ext: { + variant: 'Windows Server', + }, + }, + }, + status: 'online', + }, + { + id: '9918e050-035a-4764-bdd3-5cd536a7201c', + type: 'PERMANENT', + active: true, + enrolled_at: '2024-01-11T03:20:54.483Z', + upgraded_at: null, + upgrade_started_at: null, + access_api_key_id: 'jY3dWnkBj1tiuAw9pAmq', + default_api_key_id: 'so3dWnkBj1tiuAw9yAm3', + policy_id: '004e29f7-3b96-4ce3-8de8-c3024f56eae2', + last_checkin: '2024-01-11T04:00:38.328Z', + last_checkin_status: 'online', + policy_revision: 2, + packages: [], + sort: [1704943254483, 'Host-nh94b0esjr'], + components: [ + { + id: 'endpoint-0', + type: 'endpoint', + status: 'STARTING', + message: 'Running as external service', + units: [ + { + id: 'endpoint-1', + type: 'input', + status: 'STARTING', + message: 'Protecting machine', + payload: { + extra: 'payload', + }, + }, + { + id: 'shipper', + type: 'output', + status: 'STARTING', + message: 'Connected over GRPC', + payload: { + extra: 'payload', + }, + }, + ], + }, + ], + agent: { + id: '9918e050-035a-4764-bdd3-5cd536a7201c', + version: '8.13.0', + }, + user_provided_metadata: {}, + local_metadata: { + elastic: { + agent: { + 'build.original': '8.0.0-SNAPSHOT (build: pd6rl at 2021-05-07 18:42:49 +0000 UTC)', + id: '9918e050-035a-4764-bdd3-5cd536a7201c', + log_level: 'info', + snapshot: true, + upgradeable: true, + version: '8.13.0', + }, + }, + host: { + architecture: 'q5ni746k3b', + hostname: 'Host-nh94b0esjr', + id: 'd036aae1-8a97-4aa6-988c-2e178665272a', + ip: ['00.00.000.00'], + mac: ['00-00-00-00-00-00', '00-00-00-00-00-00', '0-00-00-00-00-00'], + name: 'Host-nh94b0esjr', + os: { + Ext: { + variant: 'Debian', + }, + kernel: '4.19.0-21-cloud-amd64 #1 SMP Debian 4.19.249-2 (2022-06-30)', + name: 'Linux', + family: 'debian', + type: 'linux', + version: '10.12', + platform: 'debian', + full: 'Debian 10.12', + }, + }, + os: { + family: 'debian', + full: 'Debian 10.12', + kernel: '4.19.0-21-cloud-amd64 #1 SMP Debian 4.19.249-2 (2022-06-30)', + name: 'Linux', + platform: 'debian', + version: '10.12', + Ext: { + variant: 'Debian', + }, + type: 'linux', + }, + }, + status: 'online', + }, + ], + total: 5, + page: 1, + perPage: 10000, +}; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/index.ts b/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/index.ts index 97a0aa16a1518..a5d38b430ec92 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/__mocks__/index.ts @@ -16,6 +16,7 @@ import { stubEndpointAlertResponse, stubProcessTree, stubFetchTimelineEvents } f import { stubEndpointMetricsResponse } from './metrics'; import { prebuiltRuleAlertsResponse } from './prebuilt_rule_alerts'; import type { ESClusterInfo, ESLicense } from '../types'; +import { stubFleetAgentResponse } from './fleet_agent_response'; export const createMockTelemetryEventsSender = ( enableTelemetry?: boolean, @@ -81,7 +82,7 @@ export const createMockTelemetryReceiver = ( fetchClusterInfo: jest.fn().mockReturnValue(stubClusterInfo), fetchLicenseInfo: jest.fn().mockReturnValue(stubLicenseInfo), copyLicenseFields: jest.fn(), - fetchFleetAgents: jest.fn(), + fetchFleetAgents: jest.fn().mockReturnValue(stubFleetAgentResponse), openPointInTime: jest.fn().mockReturnValue(Promise.resolve('test-pit-id')), getAlertsIndex: jest.fn().mockReturnValue('alerts-*'), fetchDiagnosticAlertsBatch: jest.fn().mockReturnValue(diagnosticsAlert ?? jest.fn()), diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.test.ts index f0c359b9e0b0a..4cffeccc0db3c 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.test.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.test.ts @@ -56,4 +56,29 @@ describe('endpoint telemetry task test', () => { 1 ); }); + + test('endpoint telemetry task should fetch endpoint data even if fetchPolicyConfigs throws an error', async () => { + const testTaskExecutionPeriod = { + last: new Date().toISOString(), + current: new Date().toISOString(), + }; + const mockTelemetryEventsSender = createMockTelemetryEventsSender(); + mockTelemetryEventsSender.getTelemetryUsageCluster = jest + .fn() + .mockReturnValue(telemetryUsageCounter); + const mockTelemetryReceiver = createMockTelemetryReceiver(); + mockTelemetryReceiver.fetchPolicyConfigs = jest.fn().mockRejectedValueOnce(new Error()); + const telemetryEndpointTaskConfig = createTelemetryEndpointTaskConfig(1); + + await telemetryEndpointTaskConfig.runTask( + 'test-id', + logger, + mockTelemetryReceiver, + mockTelemetryEventsSender, + testTaskExecutionPeriod + ); + + expect(mockTelemetryReceiver.fetchPolicyConfigs).toHaveBeenCalled(); + expect(mockTelemetryEventsSender.sendOnDemand).toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts index 2c5eabe612c09..f221a11204bf4 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts @@ -6,6 +6,7 @@ */ import type { Logger } from '@kbn/core/server'; +import type { AgentPolicy } from '@kbn/fleet-plugin/common'; import { FLEET_ENDPOINT_PACKAGE } from '@kbn/fleet-plugin/common'; import type { ITelemetryEventsSender } from '../sender'; import type { @@ -16,6 +17,7 @@ import type { EndpointMetadataDocument, ESClusterInfo, ESLicense, + Nullable, } from '../types'; import type { ITelemetryReceiver } from '../receiver'; import type { TaskExecutionPeriod } from '../task'; @@ -167,7 +169,12 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) { policyInfo !== undefined && !endpointPolicyCache.has(policyInfo) ) { - const agentPolicy = await receiver.fetchPolicyConfigs(policyInfo); + let agentPolicy: Nullable; + try { + agentPolicy = await receiver.fetchPolicyConfigs(policyInfo); + } catch (err) { + tlog(logger, `error fetching policy config due to ${err?.message}`); + } const packagePolicies = agentPolicy?.package_policies; if (packagePolicies !== undefined && isPackagePolicyList(packagePolicies)) { diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts index 433c1c01cf0bc..cbff7156eaf15 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts @@ -458,6 +458,8 @@ export interface ValueListResponse { indicatorMatchMetricsResponse: ValueListIndicatorMatchResponseAggregation; } +export type Nullable = T | null | undefined; + export interface ExtraInfo { clusterInfo: ESClusterInfo; licenseInfo: ESLicense | undefined; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 01154dc06f5f6..27bdcfe796e76 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 b744c1884ba49..c679f8d132ddd 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 5c7bec1a37a0a..9f2f33abf1f6e 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/threat_intelligence/README.md b/x-pack/plugins/threat_intelligence/README.md index b708a4fecaa3d..d81fb6611f155 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 67bc60a4486eb..e9e960db9858b 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/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 60a74a6d325e9..0d684afb7809c 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -2440,6 +2440,9 @@ "discover.viewAlert.searchSourceErrorTitle": "Erreur lors de la récupération de la source de recherche", "discover.viewModes.document.label": "Documents", "discover.viewModes.fieldStatistics.label": "Statistiques de champ", + "discover.hitsCounter.hitsPluralTitle": "{formattedHits} {hits, plural, one {résultat} many {résultats} other {résultats}}", + "discover.hitsCounter.partialHitsPluralTitle": "≥{formattedHits} {hits, plural, one {résultat} many {résultats} other {résultats}}", + "discover.hitsCounter.hitCountSpinnerAriaLabel": "Nombre final de résultats toujours en chargement", "domDragDrop.announce.cancelled": "Mouvement annulé. {label} revenu à sa position initiale", "domDragDrop.announce.cancelledItem": "Mouvement annulé. {label} revenu au groupe {groupLabel} à la position {position}", "domDragDrop.announce.dropped.combineCompatible": "{label} combiné dans le groupe {groupLabel} en {dropLabel} dans le groupe {dropGroupLabel} à la position {dropPosition} dans le calque {dropLayerNumber}", @@ -5642,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 ", @@ -5778,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", @@ -6186,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", @@ -6231,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}", @@ -35040,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 bc7eb85e78f6b..90240386985cf 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2454,6 +2454,9 @@ "discover.viewAlert.searchSourceErrorTitle": "検索ソースの取得エラー", "discover.viewModes.document.label": "ドキュメント", "discover.viewModes.fieldStatistics.label": "フィールド統計情報", + "discover.hitsCounter.hitsPluralTitle": "{formattedHits} {hits, plural, other {ヒット}}", + "discover.hitsCounter.partialHitsPluralTitle": "≥{formattedHits}{hits, plural, other {ヒット}}", + "discover.hitsCounter.hitCountSpinnerAriaLabel": "読み込み中の最終一致件数", "domDragDrop.announce.cancelled": "移動がキャンセルされました。{label}は初期位置に戻りました", "domDragDrop.announce.cancelledItem": "移動がキャンセルされました。{label}は位置{position}の{groupLabel}グループに戻りました", "domDragDrop.announce.dropped.combineCompatible": "レイヤー{dropLayerNumber}の位置{dropPosition}でグループ{dropGroupLabel}の{dropLabel}にグループ{groupLabel}の{label}を結合しました。", @@ -5657,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 ", @@ -5793,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", @@ -6201,7 +6198,6 @@ "unifiedFieldList.fieldListSidebar.fieldsMobileButtonLabel": "フィールド", "unifiedFieldList.fieldListSidebar.flyoutBackIcon": "戻る", "unifiedFieldList.fieldListSidebar.flyoutHeading": "フィールドリスト", - "unifiedFieldList.fieldListSidebar.indexAndFieldsSectionAriaLabel": "インデックスとフィールド", "unifiedFieldList.fieldListSidebar.toggleSidebarLegend": "サイドバーを切り替える", "unifiedFieldList.fieldNameSearch.filterByNameLabel": "検索フィールド名", "unifiedFieldList.fieldPopover.addExistsFilterLabel": "フィールド表示のフィルター", @@ -6246,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}削除", @@ -35039,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 abb16dee164fc..34f09ecbeadf1 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2454,6 +2454,9 @@ "discover.viewAlert.searchSourceErrorTitle": "提取搜索源时出错", "discover.viewModes.document.label": "文档", "discover.viewModes.fieldStatistics.label": "字段统计信息", + "discover.hitsCounter.hitsPluralTitle": "{formattedHits} {hits, plural, other {命中数}}", + "discover.hitsCounter.partialHitsPluralTitle": "≥{formattedHits} {hits, plural, other {命中数}}", + "discover.hitsCounter.hitCountSpinnerAriaLabel": "最终命中计数仍在加载", "domDragDrop.announce.cancelled": "移动已取消。{label} 已返回至其初始位置", "domDragDrop.announce.cancelledItem": "移动已取消。{label} 返回至 {groupLabel} 组中的位置 {position}", "domDragDrop.announce.dropped.combineCompatible": "已将组 {groupLabel} 中的 {label} 组合到图层 {dropLayerNumber} 的组 {dropGroupLabel} 中的位置 {dropPosition} 上的 {dropLabel}", @@ -5750,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 ", @@ -5886,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", @@ -6294,7 +6291,6 @@ "unifiedFieldList.fieldListSidebar.fieldsMobileButtonLabel": "字段", "unifiedFieldList.fieldListSidebar.flyoutBackIcon": "返回", "unifiedFieldList.fieldListSidebar.flyoutHeading": "字段列表", - "unifiedFieldList.fieldListSidebar.indexAndFieldsSectionAriaLabel": "索引和字段", "unifiedFieldList.fieldListSidebar.toggleSidebarLegend": "切换侧边栏", "unifiedFieldList.fieldNameSearch.filterByNameLabel": "搜索字段名称", "unifiedFieldList.fieldPopover.addExistsFilterLabel": "筛留存在的字段", @@ -6339,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}", @@ -35021,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 fbb5ac5a8af44..7ee23fc3ede9e 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/sections/field_browser/field_browser.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/field_browser/field_browser.test.tsx index 5c3a33d5b17c8..32a6b1b85019b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/field_browser/field_browser.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/field_browser/field_browser.test.tsx @@ -42,7 +42,12 @@ describe('FieldsBrowser', () => { result.getByTestId('show-field-browser').click(); await waitFor(() => { + // the container is rendered now expect(result.getByTestId('fields-browser-container')).toBeInTheDocument(); + // by default, no categories are selected + expect(result.getByTestId('category-badges')).toHaveTextContent(''); + // the view: all button is shown by default + result.getByText('View: all'); }); }); }); diff --git a/x-pack/test/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 67b501a784462..2507700be6fef 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_functional/pages/findings.ts b/x-pack/test/cloud_security_posture_functional/pages/findings.ts index 48bd8cede1e25..f599648d0c2de 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,7 +145,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { pageObjects.header.waitUntilLoadingHasFinished(); }); - after(async () => { + afterEach(async () => { await findings.index.remove(); }); @@ -298,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 2939f3eed9266..7f9530ba91d38 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/functional/apps/lens/group1/ad_hoc_data_view.ts b/x-pack/test/functional/apps/lens/group1/ad_hoc_data_view.ts index cfe9cc7e5f304..fc8cbd2b38d66 100644 --- a/x-pack/test/functional/apps/lens/group1/ad_hoc_data_view.ts +++ b/x-pack/test/functional/apps/lens/group1/ad_hoc_data_view.ts @@ -69,7 +69,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ).getVisibleText(); expect(actualIndexPattern).to.be('*stash*'); - const actualDiscoverQueryHits = await testSubjects.getVisibleText('unifiedHistogramQueryHits'); + const actualDiscoverQueryHits = await testSubjects.getVisibleText('discoverQueryHits'); expect(actualDiscoverQueryHits).to.be('14,005'); expect(await PageObjects.unifiedSearch.isAdHocDataView()).to.be(true); }; @@ -208,9 +208,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ).getVisibleText(); expect(actualIndexPattern).to.be('*stash*'); - const actualDiscoverQueryHits = await testSubjects.getVisibleText( - 'unifiedHistogramQueryHits' - ); + const actualDiscoverQueryHits = await testSubjects.getVisibleText('discoverQueryHits'); expect(actualDiscoverQueryHits).to.be('14,005'); const prevDataViewId = await PageObjects.discover.getCurrentDataViewId(); diff --git a/x-pack/test/functional/apps/lens/group1/smokescreen.ts b/x-pack/test/functional/apps/lens/group1/smokescreen.ts index e33e65741bf66..f482163930991 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/observability_log_explorer/columns_selection.ts b/x-pack/test/functional/apps/observability_log_explorer/columns_selection.ts index 5da73c3e5ecb5..60078440ec2a1 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 583313ec8cb9a..964ebc12b320b 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 f87edc5fc23a5..44ea416d01596 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 9c0ab19ac64c4..18b5004393027 100644 --- a/x-pack/test/functional/services/aiops/log_pattern_analysis_page.ts +++ b/x-pack/test/functional/services/aiops/log_pattern_analysis_page.ts @@ -133,13 +133,13 @@ export function LogPatternAnalysisPageProvider({ getService, getPageObject }: Ft async assertDiscoverDocCountExists() { await retry.tryForTime(30 * 1000, async () => { - await testSubjects.existOrFail('unifiedHistogramQueryHits'); + await testSubjects.existOrFail('discoverQueryHits'); }); }, async assertDiscoverDocCount(expectedDocCount: number) { await retry.tryForTime(5000, async () => { - const docCount = await testSubjects.getVisibleText('unifiedHistogramQueryHits'); + const docCount = await testSubjects.getVisibleText('discoverQueryHits'); const formattedDocCount = docCount.replaceAll(',', ''); expect(formattedDocCount).to.eql( expectedDocCount, diff --git a/x-pack/test/functional/services/transform/discover.ts b/x-pack/test/functional/services/transform/discover.ts index 07bd66cce1780..02cf7a98310eb 100644 --- a/x-pack/test/functional/services/transform/discover.ts +++ b/x-pack/test/functional/services/transform/discover.ts @@ -14,11 +14,9 @@ export function TransformDiscoverProvider({ getService }: FtrProviderContext) { return { async assertDiscoverQueryHits(expectedDiscoverQueryHits: string) { - await testSubjects.existOrFail('unifiedHistogramQueryHits'); + await testSubjects.existOrFail('discoverQueryHits'); - const actualDiscoverQueryHits = await testSubjects.getVisibleText( - 'unifiedHistogramQueryHits' - ); + const actualDiscoverQueryHits = await testSubjects.getVisibleText('discoverQueryHits'); expect(actualDiscoverQueryHits).to.eql( expectedDiscoverQueryHits, @@ -27,18 +25,16 @@ export function TransformDiscoverProvider({ getService }: FtrProviderContext) { }, async assertDiscoverQueryHitsMoreThanZero() { - await testSubjects.existOrFail('unifiedHistogramQueryHits'); + await testSubjects.existOrFail('discoverQueryHits'); - const actualDiscoverQueryHits = await testSubjects.getVisibleText( - 'unifiedHistogramQueryHits' - ); + const actualDiscoverQueryHits = await testSubjects.getVisibleText('discoverQueryHits'); const hits = parseInt(actualDiscoverQueryHits, 10); expect(hits).to.greaterThan(0, `Discover query hits should be more than 0, got ${hits}`); }, async assertNoResults(expectedDestinationIndex: string) { - await testSubjects.missingOrFail('unifiedHistogramQueryHits'); + await testSubjects.missingOrFail('discoverQueryHits'); // Discover should use the destination index pattern const actualIndexPatternSwitchLinkText = await ( diff --git a/x-pack/test/security_solution_api_integration/package.json b/x-pack/test/security_solution_api_integration/package.json index 53acd95fe2515..b3b75eed86ca3 100644 --- a/x-pack/test/security_solution_api_integration/package.json +++ b/x-pack/test/security_solution_api_integration/package.json @@ -83,7 +83,7 @@ "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", 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 333e76550a740..3b8689b47fcb8 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/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 870df90d3e475..a0b7145dbc952 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 @@ -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/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 60e0399df53fd..33f79b1bff8d7 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 448e325892a5f..83faf92bf7c84 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/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 e059cab5ae64b..17a6723a53b88 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/fleet_integration.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules/management/fleet_integration.ts index 5453ff5b34c77..a9653d7593209 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 16dba27616947..eca4e51bcc6f8 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 9acef16bfbeb1..60b1e5e1ba526 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 a75c8f87bd783..4e7ea364f0968 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/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 981bfd7126780..688816569c181 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_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 37853b865e16d..79e632c7b0493 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/utils/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/index.ts index 0fed526f9ef3f..415569827b85d 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 f888216cb6eed..16439adb00e36 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 0000000000000..dafd16aaa9f5f --- /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 0000000000000..1d2cd8b24239c --- /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/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 899d5ddd7f83f..179840ee608fc 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 291cd269580b0..df677656fbd94 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 2d03e597dc5af..7f683ca9994be 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 0f785f8a77453..da044637fc77b 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 259369346cc8b..988d73660d0ee 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 308fef271e987..499f97877bf16 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 776af6074e07e..c83e8693f2390 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 cc899ecc1dccc..592406e8c3398 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 caadba2619a74..c22aa9106a272 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/tsconfig.json b/x-pack/test/security_solution_api_integration/tsconfig.json index 77b7f288a5b00..18e019202355c 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 30e1e39b4cd3e..d8e1ed1ea3321 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/entity_analytics/enrichments.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/entity_analytics/enrichments.cy.ts index 60b93650048d9..ecffa9285c228 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 2248b37fa4876..fcb329da8b0d0 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 0000000000000..a88733b0dd291 --- /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/timelines/fields_browser.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/fields_browser.cy.ts index 776770060e85b..efa1b50ee8147 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/fields_browser.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/fields_browser.cy.ts @@ -13,9 +13,7 @@ import { FIELDS_BROWSER_MESSAGE_HEADER, FIELDS_BROWSER_FILTER_INPUT, FIELDS_BROWSER_CATEGORIES_FILTER_CONTAINER, - FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES, FIELDS_BROWSER_CATEGORY_BADGE, - FIELDS_BROWSER_VIEW_BUTTON, } from '../../../screens/fields_browser'; import { TIMELINE_FIELDS_BUTTON } from '../../../screens/timeline'; @@ -29,12 +27,11 @@ import { resetFields, toggleCategory, activateViewSelected, - activateViewAll, } from '../../../tasks/fields_browser'; import { login } from '../../../tasks/login'; import { visitWithTimeRange } from '../../../tasks/navigation'; import { openTimelineUsingToggle } from '../../../tasks/security_main'; -import { openTimelineFieldsBrowser, populateTimeline } from '../../../tasks/timeline'; +import { openTimelineFieldsBrowser } from '../../../tasks/timeline'; import { hostsUrl } from '../../../urls/navigation'; @@ -49,102 +46,75 @@ const defaultHeaders = [ { id: 'user.name' }, ]; -// Flaky in serverless tests -// FLAKY: https://github.com/elastic/kibana/issues/169363 -describe.skip('Fields Browser', { tags: ['@ess', '@serverless'] }, () => { - context('Fields Browser rendering', () => { - beforeEach(() => { - login(); - visitWithTimeRange(hostsUrl('allHosts')); - openTimelineUsingToggle(); - populateTimeline(); - openTimelineFieldsBrowser(); - }); - - it('displays all categories (by default)', () => { - cy.get(FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES).should('be.empty'); - }); - - it('displays "view all" option by default', () => { - cy.get(FIELDS_BROWSER_VIEW_BUTTON).should('contain.text', 'View: all'); - }); +describe('Fields Browser', { tags: ['@ess', '@serverless'] }, () => { + beforeEach(() => { + login(); + visitWithTimeRange(hostsUrl('allHosts')); + openTimelineUsingToggle(); + openTimelineFieldsBrowser(); + }); - it('displays the expected count of categories that match the filter input', () => { + describe('Fields Browser rendering', () => { + it('should display the expected count of categories and fields that match the filter input', () => { const filterInput = 'host.mac'; filterFieldsBrowser(filterInput); cy.get(FIELDS_BROWSER_CATEGORIES_COUNT).should('have.text', '2'); - }); - - it('displays a search results label with the expected count of fields matching the filter input', () => { - const filterInput = 'host.mac'; - filterFieldsBrowser(filterInput); - cy.get(FIELDS_BROWSER_FIELDS_COUNT).should('contain.text', '2'); }); - it('displays only the selected fields when "view selected" option is enabled', () => { + it('should display only the selected fields when "view selected" option is enabled', () => { activateViewSelected(); cy.get(FIELDS_BROWSER_FIELDS_COUNT).should('contain.text', `${defaultHeaders.length}`); defaultHeaders.forEach((header) => { cy.get(`[data-test-subj="field-${header.id}-checkbox"]`).should('be.checked'); }); - activateViewAll(); }); - it('creates the category badge when it is selected', () => { + it('should create the category badge when it is selected', () => { const category = 'host'; + const categoryCheck = 'event'; cy.get(FIELDS_BROWSER_CATEGORY_BADGE(category)).should('not.exist'); + toggleCategory(category); + cy.get(FIELDS_BROWSER_CATEGORY_BADGE(category)).should('exist'); + toggleCategory(category); - }); - it('search a category should match the category in the category filter', () => { - const category = 'host'; + cy.log('the category filter should contain the filtered category'); filterFieldsBrowser(category); toggleCategoryFilter(); + cy.get(FIELDS_BROWSER_CATEGORIES_FILTER_CONTAINER).should('contain.text', category); - }); - it('search a category should filter out non matching categories in the category filter', () => { - const category = 'host'; - const categoryCheck = 'event'; - filterFieldsBrowser(category); - toggleCategoryFilter(); + cy.log('non-matching categories should not be listed in the category filter'); + cy.get(FIELDS_BROWSER_CATEGORIES_FILTER_CONTAINER).should('not.contain.text', categoryCheck); }); }); - context('Editing the timeline', () => { - beforeEach(() => { - login(); - visitWithTimeRange(hostsUrl('allHosts')); - openTimelineUsingToggle(); - populateTimeline(); - openTimelineFieldsBrowser(); - }); + describe('Editing the timeline', () => { + it('should add/remove columns from the alerts table when the user checks/un-checks them', () => { + const filterInput = 'host.geo.c'; + + cy.log('removing the message column'); - it('removes the message field from the timeline when the user un-checks the field', () => { cy.get(FIELDS_BROWSER_MESSAGE_HEADER).should('exist'); removesMessageField(); closeFieldsBrowser(); cy.get(FIELDS_BROWSER_MESSAGE_HEADER).should('not.exist'); - }); - it('adds a field to the timeline when the user clicks the checkbox', () => { - const filterInput = 'host.geo.c'; + cy.log('add host.geo.city_name column'); - closeFieldsBrowser(); cy.get(FIELDS_BROWSER_HOST_GEO_CITY_NAME_HEADER).should('not.exist'); openTimelineFieldsBrowser(); - filterFieldsBrowser(filterInput); addsHostGeoCityNameToTimeline(); closeFieldsBrowser(); @@ -152,7 +122,7 @@ describe.skip('Fields Browser', { tags: ['@ess', '@serverless'] }, () => { cy.get(FIELDS_BROWSER_HOST_GEO_CITY_NAME_HEADER).should('exist'); }); - it('resets all fields in the timeline when `Reset Fields` is clicked', () => { + it('should reset all fields in the timeline when `Reset Fields` is clicked', () => { const filterInput = 'host.geo.c'; filterFieldsBrowser(filterInput); @@ -168,19 +138,16 @@ describe.skip('Fields Browser', { tags: ['@ess', '@serverless'] }, () => { resetFields(); cy.get(FIELDS_BROWSER_HEADER_HOST_GEO_CONTINENT_NAME_HEADER).should('not.exist'); - }); - it('restores focus to the Customize Columns button when `Reset Fields` is clicked', () => { - openTimelineFieldsBrowser(); - resetFields(); + cy.log('restores focus to the Customize Columns button when `Reset Fields` is clicked'); cy.get(TIMELINE_FIELDS_BUTTON).should('have.focus'); - }); - it('restores focus to the Customize Columns button when Esc is pressed', () => { + cy.log('restores focus to the Customize Columns button when Esc is pressed'); + openTimelineFieldsBrowser(); - cy.get(FIELDS_BROWSER_FILTER_INPUT).type('{esc}'); + cy.get(FIELDS_BROWSER_FILTER_INPUT).type('{esc}'); cy.get(TIMELINE_FIELDS_BUTTON).should('have.focus'); }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/full_screen.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/full_screen.cy.ts index ea9742a9c0829..e3f9023fe189d 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/full_screen.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/full_screen.cy.ts @@ -14,16 +14,14 @@ import { enterFullScreenMode, exitFullScreenMode, } from '../../../tasks/security_main'; -import { populateTimeline } from '../../../tasks/timeline'; import { hostsUrl } from '../../../urls/navigation'; -describe.skip('Toggle full screen', { tags: ['@ess', '@serverless'] }, () => { +describe('Toggle full screen', { tags: ['@ess', '@serverless'] }, () => { beforeEach(() => { login(); visitWithTimeRange(hostsUrl('allHosts')); openTimelineUsingToggle(); - populateTimeline(); }); it('Should hide timeline header and tab list area', () => { diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/open_timeline.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/open_timeline.cy.ts index e1dc678631124..6c1ec9b093df7 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/screens/alerts.ts b/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts index 417e53e86b952..a30990eccb403 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 7fbfb5f3a03e0..276feb83c45a3 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 6b53754cde44f..8b2d9ccac1bfa 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/discover.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/discover.ts @@ -43,7 +43,7 @@ export const DISCOVER_FILTER_BADGES = `${DISCOVER_CONTAINER} ${getDataTestSubjec 'filter-badge-' )}`; -export const DISCOVER_RESULT_HITS = getDataTestSubjectSelector('unifiedHistogramQueryHits'); +export const DISCOVER_RESULT_HITS = getDataTestSubjectSelector('discoverQueryHits'); export const DISCOVER_FIELDS_LOADING = getDataTestSubjectSelector( 'fieldListGroupedAvailableFields-countLoading' diff --git a/x-pack/test/security_solution_cypress/cypress/screens/flyout_risk_panel.ts b/x-pack/test/security_solution_cypress/cypress/screens/flyout_risk_panel.ts new file mode 100644 index 0000000000000..6c72fe439d27f --- /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/plugins/security_solution/public/timelines/components/flyout/bottom_bar/translations.ts b/x-pack/test/security_solution_cypress/cypress/screens/hosts/flyout_host_panel.ts similarity index 61% rename from x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/translations.ts rename to x-pack/test/security_solution_cypress/cypress/screens/hosts/flyout_host_panel.ts index 10f5e3faafd00..12f0256a24a34 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/translations.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/hosts/flyout_host_panel.ts @@ -5,8 +5,6 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; +import { getDataTestSubjectSelector } from '../../helpers/common'; -export const TIMELINE = i18n.translate('xpack.securitySolution.flyout.button.timeline', { - defaultMessage: 'timeline', -}); +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 0000000000000..170c87349fe45 --- /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/security_main.ts b/x-pack/test/security_solution_cypress/cypress/screens/security_main.ts index f68950dbd159a..6eacc61dd76ad 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 c6d6b14fcdfd1..69d3d4020b37f 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts @@ -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 79979c8a33016..515bd4061365a 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 31a2ffb2afb5a..a4e0eafc0018a 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 c31196a96a550..f9e83fb449b77 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/fields_browser.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/fields_browser.ts @@ -29,15 +29,11 @@ export const addsFields = (fields: string[]) => { }; export const addsHostGeoCityNameToTimeline = () => { - cy.get(FIELDS_BROWSER_HOST_GEO_CITY_NAME_CHECKBOX).check({ - force: true, - }); + cy.get(FIELDS_BROWSER_HOST_GEO_CITY_NAME_CHECKBOX).check(); }; export const addsHostGeoContinentNameToTimeline = () => { - cy.get(FIELDS_BROWSER_HOST_GEO_CONTINENT_NAME_CHECKBOX).check({ - force: true, - }); + cy.get(FIELDS_BROWSER_HOST_GEO_CONTINENT_NAME_CHECKBOX).check(); }; export const clearFieldsBrowser = () => { @@ -67,7 +63,7 @@ export const filterFieldsBrowser = (fieldName: string) => { }; export const toggleCategoryFilter = () => { - cy.get(FIELDS_BROWSER_CATEGORIES_FILTER_BUTTON).click({ force: true }); + cy.get(FIELDS_BROWSER_CATEGORIES_FILTER_BUTTON).click(); }; export const toggleCategory = (category: string) => { @@ -79,9 +75,7 @@ export const toggleCategory = (category: string) => { }; export const removesMessageField = () => { - cy.get(FIELDS_BROWSER_MESSAGE_CHECKBOX).uncheck({ - force: true, - }); + cy.get(FIELDS_BROWSER_MESSAGE_CHECKBOX).uncheck(); }; export const removeField = (fieldName: string) => { @@ -89,14 +83,14 @@ export const removeField = (fieldName: string) => { }; export const resetFields = () => { - cy.get(FIELDS_BROWSER_RESET_FIELDS).click({ force: true }); + cy.get(FIELDS_BROWSER_RESET_FIELDS).click(); }; export const activateViewSelected = () => { - cy.get(FIELDS_BROWSER_VIEW_BUTTON).click({ force: true }); - cy.get(FIELDS_BROWSER_VIEW_SELECTED).click({ force: true }); + cy.get(FIELDS_BROWSER_VIEW_BUTTON).click(); + cy.get(FIELDS_BROWSER_VIEW_SELECTED).click(); }; export const activateViewAll = () => { - cy.get(FIELDS_BROWSER_VIEW_BUTTON).click({ force: true }); - cy.get(FIELDS_BROWSER_VIEW_ALL).click({ force: true }); + cy.get(FIELDS_BROWSER_VIEW_BUTTON).click(); + cy.get(FIELDS_BROWSER_VIEW_ALL).click(); }; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/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 0000000000000..4de8d1dc85c0d --- /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/security_main.ts b/x-pack/test/security_solution_cypress/cypress/tasks/security_main.ts index 5b034b0ae4b0e..fbf31d19e5a4c 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/security_main.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/security_main.ts @@ -29,9 +29,9 @@ export const closeTimelineUsingCloseButton = () => { }; export const enterFullScreenMode = () => { - cy.get(TIMELINE_FULL_SCREEN_BUTTON).first().click({ force: true }); + cy.get(TIMELINE_FULL_SCREEN_BUTTON).first().click(); }; export const exitFullScreenMode = () => { - cy.get(TIMELINE_EXIT_FULL_SCREEN_BUTTON).first().click({ force: true }); + cy.get(TIMELINE_EXIT_FULL_SCREEN_BUTTON).first().click(); }; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts b/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts index 767123c9875b6..d8543ec852c17 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'; @@ -364,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 = () => { @@ -378,7 +379,6 @@ export const openTimelineInspectButton = () => { }; export const openTimelineFromSettings = () => { - cy.get(OPEN_TIMELINE_ICON).should('be.visible'); cy.get(OPEN_TIMELINE_ICON).click(); }; @@ -398,7 +398,7 @@ export const openTimelineById = (timelineId: string): Cypress.Chainable { diff --git a/x-pack/test/security_solution_cypress/es_archives/risk_hosts_updated/mappings.json b/x-pack/test/security_solution_cypress/es_archives/risk_hosts_updated/mappings.json index 3e1b52cb22f5e..e250700644c15 100644 --- a/x-pack/test/security_solution_cypress/es_archives/risk_hosts_updated/mappings.json +++ b/x-pack/test/security_solution_cypress/es_archives/risk_hosts_updated/mappings.json @@ -31,10 +31,6 @@ }, "settings": { "index": { - "lifecycle": { - "name": "ml_host_risk_score_latest_default", - "rollover_alias": "ml_host_risk_score_latest_default" - }, "mapping": { "total_fields": { "limit": "10000" @@ -83,10 +79,6 @@ }, "settings": { "index": { - "lifecycle": { - "name": "ml_host_risk_score_default", - "rollover_alias": "ml_host_risk_score_default" - }, "mapping": { "total_fields": { "limit": "10000" diff --git a/x-pack/test/security_solution_cypress/es_archives/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 0000000000000..71efdeb5b4c3f --- /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 0000000000000..7eace06dd4d1a --- /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 e7f35e31702cc..83cc2f8bd1f33 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/functional/test_suites/common/discover/group1/_discover.ts b/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover.ts index 684278f7e8638..9768eb43e76ca 100644 --- a/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover.ts +++ b/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover.ts @@ -117,10 +117,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should show correct initial chart interval of Auto', async function () { await PageObjects.timePicker.setDefaultAbsoluteRange(); await PageObjects.discover.waitUntilSearchingHasFinished(); - await testSubjects.click('unifiedHistogramQueryHits'); // to cancel out tooltips + await testSubjects.click('discoverQueryHits'); // to cancel out tooltips const actualInterval = await PageObjects.discover.getChartInterval(); - const expectedInterval = 'Auto'; + const expectedInterval = 'auto'; expect(actualInterval).to.be(expectedInterval); }); diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover_histogram.ts b/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover_histogram.ts index ccba699ffa710..cf581ad3edb51 100644 --- a/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover_histogram.ts +++ b/x-pack/test_serverless/functional/test_suites/common/discover/group1/_discover_histogram.ts @@ -159,6 +159,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const chartCanvasExist = await elasticChart.canvasExists(); expect(chartCanvasExist).to.be(true); const chartIntervalIconTip = await PageObjects.discover.getChartIntervalWarningIcon(); + expect(chartIntervalIconTip).to.be(false); + }); + it('should visualize monthly data with different years scaled to seconds', async () => { + const from = 'Jan 1, 2010 @ 00:00:00.000'; + const to = 'Mar 21, 2019 @ 00:00:00.000'; + await prepareTest({ from, to }, 'Second'); + const chartCanvasExist = await elasticChart.canvasExists(); + expect(chartCanvasExist).to.be(true); + const chartIntervalIconTip = await PageObjects.discover.getChartIntervalWarningIcon(); expect(chartIntervalIconTip).to.be(true); }); it('should allow hide/show histogram, persisted in url state', async () => { @@ -167,8 +176,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await prepareTest({ from, to }); let canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(true); - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(false); @@ -177,8 +185,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await browser.refresh(); canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(false); - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); @@ -192,8 +199,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await prepareTest({ from, to }); // close chart for saved search - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); let canvasExists: boolean; await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); @@ -215,8 +221,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(canvasExists).to.be(false); // open chart for saved search - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await retry.waitFor(`Discover histogram to be displayed`, async () => { canvasExists = await elasticChart.canvasExists(); return canvasExists; @@ -238,8 +243,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should show permitted hidden histogram state when returning back to discover', async () => { // close chart - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); let canvasExists: boolean; await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); @@ -251,8 +255,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); // open chart - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(true); @@ -269,8 +272,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(canvasExists).to.be(true); // close chart - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await retry.try(async () => { canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(false); @@ -281,8 +283,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('discover'); await PageObjects.discover.waitUntilSearchingHasFinished(); // Make sure the chart is visible - await testSubjects.click('unifiedHistogramChartOptionsToggle'); - await testSubjects.click('unifiedHistogramChartToggle'); + await PageObjects.discover.toggleChartVisibility(); await PageObjects.discover.waitUntilSearchingHasFinished(); // type an invalid search query, hit refresh await queryBar.setQuery('this is > not valid'); diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/group3/_sidebar.ts b/x-pack/test_serverless/functional/test_suites/common/discover/group3/_sidebar.ts index 0435fdffa7ede..0504049f89ed5 100644 --- a/x-pack/test_serverless/functional/test_suites/common/discover/group3/_sidebar.ts +++ b/x-pack/test_serverless/functional/test_suites/common/discover/group3/_sidebar.ts @@ -242,13 +242,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should collapse when clicked', async function () { - await PageObjects.discover.toggleSidebarCollapse(); - await testSubjects.existOrFail('discover-sidebar'); + await PageObjects.discover.closeSidebar(); + await testSubjects.existOrFail('dscShowSidebarButton'); await testSubjects.missingOrFail('fieldList'); }); it('should expand when clicked', async function () { - await PageObjects.discover.toggleSidebarCollapse(); + await PageObjects.discover.openSidebar(); await testSubjects.existOrFail('discover-sidebar'); await testSubjects.existOrFail('fieldList'); }); diff --git a/x-pack/test_serverless/functional/test_suites/common/visualizations/group1/smokescreen.ts b/x-pack/test_serverless/functional/test_suites/common/visualizations/group1/smokescreen.ts index 5015b4be2250c..d4337d16db4ea 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 d19cb269891d7..f73a350a501eb 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 0bb8da7a911b9..d245d2aa71911 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/yarn.lock b/yarn.lock index b8df7dbbd593c..08473482ff924 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11872,12 +11872,12 @@ axe-core@^4.8.2: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.8.2.tgz#2f6f3cde40935825cf4465e3c1c9e77b240ff6ae" integrity sha512-/dlp0fxyM3R8YW7MFzaHWXrf4zzbr0vaYb23VBFCl83R7nWNPg/yaQw2Dc8jzCMmDVLhSdzH8MjrsuIUuvX+6g== -axios@^1.3.4, axios@^1.6.0, axios@^1.6.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" @@ -17255,7 +17255,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==