diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts index 6f9f40745322b..947b062b3a551 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.test.ts @@ -7,7 +7,10 @@ */ import { + ContactCardEmbeddable, + ContactCardEmbeddableFactory, ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, CONTACT_CARD_EMBEDDABLE, } from '@kbn/embeddable-plugin/public/lib/test_samples'; import { @@ -253,3 +256,63 @@ test('creates a control group from the control group factory and waits for it to ); expect(mockControlGroupContainer.untilInitialized).toHaveBeenCalled(); }); + +/* + * dashboard.getInput$() subscriptions are used to update: + * 1) dashboard instance searchSessionId state + * 2) child input on parent input changes + * + * Rxjs subscriptions are executed in the order that they are created. + * This test ensures that searchSessionId update subscription is created before child input subscription + * to ensure child input subscription includes updated searchSessionId. + */ +test('searchSessionId is updated prior to child embeddable parent subscription execution', async () => { + const embeddableFactory = { + create: new ContactCardEmbeddableFactory((() => null) as any, {} as any), + getDefaultInput: jest.fn().mockResolvedValue({ + timeRange: { + to: 'now', + from: 'now-15m', + }, + }), + }; + pluginServices.getServices().embeddable.getEmbeddableFactory = jest + .fn() + .mockReturnValue(embeddableFactory); + let sessionCount = 0; + pluginServices.getServices().data.search.session.start = () => { + sessionCount++; + return `searchSessionId${sessionCount}`; + }; + const dashboard = await createDashboard(embeddableId, { + searchSessionSettings: { + getSearchSessionIdFromURL: () => undefined, + removeSessionIdFromUrl: () => {}, + createSessionRestorationDataProvider: () => {}, + } as unknown as DashboardCreationOptions['searchSessionSettings'], + }); + const embeddable = await dashboard.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Bob', + }); + + expect(embeddable.getInput().searchSessionId).toBe('searchSessionId1'); + + dashboard.updateInput({ + timeRange: { + to: 'now', + from: 'now-7d', + }, + }); + + expect(sessionCount).toBeGreaterThan(1); + const embeddableInput = embeddable.getInput(); + expect((embeddableInput as any).timeRange).toEqual({ + to: 'now', + from: 'now-7d', + }); + expect(embeddableInput.searchSessionId).toBe(`searchSessionId${sessionCount}`); +}); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts index f0a20e832e431..ef810f025b84b 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts @@ -217,6 +217,7 @@ export const createDashboard = async ( // -------------------------------------------------------------------------------------- // Set up search sessions integration. // -------------------------------------------------------------------------------------- + let initialSearchSessionId; if (searchSessionSettings) { const { sessionIdToRestore } = searchSessionSettings; @@ -229,7 +230,7 @@ export const createDashboard = async ( } const existingSession = session.getSessionId(); - const initialSearchSessionId = + initialSearchSessionId = sessionIdToRestore ?? (existingSession && incomingEmbeddable ? existingSession : session.start()); @@ -238,7 +239,6 @@ export const createDashboard = async ( creationOptions?.searchSessionSettings ); }); - initialInput.searchSessionId = initialSearchSessionId; } // -------------------------------------------------------------------------------------- @@ -284,6 +284,7 @@ export const createDashboard = async ( const dashboardContainer = new DashboardContainer( initialInput, reduxEmbeddablePackage, + initialSearchSessionId, savedObjectResult?.dashboardInput, dashboardCreationStartTime, undefined, diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/search_sessions/start_dashboard_search_session_integration.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/search_sessions/start_dashboard_search_session_integration.ts index 506083ab25386..7f59b56c228b6 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/search_sessions/start_dashboard_search_session_integration.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/search_sessions/start_dashboard_search_session_integration.ts @@ -6,14 +6,13 @@ * Side Public License, v 1. */ -import { debounceTime, pairwise, skip } from 'rxjs/operators'; +import { pairwise, skip } from 'rxjs/operators'; import { noSearchSessionStorageCapabilityMessage } from '@kbn/data-plugin/public'; import { DashboardContainer } from '../../dashboard_container'; import { DashboardContainerInput } from '../../../../../common'; import { pluginServices } from '../../../../services/plugin_services'; -import { CHANGE_CHECK_DEBOUNCE } from '../../../../dashboard_constants'; import { DashboardCreationOptions } from '../../dashboard_container_factory'; import { getShouldRefresh } from '../../../state/diffing/dashboard_diffing_integration'; @@ -57,10 +56,10 @@ export function startDashboardSearchSessionIntegration( // listen to and compare states to determine when to launch a new session. this.getInput$() - .pipe(pairwise(), debounceTime(CHANGE_CHECK_DEBOUNCE)) - .subscribe(async (states) => { + .pipe(pairwise()) + .subscribe((states) => { const [previous, current] = states as DashboardContainerInput[]; - const shouldRefetch = await getShouldRefresh.bind(this)(previous, current); + const shouldRefetch = getShouldRefresh.bind(this)(previous, current); if (!shouldRefetch) return; const currentSearchSessionId = this.getState().explicitInput.searchSessionId; @@ -83,7 +82,7 @@ export function startDashboardSearchSessionIntegration( })(); if (updatedSearchSessionId && updatedSearchSessionId !== currentSearchSessionId) { - this.dispatch.setSearchSessionId(updatedSearchSessionId); + this.searchSessionId = updatedSearchSessionId; } }); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx index bdce2754ba0dd..5a360446f03a8 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { mount } from 'enzyme'; +import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks'; import { findTestSubject, nextTick } from '@kbn/test-jest-helpers'; import { I18nProvider } from '@kbn/i18n-react'; import { @@ -29,9 +30,10 @@ import { applicationServiceMock, coreMock } from '@kbn/core/public/mocks'; import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks'; import { createEditModeActionDefinition } from '@kbn/embeddable-plugin/public/lib/test_samples'; -import { buildMockDashboard, getSampleDashboardPanel } from '../../mocks'; +import { buildMockDashboard, getSampleDashboardInput, getSampleDashboardPanel } from '../../mocks'; import { pluginServices } from '../../services/plugin_services'; import { ApplicationStart } from '@kbn/core-application-browser'; +import { DashboardContainer } from './dashboard_container'; const theme = coreMock.createStart().theme; let application: ApplicationStart | undefined; @@ -171,7 +173,11 @@ test('Container view mode change propagates to new children', async () => { test('searchSessionId propagates to children', async () => { const searchSessionId1 = 'searchSessionId1'; - const container = buildMockDashboard({ searchSessionId: searchSessionId1 }); + const container = new DashboardContainer( + getSampleDashboardInput(), + mockedReduxEmbeddablePackage, + searchSessionId1 + ); const embeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, ContactCardEmbeddableOutput, @@ -181,11 +187,6 @@ test('searchSessionId propagates to children', async () => { }); expect(embeddable.getInput().searchSessionId).toBe(searchSessionId1); - - const searchSessionId2 = 'searchSessionId2'; - container.updateInput({ searchSessionId: searchSessionId2 }); - - expect(embeddable.getInput().searchSessionId).toBe(searchSessionId2); }); test('DashboardContainer in edit mode shows edit mode actions', async () => { diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx index d5a5385e779b3..a0aec2c395524 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -95,6 +95,8 @@ export class DashboardContainer extends Container void; private cleanupStateTools: () => void; @@ -117,6 +119,7 @@ export class DashboardContainer extends Container - ) => { - state.explicitInput.searchSessionId = action.payload; - }, - // ------------------------------------------------------------------------------ // Unsaved Changes Reducers // ------------------------------------------------------------------------------ diff --git a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_functions.ts b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_functions.ts index 7f2a55044b527..fe8e18528e2c0 100644 --- a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_functions.ts +++ b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_functions.ts @@ -37,7 +37,7 @@ export type DashboardDiffFunctions = { ) => boolean | Promise; }; -export const isKeyEqual = async ( +export const isKeyEqualAsync = async ( key: keyof DashboardContainerInput, diffFunctionProps: DiffFunctionProps, diffingFunctions: DashboardDiffFunctions @@ -52,6 +52,25 @@ export const isKeyEqual = async ( return fastIsEqual(diffFunctionProps.currentValue, diffFunctionProps.lastValue); }; +export const isKeyEqual = ( + key: keyof Omit, // only Panels is async + diffFunctionProps: DiffFunctionProps, + diffingFunctions: DashboardDiffFunctions +) => { + const propsAsNever = diffFunctionProps as never; // todo figure out why props has conflicting types in some constituents. + const diffingFunction = diffingFunctions[key]; + if (!diffingFunction) { + return fastIsEqual(diffFunctionProps.currentValue, diffFunctionProps.lastValue); + } + + if (diffingFunction?.prototype?.name === 'AsyncFunction') { + throw new Error( + `The function for key "${key}" is async, must use isKeyEqualAsync for asynchronous functions` + ); + } + return diffingFunction(propsAsNever); +}; + /** * A collection of functions which diff individual keys of dashboard state. If a key is missing from this list it is * diffed by the default diffing function, fastIsEqual. diff --git a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.test.ts b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.test.ts index c0953f8bbc98a..b79eb27af3d79 100644 --- a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.test.ts +++ b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.test.ts @@ -29,14 +29,14 @@ describe('getShouldRefresh', () => { ); describe('filter changes', () => { - test('should return false when filters do not change', async () => { + test('should return false when filters do not change', () => { const lastInput = { filters: [existsFilter], } as unknown as DashboardContainerInput; - expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, lastInput)).toBe(false); + expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, lastInput)).toBe(false); }); - test('should return true when pinned filters change', async () => { + test('should return true when pinned filters change', () => { const pinnedFilter = pinFilter(existsFilter); const lastInput = { filters: [pinnedFilter], @@ -44,10 +44,10 @@ describe('getShouldRefresh', () => { const input = { filters: [toggleFilterNegated(pinnedFilter)], } as unknown as DashboardContainerInput; - expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true); + expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true); }); - test('should return false when disabled filters change', async () => { + test('should return false when disabled filters change', () => { const disabledFilter = disableFilter(existsFilter); const lastInput = { filters: [disabledFilter], @@ -55,29 +55,29 @@ describe('getShouldRefresh', () => { const input = { filters: [toggleFilterNegated(disabledFilter)], } as unknown as DashboardContainerInput; - expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(false); + expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(false); }); - test('should return false when pinned filter changes to unpinned', async () => { + test('should return false when pinned filter changes to unpinned', () => { const lastInput = { filters: [existsFilter], } as unknown as DashboardContainerInput; const input = { filters: [pinFilter(existsFilter)], } as unknown as DashboardContainerInput; - expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(false); + expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(false); }); }); describe('timeRange changes', () => { - test('should return false when timeRange does not change', async () => { + test('should return false when timeRange does not change', () => { const lastInput = { timeRange: { from: 'now-15m', to: 'now' }, } as unknown as DashboardContainerInput; - expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, lastInput)).toBe(false); + expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, lastInput)).toBe(false); }); - test('should return true when timeRange changes (timeRestore is true)', async () => { + test('should return true when timeRange changes (timeRestore is true)', () => { const lastInput = { timeRange: { from: 'now-15m', to: 'now' }, timeRestore: true, @@ -86,10 +86,10 @@ describe('getShouldRefresh', () => { timeRange: { from: 'now-30m', to: 'now' }, timeRestore: true, } as unknown as DashboardContainerInput; - expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true); + expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true); }); - test('should return true when timeRange changes (timeRestore is false)', async () => { + test('should return true when timeRange changes (timeRestore is false)', () => { const lastInput = { timeRange: { from: 'now-15m', to: 'now' }, timeRestore: false, @@ -98,7 +98,26 @@ describe('getShouldRefresh', () => { timeRange: { from: 'now-30m', to: 'now' }, timeRestore: false, } as unknown as DashboardContainerInput; - expect(await getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true); + expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true); + }); + }); + + describe('key without custom diffing function (syncColors)', () => { + test('should return false when syncColors do not change', () => { + const lastInput = { + syncColors: false, + } as unknown as DashboardContainerInput; + expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, lastInput)).toBe(false); + }); + + test('should return true when syncColors change', () => { + const lastInput = { + syncColors: false, + } as unknown as DashboardContainerInput; + const input = { + syncColors: true, + } as unknown as DashboardContainerInput; + expect(getShouldRefresh.bind(dashboardContainerMock)(lastInput, input)).toBe(true); }); }); }); diff --git a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts index f91cfe51fe739..897ac529fe61d 100644 --- a/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts +++ b/src/plugins/dashboard/public/dashboard_container/state/diffing/dashboard_diffing_integration.ts @@ -9,13 +9,13 @@ import { omit } from 'lodash'; import { AnyAction, Middleware } from 'redux'; import { debounceTime, Observable, startWith, Subject, switchMap } from 'rxjs'; -import { DashboardContainerInput } from '../../../../common'; -import type { DashboardDiffFunctions } from './dashboard_diffing_functions'; import { isKeyEqual, + isKeyEqualAsync, shouldRefreshDiffingFunctions, unsavedChangesDiffingFunctions, } from './dashboard_diffing_functions'; +import { DashboardContainerInput } from '../../../../common'; import { pluginServices } from '../../../services/plugin_services'; import { DashboardContainer, DashboardCreationOptions } from '../..'; import { CHANGE_CHECK_DEBOUNCE } from '../../../dashboard_constants'; @@ -29,7 +29,6 @@ import { dashboardContainerReducers } from '../dashboard_container_reducers'; export const reducersToIgnore: Array = [ 'setTimeslice', 'setFullScreenMode', - 'setSearchSessionId', 'setExpandedPanelId', 'setHasUnsavedChanges', ]; @@ -40,7 +39,6 @@ export const reducersToIgnore: Array = const keysToOmitFromSessionStorage: Array = [ 'lastReloadRequestTime', 'executionContext', - 'searchSessionId', 'timeslice', 'id', @@ -55,7 +53,6 @@ const keysToOmitFromSessionStorage: Array = [ export const keysNotConsideredUnsavedChanges: Array = [ 'lastReloadRequestTime', 'executionContext', - 'searchSessionId', 'timeslice', 'viewMode', 'id', @@ -64,7 +61,7 @@ export const keysNotConsideredUnsavedChanges: Array = [ +const sessionChangeKeys: Array> = [ 'query', 'filters', 'timeRange', @@ -139,42 +136,17 @@ export async function getUnsavedChanges( const allKeys = [...new Set([...Object.keys(lastInput), ...Object.keys(input)])] as Array< keyof DashboardContainerInput >; - return await getInputChanges(this, lastInput, input, allKeys, unsavedChangesDiffingFunctions); -} - -export async function getShouldRefresh( - this: DashboardContainer, - lastInput: DashboardContainerInput, - input: DashboardContainerInput -): Promise { - const inputChanges = await getInputChanges( - this, - lastInput, - input, - refetchKeys, - shouldRefreshDiffingFunctions - ); - return Object.keys(inputChanges).length > 0; -} - -async function getInputChanges( - container: DashboardContainer, - lastInput: DashboardContainerInput, - input: DashboardContainerInput, - keys: Array, - diffingFunctions: DashboardDiffFunctions -): Promise> { - const keyComparePromises = keys.map( + const keyComparePromises = allKeys.map( (key) => new Promise<{ key: keyof DashboardContainerInput; isEqual: boolean }>((resolve) => { if (input[key] === undefined && lastInput[key] === undefined) { resolve({ key, isEqual: true }); } - isKeyEqual( + isKeyEqualAsync( key, { - container, + container: this, currentValue: input[key], currentInput: input, @@ -182,7 +154,7 @@ async function getInputChanges( lastValue: lastInput[key], lastInput, }, - diffingFunctions + unsavedChangesDiffingFunctions ).then((isEqual) => resolve({ key, isEqual })); }) ); @@ -196,6 +168,34 @@ async function getInputChanges( return inputChanges; } +export function getShouldRefresh( + this: DashboardContainer, + lastInput: DashboardContainerInput, + input: DashboardContainerInput +): boolean { + for (const key of sessionChangeKeys) { + if (input[key] === undefined && lastInput[key] === undefined) { + continue; + } + if ( + !isKeyEqual( + key, + { + container: this, + currentValue: input[key], + currentInput: input, + lastValue: lastInput[key], + lastInput, + }, + shouldRefreshDiffingFunctions + ) + ) { + return true; + } + } + return false; +} + function updateUnsavedChangesState( this: DashboardContainer, unsavedChanges: Partial diff --git a/src/plugins/embeddable/public/lib/filterable_embeddable/should_fetch.tsx b/src/plugins/embeddable/public/lib/filterable_embeddable/should_fetch.tsx index b8b9fb1f4795f..68d9df23bb612 100644 --- a/src/plugins/embeddable/public/lib/filterable_embeddable/should_fetch.tsx +++ b/src/plugins/embeddable/public/lib/filterable_embeddable/should_fetch.tsx @@ -27,19 +27,24 @@ export function shouldFetch$< return updated$.pipe(map(() => getInput())).pipe( // wrapping distinctUntilChanged with startWith and skip to prime distinctUntilChanged with an initial input value. startWith(getInput()), - distinctUntilChanged((a: TFilterableEmbeddableInput, b: TFilterableEmbeddableInput) => { - // Only need to diff searchSessionId when container uses search sessions because - // searchSessionId changes with any filter, query, or time changes - if (a.searchSessionId !== undefined || b.searchSessionId !== undefined) { - return a.searchSessionId === b.searchSessionId; - } + distinctUntilChanged( + (previous: TFilterableEmbeddableInput, current: TFilterableEmbeddableInput) => { + if ( + !fastIsEqual( + [previous.searchSessionId, previous.query, previous.timeRange, previous.timeslice], + [current.searchSessionId, current.query, current.timeRange, current.timeslice] + ) + ) { + return false; + } - if (!fastIsEqual([a.query, a.timeRange, a.timeslice], [b.query, b.timeRange, b.timeslice])) { - return false; + return onlyDisabledFiltersChanged( + previous.filters, + current.filters, + shouldRefreshFilterCompareOptions + ); } - - return onlyDisabledFiltersChanged(a.filters, b.filters, shouldRefreshFilterCompareOptions); - }), + ), skip(1) ); } diff --git a/test/plugin_functional/test_suites/data_plugin/session.ts b/test/plugin_functional/test_suites/data_plugin/session.ts index 6c485a76db32e..469e6f992e79f 100644 --- a/test/plugin_functional/test_suites/data_plugin/session.ts +++ b/test/plugin_functional/test_suites/data_plugin/session.ts @@ -106,7 +106,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide }); it('starts a session on filter change', async () => { - await filterBar.removeAllFilters(); + await filterBar.removeFilter('animal'); const sessionIds = await getSessionIds(); expect(sessionIds.length).to.be(1); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_data_view_logic.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_data_view_logic.ts index f00ef0fecc1ed..bebd7e04278c9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_data_view_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_data_view_logic.ts @@ -20,7 +20,7 @@ export interface AnalyticsCollectionDataViewLogicValues { dataView: DataView | null; } -interface AnalyticsCollectionDataViewLogicActions { +export interface AnalyticsCollectionDataViewLogicActions { fetchedAnalyticsCollection: FetchAnalyticsCollectionActions['apiSuccess']; setDataView(dataView: DataView): { dataView: DataView }; } diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_formulas.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_formulas.ts index 87e9812b94ac7..b809f23b53d02 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_formulas.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_formulas.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { IKibanaSearchRequest, TimeRange } from '@kbn/data-plugin/common'; +import { DataView, IKibanaSearchRequest, TimeRange } from '@kbn/data-plugin/common'; const getSearchQueryRequestParams = (field: string, search: string): { regexp: {} } => { const createRegexQuery = (queryString: string) => { @@ -44,6 +44,7 @@ export const getPaginationRequestParams = (pageIndex: number, pageSize: number) }); export const getBaseSearchTemplate = ( + dataView: DataView, aggregationFieldName: string, { search, @@ -53,6 +54,7 @@ export const getBaseSearchTemplate = ( aggs: IKibanaSearchRequest['params']['aggs'] ): IKibanaSearchRequest => ({ params: { + index: dataView.title, aggs, query: { bool: { diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_logic.test.ts index 226c521c44894..8c37a7f41e8c2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_logic.test.ts @@ -7,6 +7,7 @@ import { LogicMounter } from '../../../__mocks__/kea_logic'; +import { DataView } from '@kbn/data-views-plugin/common'; import { nextTick } from '@kbn/test-jest-helpers'; import { KibanaLogic } from '../../../shared/kibana/kibana_logic'; @@ -87,7 +88,8 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { describe('isLoading', () => { beforeEach(() => { - mount({ selectedTable: ExploreTables.TopReferrers }); + mount({ selectedTable: ExploreTables.Referrers }); + AnalyticsCollectionExploreTableLogic.actions.setDataView({ id: 'test' } as DataView); }); it('should handle onTableChange', () => { @@ -112,7 +114,7 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { }); it('should handle setSelectedTable', () => { - AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.TopReferrers); + AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.Referrers); expect(AnalyticsCollectionExploreTableLogic.values.isLoading).toEqual(true); }); @@ -139,7 +141,7 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { AnalyticsCollectionExploreTableLogic.actions.onTableChange({ page: { index: 2, size: 10 }, }); - AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.TopReferrers); + AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.Referrers); expect(AnalyticsCollectionExploreTableLogic.values.pageIndex).toEqual(0); }); @@ -172,7 +174,7 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { AnalyticsCollectionExploreTableLogic.actions.onTableChange({ page: { index: 2, size: 10 }, }); - AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.TopReferrers); + AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.Referrers); expect(AnalyticsCollectionExploreTableLogic.values.pageSize).toEqual(10); }); @@ -193,7 +195,7 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { it('should handle setSelectedTable', () => { AnalyticsCollectionExploreTableLogic.actions.setSearch('test'); - AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.TopReferrers); + AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.Referrers); expect(AnalyticsCollectionExploreTableLogic.values.search).toEqual(''); }); @@ -211,10 +213,16 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { }); describe('listeners', () => { + const mockDataView = { id: 'test' } as DataView; + beforeEach(() => { + mount({ selectedTable: ExploreTables.Referrers }); + AnalyticsCollectionExploreTableLogic.actions.setDataView(mockDataView); + }); + it('should fetch items when selectedTable changes', () => { - AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.TopReferrers); + AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.Referrers); expect(KibanaLogic.values.data.search.search).toHaveBeenCalledWith(expect.any(Object), { - indexPattern: undefined, + indexPattern: mockDataView, sessionId: undefined, }); }); @@ -225,7 +233,7 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { AnalyticsCollectionToolbarLogic.actions.setTimeRange({ from: 'now-7d', to: 'now' }); expect(KibanaLogic.values.data.search.search).toHaveBeenCalledWith(expect.any(Object), { - indexPattern: undefined, + indexPattern: mockDataView, sessionId: undefined, }); }); @@ -236,7 +244,7 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { AnalyticsCollectionToolbarLogic.actions.setSearchSessionId('1234'); expect(KibanaLogic.values.data.search.search).toHaveBeenCalledWith(expect.any(Object), { - indexPattern: undefined, + indexPattern: mockDataView, sessionId: '1234', }); }); @@ -247,7 +255,7 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { AnalyticsCollectionExploreTableLogic.actions.onTableChange({}); expect(KibanaLogic.values.data.search.search).toHaveBeenCalledWith(expect.any(Object), { - indexPattern: undefined, + indexPattern: mockDataView, sessionId: undefined, }); }); @@ -262,7 +270,7 @@ describe('AnalyticsCollectionExplorerTablesLogic', () => { await nextTick(); expect(KibanaLogic.values.data.search.search).toHaveBeenCalledWith(expect.any(Object), { - indexPattern: undefined, + indexPattern: mockDataView, sessionId: undefined, }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_logic.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_logic.ts index e5e181ccfa266..26d9a227eb1c0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_logic.ts @@ -8,6 +8,7 @@ import { kea, MakeLogicType } from 'kea'; import { + DataView, IKibanaSearchRequest, IKibanaSearchResponse, isCompleteResponse, @@ -18,6 +19,7 @@ import { KibanaLogic } from '../../../shared/kibana/kibana_logic'; import { AnalyticsCollectionDataViewLogic, + AnalyticsCollectionDataViewLogicActions, AnalyticsCollectionDataViewLogicValues, } from './analytics_collection_data_view_logic'; @@ -32,9 +34,10 @@ import { ExploreTableItem, ExploreTables, SearchTermsTable, - TopClickedTable, - TopReferrersTable, + ClickedTable, + ReferrersTable, WorsePerformersTable, + LocationsTable, } from './analytics_collection_explore_table_types'; import { AnalyticsCollectionToolbarLogic, @@ -51,43 +54,50 @@ export interface Sorting { interface TableParams { parseResponse(response: IKibanaSearchResponse): { items: T[]; totalCount: number }; - requestParams(props: { - pageIndex: number; - pageSize: number; - search: string; - sorting: Sorting | null; - timeRange: TimeRange; - }): IKibanaSearchRequest; + requestParams( + dataView: DataView, + props: { + pageIndex: number; + pageSize: number; + search: string; + sorting: Sorting | null; + timeRange: TimeRange; + } + ): IKibanaSearchRequest; } const tablesParams: { + [ExploreTables.Clicked]: TableParams; + [ExploreTables.Locations]: TableParams; + [ExploreTables.Referrers]: TableParams; [ExploreTables.SearchTerms]: TableParams; - [ExploreTables.TopClicked]: TableParams; - [ExploreTables.TopReferrers]: TableParams; [ExploreTables.WorsePerformers]: TableParams; } = { [ExploreTables.SearchTerms]: { parseResponse: ( response: IKibanaSearchResponse<{ - aggregations: { + aggregations?: { searches: { buckets: Array<{ doc_count: number; key: string }> }; totalCount: { value: number }; }; }> ) => ({ - items: response.rawResponse.aggregations.searches.buckets.map((bucket) => ({ - [ExploreTableColumns.count]: bucket.doc_count, - [ExploreTableColumns.searchTerms]: bucket.key, - })), - totalCount: response.rawResponse.aggregations.totalCount.value, + items: + response.rawResponse.aggregations?.searches.buckets.map((bucket) => ({ + [ExploreTableColumns.count]: bucket.doc_count, + [ExploreTableColumns.searchTerms]: bucket.key, + })) || [], + totalCount: response.rawResponse.aggregations?.totalCount.value || 0, }), requestParams: ( + dataView, { timeRange, sorting, pageIndex, pageSize, search }, aggregationFieldName = 'search.query' ) => getBaseSearchTemplate( + dataView, aggregationFieldName, - { search, timeRange, eventType: 'search' }, + { eventType: 'search', search, timeRange }, { searches: { terms: { @@ -109,7 +119,7 @@ const tablesParams: { [ExploreTables.WorsePerformers]: { parseResponse: ( response: IKibanaSearchResponse<{ - aggregations: { + aggregations?: { formula: { searches: { buckets: Array<{ doc_count: number; key: string }> }; totalCount: { value: number }; @@ -117,19 +127,22 @@ const tablesParams: { }; }> ) => ({ - items: response.rawResponse.aggregations.formula.searches.buckets.map((bucket) => ({ - [ExploreTableColumns.count]: bucket.doc_count, - [ExploreTableColumns.query]: bucket.key, - })), - totalCount: response.rawResponse.aggregations.formula.totalCount.value, + items: + response.rawResponse.aggregations?.formula.searches.buckets.map((bucket) => ({ + [ExploreTableColumns.count]: bucket.doc_count, + [ExploreTableColumns.query]: bucket.key, + })) || [], + totalCount: response.rawResponse.aggregations?.formula.totalCount.value || 0, }), requestParams: ( + dataView, { timeRange, sorting, pageIndex, pageSize, search }, aggregationFieldName = 'search.query' ) => getBaseSearchTemplate( + dataView, aggregationFieldName, - { search, timeRange, eventType: 'search' }, + { eventType: 'search', search, timeRange }, { formula: { aggs: { @@ -153,7 +166,7 @@ const tablesParams: { } ), }, - [ExploreTables.TopClicked]: { + [ExploreTables.Clicked]: { parseResponse: ( response: IKibanaSearchResponse<{ aggregations: { @@ -164,19 +177,22 @@ const tablesParams: { }; }> ) => ({ - items: response.rawResponse.aggregations.formula.searches.buckets.map((bucket) => ({ - [ExploreTableColumns.count]: bucket.doc_count, - [ExploreTableColumns.page]: bucket.key, - })), - totalCount: response.rawResponse.aggregations.formula.totalCount.value, + items: + response.rawResponse.aggregations?.formula.searches.buckets.map((bucket) => ({ + [ExploreTableColumns.count]: bucket.doc_count, + [ExploreTableColumns.page]: bucket.key, + })) || [], + totalCount: response.rawResponse.aggregations?.formula.totalCount.value || 0, }), requestParams: ( + dataView, { timeRange, sorting, pageIndex, pageSize, search }, aggregationFieldName = 'search.results.items.page.url' ) => getBaseSearchTemplate( + dataView, aggregationFieldName, - { search, timeRange, eventType: 'search_click' }, + { eventType: 'search_click', search, timeRange }, { formula: { aggs: { @@ -200,10 +216,10 @@ const tablesParams: { } ), }, - [ExploreTables.TopReferrers]: { + [ExploreTables.Referrers]: { parseResponse: ( response: IKibanaSearchResponse<{ - aggregations: { + aggregations?: { formula: { searches: { buckets: Array<{ doc_count: number; key: string }> }; totalCount: { value: number }; @@ -211,19 +227,22 @@ const tablesParams: { }; }> ) => ({ - items: response.rawResponse.aggregations.formula.searches.buckets.map((bucket) => ({ - [ExploreTableColumns.sessions]: bucket.doc_count, - [ExploreTableColumns.page]: bucket.key, - })), - totalCount: response.rawResponse.aggregations.formula.totalCount.value, + items: + response.rawResponse.aggregations?.formula.searches.buckets.map((bucket) => ({ + [ExploreTableColumns.sessions]: bucket.doc_count, + [ExploreTableColumns.page]: bucket.key, + })) || [], + totalCount: response.rawResponse.aggregations?.formula.totalCount.value || 0, }), requestParams: ( + dataView, { timeRange, sorting, pageIndex, pageSize, search }, aggregationFieldName = 'page.referrer' ) => getBaseSearchTemplate( + dataView, aggregationFieldName, - { search, timeRange, eventType: 'page_view' }, + { eventType: 'page_view', search, timeRange }, { formula: { aggs: { @@ -247,6 +266,60 @@ const tablesParams: { } ), }, + [ExploreTables.Locations]: { + parseResponse: ( + response: IKibanaSearchResponse<{ + aggregations?: { + formula: { + searches: { buckets: Array<{ doc_count: number; key: string }> }; + totalCount: { value: number }; + }; + }; + }> + ) => ({ + items: + response.rawResponse.aggregations?.formula.searches.buckets.map((bucket) => ({ + [ExploreTableColumns.sessions]: bucket.doc_count, + [ExploreTableColumns.location]: bucket.key[0], + countryISOCode: bucket.key[1], + })) || [], + totalCount: response.rawResponse.aggregations?.formula.totalCount.value || 0, + }), + requestParams: ( + dataView, + { timeRange, sorting, pageIndex, pageSize, search }, + aggregationFieldName = 'session.location.country_name' + ) => + getBaseSearchTemplate( + dataView, + aggregationFieldName, + { eventType: 'page_view', search, timeRange }, + { + formula: { + aggs: { + ...getTotalCountRequestParams(aggregationFieldName), + searches: { + multi_terms: { + ...getPaginationRequestSizeParams(pageIndex, pageSize), + order: sorting + ? { + [sorting?.field === ExploreTableColumns.sessions ? '_count' : '_key']: + sorting?.direction, + } + : undefined, + terms: [ + { field: aggregationFieldName }, + { field: 'session.location.country_iso_code' }, + ], + }, + ...getPaginationRequestParams(pageIndex, pageSize), + }, + }, + filter: { term: { 'event.action': 'page_view' } }, + }, + } + ), + }, }; export interface AnalyticsCollectionExploreTableLogicValues { @@ -269,6 +342,7 @@ export interface AnalyticsCollectionExploreTableLogicActions { sort?: Sorting; }; reset(): void; + setDataView: AnalyticsCollectionDataViewLogicActions['setDataView']; setItems(items: ExploreTableItem[]): { items: ExploreTableItem[] }; setSearch(search: string): { search: string }; setSelectedTable( @@ -293,7 +367,12 @@ export const AnalyticsCollectionExploreTableLogic = kea< setTotalItemsCount: (count) => ({ count }), }, connect: { - actions: [AnalyticsCollectionToolbarLogic, ['setTimeRange', 'setSearchSessionId']], + actions: [ + AnalyticsCollectionToolbarLogic, + ['setTimeRange', 'setSearchSessionId'], + AnalyticsCollectionDataViewLogic, + ['setDataView'], + ], values: [ AnalyticsCollectionDataViewLogic, ['dataView'], @@ -303,7 +382,11 @@ export const AnalyticsCollectionExploreTableLogic = kea< }, listeners: ({ actions, values }) => { const fetchItems = () => { - if (values.selectedTable === null || !(values.selectedTable in tablesParams)) { + if ( + values.selectedTable === null || + !(values.selectedTable in tablesParams) || + !values.dataView + ) { actions.setItems([]); actions.setTotalItemsCount(0); @@ -315,7 +398,7 @@ export const AnalyticsCollectionExploreTableLogic = kea< const search$ = KibanaLogic.values.data.search .search( - requestParams({ + requestParams(values.dataView, { pageIndex: values.pageIndex, pageSize: values.pageSize, search: values.search, @@ -323,7 +406,7 @@ export const AnalyticsCollectionExploreTableLogic = kea< timeRange, }), { - indexPattern: values.dataView || undefined, + indexPattern: values.dataView, sessionId: values.searchSessionId, } ) @@ -345,6 +428,7 @@ export const AnalyticsCollectionExploreTableLogic = kea< return { onTableChange: fetchItems, + setDataView: fetchItems, setSearch: async (_, breakpoint) => { await breakpoint(SEARCH_COOLDOWN); fetchItems(); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_types.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_types.ts index c9c6e4c3a0244..ffca2172440b3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explore_table_types.ts @@ -8,14 +8,16 @@ export enum ExploreTables { SearchTerms, WorsePerformers, - TopClicked, - TopReferrers, + Clicked, + Referrers, + Locations, } export enum ExploreTableColumns { count = 'count', searchTerms = 'searchTerms', query = 'query', + location = 'location', page = 'page', sessions = 'sessions', } @@ -30,18 +32,25 @@ export interface WorsePerformersTable { [ExploreTableColumns.query]: string; } -export interface TopClickedTable { +export interface ClickedTable { [ExploreTableColumns.count]: number; [ExploreTableColumns.page]: string; } -export interface TopReferrersTable { +export interface ReferrersTable { [ExploreTableColumns.page]: string; [ExploreTableColumns.sessions]: number; } +export interface LocationsTable { + [ExploreTableColumns.location]: string; + [ExploreTableColumns.sessions]: number; + countryISOCode: string; +} + export type ExploreTableItem = | SearchTermsTable | WorsePerformersTable - | TopClickedTable - | TopReferrersTable; + | ClickedTable + | ReferrersTable + | LocationsTable; diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.test.tsx index fc702a0493369..3fc29c6d9e687 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.test.tsx @@ -27,7 +27,7 @@ describe('AnalyticsCollectionExplorerTable', () => { beforeEach(() => { jest.clearAllMocks(); - setMockValues({ items: [], selectedTable: ExploreTables.TopClicked }); + setMockValues({ items: [], selectedTable: ExploreTables.Clicked }); setMockActions(mockActions); }); @@ -46,7 +46,7 @@ describe('AnalyticsCollectionExplorerTable', () => { it('should call setSelectedTable when click on a tab', () => { const tabs = shallow().find('EuiTab'); - expect(tabs.length).toBe(4); + expect(tabs.length).toBe(5); tabs.at(2).simulate('click'); expect(mockActions.setSelectedTable).toHaveBeenCalledWith(ExploreTables.WorsePerformers, { diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.tsx index cc104fe93b7ba..35cf7afbd1243 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_explorer/analytics_collection_explorer_table.tsx @@ -32,15 +32,17 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { getFlag } from '../../../utils/get_flag'; import { AnalyticsCollectionExploreTableLogic } from '../analytics_collection_explore_table_logic'; import { ExploreTableColumns, ExploreTableItem, ExploreTables, SearchTermsTable, - TopClickedTable, - TopReferrersTable, + ClickedTable, + ReferrersTable, WorsePerformersTable, + LocationsTable, } from '../analytics_collection_explore_table_types'; import { AnalyticsCollectionExplorerCallout } from './analytics_collection_explorer_callout'; @@ -63,7 +65,7 @@ const tabs: Array<{ id: ExploreTables; name: string }> = [ ), }, { - id: ExploreTables.TopClicked, + id: ExploreTables.Clicked, name: i18n.translate( 'xpack.enterpriseSearch.analytics.collections.collectionsView.explorer.topClickedTab', { defaultMessage: 'Top clicked results' } @@ -77,7 +79,14 @@ const tabs: Array<{ id: ExploreTables; name: string }> = [ ), }, { - id: ExploreTables.TopReferrers, + id: ExploreTables.Locations, + name: i18n.translate( + 'xpack.enterpriseSearch.analytics.collections.collectionsView.explorer.locationsTab', + { defaultMessage: 'Locations' } + ), + }, + { + id: ExploreTables.Referrers, name: i18n.translate( 'xpack.enterpriseSearch.analytics.collections.collectionsView.explorer.referrersTab', { defaultMessage: 'Referrers' } @@ -86,9 +95,10 @@ const tabs: Array<{ id: ExploreTables; name: string }> = [ ]; const tableSettings: { + [ExploreTables.Clicked]: TableSetting; + [ExploreTables.Locations]: TableSetting; + [ExploreTables.Referrers]: TableSetting; [ExploreTables.SearchTerms]: TableSetting; - [ExploreTables.TopClicked]: TableSetting; - [ExploreTables.TopReferrers]: TableSetting; [ExploreTables.WorsePerformers]: TableSetting; } = { [ExploreTables.SearchTerms]: { @@ -149,7 +159,7 @@ const tableSettings: { }, }, }, - [ExploreTables.TopClicked]: { + [ExploreTables.Clicked]: { columns: [ { field: ExploreTableColumns.page, @@ -184,7 +194,7 @@ const tableSettings: { }, }, }, - [ExploreTables.TopReferrers]: { + [ExploreTables.Referrers]: { columns: [ { field: ExploreTableColumns.page, @@ -219,6 +229,46 @@ const tableSettings: { }, }, }, + [ExploreTables.Locations]: { + columns: [ + { + field: ExploreTableColumns.location, + name: i18n.translate( + 'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.location', + { defaultMessage: 'Location' } + ), + render: (euiTheme: UseEuiTheme['euiTheme']) => (value: string, data: LocationsTable) => + ( + + +

{getFlag(data.countryISOCode)}

+
+ +

{value}

+
+
+ ), + sortable: true, + truncateText: true, + }, + { + align: 'right', + field: ExploreTableColumns.sessions, + name: i18n.translate( + 'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.session', + { defaultMessage: 'Session' } + ), + sortable: true, + truncateText: true, + }, + ], + sorting: { + sort: { + direction: 'desc', + field: ExploreTableColumns.sessions, + }, + }, + }, }; export const AnalyticsCollectionExplorerTable = () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.test.tsx index e6e553dc51792..60d50e28fa802 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.test.tsx @@ -48,7 +48,7 @@ describe('AnalyticsCollectionOverviewTable', () => { topReferrersTab.simulate('click'); expect(mockActions.setSelectedTable).toHaveBeenCalledTimes(1); - expect(mockActions.setSelectedTable).toHaveBeenCalledWith(ExploreTables.TopReferrers, { + expect(mockActions.setSelectedTable).toHaveBeenCalledWith(ExploreTables.Locations, { direction: 'desc', field: 'sessions', }); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.tsx index 8538b11f748fe..3bad1189a0181 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_overview/analytics_collection_overview_table.tsx @@ -25,6 +25,7 @@ import { EuiTableSortingType, } from '@elastic/eui/src/components/basic_table/table_types'; import { UseEuiTheme } from '@elastic/eui/src/services/theme/hooks'; + import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -32,6 +33,7 @@ import { generateEncodedPath } from '../../../../shared/encode_path_params'; import { KibanaLogic } from '../../../../shared/kibana'; import { COLLECTION_EXPLORER_PATH } from '../../../routes'; +import { getFlag } from '../../../utils/get_flag'; import { FilterBy } from '../../../utils/get_formula_by_filter'; import { AnalyticsCollectionExploreTableLogic } from '../analytics_collection_explore_table_logic'; @@ -40,9 +42,10 @@ import { ExploreTableItem, ExploreTables, SearchTermsTable, - TopClickedTable, - TopReferrersTable, + ClickedTable, + ReferrersTable, WorsePerformersTable, + LocationsTable, } from '../analytics_collection_explore_table_types'; import { FetchAnalyticsCollectionLogic } from '../fetch_analytics_collection_logic'; @@ -67,7 +70,7 @@ const tabsByFilter: Record> ], [FilterBy.Clicks]: [ { - id: ExploreTables.TopClicked, + id: ExploreTables.Clicked, name: i18n.translate( 'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTab.topClicked', { defaultMessage: 'Top clicked results' } @@ -76,7 +79,14 @@ const tabsByFilter: Record> ], [FilterBy.Sessions]: [ { - id: ExploreTables.TopReferrers, + id: ExploreTables.Locations, + name: i18n.translate( + 'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTab.topLocations', + { defaultMessage: 'Top locations' } + ), + }, + { + id: ExploreTables.Referrers, name: i18n.translate( 'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTab.topReferrers', { defaultMessage: 'Top referrers' } @@ -95,9 +105,10 @@ interface TableSetting { } const tableSettings: { + [ExploreTables.Clicked]: TableSetting; + [ExploreTables.Locations]: TableSetting; + [ExploreTables.Referrers]: TableSetting; [ExploreTables.SearchTerms]: TableSetting; - [ExploreTables.TopClicked]: TableSetting; - [ExploreTables.TopReferrers]: TableSetting; [ExploreTables.WorsePerformers]: TableSetting; } = { [ExploreTables.SearchTerms]: { @@ -158,7 +169,7 @@ const tableSettings: { }, }, }, - [ExploreTables.TopClicked]: { + [ExploreTables.Clicked]: { columns: [ { field: ExploreTableColumns.page, @@ -193,7 +204,7 @@ const tableSettings: { }, }, }, - [ExploreTables.TopReferrers]: { + [ExploreTables.Referrers]: { columns: [ { field: ExploreTableColumns.page, @@ -228,6 +239,46 @@ const tableSettings: { }, }, }, + [ExploreTables.Locations]: { + columns: [ + { + field: ExploreTableColumns.location, + name: i18n.translate( + 'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.location', + { defaultMessage: 'Location' } + ), + render: (euiTheme: UseEuiTheme['euiTheme']) => (value: string, data: LocationsTable) => + ( + + +

{getFlag(data.countryISOCode)}

+
+ +

{value}

+
+
+ ), + truncateText: true, + }, + { + align: 'right', + field: ExploreTableColumns.sessions, + name: i18n.translate( + 'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.session', + { defaultMessage: 'Session' } + ), + sortable: true, + truncateText: true, + }, + ], + sorting: { + readOnly: true, + sort: { + direction: 'desc', + field: ExploreTableColumns.sessions, + }, + }, + }, }; interface AnalyticsCollectionOverviewTableProps { diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/utils/get_flag.ts b/x-pack/plugins/enterprise_search/public/applications/analytics/utils/get_flag.ts new file mode 100644 index 0000000000000..d82eeb27cfbd6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/utils/get_flag.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. + */ + +export const getFlag = (countryCode: string): string | null => + countryCode && countryCode.length === 2 + ? countryCode + .toUpperCase() + .replace(/./g, (c) => String.fromCharCode(55356, 56741 + c.charCodeAt(0))) + : null; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts index 516d65df089b5..2cc9a7eabea35 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts @@ -66,6 +66,7 @@ import { sortModels, sortSourceFields, } from '../../../shared/ml_inference/utils'; +import { PipelinesLogic } from '../pipelines_logic'; import { AddInferencePipelineFormErrors, @@ -227,6 +228,8 @@ export const MLInferenceLogic = kea< 'apiSuccess as attachApiSuccess', 'makeRequest as makeAttachPipelineRequest', ], + PipelinesLogic, + ['closeAddMlInferencePipelineModal as closeAddMlInferencePipelineModal'], ], values: [ CachedFetchIndexApiLogic, @@ -348,6 +351,20 @@ export const MLInferenceLogic = kea< selectedSourceFields: [], }; }, + closeAddMlInferencePipelineModal: () => ({ + configuration: { + ...EMPTY_PIPELINE_CONFIGURATION, + }, + indexName: '', + step: AddInferencePipelineSteps.Configuration, + }), + createApiSuccess: () => ({ + configuration: { + ...EMPTY_PIPELINE_CONFIGURATION, + }, + indexName: '', + step: AddInferencePipelineSteps.Configuration, + }), removeFieldFromMapping: (modal, { fieldName }) => { const { configuration: { fieldMappings }, diff --git a/x-pack/plugins/infra/common/http_api/index.ts b/x-pack/plugins/infra/common/http_api/index.ts index 355c5925702f7..e9dc741f39652 100644 --- a/x-pack/plugins/infra/common/http_api/index.ts +++ b/x-pack/plugins/infra/common/http_api/index.ts @@ -20,3 +20,4 @@ export * from './infra'; */ export * from './latest'; export * as inventoryViewsV1 from './inventory_views/v1'; +export * as metricsExplorerViewsV1 from './metrics_explorer_views/v1'; diff --git a/x-pack/plugins/infra/common/http_api/inventory_views/v1/common.ts b/x-pack/plugins/infra/common/http_api/inventory_views/v1/common.ts index c229170b8007b..3db684628334e 100644 --- a/x-pack/plugins/infra/common/http_api/inventory_views/v1/common.ts +++ b/x-pack/plugins/infra/common/http_api/inventory_views/v1/common.ts @@ -63,4 +63,4 @@ export const inventoryViewResponsePayloadRT = rt.type({ data: inventoryViewResponseRT, }); -export type GetInventoryViewResponsePayload = rt.TypeOf; +export type InventoryViewResponsePayload = rt.TypeOf; diff --git a/x-pack/plugins/infra/common/http_api/latest.ts b/x-pack/plugins/infra/common/http_api/latest.ts index 519da4a60dec1..effdbeda041da 100644 --- a/x-pack/plugins/infra/common/http_api/latest.ts +++ b/x-pack/plugins/infra/common/http_api/latest.ts @@ -6,3 +6,4 @@ */ export * from './inventory_views/v1'; +export * from './metrics_explorer_views/v1'; diff --git a/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/common.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/common.ts new file mode 100644 index 0000000000000..76b6daf60a324 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/common.ts @@ -0,0 +1,68 @@ +/* + * 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 { nonEmptyStringRt } from '@kbn/io-ts-utils'; +import * as rt from 'io-ts'; +import { either } from 'fp-ts/Either'; + +export const METRICS_EXPLORER_VIEW_URL = '/api/infra/metrics_explorer_views'; +export const METRICS_EXPLORER_VIEW_URL_ENTITY = `${METRICS_EXPLORER_VIEW_URL}/{metricsExplorerViewId}`; +export const getMetricsExplorerViewUrl = (metricsExplorerViewId?: string) => + [METRICS_EXPLORER_VIEW_URL, metricsExplorerViewId].filter(Boolean).join('/'); + +const metricsExplorerViewIdRT = new rt.Type( + 'MetricsExplorerViewId', + rt.string.is, + (u, c) => + either.chain(rt.string.validate(u, c), (id) => { + return id === '0' + ? rt.failure(u, c, `The metrics explorer view with id ${id} is not configurable.`) + : rt.success(id); + }), + String +); + +export const metricsExplorerViewRequestParamsRT = rt.type({ + metricsExplorerViewId: metricsExplorerViewIdRT, +}); + +export type MetricsExplorerViewRequestParams = rt.TypeOf; + +export const metricsExplorerViewRequestQueryRT = rt.partial({ + sourceId: rt.string, +}); + +export type MetricsExplorerViewRequestQuery = rt.TypeOf; + +const metricsExplorerViewAttributesResponseRT = rt.intersection([ + rt.strict({ + name: nonEmptyStringRt, + isDefault: rt.boolean, + isStatic: rt.boolean, + }), + rt.UnknownRecord, +]); + +const metricsExplorerViewResponseRT = rt.exact( + rt.intersection([ + rt.type({ + id: rt.string, + attributes: metricsExplorerViewAttributesResponseRT, + }), + rt.partial({ + updatedAt: rt.number, + version: rt.string, + }), + ]) +); + +export const metricsExplorerViewResponsePayloadRT = rt.type({ + data: metricsExplorerViewResponseRT, +}); + +export type GetMetricsExplorerViewResponsePayload = rt.TypeOf< + typeof metricsExplorerViewResponsePayloadRT +>; diff --git a/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/create_metrics_explorer_view.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/create_metrics_explorer_view.ts new file mode 100644 index 0000000000000..5550404529cf1 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/create_metrics_explorer_view.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { nonEmptyStringRt } from '@kbn/io-ts-utils'; +import * as rt from 'io-ts'; + +export const createMetricsExplorerViewAttributesRequestPayloadRT = rt.intersection([ + rt.type({ + name: nonEmptyStringRt, + }), + rt.UnknownRecord, + rt.exact(rt.partial({ isDefault: rt.undefined, isStatic: rt.undefined })), +]); + +export type CreateMetricsExplorerViewAttributesRequestPayload = rt.TypeOf< + typeof createMetricsExplorerViewAttributesRequestPayloadRT +>; + +export const createMetricsExplorerViewRequestPayloadRT = rt.type({ + attributes: createMetricsExplorerViewAttributesRequestPayloadRT, +}); + +export type CreateMetricsExplorerViewRequestPayload = rt.TypeOf< + typeof createMetricsExplorerViewRequestPayloadRT +>; diff --git a/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/find_metrics_explorer_view.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/find_metrics_explorer_view.ts new file mode 100644 index 0000000000000..c504b54a4f914 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/find_metrics_explorer_view.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 { nonEmptyStringRt } from '@kbn/io-ts-utils'; +import * as rt from 'io-ts'; + +export const findMetricsExplorerViewAttributesResponseRT = rt.strict({ + name: nonEmptyStringRt, + isDefault: rt.boolean, + isStatic: rt.boolean, +}); + +const findMetricsExplorerViewResponseRT = rt.exact( + rt.intersection([ + rt.type({ + id: rt.string, + attributes: findMetricsExplorerViewAttributesResponseRT, + }), + rt.partial({ + updatedAt: rt.number, + version: rt.string, + }), + ]) +); + +export const findMetricsExplorerViewResponsePayloadRT = rt.type({ + data: rt.array(findMetricsExplorerViewResponseRT), +}); + +export type FindMetricsExplorerViewResponsePayload = rt.TypeOf< + typeof findMetricsExplorerViewResponsePayloadRT +>; diff --git a/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/get_metrics_explorer_view.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/get_metrics_explorer_view.ts new file mode 100644 index 0000000000000..8a828e00c917f --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/get_metrics_explorer_view.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; + +export const getMetricsExplorerViewRequestParamsRT = rt.type({ + metricsExplorerViewId: rt.string, +}); + +export type GetMetricsExplorerViewRequestParams = rt.TypeOf< + typeof getMetricsExplorerViewRequestParamsRT +>; diff --git a/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/index.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/index.ts new file mode 100644 index 0000000000000..62a0b7a633975 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/index.ts @@ -0,0 +1,12 @@ +/* + * 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 './common'; +export * from './get_metrics_explorer_view'; +export * from './find_metrics_explorer_view'; +export * from './create_metrics_explorer_view'; +export * from './update_metrics_explorer_view'; diff --git a/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/update_metrics_explorer_view.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/update_metrics_explorer_view.ts new file mode 100644 index 0000000000000..5bf327789a65c --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/metrics_explorer_views/v1/update_metrics_explorer_view.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { nonEmptyStringRt } from '@kbn/io-ts-utils'; +import * as rt from 'io-ts'; + +export const updateMetricsExplorerViewAttributesRequestPayloadRT = rt.intersection([ + rt.type({ + name: nonEmptyStringRt, + }), + rt.UnknownRecord, + rt.exact(rt.partial({ isDefault: rt.undefined, isStatic: rt.undefined })), +]); + +export type UpdateMetricsExplorerViewAttributesRequestPayload = rt.TypeOf< + typeof updateMetricsExplorerViewAttributesRequestPayloadRT +>; + +export const updateMetricsExplorerViewRequestPayloadRT = rt.type({ + attributes: updateMetricsExplorerViewAttributesRequestPayloadRT, +}); + +export type UpdateMetricsExplorerViewRequestPayload = rt.TypeOf< + typeof updateMetricsExplorerViewRequestPayloadRT +>; diff --git a/x-pack/plugins/infra/common/metrics_explorer_views/defaults.ts b/x-pack/plugins/infra/common/metrics_explorer_views/defaults.ts index 88771d1a76fcb..8c7e6ffff192f 100644 --- a/x-pack/plugins/infra/common/metrics_explorer_views/defaults.ts +++ b/x-pack/plugins/infra/common/metrics_explorer_views/defaults.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import type { NonEmptyString } from '@kbn/io-ts-utils'; import type { MetricsExplorerViewAttributes } from './types'; -export const staticMetricsExplorerViewId = 'static'; +export const staticMetricsExplorerViewId = '0'; export const staticMetricsExplorerViewAttributes: MetricsExplorerViewAttributes = { name: i18n.translate('xpack.infra.savedView.defaultViewNameHosts', { diff --git a/x-pack/plugins/infra/common/metrics_explorer_views/index.ts b/x-pack/plugins/infra/common/metrics_explorer_views/index.ts index 6cc0ccaa93a6d..ae809a6c7c615 100644 --- a/x-pack/plugins/infra/common/metrics_explorer_views/index.ts +++ b/x-pack/plugins/infra/common/metrics_explorer_views/index.ts @@ -5,4 +5,5 @@ * 2.0. */ +export * from './defaults'; export * from './types'; diff --git a/x-pack/plugins/infra/common/metrics_explorer_views/metric_explorer_view.mock.ts b/x-pack/plugins/infra/common/metrics_explorer_views/metrics_explorer_view.mock.ts similarity index 93% rename from x-pack/plugins/infra/common/metrics_explorer_views/metric_explorer_view.mock.ts rename to x-pack/plugins/infra/common/metrics_explorer_views/metrics_explorer_view.mock.ts index e921c37dd21f8..98f6675f42a66 100644 --- a/x-pack/plugins/infra/common/metrics_explorer_views/metric_explorer_view.mock.ts +++ b/x-pack/plugins/infra/common/metrics_explorer_views/metrics_explorer_view.mock.ts @@ -8,7 +8,7 @@ import { staticMetricsExplorerViewAttributes } from './defaults'; import type { MetricsExplorerView, MetricsExplorerViewAttributes } from './types'; -export const createmetricsExplorerViewMock = ( +export const createMetricsExplorerViewMock = ( id: string, attributes: MetricsExplorerViewAttributes, updatedAt?: number, diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index 3b6ea0333f236..4d29974ceb75f 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -37,6 +37,7 @@ import { initOverviewRoute } from './routes/overview'; import { initProcessListRoute } from './routes/process_list'; import { initSnapshotRoute } from './routes/snapshot'; import { initInfraMetricsRoute } from './routes/infra'; +import { initMetricsExplorerViewRoutes } from './routes/metrics_explorer_views'; export const initInfraServer = (libs: InfraBackendLibs) => { initIpToHostName(libs); @@ -59,6 +60,7 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initLogEntriesSummaryHighlightsRoute(libs); initLogViewRoutes(libs); initMetricExplorerRoute(libs); + initMetricsExplorerViewRoutes(libs); initMetricsAPIRoute(libs); initMetadataRoute(libs); initInventoryMetaRoute(libs); diff --git a/x-pack/plugins/infra/server/mocks.ts b/x-pack/plugins/infra/server/mocks.ts index 5a97f4a7d9a52..7e5a349cb1e01 100644 --- a/x-pack/plugins/infra/server/mocks.ts +++ b/x-pack/plugins/infra/server/mocks.ts @@ -10,6 +10,7 @@ import { createLogViewsServiceSetupMock, createLogViewsServiceStartMock, } from './services/log_views/log_views_service.mock'; +import { createMetricsExplorerViewsServiceStartMock } from './services/metrics_explorer_views/metrics_explorer_views_service.mock'; import { InfraPluginSetup, InfraPluginStart } from './types'; const createInfraSetupMock = () => { @@ -26,6 +27,7 @@ const createInfraStartMock = () => { getMetricIndices: jest.fn(), inventoryViews: createInventoryViewsServiceStartMock(), logViews: createLogViewsServiceStartMock(), + metricsExplorerViews: createMetricsExplorerViewsServiceStartMock(), }; return infraStartMock; }; diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index 2c114bb75d6e5..eb8777665895a 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -49,6 +49,7 @@ import { import { InventoryViewsService } from './services/inventory_views'; import { LogEntriesService } from './services/log_entries'; import { LogViewsService } from './services/log_views'; +import { MetricsExplorerViewsService } from './services/metrics_explorer_views'; import { RulesService } from './services/rules'; import { InfraConfig, @@ -122,6 +123,7 @@ export class InfraServerPlugin private metricsRules: RulesService; private inventoryViews: InventoryViewsService; private logViews: LogViewsService; + private metricsExplorerViews: MetricsExplorerViewsService; constructor(context: PluginInitializerContext) { this.config = context.config.get(); @@ -140,6 +142,9 @@ export class InfraServerPlugin this.inventoryViews = new InventoryViewsService(this.logger.get('inventoryViews')); this.logViews = new LogViewsService(this.logger.get('logViews')); + this.metricsExplorerViews = new MetricsExplorerViewsService( + this.logger.get('metricsExplorerViews') + ); } setup(core: InfraPluginCoreSetup, plugins: InfraServerPluginSetupDeps) { @@ -155,12 +160,13 @@ export class InfraServerPlugin ); const inventoryViews = this.inventoryViews.setup(); const logViews = this.logViews.setup(); + const metricsExplorerViews = this.metricsExplorerViews.setup(); // register saved object types core.savedObjects.registerType(infraSourceConfigurationSavedObjectType); - core.savedObjects.registerType(metricsExplorerViewSavedObjectType); core.savedObjects.registerType(inventoryViewSavedObjectType); core.savedObjects.registerType(logViewSavedObjectType); + core.savedObjects.registerType(metricsExplorerViewSavedObjectType); // TODO: separate these out individually and do away with "domains" as a temporary group // and make them available via the request context so we can do away with @@ -237,6 +243,7 @@ export class InfraServerPlugin defineInternalSourceConfiguration: sources.defineInternalSourceConfiguration.bind(sources), inventoryViews, logViews, + metricsExplorerViews, } as InfraPluginSetup; } @@ -258,9 +265,15 @@ export class InfraServerPlugin }, }); + const metricsExplorerViews = this.metricsExplorerViews.start({ + infraSources: this.libs.sources, + savedObjects: core.savedObjects, + }); + return { inventoryViews, logViews, + metricsExplorerViews, getMetricIndices: makeGetMetricIndices(this.libs.sources), }; } diff --git a/x-pack/plugins/infra/server/routes/inventory_views/README.md b/x-pack/plugins/infra/server/routes/inventory_views/README.md index 8a09aedef1b75..be7d1c3734157 100644 --- a/x-pack/plugins/infra/server/routes/inventory_views/README.md +++ b/x-pack/plugins/infra/server/routes/inventory_views/README.md @@ -221,6 +221,8 @@ Updates an inventory view. Any attribute can be updated except for `isDefault` and `isStatic`, which are derived by the source configuration preference set by the user. +Any attempt to update the static view with id `0` will return a `400 The inventory view with id 0 is not configurable.` + ### Request - **Method**: PUT @@ -324,6 +326,8 @@ Status code: 409 Deletes an inventory view. +Any attempt to delete the static view with id `0` will return a `400 The inventory view with id 0 is not configurable.` + ### Request - **Method**: DELETE diff --git a/x-pack/plugins/infra/server/routes/inventory_views/create_inventory_view.ts b/x-pack/plugins/infra/server/routes/inventory_views/create_inventory_view.ts index 8f3d52db7a6dd..90bb47d8a2d76 100644 --- a/x-pack/plugins/infra/server/routes/inventory_views/create_inventory_view.ts +++ b/x-pack/plugins/infra/server/routes/inventory_views/create_inventory_view.ts @@ -28,7 +28,7 @@ export const initCreateInventoryViewRoute = ({ }, async (_requestContext, request, response) => { const { body } = request; - const { inventoryViews } = (await getStartServices())[2]; + const [, , { inventoryViews }] = await getStartServices(); const inventoryViewsClient = inventoryViews.getScopedClient(request); try { diff --git a/x-pack/plugins/infra/server/routes/inventory_views/delete_inventory_view.ts b/x-pack/plugins/infra/server/routes/inventory_views/delete_inventory_view.ts index 83ad61fc46c52..e86e44fc0ac05 100644 --- a/x-pack/plugins/infra/server/routes/inventory_views/delete_inventory_view.ts +++ b/x-pack/plugins/infra/server/routes/inventory_views/delete_inventory_view.ts @@ -27,7 +27,7 @@ export const initDeleteInventoryViewRoute = ({ }, async (_requestContext, request, response) => { const { params } = request; - const { inventoryViews } = (await getStartServices())[2]; + const [, , { inventoryViews }] = await getStartServices(); const inventoryViewsClient = inventoryViews.getScopedClient(request); try { diff --git a/x-pack/plugins/infra/server/routes/inventory_views/find_inventory_view.ts b/x-pack/plugins/infra/server/routes/inventory_views/find_inventory_view.ts index abdfc2f8749e4..a9de3a426f14f 100644 --- a/x-pack/plugins/infra/server/routes/inventory_views/find_inventory_view.ts +++ b/x-pack/plugins/infra/server/routes/inventory_views/find_inventory_view.ts @@ -27,7 +27,7 @@ export const initFindInventoryViewRoute = ({ }, async (_requestContext, request, response) => { const { query } = request; - const { inventoryViews } = (await getStartServices())[2]; + const [, , { inventoryViews }] = await getStartServices(); const inventoryViewsClient = inventoryViews.getScopedClient(request); try { diff --git a/x-pack/plugins/infra/server/routes/inventory_views/get_inventory_view.ts b/x-pack/plugins/infra/server/routes/inventory_views/get_inventory_view.ts index 1a5f5adec136d..0cb9f815ef089 100644 --- a/x-pack/plugins/infra/server/routes/inventory_views/get_inventory_view.ts +++ b/x-pack/plugins/infra/server/routes/inventory_views/get_inventory_view.ts @@ -30,7 +30,7 @@ export const initGetInventoryViewRoute = ({ }, async (_requestContext, request, response) => { const { params, query } = request; - const { inventoryViews } = (await getStartServices())[2]; + const [, , { inventoryViews }] = await getStartServices(); const inventoryViewsClient = inventoryViews.getScopedClient(request); try { diff --git a/x-pack/plugins/infra/server/routes/inventory_views/update_inventory_view.ts b/x-pack/plugins/infra/server/routes/inventory_views/update_inventory_view.ts index d2b583437d177..0f225e0546fd1 100644 --- a/x-pack/plugins/infra/server/routes/inventory_views/update_inventory_view.ts +++ b/x-pack/plugins/infra/server/routes/inventory_views/update_inventory_view.ts @@ -32,7 +32,7 @@ export const initUpdateInventoryViewRoute = ({ }, async (_requestContext, request, response) => { const { body, params, query } = request; - const { inventoryViews } = (await getStartServices())[2]; + const [, , { inventoryViews }] = await getStartServices(); const inventoryViewsClient = inventoryViews.getScopedClient(request); try { diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer_views/README.md b/x-pack/plugins/infra/server/routes/metrics_explorer_views/README.md new file mode 100644 index 0000000000000..d14d8298d0d0f --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer_views/README.md @@ -0,0 +1,354 @@ +# Metrics Explorer Views CRUD api + +## Find all: `GET /api/infra/metrics_explorer_views` + +Retrieves all metrics explorer views in a reduced version. + +### Request + +- **Method**: GET +- **Path**: /api/infra/metrics_explorer_views +- **Query params**: + - `sourceId` _(optional)_: Specify a source id related to the metrics explorer views. Default value: `default`. + +### Response + +```json +GET /api/infra/metrics_explorer_views + +Status code: 200 + +{ + "data": [ + { + "id": "static", + "attributes": { + "name": "Default view", + "isDefault": false, + "isStatic": true + } + }, + { + "id": "927ad6a0-da0c-11ed-9487-41e9b90f96b9", + "version": "WzQwMiwxXQ==", + "updatedAt": 1681398305034, + "attributes": { + "name": "Ad-hoc", + "isDefault": true, + "isStatic": false + } + }, + { + "id": "c301ef20-da0c-11ed-aac0-77131228e6f1", + "version": "WzQxMCwxXQ==", + "updatedAt": 1681398386450, + "attributes": { + "name": "Custom", + "isDefault": false, + "isStatic": false + } + } + ] +} +``` + +## Get one: `GET /api/infra/metrics_explorer_views/{metricsExplorerViewId}` + +Retrieves a single metrics explorer view by ID + +### Request + +- **Method**: GET +- **Path**: /api/infra/metrics_explorer_views/{metricsExplorerViewId} +- **Query params**: + - `sourceId` _(optional)_: Specify a source id related to the metrics explorer view. Default value: `default`. + +### Response + +```json +GET /api/infra/metrics_explorer_views/927ad6a0-da0c-11ed-9487-41e9b90f96b9 + +Status code: 200 + +{ + "data": { + "id": "927ad6a0-da0c-11ed-9487-41e9b90f96b9", + "version": "WzQwMiwxXQ==", + "updatedAt": 1681398305034, + "attributes": { + "name": "Ad-hoc", + "isDefault": true, + "isStatic": false, + "metric": { + "type": "cpu" + }, + "sort": { + "by": "name", + "direction": "desc" + }, + "groupBy": [], + "nodeType": "host", + "view": "map", + "customOptions": [], + "customMetrics": [], + "boundsOverride": { + "max": 1, + "min": 0 + }, + "autoBounds": true, + "accountId": "", + "region": "", + "autoReload": false, + "filterQuery": { + "expression": "", + "kind": "kuery" + }, + "legend": { + "palette": "cool", + "reverseColors": false, + "steps": 10 + }, + "timelineOpen": false + } + } +} +``` + +```json +GET /api/infra/metrics_explorer_views/random-id + +Status code: 404 + +{ + "statusCode": 404, + "error": "Not Found", + "message": "Saved object [metrics-explorer-view/random-id] not found" +} +``` + +## Create one: `POST /api/infra/metrics_explorer_views` + +Creates a new metrics explorer view. + +### Request + +- **Method**: POST +- **Path**: /api/infra/metrics_explorer_views +- **Request body**: + ```json + { + "attributes": { + "name": "View name", + "metric": { + "type": "cpu" + }, + "sort": { + "by": "name", + "direction": "desc" + }, + //... + } + } + ``` + +### Response + +```json +POST /api/infra/metrics_explorer_views + +Status code: 201 + +{ + "data": { + "id": "927ad6a0-da0c-11ed-9487-41e9b90f96b9", + "version": "WzQwMiwxXQ==", + "updatedAt": 1681398305034, + "attributes": { + "name": "View name", + "isDefault": false, + "isStatic": false, + "metric": { + "type": "cpu" + }, + "sort": { + "by": "name", + "direction": "desc" + }, + "groupBy": [], + "nodeType": "host", + "view": "map", + "customOptions": [], + "customMetrics": [], + "boundsOverride": { + "max": 1, + "min": 0 + }, + "autoBounds": true, + "accountId": "", + "region": "", + "autoReload": false, + "filterQuery": { + "expression": "", + "kind": "kuery" + }, + "legend": { + "palette": "cool", + "reverseColors": false, + "steps": 10 + }, + "timelineOpen": false + } + } +} +``` + +Send in the payload a `name` attribute already held by another view: +```json +POST /api/infra/metrics_explorer_views + +Status code: 409 + +{ + "statusCode": 409, + "error": "Conflict", + "message": "A view with that name already exists." +} +``` + +## Update one: `PUT /api/infra/metrics_explorer_views/{metricsExplorerViewId}` + +Updates a metrics explorer view. + +Any attribute can be updated except for `isDefault` and `isStatic`, which are derived by the source configuration preference set by the user. + +Any attempt to update the static view with id `0` will return a `400 The metrics explorer view with id 0 is not configurable.` + +### Request + +- **Method**: PUT +- **Path**: /api/infra/metrics_explorer_views/{metricsExplorerViewId} +- **Query params**: + - `sourceId` _(optional)_: Specify a source id related to the metrics explorer view. Default value: `default`. +- **Request body**: + ```json + { + "attributes": { + "name": "View name", + "metric": { + "type": "cpu" + }, + "sort": { + "by": "name", + "direction": "desc" + }, + //... + } + } + ``` + +### Response + +```json +PUT /api/infra/metrics_explorer_views/927ad6a0-da0c-11ed-9487-41e9b90f96b9 + +Status code: 200 + +{ + "data": { + "id": "927ad6a0-da0c-11ed-9487-41e9b90f96b9", + "version": "WzQwMiwxXQ==", + "updatedAt": 1681398305034, + "attributes": { + "name": "View name", + "isDefault": false, + "isStatic": false, + "metric": { + "type": "cpu" + }, + "sort": { + "by": "name", + "direction": "desc" + }, + "groupBy": [], + "nodeType": "host", + "view": "map", + "customOptions": [], + "customMetrics": [], + "boundsOverride": { + "max": 1, + "min": 0 + }, + "autoBounds": true, + "accountId": "", + "region": "", + "autoReload": false, + "filterQuery": { + "expression": "", + "kind": "kuery" + }, + "legend": { + "palette": "cool", + "reverseColors": false, + "steps": 10 + }, + "timelineOpen": false + } + } +} +``` + +```json +PUT /api/infra/metrics_explorer_views/random-id + +Status code: 404 + +{ + "statusCode": 404, + "error": "Not Found", + "message": "Saved object [metrics-explorer-view/random-id] not found" +} +``` + +Send in the payload a `name` attribute already held by another view: +```json +PUT /api/infra/metrics_explorer_views/927ad6a0-da0c-11ed-9487-41e9b90f96b9 + +Status code: 409 + +{ + "statusCode": 409, + "error": "Conflict", + "message": "A view with that name already exists." +} +``` + +## Delete one: `DELETE /api/infra/metrics_explorer_views/{metricsExplorerViewId}` + +Deletes a metrics explorer view. + +Any attempt to delete the static view with id `0` will return a `400 The metrics explorer view with id 0 is not configurable.` + +### Request + +- **Method**: DELETE +- **Path**: /api/infra/metrics_explorer_views/{metricsExplorerViewId} + +### Response + +```json +DELETE /api/infra/metrics_explorer_views/927ad6a0-da0c-11ed-9487-41e9b90f96b9 + +Status code: 204 No content +``` + +```json +DELETE /api/infra/metrics_explorer_views/random-id + +Status code: 404 + +{ + "statusCode": 404, + "error": "Not Found", + "message": "Saved object [metrics-explorer-view/random-id] not found" +} +``` diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer_views/create_metrics_explorer_view.ts b/x-pack/plugins/infra/server/routes/metrics_explorer_views/create_metrics_explorer_view.ts new file mode 100644 index 0000000000000..948dd757e7e01 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer_views/create_metrics_explorer_view.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isBoom } from '@hapi/boom'; +import { createValidationFunction } from '../../../common/runtime_types'; +import { + createMetricsExplorerViewRequestPayloadRT, + metricsExplorerViewResponsePayloadRT, + METRICS_EXPLORER_VIEW_URL, +} from '../../../common/http_api/latest'; +import type { InfraBackendLibs } from '../../lib/infra_types'; + +export const initCreateMetricsExplorerViewRoute = ({ + framework, + getStartServices, +}: Pick) => { + framework.registerRoute( + { + method: 'post', + path: METRICS_EXPLORER_VIEW_URL, + validate: { + body: createValidationFunction(createMetricsExplorerViewRequestPayloadRT), + }, + }, + async (_requestContext, request, response) => { + const { body } = request; + const [, , { metricsExplorerViews }] = await getStartServices(); + const metricsExplorerViewsClient = metricsExplorerViews.getScopedClient(request); + + try { + const metricsExplorerView = await metricsExplorerViewsClient.create(body.attributes); + + return response.custom({ + statusCode: 201, + body: metricsExplorerViewResponsePayloadRT.encode({ data: metricsExplorerView }), + }); + } catch (error) { + if (isBoom(error)) { + return response.customError({ + statusCode: error.output.statusCode, + body: { message: error.output.payload.message }, + }); + } + + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer_views/delete_metrics_explorer_view.ts b/x-pack/plugins/infra/server/routes/metrics_explorer_views/delete_metrics_explorer_view.ts new file mode 100644 index 0000000000000..a3b6f8b05f099 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer_views/delete_metrics_explorer_view.ts @@ -0,0 +1,54 @@ +/* + * 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 { isBoom } from '@hapi/boom'; +import { createValidationFunction } from '../../../common/runtime_types'; +import { + metricsExplorerViewRequestParamsRT, + METRICS_EXPLORER_VIEW_URL_ENTITY, +} from '../../../common/http_api/latest'; +import type { InfraBackendLibs } from '../../lib/infra_types'; + +export const initDeleteMetricsExplorerViewRoute = ({ + framework, + getStartServices, +}: Pick) => { + framework.registerRoute( + { + method: 'delete', + path: METRICS_EXPLORER_VIEW_URL_ENTITY, + validate: { + params: createValidationFunction(metricsExplorerViewRequestParamsRT), + }, + }, + async (_requestContext, request, response) => { + const { params } = request; + const [, , { metricsExplorerViews }] = await getStartServices(); + const metricsExplorerViewsClient = metricsExplorerViews.getScopedClient(request); + + try { + await metricsExplorerViewsClient.delete(params.metricsExplorerViewId); + + return response.noContent(); + } catch (error) { + if (isBoom(error)) { + return response.customError({ + statusCode: error.output.statusCode, + body: { message: error.output.payload.message }, + }); + } + + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer_views/find_metrics_explorer_view.ts b/x-pack/plugins/infra/server/routes/metrics_explorer_views/find_metrics_explorer_view.ts new file mode 100644 index 0000000000000..fbae7790b04eb --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer_views/find_metrics_explorer_view.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createValidationFunction } from '../../../common/runtime_types'; +import { + findMetricsExplorerViewResponsePayloadRT, + metricsExplorerViewRequestQueryRT, + METRICS_EXPLORER_VIEW_URL, +} from '../../../common/http_api/latest'; +import type { InfraBackendLibs } from '../../lib/infra_types'; + +export const initFindMetricsExplorerViewRoute = ({ + framework, + getStartServices, +}: Pick) => { + framework.registerRoute( + { + method: 'get', + path: METRICS_EXPLORER_VIEW_URL, + validate: { + query: createValidationFunction(metricsExplorerViewRequestQueryRT), + }, + }, + async (_requestContext, request, response) => { + const { query } = request; + const [, , { metricsExplorerViews }] = await getStartServices(); + const metricsExplorerViewsClient = metricsExplorerViews.getScopedClient(request); + + try { + const metricsExplorerViewsList = await metricsExplorerViewsClient.find(query); + + return response.ok({ + body: findMetricsExplorerViewResponsePayloadRT.encode({ data: metricsExplorerViewsList }), + }); + } catch (error) { + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer_views/get_metrics_explorer_view.ts b/x-pack/plugins/infra/server/routes/metrics_explorer_views/get_metrics_explorer_view.ts new file mode 100644 index 0000000000000..b8e71a3c662d6 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer_views/get_metrics_explorer_view.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isBoom } from '@hapi/boom'; +import { createValidationFunction } from '../../../common/runtime_types'; +import { + metricsExplorerViewResponsePayloadRT, + metricsExplorerViewRequestQueryRT, + METRICS_EXPLORER_VIEW_URL_ENTITY, + getMetricsExplorerViewRequestParamsRT, +} from '../../../common/http_api/latest'; +import type { InfraBackendLibs } from '../../lib/infra_types'; + +export const initGetMetricsExplorerViewRoute = ({ + framework, + getStartServices, +}: Pick) => { + framework.registerRoute( + { + method: 'get', + path: METRICS_EXPLORER_VIEW_URL_ENTITY, + validate: { + params: createValidationFunction(getMetricsExplorerViewRequestParamsRT), + query: createValidationFunction(metricsExplorerViewRequestQueryRT), + }, + }, + async (_requestContext, request, response) => { + const { params, query } = request; + const [, , { metricsExplorerViews }] = await getStartServices(); + const metricsExplorerViewsClient = metricsExplorerViews.getScopedClient(request); + + try { + const metricsExplorerView = await metricsExplorerViewsClient.get( + params.metricsExplorerViewId, + query + ); + + return response.ok({ + body: metricsExplorerViewResponsePayloadRT.encode({ data: metricsExplorerView }), + }); + } catch (error) { + if (isBoom(error)) { + return response.customError({ + statusCode: error.output.statusCode, + body: { message: error.output.payload.message }, + }); + } + + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer_views/index.ts b/x-pack/plugins/infra/server/routes/metrics_explorer_views/index.ts new file mode 100644 index 0000000000000..e4a6165374422 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer_views/index.ts @@ -0,0 +1,23 @@ +/* + * 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 { InfraBackendLibs } from '../../lib/infra_types'; +import { initCreateMetricsExplorerViewRoute } from './create_metrics_explorer_view'; +import { initDeleteMetricsExplorerViewRoute } from './delete_metrics_explorer_view'; +import { initFindMetricsExplorerViewRoute } from './find_metrics_explorer_view'; +import { initGetMetricsExplorerViewRoute } from './get_metrics_explorer_view'; +import { initUpdateMetricsExplorerViewRoute } from './update_metrics_explorer_view'; + +export const initMetricsExplorerViewRoutes = ( + dependencies: Pick +) => { + initCreateMetricsExplorerViewRoute(dependencies); + initDeleteMetricsExplorerViewRoute(dependencies); + initFindMetricsExplorerViewRoute(dependencies); + initGetMetricsExplorerViewRoute(dependencies); + initUpdateMetricsExplorerViewRoute(dependencies); +}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer_views/update_metrics_explorer_view.ts b/x-pack/plugins/infra/server/routes/metrics_explorer_views/update_metrics_explorer_view.ts new file mode 100644 index 0000000000000..ebd8caef8e030 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer_views/update_metrics_explorer_view.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isBoom } from '@hapi/boom'; +import { createValidationFunction } from '../../../common/runtime_types'; +import { + metricsExplorerViewRequestParamsRT, + metricsExplorerViewRequestQueryRT, + metricsExplorerViewResponsePayloadRT, + METRICS_EXPLORER_VIEW_URL_ENTITY, + updateMetricsExplorerViewRequestPayloadRT, +} from '../../../common/http_api/latest'; +import type { InfraBackendLibs } from '../../lib/infra_types'; + +export const initUpdateMetricsExplorerViewRoute = ({ + framework, + getStartServices, +}: Pick) => { + framework.registerRoute( + { + method: 'put', + path: METRICS_EXPLORER_VIEW_URL_ENTITY, + validate: { + params: createValidationFunction(metricsExplorerViewRequestParamsRT), + query: createValidationFunction(metricsExplorerViewRequestQueryRT), + body: createValidationFunction(updateMetricsExplorerViewRequestPayloadRT), + }, + }, + async (_requestContext, request, response) => { + const { body, params, query } = request; + const [, , { metricsExplorerViews }] = await getStartServices(); + const metricsExplorerViewsClient = metricsExplorerViews.getScopedClient(request); + + try { + const metricsExplorerView = await metricsExplorerViewsClient.update( + params.metricsExplorerViewId, + body.attributes, + query + ); + + return response.ok({ + body: metricsExplorerViewResponsePayloadRT.encode({ data: metricsExplorerView }), + }); + } catch (error) { + if (isBoom(error)) { + return response.customError({ + statusCode: error.output.statusCode, + body: { message: error.output.payload.message }, + }); + } + + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/infra/server/saved_objects/metrics_explorer_view/types.ts b/x-pack/plugins/infra/server/saved_objects/metrics_explorer_view/types.ts index 1168b2003994e..15fe0eb970cc2 100644 --- a/x-pack/plugins/infra/server/saved_objects/metrics_explorer_view/types.ts +++ b/x-pack/plugins/infra/server/saved_objects/metrics_explorer_view/types.ts @@ -5,14 +5,20 @@ * 2.0. */ -import { isoToEpochRt } from '@kbn/io-ts-utils'; +import { isoToEpochRt, nonEmptyStringRt } from '@kbn/io-ts-utils'; import * as rt from 'io-ts'; -import { metricsExplorerViewAttributesRT } from '../../../common/metrics_explorer_views'; + +export const metricsExplorerViewSavedObjectAttributesRT = rt.intersection([ + rt.strict({ + name: nonEmptyStringRt, + }), + rt.UnknownRecord, +]); export const metricsExplorerViewSavedObjectRT = rt.intersection([ rt.type({ id: rt.string, - attributes: metricsExplorerViewAttributesRT, + attributes: metricsExplorerViewSavedObjectAttributesRT, }), rt.partial({ version: rt.string, diff --git a/x-pack/plugins/infra/server/services/inventory_views/inventory_views_client.ts b/x-pack/plugins/infra/server/services/inventory_views/inventory_views_client.ts index 55a8df1024a6e..c32da344354b6 100644 --- a/x-pack/plugins/infra/server/services/inventory_views/inventory_views_client.ts +++ b/x-pack/plugins/infra/server/services/inventory_views/inventory_views_client.ts @@ -35,18 +35,16 @@ export class InventoryViewsClient implements IInventoryViewsClient { ) {} static STATIC_VIEW_ID = '0'; + static DEFAULT_SOURCE_ID = 'default'; public async find(query: InventoryViewRequestQuery): Promise { this.logger.debug('Trying to load inventory views ...'); - const sourceId = query.sourceId ?? 'default'; + const sourceId = query.sourceId ?? InventoryViewsClient.DEFAULT_SOURCE_ID; const [sourceConfiguration, inventoryViewSavedObject] = await Promise.all([ this.infraSources.getSourceConfiguration(this.savedObjectsClient, sourceId), - this.savedObjectsClient.find({ - type: inventoryViewSavedObjectName, - perPage: 1000, // Fetch 1 page by default with a max of 1000 results - }), + this.getAllViews(), ]); const defaultView = InventoryViewsClient.createStaticView( @@ -72,7 +70,7 @@ export class InventoryViewsClient implements IInventoryViewsClient { ): Promise { this.logger.debug(`Trying to load inventory view with id ${inventoryViewId} ...`); - const sourceId = query.sourceId ?? 'default'; + const sourceId = query.sourceId ?? InventoryViewsClient.DEFAULT_SOURCE_ID; // Handle the case where the requested resource is the static inventory view if (inventoryViewId === InventoryViewsClient.STATIC_VIEW_ID) { @@ -123,7 +121,7 @@ export class InventoryViewsClient implements IInventoryViewsClient { // Validate there is not a view with the same name await this.assertNameConflict(attributes.name, [inventoryViewId]); - const sourceId = query.sourceId ?? 'default'; + const sourceId = query.sourceId ?? InventoryViewsClient.DEFAULT_SOURCE_ID; const [sourceConfiguration, inventoryViewSavedObject] = await Promise.all([ this.infraSources.getSourceConfiguration(this.savedObjectsClient, sourceId), @@ -160,6 +158,13 @@ export class InventoryViewsClient implements IInventoryViewsClient { }; } + private getAllViews() { + return this.savedObjectsClient.find({ + type: inventoryViewSavedObjectName, + perPage: 1000, // Fetch 1 page by default with a max of 1000 results + }); + } + private moveDefaultViewOnTop(views: InventoryView[]) { const defaultViewPosition = views.findIndex((view) => view.attributes.isDefault); @@ -175,10 +180,7 @@ export class InventoryViewsClient implements IInventoryViewsClient { * We want to control conflicting names on the views */ private async assertNameConflict(name: string, whitelist: string[] = []) { - const results = await this.savedObjectsClient.find({ - type: inventoryViewSavedObjectName, - perPage: 1000, - }); + const results = await this.getAllViews(); const hasConflict = [InventoryViewsClient.createStaticView(), ...results.saved_objects].some( (obj) => !whitelist.includes(obj.id) && obj.attributes.name === name diff --git a/x-pack/plugins/infra/server/services/metrics_explorer_views/index.ts b/x-pack/plugins/infra/server/services/metrics_explorer_views/index.ts new file mode 100644 index 0000000000000..3cd3efd6c0f67 --- /dev/null +++ b/x-pack/plugins/infra/server/services/metrics_explorer_views/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { MetricsExplorerViewsService } from './metrics_explorer_views_service'; +export { MetricsExplorerViewsClient } from './metrics_explorer_views_client'; +export type { + MetricsExplorerViewsServiceSetup, + MetricsExplorerViewsServiceStart, + MetricsExplorerViewsServiceStartDeps, +} from './types'; diff --git a/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.mock.ts b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.mock.ts new file mode 100644 index 0000000000000..82a8cba3f6427 --- /dev/null +++ b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.mock.ts @@ -0,0 +1,17 @@ +/* + * 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 { IMetricsExplorerViewsClient } from './types'; + +export const createMetricsExplorerViewsClientMock = + (): jest.Mocked => ({ + delete: jest.fn(), + find: jest.fn(), + get: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }); diff --git a/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.test.ts b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.test.ts new file mode 100644 index 0000000000000..c903e9af360f8 --- /dev/null +++ b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.test.ts @@ -0,0 +1,262 @@ +/* + * 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 { loggerMock } from '@kbn/logging-mocks'; +import { SavedObjectsClientContract } from '@kbn/core/server'; +import { savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { MetricsExplorerViewAttributes } from '../../../common/metrics_explorer_views'; + +import { InfraSource } from '../../lib/sources'; +import { createInfraSourcesMock } from '../../lib/sources/mocks'; +import { metricsExplorerViewSavedObjectName } from '../../saved_objects/metrics_explorer_view'; +import { MetricsExplorerViewsClient } from './metrics_explorer_views_client'; +import { createMetricsExplorerViewMock } from '../../../common/metrics_explorer_views/metrics_explorer_view.mock'; +import { + CreateMetricsExplorerViewAttributesRequestPayload, + UpdateMetricsExplorerViewAttributesRequestPayload, +} from '../../../common/http_api/latest'; + +describe('MetricsExplorerViewsClient class', () => { + const mockFindMetricsExplorerList = ( + savedObjectsClient: jest.Mocked + ) => { + const metricsExplorerViewListMock = [ + createMetricsExplorerViewMock('0', { + isDefault: true, + } as MetricsExplorerViewAttributes), + createMetricsExplorerViewMock('default_id', { + name: 'Default view 2', + isStatic: false, + } as MetricsExplorerViewAttributes), + createMetricsExplorerViewMock('custom_id', { + name: 'Custom', + isStatic: false, + } as MetricsExplorerViewAttributes), + ]; + + savedObjectsClient.find.mockResolvedValue({ + total: 2, + saved_objects: metricsExplorerViewListMock.slice(1).map((view) => ({ + ...view, + type: metricsExplorerViewSavedObjectName, + score: 0, + references: [], + })), + per_page: 1000, + page: 1, + }); + + return metricsExplorerViewListMock; + }; + + describe('.find', () => { + it('resolves the list of existing metrics explorer views', async () => { + const { metricsExplorerViewsClient, infraSources, savedObjectsClient } = + createMetricsExplorerViewsClient(); + + infraSources.getSourceConfiguration.mockResolvedValue(basicTestSourceConfiguration); + + const metricsExplorerViewListMock = mockFindMetricsExplorerList(savedObjectsClient); + + const metricsExplorerViewList = await metricsExplorerViewsClient.find({}); + + expect(savedObjectsClient.find).toHaveBeenCalled(); + expect(metricsExplorerViewList).toEqual(metricsExplorerViewListMock); + }); + + it('always resolves at least the static metrics explorer view', async () => { + const { metricsExplorerViewsClient, infraSources, savedObjectsClient } = + createMetricsExplorerViewsClient(); + + const metricsExplorerViewListMock = [ + createMetricsExplorerViewMock('0', { + isDefault: true, + } as MetricsExplorerViewAttributes), + ]; + + infraSources.getSourceConfiguration.mockResolvedValue(basicTestSourceConfiguration); + + savedObjectsClient.find.mockResolvedValue({ + total: 2, + saved_objects: [], + per_page: 1000, + page: 1, + }); + + const metricsExplorerViewList = await metricsExplorerViewsClient.find({}); + + expect(savedObjectsClient.find).toHaveBeenCalled(); + expect(metricsExplorerViewList).toEqual(metricsExplorerViewListMock); + }); + }); + + it('.get resolves the an metrics explorer view by id', async () => { + const { metricsExplorerViewsClient, infraSources, savedObjectsClient } = + createMetricsExplorerViewsClient(); + + const metricsExplorerViewMock = createMetricsExplorerViewMock('custom_id', { + name: 'Custom', + isDefault: false, + isStatic: false, + } as MetricsExplorerViewAttributes); + + infraSources.getSourceConfiguration.mockResolvedValue(basicTestSourceConfiguration); + + savedObjectsClient.get.mockResolvedValue({ + ...metricsExplorerViewMock, + type: metricsExplorerViewSavedObjectName, + references: [], + }); + + const metricsExplorerView = await metricsExplorerViewsClient.get('custom_id', {}); + + expect(savedObjectsClient.get).toHaveBeenCalled(); + expect(metricsExplorerView).toEqual(metricsExplorerViewMock); + }); + + describe('.create', () => { + it('generate a new metrics explorer view', async () => { + const { metricsExplorerViewsClient, savedObjectsClient } = createMetricsExplorerViewsClient(); + + const metricsExplorerViewMock = createMetricsExplorerViewMock('new_id', { + name: 'New view', + isStatic: false, + } as MetricsExplorerViewAttributes); + + mockFindMetricsExplorerList(savedObjectsClient); + + savedObjectsClient.create.mockResolvedValue({ + ...metricsExplorerViewMock, + type: metricsExplorerViewSavedObjectName, + references: [], + }); + + const metricsExplorerView = await metricsExplorerViewsClient.create({ + name: 'New view', + } as CreateMetricsExplorerViewAttributesRequestPayload); + + expect(savedObjectsClient.create).toHaveBeenCalled(); + expect(metricsExplorerView).toEqual(metricsExplorerViewMock); + }); + + it('throws an error when a conflicting name is given', async () => { + const { metricsExplorerViewsClient, savedObjectsClient } = createMetricsExplorerViewsClient(); + + mockFindMetricsExplorerList(savedObjectsClient); + + await expect( + async () => + await metricsExplorerViewsClient.create({ + name: 'Custom', + } as CreateMetricsExplorerViewAttributesRequestPayload) + ).rejects.toThrow('A view with that name already exists.'); + }); + }); + + describe('.update', () => { + it('update an existing metrics explorer view by id', async () => { + const { metricsExplorerViewsClient, infraSources, savedObjectsClient } = + createMetricsExplorerViewsClient(); + + const metricsExplorerViews = mockFindMetricsExplorerList(savedObjectsClient); + + const metricsExplorerViewMock = { + ...metricsExplorerViews[1], + attributes: { + ...metricsExplorerViews[1].attributes, + name: 'New name', + }, + }; + + infraSources.getSourceConfiguration.mockResolvedValue(basicTestSourceConfiguration); + + savedObjectsClient.update.mockResolvedValue({ + ...metricsExplorerViewMock, + type: metricsExplorerViewSavedObjectName, + references: [], + }); + + const metricsExplorerView = await metricsExplorerViewsClient.update( + 'default_id', + { + name: 'New name', + } as UpdateMetricsExplorerViewAttributesRequestPayload, + {} + ); + + expect(savedObjectsClient.update).toHaveBeenCalled(); + expect(metricsExplorerView).toEqual(metricsExplorerViewMock); + }); + + it('throws an error when a conflicting name is given', async () => { + const { metricsExplorerViewsClient, savedObjectsClient } = createMetricsExplorerViewsClient(); + + mockFindMetricsExplorerList(savedObjectsClient); + + await expect( + async () => + await metricsExplorerViewsClient.update( + 'default_id', + { + name: 'Custom', + } as UpdateMetricsExplorerViewAttributesRequestPayload, + {} + ) + ).rejects.toThrow('A view with that name already exists.'); + }); + }); + + it('.delete removes an metrics explorer view by id', async () => { + const { metricsExplorerViewsClient, savedObjectsClient } = createMetricsExplorerViewsClient(); + + savedObjectsClient.delete.mockResolvedValue({}); + + const metricsExplorerView = await metricsExplorerViewsClient.delete('custom_id'); + + expect(savedObjectsClient.delete).toHaveBeenCalled(); + expect(metricsExplorerView).toEqual({}); + }); +}); + +const createMetricsExplorerViewsClient = () => { + const logger = loggerMock.create(); + const savedObjectsClient = savedObjectsClientMock.create(); + const infraSources = createInfraSourcesMock(); + + const metricsExplorerViewsClient = new MetricsExplorerViewsClient( + logger, + savedObjectsClient, + infraSources + ); + + return { + infraSources, + metricsExplorerViewsClient, + savedObjectsClient, + }; +}; + +const basicTestSourceConfiguration: InfraSource = { + id: 'ID', + origin: 'stored', + configuration: { + name: 'NAME', + description: 'DESCRIPTION', + logIndices: { + type: 'index_pattern', + indexPatternId: 'INDEX_PATTERN_ID', + }, + logColumns: [], + fields: { + message: [], + }, + metricAlias: 'METRIC_ALIAS', + inventoryDefaultView: '0', + metricsExplorerDefaultView: '0', + anomalyThreshold: 0, + }, +}; diff --git a/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.ts b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.ts new file mode 100644 index 0000000000000..1ba34456d88a8 --- /dev/null +++ b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_client.ts @@ -0,0 +1,218 @@ +/* + * 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 { + Logger, + SavedObject, + SavedObjectsClientContract, + SavedObjectsUpdateResponse, +} from '@kbn/core/server'; +import Boom from '@hapi/boom'; +import { + staticMetricsExplorerViewAttributes, + staticMetricsExplorerViewId, +} from '../../../common/metrics_explorer_views'; +import type { + CreateMetricsExplorerViewAttributesRequestPayload, + MetricsExplorerViewRequestQuery, +} from '../../../common/http_api/latest'; +import type { + MetricsExplorerView, + MetricsExplorerViewAttributes, +} from '../../../common/metrics_explorer_views'; +import { decodeOrThrow } from '../../../common/runtime_types'; +import type { IInfraSources } from '../../lib/sources'; +import { metricsExplorerViewSavedObjectName } from '../../saved_objects/metrics_explorer_view'; +import { metricsExplorerViewSavedObjectRT } from '../../saved_objects/metrics_explorer_view/types'; +import type { IMetricsExplorerViewsClient } from './types'; + +export class MetricsExplorerViewsClient implements IMetricsExplorerViewsClient { + constructor( + private readonly logger: Logger, + private readonly savedObjectsClient: SavedObjectsClientContract, + private readonly infraSources: IInfraSources + ) {} + + static STATIC_VIEW_ID = '0'; + static DEFAULT_SOURCE_ID = 'default'; + + public async find(query: MetricsExplorerViewRequestQuery): Promise { + this.logger.debug('Trying to load metrics explorer views ...'); + + const sourceId = query.sourceId ?? MetricsExplorerViewsClient.DEFAULT_SOURCE_ID; + + const [sourceConfiguration, metricsExplorerViewSavedObject] = await Promise.all([ + this.infraSources.getSourceConfiguration(this.savedObjectsClient, sourceId), + this.getAllViews(), + ]); + + const defaultView = MetricsExplorerViewsClient.createStaticView( + sourceConfiguration.configuration.metricsExplorerDefaultView + ); + const views = metricsExplorerViewSavedObject.saved_objects.map((savedObject) => + this.mapSavedObjectToMetricsExplorerView( + savedObject, + sourceConfiguration.configuration.metricsExplorerDefaultView + ) + ); + + const metricsExplorerViews = [defaultView, ...views]; + + const sortedMetricsExplorerViews = this.moveDefaultViewOnTop(metricsExplorerViews); + + return sortedMetricsExplorerViews; + } + + public async get( + metricsExplorerViewId: string, + query: MetricsExplorerViewRequestQuery + ): Promise { + this.logger.debug(`Trying to load metrics explorer view with id ${metricsExplorerViewId} ...`); + + const sourceId = query.sourceId ?? MetricsExplorerViewsClient.DEFAULT_SOURCE_ID; + + // Handle the case where the requested resource is the static metrics explorer view + if (metricsExplorerViewId === MetricsExplorerViewsClient.STATIC_VIEW_ID) { + const sourceConfiguration = await this.infraSources.getSourceConfiguration( + this.savedObjectsClient, + sourceId + ); + + return MetricsExplorerViewsClient.createStaticView( + sourceConfiguration.configuration.metricsExplorerDefaultView + ); + } + + const [sourceConfiguration, metricsExplorerViewSavedObject] = await Promise.all([ + this.infraSources.getSourceConfiguration(this.savedObjectsClient, sourceId), + this.savedObjectsClient.get(metricsExplorerViewSavedObjectName, metricsExplorerViewId), + ]); + + return this.mapSavedObjectToMetricsExplorerView( + metricsExplorerViewSavedObject, + sourceConfiguration.configuration.metricsExplorerDefaultView + ); + } + + public async create( + attributes: CreateMetricsExplorerViewAttributesRequestPayload + ): Promise { + this.logger.debug(`Trying to create metrics explorer view ...`); + + // Validate there is not a view with the same name + await this.assertNameConflict(attributes.name); + + const metricsExplorerViewSavedObject = await this.savedObjectsClient.create( + metricsExplorerViewSavedObjectName, + attributes + ); + + return this.mapSavedObjectToMetricsExplorerView(metricsExplorerViewSavedObject); + } + + public async update( + metricsExplorerViewId: string, + attributes: CreateMetricsExplorerViewAttributesRequestPayload, + query: MetricsExplorerViewRequestQuery + ): Promise { + this.logger.debug( + `Trying to update metrics explorer view with id "${metricsExplorerViewId}"...` + ); + + // Validate there is not a view with the same name + await this.assertNameConflict(attributes.name, [metricsExplorerViewId]); + + const sourceId = query.sourceId ?? MetricsExplorerViewsClient.DEFAULT_SOURCE_ID; + + const [sourceConfiguration, metricsExplorerViewSavedObject] = await Promise.all([ + this.infraSources.getSourceConfiguration(this.savedObjectsClient, sourceId), + this.savedObjectsClient.update( + metricsExplorerViewSavedObjectName, + metricsExplorerViewId, + attributes + ), + ]); + + return this.mapSavedObjectToMetricsExplorerView( + metricsExplorerViewSavedObject, + sourceConfiguration.configuration.metricsExplorerDefaultView + ); + } + + public delete(metricsExplorerViewId: string): Promise<{}> { + this.logger.debug( + `Trying to delete metrics explorer view with id ${metricsExplorerViewId} ...` + ); + + return this.savedObjectsClient.delete( + metricsExplorerViewSavedObjectName, + metricsExplorerViewId + ); + } + + private getAllViews() { + return this.savedObjectsClient.find({ + type: metricsExplorerViewSavedObjectName, + perPage: 1000, // Fetch 1 page by default with a max of 1000 results + }); + } + + private mapSavedObjectToMetricsExplorerView( + savedObject: SavedObject | SavedObjectsUpdateResponse, + defaultViewId?: string + ) { + const metricsExplorerViewSavedObject = decodeOrThrow(metricsExplorerViewSavedObjectRT)( + savedObject + ); + + return { + id: metricsExplorerViewSavedObject.id, + version: metricsExplorerViewSavedObject.version, + updatedAt: metricsExplorerViewSavedObject.updated_at, + attributes: { + ...metricsExplorerViewSavedObject.attributes, + isDefault: metricsExplorerViewSavedObject.id === defaultViewId, + isStatic: false, + }, + }; + } + + private moveDefaultViewOnTop(views: MetricsExplorerView[]) { + const defaultViewPosition = views.findIndex((view) => view.attributes.isDefault); + + if (defaultViewPosition !== -1) { + const element = views.splice(defaultViewPosition, 1)[0]; + views.unshift(element); + } + + return views; + } + + /** + * We want to control conflicting names on the views + */ + private async assertNameConflict(name: string, whitelist: string[] = []) { + const results = await this.getAllViews(); + + const hasConflict = [ + MetricsExplorerViewsClient.createStaticView(), + ...results.saved_objects, + ].some((obj) => !whitelist.includes(obj.id) && obj.attributes.name === name); + + if (hasConflict) { + throw Boom.conflict('A view with that name already exists.'); + } + } + + private static createStaticView = (defaultViewId?: string): MetricsExplorerView => ({ + id: staticMetricsExplorerViewId, + attributes: { + ...staticMetricsExplorerViewAttributes, + isDefault: defaultViewId === MetricsExplorerViewsClient.STATIC_VIEW_ID, + }, + }); +} diff --git a/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_service.mock.ts b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_service.mock.ts new file mode 100644 index 0000000000000..3739930944571 --- /dev/null +++ b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_service.mock.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createMetricsExplorerViewsClientMock } from './metrics_explorer_views_client.mock'; +import type { MetricsExplorerViewsServiceSetup, MetricsExplorerViewsServiceStart } from './types'; + +export const createMetricsExplorerViewsServiceSetupMock = + (): jest.Mocked => {}; + +export const createMetricsExplorerViewsServiceStartMock = + (): jest.Mocked => ({ + getClient: jest.fn((_savedObjectsClient: any) => createMetricsExplorerViewsClientMock()), + getScopedClient: jest.fn((_request: any) => createMetricsExplorerViewsClientMock()), + }); diff --git a/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_service.ts b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_service.ts new file mode 100644 index 0000000000000..38c7ab4e1f925 --- /dev/null +++ b/x-pack/plugins/infra/server/services/metrics_explorer_views/metrics_explorer_views_service.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { KibanaRequest, Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import { MetricsExplorerViewsClient } from './metrics_explorer_views_client'; +import type { + MetricsExplorerViewsServiceSetup, + MetricsExplorerViewsServiceStart, + MetricsExplorerViewsServiceStartDeps, +} from './types'; + +export class MetricsExplorerViewsService { + constructor(private readonly logger: Logger) {} + + public setup(): MetricsExplorerViewsServiceSetup {} + + public start({ + infraSources, + savedObjects, + }: MetricsExplorerViewsServiceStartDeps): MetricsExplorerViewsServiceStart { + const { logger } = this; + + return { + getClient(savedObjectsClient: SavedObjectsClientContract) { + return new MetricsExplorerViewsClient(logger, savedObjectsClient, infraSources); + }, + + getScopedClient(request: KibanaRequest) { + const savedObjectsClient = savedObjects.getScopedClient(request); + + return this.getClient(savedObjectsClient); + }, + }; + } +} diff --git a/x-pack/plugins/infra/server/services/metrics_explorer_views/types.ts b/x-pack/plugins/infra/server/services/metrics_explorer_views/types.ts new file mode 100644 index 0000000000000..0e64aaa83d27e --- /dev/null +++ b/x-pack/plugins/infra/server/services/metrics_explorer_views/types.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + KibanaRequest, + SavedObjectsClientContract, + SavedObjectsServiceStart, +} from '@kbn/core/server'; +import type { + CreateMetricsExplorerViewAttributesRequestPayload, + MetricsExplorerViewRequestQuery, + UpdateMetricsExplorerViewAttributesRequestPayload, +} from '../../../common/http_api/latest'; +import type { MetricsExplorerView } from '../../../common/metrics_explorer_views'; +import type { InfraSources } from '../../lib/sources'; + +export interface MetricsExplorerViewsServiceStartDeps { + infraSources: InfraSources; + savedObjects: SavedObjectsServiceStart; +} + +export type MetricsExplorerViewsServiceSetup = void; + +export interface MetricsExplorerViewsServiceStart { + getClient(savedObjectsClient: SavedObjectsClientContract): IMetricsExplorerViewsClient; + getScopedClient(request: KibanaRequest): IMetricsExplorerViewsClient; +} + +export interface IMetricsExplorerViewsClient { + delete(metricsExplorerViewId: string): Promise<{}>; + find(query: MetricsExplorerViewRequestQuery): Promise; + get( + metricsExplorerViewId: string, + query: MetricsExplorerViewRequestQuery + ): Promise; + create( + metricsExplorerViewAttributes: CreateMetricsExplorerViewAttributesRequestPayload + ): Promise; + update( + metricsExplorerViewId: string, + metricsExplorerViewAttributes: UpdateMetricsExplorerViewAttributesRequestPayload, + query: MetricsExplorerViewRequestQuery + ): Promise; +} diff --git a/x-pack/plugins/infra/server/types.ts b/x-pack/plugins/infra/server/types.ts index c415103d2256d..49dbca9b276b2 100644 --- a/x-pack/plugins/infra/server/types.ts +++ b/x-pack/plugins/infra/server/types.ts @@ -16,6 +16,7 @@ import type { InfraStaticSourceConfiguration } from '../common/source_configurat import { InfraServerPluginStartDeps } from './lib/adapters/framework'; import { InventoryViewsServiceStart } from './services/inventory_views'; import { LogViewsServiceSetup, LogViewsServiceStart } from './services/log_views/types'; +import { MetricsExplorerViewsServiceStart } from './services/metrics_explorer_views'; export type { InfraConfig } from '../common/plugin_config_types'; @@ -33,6 +34,7 @@ export interface InfraPluginSetup { export interface InfraPluginStart { inventoryViews: InventoryViewsServiceStart; logViews: LogViewsServiceStart; + metricsExplorerViews: MetricsExplorerViewsServiceStart; getMetricIndices: ( savedObjectsClient: SavedObjectsClientContract, sourceId?: string diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_editor.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_editor.tsx index 941047519cdac..c565c437bc59d 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_editor.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_editor.tsx @@ -908,18 +908,24 @@ export function FormulaEditor({ } > - {warnings.map(({ message, severity }, index) => ( -
- - {message} - -
- ))} +
+ {warnings.map(({ message, severity }, index) => ( +
+ + {message} + +
+ ))} +
) : null} diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.test.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.test.tsx index c127e9f1130d3..5e48e1f46dd63 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.test.tsx @@ -441,11 +441,6 @@ describe('embeddable', () => { expect(expressionRenderer).toHaveBeenCalledTimes(1); - embeddable.updateInput({ - filters: [{ meta: { alias: 'test', negate: false, disabled: false } }], - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - embeddable.updateInput({ searchSessionId: 'nextSession', }); diff --git a/x-pack/plugins/osquery/public/live_queries/form/index.tsx b/x-pack/plugins/osquery/public/live_queries/form/index.tsx index 7868c1bb3a471..d56b981b128c8 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/index.tsx @@ -82,16 +82,8 @@ const LiveQueryFormComponent: React.FC = ({ ); const hooksForm = useHookForm(); - const { - handleSubmit, - watch, - setValue, - resetField, - clearErrors, - getFieldState, - register, - formState: { isSubmitting }, - } = hooksForm; + const { handleSubmit, watch, setValue, resetField, clearErrors, getFieldState, register } = + hooksForm; const canRunSingleQuery = useMemo( () => @@ -157,7 +149,7 @@ const LiveQueryFormComponent: React.FC = ({ saved_query_id: values.savedQueryId, query, alert_ids: values.alertIds, - pack_id: values?.packId?.length ? values?.packId[0] : undefined, + pack_id: queryType === 'pack' && values?.packId?.length ? values?.packId[0] : undefined, ecs_mapping: values.ecs_mapping, }, (value) => !isEmpty(value) @@ -165,7 +157,7 @@ const LiveQueryFormComponent: React.FC = ({ await mutateAsync(serializedData); }, - [alertAttachmentContext, mutateAsync] + [alertAttachmentContext, mutateAsync, queryType] ); const serializedData: SavedQuerySOFormData = useMemo( @@ -196,7 +188,7 @@ const LiveQueryFormComponent: React.FC = ({ = ({ resultsStatus, handleShowSaveQueryFlyout, enabled, - isSubmitting, + isLoading, handleSubmit, onSubmit, ] diff --git a/x-pack/plugins/osquery/server/common/error.ts b/x-pack/plugins/osquery/server/common/error.ts new file mode 100644 index 0000000000000..b48fd925dad62 --- /dev/null +++ b/x-pack/plugins/osquery/server/common/error.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export class CustomHttpRequestError extends Error { + constructor(message: string, public readonly statusCode: number = 500) { + super(message); + // For debugging - capture name of subclasses + this.name = this.constructor.name; + this.message = message; + } +} diff --git a/x-pack/plugins/osquery/server/common/types.ts b/x-pack/plugins/osquery/server/common/types.ts index 522f1fa250ada..51dc4f59ed5b4 100644 --- a/x-pack/plugins/osquery/server/common/types.ts +++ b/x-pack/plugins/osquery/server/common/types.ts @@ -56,3 +56,7 @@ export interface SavedQuerySavedObjectAttributes { } export type SavedQuerySavedObject = SavedObject; + +export interface HTTPError extends Error { + statusCode: number; +} diff --git a/x-pack/plugins/osquery/server/handlers/action/create_action_handler.ts b/x-pack/plugins/osquery/server/handlers/action/create_action_handler.ts index b2f6ca09234eb..3c776723a2da2 100644 --- a/x-pack/plugins/osquery/server/handlers/action/create_action_handler.ts +++ b/x-pack/plugins/osquery/server/handlers/action/create_action_handler.ts @@ -21,6 +21,7 @@ import { convertSOQueriesToPack } from '../../routes/pack/utils'; import { ACTIONS_INDEX } from '../../../common/constants'; import { TELEMETRY_EBT_LIVE_QUERY_EVENT } from '../../lib/telemetry/constants'; import type { PackSavedObjectAttributes } from '../../common/types'; +import { CustomHttpRequestError } from '../../common/error'; interface Metadata { currentUser: string | undefined; @@ -55,7 +56,7 @@ export const createActionHandler = async ( }); if (!selectedAgents.length) { - throw new Error('No agents found for selection'); + throw new CustomHttpRequestError('No agents found for selection', 400); } let packSO; diff --git a/x-pack/plugins/osquery/server/lib/fleet_integration.ts b/x-pack/plugins/osquery/server/lib/fleet_integration.ts index f03afedc8628a..684334c1488b4 100644 --- a/x-pack/plugins/osquery/server/lib/fleet_integration.ts +++ b/x-pack/plugins/osquery/server/lib/fleet_integration.ts @@ -34,11 +34,20 @@ export const getPackagePolicyDeleteCallback = await Promise.all( map( foundPacks.saved_objects, - (pack: { id: string; references: SavedObjectReference[] }) => + (pack: { + id: string; + references: SavedObjectReference[]; + attributes: { shards: Array<{ key: string; value: string }> }; + }) => packsClient.update( packSavedObjectType, pack.id, - {}, + { + shards: filter( + pack.attributes.shards, + (shard) => shard.key !== deletedOsqueryManagerPolicy.policy_id + ), + }, { references: filter( pack.references, diff --git a/x-pack/plugins/osquery/server/routes/live_query/create_live_query_route.ts b/x-pack/plugins/osquery/server/routes/live_query/create_live_query_route.ts index 9d7ad88da88b6..05f857e320066 100644 --- a/x-pack/plugins/osquery/server/routes/live_query/create_live_query_route.ts +++ b/x-pack/plugins/osquery/server/routes/live_query/create_live_query_route.ts @@ -113,8 +113,9 @@ export const createLiveQueryRoute = (router: IRouter, osqueryContext: OsqueryApp body: { data: osqueryAction }, }); } catch (error) { - // TODO validate for 400 (when agents are not found for selection) - // return response.badRequest({ body: new Error('No agents found for selection') }); + if (error.statusCode === 400) { + return response.badRequest({ body: error }); + } return response.customError({ statusCode: 500, diff --git a/x-pack/plugins/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts b/x-pack/plugins/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts index ca9c85fb1a481..f89688b36fee4 100644 --- a/x-pack/plugins/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts +++ b/x-pack/plugins/synthetics/common/runtime_types/monitor_management/synthetics_overview_status.ts @@ -59,6 +59,7 @@ export const OverviewStatusCodec = t.interface({ downConfigs: t.record(t.string, OverviewStatusMetaDataCodec), pendingConfigs: t.record(t.string, OverviewPendingStatusMetaDataCodec), enabledMonitorQueryIds: t.array(t.string), + disabledMonitorQueryIds: t.array(t.string), allIds: t.array(t.string), }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_alerts.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_alerts.tsx index 693cd0d9ed85b..7d8f6268f48fb 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_alerts.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_alerts.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -24,6 +24,33 @@ import { AlertsLink } from '../../../common/links/view_alerts'; import { useRefreshedRange, useGetUrlParams } from '../../../../hooks'; import { ClientPluginsStart } from '../../../../../../plugin'; +export const useMonitorQueryIds = () => { + const { status } = useSelector(selectOverviewStatus); + + const { statusFilter } = useGetUrlParams(); + return useMemo(() => { + let monitorIds = status?.enabledMonitorQueryIds ?? []; + switch (statusFilter) { + case 'up': + monitorIds = status + ? Object.entries(status.upConfigs).map(([id, config]) => config.monitorQueryId) + : []; + break; + case 'down': + monitorIds = status + ? Object.entries(status.downConfigs).map(([id, config]) => config.monitorQueryId) + : []; + break; + case 'disabled': + monitorIds = status?.disabledMonitorQueryIds ?? []; + break; + default: + break; + } + return monitorIds.length > 0 ? monitorIds : ['false-id']; + }, [status, statusFilter]); +}; + export const OverviewAlerts = () => { const { from, to } = useRefreshedRange(12, 'hours'); @@ -39,6 +66,8 @@ export const OverviewAlerts = () => { const loading = !status?.allIds || status?.allIds.length === 0; + const monitorIds = useMonitorQueryIds(); + return ( @@ -66,10 +95,7 @@ export const OverviewAlerts = () => { selectedMetricField: RECORDS_FIELD, reportDefinitions: { 'kibana.alert.rule.category': ['Synthetics monitor status'], - 'monitor.id': - status?.enabledMonitorQueryIds.length > 0 - ? status?.enabledMonitorQueryIds - : ['false-id'], + 'monitor.id': monitorIds, ...(locations?.length ? { 'observer.geo.name': locations } : {}), }, filters: [{ field: 'kibana.alert.status', values: ['active', 'recovered'] }], @@ -93,10 +119,7 @@ export const OverviewAlerts = () => { }, reportDefinitions: { 'kibana.alert.rule.category': ['Synthetics monitor status'], - 'monitor.id': - status?.enabledMonitorQueryIds.length > 0 - ? status?.enabledMonitorQueryIds - : ['false-id'], + 'monitor.id': monitorIds, ...(locations?.length ? { 'observer.geo.name': locations } : {}), }, dataType: 'alerts', diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_errors/overview_errors.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_errors/overview_errors.tsx index 8f85b8bd90d43..ea4b0f8282ebf 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_errors/overview_errors.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_errors/overview_errors.tsx @@ -16,6 +16,7 @@ import { import React from 'react'; import { useSelector } from 'react-redux'; import { i18n } from '@kbn/i18n'; +import { useMonitorQueryIds } from '../overview_alerts'; import { selectOverviewStatus } from '../../../../../state/overview_status'; import { OverviewErrorsSparklines } from './overview_errors_sparklines'; import { useRefreshedRange, useGetUrlParams } from '../../../../../hooks'; @@ -28,7 +29,9 @@ export function OverviewErrors() { const { from, to } = useRefreshedRange(6, 'hours'); - const params = useGetUrlParams(); + const { locations } = useGetUrlParams(); + + const monitorIds = useMonitorQueryIds(); return ( @@ -44,16 +47,16 @@ export function OverviewErrors() { diff --git a/x-pack/plugins/synthetics/server/alert_rules/status_rule/status_rule_executor.ts b/x-pack/plugins/synthetics/server/alert_rules/status_rule/status_rule_executor.ts index 923177dc6b377..3b73245977dff 100644 --- a/x-pack/plugins/synthetics/server/alert_rules/status_rule/status_rule_executor.ts +++ b/x-pack/plugins/synthetics/server/alert_rules/status_rule/status_rule_executor.ts @@ -34,7 +34,8 @@ export interface StaleDownConfig extends OverviewStatusMetaData { isLocationRemoved?: boolean; } -export interface AlertOverviewStatus extends Omit { +export interface AlertOverviewStatus + extends Omit { staleDownConfigs: Record; } diff --git a/x-pack/plugins/synthetics/server/queries/query_monitor_status.ts b/x-pack/plugins/synthetics/server/queries/query_monitor_status.ts index a737e80e08069..dff791a6b535a 100644 --- a/x-pack/plugins/synthetics/server/queries/query_monitor_status.ts +++ b/x-pack/plugins/synthetics/server/queries/query_monitor_status.ts @@ -46,6 +46,7 @@ export async function queryMonitorStatus( | 'allMonitorsCount' | 'disabledMonitorsCount' | 'projectMonitorsCount' + | 'disabledMonitorQueryIds' | 'allIds' > > { diff --git a/x-pack/plugins/synthetics/server/routes/overview_status/overview_status.ts b/x-pack/plugins/synthetics/server/routes/overview_status/overview_status.ts index f8c55ba4fae39..35672937c0ffd 100644 --- a/x-pack/plugins/synthetics/server/routes/overview_status/overview_status.ts +++ b/x-pack/plugins/synthetics/server/routes/overview_status/overview_status.ts @@ -70,6 +70,7 @@ export async function getStatus(context: RouteContext, params: OverviewStatusQue const { enabledMonitorQueryIds, + disabledMonitorQueryIds, allIds, disabledCount, maxPeriod, @@ -112,6 +113,7 @@ export async function getStatus(context: RouteContext, params: OverviewStatusQue disabledMonitorsCount, projectMonitorsCount, enabledMonitorQueryIds, + disabledMonitorQueryIds, disabledCount, up, down, diff --git a/x-pack/plugins/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.test.ts b/x-pack/plugins/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.test.ts index 2c72ac660a588..8850f3b32c8df 100644 --- a/x-pack/plugins/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.test.ts +++ b/x-pack/plugins/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.test.ts @@ -59,6 +59,7 @@ describe('processMonitors', () => { 'aa925d91-40b0-4f8f-b695-bb9b53cd4e22', '7f796001-a795-4c0b-afdb-3ce74edea775', ], + disabledMonitorQueryIds: ['test-project-id-default'], listOfLocations: ['US Central QA', 'US Central Staging', 'North America - US Central'], maxPeriod: 600000, monitorLocationMap: { @@ -94,6 +95,7 @@ describe('processMonitors', () => { 'aa925d91-40b0-4f8f-b695-bb9b53cd4e22', '7f796001-a795-4c0b-afdb-3ce74edea775', ], + disabledMonitorQueryIds: ['test-project-id-default'], listOfLocations: [ 'US Central Staging', 'us_central_qa', @@ -172,6 +174,7 @@ describe('processMonitors', () => { 'aa925d91-40b0-4f8f-b695-bb9b53cd4e22', '7f796001-a795-4c0b-afdb-3ce74edea775', ], + disabledMonitorQueryIds: ['test-project-id-default'], listOfLocations: ['US Central Staging', 'US Central QA', 'North America - US Central'], maxPeriod: 600000, monitorLocationMap: { diff --git a/x-pack/plugins/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.ts b/x-pack/plugins/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.ts index 493c3a2889bdf..7670a742fa98a 100644 --- a/x-pack/plugins/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.ts +++ b/x-pack/plugins/synthetics/server/saved_objects/synthetics_monitor/get_all_monitors.ts @@ -73,6 +73,7 @@ export const processMonitors = async ( * latest ping for all enabled monitors. */ const enabledMonitorQueryIds: string[] = []; + const disabledMonitorQueryIds: string[] = []; let disabledCount = 0; let disabledMonitorsCount = 0; let maxPeriod = 0; @@ -116,6 +117,7 @@ export const processMonitors = async ( ); disabledCount += intersectingLocations.length; disabledMonitorsCount += 1; + disabledMonitorQueryIds.push(attrs[ConfigKey.MONITOR_QUERY_ID]); } else { const missingLabels = new Set(); @@ -152,6 +154,7 @@ export const processMonitors = async ( maxPeriod, allIds, enabledMonitorQueryIds, + disabledMonitorQueryIds, disabledCount, monitorLocationMap, disabledMonitorsCount, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/index.ts index 8f6cbe1a60c89..dcefc0a9b0239 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/index.ts @@ -28,6 +28,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./get_flapping_settings')); loadTestFile(require.resolve('./run_soon')); loadTestFile(require.resolve('./update_flapping_settings')); + loadTestFile(require.resolve('./user_managed_api_key')); }); }); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/user_managed_api_key.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/user_managed_api_key.ts new file mode 100644 index 0000000000000..7a92b9e11d859 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/user_managed_api_key.ts @@ -0,0 +1,636 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { generateAPIKeyName } from '@kbn/alerting-plugin/server/rules_client/common'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; +import { + checkAAD, + getEventLog, + getTestRuleData, + getUrlPrefix, + ObjectRemover, +} from '../../../../common/lib'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { SuperuserAtSpace1 } from '../../../scenarios'; + +// eslint-disable-next-line import/no-default-export +export default function userManagedApiKeyTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('es'); + const superTestWithoutAuth = getService('supertestWithoutAuth'); + const objectRemover = new ObjectRemover(supertest); + const retry = getService('retry'); + + describe('user managed api key', () => { + let apiKey: string; + + before(async () => { + // Create API key + const { body: createdApiKey } = await supertest + .post(`/internal/security/api_key`) + .set('kbn-xsrf', 'foo') + .send({ name: 'test user managed key' }) + .expect(200); + + apiKey = createdApiKey.encoded; + }); + + after(() => objectRemover.removeAll()); + + it('should successfully create rule using API key authorization', async () => { + const testRuleData = getTestRuleData({}); + const response = await superTestWithoutAuth + .post(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`) + .send(testRuleData); + + expect(response.status).to.eql(200); + const ruleId = response.body.id; + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + + expect(response.body.api_key_created_by_user).to.eql(true); + expect(apiKeyExists(testRuleData.rule_type_id, testRuleData.name)).to.eql(false); + + // Make sure rule runs successfully + const events = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + provider: 'alerting', + actions: new Map([ + // make sure the counts of the # of events per type are as expected + ['execute', { gte: 1 }], + ]), + }); + }); + + const executeEvent = events.find( + (event: IValidatedEvent) => event?.event?.action === 'execute' + ); + expect(executeEvent?.event?.outcome).to.eql('success'); + }); + + describe('rule operations', () => { + it('should successfully update rule with user managed API key', async () => { + const ruleId = await createRule(apiKey, 'test_update1'); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const updatedData = { + name: 'updated_rule_user_managed', + tags: ['bar'], + params: { + foo: true, + }, + schedule: { interval: '12s' }, + actions: [], + throttle: '1m', + notify_when: 'onThrottleInterval', + }; + + const response = await superTestWithoutAuth + .put(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/api/alerting/rule/${ruleId}`) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`) + .send(updatedData); + + expect(response.status).to.eql(200); + expect(response.body).to.eql({ + ...updatedData, + id: ruleId, + rule_type_id: 'test.noop', + running: false, + consumer: 'alertsFixture', + created_by: 'elastic', + enabled: true, + updated_by: 'elastic', + api_key_owner: 'elastic', + api_key_created_by_user: true, + mute_all: false, + muted_alert_ids: [], + actions: [], + scheduled_task_id: ruleId, + created_at: response.body.created_at, + updated_at: response.body.updated_at, + execution_status: response.body.execution_status, + revision: 1, + ...(response.body.next_run ? { next_run: response.body.next_run } : {}), + ...(response.body.last_run ? { last_run: response.body.last_run } : {}), + }); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure no API key was generated + expect(apiKeyExists('test.noop', updatedData.name)).to.eql(false); + }); + + it('should successfully update rule and regenerate API key', async () => { + const ruleId = await createRule(apiKey, 'test_update2'); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const updatedData = { + name: 'update_rule_regenerated', + tags: ['bar'], + params: { + foo: true, + }, + schedule: { interval: '12s' }, + actions: [], + throttle: '1m', + notify_when: 'onThrottleInterval', + }; + + const response = await supertest + .put(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/api/alerting/rule/${ruleId}`) + .set('kbn-xsrf', 'foo') + .send(updatedData); + + expect(response.status).to.eql(200); + expect(response.body).to.eql({ + ...updatedData, + id: ruleId, + rule_type_id: 'test.noop', + running: false, + consumer: 'alertsFixture', + created_by: 'elastic', + enabled: true, + updated_by: 'elastic', + api_key_owner: 'elastic', + api_key_created_by_user: false, + mute_all: false, + muted_alert_ids: [], + actions: [], + scheduled_task_id: ruleId, + created_at: response.body.created_at, + updated_at: response.body.updated_at, + execution_status: response.body.execution_status, + revision: 1, + ...(response.body.next_run ? { next_run: response.body.next_run } : {}), + ...(response.body.last_run ? { last_run: response.body.last_run } : {}), + }); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure an API key was generated + expect(apiKeyExists('test.noop', updatedData.name)).to.eql(true); + }); + + it('should successfully clone rule with user managed API key', async () => { + const ruleId = await createRule(apiKey, 'test_clone1'); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const response = await superTestWithoutAuth + .post( + `${getUrlPrefix(SuperuserAtSpace1.space.id)}/internal/alerting/rule/${ruleId}/_clone` + ) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`) + .send(); + expect(response.status).to.eql(200); + objectRemover.add(SuperuserAtSpace1.space.id, response.body.id, 'rule', 'alerting'); + + expect(response.body).to.eql({ + id: response.body.id, + name: 'test_clone1 [Clone]', + tags: ['foo'], + actions: [], + enabled: true, + rule_type_id: 'test.noop', + running: false, + consumer: 'alertsFixture', + params: {}, + created_by: 'elastic', + schedule: { interval: '1m' }, + scheduled_task_id: response.body.scheduled_task_id, + created_at: response.body.created_at, + updated_at: response.body.updated_at, + throttle: '1m', + notify_when: 'onThrottleInterval', + updated_by: 'elastic', + api_key_created_by_user: true, + api_key_owner: 'elastic', + mute_all: false, + muted_alert_ids: [], + execution_status: response.body.execution_status, + revision: 0, + last_run: { + alerts_count: { + active: 0, + ignored: 0, + new: 0, + recovered: 0, + }, + outcome: 'succeeded', + outcome_msg: null, + outcome_order: 0, + warning: null, + }, + next_run: response.body.next_run, + }); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: response.body.id, + }); + + // Ensure no API key was generated + expect(apiKeyExists(response.body.rule_type_id, response.body.name)).to.eql(false); + }); + + it('should successfully clone rule and regenerate API key', async () => { + const ruleId = await createRule(apiKey, 'test_clone2'); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const response = await supertest + .post( + `${getUrlPrefix(SuperuserAtSpace1.space.id)}/internal/alerting/rule/${ruleId}/_clone` + ) + .set('kbn-xsrf', 'foo') + .send(); + expect(response.status).to.eql(200); + objectRemover.add(SuperuserAtSpace1.space.id, response.body.id, 'rule', 'alerting'); + + expect(response.body).to.eql({ + id: response.body.id, + name: 'test_clone2 [Clone]', + tags: ['foo'], + actions: [], + enabled: true, + rule_type_id: 'test.noop', + running: false, + consumer: 'alertsFixture', + params: {}, + created_by: 'elastic', + schedule: { interval: '1m' }, + scheduled_task_id: response.body.scheduled_task_id, + created_at: response.body.created_at, + updated_at: response.body.updated_at, + throttle: '1m', + notify_when: 'onThrottleInterval', + updated_by: 'elastic', + api_key_created_by_user: false, + api_key_owner: 'elastic', + mute_all: false, + muted_alert_ids: [], + execution_status: response.body.execution_status, + revision: 0, + last_run: { + alerts_count: { + active: 0, + ignored: 0, + new: 0, + recovered: 0, + }, + outcome: 'succeeded', + outcome_msg: null, + outcome_order: 0, + warning: null, + }, + next_run: response.body.next_run, + }); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: response.body.id, + }); + + // Ensure an API key was generated + expect(apiKeyExists(response.body.rule_type_id, response.body.name)).to.eql(true); + }); + + it('should successfully bulk edit rule with user managed API key', async () => { + const ruleId = await createRule(apiKey, 'test_bulk_edit1'); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const payload = { + ids: [ruleId], + operations: [ + { + operation: 'add', + field: 'tags', + value: ['another-tag'], + }, + ], + }; + + const response = await superTestWithoutAuth + .post(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`) + .send(payload); + + expect(response.status).to.eql(200); + expect(response.body.rules[0].tags).to.eql(['foo', 'another-tag']); + expect(response.body.rules[0].api_key_created_by_user).to.eql(true); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure no API key was generated + expect(apiKeyExists('test.noop', 'test_bulk_edit1')).to.eql(false); + }); + + it('should successfully bulk edit rule and regenerate API key', async () => { + const ruleId = await createRule(apiKey, 'test_bulk_edit2'); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const payload = { + ids: [ruleId], + operations: [ + { + operation: 'add', + field: 'tags', + value: ['another-tag'], + }, + ], + }; + + const response = await supertest + .post(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send(payload); + + expect(response.status).to.eql(200); + expect(response.body.rules[0].tags).to.eql(['foo', 'another-tag']); + expect(response.body.rules[0].api_key_created_by_user).to.eql(false); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure an API key was generated + expect(apiKeyExists('test.noop', 'test_bulk_edit2')).to.eql(true); + }); + + it('should successfully update api key for rule with user managed API key', async () => { + const ruleId = await createRule(apiKey, 'test_update_api_key1'); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const response = await superTestWithoutAuth + .post( + `${getUrlPrefix( + SuperuserAtSpace1.space.id + )}/internal/alerting/rule/${ruleId}/_update_api_key` + ) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`); + expect(response.status).to.eql(204); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure no API key was generated + expect(apiKeyExists('test.noop', 'test_update_api_key1')).to.eql(false); + }); + + it('should successfully update api key for rule and regenerate API key', async () => { + const ruleId = await createRule(apiKey, 'test_update_api_key2'); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const response = await supertest + .post( + `${getUrlPrefix( + SuperuserAtSpace1.space.id + )}/internal/alerting/rule/${ruleId}/_update_api_key` + ) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure an API key was generated + expect(apiKeyExists('test.noop', 'test_update_api_key2')).to.eql(true); + }); + + it('should successfully enable rule with user managed API key', async () => { + const ruleId = await createRule(apiKey, 'test_enable1', false); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const response = await superTestWithoutAuth + .post(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/api/alerting/rule/${ruleId}/_enable`) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`); + expect(response.status).to.eql(204); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure no API key was generated + expect(apiKeyExists('test.noop', 'test_enable1')).to.eql(false); + }); + + it('should successfully enable rule and generate API key', async () => { + const ruleId = await createRule(apiKey, 'test_enable2', false); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const response = await supertest + .post(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/api/alerting/rule/${ruleId}/_enable`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure an API key was generated + expect(apiKeyExists('test.noop', 'test_enable2')).to.eql(true); + }); + + it('should successfully bulk enable rule with user managed API key', async () => { + const ruleId = await createRule(apiKey, 'test_bulk_enable1', false); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const response = await superTestWithoutAuth + .patch(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/internal/alerting/rules/_bulk_enable`) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`) + .send({ ids: [ruleId] }); + expect(response.status).to.eql(200); + expect(response.body.rules[0].enabled).to.eql(true); + expect(response.body.rules[0].apiKeyCreatedByUser).to.eql(true); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure no API key was generated + expect(apiKeyExists('test.noop', 'test_bulk_enable1')).to.eql(false); + }); + + it('should successfully bulk enable rule and generate API key', async () => { + const ruleId = await createRule(apiKey, 'test_bulk_enable2', false); + objectRemover.add(SuperuserAtSpace1.space.id, ruleId, 'rule', 'alerting'); + const response = await supertest + .patch(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/internal/alerting/rules/_bulk_enable`) + .set('kbn-xsrf', 'foo') + .send({ ids: [ruleId] }); + expect(response.status).to.eql(200); + expect(response.body.rules[0].enabled).to.eql(true); + expect(response.body.rules[0].apiKeyCreatedByUser).to.eql(false); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + }); + + // Ensure an API key was generated + expect(apiKeyExists('test.noop', 'test_bulk_enable2')).to.eql(true); + }); + + it('should successfully delete rule with user managed API key', async () => { + const ruleId = await createRule(apiKey, 'test_delete1'); + const response = await superTestWithoutAuth + .delete(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/api/alerting/rule/${ruleId}`) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`); + expect(response.statusCode).to.eql(204); + + const invalidateResponse = await es.security.invalidateApiKey({ + body: { ids: ['abc'], owner: false }, + }); + expect(invalidateResponse.previously_invalidated_api_keys).to.eql([]); + }); + + it('should successfully delete rule', async () => { + const ruleId = await createRule(apiKey, 'test_delete2'); + const response = await supertest + .delete(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/api/alerting/rule/${ruleId}`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + + const invalidateResponse = await es.security.invalidateApiKey({ + body: { ids: ['abc'], owner: false }, + }); + expect(invalidateResponse.previously_invalidated_api_keys).to.eql([]); + }); + + it('should successfully bulk delete rule with user managed api key', async () => { + const ruleId = await createRule(apiKey, 'test_bulk_delete1'); + const response = await superTestWithoutAuth + .patch(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/internal/alerting/rules/_bulk_delete`) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`) + .send({ ids: [ruleId] }); + expect(response.statusCode).to.eql(200); + + const invalidateResponse = await es.security.invalidateApiKey({ + body: { ids: ['abc'], owner: false }, + }); + expect(invalidateResponse.previously_invalidated_api_keys).to.eql([]); + }); + + it('should successfully bulk delete rule', async () => { + const ruleId = await createRule(apiKey, 'test_bulk_delete'); + const response = await supertest + .patch(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/internal/alerting/rules/_bulk_delete`) + .set('kbn-xsrf', 'foo') + .send({ ids: [ruleId] }); + expect(response.status).to.eql(200); + + const invalidateResponse = await es.security.invalidateApiKey({ + body: { ids: ['abc'], owner: false }, + }); + expect(invalidateResponse.previously_invalidated_api_keys).to.eql([]); + }); + }); + }); + + async function apiKeyExists(ruleTypeId: string, ruleName: string) { + // Typically an API key is created using the rule type id and the name so check + // that this does not exist + const generatedApiKeyName = generateAPIKeyName(ruleTypeId, ruleName); + + const { body: allApiKeys } = await supertest + .get(`/internal/security/api_key?isAdmin=true`) + .set('kbn-xsrf', 'foo') + .expect(200); + + return !!allApiKeys.apiKeys.find((key: { name: string }) => key.name === generatedApiKeyName); + } + + async function createRule(apiKey: string, ruleName: string, enabled: boolean = true) { + const testRuleData = getTestRuleData({}); + // Create rule and make sure it runs once successfully + const response = await superTestWithoutAuth + .post(`${getUrlPrefix(SuperuserAtSpace1.space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .set('Authorization', `ApiKey ${apiKey}`) + .send({ ...testRuleData, name: ruleName, enabled }); + + expect(response.status).to.eql(200); + const ruleId = response.body.id; + + if (enabled) { + // Make sure rule runs successfully + const events = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: SuperuserAtSpace1.space.id, + type: 'alert', + id: ruleId, + provider: 'alerting', + actions: new Map([ + // make sure the counts of the # of events per type are as expected + ['execute', { gte: 1 }], + ]), + }); + }); + const executeEvent = events.find( + (event: IValidatedEvent) => event?.event?.action === 'execute' + ); + expect(executeEvent?.event?.outcome).to.eql('success'); + } + + return ruleId; + } +} diff --git a/x-pack/test/functional/apps/dashboard/group2/panel_time_range.ts b/x-pack/test/functional/apps/dashboard/group2/panel_time_range.ts index ee07446783603..2295c90d60c65 100644 --- a/x-pack/test/functional/apps/dashboard/group2/panel_time_range.ts +++ b/x-pack/test/functional/apps/dashboard/group2/panel_time_range.ts @@ -50,7 +50,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardCustomizePanel.clickSaveButton(); await PageObjects.dashboard.waitForRenderComplete(); await dashboardBadgeActions.expectExistsTimeRangeBadgeAction(); - expect(await testSubjects.exists('emptyPlaceholder')); + expect(await testSubjects.exists('emptyPlaceholder')).to.be(true); await PageObjects.dashboard.clickQuickSave(); }); @@ -60,7 +60,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardCustomizePanel.clickSaveButton(); await PageObjects.dashboard.waitForRenderComplete(); await dashboardBadgeActions.expectMissingTimeRangeBadgeAction(); - expect(await testSubjects.exists('xyVisChart')); + expect(await testSubjects.exists('xyVisChart')).to.be(true); }); }); @@ -74,7 +74,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardCustomizePanel.clickSaveButton(); await PageObjects.dashboard.waitForRenderComplete(); await dashboardBadgeActions.expectExistsTimeRangeBadgeAction(); - expect(await testSubjects.exists('emptyPlaceholder')); + expect(await testSubjects.exists('emptyPlaceholder')).to.be(true); await PageObjects.dashboard.clickQuickSave(); }); @@ -84,7 +84,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardCustomizePanel.clickSaveButton(); await PageObjects.dashboard.waitForRenderComplete(); await dashboardBadgeActions.expectMissingTimeRangeBadgeAction(); - expect(await testSubjects.exists('xyVisChart')); + expect(await testSubjects.exists('xyVisChart')).to.be(true); }); });