From 0755f6d7738731d51aff8d2819e8581950a0a6bc Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Mon, 23 Jan 2023 08:30:51 +0100 Subject: [PATCH 1/8] [Discover] Add container for data state (migrated from useSavedSearch hook) (#148614) This PR migrates the hook responsible for data fetching in Discover main (`use_saved_search.ts`) to DataStateContainer. --- .../discover/public/__mocks__/services.ts | 4 + .../field_stats_table/field_stats_table.tsx | 23 +- .../layout/__stories__/get_layout_props.ts | 2 +- .../layout/discover_documents.test.tsx | 6 +- .../components/layout/discover_documents.tsx | 5 +- .../layout/discover_histogram_layout.test.tsx | 8 +- .../layout/discover_histogram_layout.tsx | 8 +- .../layout/discover_layout.test.tsx | 11 +- .../components/layout/discover_layout.tsx | 27 +- .../layout/discover_main_content.test.tsx | 7 +- .../layout/discover_main_content.tsx | 9 - .../main/components/layout/types.ts | 10 +- .../layout/use_discover_histogram.test.tsx | 2 +- .../layout/use_discover_histogram.ts | 2 +- .../discover_field_details.test.tsx | 2 +- .../discover_field_details.tsx | 2 +- .../sidebar/discover_field.test.tsx | 2 +- .../components/sidebar/discover_field.tsx | 2 +- .../sidebar/discover_sidebar.test.tsx | 2 +- .../components/sidebar/discover_sidebar.tsx | 2 +- .../discover_sidebar_responsive.test.tsx | 6 +- .../sidebar/discover_sidebar_responsive.tsx | 6 +- .../application/main/discover_main_app.tsx | 6 - .../application/main/hooks/use_data_state.ts | 2 +- .../main/hooks/use_discover_state.ts | 61 ++-- .../main/hooks/use_saved_search.test.tsx | 171 ----------- .../main/hooks/use_saved_search.ts | 272 ------------------ .../hooks/use_saved_search_messages.test.ts | 2 +- .../main/hooks/use_saved_search_messages.ts | 3 +- .../main/hooks/use_search_session.test.ts | 5 +- .../main/hooks/use_search_session.ts | 19 +- .../use_test_based_query_language.test.ts | 2 +- .../hooks/use_text_based_query_language.ts | 2 +- .../discover_data_state_container.test.ts | 91 ++++++ .../services/discover_data_state_container.ts | 252 ++++++++++++++++ .../main/services/discover_state.test.ts | 1 + .../main/services/discover_state.ts | 33 ++- .../application/main/utils/fetch_all.test.ts | 3 +- .../application/main/utils/fetch_all.ts | 2 +- .../main/utils/get_fetch_observable.ts | 17 +- .../main/utils/get_fetch_observeable.test.ts | 4 +- .../main/utils/get_raw_record_type.test.ts | 2 +- .../main/utils/get_raw_record_type.ts | 2 +- .../embeddable/saved_search_embeddable.tsx | 2 +- .../public/utils/get_sharing_data.test.ts | 11 +- .../discover/public/utils/get_sharing_data.ts | 12 +- 46 files changed, 492 insertions(+), 633 deletions(-) delete mode 100644 src/plugins/discover/public/application/main/hooks/use_saved_search.test.tsx delete mode 100644 src/plugins/discover/public/application/main/hooks/use_saved_search.ts create mode 100644 src/plugins/discover/public/application/main/services/discover_data_state_container.test.ts create mode 100644 src/plugins/discover/public/application/main/services/discover_data_state_container.ts diff --git a/src/plugins/discover/public/__mocks__/services.ts b/src/plugins/discover/public/__mocks__/services.ts index d21bc4fc115b3..648ba828489bf 100644 --- a/src/plugins/discover/public/__mocks__/services.ts +++ b/src/plugins/discover/public/__mocks__/services.ts @@ -43,6 +43,10 @@ export function createDiscoverServicesMock(): DiscoverServices { dataPlugin.query.timefilter.timefilter.getTime = jest.fn(() => { return { from: 'now-15m', to: 'now' }; }); + dataPlugin.query.timefilter.timefilter.getRefreshInterval = jest.fn(() => { + return { pause: true, value: 1000 }; + }); + dataPlugin.query.timefilter.timefilter.calculateBounds = jest.fn(calculateBounds); dataPlugin.query.getState = jest.fn(() => ({ query: { query: '', language: 'lucene' }, diff --git a/src/plugins/discover/public/application/main/components/field_stats_table/field_stats_table.tsx b/src/plugins/discover/public/application/main/components/field_stats_table/field_stats_table.tsx index 88479e0ee10b0..b01a05f932e13 100644 --- a/src/plugins/discover/public/application/main/components/field_stats_table/field_stats_table.tsx +++ b/src/plugins/discover/public/application/main/components/field_stats_table/field_stats_table.tsx @@ -23,7 +23,6 @@ import { css } from '@emotion/react'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { FIELD_STATISTICS_LOADED } from './constants'; import type { DiscoverStateContainer } from '../../services/discover_state'; -import { AvailableFields$, DataRefetch$, DataTotalHits$ } from '../../hooks/use_saved_search'; export interface RandomSamplingOption { mode: 'random_sampling'; seed: string; @@ -106,15 +105,11 @@ export interface FieldStatisticsTableProps { * @param eventName */ trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; - savedSearchRefetch$?: DataRefetch$; - availableFields$?: AvailableFields$; searchSessionId?: string; - savedSearchDataTotalHits$?: DataTotalHits$; } export const FieldStatisticsTable = (props: FieldStatisticsTableProps) => { const { - availableFields$, dataView, savedSearch, query, @@ -123,10 +118,9 @@ export const FieldStatisticsTable = (props: FieldStatisticsTableProps) => { stateContainer, onAddFilter, trackUiMetric, - savedSearchRefetch$, searchSessionId, - savedSearchDataTotalHits$, } = props; + const totalHits$ = stateContainer?.dataState.data$.totalHits$; const services = useDiscoverServices(); const [embeddable, setEmbeddable] = useState< | ErrorEmbeddable @@ -141,13 +135,14 @@ export const FieldStatisticsTable = (props: FieldStatisticsTableProps) => { ); useEffect(() => { + const availableFields$ = stateContainer?.dataState.data$.availableFields$; const sub = embeddable?.getOutput$().subscribe((output: DataVisualizerGridEmbeddableOutput) => { if (output.showDistributions !== undefined && stateContainer) { stateContainer.setAppState({ hideAggregatedPreview: !output.showDistributions }); } }); - const refetch = savedSearchRefetch$?.subscribe(() => { + const refetch = stateContainer?.dataState.refetch$.subscribe(() => { if (embeddable && !isErrorEmbeddable(embeddable)) { embeddable.updateInput({ lastReloadRequestTime: Date.now() }); } @@ -164,7 +159,7 @@ export const FieldStatisticsTable = (props: FieldStatisticsTableProps) => { refetch?.unsubscribe(); fields?.unsubscribe(); }; - }, [embeddable, stateContainer, savedSearchRefetch$, availableFields$]); + }, [embeddable, stateContainer]); useEffect(() => { if (embeddable && !isErrorEmbeddable(embeddable)) { @@ -177,10 +172,8 @@ export const FieldStatisticsTable = (props: FieldStatisticsTableProps) => { visibleFieldNames: columns, onAddFilter, sessionId: searchSessionId, - fieldsToFetch: availableFields$?.getValue().fields, - totalDocuments: savedSearchDataTotalHits$ - ? savedSearchDataTotalHits$.getValue()?.result - : undefined, + fieldsToFetch: stateContainer?.dataState.data$.availableFields$?.getValue().fields, + totalDocuments: totalHits$ ? totalHits$.getValue()?.result : undefined, samplingOption: { mode: 'normal_sampling', shardSize: 5000, @@ -198,8 +191,8 @@ export const FieldStatisticsTable = (props: FieldStatisticsTableProps) => { filters, onAddFilter, searchSessionId, - availableFields$, - savedSearchDataTotalHits$, + totalHits$, + stateContainer, ]); useEffect(() => { diff --git a/src/plugins/discover/public/application/main/components/layout/__stories__/get_layout_props.ts b/src/plugins/discover/public/application/main/components/layout/__stories__/get_layout_props.ts index f5b64ebf85adc..3d7e72b32bb12 100644 --- a/src/plugins/discover/public/application/main/components/layout/__stories__/get_layout_props.ts +++ b/src/plugins/discover/public/application/main/components/layout/__stories__/get_layout_props.ts @@ -18,7 +18,7 @@ import { DataMain$, DataTotalHits$, RecordRawType, -} from '../../../hooks/use_saved_search'; +} from '../../../services/discover_data_state_container'; import { buildDataTableRecordList } from '../../../../../utils/build_data_record'; import { esHits } from '../../../../../__mocks__/es_hits'; import { SavedSearch } from '../../../../..'; diff --git a/src/plugins/discover/public/application/main/components/layout/discover_documents.test.tsx b/src/plugins/discover/public/application/main/components/layout/discover_documents.test.tsx index c6af7846a4d23..d73c1163c8a0f 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_documents.test.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_documents.test.tsx @@ -13,7 +13,7 @@ import { setHeaderActionMenuMounter } from '../../../../kibana_services'; import { esHits } from '../../../../__mocks__/es_hits'; import { savedSearchMock } from '../../../../__mocks__/saved_search'; import { DiscoverStateContainer } from '../../services/discover_state'; -import { DataDocuments$ } from '../../hooks/use_saved_search'; +import { DataDocuments$ } from '../../services/discover_data_state_container'; import { discoverServiceMock } from '../../../../__mocks__/services'; import { FetchStatus } from '../../../types'; import { DiscoverDocuments, onResize } from './discover_documents'; @@ -39,14 +39,14 @@ function mountComponent(fetchStatus: FetchStatus, hits: EsHitRecord[]) { }) as DataDocuments$; const stateContainer = getDiscoverStateMock({}); stateContainer.setAppState({ index: dataViewMock.id }); + stateContainer.dataState.data$.documents$ = documents$; const props = { expandedDoc: undefined, dataView: dataViewMock, onAddFilter: jest.fn(), savedSearch: savedSearchMock, - documents$, - searchSource: documents$, + searchSource: savedSearchMock.searchSource, setExpandedDoc: jest.fn(), state: { columns: [] }, stateContainer, diff --git a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx index fbded519b956a..647a6561b7cf0 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx @@ -29,7 +29,7 @@ import { HIDE_ANNOUNCEMENTS, } from '../../../../../common'; import { useColumns } from '../../../../hooks/use_data_grid_columns'; -import { DataDocuments$, RecordRawType } from '../../hooks/use_saved_search'; +import { RecordRawType } from '../../services/discover_data_state_container'; import { DiscoverStateContainer } from '../../services/discover_state'; import { useDataState } from '../../hooks/use_data_state'; import { DocTableInfinite } from '../../../../components/doc_table/doc_table_infinite'; @@ -59,7 +59,6 @@ export const onResize = ( }; function DiscoverDocumentsComponent({ - documents$, expandedDoc, dataView, onAddFilter, @@ -68,7 +67,6 @@ function DiscoverDocumentsComponent({ stateContainer, onFieldEdited, }: { - documents$: DataDocuments$; expandedDoc?: DataTableRecord; dataView: DataView; navigateTo: (url: string) => void; @@ -79,6 +77,7 @@ function DiscoverDocumentsComponent({ onFieldEdited?: () => void; }) { const services = useDiscoverServices(); + const documents$ = stateContainer.dataState.data$.documents$; const { dataViews, capabilities, uiSettings } = services; const [query, sort, rowHeight, rowsPerPage, grid, columns, index] = useAppStateSelector( (state) => { diff --git a/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.test.tsx b/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.test.tsx index d99340da89857..765e8edfcdbaf 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.test.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.test.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { Subject, BehaviorSubject, of } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { esHits } from '../../../../__mocks__/es_hits'; import { dataViewMock } from '../../../../__mocks__/data_view'; @@ -18,7 +18,7 @@ import { DataMain$, DataTotalHits$, RecordRawType, -} from '../../hooks/use_saved_search'; +} from '../../services/discover_data_state_container'; import { discoverServiceMock } from '../../../../__mocks__/services'; import { FetchStatus } from '../../../types'; import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; @@ -117,6 +117,7 @@ const mountComponent = ({ session.getSession$.mockReturnValue(new BehaviorSubject('123')); const stateContainer = getStateContainer(); + stateContainer.dataState.data$ = savedSearchData$; const props: DiscoverHistogramLayoutProps = { isPlainRecord, @@ -124,9 +125,6 @@ const mountComponent = ({ navigateTo: jest.fn(), setExpandedDoc: jest.fn(), savedSearch, - savedSearchData$, - savedSearchFetch$: new Subject(), - savedSearchRefetch$: new Subject(), stateContainer, onFieldEdited: jest.fn(), columns: [], diff --git a/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx index aa4027c22aedc..28a6d6c051787 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx @@ -15,7 +15,6 @@ import type { DiscoverSearchSessionManager } from '../../services/discover_searc import type { InspectorAdapters } from '../../hooks/use_inspector'; import { type DiscoverMainContentProps, DiscoverMainContent } from './discover_main_content'; import { ResetSearchButton } from './reset_search_button'; -import type { DataFetch$ } from '../../hooks/use_saved_search'; export interface DiscoverHistogramLayoutProps extends DiscoverMainContentProps { resetSavedSearch: () => void; @@ -23,7 +22,6 @@ export interface DiscoverHistogramLayoutProps extends DiscoverMainContentProps { resizeRef: RefObject; inspectorAdapters: InspectorAdapters; searchSessionManager: DiscoverSearchSessionManager; - savedSearchFetch$: DataFetch$; } export const DiscoverHistogramLayout = ({ @@ -31,8 +29,6 @@ export const DiscoverHistogramLayout = ({ dataView, resetSavedSearch, savedSearch, - savedSearchData$, - savedSearchFetch$, stateContainer, isTimeBased, resizeRef, @@ -47,14 +43,14 @@ export const DiscoverHistogramLayout = ({ isPlainRecord, stateContainer, savedSearch, - savedSearchData$, + savedSearchData$: stateContainer.dataState.data$, }; const histogramProps = useDiscoverHistogram({ isTimeBased, inspectorAdapters, searchSessionManager, - savedSearchFetch$, + savedSearchFetch$: stateContainer.dataState.fetch$, ...commonProps, }); diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx index 2d6998df4898f..13994b401b044 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { Subject, BehaviorSubject, of } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import type { Query, AggregateQuery } from '@kbn/es-query'; import { setHeaderActionMenuMounter } from '../../../../kibana_services'; @@ -24,12 +24,10 @@ import { dataViewWithTimefieldMock } from '../../../../__mocks__/data_view_with_ import { AvailableFields$, DataDocuments$, - DataFetch$, DataMain$, - DataRefetch$, DataTotalHits$, RecordRawType, -} from '../../hooks/use_saved_search'; +} from '../../services/discover_data_state_container'; import { createDiscoverServicesMock } from '../../../../__mocks__/services'; import { FetchStatus } from '../../../types'; import { RequestAdapter } from '@kbn/inspector-plugin/common'; @@ -93,7 +91,7 @@ function mountComponent( result: Number(esHits.length), }) as DataTotalHits$; - const savedSearchData$ = { + stateContainer.dataState.data$ = { main$, documents$, totalHits$, @@ -115,9 +113,6 @@ function mountComponent( onUpdateQuery: jest.fn(), resetSavedSearch: jest.fn(), savedSearch: savedSearchMock, - savedSearchData$, - savedSearchFetch$: new Subject() as DataFetch$, - savedSearchRefetch$: new Subject() as DataRefetch$, searchSource: searchSourceMock, state: { columns: [], query, hideChart: false, interval: 'auto' }, stateContainer, diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx index f93bb7b9ff8ea..8f1dcc4ba8dfb 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx @@ -38,7 +38,7 @@ import { DiscoverTopNav } from '../top_nav/discover_topnav'; import { DocViewFilterFn } from '../../../../services/doc_views/doc_views_types'; import { getResultState } from '../../utils/get_result_state'; import { DiscoverUninitialized } from '../uninitialized/uninitialized'; -import { DataMainMsg, RecordRawType } from '../../hooks/use_saved_search'; +import { DataMainMsg, RecordRawType } from '../../services/discover_data_state_container'; import { useColumns } from '../../../../hooks/use_data_grid_columns'; import { FetchStatus } from '../../../types'; import { useDataState } from '../../hooks/use_data_state'; @@ -62,10 +62,7 @@ export function DiscoverLayout({ onChangeDataView, onUpdateQuery, setExpandedDoc, - savedSearchFetch$, - savedSearchRefetch$, resetSavedSearch, - savedSearchData$, savedSearch, searchSource, stateContainer, @@ -86,7 +83,7 @@ export function DiscoverLayout({ spaces, inspector, } = useDiscoverServices(); - const { main$ } = savedSearchData$; + const { main$ } = stateContainer.dataState.data$; const [query, savedQuery, filters, columns, sort] = useAppStateSelector((state) => [ state.query, state.savedQuery, @@ -166,8 +163,8 @@ export function DiscoverLayout({ if (!dataView.isPersisted()) { await updateAdHocDataViewId(dataView); } - savedSearchRefetch$.next('reset'); - }, [dataView, savedSearchRefetch$, updateAdHocDataViewId]); + stateContainer.dataState.refetch$.next('reset'); + }, [dataView, stateContainer, updateAdHocDataViewId]); const onDisableFilters = useCallback(() => { const disabledFilters = filterManager @@ -224,7 +221,11 @@ export function DiscoverLayout({ } if (resultState === 'uninitialized') { - return savedSearchRefetch$.next(undefined)} />; + return ( + stateContainer.dataState.refetch$.next(undefined)} + /> + ); } return ( @@ -237,9 +238,6 @@ export function DiscoverLayout({ expandedDoc={expandedDoc} setExpandedDoc={setExpandedDoc} savedSearch={savedSearch} - savedSearchData$={savedSearchData$} - savedSearchFetch$={savedSearchFetch$} - savedSearchRefetch$={savedSearchRefetch$} stateContainer={stateContainer} isTimeBased={isTimeBased} columns={currentColumns} @@ -271,9 +269,6 @@ export function DiscoverLayout({ resetSavedSearch, resultState, savedSearch, - savedSearchData$, - savedSearchFetch$, - savedSearchRefetch$, searchSessionManager, setExpandedDoc, stateContainer, @@ -328,7 +323,7 @@ export function DiscoverLayout({ diff --git a/src/plugins/discover/public/application/main/components/layout/discover_main_content.test.tsx b/src/plugins/discover/public/application/main/components/layout/discover_main_content.test.tsx index a98d3cd85bbb4..054fc36cdbc3e 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_main_content.test.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_main_content.test.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { Subject, BehaviorSubject, of } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { esHits } from '../../../../__mocks__/es_hits'; import { dataViewMock } from '../../../../__mocks__/data_view'; @@ -18,7 +18,7 @@ import { DataMain$, DataTotalHits$, RecordRawType, -} from '../../hooks/use_saved_search'; +} from '../../services/discover_data_state_container'; import { createDiscoverServicesMock } from '../../../../__mocks__/services'; import { FetchStatus } from '../../../types'; import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; @@ -89,6 +89,7 @@ const mountComponent = ({ availableFields$, }; const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + stateContainer.dataState.data$ = savedSearchData$; stateContainer.setAppState({ interval: 'auto', hideChart, @@ -101,8 +102,6 @@ const mountComponent = ({ navigateTo: jest.fn(), setExpandedDoc: jest.fn(), savedSearch, - savedSearchData$, - savedSearchRefetch$: new Subject(), stateContainer, onFieldEdited: jest.fn(), columns: [], 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 f91de3b2a02d3..c6deff28f62ed 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 @@ -16,7 +16,6 @@ import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { DataTableRecord } from '../../../../types'; import { DocumentViewModeToggle } from '../../../../components/view_mode_toggle'; import { DocViewFilterFn } from '../../../../services/doc_views/doc_views_types'; -import { DataRefetch$, SavedSearchData } from '../../hooks/use_saved_search'; import { DiscoverStateContainer } from '../../services/discover_state'; import { FieldStatisticsTab } from '../field_stats_table'; import { DiscoverDocuments } from './discover_documents'; @@ -27,8 +26,6 @@ export interface DiscoverMainContentProps { savedSearch: SavedSearch; isPlainRecord: boolean; navigateTo: (url: string) => void; - savedSearchData$: SavedSearchData; - savedSearchRefetch$: DataRefetch$; stateContainer: DiscoverStateContainer; expandedDoc?: DataTableRecord; setExpandedDoc: (doc?: DataTableRecord) => void; @@ -42,8 +39,6 @@ export const DiscoverMainContent = ({ dataView, isPlainRecord, navigateTo, - savedSearchData$, - savedSearchRefetch$, expandedDoc, setExpandedDoc, viewMode, @@ -85,7 +80,6 @@ export const DiscoverMainContent = ({ )} {viewMode === VIEW_MODE.DOCUMENT_LEVEL ? ( ) : ( )} diff --git a/src/plugins/discover/public/application/main/components/layout/types.ts b/src/plugins/discover/public/application/main/components/layout/types.ts index 34b8650ffc153..0d9b0c37e3009 100644 --- a/src/plugins/discover/public/application/main/components/layout/types.ts +++ b/src/plugins/discover/public/application/main/components/layout/types.ts @@ -9,10 +9,9 @@ import type { Query, TimeRange, AggregateQuery } from '@kbn/es-query'; import type { DataView } from '@kbn/data-views-plugin/public'; import type { ISearchSource } from '@kbn/data-plugin/public'; -import type { SavedSearch } from '@kbn/saved-search-plugin/public'; -import type { DataTableRecord } from '../../../../types'; -import type { DiscoverStateContainer } from '../../services/discover_state'; -import type { DataFetch$, DataRefetch$, SavedSearchData } from '../../hooks/use_saved_search'; +import { SavedSearch } from '@kbn/saved-search-plugin/public'; +import { DataTableRecord } from '../../../../types'; +import { DiscoverStateContainer } from '../../services/discover_state'; import type { DiscoverSearchSessionManager } from '../../services/discover_search_session'; import type { InspectorAdapters } from '../../hooks/use_inspector'; @@ -28,9 +27,6 @@ export interface DiscoverLayoutProps { expandedDoc?: DataTableRecord; setExpandedDoc: (doc?: DataTableRecord) => void; savedSearch: SavedSearch; - savedSearchData$: SavedSearchData; - savedSearchFetch$: DataFetch$; - savedSearchRefetch$: DataRefetch$; searchSource: ISearchSource; stateContainer: DiscoverStateContainer; persistDataView: (dataView: DataView) => Promise; 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 89059db91e7f5..b075e613b6a55 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 @@ -18,7 +18,7 @@ import { DataMain$, DataTotalHits$, RecordRawType, -} from '../../hooks/use_saved_search'; +} from '../../services/discover_data_state_container'; import type { DiscoverStateContainer } from '../../services/discover_state'; import { savedSearchMock } from '../../../../__mocks__/saved_search'; import type { Storage } from '@kbn/kibana-utils-plugin/public'; 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 d380244fe875b..ad676b1a4247d 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 @@ -23,7 +23,7 @@ import { useAppStateSelector } from '../../services/discover_app_state_container import { getUiActions } from '../../../../kibana_services'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { useDataState } from '../../hooks/use_data_state'; -import type { DataFetch$, SavedSearchData } from '../../hooks/use_saved_search'; +import type { DataFetch$, SavedSearchData } from '../../services/discover_data_state_container'; import type { DiscoverStateContainer } from '../../services/discover_state'; import { FetchStatus } from '../../../types'; import type { DiscoverSearchSessionManager } from '../../services/discover_search_session'; diff --git a/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/discover_field_details.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/discover_field_details.test.tsx index 535459c880988..e31445344b82b 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/discover_field_details.test.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/discover_field_details.test.tsx @@ -15,7 +15,7 @@ import { DataViewField } from '@kbn/data-views-plugin/public'; import { stubDataView, stubLogstashDataView } from '@kbn/data-views-plugin/common/data_view.stub'; import { BehaviorSubject } from 'rxjs'; import { FetchStatus } from '../../../../types'; -import { DataDocuments$ } from '../../../hooks/use_saved_search'; +import { DataDocuments$ } from '../../../services/discover_data_state_container'; import { getDataTableRecords } from '../../../../../__fixtures__/real_hits'; describe('discover sidebar field details', function () { diff --git a/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/discover_field_details.tsx b/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/discover_field_details.tsx index d1f32ae8df8fb..0a3d1b5c58072 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/discover_field_details.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/discover_field_details.tsx @@ -14,8 +14,8 @@ import { DataViewField, DataView } from '@kbn/data-views-plugin/public'; import { DiscoverFieldBucket } from './discover_field_bucket'; import { Bucket } from './types'; import { getDetails, isValidFieldDetails } from './get_details'; -import { DataDocuments$ } from '../../../hooks/use_saved_search'; import { FetchStatus } from '../../../../types'; +import { DataDocuments$ } from '../../../services/discover_data_state_container'; interface DiscoverFieldDetailsProps { /** diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_field.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_field.test.tsx index c25a6c5010e7e..88a5660db3017 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_field.test.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_field.test.tsx @@ -19,7 +19,7 @@ import { stubDataView } from '@kbn/data-views-plugin/common/data_view.stub'; import { DiscoverAppStateProvider } from '../../services/discover_app_state_container'; import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'; import { FetchStatus } from '../../../types'; -import { DataDocuments$ } from '../../hooks/use_saved_search'; +import { DataDocuments$ } from '../../services/discover_data_state_container'; import { getDataTableRecords } from '../../../../__fixtures__/real_hits'; import * as DetailsUtil from './deprecated_stats/get_details'; import { createDiscoverServicesMock } from '../../../../__mocks__/services'; diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_field.tsx index 89b143cf46cd3..3c4567bdc66dc 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_field.tsx @@ -36,7 +36,7 @@ import { getFieldTypeName } from '../../../../utils/get_field_type_name'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { SHOW_LEGACY_FIELD_TOP_VALUES, PLUGIN_ID } from '../../../../../common'; import { getUiActions } from '../../../../kibana_services'; -import { type DataDocuments$ } from '../../hooks/use_saved_search'; +import { type DataDocuments$ } from '../../services/discover_data_state_container'; const FieldInfoIcon: React.FC = memo(() => ( (data$: BehaviorSubject) { const [fetchState, setFetchState] = useState(data$.getValue()); diff --git a/src/plugins/discover/public/application/main/hooks/use_discover_state.ts b/src/plugins/discover/public/application/main/hooks/use_discover_state.ts index a8ff93ef94547..f5b3949e103a9 100644 --- a/src/plugins/discover/public/application/main/hooks/use_discover_state.ts +++ b/src/plugins/discover/public/application/main/hooks/use_discover_state.ts @@ -12,21 +12,15 @@ import { isOfAggregateQueryType } from '@kbn/es-query'; import { type DataView, DataViewType } from '@kbn/data-views-plugin/public'; import { SavedSearch, getSavedSearch } from '@kbn/saved-search-plugin/public'; import type { SortOrder } from '@kbn/saved-search-plugin/public'; +import { useSearchSession } from './use_search_session'; +import { FetchStatus } from '../../types'; import { useTextBasedQueryLanguage } from './use_text_based_query_language'; import { useUrlTracking } from './use_url_tracking'; import { getDiscoverStateContainer } from '../services/discover_state'; import { getStateDefaults } from '../utils/get_state_defaults'; import { DiscoverServices } from '../../../build_services'; import { loadDataView, resolveDataView } from '../utils/resolve_data_view'; -import { useSavedSearch as useSavedSearchData } from './use_saved_search'; -import { - MODIFY_COLUMNS_ON_SWITCH, - SEARCH_FIELDS_FROM_SOURCE, - SEARCH_ON_PAGE_LOAD_SETTING, - SORT_DEFAULT_ORDER_SETTING, -} from '../../../../common'; -import { useSearchSession } from './use_search_session'; -import { FetchStatus } from '../../types'; +import { MODIFY_COLUMNS_ON_SWITCH, SORT_DEFAULT_ORDER_SETTING } from '../../../../common'; import { getDataViewAppState } from '../utils/get_switch_data_view_app_state'; import { DataTableRecord } from '../../../types'; import { restoreStateFromSavedSearch } from '../../../services/saved_searches/restore_from_saved_search'; @@ -45,8 +39,6 @@ export function useDiscoverState({ }) { const { uiSettings, data, filterManager, dataViews, toastNotifications, trackUiMetric } = services; - const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]); - const { timefilter } = data.query.timefilter; const dataView = savedSearch.searchSource.getField('index')!; @@ -71,25 +63,14 @@ export function useDiscoverState({ const { setUrlTracking } = useUrlTracking(savedSearch, dataView); - const { appState, replaceUrlAppState } = stateContainer; + const { appState, replaceUrlAppState, searchSessionManager } = stateContainer; const [state, setState] = useState(appState.getState()); /** * Search session logic */ - const searchSessionManager = useSearchSession({ services, history, stateContainer, savedSearch }); - - const initialFetchStatus: FetchStatus = useMemo(() => { - // A saved search is created on every page load, so we check the ID to see if we're loading a - // previously saved search or if it is just transient - const shouldSearchOnPageLoad = - uiSettings.get(SEARCH_ON_PAGE_LOAD_SETTING) || - savedSearch.id !== undefined || - timefilter.getRefreshInterval().pause === false || - searchSessionManager.hasSearchSessionIdInURL(); - return shouldSearchOnPageLoad ? FetchStatus.LOADING : FetchStatus.UNINITIALIZED; - }, [uiSettings, savedSearch.id, searchSessionManager, timefilter]); + useSearchSession({ services, stateContainer, savedSearch }); /** * Adhoc data views functionality @@ -121,15 +102,8 @@ export function useDiscoverState({ /** * Data fetching logic */ - const { data$, fetch$, refetch$, reset, inspectorAdapters } = useSavedSearchData({ - initialFetchStatus, - searchSessionManager, - savedSearch, - searchSource, - services, - stateContainer, - useNewFieldsApi, - }); + const { data$, refetch$, reset, inspectorAdapters, initialFetchStatus } = + stateContainer.dataState; /** * State changes (data view, columns), when a text base query result is returned */ @@ -156,6 +130,14 @@ export function useDiscoverState({ return () => stopSync(); }, [stateContainer, filterManager, data, dataView]); + /** + * Data store subscribing to trigger fetching + */ + useEffect(() => { + const stopSync = stateContainer.dataState.subscribe(); + return () => stopSync(); + }, [stateContainer]); + /** * Track state changes that should trigger a fetch */ @@ -199,6 +181,14 @@ export function useDiscoverState({ stateContainer.actions.setDataView(nextDataView); } + if ( + dataViewChanged && + stateContainer.dataState.initialFetchStatus === FetchStatus.UNINITIALIZED + ) { + // stop execution if given data view has changed, and it's not configured to initially start a search in Discover + return; + } + if ( chartDisplayChanged || chartIntervalChanged || @@ -304,7 +294,7 @@ export function useDiscoverState({ * Trigger data fetching on dataView or savedSearch changes */ useEffect(() => { - if (dataView) { + if (dataView && initialFetchStatus === FetchStatus.LOADING) { refetch$.next(undefined); } }, [initialFetchStatus, refetch$, dataView, savedSearch.id]); @@ -320,10 +310,7 @@ export function useDiscoverState({ }, [dataView, stateContainer]); return { - data$, inspectorAdapters, - fetch$, - refetch$, resetSavedSearch, onChangeDataView, onUpdateQuery, diff --git a/src/plugins/discover/public/application/main/hooks/use_saved_search.test.tsx b/src/plugins/discover/public/application/main/hooks/use_saved_search.test.tsx deleted file mode 100644 index c598df55b1d1d..0000000000000 --- a/src/plugins/discover/public/application/main/hooks/use_saved_search.test.tsx +++ /dev/null @@ -1,171 +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 { Subject } from 'rxjs'; -import { renderHook } from '@testing-library/react-hooks'; -import { createSearchSessionMock } from '../../../__mocks__/search_session'; -import { discoverServiceMock } from '../../../__mocks__/services'; -import { savedSearchMock, savedSearchMockWithSQL } from '../../../__mocks__/saved_search'; -import { RecordRawType, useSavedSearch } from './use_saved_search'; -import { getDiscoverStateContainer } from '../services/discover_state'; -import { useDiscoverState } from './use_discover_state'; -import { FetchStatus } from '../../types'; -import { setUrlTracker } from '../../../kibana_services'; -import { urlTrackerMock } from '../../../__mocks__/url_tracker.mock'; -import React from 'react'; -import { DiscoverMainProvider } from '../services/discover_state_provider'; - -setUrlTracker(urlTrackerMock); -describe('test useSavedSearch', () => { - test('useSavedSearch return is valid', async () => { - const { history, searchSessionManager } = createSearchSessionMock(); - const stateContainer = getDiscoverStateContainer({ - savedSearch: savedSearchMock, - services: discoverServiceMock, - history, - }); - - const { result } = renderHook(() => { - return useSavedSearch({ - initialFetchStatus: FetchStatus.LOADING, - savedSearch: savedSearchMock, - searchSessionManager, - searchSource: savedSearchMock.searchSource.createCopy(), - services: discoverServiceMock, - stateContainer, - useNewFieldsApi: true, - }); - }); - - expect(result.current.refetch$).toBeInstanceOf(Subject); - expect(result.current.data$.main$.getValue().fetchStatus).toBe(FetchStatus.LOADING); - expect(result.current.data$.documents$.getValue().fetchStatus).toBe(FetchStatus.LOADING); - expect(result.current.data$.totalHits$.getValue().fetchStatus).toBe(FetchStatus.LOADING); - }); - test('refetch$ triggers a search', async () => { - const { history, searchSessionManager } = createSearchSessionMock(); - const stateContainer = getDiscoverStateContainer({ - savedSearch: savedSearchMock, - services: discoverServiceMock, - history, - }); - - discoverServiceMock.data.query.timefilter.timefilter.getTime = jest.fn(() => { - return { from: '2021-05-01T20:00:00Z', to: '2021-05-02T20:00:00Z' }; - }); - - const { result: resultState } = renderHook( - () => { - return useDiscoverState({ - services: discoverServiceMock, - history, - savedSearch: savedSearchMock, - setExpandedDoc: jest.fn(), - }); - }, - { - wrapper: ({ children }: { children: React.ReactElement }) => ( - {children} - ), - } - ); - - const { result, waitForValueToChange } = renderHook(() => { - return useSavedSearch({ - initialFetchStatus: FetchStatus.LOADING, - savedSearch: savedSearchMock, - searchSessionManager, - searchSource: resultState.current.searchSource, - services: discoverServiceMock, - stateContainer, - useNewFieldsApi: true, - }); - }); - - result.current.refetch$.next(undefined); - - await waitForValueToChange(() => { - return result.current.data$.main$.value.fetchStatus === 'complete'; - }); - - expect(result.current.data$.totalHits$.value.result).toBe(0); - expect(result.current.data$.documents$.value.result).toEqual([]); - }); - - test('reset sets back to initial state', async () => { - const { history, searchSessionManager } = createSearchSessionMock(); - const stateContainer = getDiscoverStateContainer({ - savedSearch: savedSearchMock, - services: discoverServiceMock, - history, - }); - - discoverServiceMock.data.query.timefilter.timefilter.getTime = jest.fn(() => { - return { from: '2021-05-01T20:00:00Z', to: '2021-05-02T20:00:00Z' }; - }); - - const { result: resultState } = renderHook( - () => { - return useDiscoverState({ - services: discoverServiceMock, - history, - savedSearch: savedSearchMock, - setExpandedDoc: jest.fn(), - }); - }, - { - wrapper: ({ children }: { children: React.ReactElement }) => ( - {children} - ), - } - ); - - const { result, waitForValueToChange } = renderHook(() => { - return useSavedSearch({ - initialFetchStatus: FetchStatus.LOADING, - savedSearch: savedSearchMock, - searchSessionManager, - searchSource: resultState.current.searchSource, - services: discoverServiceMock, - stateContainer, - useNewFieldsApi: true, - }); - }); - - result.current.refetch$.next(undefined); - - await waitForValueToChange(() => { - return result.current.data$.main$.value.fetchStatus === FetchStatus.COMPLETE; - }); - - result.current.reset(); - expect(result.current.data$.main$.value.fetchStatus).toBe(FetchStatus.LOADING); - }); - - test('useSavedSearch returns plain record raw type', async () => { - const { history, searchSessionManager } = createSearchSessionMock(); - const stateContainer = getDiscoverStateContainer({ - savedSearch: savedSearchMockWithSQL, - services: discoverServiceMock, - history, - }); - - const { result } = renderHook(() => { - return useSavedSearch({ - initialFetchStatus: FetchStatus.LOADING, - savedSearch: savedSearchMockWithSQL, - searchSessionManager, - searchSource: savedSearchMockWithSQL.searchSource.createCopy(), - services: discoverServiceMock, - stateContainer, - useNewFieldsApi: true, - }); - }); - - expect(result.current.data$.main$.getValue().recordRawType).toBe(RecordRawType.PLAIN); - }); -}); diff --git a/src/plugins/discover/public/application/main/hooks/use_saved_search.ts b/src/plugins/discover/public/application/main/hooks/use_saved_search.ts deleted file mode 100644 index 1b1a917740d43..0000000000000 --- a/src/plugins/discover/public/application/main/hooks/use_saved_search.ts +++ /dev/null @@ -1,272 +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 { useCallback, useEffect, useMemo, useRef } from 'react'; -import { BehaviorSubject, filter, map, Observable, share, Subject, tap } from 'rxjs'; -import type { AutoRefreshDoneFn } from '@kbn/data-plugin/public'; -import { ISearchSource } from '@kbn/data-plugin/public'; -import { RequestAdapter } from '@kbn/inspector-plugin/public'; -import { SavedSearch } from '@kbn/saved-search-plugin/public'; -import { AggregateQuery, Query } from '@kbn/es-query'; -import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; -import { getRawRecordType } from '../utils/get_raw_record_type'; -import { DiscoverServices } from '../../../build_services'; -import { DiscoverSearchSessionManager } from '../services/discover_search_session'; -import { DiscoverStateContainer } from '../services/discover_state'; -import { validateTimeRange } from '../utils/validate_time_range'; -import { useSingleton } from './use_singleton'; -import { FetchStatus } from '../../types'; -import { fetchAll } from '../utils/fetch_all'; -import { useBehaviorSubject } from './use_behavior_subject'; -import { sendResetMsg } from './use_saved_search_messages'; -import { getFetch$ } from '../utils/get_fetch_observable'; -import type { DataTableRecord } from '../../../types'; -import type { InspectorAdapters } from './use_inspector'; - -export interface SavedSearchData { - main$: DataMain$; - documents$: DataDocuments$; - totalHits$: DataTotalHits$; - availableFields$: AvailableFields$; -} - -export type DataMain$ = BehaviorSubject; -export type DataDocuments$ = BehaviorSubject; -export type DataTotalHits$ = BehaviorSubject; -export type AvailableFields$ = BehaviorSubject; -export type DataFetch$ = Observable<{ - reset: boolean; - searchSessionId: string; -}>; -export type DataRefetch$ = Subject; - -export interface UseSavedSearch { - refetch$: DataRefetch$; - data$: SavedSearchData; - reset: () => void; - inspectorAdapters: InspectorAdapters; -} - -export enum RecordRawType { - /** - * Documents returned Elasticsearch, nested structure - */ - DOCUMENT = 'document', - /** - * Data returned e.g. SQL queries, flat structure - * */ - PLAIN = 'plain', -} - -export type DataRefetchMsg = 'reset' | undefined; - -export interface DataMsg { - fetchStatus: FetchStatus; - error?: Error; - recordRawType?: RecordRawType; - query?: AggregateQuery | Query | undefined; -} - -export interface DataMainMsg extends DataMsg { - foundDocuments?: boolean; -} - -export interface DataDocumentsMsg extends DataMsg { - result?: DataTableRecord[]; -} - -export interface DataTotalHitsMsg extends DataMsg { - result?: number; -} - -export interface DataChartsMessage extends DataMsg { - response?: SearchResponse; -} - -export interface DataAvailableFieldsMsg extends DataMsg { - fields?: string[]; -} - -/** - * This hook return 2 observables, refetch$ allows to trigger data fetching, data$ to subscribe - * to the data fetching - */ -export const useSavedSearch = ({ - initialFetchStatus, - savedSearch, - searchSessionManager, - searchSource, - services, - stateContainer, - useNewFieldsApi, -}: { - initialFetchStatus: FetchStatus; - savedSearch: SavedSearch; - searchSessionManager: DiscoverSearchSessionManager; - searchSource: ISearchSource; - services: DiscoverServices; - stateContainer: DiscoverStateContainer; - useNewFieldsApi: boolean; -}) => { - const { data, filterManager } = services; - const timefilter = data.query.timefilter.timefilter; - const { query } = stateContainer.appState.getState(); - - const recordRawType = useMemo(() => getRawRecordType(query), [query]); - - const inspectorAdapters = useMemo(() => ({ requests: new RequestAdapter() }), []); - - /** - * The observables the UI (aka React component) subscribes to get notified about - * the changes in the data fetching process (high level: fetching started, data was received) - */ - const initialState = { fetchStatus: initialFetchStatus, recordRawType }; - const main$: DataMain$ = useBehaviorSubject(initialState) as DataMain$; - const documents$: DataDocuments$ = useBehaviorSubject(initialState) as DataDocuments$; - const totalHits$: DataTotalHits$ = useBehaviorSubject(initialState) as DataTotalHits$; - const availableFields$: AvailableFields$ = useBehaviorSubject(initialState) as AvailableFields$; - - const dataSubjects = useMemo(() => { - return { - main$, - documents$, - totalHits$, - availableFields$, - }; - }, [main$, documents$, totalHits$, availableFields$]); - - /** - * The observable to trigger data fetching in UI - * By refetch$.next('reset') rows and fieldcounts are reset to allow e.g. editing of runtime fields - * to be processed correctly - */ - const refetch$ = useSingleton(() => new Subject()); - - /** - * Values that shouldn't trigger re-rendering when changed - */ - const refs = useRef<{ - autoRefreshDone?: AutoRefreshDoneFn; - }>({}); - - /** - * handler emitted by `timefilter.getAutoRefreshFetch$()` - * to notify when data completed loading and to start a new autorefresh loop - */ - const setAutoRefreshDone = useCallback((fn: AutoRefreshDoneFn | undefined) => { - refs.current.autoRefreshDone = fn; - }, []); - - /** - * Observable that allows listening for when fetches are triggered - */ - const fetch$ = useMemo( - () => - getFetch$({ - setAutoRefreshDone, - data, - main$, - refetch$, - searchSessionManager, - searchSource, - initialFetchStatus, - }).pipe( - filter(() => validateTimeRange(timefilter.getTime(), services.toastNotifications)), - tap(() => inspectorAdapters.requests.reset()), - map((val) => ({ - reset: val === 'reset', - searchSessionId: searchSessionManager.getNextSearchSessionId(), - })), - share() - ), - [ - data, - initialFetchStatus, - inspectorAdapters.requests, - main$, - refetch$, - searchSessionManager, - searchSource, - services.toastNotifications, - setAutoRefreshDone, - timefilter, - ] - ); - - /** - * This part takes care of triggering the data fetching by creating and subscribing - * to an observable of various possible changes in state - */ - useEffect(() => { - let abortController: AbortController; - - const subscription = fetch$.subscribe(async ({ reset, searchSessionId }) => { - abortController?.abort(); - abortController = new AbortController(); - - const autoRefreshDone = refs.current.autoRefreshDone; - - await fetchAll(dataSubjects, searchSource, reset, { - abortController, - appStateContainer: stateContainer.appState, - data, - initialFetchStatus, - inspectorAdapters, - savedSearch, - searchSessionId, - services, - useNewFieldsApi, - }); - - // If the autoRefreshCallback is still the same as when we started i.e. there was no newer call - // replacing this current one, call it to make sure we tell that the auto refresh is done - // and a new one can be scheduled. - if (autoRefreshDone === refs.current.autoRefreshDone) { - // if this function was set and is executed, another refresh fetch can be triggered - refs.current.autoRefreshDone?.(); - refs.current.autoRefreshDone = undefined; - } - }); - - return () => { - abortController?.abort(); - subscription.unsubscribe(); - }; - }, [ - data, - data.query.queryString, - dataSubjects, - fetch$, - filterManager, - initialFetchStatus, - inspectorAdapters, - main$, - refetch$, - savedSearch, - searchSessionManager, - searchSessionManager.newSearchSessionIdFromURL$, - searchSource, - services, - services.toastNotifications, - stateContainer.appState, - timefilter, - useNewFieldsApi, - ]); - - const reset = useCallback( - () => sendResetMsg(dataSubjects, initialFetchStatus), - [dataSubjects, initialFetchStatus] - ); - - return { - fetch$, - refetch$, - data$: dataSubjects, - reset, - inspectorAdapters, - }; -}; diff --git a/src/plugins/discover/public/application/main/hooks/use_saved_search_messages.test.ts b/src/plugins/discover/public/application/main/hooks/use_saved_search_messages.test.ts index 5973d679b6b1c..4f22bf84147f1 100644 --- a/src/plugins/discover/public/application/main/hooks/use_saved_search_messages.test.ts +++ b/src/plugins/discover/public/application/main/hooks/use_saved_search_messages.test.ts @@ -16,7 +16,7 @@ import { } from './use_saved_search_messages'; import { FetchStatus } from '../../types'; import { BehaviorSubject } from 'rxjs'; -import { DataMainMsg, RecordRawType } from './use_saved_search'; +import { DataMainMsg, RecordRawType } from '../services/discover_data_state_container'; import { filter } from 'rxjs/operators'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; diff --git a/src/plugins/discover/public/application/main/hooks/use_saved_search_messages.ts b/src/plugins/discover/public/application/main/hooks/use_saved_search_messages.ts index ab121f76e15a0..cd69acf4fe21d 100644 --- a/src/plugins/discover/public/application/main/hooks/use_saved_search_messages.ts +++ b/src/plugins/discover/public/application/main/hooks/use_saved_search_messages.ts @@ -15,8 +15,7 @@ import type { DataMsg, DataTotalHits$, SavedSearchData, -} from './use_saved_search'; - +} from '../services/discover_data_state_container'; /** * Sends COMPLETE message to the main$ observable with the information * that no documents have been found, allowing Discover to show a no diff --git a/src/plugins/discover/public/application/main/hooks/use_search_session.test.ts b/src/plugins/discover/public/application/main/hooks/use_search_session.test.ts index 931d652d031c5..4f483a3e01c31 100644 --- a/src/plugins/discover/public/application/main/hooks/use_search_session.test.ts +++ b/src/plugins/discover/public/application/main/hooks/use_search_session.test.ts @@ -25,14 +25,13 @@ describe('test useSearchSession', () => { const nextId = 'id'; discoverServiceMock.data.search.session.start = jest.fn(() => nextId); - const { result } = renderHook(() => { + renderHook(() => { return useSearchSession({ services: discoverServiceMock, - history, stateContainer, savedSearch: savedSearchMock, }); }); - expect(result.current.getNextSearchSessionId()).toBe('id'); + expect(stateContainer.searchSessionManager.getNextSearchSessionId()).toBe('id'); }); }); diff --git a/src/plugins/discover/public/application/main/hooks/use_search_session.ts b/src/plugins/discover/public/application/main/hooks/use_search_session.ts index 5592993b90d55..8dadbd9015f1e 100644 --- a/src/plugins/discover/public/application/main/hooks/use_search_session.ts +++ b/src/plugins/discover/public/application/main/hooks/use_search_session.ts @@ -5,11 +5,9 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { useMemo, useEffect } from 'react'; -import { History } from 'history'; +import { useEffect } from 'react'; import { noSearchSessionStorageCapabilityMessage } from '@kbn/data-plugin/public'; import { SavedSearch } from '@kbn/saved-search-plugin/public'; -import { DiscoverSearchSessionManager } from '../services/discover_search_session'; import { createSearchSessionRestorationDataProvider, DiscoverStateContainer, @@ -18,27 +16,14 @@ import { DiscoverServices } from '../../../build_services'; export function useSearchSession({ services, - history, stateContainer, savedSearch, }: { services: DiscoverServices; stateContainer: DiscoverStateContainer; - history: History; savedSearch: SavedSearch; }) { const { data, capabilities } = services; - /** - * Search session logic - */ - const searchSessionManager = useMemo( - () => - new DiscoverSearchSessionManager({ - history, - session: data.search.session, - }), - [data.search.session, history] - ); useEffect(() => { data.search.session.enableStorage( @@ -58,6 +43,4 @@ export function useSearchSession({ } ); }, [capabilities.discover.storeSearchSession, data, savedSearch, stateContainer.appState]); - - return searchSessionManager; } diff --git a/src/plugins/discover/public/application/main/hooks/use_test_based_query_language.test.ts b/src/plugins/discover/public/application/main/hooks/use_test_based_query_language.test.ts index db94bd6638568..0f1b6488f3681 100644 --- a/src/plugins/discover/public/application/main/hooks/use_test_based_query_language.test.ts +++ b/src/plugins/discover/public/application/main/hooks/use_test_based_query_language.test.ts @@ -13,7 +13,7 @@ import { discoverServiceMock } from '../../../__mocks__/services'; import { useTextBasedQueryLanguage } from './use_text_based_query_language'; import { BehaviorSubject } from 'rxjs'; import { FetchStatus } from '../../types'; -import { DataDocuments$, RecordRawType } from './use_saved_search'; +import { DataDocuments$, RecordRawType } from '../services/discover_data_state_container'; import { DataTableRecord } from '../../../types'; import { AggregateQuery, Query } from '@kbn/es-query'; import { dataViewMock } from '../../../__mocks__/data_view'; diff --git a/src/plugins/discover/public/application/main/hooks/use_text_based_query_language.ts b/src/plugins/discover/public/application/main/hooks/use_text_based_query_language.ts index 220a3f1702faa..b47c7145904bf 100644 --- a/src/plugins/discover/public/application/main/hooks/use_text_based_query_language.ts +++ b/src/plugins/discover/public/application/main/hooks/use_text_based_query_language.ts @@ -16,7 +16,7 @@ import { useCallback, useEffect, useRef } from 'react'; import type { DataViewsContract } from '@kbn/data-views-plugin/public'; import { SavedSearch } from '@kbn/saved-search-plugin/public'; import type { DiscoverStateContainer } from '../services/discover_state'; -import type { DataDocuments$ } from './use_saved_search'; +import type { DataDocuments$ } from '../services/discover_data_state_container'; import { FetchStatus } from '../../types'; const MAX_NUM_OF_COLUMNS = 50; diff --git a/src/plugins/discover/public/application/main/services/discover_data_state_container.test.ts b/src/plugins/discover/public/application/main/services/discover_data_state_container.test.ts new file mode 100644 index 0000000000000..fcdce1b76a1a0 --- /dev/null +++ b/src/plugins/discover/public/application/main/services/discover_data_state_container.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { Subject } from 'rxjs'; +import { waitFor } from '@testing-library/react'; +import { discoverServiceMock } from '../../../__mocks__/services'; +import { savedSearchMockWithSQL } from '../../../__mocks__/saved_search'; +import { getDiscoverStateContainer } from './discover_state'; +import { FetchStatus } from '../../types'; +import { setUrlTracker } from '../../../kibana_services'; +import { urlTrackerMock } from '../../../__mocks__/url_tracker.mock'; +import { RecordRawType } from './discover_data_state_container'; +import { createBrowserHistory } from 'history'; +import { getDiscoverStateMock } from '../../../__mocks__/discover_state.mock'; + +setUrlTracker(urlTrackerMock); +describe('test getDataStateContainer', () => { + test('return is valid', async () => { + const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + const dataState = stateContainer.dataState; + + expect(dataState.refetch$).toBeInstanceOf(Subject); + expect(dataState.data$.main$.getValue().fetchStatus).toBe(FetchStatus.LOADING); + expect(dataState.data$.documents$.getValue().fetchStatus).toBe(FetchStatus.LOADING); + expect(dataState.data$.totalHits$.getValue().fetchStatus).toBe(FetchStatus.LOADING); + }); + test('refetch$ triggers a search', async () => { + const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + + discoverServiceMock.data.query.timefilter.timefilter.getTime = jest.fn(() => { + return { from: '2021-05-01T20:00:00Z', to: '2021-05-02T20:00:00Z' }; + }); + const dataState = stateContainer.dataState; + + const unsubscribe = dataState.subscribe(); + + expect(dataState.data$.totalHits$.value.result).toBe(undefined); + expect(dataState.data$.documents$.value.result).toEqual(undefined); + + dataState.refetch$.next(undefined); + await waitFor(() => { + expect(dataState.data$.main$.value.fetchStatus).toBe('complete'); + }); + + expect(dataState.data$.totalHits$.value.result).toBe(0); + expect(dataState.data$.documents$.value.result).toEqual([]); + unsubscribe(); + }); + + test('reset sets back to initial state', async () => { + const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + + discoverServiceMock.data.query.timefilter.timefilter.getTime = jest.fn(() => { + return { from: '2021-05-01T20:00:00Z', to: '2021-05-02T20:00:00Z' }; + }); + + const dataState = stateContainer.dataState; + const unsubscribe = dataState.subscribe(); + + await waitFor(() => { + expect(dataState.data$.main$.value.fetchStatus).toBe(FetchStatus.LOADING); + }); + + dataState.refetch$.next(undefined); + + await waitFor(() => { + expect(dataState.data$.main$.value.fetchStatus).toBe(FetchStatus.COMPLETE); + }); + dataState.reset(); + await waitFor(() => { + expect(dataState.data$.main$.value.fetchStatus).toBe(FetchStatus.LOADING); + }); + + unsubscribe(); + }); + + test('useSavedSearch returns plain record raw type', async () => { + const history = createBrowserHistory(); + const stateContainer = getDiscoverStateContainer({ + savedSearch: savedSearchMockWithSQL, + services: discoverServiceMock, + history, + }); + + expect(stateContainer.dataState.data$.main$.getValue().recordRawType).toBe(RecordRawType.PLAIN); + }); +}); diff --git a/src/plugins/discover/public/application/main/services/discover_data_state_container.ts b/src/plugins/discover/public/application/main/services/discover_data_state_container.ts new file mode 100644 index 0000000000000..5cadb099c483e --- /dev/null +++ b/src/plugins/discover/public/application/main/services/discover_data_state_container.ts @@ -0,0 +1,252 @@ +/* + * 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 { BehaviorSubject, filter, map, Observable, share, Subject, tap } from 'rxjs'; +import { AutoRefreshDoneFn } from '@kbn/data-plugin/public'; +import { RequestAdapter } from '@kbn/inspector-plugin/common'; +import { SavedSearch } from '@kbn/saved-search-plugin/public'; +import { AggregateQuery, Query } from '@kbn/es-query'; +import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; +import { ReduxLikeStateContainer } from '@kbn/kibana-utils-plugin/common'; +import { getRawRecordType } from '../utils/get_raw_record_type'; +import { AppState } from './discover_app_state_container'; +import { DiscoverServices } from '../../../build_services'; +import { DiscoverSearchSessionManager } from './discover_search_session'; +import { SEARCH_FIELDS_FROM_SOURCE, SEARCH_ON_PAGE_LOAD_SETTING } from '../../../../common'; +import { FetchStatus } from '../../types'; +import { validateTimeRange } from '../utils/validate_time_range'; +import { fetchAll } from '../utils/fetch_all'; +import { sendResetMsg } from '../hooks/use_saved_search_messages'; +import { getFetch$ } from '../utils/get_fetch_observable'; +import { DataTableRecord } from '../../../types'; + +export interface SavedSearchData { + main$: DataMain$; + documents$: DataDocuments$; + totalHits$: DataTotalHits$; + availableFields$: AvailableFields$; +} + +export type DataMain$ = BehaviorSubject; +export type DataDocuments$ = BehaviorSubject; +export type DataTotalHits$ = BehaviorSubject; +export type AvailableFields$ = BehaviorSubject; +export type DataFetch$ = Observable<{ + reset: boolean; + searchSessionId: string; +}>; + +export type DataRefetch$ = Subject; + +export enum RecordRawType { + /** + * Documents returned Elasticsearch, nested structure + */ + DOCUMENT = 'document', + /** + * Data returned e.g. SQL queries, flat structure + * */ + PLAIN = 'plain', +} + +export type DataRefetchMsg = 'reset' | undefined; + +export interface DataMsg { + fetchStatus: FetchStatus; + error?: Error; + recordRawType?: RecordRawType; + query?: AggregateQuery | Query | undefined; +} + +export interface DataMainMsg extends DataMsg { + foundDocuments?: boolean; +} + +export interface DataDocumentsMsg extends DataMsg { + result?: DataTableRecord[]; +} + +export interface DataTotalHitsMsg extends DataMsg { + result?: number; +} + +export interface DataChartsMessage extends DataMsg { + response?: SearchResponse; +} + +export interface DataAvailableFieldsMsg extends DataMsg { + fields?: string[]; +} + +export interface DataStateContainer { + /** + * Implicitly starting fetching data from ES + */ + fetch: () => void; + /** + * Observable emitting when a next fetch is triggered + */ + fetch$: DataFetch$; + /** + * Container of data observables (orchestration, data table, total hits, available fields) + */ + data$: SavedSearchData; + /** + * Observable triggering fetching data from ES + */ + refetch$: DataRefetch$; + /** + * Start subscribing to other observables that trigger data fetches + */ + subscribe: () => () => void; + /** + * resetting all data observable to initial state + */ + reset: () => void; + /** + * Available Inspector Adaptor allowing to get details about recent requests to ES + */ + inspectorAdapters: { requests: RequestAdapter }; + /** + * Initial fetch status + * UNINITIALIZED: data is not fetched initially, without user triggering it + * LOADING: data is fetched initially (when Discover is rendered, or data views are switched) + */ + initialFetchStatus: FetchStatus; +} +/** + * Container responsible for fetching of data in Discover Main + * Either by triggering requests to Elasticsearch directly, or by + * orchestrating unified plugins / components like the histogram + */ +export function getDataStateContainer({ + services, + searchSessionManager, + getAppState, + getSavedSearch, + appStateContainer, +}: { + services: DiscoverServices; + searchSessionManager: DiscoverSearchSessionManager; + getAppState: () => AppState; + getSavedSearch: () => SavedSearch; + appStateContainer: ReduxLikeStateContainer; +}): DataStateContainer { + const { data, uiSettings, toastNotifications } = services; + const { timefilter } = data.query.timefilter; + const inspectorAdapters = { requests: new RequestAdapter() }; + const appState = getAppState(); + const recordRawType = getRawRecordType(appState.query); + /** + * The observable to trigger data fetching in UI + * By refetch$.next('reset') rows and fieldcounts are reset to allow e.g. editing of runtime fields + * to be processed correctly + */ + const refetch$ = new Subject(); + const shouldSearchOnPageLoad = + uiSettings.get(SEARCH_ON_PAGE_LOAD_SETTING) || + getSavedSearch().id !== undefined || + !timefilter.getRefreshInterval().pause || + searchSessionManager.hasSearchSessionIdInURL(); + const initialFetchStatus = shouldSearchOnPageLoad + ? FetchStatus.LOADING + : FetchStatus.UNINITIALIZED; + + /** + * The observables the UI (aka React component) subscribes to get notified about + * the changes in the data fetching process (high level: fetching started, data was received) + */ + const initialState = { fetchStatus: initialFetchStatus, recordRawType }; + const dataSubjects: SavedSearchData = { + main$: new BehaviorSubject(initialState), + documents$: new BehaviorSubject(initialState), + totalHits$: new BehaviorSubject(initialState), + availableFields$: new BehaviorSubject(initialState), + }; + + let autoRefreshDone: AutoRefreshDoneFn | undefined; + /** + * handler emitted by `timefilter.getAutoRefreshFetch$()` + * to notify when data completed loading and to start a new autorefresh loop + */ + const setAutoRefreshDone = (fn: AutoRefreshDoneFn | undefined) => { + autoRefreshDone = fn; + }; + const fetch$ = getFetch$({ + setAutoRefreshDone, + data, + main$: dataSubjects.main$, + refetch$, + searchSource: getSavedSearch().searchSource, + searchSessionManager, + }).pipe( + filter(() => validateTimeRange(timefilter.getTime(), toastNotifications)), + tap(() => inspectorAdapters.requests.reset()), + map((val) => ({ + reset: val === 'reset', + searchSessionId: searchSessionManager.getNextSearchSessionId(), + })), + share() + ); + let abortController: AbortController; + + function subscribe() { + const subscription = fetch$.subscribe(async ({ reset, searchSessionId }) => { + abortController?.abort(); + abortController = new AbortController(); + const prevAutoRefreshDone = autoRefreshDone; + + await fetchAll(dataSubjects, getSavedSearch().searchSource, reset, { + abortController, + data, + initialFetchStatus, + inspectorAdapters, + searchSessionId, + services, + appStateContainer, + savedSearch: getSavedSearch(), + useNewFieldsApi: !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), + }); + + // If the autoRefreshCallback is still the same as when we started i.e. there was no newer call + // replacing this current one, call it to make sure we tell that the auto refresh is done + // and a new one can be scheduled. + if (autoRefreshDone === prevAutoRefreshDone) { + // if this function was set and is executed, another refresh fetch can be triggered + autoRefreshDone?.(); + autoRefreshDone = undefined; + } + }); + + return () => { + abortController?.abort(); + subscription.unsubscribe(); + }; + } + + const fetchQuery = (resetQuery?: boolean) => { + if (resetQuery) { + refetch$.next('reset'); + } else { + refetch$.next(undefined); + } + return refetch$; + }; + + const reset = () => sendResetMsg(dataSubjects, initialFetchStatus); + + return { + fetch: fetchQuery, + fetch$, + data$: dataSubjects, + refetch$, + subscribe, + reset, + inspectorAdapters, + initialFetchStatus, + }; +} diff --git a/src/plugins/discover/public/application/main/services/discover_state.test.ts b/src/plugins/discover/public/application/main/services/discover_state.test.ts index 8136ced9f4ff3..f3ed93a3394df 100644 --- a/src/plugins/discover/public/application/main/services/discover_state.test.ts +++ b/src/plugins/discover/public/application/main/services/discover_state.test.ts @@ -157,6 +157,7 @@ describe('Test discover state with legacy migration', () => { describe('createSearchSessionRestorationDataProvider', () => { let mockSavedSearch: SavedSearch = {} as unknown as SavedSearch; + history = createBrowserHistory(); const mockDataPlugin = dataPluginMock.createStartContract(); const searchSessionInfoProvider = createSearchSessionRestorationDataProvider({ data: mockDataPlugin, diff --git a/src/plugins/discover/public/application/main/services/discover_state.ts b/src/plugins/discover/public/application/main/services/discover_state.ts index 52e0d6e72fbf5..7a8493c87c1f9 100644 --- a/src/plugins/discover/public/application/main/services/discover_state.ts +++ b/src/plugins/discover/public/application/main/services/discover_state.ts @@ -29,6 +29,8 @@ import { } from '@kbn/data-plugin/public'; import { DataView } from '@kbn/data-views-plugin/public'; import { SavedSearch } from '@kbn/saved-search-plugin/public'; +import { DataStateContainer, getDataStateContainer } from './discover_data_state_container'; +import { DiscoverSearchSessionManager } from './discover_search_session'; import { DiscoverAppLocatorParams, DISCOVER_APP_LOCATOR } from '../../../../common'; import { AppState } from './discover_app_state_container'; import { @@ -52,7 +54,7 @@ interface DiscoverStateContainerParams { /** * Browser history */ - history?: History; + history: History; /** * The current savedSearch */ @@ -76,6 +78,14 @@ export interface DiscoverStateContainer { * Internal state that's used at several places in the UI */ internalState: InternalStateContainer; + /** + * Service for handling search sessions + */ + searchSessionManager: DiscoverSearchSessionManager; + /** + * Data fetching related state + **/ + dataState: DataStateContainer; /** * Initialize state with filters and query, start state syncing */ @@ -179,6 +189,14 @@ export function getDiscoverStateContainer({ ...(toasts && withNotifyOnErrors(toasts)), }); + /** + * Search session logic + */ + const searchSessionManager = new DiscoverSearchSessionManager({ + history, + session: services.data.search.session, + }); + const appStateFromUrl = cleanupUrlState(stateStorage.get(APP_STATE_URL_KEY) as AppStateUrl); let initialAppState = handleSourceColumnState( @@ -234,6 +252,17 @@ export function getDiscoverStateContainer({ } }; + const dataStateContainer = getDataStateContainer({ + services, + searchSessionManager, + getAppState: appStateContainer.getState, + getSavedSearch: () => { + // Simulating the behavior of the removed hook to always create a clean searchSource child that + // we then use to add query, filters, etc., will be removed soon. + return { ...savedSearch, searchSource: savedSearch.searchSource.createChild() }; + }, + appStateContainer, + }); const setDataView = (dataView: DataView) => { internalStateContainer.transitions.setDataView(dataView); }; @@ -255,6 +284,8 @@ export function getDiscoverStateContainer({ kbnUrlStateStorage: stateStorage, appState: appStateContainerModified, internalState: internalStateContainer, + dataState: dataStateContainer, + searchSessionManager, startSync: () => { const { start, stop } = syncAppState(); start(); diff --git a/src/plugins/discover/public/application/main/utils/fetch_all.test.ts b/src/plugins/discover/public/application/main/utils/fetch_all.test.ts index b11389c24b054..95b1cd7618b4d 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_all.test.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_all.test.ts @@ -21,8 +21,7 @@ import { DataTotalHitsMsg, RecordRawType, SavedSearchData, -} from '../hooks/use_saved_search'; - +} from '../services/discover_data_state_container'; import { fetchDocuments } from './fetch_documents'; import { fetchSql } from './fetch_sql'; import { buildDataTableRecord } from '../../../utils/build_data_record'; 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 f698d999662ce..c2a3c0856af06 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_all.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_all.ts @@ -23,7 +23,7 @@ import { import { updateSearchSource } from './update_search_source'; import { fetchDocuments } from './fetch_documents'; import { FetchStatus } from '../../types'; -import { DataMsg, RecordRawType, SavedSearchData } from '../hooks/use_saved_search'; +import { DataMsg, RecordRawType, SavedSearchData } from '../services/discover_data_state_container'; import { DiscoverServices } from '../../../build_services'; import { fetchSql } from './fetch_sql'; diff --git a/src/plugins/discover/public/application/main/utils/get_fetch_observable.ts b/src/plugins/discover/public/application/main/utils/get_fetch_observable.ts index 984d0737dfb31..71490f05ac6ce 100644 --- a/src/plugins/discover/public/application/main/utils/get_fetch_observable.ts +++ b/src/plugins/discover/public/application/main/utils/get_fetch_observable.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ import { merge } from 'rxjs'; -import { debounceTime, filter, skip, tap } from 'rxjs/operators'; +import { debounceTime, filter, tap } from 'rxjs/operators'; import type { AutoRefreshDoneFn, @@ -14,7 +14,7 @@ import type { ISearchSource, } from '@kbn/data-plugin/public'; import { FetchStatus } from '../../types'; -import { DataMain$, DataRefetch$ } from '../hooks/use_saved_search'; +import { DataMain$, DataRefetch$ } from '../services/discover_data_state_container'; import { DiscoverSearchSessionManager } from '../services/discover_search_session'; /** @@ -26,7 +26,6 @@ export function getFetch$({ main$, refetch$, searchSessionManager, - initialFetchStatus, }: { setAutoRefreshDone: (val: AutoRefreshDoneFn | undefined) => void; data: DataPublicPluginStart; @@ -34,11 +33,10 @@ export function getFetch$({ refetch$: DataRefetch$; searchSessionManager: DiscoverSearchSessionManager; searchSource: ISearchSource; - initialFetchStatus: FetchStatus; }) { const { timefilter } = data.query.timefilter; const { filterManager } = data.query; - let fetch$ = merge( + return merge( refetch$, filterManager.getFetches$(), timefilter.getFetch$(), @@ -60,13 +58,4 @@ export function getFetch$({ data.query.queryString.getUpdates$(), searchSessionManager.newSearchSessionIdFromURL$.pipe(filter((sessionId) => !!sessionId)) ).pipe(debounceTime(100)); - - /** - * Skip initial fetch when discover:searchOnPageLoad is disabled. - */ - if (initialFetchStatus === FetchStatus.UNINITIALIZED) { - fetch$ = fetch$.pipe(skip(1)); - } - - return fetch$; } diff --git a/src/plugins/discover/public/application/main/utils/get_fetch_observeable.test.ts b/src/plugins/discover/public/application/main/utils/get_fetch_observeable.test.ts index 67dc63b421706..10e1b397dc657 100644 --- a/src/plugins/discover/public/application/main/utils/get_fetch_observeable.test.ts +++ b/src/plugins/discover/public/application/main/utils/get_fetch_observeable.test.ts @@ -11,7 +11,7 @@ import { getFetch$ } from './get_fetch_observable'; import { FetchStatus } from '../../types'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { createSearchSessionMock } from '../../../__mocks__/search_session'; -import { DataRefetch$ } from '../hooks/use_saved_search'; +import { DataRefetch$ } from '../services/discover_data_state_container'; import { savedSearchMock, savedSearchMockWithTimeField } from '../../../__mocks__/saved_search'; function createDataMock( @@ -63,7 +63,6 @@ describe('getFetchObservable', () => { data: createDataMock(new Subject(), new Subject(), new Subject(), new Subject()), searchSessionManager: searchSessionManagerMock.searchSessionManager, searchSource: savedSearchMock.searchSource, - initialFetchStatus: FetchStatus.LOADING, }); fetch$.subscribe(() => { @@ -95,7 +94,6 @@ describe('getFetchObservable', () => { data: dataMock, searchSessionManager: searchSessionManagerMock.searchSessionManager, searchSource: savedSearchMockWithTimeField.searchSource, - initialFetchStatus: FetchStatus.LOADING, }); const fetchfnMock = jest.fn(); diff --git a/src/plugins/discover/public/application/main/utils/get_raw_record_type.test.ts b/src/plugins/discover/public/application/main/utils/get_raw_record_type.test.ts index 879ece28a527f..146a5a80a125f 100644 --- a/src/plugins/discover/public/application/main/utils/get_raw_record_type.test.ts +++ b/src/plugins/discover/public/application/main/utils/get_raw_record_type.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { RecordRawType } from '../hooks/use_saved_search'; +import { RecordRawType } from '../services/discover_data_state_container'; import { getRawRecordType } from './get_raw_record_type'; describe('getRawRecordType', () => { diff --git a/src/plugins/discover/public/application/main/utils/get_raw_record_type.ts b/src/plugins/discover/public/application/main/utils/get_raw_record_type.ts index 8a2fc81f06a82..356939e4f2dfd 100644 --- a/src/plugins/discover/public/application/main/utils/get_raw_record_type.ts +++ b/src/plugins/discover/public/application/main/utils/get_raw_record_type.ts @@ -12,7 +12,7 @@ import { isOfAggregateQueryType, getAggregateQueryMode, } from '@kbn/es-query'; -import { RecordRawType } from '../hooks/use_saved_search'; +import { RecordRawType } from '../services/discover_data_state_container'; export function getRawRecordType(query?: Query | AggregateQuery) { if (query && isOfAggregateQueryType(query) && getAggregateQueryMode(query) === 'sql') { diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx index 551f1d0bac626..69a8f4115a45e 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx @@ -37,7 +37,7 @@ import { SavedSearch } from '@kbn/saved-search-plugin/public'; import { METRIC_TYPE } from '@kbn/analytics'; import { VIEW_MODE } from '../../common/constants'; import { getSortForEmbeddable, SortPair } from '../utils/sorting'; -import { RecordRawType } from '../application/main/hooks/use_saved_search'; +import { RecordRawType } from '../application/main/services/discover_data_state_container'; import { buildDataTableRecord } from '../utils/build_data_record'; import { DataTableRecord, EsHitRecord } from '../types'; import { ISearchEmbeddable, SearchInput, SearchOutput } from './types'; diff --git a/src/plugins/discover/public/utils/get_sharing_data.test.ts b/src/plugins/discover/public/utils/get_sharing_data.test.ts index 103a40423dc35..d1d636a2d1c83 100644 --- a/src/plugins/discover/public/utils/get_sharing_data.test.ts +++ b/src/plugins/discover/public/utils/get_sharing_data.test.ts @@ -78,6 +78,12 @@ describe('getSharingData', () => { const { getSearchSource } = await getSharingData(searchSourceMock, {}, services); expect(getSearchSource()).toMatchInlineSnapshot(` Object { + "fields": Array [ + Object { + "field": "*", + "include_unmapped": "true", + }, + ], "index": "the-data-view-id", "sort": Array [ Object { @@ -194,10 +200,7 @@ describe('getSharingData', () => { test('fields conditionally do not have prepended timeField', async () => { services.uiSettings = { get: (key: string) => { - if (key === DOC_HIDE_TIME_COLUMN_SETTING) { - return true; - } - return false; + return key === DOC_HIDE_TIME_COLUMN_SETTING; }, } as unknown as IUiSettingsClient; diff --git a/src/plugins/discover/public/utils/get_sharing_data.ts b/src/plugins/discover/public/utils/get_sharing_data.ts index dfbdcc49e90a4..9a32a2431649b 100644 --- a/src/plugins/discover/public/utils/get_sharing_data.ts +++ b/src/plugins/discover/public/utils/get_sharing_data.ts @@ -96,11 +96,13 @@ export async function getSharingData( * Discover does not set fields, since having all fields is needed for the UI. */ const useFieldsApi = !config.get(SEARCH_FIELDS_FROM_SOURCE); - if (useFieldsApi && columns.length) { - searchSource.setField( - 'fields', - columns.map((field) => ({ field, include_unmapped: 'true' })) - ); + if (useFieldsApi) { + searchSource.removeField('fieldsFromSource'); + const fields = columns.length + ? columns.map((field) => ({ field, include_unmapped: 'true' })) + : [{ field: '*', include_unmapped: 'true' }]; + + searchSource.setField('fields', fields); } return searchSource.getSerializedFields(true); }, From 861df2d31196ef443c4da7435c718b58f94ca9a5 Mon Sep 17 00:00:00 2001 From: Cristina Amico Date: Mon, 23 Jan 2023 11:31:20 +0100 Subject: [PATCH 2/8] [Fleet] Fix missing policyId in cloud integrations install url (#149243) ## Summary Closes https://github.com/elastic/kibana/issues/148272 ### Repro steps This bug is only happening in cloud, to get into the same state in the local change [this line](https://github.com/elastic/kibana/blob/a31d8443189716b49a3a0e26746a6f10bb5b7693/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx#LL129C12-L129C12) to `true` - Setup a fresh version of kibana (since we need to show the "Ready to add your first integration?" splash screen) - Create a new agent policy, select the integrations tab and add a new integration - The splash screen should appear with url `app/fleet/integrations/endpoint-8.6.1/add-integration?policyId=fleet-first-agent-policy&useMultiPageLayout` - Click on the link `Add integration only (skip agent installation) on the bottom - It will navigate to the integration install form and the url should have the policy id (previously missing) `app/fleet/integrations/endpoint-8.6.1/add-integration?policyId=fleet-first-agent-policy`. The form should also be pre-filled with the correct agent policy in the `Existing hosts` section https://user-images.githubusercontent.com/16084106/213503514-97fd17b4-9559-49ec-a142-40c8df612002.mov Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../create_package_policy_page/multi_page_layout/index.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/index.tsx index a6cdc0842da4c..b63fa1ede3dc5 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/index.tsx @@ -50,7 +50,6 @@ const fleetManagedSteps = [installAgentStep, addIntegrationStep, confirmDataStep const standaloneSteps = [addIntegrationStep, installAgentStep, confirmDataStep]; export const CreatePackagePolicyMultiPage: CreatePackagePolicyParams = ({ - from, queryParamsPolicyId, prerelease, }) => { @@ -66,7 +65,7 @@ export const CreatePackagePolicyMultiPage: CreatePackagePolicyParams = ({ setIsManaged(newIsManaged); setCurrentStep(0); }; - + const agentPolicyId = policyId || queryParamsPolicyId; const { data: packageInfoData, error: packageInfoError, @@ -78,7 +77,7 @@ export const CreatePackagePolicyMultiPage: CreatePackagePolicyParams = ({ enrollmentAPIKey, error: agentPolicyError, isLoading: isAgentPolicyLoading, - } = useGetAgentPolicyOrDefault(queryParamsPolicyId); + } = useGetAgentPolicyOrDefault(agentPolicyId); const packageInfo = useMemo(() => packageInfoData?.item, [packageInfoData]); @@ -100,7 +99,7 @@ export const CreatePackagePolicyMultiPage: CreatePackagePolicyParams = ({ pkgkey, useMultiPageLayout: false, ...(integration ? { integration } : {}), - ...(policyId ? { agentPolicyId: policyId } : {}), + ...(agentPolicyId ? { agentPolicyId } : {}), }); if (onSplash || !packageInfo) { From 760ade3ab021c1a0f8a247ef04ea9b659f98c919 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Mon, 23 Jan 2023 06:47:03 -0400 Subject: [PATCH 3/8] [Fleet] Display agent metrics (#149119) --- .../fleet/common/experimental_features.ts | 1 + .../fleet/common/types/models/agent.ts | 6 + .../fleet/common/types/rest_spec/agent.ts | 4 + .../agent_details/agent_details_overview.tsx | 22 ++- .../agents/agent_details_page/index.tsx | 4 + .../components/agent_list_table.tsx | 144 ++++++++++---- .../sections/agents/agent_list_page/index.tsx | 10 +- .../sections/agents/components/index.tsx | 1 + .../components/metric_non_available.tsx | 45 +++++ .../components/tags.test.tsx | 0 .../{agent_list_page => }/components/tags.tsx | 23 ++- .../agents/services/agent_metrics.tsx | 40 ++++ .../public/components/link_and_revision.tsx | 94 ++++----- .../fleet/public/hooks/use_request/agents.ts | 8 +- .../fleet/server/routes/agent/handlers.ts | 30 ++- .../server/services/agents/agent_metrics.ts | 182 ++++++++++++++++++ .../fleet/server/types/rest_spec/agent.ts | 4 + .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../fleet_api_integration/apis/agents/list.ts | 93 ++++++++- 21 files changed, 609 insertions(+), 105 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/metric_non_available.tsx rename x-pack/plugins/fleet/public/applications/fleet/sections/agents/{agent_list_page => }/components/tags.test.tsx (100%) rename x-pack/plugins/fleet/public/applications/fleet/sections/agents/{agent_list_page => }/components/tags.tsx (56%) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/services/agent_metrics.tsx create mode 100644 x-pack/plugins/fleet/server/services/agents/agent_metrics.ts diff --git a/x-pack/plugins/fleet/common/experimental_features.ts b/x-pack/plugins/fleet/common/experimental_features.ts index bc39429cccfd4..7a9ede69c8605 100644 --- a/x-pack/plugins/fleet/common/experimental_features.ts +++ b/x-pack/plugins/fleet/common/experimental_features.ts @@ -17,6 +17,7 @@ export const allowedExperimentalValues = Object.freeze({ showDevtoolsRequest: true, diagnosticFileUploadEnabled: false, experimentalDataStreamSettings: false, + displayAgentMetrics: true, showIntegrationsSubcategories: false, }); diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index 94f9d9a5721b5..b3beb3d6cdec7 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -99,6 +99,11 @@ interface AgentBase { components?: FleetServerAgentComponent[]; } +export interface AgentMetrics { + cpu_avg?: number; + memory_size_byte_avg?: number; +} + export interface Agent extends AgentBase { id: string; access_api_key?: string; @@ -114,6 +119,7 @@ export interface Agent extends AgentBase { status?: AgentStatus; packages: string[]; sort?: Array; + metrics?: AgentMetrics; } export interface AgentSOAttributes extends AgentBase { diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts index e5cccb0ecb463..68b251f21e294 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -22,6 +22,7 @@ export interface GetAgentsRequest { query: ListWithKuery & { showInactive: boolean; showUpgradeable?: boolean; + withMetrics?: boolean; }; } @@ -39,6 +40,9 @@ export interface GetOneAgentRequest { params: { agentId: string; }; + query: { + withMetrics?: boolean; + }; } export interface GetOneAgentResponse { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx index 4b9c82bc248f5..b5f53d5640223 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details/agent_details_overview.tsx @@ -22,10 +22,11 @@ import { FormattedMessage, FormattedRelative } from '@kbn/i18n-react'; import type { Agent, AgentPolicy } from '../../../../../types'; import { useKibanaVersion } from '../../../../../hooks'; -import { isAgentUpgradeable } from '../../../../../services'; +import { ExperimentalFeaturesService, isAgentUpgradeable } from '../../../../../services'; import { AgentPolicySummaryLine } from '../../../../../components'; import { AgentHealth } from '../../../components'; -import { Tags } from '../../../agent_list_page/components/tags'; +import { Tags } from '../../../components/tags'; +import { formatAgentCPU, formatAgentMemory } from '../../../services/agent_metrics'; // Allows child text to be truncated const FlexItemWithMinWidth = styled(EuiFlexItem)` @@ -37,12 +38,29 @@ export const AgentDetailsOverviewSection: React.FunctionComponent<{ agentPolicy?: AgentPolicy; }> = memo(({ agent, agentPolicy }) => { const kibanaVersion = useKibanaVersion(); + const { displayAgentMetrics } = ExperimentalFeaturesService.get(); return ( {[ + ...(displayAgentMetrics + ? [ + { + title: i18n.translate('xpack.fleet.agentDetails.cpuLabel', { + defaultMessage: 'CPU', + }), + description: formatAgentCPU(agent.metrics, agentPolicy), + }, + { + title: i18n.translate('xpack.fleet.agentDetails.memoryLabel', { + defaultMessage: 'Memory', + }), + description: formatAgentMemory(agent.metrics, agentPolicy), + }, + ] + : []), { title: i18n.translate('xpack.fleet.agentDetails.statusLabel', { defaultMessage: 'Status', diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/index.tsx index 4ec4cf918a991..5ff3c8096bc0e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/index.tsx @@ -41,6 +41,7 @@ export const AgentDetailsPage: React.FunctionComponent = () => { params: { agentId, tabId = '' }, } = useRouteMatch<{ agentId: string; tabId?: string }>(); const { getHref } = useLink(); + const { displayAgentMetrics } = ExperimentalFeaturesService.get(); const { isLoading, isInitialRequest, @@ -49,6 +50,9 @@ export const AgentDetailsPage: React.FunctionComponent = () => { resendRequest: sendAgentRequest, } = useGetOneAgent(agentId, { pollIntervalMs: 5000, + query: { + withMetrics: displayAgentMetrics, + }, }); const { isLoading: isAgentPolicyLoading, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_list_table.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_list_table.tsx index 889144b045c38..1e3bb5ef50b7b 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_list_table.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_list_table.tsx @@ -5,21 +5,30 @@ * 2.0. */ import React from 'react'; -import type { CriteriaWithPagination } from '@elastic/eui'; -import { EuiBasicTable, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiText } from '@elastic/eui'; +import { type CriteriaWithPagination } from '@elastic/eui'; +import { + EuiBasicTable, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiToolTip, + EuiLink, + EuiText, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage, FormattedRelative } from '@kbn/i18n-react'; import type { Agent, AgentPolicy } from '../../../../types'; -import { isAgentUpgradeable } from '../../../../services'; +import { isAgentUpgradeable, ExperimentalFeaturesService } from '../../../../services'; import { AgentHealth } from '../../components'; import type { Pagination } from '../../../../hooks'; import { useLink, useKibanaVersion } from '../../../../hooks'; import { AgentPolicySummaryLine } from '../../../../components'; - -import { Tags } from './tags'; +import { Tags } from '../../components/tags'; +import type { AgentMetrics } from '../../../../../../../common/types'; +import { formatAgentCPU, formatAgentMemory } from '../../services/agent_metrics'; const VERSION_FIELD = 'local_metadata.elastic.agent.version'; const HOSTNAME_FIELD = 'local_metadata.host.hostname'; @@ -64,6 +73,9 @@ export const AgentListTable: React.FC = (props: Props) => { pagination, pageSizeOptions, } = props; + + const { displayAgentMetrics } = ExperimentalFeaturesService.get(); + const { getHref } = useLink(); const kibanaVersion = useKibanaVersion(); @@ -84,19 +96,6 @@ export const AgentListTable: React.FC = (props: Props) => { }; const columns = [ - { - field: HOSTNAME_FIELD, - sortable: true, - name: i18n.translate('xpack.fleet.agentList.hostColumnTitle', { - defaultMessage: 'Host', - }), - width: '185px', - render: (host: string, agent: Agent) => ( - - {safeMetadata(host)} - - ), - }, { field: 'active', sortable: false, @@ -107,13 +106,24 @@ export const AgentListTable: React.FC = (props: Props) => { render: (active: boolean, agent: any) => , }, { - field: 'tags', - sortable: false, - width: '210px', - name: i18n.translate('xpack.fleet.agentList.tagsColumnTitle', { - defaultMessage: 'Tags', + field: HOSTNAME_FIELD, + sortable: true, + name: i18n.translate('xpack.fleet.agentList.hostColumnTitle', { + defaultMessage: 'Host', }), - render: (tags: string[] = [], agent: any) => , + width: '185px', + render: (host: string, agent: Agent) => ( + + + + {safeMetadata(host)} + + + + + + + ), }, { field: 'policy_id', @@ -128,7 +138,9 @@ export const AgentListTable: React.FC = (props: Props) => { return ( - {agentPolicy && } + {agentPolicy && ( + + )} {showWarning && ( @@ -145,10 +157,80 @@ export const AgentListTable: React.FC = (props: Props) => { ); }, }, + ...(displayAgentMetrics + ? [ + { + field: 'metrics', + sortable: false, + name: ( + + } + > + + +   + + + + ), + width: '75px', + render: (metrics: AgentMetrics | undefined, agent: Agent) => + formatAgentCPU( + agent.metrics, + agent.policy_id ? agentPoliciesIndexedById[agent.policy_id] : undefined + ), + }, + { + field: 'metrics', + sortable: false, + name: ( + + } + > + + +   + + + + ), + width: '90px', + render: (metrics: AgentMetrics | undefined, agent: Agent) => + formatAgentMemory( + agent.metrics, + agent.policy_id ? agentPoliciesIndexedById[agent.policy_id] : undefined + ), + }, + ] + : []), + + { + field: 'last_checkin', + sortable: true, + name: i18n.translate('xpack.fleet.agentList.lastCheckinTitle', { + defaultMessage: 'Last activity', + }), + width: '180px', + render: (lastCheckin: string, agent: any) => + lastCheckin ? : null, + }, { field: VERSION_FIELD, sortable: true, - width: '135px', + width: '70px', name: i18n.translate('xpack.fleet.agentList.versionTitle', { defaultMessage: 'Version', }), @@ -172,16 +254,6 @@ export const AgentListTable: React.FC = (props: Props) => { ), }, - { - field: 'last_checkin', - sortable: true, - name: i18n.translate('xpack.fleet.agentList.lastCheckinTitle', { - defaultMessage: 'Last activity', - }), - width: '180px', - render: (lastCheckin: string, agent: any) => - lastCheckin ? : null, - }, { name: i18n.translate('xpack.fleet.agentList.actionsColumnTitle', { defaultMessage: 'Actions', diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index ff345e96e2945..ee77846bce93c 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -27,7 +27,11 @@ import { sendGetAgentTags, } from '../../../hooks'; import { AgentEnrollmentFlyout } from '../../../components'; -import { AgentStatusKueryHelper, policyHasFleetServer } from '../../../services'; +import { + AgentStatusKueryHelper, + ExperimentalFeaturesService, + policyHasFleetServer, +} from '../../../services'; import { AGENTS_PREFIX, SO_SEARCH_LIMIT } from '../../../constants'; import { AgentReassignAgentPolicyModal, @@ -52,6 +56,8 @@ import { EmptyPrompt } from './components/empty_prompt'; const REFRESH_INTERVAL_MS = 30000; export const AgentListPage: React.FunctionComponent<{}> = () => { + const { displayAgentMetrics } = ExperimentalFeaturesService.get(); + const { notifications, cloud } = useStartServices(); useBreadcrumbs('agent_list'); const defaultKuery: string = (useUrlParams().urlParams.kuery as string) || ''; @@ -278,6 +284,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { sortOrder, showInactive, showUpgradeable, + withMetrics: displayAgentMetrics, }), sendGetAgentStatus({ kuery: kuery && kuery !== '' ? kuery : undefined, @@ -354,6 +361,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { notifications.toasts, sortField, sortOrder, + displayAgentMetrics, ] ); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/index.tsx index cbae2c93221aa..e8bb8cf89cf2b 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/index.tsx @@ -12,3 +12,4 @@ export * from './agent_unenroll_modal'; export * from './agent_upgrade_modal'; export * from './fleet_server_callouts'; export * from './agent_policy_created_callout'; +export { MetricNonAvailable } from './metric_non_available'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/metric_non_available.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/metric_non_available.tsx new file mode 100644 index 0000000000000..26fb7e191529a --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/metric_non_available.tsx @@ -0,0 +1,45 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import type { AgentPolicy } from '../../../types'; + +export const MetricNonAvailable: React.FC<{ agentPolicy?: AgentPolicy }> = ({ agentPolicy }) => { + const isMonitoringEnabled = agentPolicy?.monitoring_enabled?.includes('metrics'); + + return ( + + + + N/A + + + + + ) : ( + + ) + } + color="subdued" + /> + + + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/tags.test.tsx similarity index 100% rename from x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.test.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/tags.test.tsx diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/tags.tsx similarity index 56% rename from x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/tags.tsx index 862706f1ad073..37f8e3295efd8 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/tags.tsx @@ -5,14 +5,15 @@ * 2.0. */ -import { EuiToolTip } from '@elastic/eui'; +import { EuiToolTip, EuiText, type EuiTextProps } from '@elastic/eui'; import { take } from 'lodash'; import React from 'react'; import styled from 'styled-components'; -import { truncateTag } from '../utils'; +import { truncateTag } from '../agent_list_page/utils'; const Wrapped = styled.div` + display: flex; .wrappedText { white-space: pre-wrap; } @@ -20,12 +21,14 @@ const Wrapped = styled.div` interface Props { tags: string[]; + color?: EuiTextProps['color']; + size?: EuiTextProps['size']; } // Number of tags displayed before "+ N more" is displayed const MAX_TAGS_TO_DISPLAY = 3; -export const Tags: React.FunctionComponent = ({ tags }) => { +export const Tags: React.FunctionComponent = ({ tags, color, size }) => { return ( <> @@ -33,12 +36,14 @@ export const Tags: React.FunctionComponent = ({ tags }) => { anchorClassName={'wrappedText'} content={{tags.join(', ')}} > - - {take(tags, 3).map(truncateTag).join(', ')} - {tags.length > MAX_TAGS_TO_DISPLAY - ? ` + ${tags.length - MAX_TAGS_TO_DISPLAY} more` - : ''} - + + + {take(tags, 3).map(truncateTag).join(', ')} + {tags.length > MAX_TAGS_TO_DISPLAY + ? ` + ${tags.length - MAX_TAGS_TO_DISPLAY} more` + : ''} + + diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/services/agent_metrics.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/services/agent_metrics.tsx new file mode 100644 index 0000000000000..dbe26fed7928a --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/services/agent_metrics.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 React from 'react'; + +import type { AgentMetrics, AgentPolicy } from '../../../../../../common/types'; + +import { MetricNonAvailable } from '../components'; + +export function formatAgentCPU(metrics?: AgentMetrics, agentPolicy?: AgentPolicy) { + return metrics?.cpu_avg && metrics?.cpu_avg !== 0 ? ( + `${(metrics.cpu_avg * 100).toFixed(2)} %` + ) : ( + + ); +} + +export function formatAgentMemory(metrics?: AgentMetrics, agentPolicy?: AgentPolicy) { + return metrics?.memory_size_byte_avg ? ( + formatBytes(metrics.memory_size_byte_avg) + ) : ( + + ); +} + +export function formatBytes(bytes: number, decimals = 0) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} diff --git a/x-pack/plugins/fleet/public/components/link_and_revision.tsx b/x-pack/plugins/fleet/public/components/link_and_revision.tsx index 64d4fa043e160..f080f01f737b6 100644 --- a/x-pack/plugins/fleet/public/components/link_and_revision.tsx +++ b/x-pack/plugins/fleet/public/components/link_and_revision.tsx @@ -16,49 +16,57 @@ import { useLink } from '../hooks'; const MIN_WIDTH: CSSProperties = { minWidth: 0 }; const NO_WRAP_WHITE_SPACE: CSSProperties = { whiteSpace: 'nowrap' }; -export const AgentPolicySummaryLine = memo<{ policy: AgentPolicy; agent?: Agent }>( - ({ policy, agent }) => { - const { getHref } = useLink(); - const { name, id, is_managed: isManaged } = policy; +export const AgentPolicySummaryLine = memo<{ + policy: AgentPolicy; + agent?: Agent; + direction?: 'column' | 'row'; +}>(({ policy, agent, direction = 'row' }) => { + const { getHref } = useLink(); + const { name, id, is_managed: isManaged } = policy; - const revision = agent ? agent.policy_revision : policy.revision; + const revision = agent ? agent.policy_revision : policy.revision; - return ( - - - - {name || id} - + return ( + + + + {name || id} + + + {isManaged && ( + + )} + {revision && ( + + + + - {isManaged && ( - - )} - {revision && ( - - - - - - )} - - ); - } -); + )} + + ); +}); diff --git a/x-pack/plugins/fleet/public/hooks/use_request/agents.ts b/x-pack/plugins/fleet/public/hooks/use_request/agents.ts index eb19daee386cd..41e82bdf69015 100644 --- a/x-pack/plugins/fleet/public/hooks/use_request/agents.ts +++ b/x-pack/plugins/fleet/public/hooks/use_request/agents.ts @@ -9,6 +9,7 @@ import type { GetActionStatusResponse, GetAgentTagsResponse, GetAgentUploadsResponse, + GetOneAgentRequest, PostBulkRequestDiagnosticsResponse, PostBulkUpdateAgentTagsRequest, PostRequestBulkDiagnosticsRequest, @@ -49,7 +50,12 @@ import type { UseRequestConfig } from './use_request'; type RequestOptions = Pick, 'pollIntervalMs'>; -export function useGetOneAgent(agentId: string, options?: RequestOptions) { +export function useGetOneAgent( + agentId: string, + options?: RequestOptions & { + query?: GetOneAgentRequest['query']; + } +) { return useRequest({ path: agentRouteService.getInfoPath(agentId), method: 'get', diff --git a/x-pack/plugins/fleet/server/routes/agent/handlers.ts b/x-pack/plugins/fleet/server/routes/agent/handlers.ts index 71e21378e6f5f..696e05e9c18bb 100644 --- a/x-pack/plugins/fleet/server/routes/agent/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/handlers.ts @@ -46,17 +46,26 @@ import type { } from '../../types'; import { defaultFleetErrorHandler } from '../../errors'; import * as AgentService from '../../services/agents'; +import { fetchAndAssignAgentMetrics } from '../../services/agents/agent_metrics'; export const getAgentHandler: RequestHandler< - TypeOf + TypeOf, + TypeOf > = async (context, request, response) => { const coreContext = await context.core; const esClient = coreContext.elasticsearch.client.asInternalUser; + const esClientCurrentUser = coreContext.elasticsearch.client.asCurrentUser; const soClient = coreContext.savedObjects.client; try { + let agent = await AgentService.getAgentById(esClient, soClient, request.params.agentId); + + if (request.query.withMetrics) { + agent = (await fetchAndAssignAgentMetrics(esClientCurrentUser, [agent]))[0]; + } + const body: GetOneAgentResponse = { - item: await AgentService.getAgentById(esClient, soClient, request.params.agentId), + item: agent, }; return response.ok({ body }); @@ -165,16 +174,11 @@ export const getAgentsHandler: RequestHandler< > = async (context, request, response) => { const coreContext = await context.core; const esClient = coreContext.elasticsearch.client.asInternalUser; + const esClientCurrentUser = coreContext.elasticsearch.client.asCurrentUser; const soClient = coreContext.savedObjects.client; try { - const { - agents, - total, - page, - perPage, - totalInactive = 0, - } = await AgentService.getAgentsByKuery(esClient, soClient, { + const agentRes = await AgentService.getAgentsByKuery(esClient, soClient, { page: request.query.page, perPage: request.query.perPage, showInactive: request.query.showInactive, @@ -185,6 +189,14 @@ export const getAgentsHandler: RequestHandler< getTotalInactive: true, }); + const { total, page, perPage, totalInactive = 0 } = agentRes; + let { agents } = agentRes; + + // Assign metrics + if (request.query.withMetrics) { + agents = await fetchAndAssignAgentMetrics(esClientCurrentUser, agents); + } + const body: GetAgentsResponse = { list: agents, // deprecated items: agents, diff --git a/x-pack/plugins/fleet/server/services/agents/agent_metrics.ts b/x-pack/plugins/fleet/server/services/agents/agent_metrics.ts new file mode 100644 index 0000000000000..0d57544d2a9bb --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agents/agent_metrics.ts @@ -0,0 +1,182 @@ +/* + * 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 { ElasticsearchClient } from '@kbn/core/server'; + +import type { Agent } from '../../types'; +import { appContextService } from '../app_context'; + +export async function fetchAndAssignAgentMetrics(esClient: ElasticsearchClient, agents: Agent[]) { + try { + return await _fetchAndAssignAgentMetrics(esClient, agents); + } catch (err) { + // Do not throw if we are not able to fetch metrics, as it could occur if the user is missing some permissions + appContextService.getLogger().warn(err); + + return agents; + } +} + +async function _fetchAndAssignAgentMetrics(esClient: ElasticsearchClient, agents: Agent[]) { + const res = await esClient.search< + unknown, + Record< + 'agents', + { + buckets: Array<{ + key: string; + sum_memory_size: { value: number }; + sum_cpu: { value: number }; + }>; + } + > + >({ + ...(aggregationQueryBuilder(agents.map(({ id }) => id)) as any), + index: 'metrics-elastic_agent.*', + }); + + const formattedResults = + res.aggregations?.agents.buckets.reduce((acc, bucket) => { + acc[bucket.key] = { + sum_memory_size: bucket.sum_memory_size.value, + sum_cpu: bucket.sum_cpu.value, + }; + return acc; + }, {} as Record) ?? {}; + + return agents.map((agent) => { + const results = formattedResults[agent.id]; + + return { + ...agent, + metrics: { + cpu_avg: results?.sum_cpu ? Math.trunc(results.sum_cpu * 10000) / 10000 : undefined, + memory_size_byte_avg: results?.sum_memory_size + ? Math.trunc(results?.sum_memory_size) + : undefined, + }, + }; + }); + + return agents; +} + +const aggregationQueryBuilder = (agentIds: string[]) => ({ + size: 0, + query: { + bool: { + must: [ + { + range: { + '@timestamp': { + gte: 'now-5m', + }, + }, + }, + { + terms: { + 'elastic_agent.id': agentIds, + }, + }, + { + bool: { + filter: [ + { + bool: { + should: [ + { + term: { + 'data_stream.dataset': 'elastic_agent.elastic_agent', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + aggs: { + agents: { + terms: { + field: 'elastic_agent.id', + }, + aggs: { + sum_memory_size: { + sum_bucket: { + buckets_path: 'processes>avg_memory_size', + }, + }, + sum_cpu: { + sum_bucket: { + buckets_path: 'processes>avg_cpu', + }, + }, + processes: { + terms: { + field: 'elastic_agent.process', + order: { + _count: 'desc', + }, + }, + aggs: { + avg_cpu: { + avg_bucket: { + buckets_path: 'cpu_time_series>cpu', + }, + }, + avg_memory_size: { + avg: { + field: 'system.process.memory.size', + }, + }, + cpu_time_series: { + date_histogram: { + field: '@timestamp', + calendar_interval: 'minute', + }, + aggs: { + max_cpu: { + max: { + field: 'system.process.cpu.total.value', + }, + }, + cpu_derivative: { + derivative: { + buckets_path: 'max_cpu', + gap_policy: 'skip', + unit: '10s', + }, + }, + cpu: { + bucket_script: { + buckets_path: { + cpu_total: 'cpu_derivative[normalized_value]', + }, + script: { + source: `if (params.cpu_total > 0) { + return params.cpu_total / params._interval + } + `, + lang: 'painless', + params: { + _interval: 10000, + }, + }, + gap_policy: 'skip', + }, + }, + }, + }, + }, + }, + }, + }, + }, +}); diff --git a/x-pack/plugins/fleet/server/types/rest_spec/agent.ts b/x-pack/plugins/fleet/server/types/rest_spec/agent.ts index 305b6f16f90d6..96227b2c33bfe 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/agent.ts @@ -20,6 +20,7 @@ export const GetAgentsRequestSchema = { perPage: schema.number({ defaultValue: 20 }), kuery: schema.maybe(schema.string()), showInactive: schema.boolean({ defaultValue: false }), + withMetrics: schema.boolean({ defaultValue: false }), showUpgradeable: schema.boolean({ defaultValue: false }), sortField: schema.maybe(schema.string()), sortOrder: schema.maybe(schema.oneOf([schema.literal('asc'), schema.literal('desc')])), @@ -38,6 +39,9 @@ export const GetOneAgentRequestSchema = { params: schema.object({ agentId: schema.string(), }), + query: schema.object({ + withMetrics: schema.boolean({ defaultValue: false }), + }), }; export const PostNewAgentActionRequestSchema = { diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 320a1850940e9..5b3940c8e809a 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -13748,7 +13748,6 @@ "xpack.fleet.agentList.statusOfflineFilterText": "Hors ligne", "xpack.fleet.agentList.statusUnhealthyFilterText": "Défectueux", "xpack.fleet.agentList.statusUpdatingFilterText": "Mise à jour", - "xpack.fleet.agentList.tagsColumnTitle": "Balises", "xpack.fleet.agentList.tagsFilterText": "Balises", "xpack.fleet.agentList.unenrollOneButton": "Désenregistrer l'agent", "xpack.fleet.agentList.upgradeOneButton": "Mettre à niveau l'agent", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d3eaf8657b1db..ed1a1d54df088 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13735,7 +13735,6 @@ "xpack.fleet.agentList.statusOfflineFilterText": "オフライン", "xpack.fleet.agentList.statusUnhealthyFilterText": "異常", "xpack.fleet.agentList.statusUpdatingFilterText": "更新中", - "xpack.fleet.agentList.tagsColumnTitle": "タグ", "xpack.fleet.agentList.tagsFilterText": "タグ", "xpack.fleet.agentList.unenrollOneButton": "エージェントの登録解除", "xpack.fleet.agentList.upgradeOneButton": "エージェントをアップグレード", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d287418196d63..41cd8383867af 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13753,7 +13753,6 @@ "xpack.fleet.agentList.statusOfflineFilterText": "脱机", "xpack.fleet.agentList.statusUnhealthyFilterText": "运行不正常", "xpack.fleet.agentList.statusUpdatingFilterText": "正在更新", - "xpack.fleet.agentList.tagsColumnTitle": "标签", "xpack.fleet.agentList.tagsFilterText": "标签", "xpack.fleet.agentList.unenrollOneButton": "取消注册代理", "xpack.fleet.agentList.upgradeOneButton": "升级代理", diff --git a/x-pack/test/fleet_api_integration/apis/agents/list.ts b/x-pack/test/fleet_api_integration/apis/agents/list.ts index cfce24d0840f3..405efd73c2ce7 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/list.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/list.ts @@ -6,6 +6,7 @@ */ import expect from '@kbn/expect'; +import { type Agent, FLEET_ELASTIC_AGENT_PACKAGE } from '@kbn/fleet-plugin/common'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { testUsers } from '../test_users'; @@ -14,13 +15,34 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertestWithoutAuth = getService('supertestWithoutAuth'); const supertest = getService('supertest'); - + const es = getService('es'); + let elasticAgentpkgVersion: string; describe('fleet_list_agent', () => { before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/fleet/agents'); + const getPkRes = await supertest + .get(`/api/fleet/epm/packages/${FLEET_ELASTIC_AGENT_PACKAGE}`) + .set('kbn-xsrf', 'xxxx') + .expect(200); + elasticAgentpkgVersion = getPkRes.body.item.version; + // Install latest version of the package + await supertest + .post(`/api/fleet/epm/packages/${FLEET_ELASTIC_AGENT_PACKAGE}/${elasticAgentpkgVersion}`) + .send({ + force: true, + }) + .set('kbn-xsrf', 'xxxx') + .expect(200); }); after(async () => { await esArchiver.unload('x-pack/test/functional/es_archives/fleet/agents'); + await supertest + .delete(`/api/fleet/epm/packages/${FLEET_ELASTIC_AGENT_PACKAGE}/${elasticAgentpkgVersion}`) + .set('kbn-xsrf', 'xxxx'); + await es.transport.request({ + method: 'DELETE', + path: `/_data_stream/metrics-elastic_agent.elastic_agent-default`, + }); }); it.skip('should return a 200 if a user with the fleet all try to access the list', async () => { @@ -106,5 +128,74 @@ export default function ({ getService }: FtrProviderContext) { const { body: apiResponse } = await supertest.get('/api/fleet/agents/tags').expect(200); expect(apiResponse.items).to.eql(['existingTag', 'tag1']); }); + + it('should return metrics if available and called with withMetrics', async () => { + await es.index({ + index: 'metrics-elastic_agent.elastic_agent-default', + refresh: 'wait_for', + document: { + '@timestamp': new Date(Date.now() - 3 * 60 * 1000).toISOString(), + data_stream: { + namespace: 'default', + type: 'metrics', + dataset: 'elastic_agent.elastic_agent', + }, + elastic_agent: { id: 'agent1', process: 'elastic_agent' }, + system: { + process: { + memory: { + size: 25510920, + }, + cpu: { + total: { + value: 500, + }, + }, + }, + }, + }, + }); + await es.index({ + index: 'metrics-elastic_agent.elastic_agent-default', + refresh: 'wait_for', + document: { + '@timestamp': new Date(Date.now() - 2 * 60 * 1000).toISOString(), + elastic_agent: { id: 'agent1', process: 'elastic_agent' }, + data_stream: { + namespace: 'default', + type: 'metrics', + dataset: 'elastic_agent.elastic_agent', + }, + system: { + process: { + memory: { + size: 25510920, + }, + cpu: { + total: { + value: 1500, + }, + }, + }, + }, + }, + }); + + const { body: apiResponse } = await supertest + .get(`/api/fleet/agents?withMetrics=true`) + .expect(200); + + expect(apiResponse).to.have.keys('page', 'total', 'items', 'list'); + expect(apiResponse.total).to.eql(4); + + const agent1: Agent = apiResponse.items.find((agent: any) => agent.id === 'agent1'); + + expect(agent1.metrics?.memory_size_byte_avg).to.eql('25510920'); + expect(agent1.metrics?.cpu_avg).to.eql('0.0166'); + + const agent2: Agent = apiResponse.items.find((agent: any) => agent.id === 'agent2'); + expect(agent2.metrics?.memory_size_byte_avg).equal(undefined); + expect(agent2.metrics?.cpu_avg).equal(undefined); + }); }); } From 192c739a902030955a5cc8adfa310f3bf49b93ed Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Mon, 23 Jan 2023 05:53:40 -0500 Subject: [PATCH 4/8] OSquery fix issue with document rejection by upgrading osquery_manager package and rolling over indices on upgrade (#148991) --- x-pack/plugins/osquery/server/plugin.ts | 6 +- .../server/utils/upgrade_integration.ts | 98 +++++++++++++++++++ x-pack/plugins/osquery/tsconfig.json | 3 + 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/osquery/server/utils/upgrade_integration.ts diff --git a/x-pack/plugins/osquery/server/plugin.ts b/x-pack/plugins/osquery/server/plugin.ts index 04417b15e22b8..6b9a6b4d9a71d 100644 --- a/x-pack/plugins/osquery/server/plugin.ts +++ b/x-pack/plugins/osquery/server/plugin.ts @@ -16,8 +16,9 @@ import type { import { SavedObjectsClient } from '@kbn/core/server'; import type { DataRequestHandlerContext } from '@kbn/data-plugin/server'; import type { DataViewsService } from '@kbn/data-views-plugin/common'; - import type { NewPackagePolicy, UpdatePackagePolicy } from '@kbn/fleet-plugin/common'; + +import { upgradeIntegration } from './utils/upgrade_integration'; import type { PackSavedObjectAttributes } from './common/types'; import { updateGlobalPacksCreateCallback } from './lib/update_global_packs'; import { packSavedObjectType } from '../common/types'; @@ -135,6 +136,9 @@ export class OsqueryPlugin implements Plugin { + let updatedPackageResult; + + if (packageInfo && satisfies(packageInfo?.version ?? '', '<1.6.0')) { + try { + logger.info('Updating osquery_manager integration'); + updatedPackageResult = await installPackage({ + installSource: 'registry', + savedObjectsClient: client, + pkgkey: pkgToPkgKey({ + name: packageInfo.name, + version: '1.6.0', // This package upgrade is specific to a bug fix, so keeping the upgrade focused on 1.6.0 + }), + esClient, + spaceId: packageInfo.installed_kibana_space_id || DEFAULT_SPACE_ID, + // Force install the package will update the index template and the datastream write indices + force: true, + }); + logger.info('osquery_manager integration updated'); + } catch (e) { + logger.error(e); + } + } + + // Check to see if the package has already been updated to at least 1.6.0 + if ( + satisfies(packageInfo?.version ?? '', '>=1.6.0') || + updatedPackageResult?.status === 'installed' + ) { + try { + // First get all datastreams matching the pattern. + const dataStreams = await esClient.indices.getDataStream({ + name: `logs-${OSQUERY_INTEGRATION_NAME}.result-*`, + }); + + // Then for each of those datastreams, we need to see if they need to rollover. + await asyncForEach(dataStreams.data_streams, async (dataStream) => { + const mapping = await esClient.indices.getMapping({ + index: dataStream.name, + }); + + const valuesToSort = Object.entries(mapping).map(([key, value]) => ({ + index: key, + mapping: value, + })); + + // Sort by index name to get the latest index for detecting if we need to rollover + const dataStreamMapping = orderBy(valuesToSort, ['index'], 'desc'); + + if ( + dataStreamMapping && + // @ts-expect-error 'properties' does not exist on type 'MappingMatchOnlyTextProperty' + dataStreamMapping[0]?.mapping?.mappings?.properties?.data_stream?.properties?.dataset + ?.value === 'generic' + ) { + logger.info('Rolling over index: ' + dataStream.name); + await esClient.indices.rollover({ + alias: dataStream.name, + }); + } + }); + } catch (e) { + logger.error(e); + } + } +}; diff --git a/x-pack/plugins/osquery/tsconfig.json b/x-pack/plugins/osquery/tsconfig.json index 7896d572e5bd5..cdacc858b4210 100644 --- a/x-pack/plugins/osquery/tsconfig.json +++ b/x-pack/plugins/osquery/tsconfig.json @@ -66,5 +66,8 @@ "@kbn/core-elasticsearch-client-server-mocks", "@kbn/std", "@kbn/ecs", + "@kbn/core-elasticsearch-server", + "@kbn/core-saved-objects-api-server", + "@kbn/logging", ] } From 57fd55bf85691a439c6be78fe9bc6062150d9338 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Jan 2023 12:42:43 +0100 Subject: [PATCH 5/8] Update dependency require-in-the-middle to v6 (main) (#149292) --- package.json | 2 +- yarn.lock | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 2c8e71e9bb61d..bb39971200715 100644 --- a/package.json +++ b/package.json @@ -669,7 +669,7 @@ "remark-gfm": "1.0.0", "remark-parse": "^8.0.3", "remark-stringify": "^8.0.3", - "require-in-the-middle": "^5.2.0", + "require-in-the-middle": "^6.0.0", "reselect": "^4.1.6", "resize-observer-polyfill": "^1.5.1", "rison-node": "1.0.2", diff --git a/yarn.lock b/yarn.lock index 9c7c37083762d..eec06d3d69577 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24032,6 +24032,15 @@ require-in-the-middle@^5.2.0: module-details-from-path "^1.0.3" resolve "^1.22.1" +require-in-the-middle@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/require-in-the-middle/-/require-in-the-middle-6.0.0.tgz#01cc6416286fb5e672d0fe031d996f8bc202509d" + integrity sha512-+dtWQ7l2lqQDxheaG3jjyN1QI37gEwvzACSgjYi4/C2y+ZTUMeRW8BIOm+9NBKvwaMBUSZfPXVOt1skB0vBkRw== + dependencies: + debug "^4.1.1" + module-details-from-path "^1.0.3" + resolve "^1.22.1" + require-main-filename@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" From b49ec7b939baa8f025a775c0c75e93b42ef07525 Mon Sep 17 00:00:00 2001 From: Elena Stoeva <59341489+ElenaStoeva@users.noreply.github.com> Date: Mon, 23 Jan 2023 11:43:25 +0000 Subject: [PATCH 6/8] Add support for S3 intelligent tiering in Snapshot and Restore (#149129) Closes https://github.com/elastic/kibana/issues/136682 ## Summary This PR adds the "intelligent_tiering" option to the Storage class field in the Repository form for AWS S3. Screenshot 2023-01-18 at 14 40 10 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../helpers/repository_add.helpers.ts | 1 + .../client_integration/repository_add.test.ts | 49 +++++++++++++++++++ .../type_settings/s3_settings.tsx | 7 ++- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/repository_add.helpers.ts b/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/repository_add.helpers.ts index ce92c211bc6af..372ba0bf88106 100644 --- a/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/repository_add.helpers.ts +++ b/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/repository_add.helpers.ts @@ -135,6 +135,7 @@ type TestSubjects = | 'stepTwo.readOnlyToggle' | 'stepTwo.submitButton' | 'stepTwo.title' + | 'storageClassSelect' | 'submitButton' | 'title' | 'urlRepositoryType'; diff --git a/x-pack/plugins/snapshot_restore/__jest__/client_integration/repository_add.test.ts b/x-pack/plugins/snapshot_restore/__jest__/client_integration/repository_add.test.ts index ea3f462040a2a..1bbb82760b3be 100644 --- a/x-pack/plugins/snapshot_restore/__jest__/client_integration/repository_add.test.ts +++ b/x-pack/plugins/snapshot_restore/__jest__/client_integration/repository_add.test.ts @@ -545,4 +545,53 @@ describe('', () => { }); }); }); + + describe('settings for s3 repository', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadRepositoryTypesResponse(repositoryTypes); + + testBed = await setup(httpSetup); + }); + + test('should correctly set the intelligent_tiering storage class', async () => { + const { form, actions, component } = testBed; + + const s3Repository = getRepository({ + type: 's3', + settings: { + bucket: 'test_bucket', + storageClass: 'intelligent_tiering', + }, + }); + + // Fill step 1 required fields and go to step 2 + form.setInputValue('nameInput', s3Repository.name); + actions.selectRepositoryType(s3Repository.type); + actions.clickNextButton(); + + // Fill step 2 + form.setInputValue('bucketInput', s3Repository.settings.bucket); + form.setSelectValue('storageClassSelect', s3Repository.settings.storageClass); + + await act(async () => { + actions.clickSubmitButton(); + }); + + component.update(); + + expect(httpSetup.put).toHaveBeenLastCalledWith( + `${API_BASE_PATH}repositories`, + expect.objectContaining({ + body: JSON.stringify({ + name: s3Repository.name, + type: s3Repository.type, + settings: { + bucket: s3Repository.settings.bucket, + storageClass: s3Repository.settings.storageClass, + }, + }), + }) + ); + }); + }); }); diff --git a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/s3_settings.tsx b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/s3_settings.tsx index ad0a30f3102cd..1ec83fd37cf5a 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/s3_settings.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/s3_settings.tsx @@ -71,7 +71,12 @@ export const S3Settings: React.FunctionComponent = ({ }); }; - const storageClassOptions = ['standard', 'reduced_redundancy', 'standard_ia'].map((option) => ({ + const storageClassOptions = [ + 'standard', + 'reduced_redundancy', + 'standard_ia', + 'intelligent_tiering', + ].map((option) => ({ value: option, text: option, })); From 378866e227f8f66930063f95c970c65ec5059936 Mon Sep 17 00:00:00 2001 From: Marco Antonio Ghiani Date: Mon, 23 Jan 2023 12:47:36 +0100 Subject: [PATCH 7/8] [Infrastructure UI] Implement TelemetryService for custom events (#149139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📓 Summary Part of #148739 This PR implements a new `TelemetryService` on the client side for the infra plugin. The purpose of this service is to centralize and provide a tested easy-to-use set of tracking functions that can be accessed anywhere inside the plugin thanks to the `useKibanaContextForPlugin` custom hook. For example, in case we want to track a custom event and its properties when a search is performed, we can create the related tracking method in the service and expose a function called `reportHostsViewQuerySubmitted`: ```ts function HostsView() { const { services: { telemetry }, } = useKibanaContextForPlugin(); const reportSearchEvent = () => { telemetry.reportHostsViewQuerySubmitted({ // ...params }) } return } ``` The implementation consist of some main components: ### infraTelemetryEvents Exported from the `telemetry_events.ts` file, this array includes the definition for schema and types of all the custom events. In case we need to define a new event for registering it, this is the place where the definition lives. ### TelemetryClient This class allows us to define those methods that we'll need on the client side and is where the event is reported using the core analytics API. ### TelemetryService This service class exposes a setup and start method to register all the custom events in the plugin and to retrieve a new instance of the TelemetryClient that will be passed into the plugin services on start. ## Next steps - Use the existing events for custom tracking on the Hosts View page. Co-authored-by: Marco Antonio Ghiani Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/infra/public/mocks.tsx | 2 + x-pack/plugins/infra/public/plugin.ts | 9 ++ .../infra/public/services/telemetry/index.ts | 10 ++ .../telemetry/telemetry_client.mock.ts | 13 ++ .../services/telemetry/telemetry_client.ts | 36 ++++++ .../services/telemetry/telemetry_events.ts | 77 +++++++++++ .../telemetry/telemetry_service.mock.ts | 10 ++ .../telemetry/telemetry_service.test.ts | 122 ++++++++++++++++++ .../services/telemetry/telemetry_service.ts | 35 +++++ .../infra/public/services/telemetry/types.ts | 59 +++++++++ x-pack/plugins/infra/public/types.ts | 2 + x-pack/plugins/infra/tsconfig.json | 2 + 12 files changed, 377 insertions(+) create mode 100644 x-pack/plugins/infra/public/services/telemetry/index.ts create mode 100644 x-pack/plugins/infra/public/services/telemetry/telemetry_client.mock.ts create mode 100644 x-pack/plugins/infra/public/services/telemetry/telemetry_client.ts create mode 100644 x-pack/plugins/infra/public/services/telemetry/telemetry_events.ts create mode 100644 x-pack/plugins/infra/public/services/telemetry/telemetry_service.mock.ts create mode 100644 x-pack/plugins/infra/public/services/telemetry/telemetry_service.test.ts create mode 100644 x-pack/plugins/infra/public/services/telemetry/telemetry_service.ts create mode 100644 x-pack/plugins/infra/public/services/telemetry/types.ts diff --git a/x-pack/plugins/infra/public/mocks.tsx b/x-pack/plugins/infra/public/mocks.tsx index 1f6496ab569ab..a3182c176da4b 100644 --- a/x-pack/plugins/infra/public/mocks.tsx +++ b/x-pack/plugins/infra/public/mocks.tsx @@ -7,10 +7,12 @@ import React from 'react'; import { createLogViewsServiceStartMock } from './services/log_views/log_views_service.mock'; +import { createTelemetryServiceMock } from './services/telemetry/telemetry_service.mock'; import { InfraClientStartExports } from './types'; export const createInfraPluginStartMock = () => ({ logViews: createLogViewsServiceStartMock(), + telemetry: createTelemetryServiceMock(), ContainerMetricsTable: () =>
, HostMetricsTable: () =>
, PodMetricsTable: () =>
, diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index c14a13d1a7ea1..1ba4b3aca880f 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -29,6 +29,7 @@ import { LogStreamEmbeddableFactoryDefinition } from './components/log_stream/lo import { createMetricsFetchData, createMetricsHasData } from './metrics_overview_fetchers'; import { registerFeatures } from './register_feature'; import { LogViewsService } from './services/log_views'; +import { TelemetryService } from './services/telemetry'; import { InfraClientCoreSetup, InfraClientCoreStart, @@ -43,6 +44,7 @@ import { getLogsHasDataFetcher, getLogsOverviewDataFetcher } from './utils/logs_ export class Plugin implements InfraClientPluginClass { public config: InfraPublicConfig; private logViews: LogViewsService; + private telemetry: TelemetryService; private readonly appUpdater$ = new BehaviorSubject(() => ({})); constructor(context: PluginInitializerContext) { @@ -51,6 +53,7 @@ export class Plugin implements InfraClientPluginClass { messageFields: this.config.sources?.default?.fields?.message ?? defaultLogViewsStaticConfig.messageFields, }); + this.telemetry = new TelemetryService(); } setup(core: InfraClientCoreSetup, pluginsSetup: InfraClientSetupDeps) { @@ -264,6 +267,9 @@ export class Plugin implements InfraClientPluginClass { return renderApp(params); }, }); + + // Setup telemetry events + this.telemetry.setup({ analytics: core.analytics }); } start(core: InfraClientCoreStart, plugins: InfraClientStartDeps) { @@ -275,8 +281,11 @@ export class Plugin implements InfraClientPluginClass { search: plugins.data.search, }); + const telemetry = this.telemetry.start(); + const startContract: InfraClientStartExports = { logViews, + telemetry, ContainerMetricsTable: createLazyContainerMetricsTable(getStartServices), HostMetricsTable: createLazyHostMetricsTable(getStartServices), PodMetricsTable: createLazyPodMetricsTable(getStartServices), diff --git a/x-pack/plugins/infra/public/services/telemetry/index.ts b/x-pack/plugins/infra/public/services/telemetry/index.ts new file mode 100644 index 0000000000000..c7cc9eb577e38 --- /dev/null +++ b/x-pack/plugins/infra/public/services/telemetry/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './telemetry_client'; +export * from './telemetry_service'; +export * from './types'; diff --git a/x-pack/plugins/infra/public/services/telemetry/telemetry_client.mock.ts b/x-pack/plugins/infra/public/services/telemetry/telemetry_client.mock.ts new file mode 100644 index 0000000000000..3626f324004c9 --- /dev/null +++ b/x-pack/plugins/infra/public/services/telemetry/telemetry_client.mock.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 { ITelemetryClient } from './types'; + +export const createLogViewsClientMock = (): jest.Mocked => ({ + reportHostEntryClicked: jest.fn(), + reportHostsViewQuerySubmitted: jest.fn(), +}); diff --git a/x-pack/plugins/infra/public/services/telemetry/telemetry_client.ts b/x-pack/plugins/infra/public/services/telemetry/telemetry_client.ts new file mode 100644 index 0000000000000..642611001b287 --- /dev/null +++ b/x-pack/plugins/infra/public/services/telemetry/telemetry_client.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AnalyticsServiceSetup } from '@kbn/core-analytics-server'; +import { + HostEntryClickedParams, + HostsViewQuerySubmittedParams, + InfraTelemetryEventTypes, + ITelemetryClient, +} from './types'; + +/** + * Client which aggregate all the available telemetry tracking functions + * for the Infra plugin + */ +export class TelemetryClient implements ITelemetryClient { + constructor(private analytics: AnalyticsServiceSetup) {} + + public reportHostEntryClicked = ({ + hostname, + cloud_provider: cloudProvider = 'unknown', + }: HostEntryClickedParams) => { + this.analytics.reportEvent(InfraTelemetryEventTypes.HOSTS_ENTRY_CLICKED, { + hostname, + cloud_provider: cloudProvider, + }); + }; + + public reportHostsViewQuerySubmitted = (params: HostsViewQuerySubmittedParams) => { + this.analytics.reportEvent(InfraTelemetryEventTypes.HOSTS_VIEW_QUERY_SUBMITTED, params); + }; +} diff --git a/x-pack/plugins/infra/public/services/telemetry/telemetry_events.ts b/x-pack/plugins/infra/public/services/telemetry/telemetry_events.ts new file mode 100644 index 0000000000000..52380cbec3d5b --- /dev/null +++ b/x-pack/plugins/infra/public/services/telemetry/telemetry_events.ts @@ -0,0 +1,77 @@ +/* + * 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 { + InfraTelemetryEventTypes, + InfraTelemetryEvent, + HostsViewQuerySubmittedSchema, + HostEntryClickedSchema, +} from './types'; + +const hostsViewQuerySubmittedSchema: HostsViewQuerySubmittedSchema = { + control_filters: { + type: 'array', + items: { + type: 'text', + _meta: { + description: 'Selected host control filter.', + optional: false, + }, + }, + }, + filters: { + type: 'array', + items: { + type: 'text', + _meta: { + description: 'Applied host search filter.', + optional: false, + }, + }, + }, + interval: { + type: 'text', + _meta: { + description: 'Time interval for the performed search.', + optional: false, + }, + }, + query: { + type: 'text', + _meta: { + description: 'KQL query search for hosts', + optional: false, + }, + }, +}; + +const hostsEntryClickedSchema: HostEntryClickedSchema = { + hostname: { + type: 'keyword', + _meta: { + description: 'Hostname for the clicked host.', + optional: false, + }, + }, + cloud_provider: { + type: 'keyword', + _meta: { + description: 'Cloud provider for the clicked host.', + optional: true, + }, + }, +}; + +export const infraTelemetryEvents: InfraTelemetryEvent[] = [ + { + eventType: InfraTelemetryEventTypes.HOSTS_VIEW_QUERY_SUBMITTED, + schema: hostsViewQuerySubmittedSchema, + }, + { + eventType: InfraTelemetryEventTypes.HOSTS_ENTRY_CLICKED, + schema: hostsEntryClickedSchema, + }, +]; diff --git a/x-pack/plugins/infra/public/services/telemetry/telemetry_service.mock.ts b/x-pack/plugins/infra/public/services/telemetry/telemetry_service.mock.ts new file mode 100644 index 0000000000000..acdea758aa2ff --- /dev/null +++ b/x-pack/plugins/infra/public/services/telemetry/telemetry_service.mock.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 { createLogViewsClientMock } from './telemetry_client.mock'; + +export const createTelemetryServiceMock = () => createLogViewsClientMock(); diff --git a/x-pack/plugins/infra/public/services/telemetry/telemetry_service.test.ts b/x-pack/plugins/infra/public/services/telemetry/telemetry_service.test.ts new file mode 100644 index 0000000000000..d3516fc84600b --- /dev/null +++ b/x-pack/plugins/infra/public/services/telemetry/telemetry_service.test.ts @@ -0,0 +1,122 @@ +/* + * 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 { coreMock } from '@kbn/core/server/mocks'; +import { infraTelemetryEvents } from './telemetry_events'; + +import { TelemetryService } from './telemetry_service'; +import { InfraTelemetryEventTypes } from './types'; + +describe('TelemetryService', () => { + let service: TelemetryService; + + beforeEach(() => { + service = new TelemetryService(); + }); + + const getSetupParams = () => { + const mockCoreStart = coreMock.createSetup(); + return { + analytics: mockCoreStart.analytics, + }; + }; + + describe('#setup()', () => { + it('should register all the custom events', () => { + const setupParams = getSetupParams(); + service.setup(setupParams); + + expect(setupParams.analytics.registerEventType).toHaveBeenCalledTimes( + infraTelemetryEvents.length + ); + + infraTelemetryEvents.forEach((eventConfig, pos) => { + expect(setupParams.analytics.registerEventType).toHaveBeenNthCalledWith( + pos + 1, + eventConfig + ); + }); + }); + }); + + describe('#start()', () => { + it('should return all the available tracking methods', () => { + const setupParams = getSetupParams(); + service.setup(setupParams); + const telemetry = service.start(); + + expect(telemetry).toHaveProperty('reportHostEntryClicked'); + expect(telemetry).toHaveProperty('reportHostsViewQuerySubmitted'); + }); + }); + + describe('#reportHostEntryClicked', () => { + it('should report hosts entry click with properties', async () => { + const setupParams = getSetupParams(); + service.setup(setupParams); + const telemetry = service.start(); + + telemetry.reportHostEntryClicked({ + hostname: 'hostname.test', + cloud_provider: 'gcp', + }); + + expect(setupParams.analytics.reportEvent).toHaveBeenCalledTimes(1); + expect(setupParams.analytics.reportEvent).toHaveBeenCalledWith( + InfraTelemetryEventTypes.HOSTS_ENTRY_CLICKED, + { + hostname: 'hostname.test', + cloud_provider: 'gcp', + } + ); + }); + + it('should report hosts entry click with cloud provider equal to "unknow" if not exist', async () => { + const setupParams = getSetupParams(); + service.setup(setupParams); + const telemetry = service.start(); + + telemetry.reportHostEntryClicked({ + hostname: 'hostname.test', + }); + + expect(setupParams.analytics.reportEvent).toHaveBeenCalledTimes(1); + expect(setupParams.analytics.reportEvent).toHaveBeenCalledWith( + InfraTelemetryEventTypes.HOSTS_ENTRY_CLICKED, + { + hostname: 'hostname.test', + cloud_provider: 'unknown', + } + ); + }); + }); + + describe('#reportHostsViewQuerySubmitted', () => { + it('should report hosts query and filtering submission with properties', async () => { + const setupParams = getSetupParams(); + service.setup(setupParams); + const telemetry = service.start(); + + telemetry.reportHostsViewQuerySubmitted({ + control_filters: ['test-filter'], + filters: [], + interval: 'interval(now-1h)', + query: '', + }); + + expect(setupParams.analytics.reportEvent).toHaveBeenCalledTimes(1); + expect(setupParams.analytics.reportEvent).toHaveBeenCalledWith( + InfraTelemetryEventTypes.HOSTS_VIEW_QUERY_SUBMITTED, + { + control_filters: ['test-filter'], + filters: [], + interval: 'interval(now-1h)', + query: '', + } + ); + }); + }); +}); diff --git a/x-pack/plugins/infra/public/services/telemetry/telemetry_service.ts b/x-pack/plugins/infra/public/services/telemetry/telemetry_service.ts new file mode 100644 index 0000000000000..be58505d6fc77 --- /dev/null +++ b/x-pack/plugins/infra/public/services/telemetry/telemetry_service.ts @@ -0,0 +1,35 @@ +/* + * 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 { AnalyticsServiceSetup } from '@kbn/core-analytics-server'; +import { TelemetryServiceSetupParams, ITelemetryClient, InfraTelemetryEventParams } from './types'; +import { infraTelemetryEvents } from './telemetry_events'; +import { TelemetryClient } from './telemetry_client'; + +/** + * Service that interacts with the Core's analytics module + * to trigger custom event for the Infra plugin features + */ +export class TelemetryService { + constructor(private analytics: AnalyticsServiceSetup | null = null) {} + + public setup({ analytics }: TelemetryServiceSetupParams) { + this.analytics = analytics; + infraTelemetryEvents.forEach((eventConfig) => + analytics.registerEventType(eventConfig) + ); + } + + public start(): ITelemetryClient { + if (!this.analytics) { + throw new Error( + 'The TelemetryService.setup() method has not been invoked, be sure to call it during the plugin setup.' + ); + } + + return new TelemetryClient(this.analytics); + } +} diff --git a/x-pack/plugins/infra/public/services/telemetry/types.ts b/x-pack/plugins/infra/public/services/telemetry/types.ts new file mode 100644 index 0000000000000..9f92f9e614810 --- /dev/null +++ b/x-pack/plugins/infra/public/services/telemetry/types.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 type { SchemaArray, SchemaValue } from '@kbn/analytics-client'; +import type { AnalyticsServiceSetup } from '@kbn/core/public'; + +export interface TelemetryServiceSetupParams { + analytics: AnalyticsServiceSetup; +} + +export enum InfraTelemetryEventTypes { + HOSTS_VIEW_QUERY_SUBMITTED = 'Hosts View Query Submitted', + HOSTS_ENTRY_CLICKED = 'Host Entry Clicked', +} + +export interface HostsViewQuerySubmittedParams { + control_filters: string[]; + filters: string[]; + interval: string; + query: string; +} + +export interface HostsViewQuerySubmittedSchema { + control_filters: SchemaArray; + filters: SchemaArray; + interval: SchemaValue; + query: SchemaValue; +} + +export interface HostEntryClickedParams { + hostname: string; + cloud_provider?: string; +} + +export interface HostEntryClickedSchema { + hostname: SchemaValue; + cloud_provider: SchemaValue; +} + +export interface ITelemetryClient { + reportHostEntryClicked(params: HostEntryClickedParams): void; + reportHostsViewQuerySubmitted(params: HostsViewQuerySubmittedParams): void; +} + +export type InfraTelemetryEventParams = HostsViewQuerySubmittedParams | HostEntryClickedParams; + +export type InfraTelemetryEvent = + | { + eventType: InfraTelemetryEventTypes.HOSTS_VIEW_QUERY_SUBMITTED; + schema: HostsViewQuerySubmittedSchema; + } + | { + eventType: InfraTelemetryEventTypes.HOSTS_ENTRY_CLICKED; + schema: HostEntryClickedSchema; + }; diff --git a/x-pack/plugins/infra/public/types.ts b/x-pack/plugins/infra/public/types.ts index be8c4d877c61c..e7223f513d3dc 100644 --- a/x-pack/plugins/infra/public/types.ts +++ b/x-pack/plugins/infra/public/types.ts @@ -35,12 +35,14 @@ import type { UseNodeMetricsTableOptions, } from './components/infrastructure_node_metrics_tables/shared'; import { LogViewsServiceStart } from './services/log_views'; +import { ITelemetryClient } from './services/telemetry'; // Our own setup and start contract values export type InfraClientSetupExports = void; export interface InfraClientStartExports { logViews: LogViewsServiceStart; + telemetry: ITelemetryClient; ContainerMetricsTable: ( props: UseNodeMetricsTableOptions & Partial ) => JSX.Element; diff --git a/x-pack/plugins/infra/tsconfig.json b/x-pack/plugins/infra/tsconfig.json index 328cbb5bdb6d0..241127de9d072 100644 --- a/x-pack/plugins/infra/tsconfig.json +++ b/x-pack/plugins/infra/tsconfig.json @@ -53,6 +53,8 @@ "@kbn/logging-mocks", "@kbn/field-types", "@kbn/es-types", + "@kbn/core-analytics-server", + "@kbn/analytics-client", ], "exclude": [ "target/**/*", From d614aff54636948fc0b096e950989678bc5e107a Mon Sep 17 00:00:00 2001 From: Konrad Szwarc Date: Mon, 23 Jan 2023 13:26:06 +0100 Subject: [PATCH 8/8] [Defend Workflows] [Fix] Response console privilege error (#149185) --- .../help_command_argument.tsx | 9 ++++-- .../console/components/command_usage.tsx | 30 +++++++++++++++++-- .../handle_execute_command.tsx | 16 +++++++++- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/builtin_commands/help_command_argument.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/builtin_commands/help_command_argument.tsx index 1c74ee178bfcc..17cdd39e7177e 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/builtin_commands/help_command_argument.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/builtin_commands/help_command_argument.tsx @@ -15,7 +15,9 @@ import type { CommandExecutionComponentProps } from '../../types'; /** * Builtin component that handles the output of command's `--help` argument */ -export const HelpCommandArgument = memo((props) => { +export const HelpCommandArgument = memo< + CommandExecutionComponentProps<{}, { errorMessage?: string }> +>((props) => { const CustomCommandHelp = props.command.commandDefinition.HelpComponent; useEffect(() => { @@ -37,7 +39,10 @@ export const HelpCommandArgument = memo((props) } )} > - + ); }); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx index a79cc3cd7ad54..eae2a34b86143 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx @@ -5,14 +5,16 @@ * 2.0. */ -import React, { memo, useMemo } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { EuiDescriptionList, EuiPanel, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import { ConsoleCodeBlock } from './console_code_block'; import { getArgumentsForCommand } from '../service/parsed_command_input'; import type { CommandDefinition } from '../types'; import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; +import { UnsupportedMessageCallout } from './unsupported_message_callout'; const additionalProps = { className: 'euiTruncateText', @@ -80,9 +82,10 @@ CommandInputUsage.displayName = 'CommandInputUsage'; export interface CommandUsageProps { commandDef: CommandDefinition; + errorMessage?: string; } -export const CommandUsage = memo(({ commandDef }) => { +export const CommandUsage = memo(({ commandDef, errorMessage }) => { const getTestId = useTestIdGenerator(useDataTestSubj()); const hasArgs = useMemo(() => Object.keys(commandDef.args ?? []).length > 0, [commandDef.args]); @@ -156,8 +159,31 @@ export const CommandUsage = memo(({ commandDef }) => { ); }; + const renderErrorMessage = useCallback(() => { + if (!errorMessage) { + return null; + } + return ( + + + + } + data-test-subj={getTestId('validationError')} + > +
{errorMessage}
+ +
+ ); + }, [errorMessage, getTestId]); + return ( + {renderErrorMessage()}