From f88b140f9f23869590df985097e9739859bfbec1 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Fri, 20 May 2022 12:16:22 +0200 Subject: [PATCH 01/39] [data.query] Add `getState()` api to retrieve whole `QueryState` (#132035) --- .../lib/sync_dashboard_filter_state.ts | 4 +- src/plugins/dashboard/public/locator.ts | 9 +- src/plugins/data/README.mdx | 2 + src/plugins/data/public/index.ts | 2 + src/plugins/data/public/query/index.tsx | 1 + src/plugins/data/public/query/mocks.ts | 2 + .../data/public/query/query_service.test.ts | 91 +++++++++++++++++++ .../data/public/query/query_service.ts | 13 ++- src/plugins/data/public/query/query_state.ts | 40 ++++++++ .../state_sync/connect_to_query_state.test.ts | 5 +- .../state_sync/connect_to_query_state.ts | 6 +- ...le.ts => create_query_state_observable.ts} | 27 +++--- .../data/public/query/state_sync/index.ts | 4 +- .../state_sync/sync_state_with_url.test.ts | 8 +- .../query/state_sync/sync_state_with_url.ts | 20 ++-- .../data/public/query/state_sync/types.ts | 23 ++--- src/plugins/discover/public/locator.ts | 11 ++- .../utils/get_visualize_list_item_link.ts | 12 ++- .../index_data_visualizer/locator/locator.ts | 4 +- x-pack/plugins/maps/public/locators.ts | 11 ++- .../saved_map/get_initial_refresh_config.ts | 4 +- .../saved_map/get_initial_time_filters.ts | 4 +- 22 files changed, 240 insertions(+), 63 deletions(-) create mode 100644 src/plugins/data/public/query/query_service.test.ts create mode 100644 src/plugins/data/public/query/query_state.ts rename src/plugins/data/public/query/state_sync/{create_global_query_observable.ts => create_query_state_observable.ts} (79%) diff --git a/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts b/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts index ff64f4672922c..94c9d996499c3 100644 --- a/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts +++ b/src/plugins/dashboard/public/application/lib/sync_dashboard_filter_state.ts @@ -20,7 +20,7 @@ import { Filter, Query, waitUntilNextSessionCompletes$, - QueryState, + GlobalQueryStateFromUrl, } from '../../services/data'; import { cleanFiltersForSerialize } from '.'; @@ -166,7 +166,7 @@ export const applyDashboardFilterState = ({ * time range and refresh interval to the query service. */ if (currentDashboardState.timeRestore) { - const globalQueryState = kbnUrlStateStorage.get('_g'); + const globalQueryState = kbnUrlStateStorage.get('_g'); if (!globalQueryState?.time) { if (savedDashboard.timeFrom && savedDashboard.timeTo) { timefilterService.setTime({ diff --git a/src/plugins/dashboard/public/locator.ts b/src/plugins/dashboard/public/locator.ts index 9c187ca0803cf..7649343e5bf6e 100644 --- a/src/plugins/dashboard/public/locator.ts +++ b/src/plugins/dashboard/public/locator.ts @@ -9,7 +9,12 @@ import type { SerializableRecord } from '@kbn/utility-types'; import { flow } from 'lodash'; import { type Filter } from '@kbn/es-query'; -import type { TimeRange, Query, QueryState, RefreshInterval } from '@kbn/data-plugin/public'; +import type { + TimeRange, + Query, + GlobalQueryStateFromUrl, + RefreshInterval, +} from '@kbn/data-plugin/public'; import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import { ViewMode } from '@kbn/embeddable-plugin/public'; @@ -155,7 +160,7 @@ export class DashboardAppLocatorDefinition implements LocatorDefinition( + path = setStateToKbnUrl( '_g', cleanEmptyKeys({ time: params.timeRange, diff --git a/src/plugins/data/README.mdx b/src/plugins/data/README.mdx index e24a949a0c2ec..a8cb06ff9e60b 100644 --- a/src/plugins/data/README.mdx +++ b/src/plugins/data/README.mdx @@ -91,6 +91,8 @@ function searchOnChange(indexPattern: IndexPattern, aggConfigs: AggConfigs) { ``` +You can also retrieve a snapshot of the whole `QueryState` by using `data.query.getState()` + ### Timefilter `data.query.timefilter` is responsible for the time range filter and the auto refresh behavior settings. diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 90169ca552ac2..0f50384893b18 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -255,6 +255,7 @@ export { createSavedQueryService, connectToQueryState, syncQueryStateWithUrl, + syncGlobalQueryStateWithUrl, getDefaultQuery, FilterManager, TimeHistory, @@ -280,6 +281,7 @@ export type { QueryStringContract, QuerySetup, TimefilterSetup, + GlobalQueryStateFromUrl, } from './query'; export type { AggsStart } from './search/aggs'; diff --git a/src/plugins/data/public/query/index.tsx b/src/plugins/data/public/query/index.tsx index f426573e1bd6c..392b8fda14417 100644 --- a/src/plugins/data/public/query/index.tsx +++ b/src/plugins/data/public/query/index.tsx @@ -15,3 +15,4 @@ export * from './saved_query'; export * from './persisted_log'; export * from './state_sync'; export type { QueryStringContract } from './query_string'; +export type { QueryState } from './query_state'; diff --git a/src/plugins/data/public/query/mocks.ts b/src/plugins/data/public/query/mocks.ts index 2ab15aab26db6..a2d73e5b5ce34 100644 --- a/src/plugins/data/public/query/mocks.ts +++ b/src/plugins/data/public/query/mocks.ts @@ -21,6 +21,7 @@ const createSetupContractMock = () => { timefilter: timefilterServiceMock.createSetupContract(), queryString: queryStringManagerMock.createSetupContract(), state$: new Observable(), + getState: jest.fn(), }; return setupContract; @@ -33,6 +34,7 @@ const createStartContractMock = () => { queryString: queryStringManagerMock.createStartContract(), savedQueries: jest.fn() as any, state$: new Observable(), + getState: jest.fn(), timefilter: timefilterServiceMock.createStartContract(), getEsQuery: jest.fn(), }; diff --git a/src/plugins/data/public/query/query_service.test.ts b/src/plugins/data/public/query/query_service.test.ts new file mode 100644 index 0000000000000..5eb6815c3ba20 --- /dev/null +++ b/src/plugins/data/public/query/query_service.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FilterStateStore } from '@kbn/es-query'; +import { FilterManager } from './filter_manager'; +import { QueryStringContract } from './query_string'; +import { getFilter } from './filter_manager/test_helpers/get_stub_filter'; +import { UI_SETTINGS } from '../../common'; +import { coreMock } from '@kbn/core/public/mocks'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; +import { QueryService, QueryStart } from './query_service'; +import { StubBrowserStorage } from '@kbn/test-jest-helpers'; +import { TimefilterContract } from './timefilter'; +import { createNowProviderMock } from '../now_provider/mocks'; + +const setupMock = coreMock.createSetup(); +const startMock = coreMock.createStart(); + +setupMock.uiSettings.get.mockImplementation((key: string) => { + switch (key) { + case UI_SETTINGS.FILTERS_PINNED_BY_DEFAULT: + return true; + case UI_SETTINGS.SEARCH_QUERY_LANGUAGE: + return 'kuery'; + case UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS: + return { from: 'now-15m', to: 'now' }; + case UI_SETTINGS.TIMEPICKER_REFRESH_INTERVAL_DEFAULTS: + return { pause: false, value: 0 }; + default: + throw new Error(`query_service test: not mocked uiSetting: ${key}`); + } +}); + +describe('query_service', () => { + let queryServiceStart: QueryStart; + let filterManager: FilterManager; + let timeFilter: TimefilterContract; + let queryStringManager: QueryStringContract; + + beforeEach(() => { + const queryService = new QueryService(); + queryService.setup({ + uiSettings: setupMock.uiSettings, + storage: new Storage(new StubBrowserStorage()), + nowProvider: createNowProviderMock(), + }); + queryServiceStart = queryService.start({ + uiSettings: setupMock.uiSettings, + storage: new Storage(new StubBrowserStorage()), + http: startMock.http, + }); + filterManager = queryServiceStart.filterManager; + timeFilter = queryServiceStart.timefilter.timefilter; + queryStringManager = queryServiceStart.queryString; + }); + + test('state is initialized with state from query service', () => { + const state = queryServiceStart.getState(); + + expect(state).toEqual({ + filters: filterManager.getFilters(), + refreshInterval: timeFilter.getRefreshInterval(), + time: timeFilter.getTime(), + query: queryStringManager.getQuery(), + }); + }); + + test('state is updated when underlying state in service updates', () => { + const filters = [getFilter(FilterStateStore.GLOBAL_STATE, true, true, 'key1', 'value1')]; + const query = { language: 'kql', query: 'query' }; + const time = { from: new Date().toISOString(), to: new Date().toISOString() }; + const refreshInterval = { pause: false, value: 10 }; + + filterManager.setFilters(filters); + queryStringManager.setQuery(query); + timeFilter.setTime(time); + timeFilter.setRefreshInterval(refreshInterval); + + expect(queryServiceStart.getState()).toEqual({ + filters, + refreshInterval, + time, + query, + }); + }); +}); diff --git a/src/plugins/data/public/query/query_service.ts b/src/plugins/data/public/query/query_service.ts index 1b634fda28996..8b309c9821d3e 100644 --- a/src/plugins/data/public/query/query_service.ts +++ b/src/plugins/data/public/query/query_service.ts @@ -15,7 +15,8 @@ import { createAddToQueryLog } from './lib'; import { TimefilterService } from './timefilter'; import type { TimefilterSetup } from './timefilter'; import { createSavedQueryService } from './saved_query/saved_query_service'; -import { createQueryStateObservable } from './state_sync/create_global_query_observable'; +import { createQueryStateObservable } from './state_sync/create_query_state_observable'; +import { getQueryState } from './query_state'; import type { QueryStringContract } from './query_string'; import { QueryStringManager } from './query_string'; import { getEsQueryConfig, TimeRange } from '../../common'; @@ -69,6 +70,7 @@ export class QueryService { timefilter: this.timefilter, queryString: this.queryStringManager, state$: this.state$, + getState: () => this.getQueryState(), }; } @@ -82,6 +84,7 @@ export class QueryService { queryString: this.queryStringManager, savedQueries: createSavedQueryService(http), state$: this.state$, + getState: () => this.getQueryState(), timefilter: this.timefilter, getEsQuery: (indexPattern: IndexPattern, timeRange?: TimeRange) => { const timeFilter = this.timefilter.timefilter.createFilter(indexPattern, timeRange); @@ -99,6 +102,14 @@ export class QueryService { public stop() { // nothing to do here yet } + + private getQueryState() { + return getQueryState({ + timefilter: this.timefilter, + queryString: this.queryStringManager, + filterManager: this.filterManager, + }); + } } /** @public */ diff --git a/src/plugins/data/public/query/query_state.ts b/src/plugins/data/public/query/query_state.ts new file mode 100644 index 0000000000000..77242c981bda2 --- /dev/null +++ b/src/plugins/data/public/query/query_state.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Filter } from '@kbn/es-query'; +import type { TimefilterSetup } from './timefilter'; +import type { FilterManager } from './filter_manager'; +import type { QueryStringContract } from './query_string'; +import type { RefreshInterval, TimeRange, Query } from '../../common'; + +/** + * All query state service state + */ +export interface QueryState { + time?: TimeRange; + refreshInterval?: RefreshInterval; + filters?: Filter[]; + query?: Query; +} + +export function getQueryState({ + timefilter: { timefilter }, + filterManager, + queryString, +}: { + timefilter: TimefilterSetup; + filterManager: FilterManager; + queryString: QueryStringContract; +}): QueryState { + return { + time: timefilter.getTime(), + refreshInterval: timefilter.getRefreshInterval(), + filters: filterManager.getFilters(), + query: queryString.getQuery(), + }; +} diff --git a/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts b/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts index d1d3ea5865c7e..515cc38783cbd 100644 --- a/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts +++ b/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts @@ -7,16 +7,17 @@ */ import { Subscription } from 'rxjs'; +import { Filter, FilterStateStore } from '@kbn/es-query'; import { FilterManager } from '../filter_manager'; import { getFilter } from '../filter_manager/test_helpers/get_stub_filter'; -import { Filter, FilterStateStore, UI_SETTINGS } from '../../../common'; +import { UI_SETTINGS } from '../../../common'; import { coreMock } from '@kbn/core/public/mocks'; import { BaseStateContainer, createStateContainer, Storage } from '@kbn/kibana-utils-plugin/public'; import { QueryService, QueryStart } from '../query_service'; import { StubBrowserStorage } from '@kbn/test-jest-helpers'; import { connectToQueryState } from './connect_to_query_state'; import { TimefilterContract } from '../timefilter'; -import { QueryState } from './types'; +import { QueryState } from '../query_state'; import { createNowProviderMock } from '../../now_provider/mocks'; const connectToQueryGlobalState = (query: QueryStart, state: BaseStateContainer) => diff --git a/src/plugins/data/public/query/state_sync/connect_to_query_state.ts b/src/plugins/data/public/query/state_sync/connect_to_query_state.ts index b9bb05841f161..a625dff04b0a3 100644 --- a/src/plugins/data/public/query/state_sync/connect_to_query_state.ts +++ b/src/plugins/data/public/query/state_sync/connect_to_query_state.ts @@ -9,10 +9,12 @@ import { Subscription } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import _ from 'lodash'; +import { COMPARE_ALL_OPTIONS, compareFilters } from '@kbn/es-query'; import { BaseStateContainer } from '@kbn/kibana-utils-plugin/public'; import { QuerySetup, QueryStart } from '../query_service'; -import { QueryState, QueryStateChange } from './types'; -import { FilterStateStore, COMPARE_ALL_OPTIONS, compareFilters } from '../../../common'; +import { QueryState } from '../query_state'; +import { QueryStateChange } from './types'; +import { FilterStateStore } from '../../../common'; import { validateTimeRange } from '../timefilter'; /** diff --git a/src/plugins/data/public/query/state_sync/create_global_query_observable.ts b/src/plugins/data/public/query/state_sync/create_query_state_observable.ts similarity index 79% rename from src/plugins/data/public/query/state_sync/create_global_query_observable.ts rename to src/plugins/data/public/query/state_sync/create_query_state_observable.ts index 2e054229a55da..39e7802753ee2 100644 --- a/src/plugins/data/public/query/state_sync/create_global_query_observable.ts +++ b/src/plugins/data/public/query/state_sync/create_query_state_observable.ts @@ -8,16 +8,16 @@ import { Observable, Subscription } from 'rxjs'; import { map, tap } from 'rxjs/operators'; -import { isFilterPinned } from '@kbn/es-query'; +import { COMPARE_ALL_OPTIONS, compareFilters, isFilterPinned } from '@kbn/es-query'; import { createStateContainer } from '@kbn/kibana-utils-plugin/public'; import type { TimefilterSetup } from '../timefilter'; import { FilterManager } from '../filter_manager'; -import { QueryState, QueryStateChange } from '.'; -import { compareFilters, COMPARE_ALL_OPTIONS } from '../../../common'; +import { getQueryState, QueryState } from '../query_state'; +import { QueryStateChange } from './types'; import type { QueryStringContract } from '../query_string'; export function createQueryStateObservable({ - timefilter: { timefilter }, + timefilter, filterManager, queryString, }: { @@ -25,27 +25,24 @@ export function createQueryStateObservable({ filterManager: FilterManager; queryString: QueryStringContract; }): Observable<{ changes: QueryStateChange; state: QueryState }> { - return new Observable((subscriber) => { - const state = createStateContainer({ - time: timefilter.getTime(), - refreshInterval: timefilter.getRefreshInterval(), - filters: filterManager.getFilters(), - query: queryString.getQuery(), - }); + const state = createStateContainer( + getQueryState({ timefilter, filterManager, queryString }) + ); + return new Observable((subscriber) => { let currentChange: QueryStateChange = {}; const subs: Subscription[] = [ queryString.getUpdates$().subscribe(() => { currentChange.query = true; state.set({ ...state.get(), query: queryString.getQuery() }); }), - timefilter.getTimeUpdate$().subscribe(() => { + timefilter.timefilter.getTimeUpdate$().subscribe(() => { currentChange.time = true; - state.set({ ...state.get(), time: timefilter.getTime() }); + state.set({ ...state.get(), time: timefilter.timefilter.getTime() }); }), - timefilter.getRefreshIntervalUpdate$().subscribe(() => { + timefilter.timefilter.getRefreshIntervalUpdate$().subscribe(() => { currentChange.refreshInterval = true; - state.set({ ...state.get(), refreshInterval: timefilter.getRefreshInterval() }); + state.set({ ...state.get(), refreshInterval: timefilter.timefilter.getRefreshInterval() }); }), filterManager.getUpdates$().subscribe(() => { currentChange.filters = true; diff --git a/src/plugins/data/public/query/state_sync/index.ts b/src/plugins/data/public/query/state_sync/index.ts index 58740cfab06d0..ffeda864f5172 100644 --- a/src/plugins/data/public/query/state_sync/index.ts +++ b/src/plugins/data/public/query/state_sync/index.ts @@ -7,5 +7,5 @@ */ export { connectToQueryState } from './connect_to_query_state'; -export { syncQueryStateWithUrl } from './sync_state_with_url'; -export type { QueryState, QueryStateChange } from './types'; +export { syncQueryStateWithUrl, syncGlobalQueryStateWithUrl } from './sync_state_with_url'; +export type { QueryStateChange, GlobalQueryStateFromUrl } from './types'; diff --git a/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts b/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts index edeaa7c772575..feb9fc5238ab6 100644 --- a/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts +++ b/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts @@ -21,7 +21,7 @@ import { QueryService, QueryStart } from '../query_service'; import { StubBrowserStorage } from '@kbn/test-jest-helpers'; import { TimefilterContract } from '../timefilter'; import { syncQueryStateWithUrl } from './sync_state_with_url'; -import { QueryState } from './types'; +import { GlobalQueryStateFromUrl } from './types'; import { createNowProviderMock } from '../../now_provider/mocks'; const setupMock = coreMock.createSetup(); @@ -100,14 +100,14 @@ describe('sync_query_state_with_url', () => { test('when filters change, global filters synced to urlStorage', () => { const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage); filterManager.setFilters([gF, aF]); - expect(kbnUrlStateStorage.get('_g')?.filters).toHaveLength(1); + expect(kbnUrlStateStorage.get('_g')?.filters).toHaveLength(1); stop(); }); test('when time range changes, time synced to urlStorage', () => { const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage); timefilter.setTime({ from: 'now-30m', to: 'now' }); - expect(kbnUrlStateStorage.get('_g')?.time).toEqual({ + expect(kbnUrlStateStorage.get('_g')?.time).toEqual({ from: 'now-30m', to: 'now', }); @@ -117,7 +117,7 @@ describe('sync_query_state_with_url', () => { test('when refresh interval changes, refresh interval is synced to urlStorage', () => { const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage); timefilter.setRefreshInterval({ pause: true, value: 100 }); - expect(kbnUrlStateStorage.get('_g')?.refreshInterval).toEqual({ + expect(kbnUrlStateStorage.get('_g')?.refreshInterval).toEqual({ pause: true, value: 100, }); diff --git a/src/plugins/data/public/query/state_sync/sync_state_with_url.ts b/src/plugins/data/public/query/state_sync/sync_state_with_url.ts index fd52ca5ffc979..030cc1f91d4fe 100644 --- a/src/plugins/data/public/query/state_sync/sync_state_with_url.ts +++ b/src/plugins/data/public/query/state_sync/sync_state_with_url.ts @@ -13,17 +13,17 @@ import { } from '@kbn/kibana-utils-plugin/public'; import { QuerySetup, QueryStart } from '../query_service'; import { connectToQueryState } from './connect_to_query_state'; -import { QueryState } from './types'; import { FilterStateStore } from '../../../common'; +import { GlobalQueryStateFromUrl } from './types'; const GLOBAL_STATE_STORAGE_KEY = '_g'; /** - * Helper to setup syncing of global data with the URL + * Helper to sync global query state {@link GlobalQueryStateFromUrl} with the URL (`_g` query param that is preserved between apps) * @param QueryService: either setup or start * @param kbnUrlStateStorage to use for syncing */ -export const syncQueryStateWithUrl = ( +export const syncGlobalQueryStateWithUrl = ( query: Pick, kbnUrlStateStorage: IKbnUrlStateStorage ) => { @@ -31,14 +31,15 @@ export const syncQueryStateWithUrl = ( timefilter: { timefilter }, filterManager, } = query; - const defaultState: QueryState = { + const defaultState: GlobalQueryStateFromUrl = { time: timefilter.getTime(), refreshInterval: timefilter.getRefreshInterval(), filters: filterManager.getGlobalFilters(), }; // retrieve current state from `_g` url - const initialStateFromUrl = kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY); + const initialStateFromUrl = + kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY); // remember whether there was info in the URL const hasInheritedQueryFromUrl = Boolean( @@ -46,7 +47,7 @@ export const syncQueryStateWithUrl = ( ); // prepare initial state, whatever was in URL takes precedences over current state in services - const initialState: QueryState = { + const initialState: GlobalQueryStateFromUrl = { ...defaultState, ...initialStateFromUrl, }; @@ -61,7 +62,7 @@ export const syncQueryStateWithUrl = ( // if there weren't any initial state in url, // then put _g key into url if (!initialStateFromUrl) { - kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, initialState, { + kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, initialState, { replace: true, }); } @@ -92,3 +93,8 @@ export const syncQueryStateWithUrl = ( hasInheritedQueryFromUrl, }; }; + +/** + * @deprecated use {@link syncGlobalQueryStateWithUrl} instead + */ +export const syncQueryStateWithUrl = syncGlobalQueryStateWithUrl; diff --git a/src/plugins/data/public/query/state_sync/types.ts b/src/plugins/data/public/query/state_sync/types.ts index 8bfd47987ab90..653dd36577b8d 100644 --- a/src/plugins/data/public/query/state_sync/types.ts +++ b/src/plugins/data/public/query/state_sync/types.ts @@ -6,17 +6,9 @@ * Side Public License, v 1. */ -import { Filter, RefreshInterval, TimeRange, Query } from '../../../common'; - -/** - * All query state service state - */ -export interface QueryState { - time?: TimeRange; - refreshInterval?: RefreshInterval; - filters?: Filter[]; - query?: Query; -} +import type { Filter } from '@kbn/es-query'; +import type { QueryState } from '../query_state'; +import { RefreshInterval, TimeRange } from '../../../common/types'; type QueryStateChangePartial = { [P in keyof QueryState]?: boolean; @@ -26,3 +18,12 @@ export interface QueryStateChange extends QueryStateChangePartial { appFilters?: boolean; // specifies if app filters change globalFilters?: boolean; // specifies if global filters change } + +/** + * Part of {@link QueryState} serialized in the `_g` portion of Url + */ +export interface GlobalQueryStateFromUrl { + time?: TimeRange; + refreshInterval?: RefreshInterval; + filters?: Filter[]; +} diff --git a/src/plugins/discover/public/locator.ts b/src/plugins/discover/public/locator.ts index d1b4d73571550..eb4731bd44e64 100644 --- a/src/plugins/discover/public/locator.ts +++ b/src/plugins/discover/public/locator.ts @@ -8,7 +8,12 @@ import type { SerializableRecord } from '@kbn/utility-types'; import type { Filter } from '@kbn/es-query'; -import type { TimeRange, Query, QueryState, RefreshInterval } from '@kbn/data-plugin/public'; +import type { + TimeRange, + Query, + GlobalQueryStateFromUrl, + RefreshInterval, +} from '@kbn/data-plugin/public'; import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import type { VIEW_MODE } from './components/view_mode_toggle'; @@ -126,7 +131,7 @@ export class DiscoverAppLocatorDefinition implements LocatorDefinition('_g', queryState, { useHash }, path); + path = setStateToKbnUrl('_g', queryState, { useHash }, path); path = setStateToKbnUrl('_a', appState, { useHash }, path); if (searchSessionId) { diff --git a/src/plugins/visualizations/public/visualize_app/utils/get_visualize_list_item_link.ts b/src/plugins/visualizations/public/visualize_app/utils/get_visualize_list_item_link.ts index fc41486fae84a..1285da1f3bf15 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/get_visualize_list_item_link.ts +++ b/src/plugins/visualizations/public/visualize_app/utils/get_visualize_list_item_link.ts @@ -8,7 +8,7 @@ import { ApplicationStart } from '@kbn/core/public'; import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; -import { QueryState } from '@kbn/data-plugin/public'; +import { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import { getUISettings } from '../../services'; import { GLOBAL_STATE_STORAGE_KEY, VISUALIZE_APP_NAME } from '../../../common/constants'; @@ -24,8 +24,14 @@ export const getVisualizeListItemLink = ( path: editApp ? editUrl : `#${editUrl}`, }); const useHash = getUISettings().get('state:storeInSessionStorage'); - const globalStateInUrl = kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY) || {}; + const globalStateInUrl = + kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY) || {}; - url = setStateToKbnUrl(GLOBAL_STATE_STORAGE_KEY, globalStateInUrl, { useHash }, url); + url = setStateToKbnUrl( + GLOBAL_STATE_STORAGE_KEY, + globalStateInUrl, + { useHash }, + url + ); return url; }; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts index 0f197f4a13ddd..0b3176154c5ff 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts @@ -10,7 +10,7 @@ import { SerializableRecord } from '@kbn/utility-types'; import { Filter } from '@kbn/es-query'; import { RefreshInterval, TimeRange } from '@kbn/data-plugin/common'; import { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/common'; -import { QueryState } from '@kbn/data-plugin/public'; +import { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public'; import { Dictionary, isRisonSerializationRequired } from '../../common/util/url_state'; import { SearchQueryLanguage } from '../types/combined_query'; @@ -124,7 +124,7 @@ export class IndexDataVisualizerLocatorDefinition sortField?: string; showDistributions?: number; } = {}; - const queryState: QueryState = {}; + const queryState: GlobalQueryStateFromUrl = {}; if (query) { appState.searchQuery = query.searchQuery; diff --git a/x-pack/plugins/maps/public/locators.ts b/x-pack/plugins/maps/public/locators.ts index 6c5d5a730edf7..7cfdb7a0d3fb1 100644 --- a/x-pack/plugins/maps/public/locators.ts +++ b/x-pack/plugins/maps/public/locators.ts @@ -10,7 +10,12 @@ import rison from 'rison-node'; import type { SerializableRecord } from '@kbn/utility-types'; import { type Filter, isFilterPinned } from '@kbn/es-query'; -import type { TimeRange, Query, QueryState, RefreshInterval } from '@kbn/data-plugin/public'; +import type { + TimeRange, + Query, + GlobalQueryStateFromUrl, + RefreshInterval, +} from '@kbn/data-plugin/public'; import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/public'; import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; import type { LayerDescriptor } from '../common/descriptor_types'; @@ -78,7 +83,7 @@ export class MapsAppLocatorDefinition implements LocatorDefinition !isFilterPinned(f)); @@ -87,7 +92,7 @@ export class MapsAppLocatorDefinition implements LocatorDefinition('_g', queryState, { useHash }, path); + path = setStateToKbnUrl('_g', queryState, { useHash }, path); path = setStateToKbnUrl('_a', appState, { useHash }, path); if (initialLayers && initialLayers.length) { diff --git a/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_refresh_config.ts b/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_refresh_config.ts index 8e816c6930fdb..79d3603055874 100644 --- a/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_refresh_config.ts +++ b/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_refresh_config.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { QueryState } from '@kbn/data-plugin/public'; +import { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public'; import { UI_SETTINGS } from '@kbn/data-plugin/public'; import { getUiSettings } from '../../../kibana_services'; import { SerializedMapState } from './types'; @@ -15,7 +15,7 @@ export function getInitialRefreshConfig({ globalState = {}, }: { serializedMapState?: SerializedMapState; - globalState: QueryState; + globalState: GlobalQueryStateFromUrl; }) { const uiSettings = getUiSettings(); diff --git a/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_time_filters.ts b/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_time_filters.ts index da293d5c52d29..fc3754256d659 100644 --- a/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_time_filters.ts +++ b/x-pack/plugins/maps/public/routes/map_page/saved_map/get_initial_time_filters.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { QueryState } from '@kbn/data-plugin/public'; +import { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public'; import { getUiSettings } from '../../../kibana_services'; import { SerializedMapState } from './types'; @@ -14,7 +14,7 @@ export function getInitialTimeFilters({ globalState, }: { serializedMapState?: SerializedMapState; - globalState: QueryState; + globalState: GlobalQueryStateFromUrl; }) { if (serializedMapState?.timeFilters) { return serializedMapState.timeFilters; From 473141f58b23c799c83be976afb331e09ec2d022 Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Fri, 20 May 2022 12:17:58 +0200 Subject: [PATCH 02/39] [Cases] Show deprecated icon in connectors with isDeprecated true (#132237) Co-authored-by: Christos Nasikas --- .../server/lib/is_conector_deprecated.test.ts | 25 +++++++ .../server/lib/is_conector_deprecated.ts | 40 +++++++++-- .../cases/common/api/connectors/index.ts | 10 ++- .../cases/public/common/mock/connectors.ts | 5 ++ .../components/all_cases/columns.test.tsx | 1 + .../connectors_dropdown.test.tsx | 23 ++++++ .../public/components/connectors/mock.ts | 2 + .../servicenow_itsm_case_fields.test.tsx | 19 ++--- .../servicenow_itsm_case_fields.tsx | 3 +- .../servicenow_sir_case_fields.test.tsx | 19 ++--- .../servicenow/servicenow_sir_case_fields.tsx | 3 +- .../servicenow/use_get_choices.test.tsx | 1 + .../connectors/servicenow/validator.test.ts | 48 ------------- .../connectors/servicenow/validator.ts | 34 --------- .../connectors/swimlane/validator.test.ts | 21 ++++++ .../connectors/swimlane/validator.ts | 19 +++-- .../cases/public/components/utils.test.ts | 70 ++++++++----------- .../plugins/cases/public/components/utils.ts | 45 +++++------- .../cases/server/client/cases/utils.test.ts | 1 + .../alerting_api_integration/common/config.ts | 11 +++ .../group2/tests/actions/get_all.ts | 24 +++++++ .../tests/telemetry/actions_telemetry.ts | 2 +- .../spaces_only/tests/actions/get.ts | 12 ++++ .../spaces_only/tests/actions/get_all.ts | 24 +++++++ .../cases_api_integration/common/config.ts | 1 + 25 files changed, 285 insertions(+), 178 deletions(-) delete mode 100644 x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts delete mode 100644 x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts diff --git a/x-pack/plugins/actions/server/lib/is_conector_deprecated.test.ts b/x-pack/plugins/actions/server/lib/is_conector_deprecated.test.ts index f5ace7e055254..c3697cea6a34e 100644 --- a/x-pack/plugins/actions/server/lib/is_conector_deprecated.test.ts +++ b/x-pack/plugins/actions/server/lib/is_conector_deprecated.test.ts @@ -17,6 +17,11 @@ describe('isConnectorDeprecated', () => { isPreconfigured: false as const, }; + it('returns false if the config is not defined', () => { + // @ts-expect-error + expect(isConnectorDeprecated({})).toBe(false); + }); + it('returns false if the connector is not ITSM or SecOps', () => { expect(isConnectorDeprecated(connector)).toBe(false); }); @@ -48,4 +53,24 @@ describe('isConnectorDeprecated', () => { }) ).toBe(true); }); + + it('returns true if the connector is .servicenow and the usesTableApi is omitted', () => { + expect( + isConnectorDeprecated({ + ...connector, + actionTypeId: '.servicenow', + config: { apiUrl: 'http://example.com' }, + }) + ).toBe(true); + }); + + it('returns true if the connector is .servicenow-sir and the usesTableApi is omitted', () => { + expect( + isConnectorDeprecated({ + ...connector, + actionTypeId: '.servicenow-sir', + config: { apiUrl: 'http://example.com' }, + }) + ).toBe(true); + }); }); diff --git a/x-pack/plugins/actions/server/lib/is_conector_deprecated.ts b/x-pack/plugins/actions/server/lib/is_conector_deprecated.ts index 210631cb532f6..ed46f5e685459 100644 --- a/x-pack/plugins/actions/server/lib/is_conector_deprecated.ts +++ b/x-pack/plugins/actions/server/lib/is_conector_deprecated.ts @@ -5,11 +5,14 @@ * 2.0. */ +import { isPlainObject } from 'lodash'; import { PreConfiguredAction, RawAction } from '../types'; export type ConnectorWithOptionalDeprecation = Omit & Pick, 'isDeprecated'>; +const isObject = (obj: unknown): obj is Record => isPlainObject(obj); + export const isConnectorDeprecated = ( connector: RawAction | ConnectorWithOptionalDeprecation ): boolean => { @@ -18,11 +21,40 @@ export const isConnectorDeprecated = ( * Connectors after the Elastic ServiceNow application use the * Import Set API (https://developer.servicenow.com/dev.do#!/reference/api/rome/rest/c_ImportSetAPI) * A ServiceNow connector is considered deprecated if it uses the Table API. - * - * All other connectors do not have the usesTableApi config property - * so the function will always return false for them. */ - return !!connector.config?.usesTableApi; + + /** + * We cannot deduct if the connector is + * deprecated without config. In this case + * we always return false. + */ + if (!isObject(connector.config)) { + return false; + } + + /** + * If the usesTableApi is not defined it means that the connector is created + * before the introduction of the usesTableApi property. In that case, the connector is assumed + * to be deprecated because all connectors prior 7.16 where using the Table API. + * Migrations x-pack/plugins/actions/server/saved_objects/actions_migrations.ts set + * the usesTableApi property to true to all connectors prior 7.16. Pre configured connectors + * cannot be migrated. This check ensures that pre configured connectors without the + * usesTableApi property explicitly in the kibana.yml file are considered deprecated. + * According to the schema defined here x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts + * if the property is not defined it will be set to true at the execution of the connector. + */ + if (!Object.hasOwn(connector.config, 'usesTableApi')) { + return true; + } + + /** + * Connector created prior to 7.16 will be migrated to have the usesTableApi property set to true. + * Connectors created after 7.16 should have the usesTableApi property set to true or false. + * If the usesTableApi is omitted on an API call it will be defaulted to true. Check the schema + * here x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts. + * The !! is to make TS happy. + */ + return !!connector.config.usesTableApi; } return false; diff --git a/x-pack/plugins/cases/common/api/connectors/index.ts b/x-pack/plugins/cases/common/api/connectors/index.ts index bb1892525f8e0..df9a7b0e24fd7 100644 --- a/x-pack/plugins/cases/common/api/connectors/index.ts +++ b/x-pack/plugins/cases/common/api/connectors/index.ts @@ -7,7 +7,15 @@ import * as rt from 'io-ts'; -import { ActionResult, ActionType } from '@kbn/actions-plugin/common'; +import type { ActionType } from '@kbn/actions-plugin/common'; +/** + * ActionResult type from the common folder is outdated. + * The type from server is not exported properly so we + * disable the linting for the moment + */ + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import type { ActionResult } from '@kbn/actions-plugin/server/types'; import { JiraFieldsRT } from './jira'; import { ResilientFieldsRT } from './resilient'; import { ServiceNowITSMFieldsRT } from './servicenow_itsm'; diff --git a/x-pack/plugins/cases/public/common/mock/connectors.ts b/x-pack/plugins/cases/public/common/mock/connectors.ts index 01afbbee118a8..d186b68053e7f 100644 --- a/x-pack/plugins/cases/public/common/mock/connectors.ts +++ b/x-pack/plugins/cases/public/common/mock/connectors.ts @@ -16,6 +16,7 @@ export const connectorsMock: ActionConnector[] = [ apiUrl: 'https://instance1.service-now.com', }, isPreconfigured: false, + isDeprecated: false, }, { id: 'resilient-2', @@ -26,6 +27,7 @@ export const connectorsMock: ActionConnector[] = [ orgId: '201', }, isPreconfigured: false, + isDeprecated: false, }, { id: 'jira-1', @@ -35,6 +37,7 @@ export const connectorsMock: ActionConnector[] = [ apiUrl: 'https://instance.atlassian.ne', }, isPreconfigured: false, + isDeprecated: false, }, { id: 'servicenow-sir', @@ -44,6 +47,7 @@ export const connectorsMock: ActionConnector[] = [ apiUrl: 'https://instance1.service-now.com', }, isPreconfigured: false, + isDeprecated: false, }, { id: 'servicenow-uses-table-api', @@ -54,6 +58,7 @@ export const connectorsMock: ActionConnector[] = [ usesTableApi: true, }, isPreconfigured: false, + isDeprecated: true, }, ]; diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx index 764a51443b0e3..b09eecbb31f4f 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx @@ -77,6 +77,7 @@ describe('ExternalServiceColumn ', () => { name: 'None', config: {}, isPreconfigured: false, + isDeprecated: false, }, ]} /> diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx index 63fc2e2695a3a..e8093325c1e09 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx @@ -249,6 +249,7 @@ describe('ConnectorsDropdown', () => { name: 'None', config: {}, isPreconfigured: false, + isDeprecated: false, }, ]} />, @@ -269,4 +270,26 @@ describe('ConnectorsDropdown', () => { ); expect(tooltips[0]).toBeInTheDocument(); }); + + test('it shows the deprecated tooltip when the connector is deprecated by configuration', () => { + const connector = connectors[0]; + render( + , + { wrapper: ({ children }) => {children} } + ); + + const tooltips = screen.getAllByText( + 'This connector is deprecated. Update it, or create a new one.' + ); + expect(tooltips[0]).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/cases/public/components/connectors/mock.ts b/x-pack/plugins/cases/public/components/connectors/mock.ts index 2eb512af0f2ef..ba29319a8926c 100644 --- a/x-pack/plugins/cases/public/components/connectors/mock.ts +++ b/x-pack/plugins/cases/public/components/connectors/mock.ts @@ -13,6 +13,7 @@ export const connector = { actionTypeId: '.jira', config: {}, isPreconfigured: false, + isDeprecated: false, }; export const swimlaneConnector = { @@ -29,6 +30,7 @@ export const swimlaneConnector = { }, }, isPreconfigured: false, + isDeprecated: false, }; export const issues = [ diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx index cfc16f1fb6e8b..e2f4a683772c7 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx @@ -135,18 +135,18 @@ describe('ServiceNowITSM Fields', () => { ); }); - it('shows the deprecated callout when the connector uses the table API', async () => { - const tableApiConnector = { ...connector, config: { usesTableApi: true } }; + it('shows the deprecated callout if the connector is deprecated', async () => { + const tableApiConnector = { ...connector, isDeprecated: true }; render(); expect(screen.getByTestId('deprecated-connector-warning-callout')).toBeInTheDocument(); }); - it('does not show the deprecated callout when the connector does not uses the table API', async () => { + it('does not show the deprecated callout when the connector is not deprecated', async () => { render(); expect(screen.queryByTestId('deprecated-connector-warning-callout')).not.toBeInTheDocument(); }); - it('does not show the deprecated callout when the connector is preconfigured', async () => { + it('does not show the deprecated callout when the connector is preconfigured and not deprecated', async () => { render( { expect(screen.queryByTestId('deprecated-connector-warning-callout')).not.toBeInTheDocument(); }); - it('does not show the deprecated callout when the config of the connector is undefined', async () => { + it('shows the deprecated callout when the connector is preconfigured and deprecated', async () => { render( - // @ts-expect-error - + ); - expect(screen.queryByTestId('deprecated-connector-warning-callout')).not.toBeInTheDocument(); + expect(screen.queryByTestId('deprecated-connector-warning-callout')).toBeInTheDocument(); }); it('should hide subcategory if selecting a category without subcategories', async () => { diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index f366cc95ff77a..2dae544ec274c 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -16,7 +16,6 @@ import { ConnectorCard } from '../card'; import { useGetChoices } from './use_get_choices'; import { Fields, Choice } from './types'; import { choicesToEuiOptions } from './helpers'; -import { connectorValidator } from './validator'; import { DeprecatedCallout } from '../deprecated_callout'; const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; @@ -44,7 +43,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< } = fields ?? {}; const { http, notifications } = useKibana().services; const [choices, setChoices] = useState(defaultFields); - const showConnectorWarning = useMemo(() => connectorValidator(connector) != null, [connector]); + const showConnectorWarning = connector.isDeprecated; const categoryOptions = useMemo( () => choicesToEuiOptions(choices.category), diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx index a2c61ac78be0b..1b06e0cfdce81 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx @@ -169,18 +169,18 @@ describe('ServiceNowSIR Fields', () => { ]); }); - test('it shows the deprecated callout when the connector uses the table API', async () => { - const tableApiConnector = { ...connector, config: { usesTableApi: true } }; + test('shows the deprecated callout if the connector is deprecated', async () => { + const tableApiConnector = { ...connector, isDeprecated: true }; render(); expect(screen.getByTestId('deprecated-connector-warning-callout')).toBeInTheDocument(); }); - test('it does not show the deprecated callout when the connector does not uses the table API', async () => { + test('does not show the deprecated callout when the connector is not deprecated', async () => { render(); expect(screen.queryByTestId('deprecated-connector-warning-callout')).not.toBeInTheDocument(); }); - it('does not show the deprecated callout when the connector is preconfigured', async () => { + it('does not show the deprecated callout when the connector is preconfigured and not deprecated', async () => { render( { expect(screen.queryByTestId('deprecated-connector-warning-callout')).not.toBeInTheDocument(); }); - it('does not show the deprecated callout when the config of the connector is undefined', async () => { + it('shows the deprecated callout when the connector is preconfigured and deprecated', async () => { render( - // @ts-expect-error - + ); - expect(screen.queryByTestId('deprecated-connector-warning-callout')).not.toBeInTheDocument(); + expect(screen.queryByTestId('deprecated-connector-warning-callout')).toBeInTheDocument(); }); test('it should hide subcategory if selecting a category without subcategories', async () => { diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx index 99bbe8aabaeda..78f17a1d4215a 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -17,7 +17,6 @@ import { Choice, Fields } from './types'; import { choicesToEuiOptions } from './helpers'; import * as i18n from './translations'; -import { connectorValidator } from './validator'; import { DeprecatedCallout } from '../deprecated_callout'; const useGetChoicesFields = ['category', 'subcategory', 'priority']; @@ -43,7 +42,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< const { http, notifications } = useKibana().services; const [choices, setChoices] = useState(defaultFields); - const showConnectorWarning = useMemo(() => connectorValidator(connector) != null, [connector]); + const showConnectorWarning = connector.isDeprecated; const onChangeCb = useCallback( ( diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.test.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.test.tsx index 950b17d6f784f..9a4e19d126bba 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/use_get_choices.test.tsx @@ -29,6 +29,7 @@ const connector = { actionTypeId: '.servicenow', name: 'ServiceNow', isPreconfigured: false, + isDeprecated: false, config: { apiUrl: 'https://dev94428.service-now.com/', }, diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts deleted file mode 100644 index ab21a6b5c779c..0000000000000 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/validator.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { connector } from '../mock'; -import { connectorValidator } from './validator'; - -describe('ServiceNow validator', () => { - describe('connectorValidator', () => { - test('it returns an error message if the connector uses the table API', () => { - const invalidConnector = { - ...connector, - config: { - ...connector.config, - usesTableApi: true, - }, - }; - - expect(connectorValidator(invalidConnector)).toEqual({ message: 'Deprecated connector' }); - }); - - test('it does not return an error message if the connector does not uses the table API', () => { - const invalidConnector = { - ...connector, - config: { - ...connector.config, - usesTableApi: false, - }, - }; - - expect(connectorValidator(invalidConnector)).toBeFalsy(); - }); - - test('it does not return an error message if the config of the connector is undefined', () => { - const { config, ...invalidConnector } = connector; - - // @ts-expect-error - expect(connectorValidator(invalidConnector)).toBeFalsy(); - }); - - test('it does not return an error message if the config of the connector is preconfigured', () => { - expect(connectorValidator({ ...connector, isPreconfigured: true })).toBeFalsy(); - }); - }); -}); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts b/x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts deleted file mode 100644 index fed2900715527..0000000000000 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/validator.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ValidationConfig } from '../../../common/shared_imports'; -import { CaseActionConnector } from '../../types'; - -/** - * The user can not create cases with connectors that use the table API - */ - -export const connectorValidator = ( - connector: CaseActionConnector -): ReturnType => { - /** - * It is not possible to know if a preconfigured connector - * is deprecated or not as the config property of a - * preconfigured connector is not returned by the - * actions framework - */ - - if (connector.isPreconfigured || connector.config == null) { - return; - } - - if (connector.config?.usesTableApi) { - return { - message: 'Deprecated connector', - }; - } -}; diff --git a/x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts index c8cb142232972..a179091282991 100644 --- a/x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts +++ b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.test.ts @@ -56,5 +56,26 @@ describe('Swimlane validator', () => { expect(connectorValidator(invalidConnector)).toBe(undefined); } ); + + test('it does not return an error message if the config is undefined', () => { + const invalidConnector = { + ...connector, + config: undefined, + }; + + expect(connectorValidator(invalidConnector)).toBe(undefined); + }); + + test('it returns an error message if the mappings are undefined', () => { + const invalidConnector = { + ...connector, + config: { + ...connector.config, + mappings: undefined, + }, + }; + + expect(connectorValidator(invalidConnector)).toEqual({ message: 'Invalid connector' }); + }); }); }); diff --git a/x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts index 90d9946d4adb8..d3c94d0150bbe 100644 --- a/x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts +++ b/x-pack/plugins/cases/public/components/connectors/swimlane/validator.ts @@ -28,10 +28,21 @@ export const isAnyRequiredFieldNotSet = (mapping: Record | unde export const connectorValidator = ( connector: CaseActionConnector ): ReturnType => { - const { - config: { mappings, connectorType }, - } = connector; - if (connectorType === SwimlaneConnectorType.Alerts || isAnyRequiredFieldNotSet(mappings)) { + const config = connector.config as + | { + mappings: Record | undefined; + connectorType: string; + } + | undefined; + + if (config == null) { + return; + } + + if ( + config.connectorType === SwimlaneConnectorType.Alerts || + isAnyRequiredFieldNotSet(config.mappings) + ) { return { message: 'Invalid connector', }; diff --git a/x-pack/plugins/cases/public/components/utils.test.ts b/x-pack/plugins/cases/public/components/utils.test.ts index 278bb28b86627..99ec0213ff4ad 100644 --- a/x-pack/plugins/cases/public/components/utils.test.ts +++ b/x-pack/plugins/cases/public/components/utils.test.ts @@ -7,9 +7,19 @@ import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks'; -import { getConnectorIcon, isDeprecatedConnector } from './utils'; +import { connectorDeprecationValidator, getConnectorIcon, isDeprecatedConnector } from './utils'; describe('Utils', () => { + const connector = { + id: 'test', + actionTypeId: '.webhook', + name: 'Test', + config: { usesTableApi: false }, + secrets: {}, + isPreconfigured: false, + isDeprecated: false, + }; + describe('getConnectorIcon', () => { const { createMockActionTypeModel } = actionTypeRegistryMock; const mockTriggersActionsUiService = triggersActionsUiMock.createStart(); @@ -38,60 +48,40 @@ describe('Utils', () => { }); }); - describe('isDeprecatedConnector', () => { - const connector = { - id: 'test', - actionTypeId: '.webhook', - name: 'Test', - config: { usesTableApi: false }, - secrets: {}, - isPreconfigured: false, - }; - - it('returns false if the connector is not defined', () => { - expect(isDeprecatedConnector()).toBe(false); + describe('connectorDeprecationValidator', () => { + it('returns undefined if the connector is not deprecated', () => { + expect(connectorDeprecationValidator(connector)).toBe(undefined); }); - it('returns false if the connector is not ITSM or SecOps', () => { - expect(isDeprecatedConnector(connector)).toBe(false); + it('returns a deprecation message if the connector is deprecated', () => { + expect(connectorDeprecationValidator({ ...connector, isDeprecated: true })).toEqual({ + message: 'Deprecated connector', + }); }); + }); - it('returns false if the connector is .servicenow and the usesTableApi=false', () => { - expect(isDeprecatedConnector({ ...connector, actionTypeId: '.servicenow' })).toBe(false); + describe('isDeprecatedConnector', () => { + it('returns false if the connector is not defined', () => { + expect(isDeprecatedConnector()).toBe(false); }); - it('returns false if the connector is .servicenow-sir and the usesTableApi=false', () => { - expect(isDeprecatedConnector({ ...connector, actionTypeId: '.servicenow-sir' })).toBe(false); + it('returns false if the connector is marked as deprecated', () => { + expect(isDeprecatedConnector({ ...connector, isDeprecated: false })).toBe(false); }); - it('returns true if the connector is .servicenow and the usesTableApi=true', () => { - expect( - isDeprecatedConnector({ - ...connector, - actionTypeId: '.servicenow', - config: { usesTableApi: true }, - }) - ).toBe(true); + it('returns true if the connector is marked as deprecated', () => { + expect(isDeprecatedConnector({ ...connector, isDeprecated: true })).toBe(true); }); - it('returns true if the connector is .servicenow-sir and the usesTableApi=true', () => { + it('returns true if the connector is marked as deprecated (preconfigured connector)', () => { expect( - isDeprecatedConnector({ - ...connector, - actionTypeId: '.servicenow-sir', - config: { usesTableApi: true }, - }) + isDeprecatedConnector({ ...connector, isDeprecated: true, isPreconfigured: true }) ).toBe(true); }); - it('returns false if the connector preconfigured', () => { - expect(isDeprecatedConnector({ ...connector, isPreconfigured: true })).toBe(false); - }); - - it('returns false if the config is undefined', () => { + it('returns false if the connector is not marked as deprecated (preconfigured connector)', () => { expect( - // @ts-expect-error - isDeprecatedConnector({ ...connector, config: undefined }) + isDeprecatedConnector({ ...connector, isDeprecated: false, isPreconfigured: true }) ).toBe(false); }); }); diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 34ebffb4eacb4..403f55574f9a6 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -10,7 +10,6 @@ import { ConnectorTypes } from '../../common/api'; import { FieldConfig, ValidationConfig } from '../common/shared_imports'; import { CasesPluginStart } from '../types'; import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator'; -import { connectorValidator as servicenowConnectorValidator } from './connectors/servicenow/validator'; import { CaseActionConnector } from './types'; export const getConnectorById = ( @@ -23,8 +22,16 @@ const validators: Record< (connector: CaseActionConnector) => ReturnType > = { [ConnectorTypes.swimlane]: swimlaneConnectorValidator, - [ConnectorTypes.serviceNowITSM]: servicenowConnectorValidator, - [ConnectorTypes.serviceNowSIR]: servicenowConnectorValidator, +}; + +export const connectorDeprecationValidator = ( + connector: CaseActionConnector +): ReturnType => { + if (connector.isDeprecated) { + return { + message: 'Deprecated connector', + }; + } }; export const getConnectorsFormValidators = ({ @@ -36,6 +43,14 @@ export const getConnectorsFormValidators = ({ }): FieldConfig => ({ ...config, validations: [ + { + validator: ({ value: connectorId }) => { + const connector = getConnectorById(connectorId as string, connectors); + if (connector != null) { + return connectorDeprecationValidator(connector); + } + }, + }, { validator: ({ value: connectorId }) => { const connector = getConnectorById(connectorId as string, connectors); @@ -72,28 +87,6 @@ export const getConnectorIcon = ( return emptyResponse; }; -// TODO: Remove when the applications are certified export const isDeprecatedConnector = (connector?: CaseActionConnector): boolean => { - /** - * It is not possible to know if a preconfigured connector - * is deprecated or not as the config property of a - * preconfigured connector is not returned by the - * actions framework - */ - if (connector == null || connector.config == null || connector.isPreconfigured) { - return false; - } - - if (connector.actionTypeId === '.servicenow' || connector.actionTypeId === '.servicenow-sir') { - /** - * Connector's prior to the Elastic ServiceNow application - * use the Table API (https://developer.servicenow.com/dev.do#!/reference/api/rome/rest/c_TableAPI) - * Connectors after the Elastic ServiceNow application use the - * Import Set API (https://developer.servicenow.com/dev.do#!/reference/api/rome/rest/c_ImportSetAPI) - * A ServiceNow connector is considered deprecated if it uses the Table API. - */ - return !!connector.config.usesTableApi; - } - - return false; + return connector?.isDeprecated ?? false; }; diff --git a/x-pack/plugins/cases/server/client/cases/utils.test.ts b/x-pack/plugins/cases/server/client/cases/utils.test.ts index 4832ffe5b2eaf..baf32fd30d74b 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.test.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.test.ts @@ -521,6 +521,7 @@ describe('utils', () => { apiUrl: 'https://elastic.jira.com', }, isPreconfigured: false, + isDeprecated: false, }; it('creates an external incident', async () => { diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index d1bf39b575ab5..0c5f95189ae90 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -210,6 +210,17 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) password: 'somepassword', }, }, + 'my-deprecated-servicenow-default': { + actionTypeId: '.servicenow', + name: 'ServiceNow#xyz', + config: { + apiUrl: 'https://ven04334.service-now.com', + }, + secrets: { + username: 'elastic_integration', + password: 'somepassword', + }, + }, 'custom-system-abc-connector': { actionTypeId: 'system-abc-action-type', name: 'SystemABC', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all.ts index 103ae5abd3071..69f618c804eb1 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all.ts @@ -95,6 +95,14 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'ServiceNow#xyz', referenced_by_count: 0, }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, { id: 'my-slack1', is_preconfigured: true, @@ -222,6 +230,14 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'ServiceNow#xyz', referenced_by_count: 0, }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, { id: 'my-slack1', is_preconfigured: true, @@ -313,6 +329,14 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'ServiceNow#xyz', referenced_by_count: 0, }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, { id: 'my-slack1', is_preconfigured: true, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/actions_telemetry.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/actions_telemetry.ts index b187b9e9f9759..b1e77b98b792d 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/actions_telemetry.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/telemetry/actions_telemetry.ts @@ -188,7 +188,7 @@ export default function createActionsTelemetryTests({ getService }: FtrProviderC const telemetry = JSON.parse(taskState!); // total number of connectors - expect(telemetry.count_total).to.equal(18); + expect(telemetry.count_total).to.equal(19); // total number of active connectors (used by a rule) expect(telemetry.count_active_total).to.equal(7); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get.ts index d5d5109b6e738..6d923452faac5 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get.ts @@ -98,6 +98,18 @@ export default function getActionTests({ getService }: FtrProviderContext) { connector_type_id: '.servicenow', name: 'ServiceNow#xyz', }); + + await supertest + .get( + `${getUrlPrefix(Spaces.space1.id)}/api/actions/connector/my-deprecated-servicenow-default` + ) + .expect(200, { + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + connector_type_id: '.servicenow', + name: 'ServiceNow#xyz', + }); }); describe('legacy', () => { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts index 54a0e6e10a198..0632f48ed6e8d 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts @@ -83,6 +83,14 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'ServiceNow#xyz', referenced_by_count: 0, }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, { id: 'my-slack1', is_preconfigured: true, @@ -162,6 +170,14 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'ServiceNow#xyz', referenced_by_count: 0, }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, { id: 'my-slack1', is_preconfigured: true, @@ -254,6 +270,14 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'ServiceNow#xyz', referencedByCount: 0, }, + { + actionTypeId: '.servicenow', + id: 'my-deprecated-servicenow-default', + isPreconfigured: true, + isDeprecated: true, + name: 'ServiceNow#xyz', + referencedByCount: 0, + }, { id: 'my-slack1', isPreconfigured: true, diff --git a/x-pack/test/cases_api_integration/common/config.ts b/x-pack/test/cases_api_integration/common/config.ts index 89dd19ae74897..a20dd300a4e6e 100644 --- a/x-pack/test/cases_api_integration/common/config.ts +++ b/x-pack/test/cases_api_integration/common/config.ts @@ -144,6 +144,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) actionTypeId: '.servicenow', config: { apiUrl: 'https://example.com', + usesTableApi: false, }, secrets: { username: 'elastic', From aa4c389ed2839d18ab008dd00a7a89c8f4080d74 Mon Sep 17 00:00:00 2001 From: Cristina Amico Date: Fri, 20 May 2022 12:24:38 +0200 Subject: [PATCH 03/39] [Fleet] Changes to agent upgrade modal to allow for rolling upgrades (#132421) * [Fleet] Changes to agent upgrade modal to allow for rolling upgrades * Update the onSubmit logic and handle case with single agent * Fix check * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * Add option to upgrade immediately; minor fixes * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * Add callout in modal for 400 errors * Linter fixes * Fix i18n error * Address code review comments Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../fleet/common/types/rest_spec/agent.ts | 1 + .../components/actions_menu.tsx | 1 - .../components/bulk_actions.tsx | 7 +- .../sections/agents/agent_list_page/index.tsx | 1 - .../agent_upgrade_modal/constants.tsx | 32 +++ .../components/agent_upgrade_modal/index.tsx | 187 ++++++++++++++---- .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 9 files changed, 184 insertions(+), 51 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/constants.tsx diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts index 7a8b7b918c1e3..886730d38f831 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -89,6 +89,7 @@ export interface PostBulkAgentUpgradeRequest { agents: string[] | string; source_uri?: string; version: string; + rollout_duration_seconds?: number; }; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx index 44e87d7fb4e63..239afe6c7e330 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/actions_menu.tsx @@ -70,7 +70,6 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ { setIsUpgradeModalOpen(false); refreshAgent(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx index a2515b51814ee..e27c647e25f70 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/bulk_actions.tsx @@ -24,7 +24,6 @@ import { AgentUnenrollAgentModal, AgentUpgradeAgentModal, } from '../../components'; -import { useKibanaVersion } from '../../../../hooks'; import type { SelectionMode } from './types'; @@ -48,11 +47,10 @@ export const AgentBulkActions: React.FunctionComponent = ({ selectedAgents, refreshAgents, }) => { - const kibanaVersion = useKibanaVersion(); // Bulk actions menu states const [isMenuOpen, setIsMenuOpen] = useState(false); const closeMenu = () => setIsMenuOpen(false); - const openMenu = () => setIsMenuOpen(true); + const onClickMenu = () => setIsMenuOpen(!isMenuOpen); // Actions states const [isReassignFlyoutOpen, setIsReassignFlyoutOpen] = useState(false); @@ -150,7 +148,6 @@ export const AgentBulkActions: React.FunctionComponent = ({ {isUpgradeModalOpen && ( { @@ -172,7 +169,7 @@ export const AgentBulkActions: React.FunctionComponent = ({ fill iconType="arrowDown" iconSide="right" - onClick={openMenu} + onClick={onClickMenu} data-test-subj="agentBulkActionsButton" > = () => { fetchData(); refreshUpgrades(); }} - version={kibanaVersion} /> )} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/constants.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/constants.tsx new file mode 100644 index 0000000000000..b5d8cd8f4d72d --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/constants.tsx @@ -0,0 +1,32 @@ +/* + * 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. + */ + +// Available versions for the upgrade of the Elastic Agent +// These versions are only intended to be used as a fallback +// in the event that the updated versions cannot be retrieved from the endpoint + +export const FALLBACK_VERSIONS = [ + '8.2.0', + '8.1.3', + '8.1.2', + '8.1.1', + '8.1.0', + '8.0.1', + '8.0.0', + '7.9.3', + '7.9.2', + '7.9.1', + '7.9.0', + '7.8.1', + '7.8.0', + '7.17.3', + '7.17.2', + '7.17.1', + '7.17.0', +]; + +export const MAINTAINANCE_VALUES = [1, 2, 4, 8, 12, 24, 48]; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx index 72ca7a5b80fd7..2122abb5e2785 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx @@ -7,34 +7,89 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiConfirmModal, EuiBetaBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiConfirmModal, + EuiComboBox, + EuiFormRow, + EuiSpacer, + EuiToolTip, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiCallOut, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; + import type { Agent } from '../../../../types'; import { sendPostAgentUpgrade, sendPostBulkAgentUpgrade, useStartServices, + useKibanaVersion, } from '../../../../hooks'; +import { FALLBACK_VERSIONS, MAINTAINANCE_VALUES } from './constants'; + interface Props { onClose: () => void; agents: Agent[] | string; agentCount: number; - version: string; } +const getVersion = (version: Array>) => version[0].value as string; + export const AgentUpgradeAgentModal: React.FunctionComponent = ({ onClose, agents, agentCount, - version, }) => { const { notifications } = useStartServices(); + const kibanaVersion = useKibanaVersion(); const [isSubmitting, setIsSubmitting] = useState(false); + const [errors, setErrors] = useState(); + const isSingleAgent = Array.isArray(agents) && agents.length === 1; + const isSmallBatch = Array.isArray(agents) && agents.length > 1 && agents.length <= 10; const isAllAgents = agents === ''; + + const fallbackVersions = [kibanaVersion].concat(FALLBACK_VERSIONS); + const fallbackOptions: Array> = fallbackVersions.map( + (option) => ({ + label: option, + value: option, + }) + ); + const maintainanceWindows = isSmallBatch ? [0].concat(MAINTAINANCE_VALUES) : MAINTAINANCE_VALUES; + const maintainanceOptions: Array> = maintainanceWindows.map( + (option) => ({ + label: + option === 0 + ? i18n.translate('xpack.fleet.upgradeAgents.noMaintainanceWindowOption', { + defaultMessage: 'Immediately', + }) + : i18n.translate('xpack.fleet.upgradeAgents.hourLabel', { + defaultMessage: '{option} {count, plural, one {hour} other {hours}}', + values: { option, count: option === 1 }, + }), + value: option === 0 ? 0 : option * 3600, + }) + ); + const [selectedVersion, setSelectedVersion] = useState([fallbackOptions[0]]); + const [selectedMantainanceWindow, setSelectedMantainanceWindow] = useState([ + maintainanceOptions[0], + ]); + async function onSubmit() { + const version = getVersion(selectedVersion); + const rolloutOptions = + selectedMantainanceWindow.length > 0 && (selectedMantainanceWindow[0]?.value as number) > 0 + ? { + rollout_duration_seconds: selectedMantainanceWindow[0].value, + } + : {}; + try { setIsSubmitting(true); const { data, error } = isSingleAgent @@ -42,10 +97,14 @@ export const AgentUpgradeAgentModal: React.FunctionComponent = ({ version, }) : await sendPostBulkAgentUpgrade({ - agents: Array.isArray(agents) ? agents.map((agent) => agent.id) : agents, version, + agents: Array.isArray(agents) ? agents.map((agent) => agent.id) : agents, + ...rolloutOptions, }); if (error) { + if (error?.statusCode === 400) { + setErrors(error?.message); + } throw error; } @@ -114,39 +173,20 @@ export const AgentUpgradeAgentModal: React.FunctionComponent = ({ - - {isSingleAgent ? ( - - ) : ( - - )} - - - - } - tooltipContent={ - - } + <> + {isSingleAgent ? ( + + ) : ( + - - + )} + } onCancel={onClose} onConfirm={onSubmit} @@ -179,17 +219,88 @@ export const AgentUpgradeAgentModal: React.FunctionComponent = ({ defaultMessage="This action will upgrade the agent running on '{hostName}' to version {version}. This action can not be undone. Are you sure you wish to continue?" values={{ hostName: ((agents[0] as Agent).local_metadata.host as any).hostname, - version, + version: getVersion(selectedVersion), }} /> ) : ( )}

+ + + >) => { + setSelectedVersion(selected); + }} + /> + + + {!isSingleAgent ? ( + + + {i18n.translate('xpack.fleet.upgradeAgents.maintainanceAvailableLabel', { + defaultMessage: 'Maintainance window available', + })} + + + + + + + + + } + fullWidth + > + >) => { + setSelectedMantainanceWindow(selected); + }} + /> + + ) : null} + {errors ? ( + <> + + + ) : null}
); }; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index f211cc9fede8e..8bd7308a27a70 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -13071,8 +13071,6 @@ "xpack.fleet.upgradeAgents.cancelButtonLabel": "Annuler", "xpack.fleet.upgradeAgents.confirmMultipleButtonLabel": "Mettre à niveau {count, plural, one {l'agent} other {{count} agents} =true {tous les agents sélectionnés}}", "xpack.fleet.upgradeAgents.confirmSingleButtonLabel": "Mettre à niveau l'agent", - "xpack.fleet.upgradeAgents.experimentalLabel": "Expérimental", - "xpack.fleet.upgradeAgents.experimentalLabelTooltip": "Une modification ou une suppression de la mise à niveau de l'agent peut intervenir dans une version ultérieure. La mise à niveau n'est pas soumise à l'accord de niveau de service du support technique.", "xpack.fleet.upgradeAgents.fatalErrorNotificationTitle": "Erreur lors de la mise à niveau de {count, plural, one {l'agent} other {{count} agents} =true {tous les agents sélectionnés}}", "xpack.fleet.upgradeAgents.successMultiNotificationTitle": "{isMixed, select, true {{success} agents sur {total}} other {{isAllAgents, select, true {Tous les agents sélectionnés} other {{success}} }}} mis à niveau", "xpack.fleet.upgradeAgents.successSingleNotificationTitle": "{count} agent mis à niveau", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index eec41bfb71c81..12300057ca7ff 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13178,8 +13178,6 @@ "xpack.fleet.upgradeAgents.cancelButtonLabel": "キャンセル", "xpack.fleet.upgradeAgents.confirmMultipleButtonLabel": "{count, plural, other {{count}個のエージェント} =true {すべての選択されたエージェント}}をアップグレード", "xpack.fleet.upgradeAgents.confirmSingleButtonLabel": "エージェントをアップグレード", - "xpack.fleet.upgradeAgents.experimentalLabel": "実験的", - "xpack.fleet.upgradeAgents.experimentalLabelTooltip": "アップグレードエージェントは今後のリリースで変更または削除される可能性があり、SLA のサポート対象になりません。", "xpack.fleet.upgradeAgents.fatalErrorNotificationTitle": "{count, plural, other {{count}個のエージェント} =true {すべての選択されたエージェント}}のアップグレードエラー", "xpack.fleet.upgradeAgents.successMultiNotificationTitle": "{isMixed, select, true {{success}/{total}個の} other {{isAllAgents, select, true {すべての選択された} other {{success}} }}}エージェントをアップグレードしました", "xpack.fleet.upgradeAgents.successSingleNotificationTitle": "{count}個のエージェントをアップグレードしました", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 2d7566bdd8c87..5953802b0a0a5 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13202,8 +13202,6 @@ "xpack.fleet.upgradeAgents.cancelButtonLabel": "取消", "xpack.fleet.upgradeAgents.confirmMultipleButtonLabel": "升级{count, plural, one {代理} other { {count} 个代理} =true {所有选定代理}}", "xpack.fleet.upgradeAgents.confirmSingleButtonLabel": "升级代理", - "xpack.fleet.upgradeAgents.experimentalLabel": "实验性", - "xpack.fleet.upgradeAgents.experimentalLabelTooltip": "在未来的版本中可能会更改或移除升级代理,其不受支持 SLA 的约束。", "xpack.fleet.upgradeAgents.fatalErrorNotificationTitle": "升级{count, plural, one {代理} other { {count} 个代理} =true {所有选定代理}}时出错", "xpack.fleet.upgradeAgents.successMultiNotificationTitle": "已升级{isMixed, select, true { {success} 个(共 {total} 个)} other {{isAllAgents, select, true {所有选定} other { {success} 个} }}}代理", "xpack.fleet.upgradeAgents.successSingleNotificationTitle": "已升级 {count} 个代理", From 57d783a8c7806a525b926bcab6916a6afda889d2 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 20 May 2022 12:42:07 +0200 Subject: [PATCH 04/39] add tooltip and change icon (#132581) --- .../datatable_visualization/visualization.tsx | 9 +++++---- .../config_panel/color_indicator.tsx | 11 +++++++++++ .../shared_components/collapse_setting.tsx | 19 +++++++++++++++++-- x-pack/plugins/lens/public/types.ts | 2 +- .../public/xy_visualization/visualization.tsx | 2 +- 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index d42af9aa3932c..12c5dafb5d942 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -203,10 +203,11 @@ export const getDatatableVisualization = ({ ) .map((accessor) => ({ columnId: accessor, - triggerIcon: - columnMap[accessor].hidden || columnMap[accessor].collapseFn - ? 'invisible' - : undefined, + triggerIcon: columnMap[accessor].hidden + ? 'invisible' + : columnMap[accessor].collapseFn + ? 'aggregate' + : undefined, })), supportsMoreColumns: true, filterOperations: (op) => op.isBucketed, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx index b8a5819d45532..b12f50a7b35a0 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/color_indicator.tsx @@ -59,6 +59,17 @@ export function ColorIndicator({ })} /> )} + {accessorConfig.triggerIcon === 'aggregate' && ( + + )} {accessorConfig.triggerIcon === 'colorBy' && ( + + {i18n.translate('xpack.lens.collapse.label', { defaultMessage: 'Collapse by' })} + {''} + + + + } display="columnCompressed" fullWidth > diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 1f2ee1266ddb7..1ffc300542b09 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -557,7 +557,7 @@ export type VisualizationDimensionEditorProps = VisualizationConfig export interface AccessorConfig { columnId: string; - triggerIcon?: 'color' | 'disabled' | 'colorBy' | 'none' | 'invisible'; + triggerIcon?: 'color' | 'disabled' | 'colorBy' | 'none' | 'invisible' | 'aggregate'; color?: string; palette?: string[] | Array<{ color: string; stop: number }>; } diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 096c395b31eaf..b35247f4d9d97 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -276,7 +276,7 @@ export const getXyVisualization = ({ ? [ { columnId: dataLayer.splitAccessor, - triggerIcon: dataLayer.collapseFn ? ('invisible' as const) : ('colorBy' as const), + triggerIcon: dataLayer.collapseFn ? ('aggregate' as const) : ('colorBy' as const), palette: dataLayer.collapseFn ? undefined : paletteService From b3aee1740ba63fdc83af0f9ce98246858c8fb929 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Fri, 20 May 2022 12:56:03 +0200 Subject: [PATCH 05/39] [ML] Disable AIOps UI/APIs. (#132589) This disables the UI and APIs for Explain log rate spikes in the ML plugin since it will not be part of 8.3. Once 8.3 has been branched off, we can reenable it in main. This also adds a check to the API integration tests to run the tests only when the hard coded feature flag is set to true. --- x-pack/plugins/aiops/common/index.ts | 2 +- x-pack/test/api_integration/apis/aiops/index.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/aiops/common/index.ts b/x-pack/plugins/aiops/common/index.ts index 0f4835d67ecc7..162fa9f1af624 100755 --- a/x-pack/plugins/aiops/common/index.ts +++ b/x-pack/plugins/aiops/common/index.ts @@ -19,4 +19,4 @@ export const PLUGIN_NAME = 'AIOps'; * This is an internal hard coded feature flag so we can easily turn on/off the * "Explain log rate spikes UI" during development until the first release. */ -export const AIOPS_ENABLED = true; +export const AIOPS_ENABLED = false; diff --git a/x-pack/test/api_integration/apis/aiops/index.ts b/x-pack/test/api_integration/apis/aiops/index.ts index d2aacc454b567..8d6b6ea13399f 100644 --- a/x-pack/test/api_integration/apis/aiops/index.ts +++ b/x-pack/test/api_integration/apis/aiops/index.ts @@ -5,13 +5,17 @@ * 2.0. */ +import { AIOPS_ENABLED } from '@kbn/aiops-plugin/common'; + import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('AIOps', function () { this.tags(['ml']); - loadTestFile(require.resolve('./example_stream')); - loadTestFile(require.resolve('./explain_log_rate_spikes')); + if (AIOPS_ENABLED) { + loadTestFile(require.resolve('./example_stream')); + loadTestFile(require.resolve('./explain_log_rate_spikes')); + } }); } From 3b7c7e81ff633eab2cd8c1da412ed88af7344e71 Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Fri, 20 May 2022 13:08:20 +0200 Subject: [PATCH 06/39] Update user risk dashboard name (#132441) --- .../public/users/pages/navigation/user_risk_tab_body.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx index bb1f73765bf59..1684297fd236d 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx @@ -27,7 +27,7 @@ const StyledEuiFlexGroup = styled(EuiFlexGroup)` margin-top: ${({ theme }) => theme.eui.paddingSizes.l}; `; -const RISKY_USERS_DASHBOARD_TITLE = 'User Risk Score (Start Here)'; +const RISKY_USERS_DASHBOARD_TITLE = 'Current Risk Score For Users'; const UserRiskTabBodyComponent: React.FC< Pick & { From f0cb40af75a0848068d1775427762cdcab093ef6 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Fri, 20 May 2022 05:32:02 -0600 Subject: [PATCH 07/39] [ML] Data Frame Analytics: replace custom types with estypes (#132443) * replace common data_frame_analytics types from server with esclient types * remove unused ts error ignore commments * remove generic analyis type and move types to common dir * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * move types to commont folder Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../ml/common/types/data_frame_analytics.ts | 155 +++++++++--------- .../common/analytics.test.ts | 3 + .../data_frame_analytics/common/analytics.ts | 97 +---------- .../data_frame_analytics/common/fields.ts | 2 +- .../data_frame_analytics/common/index.ts | 8 +- .../analysis_fields_table.tsx | 2 +- .../configuration_step_form.tsx | 2 +- .../components/shared/fetch_explain_data.ts | 12 +- .../column_data.tsx | 2 +- .../evaluate_panel.tsx | 2 +- .../get_roc_curve_chart_vega_lite_spec.tsx | 2 +- .../use_confusion_matrix.ts | 8 +- .../use_roc_curve.ts | 4 +- .../exploration_page_wrapper.tsx | 2 +- .../use_exploration_results.ts | 2 +- .../outlier_exploration/use_outlier_data.ts | 6 +- .../regression_exploration/evaluate_panel.tsx | 2 +- .../action_edit/edit_action_flyout.tsx | 10 +- .../analytics_list/expanded_row.tsx | 6 +- .../components/analytics_list/use_columns.tsx | 1 + .../use_create_analytics_form/reducer.ts | 1 + .../use_create_analytics_form/state.test.ts | 1 + .../hooks/use_create_analytics_form/state.ts | 4 +- .../use_create_analytics_form.ts | 4 +- .../analytics_service/get_analytics.test.ts | 2 + .../ml_api_service/data_frame_analytics.ts | 6 +- .../data_frame_analytics/analytics_manager.ts | 1 - .../models/data_frame_analytics/validation.ts | 30 +++- .../plugins/ml/server/saved_objects/checks.ts | 2 +- .../ml/stack_management_jobs/export_jobs.ts | 3 - 30 files changed, 155 insertions(+), 227 deletions(-) diff --git a/x-pack/plugins/ml/common/types/data_frame_analytics.ts b/x-pack/plugins/ml/common/types/data_frame_analytics.ts index 92c0c1d06ef93..3d7dda658a0ba 100644 --- a/x-pack/plugins/ml/common/types/data_frame_analytics.ts +++ b/x-pack/plugins/ml/common/types/data_frame_analytics.ts @@ -7,11 +7,9 @@ import Boom from '@hapi/boom'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { RuntimeMappings } from './fields'; import { EsErrorBody } from '../util/errors'; import { ANALYSIS_CONFIG_TYPE } from '../constants/data_frame_analytics'; -import { DATA_FRAME_TASK_STATE } from '../constants/data_frame_analytics'; export interface DeleteDataFrameAnalyticsWithIndexStatus { success: boolean; @@ -57,69 +55,90 @@ export interface ClassificationAnalysis { classification: Classification; } -interface GenericAnalysis { - [key: string]: Record; +export type AnalysisConfig = estypes.MlDataframeAnalysisContainer; +export interface DataFrameAnalyticsConfig + extends Omit { + analyzed_fields?: estypes.MlDataframeAnalysisAnalyzedFields; } -export type AnalysisConfig = - | OutlierAnalysis - | RegressionAnalysis - | ClassificationAnalysis - | GenericAnalysis; - -export interface DataFrameAnalyticsConfig { - id: DataFrameAnalyticsId; +export interface UpdateDataFrameAnalyticsConfig { + allow_lazy_start?: string; description?: string; - dest: { - index: IndexName; - results_field: string; - }; - source: { - index: IndexName | IndexName[]; - query?: estypes.QueryDslQueryContainer; - runtime_mappings?: RuntimeMappings; - }; - analysis: AnalysisConfig; - analyzed_fields?: { - includes?: string[]; - excludes?: string[]; - }; - model_memory_limit: string; + model_memory_limit?: string; max_num_threads?: number; - create_time: number; - version: string; - allow_lazy_start?: boolean; } export type DataFrameAnalysisConfigType = typeof ANALYSIS_CONFIG_TYPE[keyof typeof ANALYSIS_CONFIG_TYPE]; -export type DataFrameTaskStateType = - typeof DATA_FRAME_TASK_STATE[keyof typeof DATA_FRAME_TASK_STATE]; +export type DataFrameTaskStateType = estypes.MlDataframeState | 'analyzing' | 'reindexing'; + +export interface DataFrameAnalyticsStats extends Omit { + failure_reason?: string; + state: DataFrameTaskStateType; +} + +export type DfAnalyticsExplainResponse = estypes.MlExplainDataFrameAnalyticsResponse; + +export interface PredictedClass { + predicted_class: string; + count: number; +} +export interface ConfusionMatrix { + actual_class: string; + actual_class_doc_count: number; + predicted_classes: PredictedClass[]; + other_predicted_class_doc_count: number; +} + +export interface RocCurveItem { + fpr: number; + threshold: number; + tpr: number; +} -interface ProgressSection { - phase: string; - progress_percent: number; +interface EvalClass { + class_name: string; + value: number; +} +export interface ClassificationEvaluateResponse { + classification: { + multiclass_confusion_matrix?: { + confusion_matrix: ConfusionMatrix[]; + }; + recall?: { + classes: EvalClass[]; + avg_recall: number; + }; + accuracy?: { + classes: EvalClass[]; + overall_accuracy: number; + }; + auc_roc?: { + curve?: RocCurveItem[]; + value: number; + }; + }; } -export interface DataFrameAnalyticsStats { - assignment_explanation?: string; - id: DataFrameAnalyticsId; - memory_usage?: { - timestamp?: string; - peak_usage_bytes: number; - status: string; +export interface EvaluateMetrics { + classification: { + accuracy?: object; + recall?: object; + multiclass_confusion_matrix?: object; + auc_roc?: { include_curve: boolean; class_name: string }; }; - node?: { - attributes: Record; - ephemeral_id: string; - id: string; - name: string; - transport_address: string; + regression: { + r_squared: object; + mse: object; + msle: object; + huber: object; }; - progress: ProgressSection[]; - failure_reason?: string; - state: DataFrameTaskStateType; +} + +export interface FieldSelectionItem + extends Omit { + mapping_types?: string[]; } export interface AnalyticsMapNodeElement { @@ -146,30 +165,14 @@ export interface AnalyticsMapReturnType { error: null | any; } -export interface FeatureProcessor { - frequency_encoding: { - feature_name: string; - field: string; - frequency_map: Record; - }; - multi_encoding: { - processors: any[]; - }; - n_gram_encoding: { - feature_prefix?: string; - field: string; - length?: number; - n_grams: number[]; - start?: number; - }; - one_hot_encoding: { - field: string; - hot_map: string; - }; - target_mean_encoding: { - default_value: number; - feature_name: string; - field: string; - target_map: Record; +export type FeatureProcessor = estypes.MlDataframeAnalysisFeatureProcessor; + +export interface TrackTotalHitsSearchResponse { + hits: { + total: { + value: number; + relation: string; + }; + hits: any[]; }; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.test.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.test.ts index 0cd4d190ebbbd..aa83ce0a1f4ad 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.test.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.test.ts @@ -13,15 +13,18 @@ describe('Data Frame Analytics: Analytics utils', () => { expect(getAnalysisType(outlierAnalysis)).toBe('outlier_detection'); const regressionAnalysis = { regression: {} }; + // @ts-expect-error incomplete regression analysis expect(getAnalysisType(regressionAnalysis)).toBe('regression'); // test against a job type that does not exist yet. const otherAnalysis = { other: {} }; + // @ts-expect-error unkown analysis type expect(getAnalysisType(otherAnalysis)).toBe('other'); // if the analysis object has a shape that is not just a single property, // the job type will be returned as 'unknown'. const unknownAnalysis = { outlier_detection: {}, regression: {} }; + // @ts-expect-error unkown analysis type expect(getAnalysisType(unknownAnalysis)).toBe('unknown'); }); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index c2c2563c5ba7c..064416cd722d1 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -12,6 +12,11 @@ import { cloneDeep } from 'lodash'; import { ml } from '../../services/ml_api_service'; import { Dictionary } from '../../../../common/types/common'; import { extractErrorMessage } from '../../../../common/util/errors'; +import { + ClassificationEvaluateResponse, + EvaluateMetrics, + TrackTotalHitsSearchResponse, +} from '../../../../common/types/data_frame_analytics'; import { SavedSearchQuery } from '../../contexts/ml'; import { AnalysisConfig, @@ -106,23 +111,6 @@ export enum INDEX_STATUS { ERROR, } -export interface FieldSelectionItem { - name: string; - mappings_types?: string[]; - is_included: boolean; - is_required: boolean; - feature_type?: string; - reason?: string; -} - -export interface DfAnalyticsExplainResponse { - field_selection?: FieldSelectionItem[]; - memory_estimation: { - expected_memory_without_disk: string; - expected_memory_with_disk: string; - }; -} - export interface Eval { mse: number | string; msle: number | string; @@ -148,49 +136,6 @@ export interface RegressionEvaluateResponse { }; } -export interface PredictedClass { - predicted_class: string; - count: number; -} - -export interface ConfusionMatrix { - actual_class: string; - actual_class_doc_count: number; - predicted_classes: PredictedClass[]; - other_predicted_class_doc_count: number; -} - -export interface RocCurveItem { - fpr: number; - threshold: number; - tpr: number; -} - -interface EvalClass { - class_name: string; - value: number; -} - -export interface ClassificationEvaluateResponse { - classification: { - multiclass_confusion_matrix?: { - confusion_matrix: ConfusionMatrix[]; - }; - recall?: { - classes: EvalClass[]; - avg_recall: number; - }; - accuracy?: { - classes: EvalClass[]; - overall_accuracy: number; - }; - auc_roc?: { - curve?: RocCurveItem[]; - value: number; - }; - }; -} - interface LoadEvaluateResult { success: boolean; eval: RegressionEvaluateResponse | ClassificationEvaluateResponse | null; @@ -279,13 +224,6 @@ export const isClassificationEvaluateResponse = ( ); }; -export interface UpdateDataFrameAnalyticsConfig { - allow_lazy_start?: string; - description?: string; - model_memory_limit?: string; - max_num_threads?: number; -} - export enum REFRESH_ANALYTICS_LIST_STATE { ERROR = 'error', IDLE = 'idle', @@ -451,21 +389,6 @@ export enum REGRESSION_STATS { HUBER = 'huber', } -interface EvaluateMetrics { - classification: { - accuracy?: object; - recall?: object; - multiclass_confusion_matrix?: object; - auc_roc?: { include_curve: boolean; class_name: string }; - }; - regression: { - r_squared: object; - mse: object; - msle: object; - huber: object; - }; -} - interface LoadEvalDataConfig { isTraining?: boolean; index: string; @@ -548,16 +471,6 @@ export const loadEvalData = async ({ } }; -interface TrackTotalHitsSearchResponse { - hits: { - total: { - value: number; - relation: string; - }; - hits: any[]; - }; -} - interface LoadDocsCountConfig { ignoreDefaultQuery?: boolean; isTraining?: boolean; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts index 89c05643f0dc8..3ab82daa6b1f3 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts @@ -96,7 +96,7 @@ export const sortExplorationResultsFields = ( if (isClassificationAnalysis(jobConfig.analysis) || isRegressionAnalysis(jobConfig.analysis)) { const dependentVariable = getDependentVar(jobConfig.analysis); - const predictedField = getPredictedFieldName(resultsField, jobConfig.analysis, true); + const predictedField = getPredictedFieldName(resultsField!, jobConfig.analysis, true); if (a === `${resultsField}.is_training`) { return -1; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts index 2fb0daa1ed45e..f47b5b66f4944 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts @@ -5,13 +5,7 @@ * 2.0. */ -export type { - UpdateDataFrameAnalyticsConfig, - IndexPattern, - RegressionEvaluateResponse, - Eval, - SearchQuery, -} from './analytics'; +export type { IndexPattern, RegressionEvaluateResponse, Eval, SearchQuery } from './analytics'; export { getAnalysisType, getDependentVar, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx index d98940588f48f..f4a9eb0d5c0a8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx @@ -12,7 +12,7 @@ import { isEqual } from 'lodash'; import { LEFT_ALIGNMENT, SortableProperties } from '@elastic/eui/lib/services'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { FieldSelectionItem } from '../../../../common/analytics'; +import { FieldSelectionItem } from '../../../../../../../common/types/data_frame_analytics'; // @ts-ignore could not find declaration file import { CustomSelectionTable } from '../../../../../components/custom_selection_table'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx index b4f55bcae0947..758fd01a133c6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx @@ -28,10 +28,10 @@ import { ANALYSIS_CONFIG_TYPE, TRAINING_PERCENT_MIN, TRAINING_PERCENT_MAX, - FieldSelectionItem, } from '../../../../common/analytics'; import { getScatterplotMatrixLegendType } from '../../../../common/get_scatterplot_matrix_legend_type'; import { RuntimeMappings as RuntimeMappingsType } from '../../../../../../../common/types/fields'; +import { FieldSelectionItem } from '../../../../../../../common/types/data_frame_analytics'; import { isRuntimeMappings, isRuntimeField, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts index 7c83b0af15107..ca334a58b36c2 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/shared/fetch_explain_data.ts @@ -7,19 +7,15 @@ import { ml } from '../../../../../services/ml_api_service'; import { extractErrorProperties } from '../../../../../../../common/util/errors'; -import { DfAnalyticsExplainResponse, FieldSelectionItem } from '../../../../common/analytics'; +import { + DfAnalyticsExplainResponse, + FieldSelectionItem, +} from '../../../../../../../common/types/data_frame_analytics'; import { getJobConfigFromFormState, State, } from '../../../analytics_management/hooks/use_create_analytics_form/state'; -export interface FetchExplainDataReturnType { - success: boolean; - expectedMemory: string; - fieldSelection: FieldSelectionItem[]; - errorMessage: string; -} - export const fetchExplainData = async (formState: State['form']) => { const jobConfig = getJobConfigFromFormState(formState); let errorMessage = ''; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx index 31b7db66f81ae..c983511f80393 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx @@ -14,7 +14,7 @@ import { EuiPopover, EuiText, } from '@elastic/eui'; -import { ConfusionMatrix } from '../../../../common/analytics'; +import { ConfusionMatrix } from '../../../../../../../common/types/data_frame_analytics'; const COL_INITIAL_WIDTH = 165; // in pixels diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx index 97ab582832b64..8ba780a3e512a 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx @@ -119,7 +119,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se columns.map(({ id }: { id: string }) => id) ); - const resultsField = jobConfig.dest.results_field; + const resultsField = jobConfig.dest.results_field!; const isTraining = isTrainingFilter(searchQuery, resultsField); const { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx index 3ca1f65cf2ecc..e3f92c36507c6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx @@ -16,7 +16,7 @@ import { i18n } from '@kbn/i18n'; import { LEGEND_TYPES } from '../../../../../components/vega_chart/common'; -import { RocCurveItem } from '../../../../common/analytics'; +import { RocCurveItem } from '../../../../../../../common/types/data_frame_analytics'; const GRAY = euiPaletteGray(1)[0]; const BASELINE = 'baseline'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts index 2a75acf823e88..c51f5bf3e9665 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts @@ -9,13 +9,15 @@ import { useState, useEffect } from 'react'; import { isClassificationEvaluateResponse, - ClassificationEvaluateResponse, - ConfusionMatrix, ResultsSearchQuery, ANALYSIS_CONFIG_TYPE, ClassificationMetricItem, } from '../../../../common/analytics'; import { isKeywordAndTextType } from '../../../../common/fields'; +import { + ClassificationEvaluateResponse, + ConfusionMatrix, +} from '../../../../../../../common/types/data_frame_analytics'; import { getDependentVar, @@ -78,7 +80,7 @@ export const useConfusionMatrix = ( let requiresKeyword = false; const dependentVariable = getDependentVar(jobConfig.analysis); - const resultsField = jobConfig.dest.results_field; + const resultsField = jobConfig.dest.results_field!; const isTraining = isTrainingFilter(searchQuery, resultsField); try { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts index 20521258cd374..f83f9f9f31e0f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts @@ -10,10 +10,10 @@ import { useState, useEffect } from 'react'; import { isClassificationEvaluateResponse, ResultsSearchQuery, - RocCurveItem, ANALYSIS_CONFIG_TYPE, } from '../../../../common/analytics'; import { isKeywordAndTextType } from '../../../../common/fields'; +import { RocCurveItem } from '../../../../../../../common/types/data_frame_analytics'; import { getDependentVar, @@ -58,7 +58,7 @@ export const useRocCurve = ( setIsLoading(true); const dependentVariable = getDependentVar(jobConfig.analysis); - const resultsField = jobConfig.dest.results_field; + const resultsField = jobConfig.dest.results_field!; const newRocCurveData: RocCurveDataRow[] = []; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx index 48477acfe7be8..17453dd87b0d0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx @@ -170,7 +170,7 @@ export const ExplorationPageWrapper: FC = ({ indexPattern={indexPattern} setSearchQuery={searchQueryUpdateHandler} query={query} - filters={getFilters(jobConfig.dest.results_field)} + filters={getFilters(jobConfig.dest.results_field!)} /> diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts index c0590fd80a5d5..593ef5465d196 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts @@ -57,7 +57,7 @@ export const useExplorationResults = ( const columns: EuiDataGridColumn[] = []; if (jobConfig !== undefined) { - const resultsField = jobConfig.dest.results_field; + const resultsField = jobConfig.dest.results_field!; const { fieldTypes } = getIndexFields(jobConfig, needsDestIndexFields); columns.push( ...getDataGridSchemasFromFieldTypes(fieldTypes, resultsField).sort((a: any, b: any) => diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts index 45653209cdb8a..920023c23a2bd 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts @@ -55,7 +55,7 @@ export const useOutlierData = ( const resultsField = jobConfig.dest.results_field; const { fieldTypes } = getIndexFields(jobConfig, needsDestIndexFields); newColumns.push( - ...getDataGridSchemasFromFieldTypes(fieldTypes, resultsField).sort((a: any, b: any) => + ...getDataGridSchemasFromFieldTypes(fieldTypes, resultsField!).sort((a: any, b: any) => sortExplorationResultsFields(a.id, b.id, jobConfig) ) ); @@ -135,7 +135,9 @@ export const useOutlierData = ( const colorRange = useColorRange( COLOR_RANGE.BLUE, COLOR_RANGE_SCALE.INFLUENCER, - jobConfig !== undefined ? getFeatureCount(jobConfig.dest.results_field, dataGrid.tableItems) : 1 + jobConfig !== undefined + ? getFeatureCount(jobConfig.dest.results_field!, dataGrid.tableItems) + : 1 ); const renderCellValue = useRenderCellValue( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx index 6d5417db24607..1249b736960d8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx @@ -75,7 +75,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) const dependentVariable = getDependentVar(jobConfig.analysis); const predictionFieldName = getPredictionFieldName(jobConfig.analysis); // default is 'ml' - const resultsField = jobConfig.dest.results_field; + const resultsField = jobConfig.dest.results_field ?? 'ml'; const loadGeneralizationData = async (ignoreDefaultQuery: boolean = true) => { setIsLoadingGeneralization(true); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_action_flyout.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_action_flyout.tsx index 766f1bda64d5e..3b8d3ed5460ff 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_action_flyout.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_action_flyout.tsx @@ -34,10 +34,8 @@ import { MemoryInputValidatorResult, } from '../../../../../../../common/util/validators'; import { DATA_FRAME_TASK_STATE } from '../analytics_list/common'; -import { - useRefreshAnalyticsList, - UpdateDataFrameAnalyticsConfig, -} from '../../../../common/analytics'; +import { useRefreshAnalyticsList } from '../../../../common/analytics'; +import { UpdateDataFrameAnalyticsConfig } from '../../../../../../../common/types/data_frame_analytics'; import { EditAction } from './use_edit_action'; @@ -51,7 +49,9 @@ export const EditActionFlyout: FC> = ({ closeFlyout, item } const [allowLazyStart, setAllowLazyStart] = useState(initialAllowLazyStart); const [description, setDescription] = useState(config.description || ''); - const [modelMemoryLimit, setModelMemoryLimit] = useState(config.model_memory_limit); + const [modelMemoryLimit, setModelMemoryLimit] = useState( + config.model_memory_limit + ); const [mmlValidationError, setMmlValidationError] = useState(); const [maxNumThreads, setMaxNumThreads] = useState(config.max_num_threads); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx index 3f7072fba4040..2d072d1aecc1f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx @@ -75,7 +75,7 @@ export const ExpandedRow: FC = ({ item }) => { const dependentVariable = getDependentVar(item.config.analysis); const predictionFieldName = getPredictionFieldName(item.config.analysis); // default is 'ml' - const resultsField = item.config.dest.results_field; + const resultsField = item.config.dest.results_field ?? 'ml'; const jobIsCompleted = isCompletedAnalyticsJob(item.stats); const isRegressionJob = isRegressionAnalysis(item.config.analysis); const analysisType = getAnalysisType(item.config.analysis); @@ -232,8 +232,8 @@ export const ExpandedRow: FC = ({ item }) => { moment(item.config.create_time).unix() * 1000 ), }, - { title: 'model_memory_limit', description: item.config.model_memory_limit }, - { title: 'version', description: item.config.version }, + { title: 'model_memory_limit', description: item.config.model_memory_limit ?? '' }, + { title: 'version', description: item.config.version ?? '' }, ], position: 'left', dataTestSubj: 'mlAnalyticsTableRowDetailsSection stats', diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx index 3077f0fb38726..efa1f58ecddc0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx @@ -43,6 +43,7 @@ enum TASK_STATE_COLOR { started = 'primary', starting = 'primary', stopped = 'hollow', + stopping = 'hollow', } export const getTaskStateBadge = ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts index 5559e7db2d631..58a471b4e7246 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts @@ -193,6 +193,7 @@ export const validateAdvancedEditor = (state: State): State => { dependentVariableEmpty = dependentVariableName === ''; if ( !dependentVariableEmpty && + Array.isArray(analyzedFields) && analyzedFields.length > 0 && !analyzedFields.includes(dependentVariableName) ) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts index c51ccf1e20d8d..c27137fca9519 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts @@ -82,6 +82,7 @@ describe('useCreateAnalyticsForm', () => { expect(jobConfig?.dest?.index).toBe('the-destination-index'); expect(jobConfig?.source?.index).toBe('the-source-index'); expect(jobConfig?.analyzed_fields?.includes).toStrictEqual([]); + // @ts-ignore property 'excludes' does not exist expect(typeof jobConfig?.analyzed_fields?.excludes).toBe('undefined'); // test the conversion of comma-separated Kibana index patterns to ES array based index patterns diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts index 0b2cb8fcfc716..ca54c552f8ebf 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts @@ -370,7 +370,9 @@ export function getFormStateFromJobConfig( runtimeMappings: analyticsJobConfig.source.runtime_mappings, modelMemoryLimit: analyticsJobConfig.model_memory_limit, maxNumThreads: analyticsJobConfig.max_num_threads, - includes: analyticsJobConfig.analyzed_fields?.includes ?? [], + includes: Array.isArray(analyticsJobConfig.analyzed_fields?.includes) + ? analyticsJobConfig.analyzed_fields?.includes + : [], jobConfigQuery: analyticsJobConfig.source.query || defaultSearchQuery, }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts index 41a8ae4eeba92..cddc4fcd092dc 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts @@ -333,8 +333,8 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { dispatch({ type: ACTION.SWITCH_TO_FORM }); }; - const setEstimatedModelMemoryLimit = (value: State['estimatedModelMemoryLimit']) => { - dispatch({ type: ACTION.SET_ESTIMATED_MODEL_MEMORY_LIMIT, value }); + const setEstimatedModelMemoryLimit = (value: State['estimatedModelMemoryLimit'] | undefined) => { + dispatch({ type: ACTION.SET_ESTIMATED_MODEL_MEMORY_LIMIT, value: value ?? '' }); }; const setJobClone = async (cloneJob: DeepReadonly) => { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.test.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.test.ts index 1c2598477064f..e0324a261e57d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.test.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.test.ts @@ -15,6 +15,7 @@ describe('get_analytics', () => { const mockResponse: GetDataFrameAnalyticsStatsResponseOk = { count: 2, data_frame_analytics: [ + // @ts-expect-error test response missing expected properties { id: 'outlier-cloudwatch', state: DATA_FRAME_TASK_STATE.STOPPED, @@ -37,6 +38,7 @@ describe('get_analytics', () => { }, ], }, + // @ts-expect-error test response missing expected properties { id: 'reg-gallery', state: DATA_FRAME_TASK_STATE.FAILED, diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts index e4deb90d81073..479f8c50ae035 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts @@ -10,12 +10,10 @@ import { http } from '../http_service'; import { basePath } from '.'; import type { DataFrameAnalyticsStats } from '../../data_frame_analytics/pages/analytics_management/components/analytics_list/common'; import type { ValidateAnalyticsJobResponse } from '../../../../common/constants/validation'; -import type { - DataFrameAnalyticsConfig, - UpdateDataFrameAnalyticsConfig, -} from '../../data_frame_analytics/common'; +import type { DataFrameAnalyticsConfig } from '../../data_frame_analytics/common'; import type { DeepPartial } from '../../../../common/types/common'; import type { NewJobCapsResponse } from '../../../../common/types/fields'; +import type { UpdateDataFrameAnalyticsConfig } from '../../../../common/types/data_frame_analytics'; import type { JobMessage } from '../../../../common/types/audit_message'; import type { DeleteDataFrameAnalyticsWithIndexStatus, diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts index 894354a0113fc..d4076a7cf496a 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts @@ -78,7 +78,6 @@ export class AnalyticsManager { async setJobStats() { try { const jobStats = await this.getAnalyticsStats(); - // @ts-expect-error @elastic-elasticsearch Data frame types incomplete this.jobStats = jobStats; } catch (error) { // eslint-disable-next-line diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/validation.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/validation.ts index 9cd8b67be2a6d..517f3cadf3b18 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/validation.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/validation.ts @@ -239,14 +239,16 @@ async function getValidationCheckMessages( let analysisFieldsEmpty = false; const fieldLimit = - analyzedFields.length <= MINIMUM_NUM_FIELD_FOR_CHECK + Array.isArray(analyzedFields) && analyzedFields.length <= MINIMUM_NUM_FIELD_FOR_CHECK ? analyzedFields.length : MINIMUM_NUM_FIELD_FOR_CHECK; - let aggs = analyzedFields.slice(0, fieldLimit).reduce((acc, curr) => { - acc[curr] = { missing: { field: curr } }; - return acc; - }, {} as any); + let aggs = Array.isArray(analyzedFields) + ? analyzedFields.slice(0, fieldLimit).reduce((acc, curr) => { + acc[curr] = { missing: { field: curr } }; + return acc; + }, {} as any) + : {}; if (depVar !== '') { const depVarAgg = { @@ -344,10 +346,18 @@ async function getValidationCheckMessages( ); messages.push(...regressionAndClassificationMessages); - if (analyzedFields.length && analyzedFields.length > INCLUDED_FIELDS_THRESHOLD) { + if ( + Array.isArray(analyzedFields) && + analyzedFields.length && + analyzedFields.length > INCLUDED_FIELDS_THRESHOLD + ) { analysisFieldsNumHigh = true; } else { - if (analysisType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && analyzedFields.length < 1) { + if ( + analysisType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && + Array.isArray(analyzedFields) && + analyzedFields.length < 1 + ) { lowFieldCountWarningMessage.text = i18n.translate( 'xpack.ml.models.dfaValidation.messages.lowFieldCountOutlierWarningText', { @@ -358,6 +368,7 @@ async function getValidationCheckMessages( messages.push(lowFieldCountWarningMessage); } else if ( analysisType !== ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && + Array.isArray(analyzedFields) && analyzedFields.length < 2 ) { lowFieldCountWarningMessage.text = i18n.translate( @@ -446,9 +457,12 @@ export async function validateAnalyticsJob( client: IScopedClusterClient, job: DataFrameAnalyticsConfig ) { + const includedFields = ( + Array.isArray(job?.analyzed_fields?.includes) ? job?.analyzed_fields?.includes : [] + ) as string[]; const messages = await getValidationCheckMessages( client.asCurrentUser, - job?.analyzed_fields?.includes || [], + includedFields, job.analysis, job.source ); diff --git a/x-pack/plugins/ml/server/saved_objects/checks.ts b/x-pack/plugins/ml/server/saved_objects/checks.ts index 93b68ea3fd990..a5cb560d324d2 100644 --- a/x-pack/plugins/ml/server/saved_objects/checks.ts +++ b/x-pack/plugins/ml/server/saved_objects/checks.ts @@ -131,7 +131,7 @@ export function checksFactory( ); const dfaJobsCreateTimeMap = dfaJobs.data_frame_analytics.reduce((acc, cur) => { - acc.set(cur.id, cur.create_time); + acc.set(cur.id, cur.create_time!); return acc; }, new Map()); diff --git a/x-pack/test/functional/apps/ml/stack_management_jobs/export_jobs.ts b/x-pack/test/functional/apps/ml/stack_management_jobs/export_jobs.ts index c43cf74e3048c..69ecc7f446b58 100644 --- a/x-pack/test/functional/apps/ml/stack_management_jobs/export_jobs.ts +++ b/x-pack/test/functional/apps/ml/stack_management_jobs/export_jobs.ts @@ -169,7 +169,6 @@ const testADJobs: Array<{ job: Job; datafeed: Datafeed }> = [ ]; const testDFAJobs: DataFrameAnalyticsConfig[] = [ - // @ts-expect-error not full interface { id: `bm_1_1`, description: @@ -198,7 +197,6 @@ const testDFAJobs: DataFrameAnalyticsConfig[] = [ model_memory_limit: '60mb', allow_lazy_start: false, }, - // @ts-expect-error not full interface { id: `ihp_1_2`, description: 'This is the job description', @@ -221,7 +219,6 @@ const testDFAJobs: DataFrameAnalyticsConfig[] = [ }, model_memory_limit: '5mb', }, - // @ts-expect-error not full interface { id: `egs_1_3`, description: 'This is the job description', From 92ac7f925545e088de8466e39d77a284ab9ffd67 Mon Sep 17 00:00:00 2001 From: Katrin Freihofner Date: Fri, 20 May 2022 13:51:51 +0200 Subject: [PATCH 08/39] adds small styling updates to header panels (#132596) --- .../public/pages/rule_details/index.tsx | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx index 99000a91671b8..5cc12452e57e1 100644 --- a/x-pack/plugins/observability/public/pages/rule_details/index.tsx +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -301,15 +301,15 @@ export function RuleDetailsPage() { : [], }} > - + {/* Left side of Rule Summary */} - + @@ -318,7 +318,7 @@ export function RuleDetailsPage() { - + {i18n.translate('xpack.observability.ruleDetails.lastRun', { @@ -330,11 +330,7 @@ export function RuleDetailsPage() { itemValue={moment(rule.executionStatus.lastExecutionDate).fromNow()} /> - - - - - + {i18n.translate('xpack.observability.ruleDetails.alerts', { @@ -376,8 +372,6 @@ export function RuleDetailsPage() { /> )} - - @@ -385,7 +379,7 @@ export function RuleDetailsPage() { {/* Right side of Rule Summary */} - + @@ -401,7 +395,7 @@ export function RuleDetailsPage() { )} - + @@ -416,9 +410,9 @@ export function RuleDetailsPage() { /> - + - + {i18n.translate('xpack.observability.ruleDetails.description', { defaultMessage: 'Description', @@ -429,7 +423,7 @@ export function RuleDetailsPage() { /> - + @@ -449,8 +443,6 @@ export function RuleDetailsPage() { - - @@ -463,7 +455,7 @@ export function RuleDetailsPage() { - + @@ -474,7 +466,7 @@ export function RuleDetailsPage() { - + {i18n.translate('xpack.observability.ruleDetails.actions', { From 1c2eb9f03da58875360ed1f65a6a0bad33e52c6e Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Fri, 20 May 2022 13:59:56 +0100 Subject: [PATCH 09/39] [Security Solution] New Side nav integrating links config (#132210) * Update navigation landing pages to use appLinks config * align app links changes * link configs refactor to use updater$ * navigation panel categories * test and type fixes * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * types changes * shared style change moved to a separate PR * use old deep links * minor changes after ux meeting * add links filtering * remove duplicated categories * temporary increase of plugin size limit * swap management links order * improve performance closing nav panel * test updated * host isolation page filterd and some improvements * remove async from plugin start * move links register from start to mount * restore size limits * Fix use_show_timeline unit tests Co-authored-by: Pablo Neves Machado Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../public/app/deep_links/index.ts | 39 +- .../app/home/template_wrapper/index.tsx | 25 +- .../public/app/translations.ts | 2 +- .../security_solution/public/cases/links.ts | 12 +- .../components/navigation/nav_links.test.ts | 51 +- .../common/components/navigation/nav_links.ts | 34 +- .../security_side_nav/icons/launch.tsx | 25 + .../navigation/security_side_nav/index.ts | 8 + .../security_side_nav.test.tsx | 256 +++++++++ .../security_side_nav/security_side_nav.tsx | 156 ++++++ .../solution_grouped_nav.test.tsx | 4 +- .../solution_grouped_nav.tsx | 253 +++++---- .../solution_grouped_nav_item.tsx | 188 ------- .../solution_grouped_nav_panel.test.tsx | 17 +- .../solution_grouped_nav_panel.tsx | 123 ++++- .../navigation/solution_grouped_nav/types.ts | 32 ++ .../common/components/navigation/types.ts | 18 +- .../__snapshots__/index.test.tsx.snap | 2 +- .../use_primary_navigation.tsx | 6 +- .../public/common/links/app_links.ts | 60 +- .../public/common/links/index.tsx | 1 + .../public/common/links/links.test.ts | 520 ++++++------------ .../public/common/links/links.ts | 313 ++++++----- .../public/common/links/types.ts | 108 ++-- .../utils/timeline/use_show_timeline.test.tsx | 22 + .../public/detections/links.ts | 7 +- .../security_solution/public/hosts/links.ts | 1 - .../components/landing_links_icons.test.tsx | 3 +- .../components/landing_links_icons.tsx | 2 +- .../components/landing_links_images.test.tsx | 3 +- .../components/landing_links_images.tsx | 2 +- .../public/landing_pages/constants.ts | 36 -- .../public/landing_pages/links.ts | 52 ++ .../landing_pages/pages/manage.test.tsx | 172 +++++- .../public/landing_pages/pages/manage.tsx | 81 +-- .../public/management/links.ts | 60 +- .../public/overview/links.ts | 28 +- .../security_solution/public/plugin.tsx | 100 +++- .../public/timelines/links.ts | 7 +- .../security_solution/server/ui_settings.ts | 2 +- 40 files changed, 1710 insertions(+), 1121 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/icons/launch.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/index.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_item.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/types.ts delete mode 100644 x-pack/plugins/security_solution/public/landing_pages/constants.ts create mode 100644 x-pack/plugins/security_solution/public/landing_pages/links.ts diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index 550ec608a76cb..6598e0dc29426 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -10,7 +10,8 @@ import { i18n } from '@kbn/i18n'; import { get } from 'lodash'; import { LicenseType } from '@kbn/licensing-plugin/common/types'; import { getCasesDeepLinks } from '@kbn/cases-plugin/public'; -import { AppDeepLink, AppNavLinkStatus, Capabilities } from '@kbn/core/public'; +import { AppDeepLink, AppNavLinkStatus, AppUpdater, Capabilities } from '@kbn/core/public'; +import { Subject } from 'rxjs'; import { SecurityPageName } from '../types'; import { OVERVIEW, @@ -63,6 +64,8 @@ import { RULES_CREATE_PATH, } from '../../../common/constants'; import { ExperimentalFeatures } from '../../../common/experimental_features'; +import { subscribeAppLinks } from '../../common/links'; +import { AppLinkItems } from '../../common/links/types'; const FEATURE = { general: `${SERVER_APP_ID}.show`, @@ -553,3 +556,37 @@ export function isPremiumLicense(licenseType?: LicenseType): boolean { licenseType === 'trial' ); } + +/** + * New deep links code starts here. + * All the code above will be removed once the appLinks migration is over. + * The code below manages the new implementation using the unified appLinks. + */ + +const formatDeepLinks = (appLinks: AppLinkItems): AppDeepLink[] => + appLinks.map((appLink) => ({ + id: appLink.id, + path: appLink.path, + title: appLink.title, + navLinkStatus: appLink.globalNavEnabled ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden, + searchable: !appLink.globalSearchDisabled, + ...(appLink.globalSearchKeywords != null ? { keywords: appLink.globalSearchKeywords } : {}), + ...(appLink.globalNavOrder != null ? { order: appLink.globalNavOrder } : {}), + ...(appLink.links && appLink.links?.length + ? { + deepLinks: formatDeepLinks(appLink.links), + } + : {}), + })); + +/** + * Registers any change in appLinks to be updated in app deepLinks + */ +export const registerDeepLinksUpdater = (appUpdater$: Subject) => { + subscribeAppLinks((appLinks) => { + appUpdater$.next(() => ({ + navLinkStatus: AppNavLinkStatus.hidden, // needed to prevent main security link to switch to visible after update + deepLinks: formatDeepLinks(appLinks), + })); + }); +}; diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx index 3b436d2bdefc1..8d7d9daad550d 100644 --- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx @@ -26,6 +26,7 @@ import { gutterTimeline } from '../../../common/lib/helpers'; import { useKibana } from '../../../common/lib/kibana'; import { useShowPagesWithEmptyView } from '../../../common/utils/empty_view/use_show_pages_with_empty_view'; import { useIsPolicySettingsBarVisible } from '../../../management/pages/policy/view/policy_hooks'; +import { useIsGroupedNavigationEnabled } from '../../../common/components/navigation/helpers'; const NO_DATA_PAGE_MAX_WIDTH = 950; @@ -44,8 +45,7 @@ const NO_DATA_PAGE_TEMPLATE_PROPS = { */ const StyledKibanaPageTemplate = styled(KibanaPageTemplate)<{ $isShowingTimelineOverlay?: boolean; - $isTimelineBottomBarVisible?: boolean; - $isPolicySettingsVisible?: boolean; + $addBottomPadding?: boolean; }>` .${BOTTOM_BAR_CLASSNAME} { animation: 'none !important'; // disable the default bottom bar slide animation @@ -63,19 +63,8 @@ const StyledKibanaPageTemplate = styled(KibanaPageTemplate)<{ } // If the bottom bar is visible add padding to the navigation - ${({ $isTimelineBottomBarVisible }) => - $isTimelineBottomBarVisible && - ` - @media (min-width: 768px) { - .kbnPageTemplateSolutionNav { - padding-bottom: ${gutterTimeline}; - } - } - `} - - // If the policy settings bottom bar is visible add padding to the navigation - ${({ $isPolicySettingsVisible }) => - $isPolicySettingsVisible && + ${({ $addBottomPadding }) => + $addBottomPadding && ` @media (min-width: 768px) { .kbnPageTemplateSolutionNav { @@ -98,6 +87,9 @@ export const SecuritySolutionTemplateWrapper: React.FC getTimelineShowStatus(state, TimelineId.active) ); + const isGroupedNavEnabled = useIsGroupedNavigationEnabled(); + const addBottomPadding = + isTimelineBottomBarVisible || isPolicySettingsVisible || isGroupedNavEnabled; const userHasSecuritySolutionVisible = useKibana().services.application.capabilities.siem.show; const showEmptyState = useShowPagesWithEmptyView(); @@ -117,9 +109,8 @@ export const SecuritySolutionTemplateWrapper: React.FC diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index 9857e7160a209..354ba438ff52a 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -23,7 +23,7 @@ export const HOSTS = i18n.translate('xpack.securitySolution.navigation.hosts', { }); export const GETTING_STARTED = i18n.translate('xpack.securitySolution.navigation.gettingStarted', { - defaultMessage: 'Getting started', + defaultMessage: 'Get started', }); export const THREAT_HUNTING = i18n.translate('xpack.securitySolution.navigation.threatHunting', { diff --git a/x-pack/plugins/security_solution/public/cases/links.ts b/x-pack/plugins/security_solution/public/cases/links.ts index 9ed7a1f3980a6..bafaee6baa583 100644 --- a/x-pack/plugins/security_solution/public/cases/links.ts +++ b/x-pack/plugins/security_solution/public/cases/links.ts @@ -6,8 +6,8 @@ */ import { getCasesDeepLinks } from '@kbn/cases-plugin/public'; -import { CASES_PATH, SecurityPageName } from '../../common/constants'; -import { FEATURE, LinkItem } from '../common/links/types'; +import { CASES_FEATURE_ID, CASES_PATH, SecurityPageName } from '../../common/constants'; +import { LinkItem } from '../common/links/types'; export const getCasesLinkItems = (): LinkItem => { const casesLinks = getCasesDeepLinks({ @@ -16,15 +16,17 @@ export const getCasesLinkItems = (): LinkItem => { [SecurityPageName.case]: { globalNavEnabled: true, globalNavOrder: 9006, - features: [FEATURE.casesRead], + capabilities: [`${CASES_FEATURE_ID}.read_cases`], }, [SecurityPageName.caseConfigure]: { - features: [FEATURE.casesCrud], + capabilities: [`${CASES_FEATURE_ID}.crud_cases`], licenseType: 'gold', + sideNavDisabled: true, hideTimeline: true, }, [SecurityPageName.caseCreate]: { - features: [FEATURE.casesCrud], + capabilities: [`${CASES_FEATURE_ID}.crud_cases`], + sideNavDisabled: true, hideTimeline: true, }, }, diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts index ff7aa7581fc4b..41b62e8589854 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts @@ -7,11 +7,12 @@ import { renderHook } from '@testing-library/react-hooks'; import { SecurityPageName } from '../../../app/types'; -import { NavLinkItem } from '../../links/types'; +import { AppLinkItems } from '../../links'; import { TestProviders } from '../../mock'; import { useAppNavLinks, useAppRootNavLink } from './nav_links'; +import { NavLinkItem } from './types'; -const mockNavLinks = [ +const mockNavLinks: AppLinkItems = [ { description: 'description', id: SecurityPageName.administration, @@ -22,6 +23,10 @@ const mockNavLinks = [ links: [], path: '/path_2', title: 'title 2', + sideNavDisabled: true, + landingIcon: 'someicon', + landingImage: 'someimage', + skipUrlState: true, }, ], path: '/path', @@ -30,7 +35,7 @@ const mockNavLinks = [ ]; jest.mock('../../links', () => ({ - getNavLinkItems: () => mockNavLinks, + useAppLinks: () => mockNavLinks, })); const renderUseAppNavLinks = () => @@ -44,11 +49,47 @@ const renderUseAppRootNavLink = (id: SecurityPageName) => describe('useAppNavLinks', () => { it('should return all nav links', () => { const { result } = renderUseAppNavLinks(); - expect(result.current).toEqual(mockNavLinks); + expect(result.current).toMatchInlineSnapshot(` + Array [ + Object { + "description": "description", + "id": "administration", + "links": Array [ + Object { + "description": "description 2", + "disabled": true, + "icon": "someicon", + "id": "endpoints", + "image": "someimage", + "skipUrlState": true, + "title": "title 2", + }, + ], + "title": "title", + }, + ] + `); }); it('should return a root nav links', () => { const { result } = renderUseAppRootNavLink(SecurityPageName.administration); - expect(result.current).toEqual(mockNavLinks[0]); + expect(result.current).toMatchInlineSnapshot(` + Object { + "description": "description", + "id": "administration", + "links": Array [ + Object { + "description": "description 2", + "disabled": true, + "icon": "someicon", + "id": "endpoints", + "image": "someimage", + "skipUrlState": true, + "title": "title 2", + }, + ], + "title": "title", + } + `); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts index efdf72a1f7926..db8b5788b04d6 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts @@ -5,21 +5,35 @@ * 2.0. */ -import { useKibana } from '../../lib/kibana'; -import { useEnableExperimental } from '../../hooks/use_experimental_features'; -import { useLicense } from '../../hooks/use_license'; -import { getNavLinkItems } from '../../links'; +import { useMemo } from 'react'; +import { useAppLinks } from '../../links'; import type { SecurityPageName } from '../../../app/types'; -import type { NavLinkItem } from '../../links/types'; +import { NavLinkItem } from './types'; +import { AppLinkItems } from '../../links/types'; export const useAppNavLinks = (): NavLinkItem[] => { - const license = useLicense(); - const enableExperimental = useEnableExperimental(); - const capabilities = useKibana().services.application.capabilities; - - return getNavLinkItems({ enableExperimental, license, capabilities }); + const appLinks = useAppLinks(); + const navLinks = useMemo(() => formatNavLinkItems(appLinks), [appLinks]); + return navLinks; }; export const useAppRootNavLink = (linkId: SecurityPageName): NavLinkItem | undefined => { return useAppNavLinks().find(({ id }) => id === linkId); }; + +const formatNavLinkItems = (appLinks: AppLinkItems): NavLinkItem[] => + appLinks.map((link) => ({ + id: link.id, + title: link.title, + ...(link.categories != null ? { categories: link.categories } : {}), + ...(link.description != null ? { description: link.description } : {}), + ...(link.sideNavDisabled === true ? { disabled: true } : {}), + ...(link.landingIcon != null ? { icon: link.landingIcon } : {}), + ...(link.landingImage != null ? { image: link.landingImage } : {}), + ...(link.skipUrlState != null ? { skipUrlState: link.skipUrlState } : {}), + ...(link.links && link.links.length + ? { + links: formatNavLinkItems(link.links), + } + : {}), + })); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/icons/launch.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/icons/launch.tsx new file mode 100644 index 0000000000000..de96338ef98e6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/icons/launch.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { SVGProps } from 'react'; + +export const EuiIconLaunch: React.FC> = ({ ...props }) => ( + + + + + + + +); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/index.ts new file mode 100644 index 0000000000000..a2c866e604e16 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { SecuritySideNav } from './security_side_nav'; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx new file mode 100644 index 0000000000000..c0ebd0722f725 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx @@ -0,0 +1,256 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { SecurityPageName } from '../../../../app/types'; +import { TestProviders } from '../../../mock'; +import { SecuritySideNav } from './security_side_nav'; +import { SolutionGroupedNavProps } from '../solution_grouped_nav/solution_grouped_nav'; +import { NavLinkItem } from '../types'; + +const manageNavLink: NavLinkItem = { + id: SecurityPageName.administration, + title: 'manage', + description: 'manage description', + categories: [{ label: 'test category', linkIds: [SecurityPageName.endpoints] }], + links: [ + { + id: SecurityPageName.endpoints, + title: 'title 2', + description: 'description 2', + }, + ], +}; +const alertsNavLink: NavLinkItem = { + id: SecurityPageName.alerts, + title: 'alerts', + description: 'alerts description', +}; + +const mockSolutionGroupedNav = jest.fn((_: SolutionGroupedNavProps) => <>); +jest.mock('../solution_grouped_nav', () => ({ + SolutionGroupedNav: (props: SolutionGroupedNavProps) => mockSolutionGroupedNav(props), +})); +const mockUseRouteSpy = jest.fn(() => [{ pageName: SecurityPageName.alerts }]); +jest.mock('../../../utils/route/use_route_spy', () => ({ + useRouteSpy: () => mockUseRouteSpy(), +})); + +const mockedUseCanSeeHostIsolationExceptionsMenu = jest.fn(); +jest.mock('../../../../management/pages/host_isolation_exceptions/view/hooks', () => ({ + useCanSeeHostIsolationExceptionsMenu: () => mockedUseCanSeeHostIsolationExceptionsMenu(), +})); +jest.mock('../../../links', () => ({ + getAncestorLinksInfo: (id: string) => [{ id }], +})); + +const mockUseAppNavLinks = jest.fn((): NavLinkItem[] => [alertsNavLink, manageNavLink]); +jest.mock('../nav_links', () => ({ + useAppNavLinks: () => mockUseAppNavLinks(), +})); +jest.mock('../../links', () => ({ + useGetSecuritySolutionLinkProps: + () => + ({ deepLinkId }: { deepLinkId: SecurityPageName }) => ({ + href: `/${deepLinkId}`, + }), +})); + +const renderNav = () => + render(, { + wrapper: TestProviders, + }); + +describe('SecuritySideNav', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render main items', () => { + mockUseAppNavLinks.mockReturnValueOnce([alertsNavLink]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith({ + selectedId: SecurityPageName.alerts, + items: [ + { + id: SecurityPageName.alerts, + label: 'alerts', + href: '/alerts', + }, + ], + footerItems: [], + }); + }); + + it('should render the loader if items are still empty', () => { + mockUseAppNavLinks.mockReturnValueOnce([]); + const result = renderNav(); + expect(result.getByTestId('sideNavLoader')).toBeInTheDocument(); + expect(mockSolutionGroupedNav).not.toHaveBeenCalled(); + }); + + it('should render with selected id', () => { + mockUseRouteSpy.mockReturnValueOnce([{ pageName: SecurityPageName.administration }]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + selectedId: SecurityPageName.administration, + }) + ); + }); + + it('should render footer items', () => { + mockUseAppNavLinks.mockReturnValueOnce([manageNavLink]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.administration, + label: 'manage', + href: '/administration', + categories: manageNavLink.categories, + items: [ + { + id: SecurityPageName.endpoints, + label: 'title 2', + description: 'description 2', + href: '/endpoints', + }, + ], + }, + ], + }) + ); + }); + + it('should not render disabled items', () => { + mockUseAppNavLinks.mockReturnValueOnce([ + { ...alertsNavLink, disabled: true }, + { + ...manageNavLink, + links: [ + { + id: SecurityPageName.endpoints, + title: 'title 2', + description: 'description 2', + disabled: true, + }, + ], + }, + ]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.administration, + label: 'manage', + href: '/administration', + categories: manageNavLink.categories, + items: [], + }, + ], + }) + ); + }); + + it('should render hostIsolationExceptionsLink when useCanSeeHostIsolationExceptionsMenu is true', () => { + mockedUseCanSeeHostIsolationExceptionsMenu.mockReturnValue(true); + const hostIsolationExceptionsLink = { + id: SecurityPageName.hostIsolationExceptions, + title: 'test hostIsolationExceptions', + description: 'test description hostIsolationExceptions', + }; + + mockUseAppNavLinks.mockReturnValueOnce([ + { + ...manageNavLink, + links: [hostIsolationExceptionsLink], + }, + ]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.administration, + label: 'manage', + href: '/administration', + categories: manageNavLink.categories, + items: [ + { + id: hostIsolationExceptionsLink.id, + label: hostIsolationExceptionsLink.title, + description: hostIsolationExceptionsLink.description, + href: '/host_isolation_exceptions', + }, + ], + }, + ], + }) + ); + }); + + it('should not render hostIsolationExceptionsLink when useCanSeeHostIsolationExceptionsMenu is false', () => { + mockedUseCanSeeHostIsolationExceptionsMenu.mockReturnValue(false); + const hostIsolationExceptionsLink = { + id: SecurityPageName.hostIsolationExceptions, + title: 'test hostIsolationExceptions', + description: 'test description hostIsolationExceptions', + }; + + mockUseAppNavLinks.mockReturnValueOnce([ + { + ...manageNavLink, + links: [hostIsolationExceptionsLink], + }, + ]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.administration, + label: 'manage', + href: '/administration', + categories: manageNavLink.categories, + items: [], + }, + ], + }) + ); + }); + + it('should render custom item', () => { + mockUseAppNavLinks.mockReturnValueOnce([ + { id: SecurityPageName.landing, title: 'get started' }, + ]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.landing, + render: expect.any(Function), + }, + ], + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx new file mode 100644 index 0000000000000..b9173270e381e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useCallback } from 'react'; +import { EuiHorizontalRule, EuiListGroupItem, EuiLoadingSpinner } from '@elastic/eui'; +import { SecurityPageName } from '../../../../app/types'; +import { getAncestorLinksInfo } from '../../../links'; +import { useRouteSpy } from '../../../utils/route/use_route_spy'; +import { SecuritySolutionLinkAnchor, useGetSecuritySolutionLinkProps } from '../../links'; +import { useAppNavLinks } from '../nav_links'; +import { SolutionGroupedNav } from '../solution_grouped_nav'; +import { CustomSideNavItem, DefaultSideNavItem, SideNavItem } from '../solution_grouped_nav/types'; +import { NavLinkItem } from '../types'; +import { EuiIconLaunch } from './icons/launch'; +import { useCanSeeHostIsolationExceptionsMenu } from '../../../../management/pages/host_isolation_exceptions/view/hooks'; + +const isFooterNavItem = (id: SecurityPageName) => + id === SecurityPageName.landing || id === SecurityPageName.administration; + +type FormatSideNavItems = (navItems: NavLinkItem) => SideNavItem; + +/** + * Renders the navigation item for "Get Started" custom link + */ +const GetStartedCustomLinkComponent: React.FC<{ + isSelected: boolean; + title: string; +}> = ({ isSelected, title }) => ( + + + + +); +const GetStartedCustomLink = React.memo(GetStartedCustomLinkComponent); + +/** + * Returns a function to format generic `NavLinkItem` array to the `SideNavItem` type + */ +const useFormatSideNavItem = (): FormatSideNavItems => { + const hideHostIsolationExceptions = !useCanSeeHostIsolationExceptionsMenu(); + const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps(); // adds href and onClick props + + const formatSideNavItem: FormatSideNavItems = useCallback( + (navLinkItem) => { + const formatDefaultItem = (navItem: NavLinkItem): DefaultSideNavItem => ({ + id: navItem.id, + label: navItem.title, + ...getSecuritySolutionLinkProps({ deepLinkId: navItem.id }), + ...(navItem.categories && navItem.categories.length > 0 + ? { categories: navItem.categories } + : {}), + ...(navItem.links && navItem.links.length > 0 + ? { + items: navItem.links + .filter( + (link) => + !link.disabled && + !( + link.id === SecurityPageName.hostIsolationExceptions && + hideHostIsolationExceptions + ) + ) + .map((panelNavItem) => ({ + id: panelNavItem.id, + label: panelNavItem.title, + description: panelNavItem.description, + ...getSecuritySolutionLinkProps({ deepLinkId: panelNavItem.id }), + })), + } + : {}), + }); + + const formatGetStartedItem = (navItem: NavLinkItem): CustomSideNavItem => ({ + id: navItem.id, + render: (isSelected) => ( + + ), + }); + + if (navLinkItem.id === SecurityPageName.landing) { + return formatGetStartedItem(navLinkItem); + } + return formatDefaultItem(navLinkItem); + }, + [getSecuritySolutionLinkProps, hideHostIsolationExceptions] + ); + + return formatSideNavItem; +}; + +/** + * Returns the formatted `items` and `footerItems` to be rendered in the navigation + */ +const useSideNavItems = () => { + const appNavLinks = useAppNavLinks(); + const formatSideNavItem = useFormatSideNavItem(); + + const sideNavItems = useMemo(() => { + const mainNavItems: SideNavItem[] = []; + const footerNavItems: SideNavItem[] = []; + appNavLinks.forEach((appNavLink) => { + if (appNavLink.disabled) { + return; + } + + if (isFooterNavItem(appNavLink.id)) { + footerNavItems.push(formatSideNavItem(appNavLink)); + } else { + mainNavItems.push(formatSideNavItem(appNavLink)); + } + }); + return [mainNavItems, footerNavItems]; + }, [appNavLinks, formatSideNavItem]); + + return sideNavItems; +}; + +const useSelectedId = (): SecurityPageName => { + const [{ pageName }] = useRouteSpy(); + const selectedId = useMemo(() => { + const [rootLinkInfo] = getAncestorLinksInfo(pageName as SecurityPageName); + return rootLinkInfo?.id ?? ''; + }, [pageName]); + + return selectedId; +}; + +/** + * Main security navigation component. + * It takes the links to render from the generic application `links` configs. + */ +export const SecuritySideNav: React.FC = () => { + const [items, footerItems] = useSideNavItems(); + const selectedId = useSelectedId(); + + if (items.length === 0 && footerItems.length === 0) { + return ; + } + + return ; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx index f141264bd97e4..e41b566bbc7c8 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx @@ -9,15 +9,15 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { SecurityPageName } from '../../../../app/types'; import { TestProviders } from '../../../mock'; -import { NavItem } from './solution_grouped_nav_item'; import { SolutionGroupedNav, SolutionGroupedNavProps } from './solution_grouped_nav'; +import { SideNavItem } from './types'; const mockUseShowTimeline = jest.fn((): [boolean] => [false]); jest.mock('../../../utils/timeline/use_show_timeline', () => ({ useShowTimeline: () => mockUseShowTimeline(), })); -const mockItems: NavItem[] = [ +const mockItems: SideNavItem[] = [ { id: SecurityPageName.dashboardsLanding, label: 'Dashboards', diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx index fcfcc9d6b1b4b..073723b80f518 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx @@ -15,22 +15,38 @@ import { } from '@elastic/eui'; import classNames from 'classnames'; -import { SolutionGroupedNavPanel } from './solution_grouped_nav_panel'; +import { SolutionNavPanel } from './solution_grouped_nav_panel'; import { EuiListGroupItemStyled } from './solution_grouped_nav.styles'; -import { - isCustomNavItem, - isDefaultNavItem, - NavItem, - PortalNavItem, -} from './solution_grouped_nav_item'; +import { DefaultSideNavItem, SideNavItem, isCustomItem, isDefaultItem } from './types'; import { EuiIconSpaces } from './icons/spaces'; +import type { LinkCategories } from '../../../links'; export interface SolutionGroupedNavProps { - items: NavItem[]; + items: SideNavItem[]; + selectedId: string; + footerItems?: SideNavItem[]; +} +export interface SolutionNavItemsProps { + items: SideNavItem[]; selectedId: string; - footerItems?: NavItem[]; + activePanelNavId: ActivePanelNav; + isMobileSize: boolean; + navItemsById: NavItemsById; + onOpenPanelNav: (id: string) => void; } -type ActivePortalNav = string | null; +export interface SolutionNavItemProps { + item: SideNavItem; + isSelected: boolean; + isActive: boolean; + hasPanelNav: boolean; + onOpenPanelNav: (id: string) => void; +} + +type ActivePanelNav = string | null; +type NavItemsById = Record< + string, + { title: string; panelItems: DefaultSideNavItem[]; categories?: LinkCategories } +>; export const SolutionGroupedNavComponent: React.FC = ({ items, @@ -39,41 +55,40 @@ export const SolutionGroupedNavComponent: React.FC = ({ }) => { const isMobileSize = useIsWithinBreakpoints(['xs', 's']); - const [activePortalNavId, setActivePortalNavId] = useState(null); - const activePortalNavIdRef = useRef(null); + const [activePanelNavId, setActivePanelNavId] = useState(null); + const activePanelNavIdRef = useRef(null); - const openPortalNav = (navId: string) => { - activePortalNavIdRef.current = navId; - setActivePortalNavId(navId); + const openPanelNav = (id: string) => { + activePanelNavIdRef.current = id; + setActivePanelNavId(id); }; - const closePortalNav = () => { - activePortalNavIdRef.current = null; - setActivePortalNavId(null); - }; + const onClosePanelNav = useCallback(() => { + activePanelNavIdRef.current = null; + setActivePanelNavId(null); + }, []); - const onClosePortalNav = useCallback(() => { - const currentPortalNavId = activePortalNavIdRef.current; + const onOutsidePanelClick = useCallback(() => { + const currentPanelNavId = activePanelNavIdRef.current; setTimeout(() => { // This event is triggered on outside click. // Closing the side nav at the end of event loop to make sure it - // closes also if the active "nav group" button has been clicked (toggle), - // but it does not close if any some other "nav group" open button has been clicked. - if (activePortalNavIdRef.current === currentPortalNavId) { - closePortalNav(); + // closes also if the active panel button has been clicked (toggle), + // but it does not close if any any other panel open button has been clicked. + if (activePanelNavIdRef.current === currentPanelNavId) { + onClosePanelNav(); } }); - }, []); + }, [onClosePanelNav]); - const navItemsById = useMemo( + const navItemsById = useMemo( () => - [...items, ...footerItems].reduce< - Record - >((acc, navItem) => { - if (isDefaultNavItem(navItem) && navItem.items && navItem.items.length > 0) { + [...items, ...footerItems].reduce((acc, navItem) => { + if (isDefaultItem(navItem) && navItem.items && navItem.items.length > 0) { acc[navItem.id] = { title: navItem.label, - subItems: navItem.items, + panelItems: navItem.items, + categories: navItem.categories, }; } return acc; @@ -82,67 +97,20 @@ export const SolutionGroupedNavComponent: React.FC = ({ ); const portalNav = useMemo(() => { - if (activePortalNavId == null || !navItemsById[activePortalNavId]) { + if (activePanelNavId == null || !navItemsById[activePanelNavId]) { return null; } - const { subItems, title } = navItemsById[activePortalNavId]; - return ; - }, [activePortalNavId, navItemsById, onClosePortalNav]); - - const renderNavItem = useCallback( - (navItem: NavItem) => { - if (isCustomNavItem(navItem)) { - return {navItem.render()}; - } - const { id, href, label, onClick } = navItem; - const isActive = activePortalNavId === id; - const isCurrentNav = selectedId === id; - - const itemClassNames = classNames('solutionGroupedNavItem', { - 'solutionGroupedNavItem--isActive': isActive, - 'solutionGroupedNavItem--isPrimary': isCurrentNav, - }); - const buttonClassNames = classNames('solutionGroupedNavItemButton'); - - return ( - // eslint-disable-next-line @elastic/eui/href-or-on-click - - { - ev.preventDefault(); - ev.stopPropagation(); - openPortalNav(id); - }, - iconType: EuiIconSpaces, - iconSize: 'm', - 'aria-label': 'Toggle group nav', - 'data-test-subj': `groupedNavItemButton-${id}`, - alwaysShow: true, - }, - } - : {})} - /> - - ); - }, - [activePortalNavId, isMobileSize, navItemsById, selectedId] - ); + const { panelItems, title, categories } = navItemsById[activePanelNavId]; + return ( + + ); + }, [activePanelNavId, navItemsById, onClosePanelNav, onOutsidePanelClick]); return ( <> @@ -150,10 +118,28 @@ export const SolutionGroupedNavComponent: React.FC = ({ - {items.map(renderNavItem)} + + + - {footerItems.map(renderNavItem)} + + + @@ -163,5 +149,84 @@ export const SolutionGroupedNavComponent: React.FC = ({ ); }; - export const SolutionGroupedNav = React.memo(SolutionGroupedNavComponent); + +const SolutionNavItems: React.FC = ({ + items, + selectedId, + activePanelNavId, + isMobileSize, + navItemsById, + onOpenPanelNav, +}) => ( + <> + {items.map((item) => ( + + ))} + +); + +const SolutionNavItemComponent: React.FC = ({ + item, + isSelected, + isActive, + hasPanelNav, + onOpenPanelNav, +}) => { + if (isCustomItem(item)) { + return {item.render(isSelected)}; + } + const { id, href, label, onClick } = item; + + const itemClassNames = classNames('solutionGroupedNavItem', { + 'solutionGroupedNavItem--isActive': isActive, + 'solutionGroupedNavItem--isPrimary': isSelected, + }); + const buttonClassNames = classNames('solutionGroupedNavItemButton'); + + const onButtonClick: React.MouseEventHandler = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + onOpenPanelNav(id); + }; + + return ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + + + + ); +}; +const SolutionNavItem = React.memo(SolutionNavItemComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_item.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_item.tsx deleted file mode 100644 index df7e08ad46f95..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_item.tsx +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { useGetSecuritySolutionLinkProps } from '../../links'; -import { SecurityPageName } from '../../../../../common/constants'; - -export type NavItemCategories = Array<{ label: string; itemIds: string[] }>; -export interface DefaultNavItem { - id: string; - label: string; - href: string; - onClick?: React.MouseEventHandler; - items?: PortalNavItem[]; - categories?: NavItemCategories; -} - -export interface CustomNavItem { - id: string; - render: () => React.ReactNode; -} - -export type NavItem = DefaultNavItem | CustomNavItem; - -export interface PortalNavItem { - id: string; - label: string; - href: string; - onClick?: React.MouseEventHandler; - description?: string; -} - -export const isCustomNavItem = (navItem: NavItem): navItem is CustomNavItem => 'render' in navItem; -export const isDefaultNavItem = (navItem: NavItem): navItem is DefaultNavItem => - !isCustomNavItem(navItem); - -export const useNavItems: () => NavItem[] = () => { - const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps(); - return [ - { - id: SecurityPageName.dashboardsLanding, - label: 'Dashboards', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.dashboardsLanding }), - items: [ - { - id: 'overview', - label: 'Overview', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.overview }), - }, - { - id: 'detection_response', - label: 'Detection & Response', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.detectionAndResponse }), - }, - // TODO: add the cloudPostureFindings to the config here - // { - // id: SecurityPageName.cloudPostureFindings, - // label: 'Cloud Posture Findings', - // description: 'The description goes here', - // ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.cloudPostureFindings }), - // }, - ], - }, - { - id: SecurityPageName.alerts, - label: 'Alerts', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.alerts }), - }, - { - id: SecurityPageName.timelines, - label: 'Timelines', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.timelines }), - }, - { - id: SecurityPageName.case, - label: 'Cases', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.case }), - }, - { - id: SecurityPageName.threatHuntingLanding, - label: 'Threat Hunting', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.threatHuntingLanding }), - items: [ - { - id: SecurityPageName.hosts, - label: 'Hosts', - description: - 'Computer or other device that communicates with other hosts on a network. Hosts on a network include clients and servers -- that send or receive data, services or applications.', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.hosts }), - }, - { - id: SecurityPageName.network, - label: 'Network', - description: - 'The action or process of interacting with others to exchange information and develop professional or social contacts.', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.network }), - }, - { - id: SecurityPageName.users, - label: 'Users', - description: 'Sudo commands dashboard from the Logs System integration.', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.users }), - }, - ], - }, - // TODO: implement footer and move management - { - id: SecurityPageName.administration, - label: 'Manage', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.administration }), - categories: [ - { label: 'SIEM', itemIds: [SecurityPageName.rules, SecurityPageName.exceptions] }, - { - label: 'ENDPOINTS', - itemIds: [ - SecurityPageName.endpoints, - SecurityPageName.policies, - SecurityPageName.trustedApps, - SecurityPageName.eventFilters, - SecurityPageName.blocklist, - SecurityPageName.hostIsolationExceptions, - ], - }, - ], - items: [ - { - id: SecurityPageName.rules, - label: 'Rules', - description: 'The description here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.rules }), - }, - { - id: SecurityPageName.exceptions, - label: 'Exceptions', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.exceptions }), - }, - { - id: SecurityPageName.endpoints, - label: 'Endpoints', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.endpoints }), - }, - { - id: SecurityPageName.policies, - label: 'Policies', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.policies }), - }, - { - id: SecurityPageName.trustedApps, - label: 'Trusted applications', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.trustedApps }), - }, - { - id: SecurityPageName.eventFilters, - label: 'Event filters', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.eventFilters }), - }, - { - id: SecurityPageName.blocklist, - label: 'Blocklist', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.blocklist }), - }, - { - id: SecurityPageName.hostIsolationExceptions, - label: 'Host Isolation IP exceptions', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.hostIsolationExceptions }), - }, - ], - }, - ]; -}; - -export const useFooterNavItems: () => NavItem[] = () => { - // TODO: implement footer items - return []; -}; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx index 93d46c35d6bed..8215d9c0b9f40 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx @@ -9,18 +9,15 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { SecurityPageName } from '../../../../app/types'; import { TestProviders } from '../../../mock'; -import { PortalNavItem } from './solution_grouped_nav_item'; -import { - SolutionGroupedNavPanel, - SolutionGroupedNavPanelProps, -} from './solution_grouped_nav_panel'; +import { SolutionNavPanel, SolutionNavPanelProps } from './solution_grouped_nav_panel'; +import { DefaultSideNavItem } from './types'; const mockUseShowTimeline = jest.fn((): [boolean] => [false]); jest.mock('../../../utils/timeline/use_show_timeline', () => ({ useShowTimeline: () => mockUseShowTimeline(), })); -const mockItems: PortalNavItem[] = [ +const mockItems: DefaultSideNavItem[] = [ { id: SecurityPageName.hosts, label: 'Hosts', @@ -37,14 +34,16 @@ const mockItems: PortalNavItem[] = [ const PANEL_TITLE = 'test title'; const mockOnClose = jest.fn(); -const renderNavPanel = (props: Partial = {}) => +const mockOnOutsideClick = jest.fn(); +const renderNavPanel = (props: Partial = {}) => render( <>
- , @@ -112,7 +111,7 @@ describe('SolutionGroupedNav', () => { const result = renderNavPanel(); result.getByTestId('outsideClickDummy').click(); waitFor(() => { - expect(mockOnClose).toHaveBeenCalled(); + expect(mockOnOutsideClick).toHaveBeenCalled(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx index c1615a97264eb..a418f666d2782 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx @@ -13,8 +13,10 @@ import { EuiFlexGroup, EuiFlexItem, EuiFocusTrap, + EuiHorizontalRule, EuiOutsideClickDetector, EuiPortal, + EuiSpacer, EuiTitle, EuiWindowEvent, keys, @@ -22,18 +24,39 @@ import { } from '@elastic/eui'; import classNames from 'classnames'; import { EuiPanelStyled } from './solution_grouped_nav_panel.styles'; -import { PortalNavItem } from './solution_grouped_nav_item'; import { useShowTimeline } from '../../../utils/timeline/use_show_timeline'; +import type { DefaultSideNavItem } from './types'; +import type { LinkCategories } from '../../../links/types'; -export interface SolutionGroupedNavPanelProps { +export interface SolutionNavPanelProps { onClose: () => void; + onOutsideClick: () => void; title: string; - items: PortalNavItem[]; + items: DefaultSideNavItem[]; + categories?: LinkCategories; +} +export interface SolutionNavPanelCategoriesProps { + categories: LinkCategories; + items: DefaultSideNavItem[]; + onClose: () => void; +} +export interface SolutionNavPanelItemsProps { + items: DefaultSideNavItem[]; + onClose: () => void; +} +export interface SolutionNavPanelItemProps { + item: DefaultSideNavItem; + onClose: () => void; } -const SolutionGroupedNavPanelComponent: React.FC = ({ +/** + * Renders the side navigation panel for secondary links + */ +const SolutionNavPanelComponent: React.FC = ({ onClose, + onOutsideClick, title, + categories, items, }) => { const [hasTimelineBar] = useShowTimeline(); @@ -41,9 +64,7 @@ const SolutionGroupedNavPanelComponent: React.FC = const isTimelineVisible = hasTimelineBar && isLargerBreakpoint; const panelClasses = classNames('eui-yScroll'); - /** - * ESC key closes SideNav - */ + // ESC key closes PanelNav const onKeyDown = useCallback( (ev: KeyboardEvent) => { if (ev.key === keys.ESCAPE) { @@ -58,7 +79,7 @@ const SolutionGroupedNavPanelComponent: React.FC = - onClose()}> + = - {items.map(({ id, href, onClick, label, description }: PortalNavItem) => ( - - - { - onClose(); - if (onClick) { - onClick(ev); - } - }} - > - {label} - - - {description} - - ))} + {categories ? ( + + ) : ( + + )} @@ -105,5 +116,61 @@ const SolutionGroupedNavPanelComponent: React.FC = ); }; +export const SolutionNavPanel = React.memo(SolutionNavPanelComponent); + +const SolutionNavPanelCategories: React.FC = ({ + categories, + items, + onClose, +}) => { + const itemsMap = new Map(items.map((item) => [item.id, item])); + + return ( + <> + {categories.map(({ label, linkIds }) => { + const links = linkIds.reduce((acc, linkId) => { + const link = itemsMap.get(linkId); + if (link) { + acc.push(link); + } + return acc; + }, []); + + return ( + + +

{label}

+
+ + + +
+ ); + })} + + ); +}; -export const SolutionGroupedNavPanel = React.memo(SolutionGroupedNavPanelComponent); +const SolutionNavPanelItems: React.FC = ({ items, onClose }) => ( + <> + {items.map(({ id, href, onClick, label, description }) => ( + + + { + onClose(); + if (onClick) { + onClick(ev); + } + }} + > + {label} + + + {description} + + ))} + +); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/types.ts new file mode 100644 index 0000000000000..a16bad9126d09 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/types.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { SecurityPageName } from '../../../../app/types'; +import type { LinkCategories } from '../../../links/types'; + +export interface DefaultSideNavItem { + id: SecurityPageName; + label: string; + href: string; + onClick?: React.MouseEventHandler; + description?: string; + items?: DefaultSideNavItem[]; + categories?: LinkCategories; +} + +export interface CustomSideNavItem { + id: string; + render: (isSelected: boolean) => React.ReactNode; +} + +export type SideNavItem = DefaultSideNavItem | CustomSideNavItem; + +export const isCustomItem = (navItem: SideNavItem): navItem is CustomSideNavItem => + 'render' in navItem; +export const isDefaultItem = (navItem: SideNavItem): navItem is DefaultSideNavItem => + !isCustomItem(navItem); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 91edd1feea2da..85d504165484b 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -5,10 +5,12 @@ * 2.0. */ +import { IconType } from '@elastic/eui'; import { UrlStateType } from '../url_state/constants'; import { SecurityPageName } from '../../../app/types'; import { UrlState } from '../url_state/types'; import { SiemRouteType } from '../../utils/route/types'; +import { LinkCategories } from '../../links'; export interface TabNavigationComponentProps { pageName: string; @@ -76,10 +78,14 @@ export type GetUrlForApp = ( ) => string; export type NavigateToUrl = (url: string) => void; - -export interface NavigationCategory { - label: string; - linkIds: readonly SecurityPageName[]; +export interface NavLinkItem { + categories?: LinkCategories; + description?: string; + disabled?: boolean; + icon?: IconType; + id: SecurityPageName; + links?: NavLinkItem[]; + image?: string; + title: string; + skipUrlState?: boolean; } - -export type NavigationCategories = Readonly; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap index cadb9057ccbcc..d50b07ca56089 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap @@ -14,7 +14,7 @@ Object { "href": "securitySolutionUI/get_started?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", "id": "get_started", "isSelected": false, - "name": "Getting started", + "name": "Get started", "onClick": [Function], }, Object { diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx index 1dbcf929ed81f..1123fd50a53e6 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx @@ -11,9 +11,8 @@ import { i18n } from '@kbn/i18n'; import { KibanaPageTemplateProps } from '@kbn/shared-ux-components'; import { PrimaryNavigationProps } from './types'; import { usePrimaryNavigationItems } from './use_navigation_items'; -import { SolutionGroupedNav } from '../solution_grouped_nav'; -import { useNavItems } from '../solution_grouped_nav/solution_grouped_nav_item'; import { useIsGroupedNavigationEnabled } from '../helpers'; +import { SecuritySideNav } from '../security_side_nav'; const translatedNavTitle = i18n.translate('xpack.securitySolution.navigation.mainLabel', { defaultMessage: 'Security', @@ -48,7 +47,6 @@ export const usePrimaryNavigation = ({ // we do need navTabs in case the selectedTabId appears after initial load (ex. checking permissions for anomalies) }, [pageName, navTabs, mapLocationToTab, selectedTabId]); - const navLinkItems = useNavItems(); const navItems = usePrimaryNavigationItems({ navTabs, selectedTabId, @@ -65,7 +63,7 @@ export const usePrimaryNavigation = ({ icon: 'logoSecurity', ...(isGroupedNavigationEnabled ? { - children: , + children: , closeFlyoutButtonPosition: 'inside', } : { items: navItems }), diff --git a/x-pack/plugins/security_solution/public/common/links/app_links.ts b/x-pack/plugins/security_solution/public/common/links/app_links.ts index 1a78444012334..45a7ed373222f 100644 --- a/x-pack/plugins/security_solution/public/common/links/app_links.ts +++ b/x-pack/plugins/security_solution/public/common/links/app_links.ts @@ -4,48 +4,30 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { i18n } from '@kbn/i18n'; -import { SecurityPageName, THREAT_HUNTING_PATH } from '../../../common/constants'; -import { THREAT_HUNTING } from '../../app/translations'; -import { FEATURE, LinkItem, UserPermissions } from './types'; -import { links as hostsLinks } from '../../hosts/links'; +import { CoreStart } from '@kbn/core/public'; +import { AppLinkItems } from './types'; import { links as detectionLinks } from '../../detections/links'; -import { links as networkLinks } from '../../network/links'; -import { links as usersLinks } from '../../users/links'; import { links as timelinesLinks } from '../../timelines/links'; import { getCasesLinkItems } from '../../cases/links'; -import { links as managementLinks } from '../../management/links'; -import { gettingStartedLinks, dashboardsLandingLinks } from '../../overview/links'; +import { getManagementLinkItems } from '../../management/links'; +import { dashboardsLandingLinks, threatHuntingLandingLinks } from '../../landing_pages/links'; +import { gettingStartedLinks } from '../../overview/links'; +import { StartPlugins } from '../../types'; -export const appLinks: Readonly = Object.freeze([ - gettingStartedLinks, - dashboardsLandingLinks, - detectionLinks, - { - id: SecurityPageName.threatHuntingLanding, - title: THREAT_HUNTING, - path: THREAT_HUNTING_PATH, - globalNavEnabled: false, - features: [FEATURE.general], - globalSearchKeywords: [ - i18n.translate('xpack.securitySolution.appLinks.threatHunting', { - defaultMessage: 'Threat hunting', - }), - ], - links: [hostsLinks, networkLinks, usersLinks], - skipUrlState: true, - hideTimeline: true, - }, - timelinesLinks, - getCasesLinkItems(), - managementLinks, -]); +export const getAppLinks = async ( + core: CoreStart, + plugins: StartPlugins +): Promise => { + const managementLinks = await getManagementLinkItems(core, plugins); + const casesLinks = getCasesLinkItems(); -export const getAppLinks = async ({ - enableExperimental, - license, - capabilities, -}: UserPermissions) => { - // OLM team, implement async behavior here - return appLinks; + return Object.freeze([ + dashboardsLandingLinks, + detectionLinks, + timelinesLinks, + casesLinks, + threatHuntingLandingLinks, + gettingStartedLinks, + managementLinks, + ]); }; diff --git a/x-pack/plugins/security_solution/public/common/links/index.tsx b/x-pack/plugins/security_solution/public/common/links/index.tsx index 6d8e99cd416d2..e4e4de0b49430 100644 --- a/x-pack/plugins/security_solution/public/common/links/index.tsx +++ b/x-pack/plugins/security_solution/public/common/links/index.tsx @@ -6,3 +6,4 @@ */ export * from './links'; +export * from './types'; diff --git a/x-pack/plugins/security_solution/public/common/links/links.test.ts b/x-pack/plugins/security_solution/public/common/links/links.test.ts index b68ae3d863de3..896f9357077c8 100644 --- a/x-pack/plugins/security_solution/public/common/links/links.test.ts +++ b/x-pack/plugins/security_solution/public/common/links/links.test.ts @@ -5,399 +5,223 @@ * 2.0. */ +import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; +import { Capabilities } from '@kbn/core/types'; +import { mockGlobalState, TestProviders } from '../mock'; +import { ILicense, LicenseType } from '@kbn/licensing-plugin/common/types'; +import { AppLinkItems } from './types'; +import { act, renderHook } from '@testing-library/react-hooks'; import { + useAppLinks, getAncestorLinksInfo, - getDeepLinks, - getInitialDeepLinks, getLinkInfo, - getNavLinkItems, needsUrlState, + updateAppLinks, + excludeAppLink, } from './links'; -import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; -import { Capabilities } from '@kbn/core/types'; -import { AppDeepLink } from '@kbn/core/public'; -import { mockGlobalState } from '../mock'; -import { NavLinkItem } from './types'; -import { LicenseType } from '@kbn/licensing-plugin/common/types'; -import { LicenseService } from '../../../common/license'; + +const defaultAppLinks: AppLinkItems = [ + { + id: SecurityPageName.hosts, + title: 'Hosts', + path: '/hosts', + links: [ + { + id: SecurityPageName.hostsAuthentications, + title: 'Authentications', + path: `/hosts/authentications`, + }, + { + id: SecurityPageName.hostsEvents, + title: 'Events', + path: `/hosts/events`, + skipUrlState: true, + }, + ], + }, +]; const mockExperimentalDefaults = mockGlobalState.app.enableExperimental; + const mockCapabilities = { [CASES_FEATURE_ID]: { read_cases: true, crud_cases: true }, [SERVER_APP_ID]: { show: true }, } as unknown as Capabilities; -const findDeepLink = (id: string, deepLinks: AppDeepLink[]): AppDeepLink | null => - deepLinks.reduce((deepLinkFound: AppDeepLink | null, deepLink) => { - if (deepLinkFound !== null) { - return deepLinkFound; - } - if (deepLink.id === id) { - return deepLink; - } - if (deepLink.deepLinks) { - return findDeepLink(id, deepLink.deepLinks); - } - return null; - }, null); - -const findNavLink = (id: SecurityPageName, navLinks: NavLinkItem[]): NavLinkItem | null => - navLinks.reduce((deepLinkFound: NavLinkItem | null, deepLink) => { - if (deepLinkFound !== null) { - return deepLinkFound; - } - if (deepLink.id === id) { - return deepLink; - } - if (deepLink.links) { - return findNavLink(id, deepLink.links); - } - return null; - }, null); - -// remove filter once new nav is live -const allPages = Object.values(SecurityPageName).filter( - (pageName) => - pageName !== SecurityPageName.explore && - pageName !== SecurityPageName.detections && - pageName !== SecurityPageName.investigate -); -const casesPages = [ - SecurityPageName.case, - SecurityPageName.caseConfigure, - SecurityPageName.caseCreate, -]; -const featureFlagPages = [ - SecurityPageName.detectionAndResponse, - SecurityPageName.hostsAuthentications, - SecurityPageName.hostsRisk, - SecurityPageName.usersRisk, -]; -const premiumPages = [ - SecurityPageName.caseConfigure, - SecurityPageName.hostsAnomalies, - SecurityPageName.networkAnomalies, - SecurityPageName.usersAnomalies, - SecurityPageName.detectionAndResponse, - SecurityPageName.hostsRisk, - SecurityPageName.usersRisk, -]; -const nonCasesPages = allPages.reduce( - (acc: SecurityPageName[], p) => - casesPages.includes(p) || featureFlagPages.includes(p) ? acc : [p, ...acc], - [] -); - const licenseBasicMock = jest.fn().mockImplementation((arg: LicenseType) => arg === 'basic'); const licensePremiumMock = jest.fn().mockReturnValue(true); const mockLicense = { - isAtLeast: licensePremiumMock, -} as unknown as LicenseService; - -const threatHuntingLinkInfo = { - features: ['siem.show'], - globalNavEnabled: false, - globalSearchKeywords: ['Threat hunting'], - id: 'threat_hunting', - path: '/threat_hunting', - title: 'Threat Hunting', - hideTimeline: true, - skipUrlState: true, -}; + hasAtLeast: licensePremiumMock, +} as unknown as ILicense; -const hostsLinkInfo = { - globalNavEnabled: true, - globalNavOrder: 9002, - globalSearchEnabled: true, - globalSearchKeywords: ['Hosts'], - id: 'hosts', - path: '/hosts', - title: 'Hosts', - landingImage: 'test-file-stub', - description: 'A comprehensive overview of all hosts and host-related security events.', -}; +const renderUseAppLinks = () => + renderHook<{}, AppLinkItems>(() => useAppLinks(), { wrapper: TestProviders }); -describe('security app link helpers', () => { +describe('Security app links', () => { beforeEach(() => { - mockLicense.isAtLeast = licensePremiumMock; - }); - describe('getInitialDeepLinks', () => { - it('should return all pages in the app', () => { - const links = getInitialDeepLinks(); - allPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); - }); - }); - describe('getDeepLinks', () => { - it('basicLicense should return only basic links', async () => { - mockLicense.isAtLeast = licenseBasicMock; + mockLicense.hasAtLeast = licensePremiumMock; - const links = await getDeepLinks({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsAnomalies, links)).toBeFalsy(); - allPages.forEach((page) => { - if (premiumPages.includes(page)) { - return expect(findDeepLink(page, links)).toBeFalsy(); - } - if (featureFlagPages.includes(page)) { - // ignore feature flag pages - return; - } - expect(findDeepLink(page, links)).toBeTruthy(); - }); - }); - it('platinumLicense should return all links', async () => { - const links = await getDeepLinks({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities: mockCapabilities, - }); - allPages.forEach((page) => { - if (premiumPages.includes(page) && !featureFlagPages.includes(page)) { - return expect(findDeepLink(page, links)).toBeTruthy(); - } - if (featureFlagPages.includes(page)) { - // ignore feature flag pages - return; - } - expect(findDeepLink(page, links)).toBeTruthy(); - }); - }); - it('hideWhenExperimentalKey hides entry when key = true', async () => { - const links = await getDeepLinks({ - enableExperimental: { ...mockExperimentalDefaults, usersEnabled: true }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsAuthentications, links)).toBeFalsy(); - }); - it('hideWhenExperimentalKey shows entry when key = false', async () => { - const links = await getDeepLinks({ - enableExperimental: { ...mockExperimentalDefaults, usersEnabled: false }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsAuthentications, links)).toBeTruthy(); - }); - it('experimentalKey shows entry when key = false', async () => { - const links = await getDeepLinks({ - enableExperimental: { - ...mockExperimentalDefaults, - riskyHostsEnabled: false, - riskyUsersEnabled: false, - detectionResponseEnabled: false, - }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsRisk, links)).toBeFalsy(); - expect(findDeepLink(SecurityPageName.usersRisk, links)).toBeFalsy(); - expect(findDeepLink(SecurityPageName.detectionAndResponse, links)).toBeFalsy(); - }); - it('experimentalKey shows entry when key = true', async () => { - const links = await getDeepLinks({ - enableExperimental: { - ...mockExperimentalDefaults, - riskyHostsEnabled: true, - riskyUsersEnabled: true, - detectionResponseEnabled: true, - }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsRisk, links)).toBeTruthy(); - expect(findDeepLink(SecurityPageName.usersRisk, links)).toBeTruthy(); - expect(findDeepLink(SecurityPageName.detectionAndResponse, links)).toBeTruthy(); + updateAppLinks(defaultAppLinks, { + capabilities: mockCapabilities, + experimentalFeatures: mockExperimentalDefaults, + license: mockLicense, }); + }); - it('Removes siem features when siem capabilities are false', async () => { - const capabilities = { - ...mockCapabilities, - [SERVER_APP_ID]: { show: false }, - } as unknown as Capabilities; - const links = await getDeepLinks({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities, - }); - nonCasesPages.forEach((page) => { - // investigate is active for both Cases and Timelines pages - if (page === SecurityPageName.investigate) { - return expect(findDeepLink(page, links)).toBeTruthy(); - } - return expect(findDeepLink(page, links)).toBeFalsy(); - }); - casesPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); - }); - it('Removes cases features when cases capabilities are false', async () => { - const capabilities = { - ...mockCapabilities, - [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, - } as unknown as Capabilities; - const links = await getDeepLinks({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities, - }); - nonCasesPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); - casesPages.forEach((page) => expect(findDeepLink(page, links)).toBeFalsy()); + describe('useAppLinks', () => { + it('should return initial appLinks', () => { + const { result } = renderUseAppLinks(); + expect(result.current).toStrictEqual(defaultAppLinks); + }); + + it('should filter not allowed links', async () => { + const { result, waitForNextUpdate } = renderUseAppLinks(); + // this link should not be excluded, the test checks all conditions are passed + const networkLinkItem = { + id: SecurityPageName.network, + title: 'Network', + path: '/network', + capabilities: [`${CASES_FEATURE_ID}.read_cases`, `${SERVER_APP_ID}.show`], + experimentalKey: 'flagEnabled' as unknown as keyof typeof mockExperimentalDefaults, + hideWhenExperimentalKey: 'flagDisabled' as unknown as keyof typeof mockExperimentalDefaults, + licenseType: 'basic' as const, + }; + + await act(async () => { + updateAppLinks( + [ + { + ...networkLinkItem, + // all its links should be filtered for all different criteria + links: [ + { + id: SecurityPageName.networkExternalAlerts, + title: 'external alerts', + path: '/external_alerts', + experimentalKey: + 'flagDisabled' as unknown as keyof typeof mockExperimentalDefaults, + }, + { + id: SecurityPageName.networkDns, + title: 'dns', + path: '/dns', + hideWhenExperimentalKey: + 'flagEnabled' as unknown as keyof typeof mockExperimentalDefaults, + }, + { + id: SecurityPageName.networkAnomalies, + title: 'Anomalies', + path: '/anomalies', + capabilities: [ + `${CASES_FEATURE_ID}.read_cases`, + `${CASES_FEATURE_ID}.write_cases`, + ], + }, + { + id: SecurityPageName.networkHttp, + title: 'Http', + path: '/http', + licenseType: 'gold', + }, + ], + }, + { + // should be excluded by license with all its links + id: SecurityPageName.hosts, + title: 'Hosts', + path: '/hosts', + licenseType: 'platinum', + links: [ + { + id: SecurityPageName.hostsEvents, + title: 'Events', + path: '/events', + }, + ], + }, + ], + { + capabilities: { + ...mockCapabilities, + [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, + }, + experimentalFeatures: { + flagEnabled: true, + flagDisabled: false, + } as unknown as typeof mockExperimentalDefaults, + license: { hasAtLeast: licenseBasicMock } as unknown as ILicense, + } + ); + await waitForNextUpdate(); + }); + + expect(result.current).toStrictEqual([networkLinkItem]); }); }); - describe('getNavLinkItems', () => { - it('basicLicense should return only basic links', () => { - mockLicense.isAtLeast = licenseBasicMock; - const links = getNavLinkItems({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findNavLink(SecurityPageName.hostsAnomalies, links)).toBeFalsy(); - allPages.forEach((page) => { - if (premiumPages.includes(page)) { - return expect(findNavLink(page, links)).toBeFalsy(); - } - if (featureFlagPages.includes(page)) { - // ignore feature flag pages - return; - } - expect(findNavLink(page, links)).toBeTruthy(); - }); - }); - it('platinumLicense should return all links', () => { - const links = getNavLinkItems({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities: mockCapabilities, - }); - allPages.forEach((page) => { - if (premiumPages.includes(page) && !featureFlagPages.includes(page)) { - return expect(findNavLink(page, links)).toBeTruthy(); - } - if (featureFlagPages.includes(page)) { - // ignore feature flag pages - return; - } - expect(findNavLink(page, links)).toBeTruthy(); - }); - }); - it('hideWhenExperimentalKey hides entry when key = true', () => { - const links = getNavLinkItems({ - enableExperimental: { ...mockExperimentalDefaults, usersEnabled: true }, - license: mockLicense, - capabilities: mockCapabilities, + describe('excludeAppLink', () => { + it('should exclude link from app links', async () => { + const { result, waitForNextUpdate } = renderUseAppLinks(); + await act(async () => { + excludeAppLink(SecurityPageName.hostsEvents); + await waitForNextUpdate(); }); - expect(findNavLink(SecurityPageName.hostsAuthentications, links)).toBeFalsy(); - }); - it('hideWhenExperimentalKey shows entry when key = false', () => { - const links = getNavLinkItems({ - enableExperimental: { ...mockExperimentalDefaults, usersEnabled: false }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findNavLink(SecurityPageName.hostsAuthentications, links)).toBeTruthy(); - }); - it('experimentalKey shows entry when key = false', () => { - const links = getNavLinkItems({ - enableExperimental: { - ...mockExperimentalDefaults, - riskyHostsEnabled: false, - riskyUsersEnabled: false, - detectionResponseEnabled: false, - }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findNavLink(SecurityPageName.hostsRisk, links)).toBeFalsy(); - expect(findNavLink(SecurityPageName.usersRisk, links)).toBeFalsy(); - expect(findNavLink(SecurityPageName.detectionAndResponse, links)).toBeFalsy(); - }); - it('experimentalKey shows entry when key = true', () => { - const links = getNavLinkItems({ - enableExperimental: { - ...mockExperimentalDefaults, - riskyHostsEnabled: true, - riskyUsersEnabled: true, - detectionResponseEnabled: true, + expect(result.current).toStrictEqual([ + { + id: SecurityPageName.hosts, + title: 'Hosts', + path: '/hosts', + links: [ + { + id: SecurityPageName.hostsAuthentications, + title: 'Authentications', + path: `/hosts/authentications`, + }, + ], }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findNavLink(SecurityPageName.hostsRisk, links)).toBeTruthy(); - expect(findNavLink(SecurityPageName.usersRisk, links)).toBeTruthy(); - expect(findNavLink(SecurityPageName.detectionAndResponse, links)).toBeTruthy(); - }); - - it('Removes siem features when siem capabilities are false', () => { - const capabilities = { - ...mockCapabilities, - [SERVER_APP_ID]: { show: false }, - } as unknown as Capabilities; - const links = getNavLinkItems({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities, - }); - nonCasesPages.forEach((page) => { - // investigate is active for both Cases and Timelines pages - if (page === SecurityPageName.investigate) { - return expect(findNavLink(page, links)).toBeTruthy(); - } - return expect(findNavLink(page, links)).toBeFalsy(); - }); - casesPages.forEach((page) => expect(findNavLink(page, links)).toBeTruthy()); - }); - it('Removes cases features when cases capabilities are false', () => { - const capabilities = { - ...mockCapabilities, - [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, - } as unknown as Capabilities; - const links = getNavLinkItems({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities, - }); - nonCasesPages.forEach((page) => expect(findNavLink(page, links)).toBeTruthy()); - casesPages.forEach((page) => expect(findNavLink(page, links)).toBeFalsy()); + ]); }); }); describe('getAncestorLinksInfo', () => { - it('finds flattened links for hosts', () => { - const hierarchy = getAncestorLinksInfo(SecurityPageName.hosts); - expect(hierarchy).toEqual([threatHuntingLinkInfo, hostsLinkInfo]); - }); - it('finds flattened links for uncommonProcesses', () => { - const hierarchy = getAncestorLinksInfo(SecurityPageName.uncommonProcesses); - expect(hierarchy).toEqual([ - threatHuntingLinkInfo, - hostsLinkInfo, + it('should find ancestors flattened links', () => { + const hierarchy = getAncestorLinksInfo(SecurityPageName.hostsEvents); + expect(hierarchy).toStrictEqual([ { - id: 'uncommon_processes', - path: '/hosts/uncommonProcesses', - title: 'Uncommon Processes', + id: SecurityPageName.hosts, + path: '/hosts', + title: 'Hosts', + }, + { + id: SecurityPageName.hostsEvents, + path: '/hosts/events', + skipUrlState: true, + title: 'Events', }, ]); }); }); describe('needsUrlState', () => { - it('returns true when url state exists for page', () => { + it('should return true when url state exists for page', () => { const needsUrl = needsUrlState(SecurityPageName.hosts); expect(needsUrl).toEqual(true); }); - it('returns false when url state does not exist for page', () => { - const needsUrl = needsUrlState(SecurityPageName.landing); + it('should return false when url state does not exist for page', () => { + const needsUrl = needsUrlState(SecurityPageName.hostsEvents); expect(needsUrl).toEqual(false); }); }); describe('getLinkInfo', () => { - it('gets information for an individual link', () => { - const linkInfo = getLinkInfo(SecurityPageName.hosts); - expect(linkInfo).toEqual(hostsLinkInfo); + it('should get information for an individual link', () => { + const linkInfo = getLinkInfo(SecurityPageName.hostsEvents); + expect(linkInfo).toStrictEqual({ + id: SecurityPageName.hostsEvents, + path: '/hosts/events', + skipUrlState: true, + title: 'Events', + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/links/links.ts b/x-pack/plugins/security_solution/public/common/links/links.ts index 57965bdeba0c0..384861a9dc5e7 100644 --- a/x-pack/plugins/security_solution/public/common/links/links.ts +++ b/x-pack/plugins/security_solution/public/common/links/links.ts @@ -5,169 +5,120 @@ * 2.0. */ -import { AppDeepLink, AppNavLinkStatus, Capabilities } from '@kbn/core/public'; +import type { Capabilities } from '@kbn/core/public'; import { get } from 'lodash'; +import { useEffect, useState } from 'react'; +import { BehaviorSubject } from 'rxjs'; import { SecurityPageName } from '../../../common/constants'; -import { appLinks, getAppLinks } from './app_links'; -import { - Feature, +import type { + AppLinkItems, LinkInfo, LinkItem, - NavLinkItem, NormalizedLink, NormalizedLinks, - UserPermissions, + LinksPermissions, } from './types'; -const createDeepLink = (link: LinkItem, linkProps?: UserPermissions): AppDeepLink => ({ - id: link.id, - path: link.path, - title: link.title, - ...(link.links && link.links.length - ? { - deepLinks: reduceLinks({ - links: link.links, - linkProps, - formatFunction: createDeepLink, - }), - } - : {}), - ...(link.globalSearchKeywords != null ? { keywords: link.globalSearchKeywords } : {}), - ...(link.globalNavEnabled != null - ? { navLinkStatus: link.globalNavEnabled ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden } - : {}), - ...(link.globalNavOrder != null ? { order: link.globalNavOrder } : {}), - ...(link.globalSearchEnabled != null ? { searchable: link.globalSearchEnabled } : {}), +/** + * App links updater, it keeps the value of the app links in sync with all application. + * It can be updated using `updateAppLinks` or `excludeAppLink` + * Read it using `subscribeAppLinks` or `useAppLinks` hook. + */ +const appLinksUpdater$ = new BehaviorSubject<{ + links: AppLinkItems; + normalizedLinks: NormalizedLinks; +}>({ + links: [], // stores the appLinkItems recursive hierarchy + normalizedLinks: {}, // stores a flatten normalized object for direct id access }); -const createNavLinkItem = (link: LinkItem, linkProps?: UserPermissions): NavLinkItem => ({ - id: link.id, - path: link.path, - title: link.title, - ...(link.description != null ? { description: link.description } : {}), - ...(link.landingIcon != null ? { icon: link.landingIcon } : {}), - ...(link.landingImage != null ? { image: link.landingImage } : {}), - ...(link.links && link.links.length - ? { - links: reduceLinks({ - links: link.links, - linkProps, - formatFunction: createNavLinkItem, - }), - } - : {}), - ...(link.skipUrlState != null ? { skipUrlState: link.skipUrlState } : {}), -}); +const getAppLinksValue = (): AppLinkItems => appLinksUpdater$.getValue().links; +const getNormalizedLinksValue = (): NormalizedLinks => appLinksUpdater$.getValue().normalizedLinks; -const hasFeaturesCapability = ( - features: Feature[] | undefined, - capabilities: Capabilities -): boolean => { - if (!features) { - return true; - } - return features.some((featureKey) => get(capabilities, featureKey, false)); -}; +/** + * Subscribes to the updater to get the app links updates + */ +export const subscribeAppLinks = (onChange: (links: AppLinkItems) => void) => + appLinksUpdater$.subscribe(({ links }) => onChange(links)); -const isLinkAllowed = (link: LinkItem, linkProps?: UserPermissions) => - !( - linkProps != null && - // exclude link when license is basic and link is premium - ((linkProps.license && !linkProps.license.isAtLeast(link.licenseType ?? 'basic')) || - // exclude link when enableExperimental[hideWhenExperimentalKey] is enabled and link has hideWhenExperimentalKey - (link.hideWhenExperimentalKey != null && - linkProps.enableExperimental[link.hideWhenExperimentalKey]) || - // exclude link when enableExperimental[experimentalKey] is disabled and link has experimentalKey - (link.experimentalKey != null && !linkProps.enableExperimental[link.experimentalKey]) || - // exclude link when link is not part of enabled feature capabilities - (linkProps.capabilities != null && - !hasFeaturesCapability(link.features, linkProps.capabilities))) - ); - -export function reduceLinks({ - links, - linkProps, - formatFunction, -}: { - links: Readonly; - linkProps?: UserPermissions; - formatFunction: (link: LinkItem, linkProps?: UserPermissions) => T; -}): T[] { - return links.reduce( - (deepLinks: T[], link: LinkItem) => - isLinkAllowed(link, linkProps) ? [...deepLinks, formatFunction(link, linkProps)] : deepLinks, - [] - ); -} - -export const getInitialDeepLinks = (): AppDeepLink[] => { - return appLinks.map((link) => createDeepLink(link)); -}; +/** + * Hook to get the app links updated value + */ +export const useAppLinks = (): AppLinkItems => { + const [appLinks, setAppLinks] = useState(getAppLinksValue); -export const getDeepLinks = async ({ - enableExperimental, - license, - capabilities, -}: UserPermissions): Promise => { - const links = await getAppLinks({ enableExperimental, license, capabilities }); - return reduceLinks({ - links, - linkProps: { enableExperimental, license, capabilities }, - formatFunction: createDeepLink, - }); -}; + useEffect(() => { + const linksSubscription = subscribeAppLinks((newAppLinks) => { + setAppLinks(newAppLinks); + }); + return () => linksSubscription.unsubscribe(); + }, []); -export const getNavLinkItems = ({ - enableExperimental, - license, - capabilities, -}: UserPermissions): NavLinkItem[] => - reduceLinks({ - links: appLinks, - linkProps: { enableExperimental, license, capabilities }, - formatFunction: createNavLinkItem, - }); + return appLinks; +}; /** - * Recursive function to create the `NormalizedLinks` structure from a `LinkItem` array parameter + * Updates the app links applying the filter by permissions */ -const getNormalizedLinks = ( - currentLinks: Readonly, - parentId?: SecurityPageName -): NormalizedLinks => { - const result = currentLinks.reduce>( - (normalized, { links, ...currentLink }) => { - normalized[currentLink.id] = { - ...currentLink, - parentId, - }; - if (links && links.length > 0) { - Object.assign(normalized, getNormalizedLinks(links, currentLink.id)); - } - return normalized; - }, - {} - ); - return result as NormalizedLinks; +export const updateAppLinks = ( + appLinksToUpdate: AppLinkItems, + linksPermissions: LinksPermissions +) => { + const filteredAppLinks = getFilteredAppLinks(appLinksToUpdate, linksPermissions); + appLinksUpdater$.next({ + links: Object.freeze(filteredAppLinks), + normalizedLinks: Object.freeze(getNormalizedLinks(filteredAppLinks)), + }); }; /** - * Normalized indexed version of the global `links` array, referencing the parent by id, instead of having nested links children - */ -const normalizedLinks: Readonly = Object.freeze(getNormalizedLinks(appLinks)); -/** - * Returns the `NormalizedLink` from a link id parameter. - * The object reference is frozen to make sure it is not mutated by the caller. + * Excludes a link by id from the current app links + * @deprecated this function will not be needed when async link filtering is migrated to the main getAppLinks functions */ -const getNormalizedLink = (id: SecurityPageName): Readonly => - Object.freeze(normalizedLinks[id]); +export const excludeAppLink = (linkId: SecurityPageName) => { + const { links, normalizedLinks } = appLinksUpdater$.getValue(); + if (!normalizedLinks[linkId]) { + return; + } + + let found = false; + const excludeRec = (currentLinks: AppLinkItems): LinkItem[] => + currentLinks.reduce((acc, link) => { + if (!found) { + if (link.id === linkId) { + found = true; + return acc; + } + if (link.links) { + const excludedLinks = excludeRec(link.links); + if (excludedLinks.length > 0) { + acc.push({ ...link, links: excludedLinks }); + return acc; + } + } + } + acc.push(link); + return acc; + }, []); + + const excludedLinks = excludeRec(links); + + appLinksUpdater$.next({ + links: Object.freeze(excludedLinks), + normalizedLinks: Object.freeze(getNormalizedLinks(excludedLinks)), + }); +}; /** * Returns the `LinkInfo` from a link id parameter */ -export const getLinkInfo = (id: SecurityPageName): LinkInfo => { +export const getLinkInfo = (id: SecurityPageName): LinkInfo | undefined => { + const normalizedLink = getNormalizedLink(id); + if (!normalizedLink) { + return undefined; + } // discards the parentId and creates the linkInfo copy. - const { parentId, ...linkInfo } = getNormalizedLink(id); + const { parentId, ...linkInfo } = normalizedLink; return linkInfo; }; @@ -178,9 +129,14 @@ export const getAncestorLinksInfo = (id: SecurityPageName): LinkInfo[] => { const ancestors: LinkInfo[] = []; let currentId: SecurityPageName | undefined = id; while (currentId) { - const { parentId, ...linkInfo } = getNormalizedLink(currentId); - ancestors.push(linkInfo); - currentId = parentId; + const normalizedLink = getNormalizedLink(currentId); + if (normalizedLink) { + const { parentId, ...linkInfo } = normalizedLink; + ancestors.push(linkInfo); + currentId = parentId; + } else { + currentId = undefined; + } } return ancestors.reverse(); }; @@ -190,9 +146,82 @@ export const getAncestorLinksInfo = (id: SecurityPageName): LinkInfo[] => { * Defaults to `true` if the `skipUrlState` property of the `LinkItem` is `undefined`. */ export const needsUrlState = (id: SecurityPageName): boolean => { - return !getNormalizedLink(id).skipUrlState; + return !getNormalizedLink(id)?.skipUrlState; +}; + +// Internal functions + +/** + * Creates the `NormalizedLinks` structure from a `LinkItem` array + */ +const getNormalizedLinks = ( + currentLinks: AppLinkItems, + parentId?: SecurityPageName +): NormalizedLinks => { + return currentLinks.reduce((normalized, { links, ...currentLink }) => { + normalized[currentLink.id] = { + ...currentLink, + parentId, + }; + if (links && links.length > 0) { + Object.assign(normalized, getNormalizedLinks(links, currentLink.id)); + } + return normalized; + }, {}); +}; + +const getNormalizedLink = (id: SecurityPageName): Readonly | undefined => + getNormalizedLinksValue()[id]; + +const getFilteredAppLinks = ( + appLinkToFilter: AppLinkItems, + linksPermissions: LinksPermissions +): LinkItem[] => + appLinkToFilter.reduce((acc, { links, ...appLink }) => { + if (!isLinkAllowed(appLink, linksPermissions)) { + return acc; + } + if (links) { + const childrenLinks = getFilteredAppLinks(links, linksPermissions); + if (childrenLinks.length > 0) { + acc.push({ ...appLink, links: childrenLinks }); + } else { + acc.push(appLink); + } + } else { + acc.push(appLink); + } + return acc; + }, []); + +// It checks if the user has at least one of the link capabilities needed +const hasCapabilities = (linkCapabilities: string[], userCapabilities: Capabilities): boolean => + linkCapabilities.some((linkCapability) => get(userCapabilities, linkCapability, false)); + +const isLinkAllowed = ( + link: LinkItem, + { license, experimentalFeatures, capabilities }: LinksPermissions +) => { + const linkLicenseType = link.licenseType ?? 'basic'; + if (license) { + if (!license.hasAtLeast(linkLicenseType)) { + return false; + } + } else if (linkLicenseType !== 'basic') { + return false; + } + if (link.hideWhenExperimentalKey && experimentalFeatures[link.hideWhenExperimentalKey]) { + return false; + } + if (link.experimentalKey && !experimentalFeatures[link.experimentalKey]) { + return false; + } + if (link.capabilities && !hasCapabilities(link.capabilities, capabilities)) { + return false; + } + return true; }; export const getLinksWithHiddenTimeline = (): LinkInfo[] => { - return Object.values(normalizedLinks).filter((link) => link.hideTimeline); + return Object.values(getNormalizedLinksValue()).filter((link) => link.hideTimeline); }; diff --git a/x-pack/plugins/security_solution/public/common/links/types.ts b/x-pack/plugins/security_solution/public/common/links/types.ts index bfa87851306ff..323873cafc23c 100644 --- a/x-pack/plugins/security_solution/public/common/links/types.ts +++ b/x-pack/plugins/security_solution/public/common/links/types.ts @@ -6,43 +6,73 @@ */ import { Capabilities } from '@kbn/core/types'; -import { LicenseType } from '@kbn/licensing-plugin/common/types'; +import { ILicense, LicenseType } from '@kbn/licensing-plugin/common/types'; import { IconType } from '@elastic/eui'; -import { LicenseService } from '../../../common/license'; import { ExperimentalFeatures } from '../../../common/experimental_features'; -import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; +import { SecurityPageName } from '../../../common/constants'; -export const FEATURE = { - general: `${SERVER_APP_ID}.show`, - casesRead: `${CASES_FEATURE_ID}.read_cases`, - casesCrud: `${CASES_FEATURE_ID}.crud_cases`, -}; - -export type Feature = Readonly; +/** + * Permissions related parameters needed for the links to be filtered + */ +export interface LinksPermissions { + capabilities: Capabilities; + experimentalFeatures: Readonly; + license?: ILicense; +} -export interface UserPermissions { - enableExperimental: ExperimentalFeatures; - license?: LicenseService; - capabilities?: Capabilities; +export interface LinkCategory { + label: string; + linkIds: readonly SecurityPageName[]; } +export type LinkCategories = Readonly; + export interface LinkItem { + /** + * The description of the link content + */ description?: string; - disabled?: boolean; // default false /** - * Displays deep link when feature flag is enabled. + * Experimental flag needed to enable the link */ experimentalKey?: keyof ExperimentalFeatures; - features?: Feature[]; /** - * Hides deep link when feature flag is enabled. + * Capabilities strings (using object dot notation) to enable the link. + * Uses "or" conditional, only one enabled capability is needed to activate the link + */ + capabilities?: string[]; + /** + * Categories to display in the navigation + */ + categories?: LinkCategories; + /** + * Enables link in the global navigation. Defaults to false. + */ + globalNavEnabled?: boolean; + /** + * Global navigation order number */ - globalNavEnabled?: boolean; // default false globalNavOrder?: number; - globalSearchEnabled?: boolean; + /** + * Disables link in the global search. Defaults to false. + */ + globalSearchDisabled?: boolean; + /** + * Keywords for the global search to search. + */ globalSearchKeywords?: string[]; + /** + * Experimental flag needed to disable the link. Opposite of experimentalKey + */ hideWhenExperimentalKey?: keyof ExperimentalFeatures; + /** + * Link id. Refers to a SecurityPageName + */ id: SecurityPageName; + /** + * Displays the "Beta" badge + */ + isBeta?: boolean; /** * Icon that is displayed on menu navigation landing page. * Only required for pages that are displayed inside a landing page. @@ -53,26 +83,38 @@ export interface LinkItem { * Only required for pages that are displayed inside a landing page. */ landingImage?: string; - isBeta?: boolean; + /** + * Minimum license required to enable the link + */ licenseType?: LicenseType; + /** + * Nested links + */ links?: LinkItem[]; + /** + * Link path relative to security root + */ path: string; - skipUrlState?: boolean; // defaults to false + /** + * Disables link in the side navigation. Defaults to false. + */ + sideNavDisabled?: boolean; + /** + * Disables the state query string in the URL. Defaults to false. + */ + skipUrlState?: boolean; + /** + * Disables the timeline call to action on the bottom of the page. Defaults to false. + */ hideTimeline?: boolean; // defaults to false + /** + * Title of the link + */ title: string; } -export interface NavLinkItem { - description?: string; - icon?: IconType; - id: SecurityPageName; - links?: NavLinkItem[]; - image?: string; - path: string; - title: string; - skipUrlState?: boolean; // default to false -} +export type AppLinkItems = Readonly; export type LinkInfo = Omit; export type NormalizedLink = LinkInfo & { parentId?: SecurityPageName }; -export type NormalizedLinks = Record; +export type NormalizedLinks = Partial>; diff --git a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx index 33a9f3a37a42f..ca9029c6c0939 100644 --- a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx @@ -6,7 +6,12 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; +import { coreMock } from '@kbn/core/public/mocks'; +import { allowedExperimentalValues } from '../../../../common/experimental_features'; +import { updateAppLinks } from '../../links'; +import { getAppLinks } from '../../links/app_links'; import { useShowTimeline } from './use_show_timeline'; +import { StartPlugins } from '../../../types'; const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/overview' }); jest.mock('react-router-dom', () => { @@ -24,6 +29,23 @@ jest.mock('../../components/navigation/helpers', () => ({ })); describe('use show timeline', () => { + beforeAll(async () => { + // initialize all App links before running test + const appLinks = await getAppLinks(coreMock.createStart(), {} as StartPlugins); + updateAppLinks(appLinks, { + experimentalFeatures: allowedExperimentalValues, + capabilities: { + navLinks: {}, + management: {}, + catalogue: {}, + actions: { show: true, crud: true }, + siem: { + show: true, + crud: true, + }, + }, + }); + }); describe('useIsGroupedNavigationEnabled false', () => { beforeAll(() => { mockedUseIsGroupedNavigationEnabled.mockReturnValue(false); diff --git a/x-pack/plugins/security_solution/public/detections/links.ts b/x-pack/plugins/security_solution/public/detections/links.ts index 1cfac62d80e6e..df9d32fcb57ed 100644 --- a/x-pack/plugins/security_solution/public/detections/links.ts +++ b/x-pack/plugins/security_solution/public/detections/links.ts @@ -5,21 +5,20 @@ * 2.0. */ import { i18n } from '@kbn/i18n'; -import { ALERTS_PATH, SecurityPageName } from '../../common/constants'; +import { ALERTS_PATH, SecurityPageName, SERVER_APP_ID } from '../../common/constants'; import { ALERTS } from '../app/translations'; -import { LinkItem, FEATURE } from '../common/links/types'; +import { LinkItem } from '../common/links/types'; export const links: LinkItem = { id: SecurityPageName.alerts, title: ALERTS, path: ALERTS_PATH, - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalNavEnabled: true, globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.alerts', { defaultMessage: 'Alerts', }), ], - globalSearchEnabled: true, globalNavOrder: 9001, }; diff --git a/x-pack/plugins/security_solution/public/hosts/links.ts b/x-pack/plugins/security_solution/public/hosts/links.ts index d1bc26c5fb3f2..dcdeb73ac1219 100644 --- a/x-pack/plugins/security_solution/public/hosts/links.ts +++ b/x-pack/plugins/security_solution/public/hosts/links.ts @@ -24,7 +24,6 @@ export const links: LinkItem = { defaultMessage: 'Hosts', }), ], - globalSearchEnabled: true, globalNavOrder: 9002, links: [ { diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx index 81b72527500ad..57aee98af4e9d 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx @@ -8,7 +8,7 @@ import { render } from '@testing-library/react'; import React from 'react'; import { SecurityPageName } from '../../app/types'; -import { NavLinkItem } from '../../common/links/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; import { TestProviders } from '../../common/mock'; import { LandingLinksIcons } from './landing_links_icons'; @@ -17,7 +17,6 @@ const DEFAULT_NAV_ITEM: NavLinkItem = { title: 'TEST LABEL', description: 'TEST DESCRIPTION', icon: 'myTestIcon', - path: '', }; const mockNavigateTo = jest.fn(); diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx index 04a3e20b1f178..b30d4f404b163 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx @@ -12,7 +12,7 @@ import { SecuritySolutionLinkAnchor, withSecuritySolutionLink, } from '../../common/components/links'; -import { NavLinkItem } from '../../common/links/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; interface LandingLinksImagesProps { items: NavLinkItem[]; diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx index c44374852f29b..81881a3796f0b 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx @@ -8,7 +8,7 @@ import { render } from '@testing-library/react'; import React from 'react'; import { SecurityPageName } from '../../app/types'; -import { NavLinkItem } from '../../common/links/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; import { TestProviders } from '../../common/mock'; import { LandingLinksImages } from './landing_links_images'; @@ -17,7 +17,6 @@ const DEFAULT_NAV_ITEM: NavLinkItem = { title: 'TEST LABEL', description: 'TEST DESCRIPTION', image: 'TEST_IMAGE.png', - path: '', }; jest.mock('../../common/lib/kibana/kibana_react', () => { diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx index 22bcc0f1aa251..4cf8db26bbe7a 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx @@ -8,7 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiImage, EuiPanel, EuiText, EuiTitle } from import React from 'react'; import styled from 'styled-components'; import { withSecuritySolutionLink } from '../../common/components/links'; -import { NavLinkItem } from '../../common/links/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; interface LandingLinksImagesProps { items: NavLinkItem[]; diff --git a/x-pack/plugins/security_solution/public/landing_pages/constants.ts b/x-pack/plugins/security_solution/public/landing_pages/constants.ts deleted file mode 100644 index a6b72a5e7db4f..0000000000000 --- a/x-pack/plugins/security_solution/public/landing_pages/constants.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import { SecurityPageName } from '../app/types'; - -export interface LandingNavGroup { - label: string; - itemIds: SecurityPageName[]; -} - -export const MANAGE_NAVIGATION_CATEGORIES: LandingNavGroup[] = [ - { - label: i18n.translate('xpack.securitySolution.landing.siemTitle', { - defaultMessage: 'SIEM', - }), - itemIds: [SecurityPageName.rules, SecurityPageName.exceptions], - }, - { - label: i18n.translate('xpack.securitySolution.landing.endpointsTitle', { - defaultMessage: 'ENDPOINTS', - }), - itemIds: [ - SecurityPageName.endpoints, - SecurityPageName.policies, - SecurityPageName.trustedApps, - SecurityPageName.eventFilters, - SecurityPageName.blocklist, - SecurityPageName.hostIsolationExceptions, - ], - }, -]; diff --git a/x-pack/plugins/security_solution/public/landing_pages/links.ts b/x-pack/plugins/security_solution/public/landing_pages/links.ts new file mode 100644 index 0000000000000..48cd31485ea7f --- /dev/null +++ b/x-pack/plugins/security_solution/public/landing_pages/links.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { + DASHBOARDS_PATH, + SecurityPageName, + SERVER_APP_ID, + THREAT_HUNTING_PATH, +} from '../../common/constants'; +import { DASHBOARDS, THREAT_HUNTING } from '../app/translations'; +import { LinkItem } from '../common/links/types'; +import { overviewLinks, detectionResponseLinks } from '../overview/links'; +import { links as hostsLinks } from '../hosts/links'; +import { links as networkLinks } from '../network/links'; +import { links as usersLinks } from '../users/links'; + +export const dashboardsLandingLinks: LinkItem = { + id: SecurityPageName.dashboardsLanding, + title: DASHBOARDS, + path: DASHBOARDS_PATH, + globalNavEnabled: false, + capabilities: [`${SERVER_APP_ID}.show`], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.dashboards', { + defaultMessage: 'Dashboards', + }), + ], + links: [overviewLinks, detectionResponseLinks], + skipUrlState: true, + hideTimeline: true, +}; + +export const threatHuntingLandingLinks: LinkItem = { + id: SecurityPageName.threatHuntingLanding, + title: THREAT_HUNTING, + path: THREAT_HUNTING_PATH, + globalNavEnabled: false, + capabilities: [`${SERVER_APP_ID}.show`], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.threatHunting', { + defaultMessage: 'Threat hunting', + }), + ], + links: [hostsLinks, networkLinks, usersLinks], + skipUrlState: true, + hideTimeline: true, +}; diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx index 1955d56c0a151..a09db6ebf5eaa 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx @@ -9,53 +9,58 @@ import { render } from '@testing-library/react'; import React from 'react'; import { SecurityPageName } from '../../app/types'; import { TestProviders } from '../../common/mock'; -import { LandingCategories } from './manage'; -import { NavLinkItem } from '../../common/links/types'; +import { ManagementCategories } from './manage'; +import { NavLinkItem } from '../../common/components/navigation/types'; const RULES_ITEM_LABEL = 'elastic rules!'; const EXCEPTIONS_ITEM_LABEL = 'exceptional!'; +const CATEGORY_1_LABEL = 'first tests category'; +const CATEGORY_2_LABEL = 'second tests category'; -const mockAppManageLink: NavLinkItem = { +const defaultAppManageLink: NavLinkItem = { id: SecurityPageName.administration, - path: '', title: 'admin', + categories: [ + { + label: CATEGORY_1_LABEL, + linkIds: [SecurityPageName.rules], + }, + { + label: CATEGORY_2_LABEL, + linkIds: [SecurityPageName.exceptions], + }, + ], links: [ { id: SecurityPageName.rules, title: RULES_ITEM_LABEL, description: '', icon: 'testIcon1', - path: '', }, { id: SecurityPageName.exceptions, title: EXCEPTIONS_ITEM_LABEL, description: '', icon: 'testIcon2', - path: '', }, ], }; + +const mockedUseCanSeeHostIsolationExceptionsMenu = jest.fn(); +jest.mock('../../management/pages/host_isolation_exceptions/view/hooks', () => ({ + useCanSeeHostIsolationExceptionsMenu: () => mockedUseCanSeeHostIsolationExceptionsMenu(), +})); + +const mockAppManageLink = jest.fn(() => defaultAppManageLink); jest.mock('../../common/components/navigation/nav_links', () => ({ - useAppRootNavLink: jest.fn(() => mockAppManageLink), + useAppRootNavLink: () => mockAppManageLink(), })); -describe('LandingCategories', () => { - it('renders items', () => { +describe('ManagementCategories', () => { + it('should render items', () => { const { queryByText } = render( - + ); @@ -63,17 +68,19 @@ describe('LandingCategories', () => { expect(queryByText(EXCEPTIONS_ITEM_LABEL)).toBeInTheDocument(); }); - it('renders items in the same order as defined', () => { + it('should render items in the same order as defined', () => { + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + categories: [ + { + label: '', + linkIds: [SecurityPageName.exceptions, SecurityPageName.rules], + }, + ], + }); const { queryAllByTestId } = render( - + ); @@ -82,4 +89,109 @@ describe('LandingCategories', () => { expect(renderedItems[0]).toHaveTextContent(EXCEPTIONS_ITEM_LABEL); expect(renderedItems[1]).toHaveTextContent(RULES_ITEM_LABEL); }); + + it('should not render category items filtered', () => { + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + categories: [ + { + label: CATEGORY_1_LABEL, + linkIds: [SecurityPageName.rules, SecurityPageName.exceptions], + }, + ], + links: [ + { + id: SecurityPageName.rules, + title: RULES_ITEM_LABEL, + description: '', + icon: 'testIcon1', + }, + ], + }); + const { queryAllByTestId } = render( + + + + ); + + const renderedItems = queryAllByTestId('LandingItem'); + + expect(renderedItems).toHaveLength(1); + expect(renderedItems[0]).toHaveTextContent(RULES_ITEM_LABEL); + }); + + it('should not render category if all items filtered', () => { + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + links: [], + }); + const { queryByText } = render( + + + + ); + + expect(queryByText(CATEGORY_1_LABEL)).not.toBeInTheDocument(); + expect(queryByText(CATEGORY_2_LABEL)).not.toBeInTheDocument(); + }); + + it('should not render hostIsolationExceptionsLink when useCanSeeHostIsolationExceptionsMenu is false', () => { + mockedUseCanSeeHostIsolationExceptionsMenu.mockReturnValueOnce(false); + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + categories: [ + { + label: CATEGORY_1_LABEL, + linkIds: [SecurityPageName.hostIsolationExceptions], + }, + ], + links: [ + { + id: SecurityPageName.hostIsolationExceptions, + title: 'test hostIsolationExceptions title', + description: 'test hostIsolationExceptions description', + icon: 'testIcon1', + }, + ], + }); + const { queryByText } = render( + + + + ); + + expect(queryByText(CATEGORY_1_LABEL)).not.toBeInTheDocument(); + }); + + it('should render hostIsolationExceptionsLink when useCanSeeHostIsolationExceptionsMenu is true', () => { + const HOST_ISOLATION_EXCEPTIONS_ITEM_LABEL = 'test hostIsolationExceptions title'; + mockedUseCanSeeHostIsolationExceptionsMenu.mockReturnValueOnce(true); + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + categories: [ + { + label: CATEGORY_1_LABEL, + linkIds: [SecurityPageName.hostIsolationExceptions], + }, + ], + links: [ + { + id: SecurityPageName.hostIsolationExceptions, + title: HOST_ISOLATION_EXCEPTIONS_ITEM_LABEL, + description: 'test hostIsolationExceptions description', + icon: 'testIcon1', + }, + ], + }); + const { queryAllByTestId } = render( + + + + ); + + const renderedItems = queryAllByTestId('LandingItem'); + + expect(renderedItems).toHaveLength(1); + expect(renderedItems[0]).toHaveTextContent(HOST_ISOLATION_EXCEPTIONS_ITEM_LABEL); + }); }); diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx index f0e6094d5113f..d484e5fe90a52 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx @@ -11,18 +11,18 @@ import styled from 'styled-components'; import { SecurityPageName } from '../../app/types'; import { HeaderPage } from '../../common/components/header_page'; import { useAppRootNavLink } from '../../common/components/navigation/nav_links'; -import { NavigationCategories } from '../../common/components/navigation/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { SpyRoute } from '../../common/utils/route/spy_routes'; -import { navigationCategories } from '../../management/links'; +import { useCanSeeHostIsolationExceptionsMenu } from '../../management/pages/host_isolation_exceptions/view/hooks'; import { LandingLinksIcons } from '../components/landing_links_icons'; import { MANAGE_PAGE_TITLE } from './translations'; export const ManageLandingPage = () => ( - - + + ); @@ -31,37 +31,52 @@ const StyledEuiHorizontalRule = styled(EuiHorizontalRule)` margin-bottom: ${({ theme }) => theme.eui.paddingSizes.l}; `; -const useGetManageNavLinks = () => { - const manageNavLinks = useAppRootNavLink(SecurityPageName.administration)?.links ?? []; +type ManagementCategories = Array<{ label: string; links: NavLinkItem[] }>; +const useManagementCategories = (): ManagementCategories => { + const hideHostIsolationExceptions = !useCanSeeHostIsolationExceptionsMenu(); + const { links = [], categories = [] } = useAppRootNavLink(SecurityPageName.administration) ?? {}; - const manageLinksById = Object.fromEntries(manageNavLinks.map((link) => [link.id, link])); - return (linkIds: readonly SecurityPageName[]) => linkIds.map((linkId) => manageLinksById[linkId]); + const manageLinksById = Object.fromEntries(links.map((link) => [link.id, link])); + + return categories.reduce((acc, { label, linkIds }) => { + const linksItem = linkIds.reduce((linksAcc, linkId) => { + if ( + manageLinksById[linkId] && + !(linkId === SecurityPageName.hostIsolationExceptions && hideHostIsolationExceptions) + ) { + linksAcc.push(manageLinksById[linkId]); + } + return linksAcc; + }, []); + if (linksItem.length > 0) { + acc.push({ label, links: linksItem }); + } + return acc; + }, []); }; -export const LandingCategories = React.memo( - ({ categories }: { categories: NavigationCategories }) => { - const getManageNavLinks = useGetManageNavLinks(); +export const ManagementCategories = () => { + const managementCategories = useManagementCategories(); - return ( - <> - {categories.map(({ label, linkIds }, index) => ( -
- {index > 0 && ( - <> - - - - )} - -

{label}

-
- - -
- ))} - - ); - } -); + return ( + <> + {managementCategories.map(({ label, links }, index) => ( +
+ {index > 0 && ( + <> + + + + )} + +

{label}

+
+ + +
+ ))} + + ); +}; -LandingCategories.displayName = 'LandingCategories'; +ManagementCategories.displayName = 'ManagementCategories'; diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index ee60274cbb83d..9316f92a0d0b8 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { CoreStart } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { BLOCKLIST_PATH, @@ -17,6 +18,7 @@ import { RULES_CREATE_PATH, RULES_PATH, SecurityPageName, + SERVER_APP_ID, TRUSTED_APPS_PATH, } from '../../common/constants'; import { @@ -31,8 +33,8 @@ import { RULES, TRUSTED_APPLICATIONS, } from '../app/translations'; -import { NavigationCategories } from '../common/components/navigation/types'; -import { FEATURE, LinkItem } from '../common/links/types'; +import { LinkItem } from '../common/links/types'; +import { StartPlugins } from '../types'; import { IconBlocklist } from './icons/blocklist'; import { IconEndpoints } from './icons/endpoints'; @@ -43,19 +45,42 @@ import { IconHostIsolation } from './icons/host_isolation'; import { IconSiemRules } from './icons/siem_rules'; import { IconTrustedApplications } from './icons/trusted_applications'; -export const links: LinkItem = { +const categories = [ + { + label: i18n.translate('xpack.securitySolution.appLinks.category.siem', { + defaultMessage: 'SIEM', + }), + linkIds: [SecurityPageName.rules, SecurityPageName.exceptions], + }, + { + label: i18n.translate('xpack.securitySolution.appLinks.category.endpoints', { + defaultMessage: 'ENDPOINTS', + }), + linkIds: [ + SecurityPageName.endpoints, + SecurityPageName.policies, + SecurityPageName.trustedApps, + SecurityPageName.eventFilters, + SecurityPageName.hostIsolationExceptions, + SecurityPageName.blocklist, + ], + }, +]; + +const links: LinkItem = { id: SecurityPageName.administration, title: MANAGE, path: MANAGE_PATH, skipUrlState: true, hideTimeline: true, globalNavEnabled: false, - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.manage', { defaultMessage: 'Manage', }), ], + categories, links: [ { id: SecurityPageName.rules, @@ -73,7 +98,6 @@ export const links: LinkItem = { defaultMessage: 'Rules', }), ], - globalSearchEnabled: true, links: [ { id: SecurityPageName.rulesCreate, @@ -99,7 +123,6 @@ export const links: LinkItem = { defaultMessage: 'Exception lists', }), ], - globalSearchEnabled: true, }, { id: SecurityPageName.endpoints, @@ -178,24 +201,7 @@ export const links: LinkItem = { ], }; -export const navigationCategories: NavigationCategories = [ - { - label: i18n.translate('xpack.securitySolution.appLinks.category.siem', { - defaultMessage: 'SIEM', - }), - linkIds: [SecurityPageName.rules, SecurityPageName.exceptions], - }, - { - label: i18n.translate('xpack.securitySolution.appLinks.category.endpoints', { - defaultMessage: 'ENDPOINTS', - }), - linkIds: [ - SecurityPageName.endpoints, - SecurityPageName.policies, - SecurityPageName.trustedApps, - SecurityPageName.eventFilters, - SecurityPageName.blocklist, - SecurityPageName.hostIsolationExceptions, - ], - }, -] as const; +export const getManagementLinkItems = async (core: CoreStart, plugins: StartPlugins) => { + // TODO: implement async logic to exclude links + return links; +}; diff --git a/x-pack/plugins/security_solution/public/overview/links.ts b/x-pack/plugins/security_solution/public/overview/links.ts index 9fd06b523347f..dbcc04b5c6d8e 100644 --- a/x-pack/plugins/security_solution/public/overview/links.ts +++ b/x-pack/plugins/security_solution/public/overview/links.ts @@ -7,14 +7,14 @@ import { i18n } from '@kbn/i18n'; import { - DASHBOARDS_PATH, DETECTION_RESPONSE_PATH, LANDING_PATH, OVERVIEW_PATH, SecurityPageName, + SERVER_APP_ID, } from '../../common/constants'; -import { DASHBOARDS, DETECTION_RESPONSE, GETTING_STARTED, OVERVIEW } from '../app/translations'; -import { FEATURE, LinkItem } from '../common/links/types'; +import { DETECTION_RESPONSE, GETTING_STARTED, OVERVIEW } from '../app/translations'; +import { LinkItem } from '../common/links/types'; import overviewPageImg from '../common/images/overview_page.png'; import detectionResponsePageImg from '../common/images/detection_response_page.png'; @@ -27,7 +27,7 @@ export const overviewLinks: LinkItem = { }), path: OVERVIEW_PATH, globalNavEnabled: true, - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.overview', { defaultMessage: 'Overview', @@ -41,7 +41,7 @@ export const gettingStartedLinks: LinkItem = { title: GETTING_STARTED, path: LANDING_PATH, globalNavEnabled: false, - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.getStarted', { defaultMessage: 'Getting started', @@ -62,26 +62,10 @@ export const detectionResponseLinks: LinkItem = { path: DETECTION_RESPONSE_PATH, globalNavEnabled: false, experimentalKey: 'detectionResponseEnabled', - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.detectionAndResponse', { defaultMessage: 'Detection & Response', }), ], }; - -export const dashboardsLandingLinks: LinkItem = { - id: SecurityPageName.dashboardsLanding, - title: DASHBOARDS, - path: DASHBOARDS_PATH, - globalNavEnabled: false, - features: [FEATURE.general], - globalSearchKeywords: [ - i18n.translate('xpack.securitySolution.appLinks.dashboards', { - defaultMessage: 'Dashboards', - }), - ], - links: [overviewLinks, detectionResponseLinks], - skipUrlState: true, - hideTimeline: true, -}; diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 4b49c04f295a5..1716e08febd40 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -45,9 +45,11 @@ import { DETECTION_ENGINE_INDEX_URL, SERVER_APP_ID, SOURCERER_API_URL, + ENABLE_GROUPED_NAVIGATION, } from '../common/constants'; -import { getDeepLinks } from './app/deep_links'; +import { getDeepLinks, registerDeepLinksUpdater } from './app/deep_links'; +import { AppLinkItems, subscribeAppLinks, updateAppLinks } from './common/links'; import { getSubPluginRoutesByCapabilities, manageOldSiemRoutes } from './helpers'; import { SecurityAppStore } from './common/store/store'; import { licenseService } from './common/hooks/use_license'; @@ -140,7 +142,6 @@ export class Plugin implements IPlugin { // required to show the alert table inside cases const { alertsTableConfigurationRegistry } = plugins.triggersActionsUi; @@ -171,7 +172,15 @@ export class Plugin implements IPlugin { const [coreStart] = await core.getStartServices(); - manageOldSiemRoutes(coreStart); + + const subscription = subscribeAppLinks((links: AppLinkItems) => { + // It has to be called once after deep links are initialized + if (links.length > 0) { + manageOldSiemRoutes(coreStart); + subscription.unsubscribe(); + } + }); + return () => true; }, }); @@ -220,35 +229,65 @@ export class Plugin implements IPlugin { - if (currentLicense.type !== undefined) { - this.appUpdater$.next(() => ({ - navLinkStatus: AppNavLinkStatus.hidden, // workaround to prevent main navLink to switch to visible after update. should not be needed - deepLinks: getDeepLinks( - this.experimentalFeatures, - currentLicense.type, - core.application.capabilities - ), - })); + + if (newNavEnabled) { + registerDeepLinksUpdater(this.appUpdater$); + } + + // Not using await to prevent blocking start execution + this.lazyApplicationLinks().then(({ getAppLinks }) => { + getAppLinks(core, plugins).then((appLinks) => { + if (licensing !== null) { + this.licensingSubscription = licensing.subscribe((currentLicense) => { + if (currentLicense.type !== undefined) { + updateAppLinks(appLinks, { + experimentalFeatures: this.experimentalFeatures, + license: currentLicense, + capabilities: core.application.capabilities, + }); + + if (!newNavEnabled) { + // TODO: remove block when nav flag no longer needed + this.appUpdater$.next(() => ({ + navLinkStatus: AppNavLinkStatus.hidden, // workaround to prevent main navLink to switch to visible after update. should not be needed + deepLinks: getDeepLinks( + this.experimentalFeatures, + currentLicense.type, + core.application.capabilities + ), + })); + } + } + }); + } else { + updateAppLinks(appLinks, { + experimentalFeatures: this.experimentalFeatures, + capabilities: core.application.capabilities, + }); + + if (!newNavEnabled) { + // TODO: remove block when nav flag no longer needed + this.appUpdater$.next(() => ({ + navLinkStatus: AppNavLinkStatus.hidden, // workaround to prevent main navLink to switch to visible after update. should not be needed + deepLinks: getDeepLinks( + this.experimentalFeatures, + undefined, + core.application.capabilities + ), + })); + } } }); - } else { - this.appUpdater$.next(() => ({ - navLinkStatus: AppNavLinkStatus.hidden, // workaround to prevent main navLink to switch to visible after update. should not be needed - deepLinks: getDeepLinks( - this.experimentalFeatures, - undefined, - core.application.capabilities - ), - })); - } + }); return {}; } @@ -296,11 +335,22 @@ export class Plugin implements IPlugin Date: Fri, 20 May 2022 16:27:14 +0300 Subject: [PATCH 10/39] [XY] `pointsRadius`, `showPoints` and `lineWidth`. (#130391) * Added pointsRadius, showPoints and lineWidth. * Added tests. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../extended_data_layer.test.ts.snap | 6 ++ .../common_data_layer_args.ts | 12 +++ .../extended_data_layer.test.ts | 94 ++++++++++++------- .../extended_data_layer_fn.ts | 10 +- .../reference_line_layer.ts | 17 +--- .../reference_line_layer_fn.ts | 24 +++++ .../common/expression_functions/validate.ts | 51 ++++++++++ .../expression_functions/xy_vis.test.ts | 1 + .../common/expression_functions/xy_vis_fn.ts | 12 +++ .../expression_xy/common/i18n/index.tsx | 12 +++ .../common/types/expression_functions.ts | 8 +- .../public/components/xy_chart.test.tsx | 69 ++++++++++++++ .../public/helpers/data_layers.tsx | 56 ++++++++--- 13 files changed, 313 insertions(+), 59 deletions(-) create mode 100644 src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer_fn.ts diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap index 68262f8a4f3de..9abd76c669b8f 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap @@ -1,5 +1,11 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`extendedDataLayerConfig throws the error if lineWidth is provided to the not line/area chart 1`] = `"\`lineWidth\` can be applied only for line or area charts"`; + exports[`extendedDataLayerConfig throws the error if markSizeAccessor doesn't have the corresponding column in the table 1`] = `"Provided column name or index is invalid: nonsense"`; exports[`extendedDataLayerConfig throws the error if markSizeAccessor is provided to the not line/area chart 1`] = `"\`markSizeAccessor\` can't be used. Dots are applied only for line or area charts"`; + +exports[`extendedDataLayerConfig throws the error if pointsRadius is provided to the not line/area chart 1`] = `"\`pointsRadius\` can be applied only for line or area charts"`; + +exports[`extendedDataLayerConfig throws the error if showPoints is provided to the not line/area chart 1`] = `"\`showPoints\` can be applied only for line or area charts"`; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts index f4543c5236ce2..c7f2da8ec1543 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts @@ -43,6 +43,18 @@ export const commonDataLayerArgs: Omit< default: false, help: strings.getIsHistogramHelp(), }, + lineWidth: { + types: ['number'], + help: strings.getLineWidthHelp(), + }, + showPoints: { + types: ['boolean'], + help: strings.getShowPointsHelp(), + }, + pointsRadius: { + types: ['number'], + help: strings.getPointsRadiusHelp(), + }, yConfig: { types: [Y_CONFIG], help: strings.getYConfigHelp(), diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts index 5b943b0790313..7f513168a8607 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts @@ -13,62 +13,92 @@ import { LayerTypes } from '../constants'; import { extendedDataLayerFunction } from './extended_data_layer'; describe('extendedDataLayerConfig', () => { + const args: ExtendedDataLayerArgs = { + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + xScaleType: 'linear', + isHistogram: false, + palette: mockPaletteOutput, + }; + test('produces the correct arguments', async () => { const { data } = sampleArgs(); - const args: ExtendedDataLayerArgs = { - seriesType: 'line', - xAccessor: 'c', - accessors: ['a', 'b'], - splitAccessor: 'd', - xScaleType: 'linear', - isHistogram: false, - palette: mockPaletteOutput, + const fullArgs: ExtendedDataLayerArgs = { + ...args, markSizeAccessor: 'b', + showPoints: true, + lineWidth: 10, + pointsRadius: 10, }; - const result = await extendedDataLayerFunction.fn(data, args, createMockExecutionContext()); + const result = await extendedDataLayerFunction.fn(data, fullArgs, createMockExecutionContext()); expect(result).toEqual({ type: 'extendedDataLayer', layerType: LayerTypes.DATA, - ...args, + ...fullArgs, table: data, }); }); test('throws the error if markSizeAccessor is provided to the not line/area chart', async () => { const { data } = sampleArgs(); - const args: ExtendedDataLayerArgs = { - seriesType: 'bar', - xAccessor: 'c', - accessors: ['a', 'b'], - splitAccessor: 'd', - xScaleType: 'linear', - isHistogram: false, - palette: mockPaletteOutput, - markSizeAccessor: 'b', - }; expect( - extendedDataLayerFunction.fn(data, args, createMockExecutionContext()) + extendedDataLayerFunction.fn( + data, + { ...args, seriesType: 'bar', markSizeAccessor: 'b' }, + createMockExecutionContext() + ) ).rejects.toThrowErrorMatchingSnapshot(); }); test("throws the error if markSizeAccessor doesn't have the corresponding column in the table", async () => { const { data } = sampleArgs(); - const args: ExtendedDataLayerArgs = { - seriesType: 'line', - xAccessor: 'c', - accessors: ['a', 'b'], - splitAccessor: 'd', - xScaleType: 'linear', - isHistogram: false, - palette: mockPaletteOutput, - markSizeAccessor: 'nonsense', - }; expect( - extendedDataLayerFunction.fn(data, args, createMockExecutionContext()) + extendedDataLayerFunction.fn( + data, + { ...args, markSizeAccessor: 'nonsense' }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('throws the error if lineWidth is provided to the not line/area chart', async () => { + const { data } = sampleArgs(); + expect( + extendedDataLayerFunction.fn( + data, + { ...args, seriesType: 'bar', lineWidth: 10 }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('throws the error if showPoints is provided to the not line/area chart', async () => { + const { data } = sampleArgs(); + + expect( + extendedDataLayerFunction.fn( + data, + { ...args, seriesType: 'bar', showPoints: true }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('throws the error if pointsRadius is provided to the not line/area chart', async () => { + const { data } = sampleArgs(); + + expect( + extendedDataLayerFunction.fn( + data, + { ...args, seriesType: 'bar', pointsRadius: 10 }, + createMockExecutionContext() + ) ).rejects.toThrowErrorMatchingSnapshot(); }); }); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts index 8e5019e065133..f45aea7e86d8d 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts @@ -10,7 +10,12 @@ import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; import { ExtendedDataLayerArgs, ExtendedDataLayerFn } from '../types'; import { EXTENDED_DATA_LAYER, LayerTypes } from '../constants'; import { getAccessors, normalizeTable } from '../helpers'; -import { validateMarkSizeForChartType } from './validate'; +import { + validateLineWidthForChartType, + validateMarkSizeForChartType, + validatePointsRadiusForChartType, + validateShowPointsForChartType, +} from './validate'; export const extendedDataLayerFn: ExtendedDataLayerFn['fn'] = async (data, args, context) => { const table = args.table ?? data; @@ -21,6 +26,9 @@ export const extendedDataLayerFn: ExtendedDataLayerFn['fn'] = async (data, args, accessors.accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); validateMarkSizeForChartType(args.markSizeAccessor, args.seriesType); validateAccessor(args.markSizeAccessor, table.columns); + validateLineWidthForChartType(args.lineWidth, args.seriesType); + validateShowPointsForChartType(args.showPoints, args.seriesType); + validatePointsRadiusForChartType(args.pointsRadius, args.seriesType); const normalizedTable = normalizeTable(table, accessors.xAccessor); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts index 6b51edd2d209e..234001015d73a 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts @@ -6,8 +6,7 @@ * Side Public License, v 1. */ -import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; -import { LayerTypes, REFERENCE_LINE_LAYER, EXTENDED_Y_CONFIG } from '../constants'; +import { REFERENCE_LINE_LAYER, EXTENDED_Y_CONFIG } from '../constants'; import { ReferenceLineLayerFn } from '../types'; import { strings } from '../i18n'; @@ -41,16 +40,8 @@ export const referenceLineLayerFunction: ReferenceLineLayerFn = { help: strings.getLayerIdHelp(), }, }, - fn(input, args) { - const table = args.table ?? input; - const accessors = args.accessors ?? []; - accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); - - return { - type: REFERENCE_LINE_LAYER, - ...args, - layerType: LayerTypes.REFERENCELINE, - table: args.table ?? input, - }; + async fn(input, args, context) { + const { referenceLineLayerFn } = await import('./reference_line_layer_fn'); + return await referenceLineLayerFn(input, args, context); }, }; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer_fn.ts new file mode 100644 index 0000000000000..8b6d1cc531447 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer_fn.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; +import { LayerTypes, REFERENCE_LINE_LAYER } from '../constants'; +import { ReferenceLineLayerFn } from '../types'; + +export const referenceLineLayerFn: ReferenceLineLayerFn['fn'] = async (input, args, handlers) => { + const table = args.table ?? input; + const accessors = args.accessors ?? []; + accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); + + return { + type: REFERENCE_LINE_LAYER, + ...args, + layerType: LayerTypes.REFERENCELINE, + table: args.table ?? input, + }; +}; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts index df7f9ee08632e..de01b149802b9 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts @@ -34,6 +34,27 @@ export const errors = { i18n.translate('expressionXY.reusable.function.xyVis.errors.markSizeLimitsError', { defaultMessage: 'Mark size ratio must be greater or equal to 1 and less or equal to 100', }), + lineWidthForNonLineOrAreaChartError: () => + i18n.translate( + 'expressionXY.reusable.function.xyVis.errors.lineWidthForNonLineOrAreaChartError', + { + defaultMessage: '`lineWidth` can be applied only for line or area charts', + } + ), + showPointsForNonLineOrAreaChartError: () => + i18n.translate( + 'expressionXY.reusable.function.xyVis.errors.showPointsForNonLineOrAreaChartError', + { + defaultMessage: '`showPoints` can be applied only for line or area charts', + } + ), + pointsRadiusForNonLineOrAreaChartError: () => + i18n.translate( + 'expressionXY.reusable.function.xyVis.errors.pointsRadiusForNonLineOrAreaChartError', + { + defaultMessage: '`pointsRadius` can be applied only for line or area charts', + } + ), markSizeRatioWithoutAccessor: () => i18n.translate('expressionXY.reusable.function.xyVis.errors.markSizeRatioWithoutAccessor', { defaultMessage: 'Mark size ratio can be applied only with `markSizeAccessor`', @@ -140,6 +161,9 @@ export const validateValueLabels = ( } }; +const isAreaOrLineChart = (seriesType: SeriesType) => + seriesType.includes('line') || seriesType.includes('area'); + export const validateAddTimeMarker = ( dataLayers: Array, addTimeMarker?: boolean @@ -164,6 +188,33 @@ export const validateMarkSizeRatioLimits = (markSizeRatio?: number) => { } }; +export const validateLineWidthForChartType = ( + lineWidth: number | undefined, + seriesType: SeriesType +) => { + if (lineWidth !== undefined && !isAreaOrLineChart(seriesType)) { + throw new Error(errors.lineWidthForNonLineOrAreaChartError()); + } +}; + +export const validateShowPointsForChartType = ( + showPoints: boolean | undefined, + seriesType: SeriesType +) => { + if (showPoints !== undefined && !isAreaOrLineChart(seriesType)) { + throw new Error(errors.showPointsForNonLineOrAreaChartError()); + } +}; + +export const validatePointsRadiusForChartType = ( + pointsRadius: number | undefined, + seriesType: SeriesType +) => { + if (pointsRadius !== undefined && !isAreaOrLineChart(seriesType)) { + throw new Error(errors.pointsRadiusForNonLineOrAreaChartError()); + } +}; + export const validateMarkSizeRatioWithAccessor = ( markSizeRatio: number | undefined, markSizeAccessor: ExpressionValueVisDimension | string | undefined diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts index 8a327ccca9e20..174ff908eeaa1 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts @@ -65,6 +65,7 @@ describe('xyVis', () => { ) ).rejects.toThrowErrorMatchingSnapshot(); }); + test('it should throw error if minTimeBarInterval is invalid', async () => { const { data, args } = sampleArgs(); const { layers, ...rest } = args; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts index 4c25e3378d523..afe569a86f894 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts @@ -29,6 +29,9 @@ import { validateMinTimeBarInterval, validateMarkSizeForChartType, validateMarkSizeRatioWithAccessor, + validateShowPointsForChartType, + validateLineWidthForChartType, + validatePointsRadiusForChartType, } from './validate'; const createDataLayer = (args: XYArgs, table: Datatable): DataLayerConfigResult => { @@ -43,6 +46,9 @@ const createDataLayer = (args: XYArgs, table: Datatable): DataLayerConfigResult isHistogram: args.isHistogram, palette: args.palette, yConfig: args.yConfig, + showPoints: args.showPoints, + pointsRadius: args.pointsRadius, + lineWidth: args.lineWidth, layerType: LayerTypes.DATA, table: normalizedTable, ...accessors, @@ -68,6 +74,9 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { yConfig, palette, markSizeAccessor, + showPoints, + pointsRadius, + lineWidth, ...restArgs } = args; @@ -116,6 +125,9 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { validateValueLabels(args.valueLabels, hasBar, hasNotHistogramBars); validateMarkSizeRatioWithAccessor(args.markSizeRatio, dataLayers[0].markSizeAccessor); validateMarkSizeRatioLimits(args.markSizeRatio); + validateLineWidthForChartType(lineWidth, args.seriesType); + validateShowPointsForChartType(showPoints, args.seriesType); + validatePointsRadiusForChartType(pointsRadius, args.seriesType); return { type: 'render', diff --git a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx index ed2ef4a7a57ce..4f94d5805396d 100644 --- a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx +++ b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx @@ -181,6 +181,18 @@ export const strings = { i18n.translate('expressionXY.dataLayer.markSizeAccessor.help', { defaultMessage: 'Mark size accessor', }), + getLineWidthHelp: () => + i18n.translate('expressionXY.dataLayer.lineWidth.help', { + defaultMessage: 'Line width', + }), + getShowPointsHelp: () => + i18n.translate('expressionXY.dataLayer.showPoints.help', { + defaultMessage: 'Show points', + }), + getPointsRadiusHelp: () => + i18n.translate('expressionXY.dataLayer.pointsRadius.help', { + defaultMessage: 'Points radius', + }), getYConfigHelp: () => i18n.translate('expressionXY.dataLayer.yConfig.help', { defaultMessage: 'Additional configuration for y axes', diff --git a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts index c0336fc67536f..05447607bc194 100644 --- a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts @@ -102,6 +102,9 @@ export interface DataLayerArgs { hide?: boolean; splitAccessor?: string | ExpressionValueVisDimension; markSizeAccessor?: string | ExpressionValueVisDimension; + lineWidth?: number; + showPoints?: boolean; + pointsRadius?: number; columnToLabel?: string; // Actually a JSON key-value pair xScaleType: XScaleType; isHistogram: boolean; @@ -121,6 +124,9 @@ export interface ExtendedDataLayerArgs { hide?: boolean; splitAccessor?: string; markSizeAccessor?: string; + lineWidth?: number; + showPoints?: boolean; + pointsRadius?: number; columnToLabel?: string; // Actually a JSON key-value pair xScaleType: XScaleType; isHistogram: boolean; @@ -416,7 +422,7 @@ export type ReferenceLineLayerFn = ExpressionFunctionDefinition< typeof REFERENCE_LINE_LAYER, Datatable, ReferenceLineLayerArgs, - ReferenceLineLayerConfigResult + Promise >; export type YConfigFn = ExpressionFunctionDefinition; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx index 91e5ae8ad1484..f46213fe41ba3 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx @@ -722,6 +722,75 @@ describe('XYChart component', () => { expect(lineArea.prop('lineSeriesStyle')).toEqual(expectedSeriesStyle); }); + test('applies the line width to the chart', () => { + const { args } = sampleArgs(); + const lineWidthArg = { lineWidth: 10 }; + const component = shallow( + + ); + const dataLayers = component.find(DataLayers).dive(); + const lineArea = dataLayers.find(LineSeries).at(0); + const expectedSeriesStyle = expect.objectContaining({ + line: { strokeWidth: lineWidthArg.lineWidth }, + }); + + expect(lineArea.prop('areaSeriesStyle')).toEqual(expectedSeriesStyle); + expect(lineArea.prop('lineSeriesStyle')).toEqual(expectedSeriesStyle); + }); + + test('applies showPoints to the chart', () => { + const checkIfPointsVisibilityIsApplied = (showPoints: boolean) => { + const { args } = sampleArgs(); + const component = shallow( + + ); + const dataLayers = component.find(DataLayers).dive(); + const lineArea = dataLayers.find(LineSeries).at(0); + const expectedSeriesStyle = expect.objectContaining({ + point: expect.objectContaining({ + visible: showPoints, + }), + }); + expect(lineArea.prop('areaSeriesStyle')).toEqual(expectedSeriesStyle); + expect(lineArea.prop('lineSeriesStyle')).toEqual(expectedSeriesStyle); + }; + + checkIfPointsVisibilityIsApplied(true); + checkIfPointsVisibilityIsApplied(false); + }); + + test('applies point radius to the chart', () => { + const pointsRadius = 10; + const { args } = sampleArgs(); + const component = shallow( + + ); + const dataLayers = component.find(DataLayers).dive(); + const lineArea = dataLayers.find(LineSeries).at(0); + const expectedSeriesStyle = expect.objectContaining({ + point: expect.objectContaining({ + radius: pointsRadius, + }), + }); + expect(lineArea.prop('areaSeriesStyle')).toEqual(expectedSeriesStyle); + expect(lineArea.prop('lineSeriesStyle')).toEqual(expectedSeriesStyle); + }); + test('it renders bar', () => { const { args } = sampleArgs(); const component = shallow( diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx b/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx index 08761f633f851..34e5e36091ae1 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx @@ -8,6 +8,7 @@ import { AreaSeriesProps, + AreaSeriesStyle, BarSeriesProps, ColorVariant, LineSeriesProps, @@ -80,6 +81,14 @@ type GetColorFn = ( } ) => string | null; +type GetLineConfigFn = (config: { + xAccessor: string | undefined; + markSizeAccessor: string | undefined; + emphasizeFitting?: boolean; + showPoints?: boolean; + pointsRadius?: number; +}) => Partial; + export interface DatatableWithFormatInfo { table: Datatable; formattedColumns: Record; @@ -227,17 +236,26 @@ const getSeriesName: GetSeriesNameFn = ( return splitColumnId ? data.seriesKeys[0] : columnToLabelMap[data.seriesKeys[0]] ?? null; }; -const getPointConfig = ( - xAccessor: string | undefined, - markSizeAccessor: string | undefined, - emphasizeFitting?: boolean -) => ({ - visible: !xAccessor || markSizeAccessor !== undefined, - radius: xAccessor && !emphasizeFitting ? 5 : 0, +const getPointConfig: GetLineConfigFn = ({ + xAccessor, + markSizeAccessor, + emphasizeFitting, + showPoints, + pointsRadius, +}) => ({ + visible: showPoints !== undefined ? showPoints : !xAccessor || markSizeAccessor !== undefined, + radius: pointsRadius !== undefined ? pointsRadius : xAccessor && !emphasizeFitting ? 5 : 0, fill: markSizeAccessor ? ColorVariant.Series : undefined, }); -const getLineConfig = () => ({ visible: true, stroke: ColorVariant.Series, opacity: 1, dash: [] }); +const getFitLineConfig = () => ({ + visible: true, + stroke: ColorVariant.Series, + opacity: 1, + dash: [], +}); + +const getLineConfig = (strokeWidth?: number) => ({ strokeWidth }); const getColor: GetColorFn = ( { yAccessor, seriesKeys }, @@ -363,15 +381,29 @@ export const getSeriesProps: GetSeriesPropsFn = ({ stackMode: isPercentage ? StackMode.Percentage : undefined, timeZone, areaSeriesStyle: { - point: getPointConfig(xColumnId, markSizeColumnId, emphasizeFitting), + point: getPointConfig({ + xAccessor: xColumnId, + markSizeAccessor: markSizeColumnId, + emphasizeFitting, + showPoints: layer.showPoints, + pointsRadius: layer.pointsRadius, + }), ...(fillOpacity && { area: { opacity: fillOpacity } }), ...(emphasizeFitting && { - fit: { area: { opacity: fillOpacity || 0.5 }, line: getLineConfig() }, + fit: { area: { opacity: fillOpacity || 0.5 }, line: getFitLineConfig() }, }), + line: getLineConfig(layer.lineWidth), }, lineSeriesStyle: { - point: getPointConfig(xColumnId, markSizeColumnId, emphasizeFitting), - ...(emphasizeFitting && { fit: { line: getLineConfig() } }), + point: getPointConfig({ + xAccessor: xColumnId, + markSizeAccessor: markSizeColumnId, + emphasizeFitting, + showPoints: layer.showPoints, + pointsRadius: layer.pointsRadius, + }), + ...(emphasizeFitting && { fit: { line: getFitLineConfig() } }), + line: getLineConfig(layer.lineWidth), }, name(d) { return getSeriesName(d, { From 2e51140d9c297abfd6394d61bff85aa0b93d9006 Mon Sep 17 00:00:00 2001 From: Katerina Patticha Date: Fri, 20 May 2022 15:34:29 +0200 Subject: [PATCH 11/39] Show service group icon only when there are service groups (#131138) * Show service group icon when there are service groups * Fix fix errors * Remove additional request and display icon only for the service groups * Revert "Remove additional request and display icon only for the service groups" This reverts commit 7ff2bc97f48914a4487998e6e66370ad8beba506. * Add dependencies Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../templates/service_group_template.tsx | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/x-pack/plugins/apm/public/components/routing/templates/service_group_template.tsx b/x-pack/plugins/apm/public/components/routing/templates/service_group_template.tsx index bcf0b44814089..006b3cc67bd5e 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/service_group_template.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/service_group_template.tsx @@ -11,6 +11,7 @@ import { EuiFlexItem, EuiButtonIcon, EuiLoadingContent, + EuiLoadingSpinner, } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; @@ -19,7 +20,7 @@ import { KibanaPageTemplateProps, } from '@kbn/kibana-react-plugin/public'; import { enableServiceGroups } from '@kbn/observability-plugin/public'; -import { useFetcher } from '../../../hooks/use_fetcher'; +import { useFetcher, FETCH_STATUS } from '../../../hooks/use_fetcher'; import { ApmPluginStartDeps } from '../../../plugin'; import { useApmRouter } from '../../../hooks/use_apm_router'; import { useAnyOfApmParams } from '../../../hooks/use_apm_params'; @@ -51,17 +52,29 @@ export function ServiceGroupTemplate({ query: { serviceGroup: serviceGroupId }, } = useAnyOfApmParams('/services', '/service-map'); - const { data } = useFetcher((callApmApi) => { - if (serviceGroupId) { - return callApmApi('GET /internal/apm/service-group', { - params: { query: { serviceGroup: serviceGroupId } }, - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const { data } = useFetcher( + (callApmApi) => { + if (serviceGroupId) { + return callApmApi('GET /internal/apm/service-group', { + params: { query: { serviceGroup: serviceGroupId } }, + }); + } + }, + [serviceGroupId] + ); + + const { data: serviceGroupsData, status: serviceGroupsStatus } = useFetcher( + (callApmApi) => { + if (!serviceGroupId && isServiceGroupsEnabled) { + return callApmApi('GET /internal/apm/service-groups'); + } + }, + [serviceGroupId, isServiceGroupsEnabled] + ); const serviceGroupName = data?.serviceGroup.groupName; const loadingServiceGroupName = !!serviceGroupId && !serviceGroupName; + const hasServiceGroups = !!serviceGroupsData?.serviceGroups.length; const serviceGroupsLink = router.link('/service-groups', { query: { ...query, serviceGroup: '' }, }); @@ -74,15 +87,22 @@ export function ServiceGroupTemplate({ justifyContent="flexStart" responsive={false} > - - - + {serviceGroupsStatus === FETCH_STATUS.LOADING && ( + + + + )} + {(serviceGroupId || hasServiceGroups) && ( + + + + )} {loadingServiceGroupName ? ( From 7c37eda9ed8dfc7dd50b506ee57315a0babd779a Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Fri, 20 May 2022 15:42:28 +0200 Subject: [PATCH 12/39] [Osquery] Fix pagination issue on Alert's Osquery Flyout (#132611) --- x-pack/plugins/osquery/public/results/results_table.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/osquery/public/results/results_table.tsx b/x-pack/plugins/osquery/public/results/results_table.tsx index 229714eaaed99..ae0baaea7f586 100644 --- a/x-pack/plugins/osquery/public/results/results_table.tsx +++ b/x-pack/plugins/osquery/public/results/results_table.tsx @@ -315,8 +315,11 @@ const ResultsTableComponent: React.FC = ({ id: 'timeline', width: 38, headerCellRender: () => null, - rowCellRender: (actionProps: EuiDataGridCellValueElementProps) => { - const eventId = data[actionProps.rowIndex]._id; + rowCellRender: (actionProps) => { + const { visibleRowIndex } = actionProps as EuiDataGridCellValueElementProps & { + visibleRowIndex: number; + }; + const eventId = data[visibleRowIndex]._id; return addToTimeline({ query: ['_id', eventId], isIcon: true }); }, From 1d8bc7ede1e6e9aa4415adabfdc457a629e5cf6e Mon Sep 17 00:00:00 2001 From: Shivindera Singh Date: Fri, 20 May 2022 15:53:00 +0200 Subject: [PATCH 13/39] hasData service - hit search api in case of an error with resolve api (#132618) --- src/plugins/data_views/public/index.ts | 1 + .../data_views/public/services/has_data.ts | 61 ++++++++++++++++--- src/plugins/data_views/public/types.ts | 4 ++ 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/plugins/data_views/public/index.ts b/src/plugins/data_views/public/index.ts index f6a0843babed6..5b14ca9d25030 100644 --- a/src/plugins/data_views/public/index.ts +++ b/src/plugins/data_views/public/index.ts @@ -57,6 +57,7 @@ export type { HasDataViewsResponse, IndicesResponse, IndicesResponseModified, + IndicesViaSearchResponse, } from './types'; // Export plugin after all other imports diff --git a/src/plugins/data_views/public/services/has_data.ts b/src/plugins/data_views/public/services/has_data.ts index 76f6b39ec4982..d10f6a3d446f8 100644 --- a/src/plugins/data_views/public/services/has_data.ts +++ b/src/plugins/data_views/public/services/has_data.ts @@ -8,7 +8,12 @@ import { CoreStart, HttpStart } from '@kbn/core/public'; import { DEFAULT_ASSETS_TO_IGNORE } from '../../common'; -import { HasDataViewsResponse, IndicesResponse, IndicesResponseModified } from '..'; +import { + HasDataViewsResponse, + IndicesResponse, + IndicesResponseModified, + IndicesViaSearchResponse, +} from '..'; export class HasData { private removeAliases = (source: IndicesResponseModified): boolean => !source.item.indices; @@ -77,6 +82,41 @@ export class HasData { return source; }; + private getIndicesViaSearch = async ({ + http, + pattern, + showAllIndices, + }: { + http: HttpStart; + pattern: string; + showAllIndices: boolean; + }): Promise => + http + .post(`/internal/search/ese`, { + body: JSON.stringify({ + params: { + ignore_unavailable: true, + expand_wildcards: showAllIndices ? 'all' : 'open', + index: pattern, + body: { + size: 0, // no hits + aggs: { + indices: { + terms: { + field: '_index', + size: 200, + }, + }, + }, + }, + }, + }), + }) + .then((resp) => { + return !!(resp && resp.total >= 0); + }) + .catch(() => false); + private getIndices = async ({ http, pattern, @@ -96,26 +136,29 @@ export class HasData { } else { return this.responseToItemArray(response); } - }) - .catch(() => []); + }); private checkLocalESData = (http: HttpStart): Promise => this.getIndices({ http, pattern: '*', showAllIndices: false, - }).then((dataSources: IndicesResponseModified[]) => { - return dataSources.some(this.isUserDataIndex); - }); + }) + .then((dataSources: IndicesResponseModified[]) => { + return dataSources.some(this.isUserDataIndex); + }) + .catch(() => this.getIndicesViaSearch({ http, pattern: '*', showAllIndices: false })); private checkRemoteESData = (http: HttpStart): Promise => this.getIndices({ http, pattern: '*:*', showAllIndices: false, - }).then((dataSources: IndicesResponseModified[]) => { - return !!dataSources.filter(this.removeAliases).length; - }); + }) + .then((dataSources: IndicesResponseModified[]) => { + return !!dataSources.filter(this.removeAliases).length; + }) + .catch(() => this.getIndicesViaSearch({ http, pattern: '*:*', showAllIndices: false })); // Data Views diff --git a/src/plugins/data_views/public/types.ts b/src/plugins/data_views/public/types.ts index 612f22335e72a..f2d34961ab6e0 100644 --- a/src/plugins/data_views/public/types.ts +++ b/src/plugins/data_views/public/types.ts @@ -56,6 +56,10 @@ export interface IndicesResponse { data_streams?: IndicesResponseItemDataStream[]; } +export interface IndicesViaSearchResponse { + total: number; +} + export interface HasDataViewsResponse { hasDataView: boolean; hasUserDataView: boolean; From d34408876a67c7158f972f9ec0e493fe4f9a4e7b Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 20 May 2022 08:06:25 -0600 Subject: [PATCH 14/39] [maps] Use label features from ES vector tile search API to fix multiple labels (#132080) * [maps] mvt labels * eslint * only request labels when needed * update vector tile integration tests for hasLabels parameter * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * fix tests * fix test * only add _mvt_label_position filter when vector tiles are from ES vector tile search API * review feedback * include hasLabels in source data * fix jest test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/maps/common/mvt_request_body.ts | 6 ++ .../layers/heatmap_layer/heatmap_layer.ts | 1 + .../mvt_vector_layer/mvt_source_data.test.ts | 57 +++++++++++++++++++ .../mvt_vector_layer/mvt_source_data.ts | 11 +++- .../mvt_vector_layer/mvt_vector_layer.tsx | 1 + .../layers/vector_layer/vector_layer.tsx | 4 ++ .../es_geo_grid_source.test.ts | 4 +- .../es_geo_grid_source/es_geo_grid_source.tsx | 7 ++- .../es_search_source/es_search_source.test.ts | 4 +- .../es_search_source/es_search_source.tsx | 7 ++- .../vector_source/mvt_vector_source.ts | 6 +- .../classes/styles/vector/style_util.ts | 4 +- .../classes/styles/vector/vector_style.tsx | 14 ++++- .../classes/util/mb_filter_expressions.ts | 23 +++++--- .../components/get_tile_request.test.ts | 6 +- .../components/get_tile_request.ts | 6 ++ x-pack/plugins/maps/server/mvt/mvt_routes.ts | 4 ++ .../apis/maps/get_grid_tile.js | 37 ++++++++++++ .../api_integration/apis/maps/get_tile.js | 47 +++++++++++++++ .../apps/maps/group4/mvt_geotile_grid.js | 1 + .../apps/maps/group4/mvt_scaling.js | 1 + 21 files changed, 229 insertions(+), 22 deletions(-) diff --git a/x-pack/plugins/maps/common/mvt_request_body.ts b/x-pack/plugins/maps/common/mvt_request_body.ts index e5517b23e0cba..c2d367f89fa8a 100644 --- a/x-pack/plugins/maps/common/mvt_request_body.ts +++ b/x-pack/plugins/maps/common/mvt_request_body.ts @@ -21,6 +21,7 @@ export function getAggsTileRequest({ encodedRequestBody, geometryFieldName, gridPrecision, + hasLabels, index, renderAs = RENDER_AS.POINT, x, @@ -30,6 +31,7 @@ export function getAggsTileRequest({ encodedRequestBody: string; geometryFieldName: string; gridPrecision: number; + hasLabels: boolean; index: string; renderAs: RENDER_AS; x: number; @@ -50,6 +52,7 @@ export function getAggsTileRequest({ aggs: requestBody.aggs, fields: requestBody.fields, runtime_mappings: requestBody.runtime_mappings, + with_labels: hasLabels, }, }; } @@ -57,6 +60,7 @@ export function getAggsTileRequest({ export function getHitsTileRequest({ encodedRequestBody, geometryFieldName, + hasLabels, index, x, y, @@ -64,6 +68,7 @@ export function getHitsTileRequest({ }: { encodedRequestBody: string; geometryFieldName: string; + hasLabels: boolean; index: string; x: number; y: number; @@ -86,6 +91,7 @@ export function getHitsTileRequest({ ), runtime_mappings: requestBody.runtime_mappings, track_total_hits: typeof requestBody.size === 'number' ? requestBody.size + 1 : false, + with_labels: hasLabels, }, }; } diff --git a/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts b/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts index e796ecad332ca..ec9cec3a914ba 100644 --- a/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts @@ -87,6 +87,7 @@ export class HeatmapLayer extends AbstractLayer { async syncData(syncContext: DataRequestContext) { await syncMvtSourceData({ + hasLabels: false, layerId: this.getId(), layerName: await this.getDisplayName(), prevDataRequest: this.getSourceDataRequest(), diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts index 1f710879d9dd7..dae0f5343dcc9 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts @@ -52,6 +52,7 @@ describe('syncMvtSourceData', () => { const syncContext = new MockSyncContext({ dataFilters: {} }); await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: undefined, @@ -82,6 +83,7 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'aggs', tileUrl: 'https://example.com/{x}/{y}/{z}.pbf', refreshToken: '12345', + hasLabels: false, }); }); @@ -99,6 +101,7 @@ describe('syncMvtSourceData', () => { }; await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: { @@ -112,6 +115,7 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'aggs', tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', refreshToken: '12345', + hasLabels: false, }; }, } as unknown as DataRequest, @@ -142,6 +146,7 @@ describe('syncMvtSourceData', () => { }; await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: { @@ -155,6 +160,7 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'aggs', tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', refreshToken: '12345', + hasLabels: false, }; }, } as unknown as DataRequest, @@ -182,6 +188,7 @@ describe('syncMvtSourceData', () => { }; await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: { @@ -195,6 +202,7 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'aggs', tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', refreshToken: '12345', + hasLabels: false, }; }, } as unknown as DataRequest, @@ -230,6 +238,7 @@ describe('syncMvtSourceData', () => { }; await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: { @@ -243,6 +252,7 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'barfoo', // tileSourceLayer is different then mockSource tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', refreshToken: '12345', + hasLabels: false, }; }, } as unknown as DataRequest, @@ -270,6 +280,7 @@ describe('syncMvtSourceData', () => { }; await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: { @@ -283,6 +294,7 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'aggs', tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', refreshToken: '12345', + hasLabels: false, }; }, } as unknown as DataRequest, @@ -310,6 +322,7 @@ describe('syncMvtSourceData', () => { }; await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: { @@ -323,6 +336,49 @@ describe('syncMvtSourceData', () => { tileSourceLayer: 'aggs', tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', refreshToken: '12345', + hasLabels: false, + }; + }, + } as unknown as DataRequest, + requestMeta: { ...prevRequestMeta }, + source: mockSource, + syncContext, + }); + // @ts-expect-error + sinon.assert.calledOnce(syncContext.startLoading); + // @ts-expect-error + sinon.assert.calledOnce(syncContext.stopLoading); + }); + + test('Should re-sync when hasLabel state changes', async () => { + const syncContext = new MockSyncContext({ dataFilters: {} }); + const prevRequestMeta = { + ...syncContext.dataFilters, + applyGlobalQuery: true, + applyGlobalTime: true, + applyForceRefresh: true, + fieldNames: [], + sourceMeta: {}, + isForceRefresh: false, + isFeatureEditorOpenForLayer: false, + }; + + await syncMvtSourceData({ + hasLabels: true, + layerId: 'layer1', + layerName: 'my layer', + prevDataRequest: { + getMeta: () => { + return prevRequestMeta; + }, + getData: () => { + return { + tileMinZoom: 4, + tileMaxZoom: 14, + tileSourceLayer: 'aggs', + tileUrl: 'https://example.com/{x}/{y}/{z}.pbf?token=12345', + refreshToken: '12345', + hasLabels: false, }; }, } as unknown as DataRequest, @@ -340,6 +396,7 @@ describe('syncMvtSourceData', () => { const syncContext = new MockSyncContext({ dataFilters: {} }); await syncMvtSourceData({ + hasLabels: false, layerId: 'layer1', layerName: 'my layer', prevDataRequest: undefined, diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts index 76550090109a1..19ad39e41a238 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts @@ -20,9 +20,11 @@ export interface MvtSourceData { tileMaxZoom: number; tileUrl: string; refreshToken: string; + hasLabels: boolean; } export async function syncMvtSourceData({ + hasLabels, layerId, layerName, prevDataRequest, @@ -30,6 +32,7 @@ export async function syncMvtSourceData({ source, syncContext, }: { + hasLabels: boolean; layerId: string; layerName: string; prevDataRequest: DataRequest | undefined; @@ -56,7 +59,10 @@ export async function syncMvtSourceData({ }, }); const canSkip = - !syncContext.forceRefreshDueToDrawing && noChangesInSourceState && noChangesInSearchState; + !syncContext.forceRefreshDueToDrawing && + noChangesInSourceState && + noChangesInSearchState && + prevData.hasLabels === hasLabels; if (canSkip) { return; @@ -72,7 +78,7 @@ export async function syncMvtSourceData({ ? uuid() : prevData.refreshToken; - const tileUrl = await source.getTileUrl(requestMeta, refreshToken); + const tileUrl = await source.getTileUrl(requestMeta, refreshToken, hasLabels); if (source.isESSource()) { syncContext.inspectorAdapters.vectorTiles.addLayer(layerId, layerName, tileUrl); } @@ -82,6 +88,7 @@ export async function syncMvtSourceData({ tileMinZoom: source.getMinZoom(), tileMaxZoom: source.getMaxZoom(), refreshToken, + hasLabels, }; syncContext.stopLoading(SOURCE_DATA_REQUEST_ID, requestToken, sourceData, {}); } catch (error) { diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx index 462ea5b0cc8f1..7eaec94eac0a2 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx @@ -219,6 +219,7 @@ export class MvtVectorLayer extends AbstractVectorLayer { await this._syncSupportsFeatureEditing({ syncContext, source: this.getSource() }); await syncMvtSourceData({ + hasLabels: this.getCurrentStyle().hasLabels(), layerId: this.getId(), layerName: await this.getDisplayName(), prevDataRequest: this.getSourceDataRequest(), diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx index 82ca62c7f33df..73e036b105730 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx @@ -736,7 +736,10 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { } } + const isSourceGeoJson = !this.getSource().isMvt(); const filterExpr = getPointFilterExpression( + isSourceGeoJson, + this.getSource().isESSource(), this._getJoinFilterExpression(), timesliceMaskConfig ); @@ -843,6 +846,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { const isSourceGeoJson = !this.getSource().isMvt(); const filterExpr = getLabelFilterExpression( isSourceGeoJson, + this.getSource().isESSource(), this._getJoinFilterExpression(), timesliceMaskConfig ); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts index b08b95a58a495..831dc90871dff 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts @@ -306,10 +306,10 @@ describe('ESGeoGridSource', () => { }); it('getTileUrl', async () => { - const tileUrl = await mvtGeogridSource.getTileUrl(vectorSourceRequestMeta, '1234'); + const tileUrl = await mvtGeogridSource.getTileUrl(vectorSourceRequestMeta, '1234', false); expect(tileUrl).toEqual( - "rootdir/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=undefined&gridPrecision=8&requestBody=(foobar%3AES_DSL_PLACEHOLDER%2Cparams%3A('0'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'1'%3A('0'%3Asize%2C'1'%3A0)%2C'2'%3A('0'%3Afilter%2C'1'%3A!())%2C'3'%3A('0'%3Aquery)%2C'4'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'5'%3A('0'%3Aquery%2C'1'%3A(language%3AKQL%2Cquery%3A''))%2C'6'%3A('0'%3Aaggs%2C'1'%3A())))&renderAs=heatmap&token=1234" + "rootdir/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=undefined&gridPrecision=8&hasLabels=false&requestBody=(foobar%3AES_DSL_PLACEHOLDER%2Cparams%3A('0'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'1'%3A('0'%3Asize%2C'1'%3A0)%2C'2'%3A('0'%3Afilter%2C'1'%3A!())%2C'3'%3A('0'%3Aquery)%2C'4'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'5'%3A('0'%3Aquery%2C'1'%3A(language%3AKQL%2Cquery%3A''))%2C'6'%3A('0'%3Aaggs%2C'1'%3A())))&renderAs=heatmap&token=1234" ); }); }); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx index 66a07804c0105..1680b1d2fb55c 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx @@ -471,7 +471,11 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo return 'aggs'; } - async getTileUrl(searchFilters: VectorSourceRequestMeta, refreshToken: string): Promise { + async getTileUrl( + searchFilters: VectorSourceRequestMeta, + refreshToken: string, + hasLabels: boolean + ): Promise { const indexPattern = await this.getIndexPattern(); const searchSource = await this.makeSearchSource(searchFilters, 0); searchSource.setField('aggs', this.getValueAggsDsl(indexPattern)); @@ -484,6 +488,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo ?geometryFieldName=${this._descriptor.geoField}\ &index=${indexPattern.title}\ &gridPrecision=${this._getGeoGridPrecisionResolutionDelta()}\ +&hasLabels=${hasLabels}\ &requestBody=${encodeMvtResponseBody(searchSource.getSearchRequestBody())}\ &renderAs=${this._descriptor.requestType}\ &token=${refreshToken}`; diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts index 2df2e119df30c..24470ae0fade7 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts @@ -114,9 +114,9 @@ describe('ESSearchSource', () => { geoField: geoFieldName, indexPatternId: 'ipId', }); - const tileUrl = await esSearchSource.getTileUrl(searchFilters, '1234'); + const tileUrl = await esSearchSource.getTileUrl(searchFilters, '1234', false); expect(tileUrl).toBe( - `rootdir/api/maps/mvt/getTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=foobar-title-*&requestBody=(foobar%3AES_DSL_PLACEHOLDER%2Cparams%3A('0'%3A('0'%3Aindex%2C'1'%3A(fields%3A()%2Ctitle%3A'foobar-title-*'))%2C'1'%3A('0'%3Asize%2C'1'%3A1000)%2C'2'%3A('0'%3Afilter%2C'1'%3A!())%2C'3'%3A('0'%3Aquery)%2C'4'%3A('0'%3Aindex%2C'1'%3A(fields%3A()%2Ctitle%3A'foobar-title-*'))%2C'5'%3A('0'%3Aquery%2C'1'%3A(language%3AKQL%2Cquery%3A'tooltipField%3A%20foobar'))%2C'6'%3A('0'%3AfieldsFromSource%2C'1'%3A!(tooltipField%2CstyleField))%2C'7'%3A('0'%3Asource%2C'1'%3A!(tooltipField%2CstyleField))))&token=1234` + `rootdir/api/maps/mvt/getTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=foobar-title-*&hasLabels=false&requestBody=(foobar%3AES_DSL_PLACEHOLDER%2Cparams%3A('0'%3A('0'%3Aindex%2C'1'%3A(fields%3A()%2Ctitle%3A'foobar-title-*'))%2C'1'%3A('0'%3Asize%2C'1'%3A1000)%2C'2'%3A('0'%3Afilter%2C'1'%3A!())%2C'3'%3A('0'%3Aquery)%2C'4'%3A('0'%3Aindex%2C'1'%3A(fields%3A()%2Ctitle%3A'foobar-title-*'))%2C'5'%3A('0'%3Aquery%2C'1'%3A(language%3AKQL%2Cquery%3A'tooltipField%3A%20foobar'))%2C'6'%3A('0'%3AfieldsFromSource%2C'1'%3A!(tooltipField%2CstyleField))%2C'7'%3A('0'%3Asource%2C'1'%3A!(tooltipField%2CstyleField))))&token=1234` ); }); }); diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx index 52b9675cdbb39..b8982042b2365 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx @@ -810,7 +810,11 @@ export class ESSearchSource extends AbstractESSource implements IMvtVectorSource return 'hits'; } - async getTileUrl(searchFilters: VectorSourceRequestMeta, refreshToken: string): Promise { + async getTileUrl( + searchFilters: VectorSourceRequestMeta, + refreshToken: string, + hasLabels: boolean + ): Promise { const indexPattern = await this.getIndexPattern(); const indexSettings = await loadIndexSettings(indexPattern.title); @@ -847,6 +851,7 @@ export class ESSearchSource extends AbstractESSource implements IMvtVectorSource return `${mvtUrlServicePath}\ ?geometryFieldName=${this._descriptor.geoField}\ &index=${indexPattern.title}\ +&hasLabels=${hasLabels}\ &requestBody=${encodeMvtResponseBody(searchSource.getSearchRequestBody())}\ &token=${refreshToken}`; } diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/mvt_vector_source.ts b/x-pack/plugins/maps/public/classes/sources/vector_source/mvt_vector_source.ts index fca72af193ca3..c6f55436efc15 100644 --- a/x-pack/plugins/maps/public/classes/sources/vector_source/mvt_vector_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/vector_source/mvt_vector_source.ts @@ -13,7 +13,11 @@ export interface IMvtVectorSource extends IVectorSource { * IMvtVectorSource.getTileUrl returns the tile source URL. * Append refreshToken as a URL parameter to force tile re-fetch on refresh (not required) */ - getTileUrl(searchFilters: VectorSourceRequestMeta, refreshToken: string): Promise; + getTileUrl( + searchFilters: VectorSourceRequestMeta, + refreshToken: string, + hasLabels: boolean + ): Promise; /* * Tile vector sources can contain multiple layers. For example, elasticsearch _mvt tiles contain the layers "hits", "aggs", and "meta". diff --git a/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts b/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts index 5d4d5bc3ecbfb..905bc63fb078b 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts @@ -94,9 +94,9 @@ export function makeMbClampedNumberExpression({ ]; } -export function getHasLabel(label: StaticTextProperty | DynamicTextProperty) { +export function getHasLabel(label: StaticTextProperty | DynamicTextProperty): boolean { return label.isDynamic() ? label.isComplete() : (label as StaticTextProperty).getOptions().value != null && - (label as StaticTextProperty).getOptions().value.length; + (label as StaticTextProperty).getOptions().value.length > 0; } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx index d9a296031b5a1..7ce9673fdc10e 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx @@ -115,6 +115,12 @@ export interface IVectorStyle extends IStyle { mbMap: MbMap, mbSourceId: string ) => boolean; + + /* + * Returns true when "Label" style configuration is complete and map shows a label for layer features. + */ + hasLabels: () => boolean; + arePointsSymbolizedAsCircles: () => boolean; setMBPaintProperties: ({ alpha, @@ -674,14 +680,14 @@ export class VectorStyle implements IVectorStyle { } _getLegendDetailStyleProperties = () => { - const hasLabel = getHasLabel(this._labelStyleProperty); + const hasLabels = this.hasLabels(); return this.getDynamicPropertiesArray().filter((styleProperty) => { const styleName = styleProperty.getStyleName(); if ([VECTOR_STYLES.ICON_ORIENTATION, VECTOR_STYLES.LABEL_TEXT].includes(styleName)) { return false; } - if (!hasLabel && LABEL_STYLES.includes(styleName)) { + if (!hasLabels && LABEL_STYLES.includes(styleName)) { // do not render legend for label styles when there is no label return false; } @@ -768,6 +774,10 @@ export class VectorStyle implements IVectorStyle { return !this._symbolizeAsStyleProperty.isSymbolizedAsIcon(); } + hasLabels() { + return getHasLabel(this._labelStyleProperty); + } + setMBPaintProperties({ alpha, mbMap, diff --git a/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts b/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts index 2f25dc84fe224..a86ca84901cd9 100644 --- a/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts +++ b/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts @@ -55,7 +55,7 @@ export function getFillFilterExpression( ): FilterSpecification { return getFilterExpression( [ - // explicit EXCLUDE_CENTROID_FEATURES filter not needed. Centroids are points and are filtered out by geometry narrowing + // explicit "exclude centroid features" filter not needed. Label features are points and are filtered out by geometry narrowing [ 'any', ['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON], @@ -73,7 +73,7 @@ export function getLineFilterExpression( ): FilterSpecification { return getFilterExpression( [ - // explicit EXCLUDE_CENTROID_FEATURES filter not needed. Centroids are points and are filtered out by geometry narrowing + // explicit "exclude centroid features" filter not needed. Label features are points and are filtered out by geometry narrowing [ 'any', ['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON], @@ -94,18 +94,25 @@ const IS_POINT_FEATURE = [ ]; export function getPointFilterExpression( + isSourceGeoJson: boolean, + isESSource: boolean, joinFilter?: FilterSpecification, timesliceMaskConfig?: TimesliceMaskConfig ): FilterSpecification { - return getFilterExpression( - [EXCLUDE_CENTROID_FEATURES, IS_POINT_FEATURE], - joinFilter, - timesliceMaskConfig - ); + const filters: FilterSpecification[] = []; + if (isSourceGeoJson) { + filters.push(EXCLUDE_CENTROID_FEATURES); + } else if (!isSourceGeoJson && isESSource) { + filters.push(['!=', ['get', '_mvt_label_position'], true]); + } + filters.push(IS_POINT_FEATURE); + + return getFilterExpression(filters, joinFilter, timesliceMaskConfig); } export function getLabelFilterExpression( isSourceGeoJson: boolean, + isESSource: boolean, joinFilter?: FilterSpecification, timesliceMaskConfig?: TimesliceMaskConfig ): FilterSpecification { @@ -116,6 +123,8 @@ export function getLabelFilterExpression( // For GeoJSON sources, show label for centroid features or point/multi-point features only. // no explicit isCentroidFeature filter is needed, centroids are points and are included in the geometry filter. filters.push(IS_POINT_FEATURE); + } else if (!isSourceGeoJson && isESSource) { + filters.push(['==', ['get', '_mvt_label_position'], true]); } return getFilterExpression(filters, joinFilter, timesliceMaskConfig); diff --git a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.test.ts b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.test.ts index a45be3cf80ec0..4534c8047409d 100644 --- a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.test.ts +++ b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.test.ts @@ -11,7 +11,7 @@ test('Should return elasticsearch vector tile request for aggs tiles', () => { expect( getTileRequest({ layerId: '1', - tileUrl: `/pof/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=geo.coordinates&index=kibana_sample_data_logs&gridPrecision=8&requestBody=(_source%3A(excludes%3A!())%2Caggs%3A()%2Cfields%3A!((field%3A'%40timestamp'%2Cformat%3Adate_time)%2C(field%3Atimestamp%2Cformat%3Adate_time)%2C(field%3Autc_time%2Cformat%3Adate_time))%2Cquery%3A(bool%3A(filter%3A!((match_phrase%3A(machine.os.keyword%3Aios))%2C(range%3A(timestamp%3A(format%3Astrict_date_optional_time%2Cgte%3A'2022-04-22T16%3A46%3A00.744Z'%2Clte%3A'2022-04-29T16%3A46%3A05.345Z'))))%2Cmust%3A!()%2Cmust_not%3A!()%2Cshould%3A!()))%2Cruntime_mappings%3A(hour_of_day%3A(script%3A(source%3A'emit(doc%5B!'timestamp!'%5D.value.getHour())%3B')%2Ctype%3Along))%2Cscript_fields%3A()%2Csize%3A0%2Cstored_fields%3A!('*'))&renderAs=heatmap&token=e8bff005-ccea-464a-ae56-2061b4f8ce68`, + tileUrl: `/pof/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=geo.coordinates&hasLabels=false&index=kibana_sample_data_logs&gridPrecision=8&requestBody=(_source%3A(excludes%3A!())%2Caggs%3A()%2Cfields%3A!((field%3A'%40timestamp'%2Cformat%3Adate_time)%2C(field%3Atimestamp%2Cformat%3Adate_time)%2C(field%3Autc_time%2Cformat%3Adate_time))%2Cquery%3A(bool%3A(filter%3A!((match_phrase%3A(machine.os.keyword%3Aios))%2C(range%3A(timestamp%3A(format%3Astrict_date_optional_time%2Cgte%3A'2022-04-22T16%3A46%3A00.744Z'%2Clte%3A'2022-04-29T16%3A46%3A05.345Z'))))%2Cmust%3A!()%2Cmust_not%3A!()%2Cshould%3A!()))%2Cruntime_mappings%3A(hour_of_day%3A(script%3A(source%3A'emit(doc%5B!'timestamp!'%5D.value.getHour())%3B')%2Ctype%3Along))%2Cscript_fields%3A()%2Csize%3A0%2Cstored_fields%3A!('*'))&renderAs=heatmap&token=e8bff005-ccea-464a-ae56-2061b4f8ce68`, x: 3, y: 0, z: 2, @@ -71,6 +71,7 @@ test('Should return elasticsearch vector tile request for aggs tiles', () => { type: 'long', }, }, + with_labels: false, }, }); }); @@ -79,7 +80,7 @@ test('Should return elasticsearch vector tile request for hits tiles', () => { expect( getTileRequest({ layerId: '1', - tileUrl: `http://localhost:5601/pof/api/maps/mvt/getTile/{z}/{x}/{y}.pbf?geometryFieldName=geo.coordinates&index=kibana_sample_data_logs&requestBody=(_source%3A!f%2Cdocvalue_fields%3A!()%2Cquery%3A(bool%3A(filter%3A!((range%3A(timestamp%3A(format%3Astrict_date_optional_time%2Cgte%3A%272022-04-22T16%3A46%3A00.744Z%27%2Clte%3A%272022-04-29T16%3A46%3A05.345Z%27))))%2Cmust%3A!()%2Cmust_not%3A!()%2Cshould%3A!()))%2Cruntime_mappings%3A(hour_of_day%3A(script%3A(source%3A%27emit(doc%5B!%27timestamp!%27%5D.value.getHour())%3B%27)%2Ctype%3Along))%2Cscript_fields%3A()%2Csize%3A10000%2Cstored_fields%3A!(geo.coordinates))&token=415049b6-bb0a-444a-a7b9-89717db5183c`, + tileUrl: `http://localhost:5601/pof/api/maps/mvt/getTile/{z}/{x}/{y}.pbf?geometryFieldName=geo.coordinates&hasLabels=true&index=kibana_sample_data_logs&requestBody=(_source%3A!f%2Cdocvalue_fields%3A!()%2Cquery%3A(bool%3A(filter%3A!((range%3A(timestamp%3A(format%3Astrict_date_optional_time%2Cgte%3A%272022-04-22T16%3A46%3A00.744Z%27%2Clte%3A%272022-04-29T16%3A46%3A05.345Z%27))))%2Cmust%3A!()%2Cmust_not%3A!()%2Cshould%3A!()))%2Cruntime_mappings%3A(hour_of_day%3A(script%3A(source%3A%27emit(doc%5B!%27timestamp!%27%5D.value.getHour())%3B%27)%2Ctype%3Along))%2Cscript_fields%3A()%2Csize%3A10000%2Cstored_fields%3A!(geo.coordinates))&token=415049b6-bb0a-444a-a7b9-89717db5183c`, x: 0, y: 0, z: 2, @@ -118,6 +119,7 @@ test('Should return elasticsearch vector tile request for hits tiles', () => { }, }, track_total_hits: 10001, + with_labels: true, }, }); }); diff --git a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.ts b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.ts index f483dfda23409..c79ef7c64fdd1 100644 --- a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.ts +++ b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.ts @@ -35,11 +35,16 @@ export function getTileRequest(tileRequest: TileRequest): { path?: string; body? } const geometryFieldName = searchParams.get('geometryFieldName') as string; + const hasLabels = searchParams.has('hasLabels') + ? searchParams.get('hasLabels') === 'true' + : false; + if (tileRequest.tileUrl.includes(MVT_GETGRIDTILE_API_PATH)) { return getAggsTileRequest({ encodedRequestBody, geometryFieldName, gridPrecision: parseInt(searchParams.get('gridPrecision') as string, 10), + hasLabels, index, renderAs: searchParams.get('renderAs') as RENDER_AS, x: tileRequest.x, @@ -52,6 +57,7 @@ export function getTileRequest(tileRequest: TileRequest): { path?: string; body? return getHitsTileRequest({ encodedRequestBody, geometryFieldName, + hasLabels, index, x: tileRequest.x, y: tileRequest.y, diff --git a/x-pack/plugins/maps/server/mvt/mvt_routes.ts b/x-pack/plugins/maps/server/mvt/mvt_routes.ts index 8af26548b1d28..6fd7374fb69c1 100644 --- a/x-pack/plugins/maps/server/mvt/mvt_routes.ts +++ b/x-pack/plugins/maps/server/mvt/mvt_routes.ts @@ -44,6 +44,7 @@ export function initMVTRoutes({ }), query: schema.object({ geometryFieldName: schema.string(), + hasLabels: schema.boolean(), requestBody: schema.string(), index: schema.string(), token: schema.maybe(schema.string()), @@ -65,6 +66,7 @@ export function initMVTRoutes({ tileRequest = getHitsTileRequest({ encodedRequestBody: query.requestBody as string, geometryFieldName: query.geometryFieldName as string, + hasLabels: query.hasLabels as boolean, index: query.index as string, x, y, @@ -102,6 +104,7 @@ export function initMVTRoutes({ }), query: schema.object({ geometryFieldName: schema.string(), + hasLabels: schema.boolean(), requestBody: schema.string(), index: schema.string(), renderAs: schema.string(), @@ -126,6 +129,7 @@ export function initMVTRoutes({ encodedRequestBody: query.requestBody as string, geometryFieldName: query.geometryFieldName as string, gridPrecision: parseInt(query.gridPrecision, 10), + hasLabels: query.hasLabels as boolean, index: query.index as string, renderAs: query.renderAs as RENDER_AS, x, diff --git a/x-pack/test/api_integration/apis/maps/get_grid_tile.js b/x-pack/test/api_integration/apis/maps/get_grid_tile.js index 46fdda09ec476..26ba8c24ce71a 100644 --- a/x-pack/test/api_integration/apis/maps/get_grid_tile.js +++ b/x-pack/test/api_integration/apis/maps/get_grid_tile.js @@ -9,12 +9,22 @@ import { VectorTile } from '@mapbox/vector-tile'; import Protobuf from 'pbf'; import expect from '@kbn/expect'; +function findFeature(layer, callbackFn) { + for (let i = 0; i < layer.length; i++) { + const feature = layer.feature(i); + if (callbackFn(feature)) { + return feature; + } + } +} + export default function ({ getService }) { const supertest = getService('supertest'); describe('getGridTile', () => { const URL = `/api/maps/mvt/getGridTile/3/2/3.pbf\ ?geometryFieldName=geo.coordinates\ +&hasLabels=false\ &index=logstash-*\ &gridPrecision=8\ &requestBody=(_source:(excludes:!()),aggs:(avg_of_bytes:(avg:(field:bytes))),fields:!((field:%27@timestamp%27,format:date_time),(field:%27relatedContent.article:modified_time%27,format:date_time),(field:%27relatedContent.article:published_time%27,format:date_time),(field:utc_time,format:date_time)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(hour_of_day:(script:(lang:painless,source:%27doc[!%27@timestamp!%27].value.getHour()%27))),size:0,stored_fields:!(%27*%27))`; @@ -152,6 +162,33 @@ export default function ({ getService }) { ]); }); + it('should return vector tile containing label features when hasLabels is true', async () => { + const resp = await supertest + .get(URL.replace('hasLabels=false', 'hasLabels=true') + '&renderAs=hex') + .set('kbn-xsrf', 'kibana') + .responseType('blob') + .expect(200); + + const jsonTile = new VectorTile(new Protobuf(resp.body)); + const layer = jsonTile.layers.aggs; + expect(layer.length).to.be(2); + + const labelFeature = findFeature(layer, (feature) => { + return feature.properties._mvt_label_position === true; + }); + expect(labelFeature).not.to.be(undefined); + expect(labelFeature.type).to.be(1); + expect(labelFeature.extent).to.be(4096); + expect(labelFeature.id).to.be(undefined); + expect(labelFeature.properties).to.eql({ + _count: 1, + _key: '85264a33fffffff', + 'avg_of_bytes.value': 9252, + _mvt_label_position: true, + }); + expect(labelFeature.loadGeometry()).to.eql([[{ x: 93, y: 667 }]]); + }); + it('should return vector tile with meta layer', async () => { const resp = await supertest .get(URL + '&renderAs=point') diff --git a/x-pack/test/api_integration/apis/maps/get_tile.js b/x-pack/test/api_integration/apis/maps/get_tile.js index 09b8bf1d8b862..6803b5e404ab0 100644 --- a/x-pack/test/api_integration/apis/maps/get_tile.js +++ b/x-pack/test/api_integration/apis/maps/get_tile.js @@ -27,6 +27,7 @@ export default function ({ getService }) { .get( `/api/maps/mvt/getTile/2/1/1.pbf\ ?geometryFieldName=geo.coordinates\ +&hasLabels=false\ &index=logstash-*\ &requestBody=(_source:!f,docvalue_fields:!(bytes,geo.coordinates,machine.os.raw,(field:'@timestamp',format:epoch_millis)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10000,stored_fields:!(bytes,geo.coordinates,machine.os.raw,'@timestamp'))` ) @@ -85,11 +86,57 @@ export default function ({ getService }) { ]); }); + it('should return ES vector tile containing label features when hasLabels is true', async () => { + const resp = await supertest + .get( + `/api/maps/mvt/getTile/2/1/1.pbf\ +?geometryFieldName=geo.coordinates\ +&hasLabels=true\ +&index=logstash-*\ +&requestBody=(_source:!f,docvalue_fields:!(bytes,geo.coordinates,machine.os.raw,(field:'@timestamp',format:epoch_millis)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10000,stored_fields:!(bytes,geo.coordinates,machine.os.raw,'@timestamp'))` + ) + .set('kbn-xsrf', 'kibana') + .responseType('blob') + .expect(200); + + expect(resp.headers['content-encoding']).to.be('gzip'); + expect(resp.headers['content-disposition']).to.be('inline'); + expect(resp.headers['content-type']).to.be('application/x-protobuf'); + expect(resp.headers['cache-control']).to.be('public, max-age=3600'); + + const jsonTile = new VectorTile(new Protobuf(resp.body)); + const layer = jsonTile.layers.hits; + expect(layer.length).to.be(4); // 2 docs + 2 label features + + // Verify ES document + + const feature = findFeature(layer, (feature) => { + return ( + feature.properties._id === 'AU_x3_BsGFA8no6Qjjug' && + feature.properties._mvt_label_position === true + ); + }); + expect(feature).not.to.be(undefined); + expect(feature.type).to.be(1); + expect(feature.extent).to.be(4096); + expect(feature.id).to.be(undefined); + expect(feature.properties).to.eql({ + '@timestamp': '1442709961071', + _id: 'AU_x3_BsGFA8no6Qjjug', + _index: 'logstash-2015.09.20', + bytes: 9252, + 'machine.os.raw': 'ios', + _mvt_label_position: true, + }); + expect(feature.loadGeometry()).to.eql([[{ x: 44, y: 2382 }]]); + }); + it('should return error when index does not exist', async () => { await supertest .get( `/api/maps/mvt/getTile/2/1/1.pbf\ ?geometryFieldName=geo.coordinates\ +&hasLabels=false\ &index=notRealIndex\ &requestBody=(_source:!f,docvalue_fields:!(bytes,geo.coordinates,machine.os.raw,(field:'@timestamp',format:epoch_millis)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10000,stored_fields:!(bytes,geo.coordinates,machine.os.raw,'@timestamp'))` ) diff --git a/x-pack/test/functional/apps/maps/group4/mvt_geotile_grid.js b/x-pack/test/functional/apps/maps/group4/mvt_geotile_grid.js index 40dfa5ac8e571..66eb54278e580 100644 --- a/x-pack/test/functional/apps/maps/group4/mvt_geotile_grid.js +++ b/x-pack/test/functional/apps/maps/group4/mvt_geotile_grid.js @@ -45,6 +45,7 @@ export default function ({ getPageObjects, getService }) { expect(searchParams).to.eql({ geometryFieldName: 'geo.coordinates', + hasLabels: 'false', index: 'logstash-*', gridPrecision: 8, renderAs: 'grid', diff --git a/x-pack/test/functional/apps/maps/group4/mvt_scaling.js b/x-pack/test/functional/apps/maps/group4/mvt_scaling.js index 0f74752d01136..5f740e9137cdb 100644 --- a/x-pack/test/functional/apps/maps/group4/mvt_scaling.js +++ b/x-pack/test/functional/apps/maps/group4/mvt_scaling.js @@ -50,6 +50,7 @@ export default function ({ getPageObjects, getService }) { expect(searchParams).to.eql({ geometryFieldName: 'geometry', + hasLabels: 'false', index: 'geo_shapes*', requestBody: '(_source:!f,docvalue_fields:!(prop1),query:(bool:(filter:!(),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(),size:10001,stored_fields:!(geometry,prop1))', From bc31053dc9e5cca9bdf344f8690bf9a0e3c043ac Mon Sep 17 00:00:00 2001 From: Dmitry Tomashevich <39378793+Dmitriynj@users.noreply.github.com> Date: Fri, 20 May 2022 17:09:20 +0300 Subject: [PATCH 15/39] [Discover][Alerting] Implement editing of dataView, query & filters (#131688) * [Discover] introduce params editing using unified search * [Discover] fix unit tests * [Discover] fix functional tests * [Discover] fix unit tests * [Discover] return test subject name * [Discover] fix alert functional test * Update x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx Co-authored-by: Julia Rechkunova * Update x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx Co-authored-by: Matthias Wilhelm * [Discover] hide filter panel options * [Discover] improve functional test * [Discover] apply suggestions * [Discover] change data view selector * [Discover] fix tests * [Discover] apply suggestions, fix lang mode toggler * [Discover] mote interface to types file, clean up diff * [Discover] fix saved query issue * Update x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts Co-authored-by: Matthias Wilhelm * [Discover] remove zIndex * [Discover] omit null searchType from esQuery completely, add isEsQueryAlert check for useSavedObjectReferences hook * [Discover] set searchType to esQuery when needed * [Discover] fix unit tests * Update x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts Co-authored-by: Matthias Wilhelm * Update x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts Co-authored-by: Matthias Wilhelm Co-authored-by: Julia Rechkunova Co-authored-by: Matthias Wilhelm --- src/plugins/data/public/mocks.ts | 1 + src/plugins/data/public/query/mocks.ts | 2 +- src/plugins/data_views/public/mocks.ts | 1 + .../components/top_nav/get_top_nav_links.tsx | 1 + .../top_nav/open_alerts_popover.tsx | 14 +- .../filter_bar/filter_item/filter_item.tsx | 14 - .../public/filter_bar/filter_view/index.tsx | 62 ++-- .../query_string_input/query_bar_top_row.tsx | 3 + .../public/search_bar/search_bar.tsx | 4 + x-pack/plugins/stack_alerts/kibana.json | 3 +- .../data_view_select_popover.test.tsx | 75 +++++ .../components/data_view_select_popover.tsx | 120 ++++++++ .../public/alert_types/es_query/constants.ts | 15 + .../expression/es_query_expression.tsx | 1 + .../es_query/expression/expression.tsx | 41 +-- .../expression/read_only_filter_items.tsx | 66 ----- .../search_source_expression.test.tsx | 133 +++++---- .../expression/search_source_expression.tsx | 219 ++++---------- .../search_source_expression_form.tsx | 269 ++++++++++++++++++ .../public/alert_types/es_query/types.ts | 20 +- .../public/alert_types/es_query/util.ts | 5 +- .../public/alert_types/es_query/validation.ts | 16 +- ...inment_alert_type_expression.test.tsx.snap | 3 + .../es_query/action_context.test.ts | 2 + .../alert_types/es_query/alert_type.test.ts | 9 + .../server/alert_types/es_query/alert_type.ts | 24 +- .../es_query/alert_type_params.test.ts | 1 + .../alert_types/es_query/alert_type_params.ts | 29 +- .../alert_types/es_query/executor.test.ts | 1 + .../server/alert_types/es_query/executor.ts | 7 +- .../server/alert_types/es_query/types.ts | 4 +- .../server/alert_types/es_query/util.ts | 12 + .../sections/rule_form/rule_form.tsx | 6 +- .../apps/discover/search_source_alert.ts | 31 +- 34 files changed, 799 insertions(+), 415 deletions(-) create mode 100644 x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.test.tsx create mode 100644 x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.tsx delete mode 100644 x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/read_only_filter_items.tsx create mode 100644 x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx create mode 100644 x-pack/plugins/stack_alerts/server/alert_types/es_query/util.ts diff --git a/src/plugins/data/public/mocks.ts b/src/plugins/data/public/mocks.ts index 27e365ce0cb37..e1b42b7c193e2 100644 --- a/src/plugins/data/public/mocks.ts +++ b/src/plugins/data/public/mocks.ts @@ -38,6 +38,7 @@ const createStartContract = (): Start => { }), get: jest.fn().mockReturnValue(Promise.resolve({})), clearCache: jest.fn(), + getIdsWithTitle: jest.fn(), } as unknown as DataViewsContract; return { diff --git a/src/plugins/data/public/query/mocks.ts b/src/plugins/data/public/query/mocks.ts index a2d73e5b5ce34..296a61afef2fd 100644 --- a/src/plugins/data/public/query/mocks.ts +++ b/src/plugins/data/public/query/mocks.ts @@ -32,7 +32,7 @@ const createStartContractMock = () => { addToQueryLog: jest.fn(), filterManager: createFilterManagerMock(), queryString: queryStringManagerMock.createStartContract(), - savedQueries: jest.fn() as any, + savedQueries: { getSavedQuery: jest.fn() } as any, state$: new Observable(), getState: jest.fn(), timefilter: timefilterServiceMock.createStartContract(), diff --git a/src/plugins/data_views/public/mocks.ts b/src/plugins/data_views/public/mocks.ts index 61713c9406c23..3767c93be10e6 100644 --- a/src/plugins/data_views/public/mocks.ts +++ b/src/plugins/data_views/public/mocks.ts @@ -28,6 +28,7 @@ const createStartContract = (): Start => { get: jest.fn().mockReturnValue(Promise.resolve({})), clearCache: jest.fn(), getCanSaveSync: jest.fn(), + getIdsWithTitle: jest.fn(), } as unknown as jest.Mocked; }; diff --git a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx index f2ac0d2bfa060..ee35e10b6631a 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx @@ -74,6 +74,7 @@ export const getTopNavLinks = ({ anchorElement, searchSource: savedSearch.searchSource, services, + savedQueryId: state.appStateContainer.getState().savedQuery, }); }, testId: 'discoverAlertsButton', diff --git a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx index d414919e567f9..71a0ef3df1b8c 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx @@ -26,9 +26,15 @@ interface AlertsPopoverProps { onClose: () => void; anchorElement: HTMLElement; searchSource: ISearchSource; + savedQueryId?: string; } -export function AlertsPopover({ searchSource, anchorElement, onClose }: AlertsPopoverProps) { +export function AlertsPopover({ + searchSource, + anchorElement, + savedQueryId, + onClose, +}: AlertsPopoverProps) { const dataView = searchSource.getField('index')!; const services = useDiscoverServices(); const { triggersActionsUi } = services; @@ -49,8 +55,9 @@ export function AlertsPopover({ searchSource, anchorElement, onClose }: AlertsPo return { searchType: 'searchSource', searchConfiguration: nextSearchSource.getSerializedFields(), + savedQueryId, }; - }, [searchSource, services]); + }, [savedQueryId, searchSource, services]); const SearchThresholdAlertFlyout = useMemo(() => { if (!alertFlyoutVisible) { @@ -156,11 +163,13 @@ export function openAlertsPopover({ anchorElement, searchSource, services, + savedQueryId, }: { I18nContext: I18nStart['Context']; anchorElement: HTMLElement; searchSource: ISearchSource; services: DiscoverServices; + savedQueryId?: string; }) { if (isOpen) { closeAlertsPopover(); @@ -177,6 +186,7 @@ export function openAlertsPopover({ onClose={closeAlertsPopover} anchorElement={anchorElement} searchSource={searchSource} + savedQueryId={savedQueryId} /> diff --git a/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx index 387b5e751ff44..847140fd8e272 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx @@ -42,7 +42,6 @@ export interface FilterItemProps { uiSettings: IUiSettingsClient; hiddenPanelOptions?: FilterPanelOption[]; timeRangeForSuggestionsOverride?: boolean; - readonly?: boolean; } type FilterPopoverProps = HTMLAttributes & EuiPopoverProps; @@ -364,7 +363,6 @@ export function FilterItem(props: FilterItemProps) { iconOnClick: handleIconClick, onClick: handleBadgeClick, 'data-test-subj': getDataTestSubj(valueLabelConfig), - readonly: props.readonly, }; const popoverProps: FilterPopoverProps = { @@ -379,18 +377,6 @@ export function FilterItem(props: FilterItemProps) { panelPaddingSize: 'none', }; - if (props.readonly) { - return ( - - - - ); - } - return ( {renderedComponent === 'menu' ? ( diff --git a/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx b/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx index d399bb0025a10..0e10766139820 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx @@ -19,7 +19,6 @@ interface Props { fieldLabel?: string; filterLabelStatus: FilterLabelStatus; errorMessage?: string; - readonly?: boolean; hideAlias?: boolean; [propName: string]: any; } @@ -32,7 +31,6 @@ export const FilterView: FC = ({ fieldLabel, errorMessage, filterLabelStatus, - readonly, hideAlias, ...rest }: Props) => { @@ -56,45 +54,29 @@ export const FilterView: FC = ({ })} ${title}`; } - const badgeProps: EuiBadgeProps = readonly - ? { - title, - color: 'hollow', - onClick, - onClickAriaLabel: i18n.translate( - 'unifiedSearch.filter.filterBar.filterItemReadOnlyBadgeAriaLabel', - { - defaultMessage: 'Filter entry', - } - ), - iconOnClick, + const badgeProps: EuiBadgeProps = { + title, + color: 'hollow', + iconType: 'cross', + iconSide: 'right', + closeButtonProps: { + // Removing tab focus on close button because the same option can be obtained through the context menu + // Also, we may want to add a `DEL` keyboard press functionality + tabIndex: -1, + }, + iconOnClick, + iconOnClickAriaLabel: i18n.translate( + 'unifiedSearch.filter.filterBar.filterItemBadgeIconAriaLabel', + { + defaultMessage: 'Delete {filter}', + values: { filter: innerText }, } - : { - title, - color: 'hollow', - iconType: 'cross', - iconSide: 'right', - closeButtonProps: { - // Removing tab focus on close button because the same option can be obtained through the context menu - // Also, we may want to add a `DEL` keyboard press functionality - tabIndex: -1, - }, - iconOnClick, - iconOnClickAriaLabel: i18n.translate( - 'unifiedSearch.filter.filterBar.filterItemBadgeIconAriaLabel', - { - defaultMessage: 'Delete {filter}', - values: { filter: innerText }, - } - ), - onClick, - onClickAriaLabel: i18n.translate( - 'unifiedSearch.filter.filterBar.filterItemBadgeAriaLabel', - { - defaultMessage: 'Filter actions', - } - ), - }; + ), + onClick, + onClickAriaLabel: i18n.translate('unifiedSearch.filter.filterBar.filterItemBadgeAriaLabel', { + defaultMessage: 'Filter actions', + }), + }; return ( diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx index 0ad4756e9177b..d62a7f79c82de 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx @@ -43,6 +43,7 @@ import { shallowEqual } from '../utils/shallow_equal'; import { AddFilterPopover } from './add_filter_popover'; import { DataViewPicker, DataViewPickerProps } from '../dataview_picker'; import { FilterButtonGroup } from '../filter_bar/filter_button_group/filter_button_group'; +import type { SuggestionsListSize } from '../typeahead/suggestions_component'; import './query_bar.scss'; const SuperDatePicker = React.memo( @@ -88,6 +89,7 @@ export interface QueryBarTopRowProps { filterBar?: React.ReactNode; showDatePickerAsBadge?: boolean; showSubmitButton?: boolean; + suggestionsSize?: SuggestionsListSize; isScreenshotMode?: boolean; } @@ -483,6 +485,7 @@ export const QueryBarTopRow = React.memo( timeRangeForSuggestionsOverride={props.timeRangeForSuggestionsOverride} disableLanguageSwitcher={true} prepend={renderFilterMenuOnly() && renderFilterButtonGroup()} + size={props.suggestionsSize} /> )} diff --git a/src/plugins/unified_search/public/search_bar/search_bar.tsx b/src/plugins/unified_search/public/search_bar/search_bar.tsx index a6ca444612402..9d96ba936f708 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.tsx @@ -29,6 +29,7 @@ import { QueryBarMenu, QueryBarMenuProps } from '../query_string_input/query_bar import type { DataViewPickerProps } from '../dataview_picker'; import QueryBarTopRow from '../query_string_input/query_bar_top_row'; import { FilterBar, FilterItems } from '../filter_bar'; +import type { SuggestionsListSize } from '../typeahead/suggestions_component'; import { searchBarStyles } from './search_bar.styles'; export interface SearchBarInjectedDeps { @@ -88,6 +89,8 @@ export interface SearchBarOwnProps { fillSubmitButton?: boolean; dataViewPickerComponentProps?: DataViewPickerProps; showSubmitButton?: boolean; + // defines size of suggestions query popover + suggestionsSize?: SuggestionsListSize; isScreenshotMode?: boolean; } @@ -485,6 +488,7 @@ class SearchBarUI extends Component { dataViewPickerComponentProps={this.props.dataViewPickerComponentProps} showDatePickerAsBadge={this.shouldShowDatePickerAsBadge()} filterBar={filterBar} + suggestionsSize={this.props.suggestionsSize} isScreenshotMode={this.props.isScreenshotMode} />
diff --git a/x-pack/plugins/stack_alerts/kibana.json b/x-pack/plugins/stack_alerts/kibana.json index abafba8010fbc..ff436ef53fae7 100644 --- a/x-pack/plugins/stack_alerts/kibana.json +++ b/x-pack/plugins/stack_alerts/kibana.json @@ -14,7 +14,8 @@ "triggersActionsUi", "kibanaReact", "savedObjects", - "data" + "data", + "kibanaUtils" ], "configPath": ["xpack", "stack_alerts"], "requiredBundles": ["esUiShared"], diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.test.tsx new file mode 100644 index 0000000000000..94e6a6b0c0cd4 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; +import { DataViewSelectPopover } from './data_view_select_popover'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { act } from 'react-dom/test-utils'; + +const props = { + onSelectDataView: () => {}, + initialDataViewTitle: 'kibana_sample_data_logs', + initialDataViewId: 'mock-data-logs-id', +}; + +const dataViewOptions = [ + { + id: 'mock-data-logs-id', + namespaces: ['default'], + title: 'kibana_sample_data_logs', + }, + { + id: 'mock-flyghts-id', + namespaces: ['default'], + title: 'kibana_sample_data_flights', + }, + { + id: 'mock-ecommerce-id', + namespaces: ['default'], + title: 'kibana_sample_data_ecommerce', + typeMeta: {}, + }, + { + id: 'mock-test-id', + namespaces: ['default'], + title: 'test', + typeMeta: {}, + }, +]; + +const mount = () => { + const dataViewsMock = dataViewPluginMocks.createStartContract(); + dataViewsMock.getIdsWithTitle.mockImplementation(() => Promise.resolve(dataViewOptions)); + + return { + wrapper: mountWithIntl( + + + + ), + dataViewsMock, + }; +}; + +describe('DataViewSelectPopover', () => { + test('renders properly', async () => { + const { wrapper, dataViewsMock } = mount(); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(dataViewsMock.getIdsWithTitle).toHaveBeenCalled(); + expect(wrapper.find('[data-test-subj="selectDataViewExpression"]').exists()).toBeTruthy(); + + const getIdsWithTitleResult = await dataViewsMock.getIdsWithTitle.mock.results[0].value; + expect(getIdsWithTitleResult).toBe(dataViewOptions); + }); +}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.tsx new file mode 100644 index 0000000000000..a62b640e0d8eb --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/data_view_select_popover.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButtonIcon, + EuiExpression, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPopover, + EuiPopoverTitle, +} from '@elastic/eui'; +import { DataViewsList } from '@kbn/unified-search-plugin/public'; +import { DataViewListItem } from '@kbn/data-views-plugin/public'; +import { useTriggersAndActionsUiDeps } from '../es_query/util'; + +interface DataViewSelectPopoverProps { + onSelectDataView: (newDataViewId: string) => void; + initialDataViewTitle: string; + initialDataViewId?: string; +} + +export const DataViewSelectPopover: React.FunctionComponent = ({ + onSelectDataView, + initialDataViewTitle, + initialDataViewId, +}) => { + const { data } = useTriggersAndActionsUiDeps(); + const [dataViewItems, setDataViewsItems] = useState(); + const [dataViewPopoverOpen, setDataViewPopoverOpen] = useState(false); + + const [selectedDataViewId, setSelectedDataViewId] = useState(initialDataViewId); + const [selectedTitle, setSelectedTitle] = useState(initialDataViewTitle); + + useEffect(() => { + const initDataViews = async () => { + const fetchedDataViewItems = await data.dataViews.getIdsWithTitle(); + setDataViewsItems(fetchedDataViewItems); + }; + initDataViews(); + }, [data.dataViews]); + + const closeDataViewPopover = useCallback(() => setDataViewPopoverOpen(false), []); + + if (!dataViewItems) { + return null; + } + + return ( + { + setDataViewPopoverOpen(true); + }} + isInvalid={!selectedTitle} + /> + } + isOpen={dataViewPopoverOpen} + closePopover={closeDataViewPopover} + ownFocus + anchorPosition="downLeft" + display="block" + > +
+ + + + {i18n.translate('xpack.stackAlerts.components.ui.alertParams.dataViewPopoverTitle', { + defaultMessage: 'Data view', + })} + + + + + + + + { + setSelectedDataViewId(newId); + const newTitle = dataViewItems?.find(({ id }) => id === newId)?.title; + if (newTitle) { + setSelectedTitle(newTitle); + } + + onSelectDataView(newId); + closeDataViewPopover(); + }} + currentDataViewId={selectedDataViewId} + /> + +
+
+ ); +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/constants.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/constants.ts index bceb39ba08cf9..da85c878f3281 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/constants.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/constants.ts @@ -6,6 +6,7 @@ */ import { COMPARATORS } from '@kbn/triggers-actions-ui-plugin/public'; +import { ErrorKey } from './types'; export const DEFAULT_VALUES = { THRESHOLD_COMPARATOR: COMPARATORS.GREATER_THAN, @@ -19,3 +20,17 @@ export const DEFAULT_VALUES = { TIME_WINDOW_UNIT: 'm', THRESHOLD: [1000], }; + +export const EXPRESSION_ERRORS = { + index: new Array(), + size: new Array(), + timeField: new Array(), + threshold0: new Array(), + threshold1: new Array(), + esQuery: new Array(), + thresholdComparator: new Array(), + timeWindowSize: new Array(), + searchConfiguration: new Array(), +}; + +export const EXPRESSION_ERROR_KEYS = Object.keys(EXPRESSION_ERRORS) as ErrorKey[]; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx index 10b774648d735..afb45f90c6e52 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx @@ -83,6 +83,7 @@ export const EsQueryExpression = ({ thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR, size: size ?? DEFAULT_VALUES.SIZE, esQuery: esQuery ?? DEFAULT_VALUES.QUERY, + searchType: 'esQuery', }); const setParam = useCallback( diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/expression.tsx index df44a8923183c..3b5e978b999c8 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/expression.tsx @@ -5,29 +5,33 @@ * 2.0. */ -import React from 'react'; +import React, { memo, PropsWithChildren } from 'react'; import { i18n } from '@kbn/i18n'; +import deepEqual from 'fast-deep-equal'; import 'brace/theme/github'; import { EuiSpacer, EuiCallOut } from '@elastic/eui'; import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; -import { EsQueryAlertParams } from '../types'; -import { SearchSourceExpression } from './search_source_expression'; +import { ErrorKey, EsQueryAlertParams } from '../types'; +import { SearchSourceExpression, SearchSourceExpressionProps } from './search_source_expression'; import { EsQueryExpression } from './es_query_expression'; import { isSearchSourceAlert } from '../util'; +import { EXPRESSION_ERROR_KEYS } from '../constants'; -const expressionFieldsWithValidation = [ - 'index', - 'size', - 'timeField', - 'threshold0', - 'threshold1', - 'timeWindowSize', - 'searchType', - 'esQuery', - 'searchConfiguration', -]; +function areSearchSourceExpressionPropsEqual( + prevProps: Readonly>, + nextProps: Readonly> +) { + const areErrorsEqual = deepEqual(prevProps.errors, nextProps.errors); + const areRuleParamsEqual = deepEqual(prevProps.ruleParams, nextProps.ruleParams); + return areErrorsEqual && areRuleParamsEqual; +} + +const SearchSourceExpressionMemoized = memo( + SearchSourceExpression, + areSearchSourceExpressionPropsEqual +); export const EsQueryAlertTypeExpression: React.FunctionComponent< RuleTypeParamsExpressionProps @@ -35,11 +39,11 @@ export const EsQueryAlertTypeExpression: React.FunctionComponent< const { ruleParams, errors } = props; const isSearchSource = isSearchSourceAlert(ruleParams); - const hasExpressionErrors = !!Object.keys(errors).find((errorKey) => { + const hasExpressionErrors = Object.keys(errors).some((errorKey) => { return ( - expressionFieldsWithValidation.includes(errorKey) && + EXPRESSION_ERROR_KEYS.includes(errorKey as ErrorKey) && errors[errorKey].length >= 1 && - ruleParams[errorKey as keyof EsQueryAlertParams] !== undefined + ruleParams[errorKey] !== undefined ); }); @@ -54,14 +58,13 @@ export const EsQueryAlertTypeExpression: React.FunctionComponent< <> {hasExpressionErrors && ( <> - )} {isSearchSource ? ( - + ) : ( )} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/read_only_filter_items.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/read_only_filter_items.tsx deleted file mode 100644 index 6747c60bb840c..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/read_only_filter_items.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { injectI18n } from '@kbn/i18n-react'; -import { css } from '@emotion/react'; - -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { getDisplayValueFromFilter } from '@kbn/data-plugin/public'; -import { Filter } from '@kbn/data-plugin/common'; -import { DataView } from '@kbn/data-views-plugin/public'; -import { FilterItem } from '@kbn/unified-search-plugin/public'; - -const FilterItemComponent = injectI18n(FilterItem); - -interface ReadOnlyFilterItemsProps { - filters: Filter[]; - indexPatterns: DataView[]; -} - -const noOp = () => {}; - -export const ReadOnlyFilterItems = ({ filters, indexPatterns }: ReadOnlyFilterItemsProps) => { - const { uiSettings } = useKibana().services; - - const filterList = filters.map((filter, index) => { - const filterValue = getDisplayValueFromFilter(filter, indexPatterns); - return ( - - - - ); - }); - - return ( - - {filterList} - - ); -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx index 7041bba0fe2ff..d12833a3f258f 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx @@ -10,18 +10,12 @@ import React from 'react'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; -import { DataPublicPluginStart, ISearchStart } from '@kbn/data-plugin/public'; import { EsQueryAlertParams, SearchType } from '../types'; import { SearchSourceExpression } from './search_source_expression'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { act } from 'react-dom/test-utils'; -import { EuiCallOut, EuiLoadingSpinner } from '@elastic/eui'; -import { ReactWrapper } from 'enzyme'; - -const dataMock = dataPluginMock.createStartContract() as DataPublicPluginStart & { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - search: ISearchStart & { searchSource: { create: jest.MockedFunction } }; -}; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; const dataViewPluginMock = dataViewPluginMocks.createStartContract(); const chartsStartMock = chartPluginMock.createStartContract(); @@ -40,6 +34,18 @@ const defaultSearchSourceExpressionParams: EsQueryAlertParams { if (name === 'filter') { return []; @@ -48,7 +54,33 @@ const searchSourceMock = { }, }; -const setup = async (alertParams: EsQueryAlertParams) => { +const savedQueryMock = { + id: 'test-id', + attributes: { + title: 'test-filter-set', + description: '', + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + filters: [], + }, +}; + +jest.mock('./search_source_expression_form', () => ({ + SearchSourceExpressionForm: () =>
search source expression form mock
, +})); + +const dataMock = dataPluginMock.createStartContract(); +(dataMock.search.searchSource.create as jest.Mock).mockImplementation(() => + Promise.resolve(searchSourceMock) +); +(dataMock.dataViews.getIdsWithTitle as jest.Mock).mockImplementation(() => Promise.resolve([])); +(dataMock.query.savedQueries.getSavedQuery as jest.Mock).mockImplementation(() => + Promise.resolve(savedQueryMock) +); + +const setup = (alertParams: EsQueryAlertParams) => { const errors = { size: [], timeField: [], @@ -57,67 +89,58 @@ const setup = async (alertParams: EsQueryAlertParams) = }; const wrapper = mountWithIntl( - {}} - setRuleProperty={() => {}} - errors={errors} - unifiedSearch={unifiedSearchMock} - data={dataMock} - dataViews={dataViewPluginMock} - defaultActionGroupId="" - actionGroups={[]} - charts={chartsStartMock} - /> + + {}} + setRuleProperty={() => {}} + errors={errors} + unifiedSearch={unifiedSearchMock} + data={dataMock} + dataViews={dataViewPluginMock} + defaultActionGroupId="" + actionGroups={[]} + charts={chartsStartMock} + /> + ); return wrapper; }; -const rerender = async (wrapper: ReactWrapper) => { - const update = async () => +describe('SearchSourceAlertTypeExpression', () => { + test('should render correctly', async () => { + let wrapper = setup(defaultSearchSourceExpressionParams).children(); + + expect(wrapper.find(EuiLoadingSpinner).exists()).toBeTruthy(); + expect(wrapper.text().includes('Cant find searchSource')).toBeFalsy(); + await act(async () => { await nextTick(); - wrapper.update(); }); - await update(); -}; + wrapper = await wrapper.update(); -describe('SearchSourceAlertTypeExpression', () => { - test('should render loading prompt', async () => { - dataMock.search.searchSource.create.mockImplementation(() => - Promise.resolve(() => searchSourceMock) - ); - - const wrapper = await setup(defaultSearchSourceExpressionParams); - - expect(wrapper.find(EuiLoadingSpinner).exists()).toBeTruthy(); + expect(wrapper.text().includes('Cant find searchSource')).toBeFalsy(); + expect(wrapper.text().includes('search source expression form mock')).toBeTruthy(); }); test('should render error prompt', async () => { - dataMock.search.searchSource.create.mockImplementation(() => - Promise.reject(() => 'test error') + (dataMock.search.searchSource.create as jest.Mock).mockImplementationOnce(() => + Promise.reject(new Error('Cant find searchSource')) ); + let wrapper = setup(defaultSearchSourceExpressionParams).children(); - const wrapper = await setup(defaultSearchSourceExpressionParams); - await rerender(wrapper); - - expect(wrapper.find(EuiCallOut).exists()).toBeTruthy(); - }); - - test('should render SearchSourceAlertTypeExpression with expected components', async () => { - dataMock.search.searchSource.create.mockImplementation(() => - Promise.resolve(() => searchSourceMock) - ); + expect(wrapper.find(EuiLoadingSpinner).exists()).toBeTruthy(); + expect(wrapper.text().includes('Cant find searchSource')).toBeFalsy(); - const wrapper = await setup(defaultSearchSourceExpressionParams); - await rerender(wrapper); + await act(async () => { + await nextTick(); + }); + wrapper = await wrapper.update(); - expect(wrapper.find('[data-test-subj="sizeValueExpression"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="thresholdExpression"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="forLastExpression"]').exists()).toBeTruthy(); + expect(wrapper.text().includes('Cant find searchSource')).toBeTruthy(); }); }); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx index 1d54609223aaf..26b2d074bfd8b 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx @@ -5,36 +5,27 @@ * 2.0. */ -import React, { Fragment, useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import './search_source_expression.scss'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiSpacer, - EuiTitle, - EuiExpression, - EuiLoadingSpinner, - EuiEmptyPrompt, - EuiCallOut, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { Filter, ISearchSource } from '@kbn/data-plugin/common'; -import { - ForLastExpression, - RuleTypeParamsExpressionProps, - ThresholdExpression, - ValueExpression, -} from '@kbn/triggers-actions-ui-plugin/public'; +import { EuiSpacer, EuiLoadingSpinner, EuiEmptyPrompt, EuiCallOut } from '@elastic/eui'; +import { ISearchSource } from '@kbn/data-plugin/common'; +import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { SavedQuery } from '@kbn/data-plugin/public'; import { EsQueryAlertParams, SearchType } from '../types'; +import { useTriggersAndActionsUiDeps } from '../util'; +import { SearchSourceExpressionForm } from './search_source_expression_form'; import { DEFAULT_VALUES } from '../constants'; -import { ReadOnlyFilterItems } from './read_only_filter_items'; + +export type SearchSourceExpressionProps = RuleTypeParamsExpressionProps< + EsQueryAlertParams +>; export const SearchSourceExpression = ({ ruleParams, + errors, setRuleParams, setRuleProperty, - data, - errors, -}: RuleTypeParamsExpressionProps>) => { +}: SearchSourceExpressionProps) => { const { searchConfiguration, thresholdComparator, @@ -43,48 +34,43 @@ export const SearchSourceExpression = ({ timeWindowUnit, size, } = ruleParams; - const [usedSearchSource, setUsedSearchSource] = useState(); - const [paramsError, setParamsError] = useState(); + const { data } = useTriggersAndActionsUiDeps(); - const [currentAlertParams, setCurrentAlertParams] = useState< - EsQueryAlertParams - >({ - searchConfiguration, - searchType: SearchType.searchSource, - timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE, - timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT, - threshold: threshold ?? DEFAULT_VALUES.THRESHOLD, - thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR, - size: size ?? DEFAULT_VALUES.SIZE, - }); + const [searchSource, setSearchSource] = useState(); + const [savedQuery, setSavedQuery] = useState(); + const [paramsError, setParamsError] = useState(); const setParam = useCallback( - (paramField: string, paramValue: unknown) => { - setCurrentAlertParams((currentParams) => ({ - ...currentParams, - [paramField]: paramValue, - })); - setRuleParams(paramField, paramValue); - }, + (paramField: string, paramValue: unknown) => setRuleParams(paramField, paramValue), [setRuleParams] ); - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => setRuleProperty('params', currentAlertParams), []); + useEffect(() => { + setRuleProperty('params', { + searchConfiguration, + searchType: SearchType.searchSource, + timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE, + timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT, + threshold: threshold ?? DEFAULT_VALUES.THRESHOLD, + thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR, + size: size ?? DEFAULT_VALUES.SIZE, + }); + + const initSearchSource = () => + data.search.searchSource + .create(searchConfiguration) + .then((fetchedSearchSource) => setSearchSource(fetchedSearchSource)) + .catch(setParamsError); + + initSearchSource(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data.search.searchSource, data.dataViews]); useEffect(() => { - async function initSearchSource() { - try { - const loadedSearchSource = await data.search.searchSource.create(searchConfiguration); - setUsedSearchSource(loadedSearchSource); - } catch (error) { - setParamsError(error); - } - } - if (searchConfiguration) { - initSearchSource(); + if (ruleParams.savedQueryId) { + data.query.savedQueries.getSavedQuery(ruleParams.savedQueryId).then(setSavedQuery); } - }, [data.search.searchSource, searchConfiguration]); + }, [data.query.savedQueries, ruleParams.savedQueryId]); if (paramsError) { return ( @@ -97,124 +83,17 @@ export const SearchSourceExpression = ({ ); } - if (!usedSearchSource) { + if (!searchSource) { return } />; } - const dataView = usedSearchSource.getField('index')!; - const query = usedSearchSource.getField('query')!; - const filters = (usedSearchSource.getField('filter') as Filter[]).filter( - ({ meta }) => !meta.disabled - ); - const dataViews = [dataView]; return ( - - -
- -
-
- - - } - iconType="iInCircle" - /> - - - {query.query !== '' && ( - - )} - {filters.length > 0 && ( - } - display="columns" - /> - )} - - - -
- -
-
- - - setParam('threshold', selectedThresholds) - } - onChangeSelectedThresholdComparator={(selectedThresholdComparator) => - setParam('thresholdComparator', selectedThresholdComparator) - } - /> - - setParam('timeWindowSize', selectedWindowSize) - } - onChangeWindowUnit={(selectedWindowUnit: string) => - setParam('timeWindowUnit', selectedWindowUnit) - } - /> - - -
- -
-
- - { - setParam('size', updatedValue); - }} - /> - -
+ ); }; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx new file mode 100644 index 0000000000000..afd6a156187ee --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx @@ -0,0 +1,269 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Fragment, useCallback, useEffect, useMemo, useReducer, useState } from 'react'; +import deepEqual from 'fast-deep-equal'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiSpacer, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Filter, DataView, Query, ISearchSource } from '@kbn/data-plugin/common'; +import { + ForLastExpression, + IErrorObject, + ThresholdExpression, + ValueExpression, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { SearchBar } from '@kbn/unified-search-plugin/public'; +import { mapAndFlattenFilters, SavedQuery, TimeHistory } from '@kbn/data-plugin/public'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; +import { EsQueryAlertParams, SearchType } from '../types'; +import { DEFAULT_VALUES } from '../constants'; +import { DataViewSelectPopover } from '../../components/data_view_select_popover'; +import { useTriggersAndActionsUiDeps } from '../util'; + +interface LocalState { + index: DataView; + filter: Filter[]; + query: Query; + threshold: number[]; + timeWindowSize: number; + size: number; +} + +interface LocalStateAction { + type: SearchSourceParamsAction['type'] | ('threshold' | 'timeWindowSize' | 'size'); + payload: SearchSourceParamsAction['payload'] | (number[] | number); +} + +type LocalStateReducer = (prevState: LocalState, action: LocalStateAction) => LocalState; + +interface SearchSourceParamsAction { + type: 'index' | 'filter' | 'query'; + payload: DataView | Filter[] | Query; +} + +interface SearchSourceExpressionFormProps { + searchSource: ISearchSource; + ruleParams: EsQueryAlertParams; + errors: IErrorObject; + initialSavedQuery?: SavedQuery; + setParam: (paramField: string, paramValue: unknown) => void; +} + +const isSearchSourceParam = (action: LocalStateAction): action is SearchSourceParamsAction => { + return action.type === 'filter' || action.type === 'index' || action.type === 'query'; +}; + +export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProps) => { + const { data } = useTriggersAndActionsUiDeps(); + const { searchSource, ruleParams, errors, initialSavedQuery, setParam } = props; + const { thresholdComparator, timeWindowUnit } = ruleParams; + const [savedQuery, setSavedQuery] = useState(); + + const timeHistory = useMemo(() => new TimeHistory(new Storage(localStorage)), []); + + useEffect(() => setSavedQuery(initialSavedQuery), [initialSavedQuery]); + + const [{ index: dataView, query, filter: filters, threshold, timeWindowSize, size }, dispatch] = + useReducer( + (currentState, action) => { + if (isSearchSourceParam(action)) { + searchSource.setParent(undefined).setField(action.type, action.payload); + setParam('searchConfiguration', searchSource.getSerializedFields()); + } else { + setParam(action.type, action.payload); + } + return { ...currentState, [action.type]: action.payload }; + }, + { + index: searchSource.getField('index')!, + query: searchSource.getField('query')!, + filter: mapAndFlattenFilters(searchSource.getField('filter') as Filter[]), + threshold: ruleParams.threshold, + timeWindowSize: ruleParams.timeWindowSize, + size: ruleParams.size, + } + ); + const dataViews = useMemo(() => [dataView], [dataView]); + + const onSelectDataView = useCallback( + (newDataViewId) => + data.dataViews + .get(newDataViewId) + .then((newDataView) => dispatch({ type: 'index', payload: newDataView })), + [data.dataViews] + ); + + const onUpdateFilters = useCallback((newFilters) => { + dispatch({ type: 'filter', payload: mapAndFlattenFilters(newFilters) }); + }, []); + + const onChangeQuery = useCallback( + ({ query: newQuery }: { query?: Query }) => { + if (!deepEqual(newQuery, query)) { + dispatch({ type: 'query', payload: newQuery || { ...query, query: '' } }); + } + }, + [query] + ); + + // needs to change language mode only + const onQueryBarSubmit = ({ query: newQuery }: { query?: Query }) => { + if (newQuery?.language !== query.language) { + dispatch({ type: 'query', payload: { ...query, language: newQuery?.language } as Query }); + } + }; + + // Saved query + const onSavedQuery = useCallback((newSavedQuery: SavedQuery) => { + setSavedQuery(newSavedQuery); + const newFilters = newSavedQuery.attributes.filters; + if (newFilters) { + dispatch({ type: 'filter', payload: newFilters }); + } + }, []); + + const onClearSavedQuery = () => { + setSavedQuery(undefined); + dispatch({ type: 'query', payload: { ...query, query: '' } }); + }; + + // window size + const onChangeWindowUnit = useCallback( + (selectedWindowUnit: string) => setParam('timeWindowUnit', selectedWindowUnit), + [setParam] + ); + + const onChangeWindowSize = useCallback( + (selectedWindowSize?: number) => + selectedWindowSize && dispatch({ type: 'timeWindowSize', payload: selectedWindowSize }), + [] + ); + + // threshold + const onChangeSelectedThresholdComparator = useCallback( + (selectedThresholdComparator?: string) => + setParam('thresholdComparator', selectedThresholdComparator), + [setParam] + ); + + const onChangeSelectedThreshold = useCallback( + (selectedThresholds?: number[]) => + selectedThresholds && dispatch({ type: 'threshold', payload: selectedThresholds }), + [] + ); + + const onChangeSizeValue = useCallback( + (updatedValue: number) => dispatch({ type: 'size', payload: updatedValue }), + [] + ); + + return ( + + +
+ +
+
+ + + + + + + + + + + +
+ +
+
+ + + + + +
+ +
+
+ + + +
+ ); +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts index bccf6ed4ced43..703570ad5faae 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts @@ -7,6 +7,9 @@ import { RuleTypeParams } from '@kbn/alerting-plugin/common'; import { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { EuiComboBoxOptionOption } from '@elastic/eui'; +import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { EXPRESSION_ERRORS } from './constants'; export interface Comparator { text: string; @@ -19,7 +22,7 @@ export enum SearchType { searchSource = 'searchSource', } -export interface CommonAlertParams extends RuleTypeParams { +export interface CommonAlertParams extends RuleTypeParams { size: number; thresholdComparator?: string; threshold: number[]; @@ -28,8 +31,8 @@ export interface CommonAlertParams extends RuleTypeParams } export type EsQueryAlertParams = T extends SearchType.searchSource - ? CommonAlertParams & OnlySearchSourceAlertParams - : CommonAlertParams & OnlyEsQueryAlertParams; + ? CommonAlertParams & OnlySearchSourceAlertParams + : CommonAlertParams & OnlyEsQueryAlertParams; export interface OnlyEsQueryAlertParams { esQuery: string; @@ -39,4 +42,15 @@ export interface OnlyEsQueryAlertParams { export interface OnlySearchSourceAlertParams { searchType: 'searchSource'; searchConfiguration: SerializedSearchSourceFields; + savedQueryId?: string; +} + +export type DataViewOption = EuiComboBoxOptionOption; + +export type ExpressionErrors = typeof EXPRESSION_ERRORS; + +export type ErrorKey = keyof ExpressionErrors & unknown; + +export interface TriggersAndActionsUiDeps { + data: DataPublicPluginStart; } diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/util.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/util.ts index 5b70da7cb3e80..1f57a133fa65a 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/util.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/util.ts @@ -5,10 +5,13 @@ * 2.0. */ -import { EsQueryAlertParams, SearchType } from './types'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { EsQueryAlertParams, SearchType, TriggersAndActionsUiDeps } from './types'; export const isSearchSourceAlert = ( ruleParams: EsQueryAlertParams ): ruleParams is EsQueryAlertParams => { return ruleParams.searchType === 'searchSource'; }; + +export const useTriggersAndActionsUiDeps = () => useKibana().services; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts index 914dd6a4f5f9f..8a1135e75492f 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts @@ -5,25 +5,17 @@ * 2.0. */ +import { defaultsDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; import { ValidationResult, builtInComparators } from '@kbn/triggers-actions-ui-plugin/public'; -import { EsQueryAlertParams } from './types'; +import { EsQueryAlertParams, ExpressionErrors } from './types'; import { isSearchSourceAlert } from './util'; +import { EXPRESSION_ERRORS } from './constants'; export const validateExpression = (alertParams: EsQueryAlertParams): ValidationResult => { const { size, threshold, timeWindowSize, thresholdComparator } = alertParams; const validationResult = { errors: {} }; - const errors = { - index: new Array(), - timeField: new Array(), - esQuery: new Array(), - size: new Array(), - threshold0: new Array(), - threshold1: new Array(), - thresholdComparator: new Array(), - timeWindowSize: new Array(), - searchConfiguration: new Array(), - }; + const errors: ExpressionErrors = defaultsDeep({}, EXPRESSION_ERRORS); validationResult.errors = errors; if (!threshold || threshold.length === 0 || threshold[0] === undefined) { errors.threshold0.push( diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap index 65dff2bd3a6c6..fe53610caa316 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap @@ -30,6 +30,7 @@ exports[`should render BoundaryIndexExpression 1`] = ` "ensureDefaultIndexPattern": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "getIdsWithTitle": [MockFunction], "make": [Function], } } @@ -106,6 +107,7 @@ exports[`should render EntityIndexExpression 1`] = ` "ensureDefaultIndexPattern": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "getIdsWithTitle": [MockFunction], "make": [Function], } } @@ -188,6 +190,7 @@ exports[`should render EntityIndexExpression w/ invalid flag if invalid 1`] = ` "ensureDefaultIndexPattern": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "getIdsWithTitle": [MockFunction], "make": [Function], } } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts index 468729fb2120d..884bf606d2f90 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts @@ -20,6 +20,7 @@ describe('ActionContext', () => { timeWindowUnit: 'm', thresholdComparator: '>', threshold: [4], + searchType: 'esQuery', }) as OnlyEsQueryAlertParams; const base: EsQueryAlertActionContext = { date: '2020-01-01T00:00:00.000Z', @@ -50,6 +51,7 @@ describe('ActionContext', () => { timeWindowUnit: 'm', thresholdComparator: 'between', threshold: [4, 5], + searchType: 'esQuery', }) as OnlyEsQueryAlertParams; const base: EsQueryAlertActionContext = { date: '2020-01-01T00:00:00.000Z', diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts index 3fce895a2bfd1..3304ca5e902f7 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts @@ -110,6 +110,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.LT, threshold: [0], + searchType: 'esQuery', }; expect(alertType.validate?.params?.validate(params)).toBeTruthy(); @@ -128,6 +129,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.BETWEEN, threshold: [0], + searchType: 'esQuery', }; expect(() => paramsSchema.validate(params)).toThrowErrorMatchingInlineSnapshot( @@ -145,6 +147,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.BETWEEN, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -174,6 +177,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -219,6 +223,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -267,6 +272,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -309,6 +315,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -380,6 +387,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); @@ -425,6 +433,7 @@ describe('alertType', () => { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; const alertServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices(); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts index 5b41d7c55fe0a..dfab69f445629 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts @@ -7,10 +7,12 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup, Logger } from '@kbn/core/server'; +import { extractReferences, injectReferences } from '@kbn/data-plugin/common'; import { RuleType } from '../../types'; import { ActionContext } from './action_context'; import { EsQueryAlertParams, + EsQueryAlertParamsExtractedParams, EsQueryAlertParamsSchema, EsQueryAlertState, } from './alert_type_params'; @@ -18,13 +20,14 @@ import { STACK_ALERTS_FEATURE_ID } from '../../../common'; import { ExecutorOptions } from './types'; import { ActionGroupId, ES_QUERY_ID } from './constants'; import { executor } from './executor'; +import { isEsQueryAlert } from './util'; export function getAlertType( logger: Logger, core: CoreSetup ): RuleType< EsQueryAlertParams, - never, // Only use if defining useSavedObjectReferences hook + EsQueryAlertParamsExtractedParams, EsQueryAlertState, {}, ActionContext, @@ -159,6 +162,25 @@ export function getAlertType( { name: 'index', description: actionVariableContextIndexLabel }, ], }, + useSavedObjectReferences: { + extractReferences: (params) => { + if (isEsQueryAlert(params.searchType)) { + return { params: params as EsQueryAlertParamsExtractedParams, references: [] }; + } + const [searchConfiguration, references] = extractReferences(params.searchConfiguration); + const newParams = { ...params, searchConfiguration } as EsQueryAlertParamsExtractedParams; + return { params: newParams, references }; + }, + injectReferences: (params, references) => { + if (isEsQueryAlert(params.searchType)) { + return params; + } + return { + ...params, + searchConfiguration: injectReferences(params.searchConfiguration, references), + }; + }, + }, minimumLicenseRequired: 'basic', isExportable: true, executor: async (options: ExecutorOptions) => { diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts index d6ba0468b7cbf..a1155fedb7a02 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts @@ -23,6 +23,7 @@ const DefaultParams: Writable> = { timeWindowUnit: 'm', thresholdComparator: Comparator.GT, threshold: [0], + searchType: 'esQuery', }; describe('alertType Params validate()', () => { diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts index f205fbd0327ce..d32fce9debbc2 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; import { validateTimeWindowUnits } from '@kbn/triggers-actions-ui-plugin/server'; import { RuleTypeState } from '@kbn/alerting-plugin/server'; +import { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { Comparator } from '../../../common/comparator_types'; import { ComparatorFnNames } from '../lib'; import { getComparatorSchemaType } from '../lib/comparator'; @@ -21,13 +22,21 @@ export interface EsQueryAlertState extends RuleTypeState { latestTimestamp: string | undefined; } +export type EsQueryAlertParamsExtractedParams = Omit & { + searchConfiguration: SerializedSearchSourceFields & { + indexRefName: string; + }; +}; + const EsQueryAlertParamsSchemaProperties = { size: schema.number({ min: 0, max: ES_QUERY_MAX_HITS_PER_EXECUTION }), timeWindowSize: schema.number({ min: 1 }), timeWindowUnit: schema.string({ validate: validateTimeWindowUnits }), threshold: schema.arrayOf(schema.number(), { minSize: 1, maxSize: 2 }), thresholdComparator: getComparatorSchemaType(validateComparator), - searchType: schema.nullable(schema.literal('searchSource')), + searchType: schema.oneOf([schema.literal('searchSource'), schema.literal('esQuery')], { + defaultValue: 'esQuery', + }), // searchSource alert param only searchConfiguration: schema.conditional( schema.siblingRef('searchType'), @@ -38,21 +47,21 @@ const EsQueryAlertParamsSchemaProperties = { // esQuery alert params only esQuery: schema.conditional( schema.siblingRef('searchType'), - schema.literal('searchSource'), - schema.never(), - schema.string({ minLength: 1 }) + schema.literal('esQuery'), + schema.string({ minLength: 1 }), + schema.never() ), index: schema.conditional( schema.siblingRef('searchType'), - schema.literal('searchSource'), - schema.never(), - schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }) + schema.literal('esQuery'), + schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }), + schema.never() ), timeField: schema.conditional( schema.siblingRef('searchType'), - schema.literal('searchSource'), - schema.never(), - schema.string({ minLength: 1 }) + schema.literal('esQuery'), + schema.string({ minLength: 1 }), + schema.never() ), }; diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.test.ts index 670f76f5e19de..7b4cc7521654b 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.test.ts @@ -18,6 +18,7 @@ describe('es_query executor', () => { esQuery: '{ "query": "test-query" }', index: ['test-index'], timeField: '', + searchType: 'esQuery', }; describe('tryToParseAsDate', () => { it.each<[string | number]>([['2019-01-01T00:00:00.000Z'], [1546300800000]])( diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts index 44708a1df90fd..6e47c5f471d88 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/executor.ts @@ -16,13 +16,14 @@ import { fetchEsQuery } from './lib/fetch_es_query'; import { EsQueryAlertParams } from './alert_type_params'; import { fetchSearchSourceQuery } from './lib/fetch_search_source_query'; import { Comparator } from '../../../common/comparator_types'; +import { isEsQueryAlert } from './util'; export async function executor( logger: Logger, core: CoreSetup, options: ExecutorOptions ) { - const esQueryAlert = isEsQueryAlert(options); + const esQueryAlert = isEsQueryAlert(options.params.searchType); const { alertId, name, services, params, state } = options; const { alertFactory, scopedClusterClient, searchSourceClient } = services; const currentTimestamp = new Date().toISOString(); @@ -162,10 +163,6 @@ export function tryToParseAsDate(sortValue?: string | number | null): undefined } } -export function isEsQueryAlert(options: ExecutorOptions) { - return options.params.searchType !== 'searchSource'; -} - export function getChecksum(params: EsQueryAlertParams) { return sha256.create().update(JSON.stringify(params)); } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/types.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/types.ts index 12b2ee02af171..8595870a84940 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/types.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/types.ts @@ -10,7 +10,9 @@ import { ActionContext } from './action_context'; import { EsQueryAlertParams, EsQueryAlertState } from './alert_type_params'; import { ActionGroupId } from './constants'; -export type OnlyEsQueryAlertParams = Omit; +export type OnlyEsQueryAlertParams = Omit & { + searchType: 'esQuery'; +}; export type OnlySearchSourceAlertParams = Omit< EsQueryAlertParams, diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/util.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/util.ts new file mode 100644 index 0000000000000..b58a362cd27e9 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/util.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. + */ + +import { EsQueryAlertParams } from './alert_type_params'; + +export function isEsQueryAlert(searchType: EsQueryAlertParams['searchType']) { + return searchType !== 'searchSource'; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx index 1bca80a08c936..6da565b13d91e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx @@ -724,10 +724,10 @@ export const RuleForm = ({ name="interval" data-test-subj="intervalInput" onChange={(e) => { - const interval = - e.target.value !== '' ? parseInt(e.target.value, 10) : undefined; + const value = e.target.value; + const interval = value !== '' ? parseInt(value, 10) : undefined; setRuleInterval(interval); - setScheduleProperty('interval', `${e.target.value}${ruleIntervalUnit}`); + setScheduleProperty('interval', `${value}${ruleIntervalUnit}`); }} />
diff --git a/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts b/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts index bae045fc93838..2cb77ac262ca6 100644 --- a/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts +++ b/x-pack/test/functional_with_es_ssl/apps/discover/search_source_alert.ts @@ -30,6 +30,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const supertest = getService('supertest'); const queryBar = getService('queryBar'); const security = getService('security'); + const filterBar = getService('filterBar'); const SOURCE_DATA_INDEX = 'search-source-alert'; const OUTPUT_DATA_INDEX = 'search-source-alert-output'; @@ -47,17 +48,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { mappings: { properties: { '@timestamp': { type: 'date' }, - message: { type: 'text' }, + message: { type: 'keyword' }, }, }, }, }); const generateNewDocs = async (docsNumber: number) => { - const mockMessages = new Array(docsNumber).map((current) => `msg-${current}`); + const mockMessages = Array.from({ length: docsNumber }, (_, i) => `msg-${i}`); const dateNow = new Date().toISOString(); - for (const message of mockMessages) { - await es.transport.request({ + for await (const message of mockMessages) { + es.transport.request({ path: `/${SOURCE_DATA_INDEX}/_doc`, method: 'POST', body: { @@ -212,7 +213,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await navigateToDiscover(link); }; - const openAlertRule = async () => { + const openAlertRuleInManagement = async () => { await PageObjects.common.navigateToApp('management'); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -229,7 +230,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { before(async () => { await security.testUser.setRoles(['discover_alert']); - log.debug('create source index'); + log.debug('create source indices'); await createSourceIndex(); log.debug('generate documents'); @@ -250,8 +251,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); after(async () => { - // delete only remaining output index - await es.transport.request({ + es.transport.request({ path: `/${OUTPUT_DATA_INDEX}`, method: 'DELETE', }); @@ -272,7 +272,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await defineSearchSourceAlert(RULE_NAME); await PageObjects.header.waitUntilLoadingHasFinished(); - await openAlertRule(); + await openAlertRuleInManagement(); await testSubjects.click('ruleDetails-viewInApp'); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -298,10 +298,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should display warning about updated alert rule', async () => { - await openAlertRule(); + await openAlertRuleInManagement(); // change rule configuration await testSubjects.click('openEditRuleFlyoutButton'); + await queryBar.setQuery('message:msg-1'); + await filterBar.addFilter('message.keyword', 'is', 'msg-1'); + await testSubjects.click('thresholdPopover'); await testSubjects.setValue('alertThresholdInput', '1'); await testSubjects.click('saveEditedRuleButton'); @@ -311,7 +314,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await navigateToResults(); const { message, title } = await getLastToast(); - expect(await dataGrid.getDocCount()).to.be(5); + const queryString = await queryBar.getQueryString(); + const hasFilter = await filterBar.hasFilter('message.keyword', 'msg-1'); + + expect(queryString).to.be.equal('message:msg-1'); + expect(hasFilter).to.be.equal(true); + + expect(await dataGrid.getDocCount()).to.be(1); expect(title).to.be.equal('Alert rule has changed'); expect(message).to.be.equal( 'The displayed documents might not match the documents that triggered the alert because the rule configuration changed.' From 7e15097379841b2923a111629d53b6b560c44dd9 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Fri, 20 May 2022 07:32:27 -0700 Subject: [PATCH 16/39] [ML] Adds placeholder text for testing NLP models (#132486) --- .../test_models/models/ner/ner_inference.ts | 7 +++++-- .../models/text_classification/fill_mask_inference.ts | 5 +++-- .../models/text_classification/lang_ident_inference.ts | 9 ++++++++- .../text_classification/text_classification_inference.ts | 9 ++++++++- .../models/text_embedding/text_embedding_inference.ts | 9 ++++++++- 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts index 13f07d8c88770..7d780559fb47d 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts @@ -6,7 +6,7 @@ */ import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - +import { i18n } from '@kbn/i18n'; import { InferenceBase, InferResponse } from '../inference_base'; import { getGeneralInputComponent } from '../text_input'; import { getNerOutputComponent } from './ner_output'; @@ -52,7 +52,10 @@ export class NerInference extends InferenceBase { } public getInputComponent(): JSX.Element { - return getGeneralInputComponent(this); + const placeholder = i18n.translate('xpack.ml.trainedModels.testModelsFlyout.ner.inputText', { + defaultMessage: 'Enter a phrase to test', + }); + return getGeneralInputComponent(this, placeholder); } public getOutputComponent(): JSX.Element { diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts index bb4feaffffb38..b9c1c724ca348 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts @@ -55,9 +55,10 @@ export class FillMaskInference extends InferenceBase public getInputComponent(): JSX.Element { const placeholder = i18n.translate( - 'xpack.ml.trainedModels.testModelsFlyout.langIdent.inputText', + 'xpack.ml.trainedModels.testModelsFlyout.fillMask.inputText', { - defaultMessage: 'Mask token: [MASK]. e.g. Paris is the [MASK] of France.', + defaultMessage: + 'Enter a phrase to test. Use [MASK] as a placeholder for the missing words.', } ); diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_inference.ts index a56d4a3598a66..155b696fa7665 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_inference.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { InferenceBase, InferenceType } from '../inference_base'; import { processResponse } from './common'; import { getGeneralInputComponent } from '../text_input'; @@ -44,7 +45,13 @@ export class LangIdentInference extends InferenceBase } public getInputComponent(): JSX.Element { - return getGeneralInputComponent(this); + const placeholder = i18n.translate( + 'xpack.ml.trainedModels.testModelsFlyout.textEmbedding.inputText', + { + defaultMessage: 'Enter a phrase to test', + } + ); + return getGeneralInputComponent(this, placeholder); } public getOutputComponent(): JSX.Element { From 759f13f50f87365681c1baa98607e9b385567d60 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Fri, 20 May 2022 10:39:09 -0400 Subject: [PATCH 17/39] [Fleet] Remove reference to non removable package feature (#132458) --- .../context/fixtures/integration.nginx.ts | 1 - .../context/fixtures/integration.okta.ts | 1 - .../plugins/fleet/common/openapi/bundled.json | 3 - .../plugins/fleet/common/openapi/bundled.yaml | 2 - .../components/schemas/package_info.yaml | 2 - .../common/services/fixtures/aws_package.ts | 1 - .../plugins/fleet/common/types/models/epm.ts | 1 - .../create_package_policy_page/index.test.tsx | 1 - .../step_configure_package.test.tsx | 1 - .../edit_package_policy_page/index.test.tsx | 1 - .../epm/screens/detail/index.test.tsx | 1 - .../epm/screens/detail/settings/settings.tsx | 76 ++++++++----------- .../fleet/server/saved_objects/index.ts | 3 +- .../saved_objects/migrations/to_v8_3_0.ts | 19 +++++ .../fleet/server/services/epm/packages/get.ts | 1 - .../server/services/epm/packages/install.ts | 2 - .../server/services/epm/packages/remove.ts | 4 +- ...kage_policies_to_agent_permissions.test.ts | 1 - .../common/endpoint/generate_data.ts | 1 - .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../apis/epm/install_remove_assets.ts | 1 - .../apis/epm/update_assets.ts | 1 - .../test_packages/filetest/0.1.0/manifest.yml | 2 - .../0.1.0/manifest.yml | 2 - 26 files changed, 52 insertions(+), 79 deletions(-) create mode 100644 x-pack/plugins/fleet/server/saved_objects/migrations/to_v8_3_0.ts diff --git a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts index d74d7656ad58e..8f47d564c44a2 100644 --- a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts +++ b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts @@ -664,6 +664,5 @@ export const item: GetInfoResponse['item'] = { github: 'elastic/integrations', }, latestVersion: '0.7.0', - removable: true, status: 'not_installed', }; diff --git a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts index 1f4b9e85043a6..8778938443661 100644 --- a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts +++ b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts @@ -263,6 +263,5 @@ export const item: GetInfoResponse['item'] = { github: 'elastic/security-external-integrations', }, latestVersion: '1.2.0', - removable: true, status: 'not_installed', }; diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index dca3fd3ccb678..ba18b78d5f768 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -3573,9 +3573,6 @@ }, "path": { "type": "string" - }, - "removable": { - "type": "boolean" } }, "required": [ diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index d1a114b35ab6c..e18fe6b8fc3f8 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -2228,8 +2228,6 @@ components: type: string path: type: string - removable: - type: boolean required: - name - title diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/package_info.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/package_info.yaml index ec4f18af8a223..e61c349f3f490 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/package_info.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/package_info.yaml @@ -102,8 +102,6 @@ properties: type: string path: type: string - removable: - type: boolean required: - name - title diff --git a/x-pack/plugins/fleet/common/services/fixtures/aws_package.ts b/x-pack/plugins/fleet/common/services/fixtures/aws_package.ts index 2b93cca3d4e4d..63397e484a7df 100644 --- a/x-pack/plugins/fleet/common/services/fixtures/aws_package.ts +++ b/x-pack/plugins/fleet/common/services/fixtures/aws_package.ts @@ -1921,7 +1921,6 @@ export const AWS_PACKAGE = { }, ], latestVersion: '0.5.3', - removable: true, status: 'not_installed', }; diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index c7951e86d7866..cb5d8f3bb009b 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -372,7 +372,6 @@ export interface EpmPackageAdditions { title: string; latestVersion: string; assets: AssetsGroupedByServiceByType; - removable?: boolean; notice?: string; keepPoliciesUpToDate?: boolean; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.test.tsx index 0f719f6a61585..4a13f117ec6ba 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.test.tsx @@ -164,7 +164,6 @@ describe('when on the package policy create page', () => { }, ], latestVersion: '1.3.0', - removable: true, keepPoliciesUpToDate: false, status: 'not_installed', }, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_configure_package.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_configure_package.test.tsx index 543747307908e..ff4c39af799f2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_configure_package.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_configure_package.test.tsx @@ -96,7 +96,6 @@ describe('StepConfigurePackage', () => { }, ], latestVersion: '1.3.0', - removable: true, keepPoliciesUpToDate: false, status: 'not_installed', }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx index 3a5050b1b6d06..464f705811ebf 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx @@ -89,7 +89,6 @@ jest.mock('../../../hooks', () => { }, ], latestVersion: version, - removable: true, keepPoliciesUpToDate: false, status: 'not_installed', }, diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.test.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.test.tsx index e4341af45cf41..9d46c636150d3 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.test.tsx @@ -509,7 +509,6 @@ const mockApiCalls = ( ], owner: { github: 'elastic/integrations-services' }, latestVersion: '0.3.7', - removable: true, status: 'installed', }, } as GetInfoResponse; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx index 05ff443a7b0e6..d84fab93dc8c2 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx @@ -97,7 +97,7 @@ interface Props { } export const SettingsPage: React.FC = memo(({ packageInfo, theme$ }: Props) => { - const { name, title, removable, latestVersion, version, keepPoliciesUpToDate } = packageInfo; + const { name, title, latestVersion, version, keepPoliciesUpToDate } = packageInfo; const [dryRunData, setDryRunData] = useState(); const [isUpgradingPackagePolicies, setIsUpgradingPackagePolicies] = useState(false); const getPackageInstallStatus = useGetPackageInstallStatus(); @@ -342,41 +342,39 @@ export const SettingsPage: React.FC = memo(({ packageInfo, theme$ }: Prop
) : ( - removable && ( - <> -
- -

- -

-
- -

+ <> +

+ +

+

+
+ +

+ +

+
+ + +

+

-
- - -

- -

-
-
- - ) + + + )} - {packageHasUsages && removable === true && ( + {packageHasUsages && (

= memo(({ packageInfo, theme$ }: Prop

)} - {removable === false && ( -

- - , - }} - /> - -

- )} )} {hideInstallOptions && isViewingOldPackage && !isUpdating && ( diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 2a8f14f795f7c..edcf2ed751f3e 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -38,6 +38,7 @@ import { migratePackagePolicyToV7150 } from './migrations/to_v7_15_0'; import { migrateInstallationToV7160, migratePackagePolicyToV7160 } from './migrations/to_v7_16_0'; import { migrateInstallationToV800, migrateOutputToV800 } from './migrations/to_v8_0_0'; import { migratePackagePolicyToV820 } from './migrations/to_v8_2_0'; +import { migrateInstallationToV830 } from './migrations/to_v8_3_0'; /* * Saved object types and mappings @@ -223,7 +224,6 @@ const getSavedObjectTypes = ( name: { type: 'keyword' }, version: { type: 'keyword' }, internal: { type: 'boolean' }, - removable: { type: 'boolean' }, keep_policies_up_to_date: { type: 'boolean', index: false }, es_index_patterns: { enabled: false, @@ -262,6 +262,7 @@ const getSavedObjectTypes = ( '7.14.1': migrateInstallationToV7140, '7.16.0': migrateInstallationToV7160, '8.0.0': migrateInstallationToV800, + '8.3.0': migrateInstallationToV830, }, }, [ASSETS_SAVED_OBJECT_TYPE]: { diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/to_v8_3_0.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v8_3_0.ts new file mode 100644 index 0000000000000..843427f3cf862 --- /dev/null +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v8_3_0.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectMigrationFn } from '@kbn/core/server'; + +import type { Installation } from '../../../common'; + +export const migrateInstallationToV830: SavedObjectMigrationFn = ( + installationDoc, + migrationContext +) => { + delete installationDoc.attributes.removable; + + return installationDoc; +}; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get.ts b/x-pack/plugins/fleet/server/services/epm/packages/get.ts index 27468e77c8e9f..acd5761919a16 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get.ts @@ -176,7 +176,6 @@ export async function getPackageInfo({ : resolvedPkgVersion, title: packageInfo.title || nameAsTitle(packageInfo.name), assets: Registry.groupPathsByService(paths || []), - removable: true, notice: Registry.getNoticePath(paths || []), keepPoliciesUpToDate: savedObject?.attributes.keep_policies_up_to_date ?? false, }; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts index c7fc01c89eb06..6bbb91ada321c 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -598,7 +598,6 @@ export async function createInstallation(options: { ? true : undefined; - // TODO cleanup removable flag and isUnremovablePackage function const created = await savedObjectsClient.create( PACKAGES_SAVED_OBJECT_TYPE, { @@ -609,7 +608,6 @@ export async function createInstallation(options: { es_index_patterns: toSaveESIndexPatterns, name: pkgName, version: pkgVersion, - removable: true, install_version: pkgVersion, install_status: 'installing', install_started_at: new Date().toISOString(), diff --git a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts index 95e65acfebef6..53e001aeee8d0 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts @@ -44,11 +44,9 @@ export async function removeInstallation(options: { esClient: ElasticsearchClient; force?: boolean; }): Promise { - const { savedObjectsClient, pkgName, pkgVersion, esClient, force } = options; + const { savedObjectsClient, pkgName, pkgVersion, esClient } = options; const installation = await getInstallation({ savedObjectsClient, pkgName }); if (!installation) throw Boom.badRequest(`${pkgName} is not installed`); - if (installation.removable === false && !force) - throw Boom.badRequest(`${pkgName} is installed by default and cannot be removed`); const { total } = await packagePolicyService.list(savedObjectsClient, { kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${pkgName}`, diff --git a/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts b/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts index 6bc56e8316da6..5c63d0ba5dca1 100644 --- a/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts @@ -391,7 +391,6 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { }, ], latestVersion: '0.3.0', - removable: true, notice: undefined, status: 'not_installed', assets: { diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 5a6b20550f224..35eb9de6d4060 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -1764,7 +1764,6 @@ export class EndpointDocGenerator extends BaseDataGenerator { name: 'endpoint', version: '0.5.0', internal: false, - removable: false, install_version: '0.5.0', install_status: 'installed', install_started_at: '2020-06-24T14:41:23.098Z', diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 8bd7308a27a70..85ea8a0ffc348 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -12839,7 +12839,6 @@ "xpack.fleet.integrations.settings.packageUninstallDescription": "Supprimez les ressources Kibana et Elasticsearch installées par cette intégration.", "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteDetail": "{strongNote} Impossible d'installer {title}, car des agents actifs utilisent cette intégration. Pour procéder à la désinstallation, supprimez toutes les intégrations {title} de vos stratégies d'agent.", "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteLabel": "Remarque :", - "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallUninstallableNoteDetail": "{strongNote} L'intégration de {title} est une intégration système. Vous ne pouvez pas la supprimer.", "xpack.fleet.integrations.settings.packageUninstallTitle": "Désinstaller", "xpack.fleet.integrations.settings.packageVersionTitle": "Version de {title}", "xpack.fleet.integrations.settings.versionInfo.installedVersion": "Version installée", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 12300057ca7ff..cf84dbd2d6305 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12946,7 +12946,6 @@ "xpack.fleet.integrations.settings.packageUninstallDescription": "この統合によってインストールされたKibanaおよびElasticsearchアセットを削除します。", "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteDetail": "{strongNote} {title}をアンインストールできません。この統合を使用しているアクティブなエージェントがあります。アンインストールするには、エージェントポリシーからすべての{title}統合を削除します。", "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteLabel": "注:", - "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallUninstallableNoteDetail": "{strongNote} {title}統合はシステム統合であるため、削除できません。", "xpack.fleet.integrations.settings.packageUninstallTitle": "アンインストール", "xpack.fleet.integrations.settings.packageVersionTitle": "{title}バージョン", "xpack.fleet.integrations.settings.versionInfo.installedVersion": "インストールされているバージョン", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 5953802b0a0a5..b15cacd8dc8ab 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12970,7 +12970,6 @@ "xpack.fleet.integrations.settings.packageUninstallDescription": "移除此集成安装的 Kibana 和 Elasticsearch 资产。", "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteDetail": "{strongNote}{title} 无法卸载,因为存在使用此集成的活动代理。要卸载,请从您的代理策略中移除所有 {title} 集成。", "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteLabel": "注意:", - "xpack.fleet.integrations.settings.packageUninstallNoteDescription.packageUninstallUninstallableNoteDetail": "{strongNote}{title} 集成是系统集成,无法移除。", "xpack.fleet.integrations.settings.packageUninstallTitle": "卸载", "xpack.fleet.integrations.settings.packageVersionTitle": "{title} 版本", "xpack.fleet.integrations.settings.versionInfo.installedVersion": "已安装版本", diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts index ddb9317789069..0d06a1ca9e0f7 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts @@ -738,7 +738,6 @@ const expectAssetsInstalled = ({ }, name: 'all_assets', version: '0.1.0', - removable: true, install_version: '0.1.0', install_status: 'installed', install_started_at: res.attributes.install_started_at, diff --git a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts index 6cbedf68da567..e367e76049b72 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts @@ -498,7 +498,6 @@ export default function (providerContext: FtrProviderContext) { ], name: 'all_assets', version: '0.2.0', - removable: true, install_version: '0.2.0', install_status: 'installed', install_started_at: res.attributes.install_started_at, diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml index ec3586689becf..c4fb3f967913d 100644 --- a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/filetest/0.1.0/manifest.yml @@ -10,8 +10,6 @@ release: beta # The default type is integration and will be set if empty. type: integration license: basic -# This package can be removed -removable: true requirement: elasticsearch: diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/manifest.yml index f1ed5a8a5a78b..472888818e717 100644 --- a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/manifest.yml +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/with_required_variables/0.1.0/manifest.yml @@ -10,8 +10,6 @@ release: beta # The default type is integration and will be set if empty. type: integration license: basic -# This package can be removed -removable: true requirement: elasticsearch: From 1b4ac7d2719b64ec22c5c50a7e245e37d9e148fe Mon Sep 17 00:00:00 2001 From: Yaroslav Kuznietsov Date: Fri, 20 May 2022 17:54:13 +0300 Subject: [PATCH 18/39] [XY] Reference lines overlay fix. (#132607) --- .../reference_line.test.ts | 4 + .../common/types/expression_functions.ts | 3 +- .../reference_lines/reference_line.tsx | 4 +- .../reference_lines/reference_lines.test.tsx | 18 ++--- .../reference_lines/reference_lines.tsx | 53 ++++---------- .../components/reference_lines/utils.tsx | 73 ++++++++++++++++++- 6 files changed, 103 insertions(+), 52 deletions(-) diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts index b96f39923fab2..4c7c2e3dc628f 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line.test.ts @@ -14,6 +14,7 @@ describe('referenceLine', () => { test('produces the correct arguments for minimum arguments', async () => { const args: ReferenceLineArgs = { value: 100, + fill: 'above', }; const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); @@ -67,6 +68,7 @@ describe('referenceLine', () => { const args: ReferenceLineArgs = { name: 'some name', value: 100, + fill: 'none', }; const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); @@ -90,6 +92,7 @@ describe('referenceLine', () => { const args: ReferenceLineArgs = { value: 100, textVisibility: true, + fill: 'none', }; const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); @@ -115,6 +118,7 @@ describe('referenceLine', () => { value: 100, name: 'some text', textVisibility, + fill: 'none', }; const result = referenceLineFunction.fn(null, args, createMockExecutionContext()); diff --git a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts index 05447607bc194..502bb39cda894 100644 --- a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts @@ -297,9 +297,10 @@ export type ExtendedAnnotationLayerConfigResult = ExtendedAnnotationLayerArgs & layerType: typeof LayerTypes.ANNOTATIONS; }; -export interface ReferenceLineArgs extends Omit { +export interface ReferenceLineArgs extends Omit { name?: string; value: number; + fill: FillStyle; } export interface ReferenceLineLayerArgs { diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx index 74bb18597f2f2..30f4a97986ec3 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_line.tsx @@ -19,6 +19,7 @@ interface ReferenceLineProps { formatters: Record<'left' | 'right' | 'bottom', FieldFormat | undefined>; axesMap: Record<'left' | 'right', boolean>; isHorizontal: boolean; + nextValue?: number; } export const ReferenceLine: FC = ({ @@ -27,6 +28,7 @@ export const ReferenceLine: FC = ({ formatters, paddingMap, isHorizontal, + nextValue, }) => { const { yConfig: [yConfig], @@ -46,7 +48,7 @@ export const ReferenceLine: FC = ({ return ( { ['yAccessorLeft', 'below'], ['yAccessorRight', 'above'], ['yAccessorRight', 'below'], - ] as Array<[string, ExtendedYConfig['fill']]>)( + ] as Array<[string, Exclude]>)( 'should render a RectAnnotation for a reference line with fill set: %s %s', (layerPrefix, fill) => { const axisMode = getAxisFromId(layerPrefix); @@ -154,7 +154,7 @@ describe('ReferenceLines', () => { it.each([ ['xAccessor', 'above'], ['xAccessor', 'below'], - ] as Array<[string, ExtendedYConfig['fill']]>)( + ] as Array<[string, Exclude]>)( 'should render a RectAnnotation for a reference line with fill set: %s %s', (layerPrefix, fill) => { const wrapper = shallow( @@ -196,7 +196,7 @@ describe('ReferenceLines', () => { ['yAccessorLeft', 'below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], ['yAccessorRight', 'above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], ['yAccessorRight', 'below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], - ] as Array<[string, ExtendedYConfig['fill'], YCoords, YCoords]>)( + ] as Array<[string, Exclude, YCoords, YCoords]>)( 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', (layerPrefix, fill, coordsA, coordsB) => { const axisMode = getAxisFromId(layerPrefix); @@ -252,7 +252,7 @@ describe('ReferenceLines', () => { it.each([ ['xAccessor', 'above', { x0: 1, x1: 2 }, { x0: 2, x1: undefined }], ['xAccessor', 'below', { x0: undefined, x1: 1 }, { x0: 1, x1: 2 }], - ] as Array<[string, ExtendedYConfig['fill'], XCoords, XCoords]>)( + ] as Array<[string, Exclude, XCoords, XCoords]>)( 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', (layerPrefix, fill, coordsA, coordsB) => { const wrapper = shallow( @@ -361,7 +361,7 @@ describe('ReferenceLines', () => { it.each([ ['above', { y0: 5, y1: 10 }, { y0: 10, y1: undefined }], ['below', { y0: undefined, y1: 5 }, { y0: 5, y1: 10 }], - ] as Array<[ExtendedYConfig['fill'], YCoords, YCoords]>)( + ] as Array<[Exclude, YCoords, YCoords]>)( 'should be robust and works also for different axes when on same direction: 1x Left + 1x Right both %s', (fill, coordsA, coordsB) => { const wrapper = shallow( @@ -438,7 +438,7 @@ describe('ReferenceLines', () => { ['yAccessorLeft', 'below'], ['yAccessorRight', 'above'], ['yAccessorRight', 'below'], - ] as Array<[string, ExtendedYConfig['fill']]>)( + ] as Array<[string, Exclude]>)( 'should render a RectAnnotation for a reference line with fill set: %s %s', (layerPrefix, fill) => { const axisMode = getAxisFromId(layerPrefix); @@ -479,7 +479,7 @@ describe('ReferenceLines', () => { it.each([ ['xAccessor', 'above'], ['xAccessor', 'below'], - ] as Array<[string, ExtendedYConfig['fill']]>)( + ] as Array<[string, Exclude]>)( 'should render a RectAnnotation for a reference line with fill set: %s %s', (layerPrefix, fill) => { const value = 1; @@ -519,7 +519,7 @@ describe('ReferenceLines', () => { it.each([ ['yAccessorLeft', 'above', { y0: 10, y1: undefined }, { y0: 10, y1: undefined }], ['yAccessorLeft', 'below', { y0: undefined, y1: 5 }, { y0: undefined, y1: 5 }], - ] as Array<[string, ExtendedYConfig['fill'], YCoords, YCoords]>)( + ] as Array<[string, Exclude, YCoords, YCoords]>)( 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', (layerPrefix, fill, coordsA, coordsB) => { const axisMode = getAxisFromId(layerPrefix); @@ -570,7 +570,7 @@ describe('ReferenceLines', () => { it.each([ ['xAccessor', 'above', { x0: 1, x1: undefined }, { x0: 1, x1: undefined }], ['xAccessor', 'below', { x0: undefined, x1: 1 }, { x0: undefined, x1: 1 }], - ] as Array<[string, ExtendedYConfig['fill'], XCoords, XCoords]>)( + ] as Array<[string, Exclude, XCoords, XCoords]>)( 'should avoid overlap between two reference lines with fill in the same direction: 2 x %s %s', (layerPrefix, fill, coordsA, coordsB) => { const value = coordsA.x0 ?? coordsA.x1!; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx index 9dca7b6107072..5d48c3c05166d 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/reference_lines.tsx @@ -11,44 +11,11 @@ import './reference_lines.scss'; import React from 'react'; import { Position } from '@elastic/charts'; import type { FieldFormat } from '@kbn/field-formats-plugin/common'; -import type { CommonXYReferenceLineLayerConfig } from '../../../common/types'; -import { isReferenceLine, mapVerticalToHorizontalPlacement } from '../../helpers'; +import type { CommonXYReferenceLineLayerConfig, ReferenceLineConfig } from '../../../common/types'; +import { isReferenceLine } from '../../helpers'; import { ReferenceLineLayer } from './reference_line_layer'; import { ReferenceLine } from './reference_line'; - -export const computeChartMargins = ( - referenceLinePaddings: Partial>, - labelVisibility: Partial>, - titleVisibility: Partial>, - axesMap: Record<'left' | 'right', unknown>, - isHorizontal: boolean -) => { - const result: Partial> = {}; - if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; - result[placement] = referenceLinePaddings.bottom; - } - if ( - referenceLinePaddings.left && - (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) - ) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; - result[placement] = referenceLinePaddings.left; - } - if ( - referenceLinePaddings.right && - (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) - ) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; - result[placement] = referenceLinePaddings.right; - } - // there's no top axis, so just check if a margin has been computed - if (referenceLinePaddings.top) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; - result[placement] = referenceLinePaddings.top; - } - return result; -}; +import { getNextValuesForReferenceLines } from './utils'; export interface ReferenceLinesProps { layers: CommonXYReferenceLineLayerConfig[]; @@ -59,6 +26,12 @@ export interface ReferenceLinesProps { } export const ReferenceLines = ({ layers, ...rest }: ReferenceLinesProps) => { + const referenceLines = layers.filter((layer): layer is ReferenceLineConfig => + isReferenceLine(layer) + ); + + const referenceLinesNextValues = getNextValuesForReferenceLines(referenceLines); + return ( <> {layers.flatMap((layer) => { @@ -66,13 +39,13 @@ export const ReferenceLines = ({ layers, ...rest }: ReferenceLinesProps) => { return null; } + const key = `referenceLine-${layer.layerId}`; if (isReferenceLine(layer)) { - return ; + const nextValue = referenceLinesNextValues[layer.yConfig[0].fill][layer.layerId]; + return ; } - return ( - - ); + return ; })} ); diff --git a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx index 1a6eae6a490e6..85d96c573f314 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/reference_lines/utils.tsx @@ -10,7 +10,9 @@ import React from 'react'; import { Position } from '@elastic/charts'; import { euiLightVars } from '@kbn/ui-theme'; import { FieldFormat } from '@kbn/field-formats-plugin/common'; -import { IconPosition, YAxisMode } from '../../../common/types'; +import { groupBy, orderBy } from 'lodash'; +import { IconPosition, ReferenceLineConfig, YAxisMode, FillStyle } from '../../../common/types'; +import { FillStyles } from '../../../common/constants'; import { LINES_MARKER_SIZE, mapVerticalToHorizontalPlacement, @@ -141,3 +143,72 @@ export const getHorizontalRect = ( header: headerLabel, details: formatter?.convert(currentValue) || currentValue.toString(), }); + +const sortReferenceLinesByGroup = (referenceLines: ReferenceLineConfig[], group: FillStyle) => { + if (group === FillStyles.ABOVE || group === FillStyles.BELOW) { + const order = group === FillStyles.ABOVE ? 'asc' : 'desc'; + return orderBy(referenceLines, ({ yConfig: [{ value }] }) => value, [order]); + } + return referenceLines; +}; + +export const getNextValuesForReferenceLines = (referenceLines: ReferenceLineConfig[]) => { + const grouppedReferenceLines = groupBy(referenceLines, ({ yConfig: [yConfig] }) => yConfig.fill); + const groups = Object.keys(grouppedReferenceLines) as FillStyle[]; + + return groups.reduce>>( + (nextValueByDirection, group) => { + const sordedReferenceLines = sortReferenceLinesByGroup(grouppedReferenceLines[group], group); + + const nv = sordedReferenceLines.reduce>( + (nextValues, referenceLine, index, lines) => { + let nextValue: number | undefined; + if (index < lines.length - 1) { + const [yConfig] = lines[index + 1].yConfig; + nextValue = yConfig.value; + } + + return { ...nextValues, [referenceLine.layerId]: nextValue }; + }, + {} + ); + + return { ...nextValueByDirection, [group]: nv }; + }, + {} as Record> + ); +}; + +export const computeChartMargins = ( + referenceLinePaddings: Partial>, + labelVisibility: Partial>, + titleVisibility: Partial>, + axesMap: Record<'left' | 'right', unknown>, + isHorizontal: boolean +) => { + const result: Partial> = {}; + if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; + result[placement] = referenceLinePaddings.bottom; + } + if ( + referenceLinePaddings.left && + (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; + result[placement] = referenceLinePaddings.left; + } + if ( + referenceLinePaddings.right && + (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; + result[placement] = referenceLinePaddings.right; + } + // there's no top axis, so just check if a margin has been computed + if (referenceLinePaddings.top) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; + result[placement] = referenceLinePaddings.top; + } + return result; +}; From d9f141a3e1bd4c9918b75e3b08d8a0ceac2cbf2c Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Fri, 20 May 2022 11:37:35 -0400 Subject: [PATCH 19/39] [Security Solution] Telemetry for Event Filters counts on both user and global entries (#132542) --- .../security_solution/server/lib/telemetry/tasks/endpoint.ts | 2 ++ .../plugins/security_solution/server/lib/telemetry/types.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts index 59bc07f8ca2eb..f6e3ca6e9d8ef 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts @@ -256,6 +256,7 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) { malicious_behavior_rules: maliciousBehaviorRules, system_impact: systemImpact, threads, + event_filter: eventFilter, } = endpoint.endpoint_metrics.Endpoint.metrics; const endpointPolicyDetail = extractEndpointPolicyConfig(policyConfig); @@ -275,6 +276,7 @@ export function createTelemetryEndpointTaskConfig(maxTelemetryBatch: number) { maliciousBehaviorRules, systemImpact, threads, + eventFilter, }, endpoint_meta: { os: endpoint.endpoint_metrics.host.os, diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts index 15c92740e3a71..d70a011ea85aa 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts @@ -233,6 +233,10 @@ export interface EndpointMetrics { library_load_events?: SystemImpactEventsMetrics; }>; threads: Array<{ name: string; cpu: { mean: number } }>; + event_filter: { + active_global_count: number; + active_user_count: number; + }; } interface EndpointMetricOS { From f70b4af7f2a5119bab5d56fb7a79d08f268570aa Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Fri, 20 May 2022 12:22:08 -0400 Subject: [PATCH 20/39] [Fleet] Fix rolling upgrade CANCEL and UI fixes (#132625) --- .../hooks/use_current_upgrades.tsx | 8 ++--- .../sections/agents/agent_list_page/index.tsx | 4 +-- .../server/services/agents/actions.test.ts | 30 +++++++++++++++++++ .../fleet/server/services/agents/actions.ts | 14 +++++++++ .../fleet/server/services/agents/upgrade.ts | 2 ++ 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx index 02463025c86db..cdec2ad667be4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_current_upgrades.tsx @@ -12,9 +12,9 @@ import { sendGetCurrentUpgrades, sendPostCancelAction, useStartServices } from ' import type { CurrentUpgrade } from '../../../../types'; -const POLL_INTERVAL = 30 * 1000; +const POLL_INTERVAL = 2 * 60 * 1000; // 2 minutes -export function useCurrentUpgrades() { +export function useCurrentUpgrades(onAbortSuccess: () => void) { const [currentUpgrades, setCurrentUpgrades] = useState([]); const currentTimeoutRef = useRef(); const isCancelledRef = useRef(false); @@ -65,7 +65,7 @@ export function useCurrentUpgrades() { return; } await sendPostCancelAction(currentUpgrade.actionId); - await refreshUpgrades(); + await Promise.all([refreshUpgrades(), onAbortSuccess()]); } catch (err) { notifications.toasts.addError(err, { title: i18n.translate('xpack.fleet.currentUpgrade.abortRequestError', { @@ -74,7 +74,7 @@ export function useCurrentUpgrades() { }); } }, - [refreshUpgrades, notifications.toasts, overlays] + [refreshUpgrades, notifications.toasts, overlays, onAbortSuccess] ); // Poll for upgrades diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index 7ddf9b0f332f8..bbea3284f72b8 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -338,7 +338,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }, [flyoutContext]); // Current upgrades - const { abortUpgrade, currentUpgrades, refreshUpgrades } = useCurrentUpgrades(); + const { abortUpgrade, currentUpgrades, refreshUpgrades } = useCurrentUpgrades(fetchData); const columns = [ { @@ -545,7 +545,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { selectionMode={selectionMode} currentQuery={kuery} selectedAgents={selectedAgents} - refreshAgents={() => fetchData()} + refreshAgents={() => Promise.all([fetchData(), refreshUpgrades()])} /> {/* Agent total, bulk actions and status bar */} diff --git a/x-pack/plugins/fleet/server/services/agents/actions.test.ts b/x-pack/plugins/fleet/server/services/agents/actions.test.ts index 2838f2204ad96..97d7c73035e6d 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.test.ts @@ -8,6 +8,11 @@ import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; import { cancelAgentAction } from './actions'; +import { bulkUpdateAgents } from './crud'; + +jest.mock('./crud'); + +const mockedBulkUpdateAgents = bulkUpdateAgents as jest.Mock; describe('Agent actions', () => { describe('cancelAgentAction', () => { @@ -67,5 +72,30 @@ describe('Agent actions', () => { }) ); }); + + it('should cancel UPGRADE action', async () => { + const esClient = elasticsearchServiceMock.createInternalClient(); + esClient.search.mockResolvedValue({ + hits: { + hits: [ + { + _source: { + type: 'UPGRADE', + action_id: 'action1', + agents: ['agent1', 'agent2'], + expiration: '2022-05-12T18:16:18.019Z', + }, + }, + ], + }, + } as any); + await cancelAgentAction(esClient, 'action1'); + + expect(mockedBulkUpdateAgents).toBeCalled(); + expect(mockedBulkUpdateAgents).toBeCalledWith(expect.anything(), [ + expect.objectContaining({ agentId: 'agent1' }), + expect.objectContaining({ agentId: 'agent2' }), + ]); + }); }); }); diff --git a/x-pack/plugins/fleet/server/services/agents/actions.ts b/x-pack/plugins/fleet/server/services/agents/actions.ts index afa65bfe91fb3..c4f3530892543 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.ts @@ -17,6 +17,8 @@ import type { import { AGENT_ACTIONS_INDEX, SO_SEARCH_LIMIT } from '../../../common/constants'; import { AgentActionNotFoundError } from '../../errors'; +import { bulkUpdateAgents } from './crud'; + const ONE_MONTH_IN_MS = 2592000000; export async function createAgentAction( @@ -131,6 +133,18 @@ export async function cancelAgentAction(esClient: ElasticsearchClient, actionId: created_at: now, expiration: hit._source.expiration, }); + if (hit._source.type === 'UPGRADE') { + await bulkUpdateAgents( + esClient, + hit._source.agents.map((agentId) => ({ + agentId, + data: { + upgraded_at: null, + upgrade_started_at: null, + }, + })) + ); + } } return { diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index 6d0174e064184..d7f2735e2d284 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -267,6 +267,7 @@ async function _getCancelledActionId( ) { const res = await esClient.search({ index: AGENT_ACTIONS_INDEX, + ignore_unavailable: true, query: { bool: { must: [ @@ -296,6 +297,7 @@ async function _getCancelledActionId( async function _getUpgradeActions(esClient: ElasticsearchClient, now = new Date().toISOString()) { const res = await esClient.search({ index: AGENT_ACTIONS_INDEX, + ignore_unavailable: true, query: { bool: { must: [ From d70ae0fa8a02fc927b932b2a17e539272f4ed5fc Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Fri, 20 May 2022 11:34:35 -0500 Subject: [PATCH 21/39] [ILM] Add warnings for managed system policies (#132269) * Add warnings to system/managed policies * Fix translations, policies * Add jest tests * Add jest tests to assert new toggle behavior * Add jest tests for edit policy callout * Fix snapshot * [ML] Update jest tests with helper, rename helper for clarity * [ML] Add hook for local storage to remember toggle setting * [ML] Fix naming Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../__snapshots__/policy_table.test.tsx.snap | 64 +++++----- .../edit_policy/constants.ts | 23 ++++ .../edit_policy/features/edit_warning.test.ts | 15 ++- .../__jest__/policy_table.test.tsx | 112 +++++++++++++++++- .../application/lib/settings_local_storage.ts | 31 +++++ .../edit_policy/components/edit_warning.tsx | 29 ++++- .../policy_list/components/confirm_delete.tsx | 62 ++++++++-- .../policy_list/components/policy_table.tsx | 101 +++++++++++++--- 8 files changed, 375 insertions(+), 62 deletions(-) create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/lib/settings_local_storage.ts diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap index 8cbb4aa450c7c..32d2b96675594 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap +++ b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap @@ -7,26 +7,26 @@ Array [ "testy10", "testy100", "testy101", - "testy102", "testy103", "testy104", "testy11", - "testy12", + "testy13", + "testy14", ] `; exports[`policy table changes pages when a pagination link is clicked on 2`] = ` Array [ - "testy13", - "testy14", - "testy15", "testy16", "testy17", - "testy18", "testy19", "testy2", "testy20", - "testy21", + "testy22", + "testy23", + "testy25", + "testy26", + "testy28", ] `; @@ -113,15 +113,15 @@ exports[`policy table shows empty state when there are no policies 1`] = ` exports[`policy table sorts when linked index templates header is clicked 1`] = ` Array [ "testy1", - "testy3", "testy5", "testy7", - "testy9", "testy11", "testy13", - "testy15", "testy17", "testy19", + "testy23", + "testy25", + "testy29", ] `; @@ -130,28 +130,28 @@ Array [ "testy0", "testy2", "testy4", - "testy6", "testy8", "testy10", - "testy12", "testy14", "testy16", - "testy18", + "testy20", + "testy22", + "testy26", ] `; exports[`policy table sorts when linked indices header is clicked 1`] = ` Array [ "testy1", - "testy3", "testy5", "testy7", - "testy9", "testy11", "testy13", - "testy15", "testy17", "testy19", + "testy23", + "testy25", + "testy29", ] `; @@ -160,13 +160,13 @@ Array [ "testy0", "testy2", "testy4", - "testy6", "testy8", "testy10", - "testy12", "testy14", "testy16", - "testy18", + "testy20", + "testy22", + "testy26", ] `; @@ -175,13 +175,13 @@ Array [ "testy0", "testy104", "testy103", - "testy102", "testy101", "testy100", - "testy99", "testy98", "testy97", - "testy96", + "testy95", + "testy94", + "testy92", ] `; @@ -189,29 +189,29 @@ exports[`policy table sorts when modified date header is clicked 2`] = ` Array [ "testy1", "testy2", - "testy3", "testy4", "testy5", - "testy6", "testy7", "testy8", - "testy9", "testy10", + "testy11", + "testy13", + "testy14", ] `; exports[`policy table sorts when name header is clicked 1`] = ` Array [ - "testy99", "testy98", "testy97", - "testy96", "testy95", "testy94", - "testy93", "testy92", "testy91", - "testy90", + "testy89", + "testy88", + "testy86", + "testy85", ] `; @@ -220,12 +220,12 @@ Array [ "testy0", "testy1", "testy2", - "testy3", "testy4", "testy5", - "testy6", "testy7", "testy8", - "testy9", + "testy10", + "testy11", + "testy13", ] `; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts index f57f351ae0831..620cb9d6f8dde 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/constants.ts @@ -221,6 +221,29 @@ export const POLICY_WITH_KNOWN_AND_UNKNOWN_FIELDS = { name: POLICY_NAME, } as any as PolicyFromES; +export const POLICY_MANAGED_BY_ES: PolicyFromES = { + version: 1, + modifiedDate: Date.now().toString(), + policy: { + name: POLICY_NAME, + phases: { + hot: { + min_age: '0ms', + actions: { + rollover: { + max_age: '30d', + max_primary_shard_size: '50gb', + }, + }, + }, + }, + _meta: { + managed: true, + }, + }, + name: POLICY_NAME, +}; + export const getGeneratedPolicies = (): PolicyFromES[] => { const policy = { phases: { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/edit_warning.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/edit_warning.test.ts index 0cf57f4140aa4..98d6078da031c 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/edit_warning.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/features/edit_warning.test.ts @@ -9,7 +9,7 @@ import { act } from 'react-dom/test-utils'; import { TestBed } from '@kbn/test-jest-helpers'; import { setupEnvironment } from '../../helpers'; import { initTestBed } from '../init_test_bed'; -import { getDefaultHotPhasePolicy, POLICY_NAME } from '../constants'; +import { getDefaultHotPhasePolicy, POLICY_NAME, POLICY_MANAGED_BY_ES } from '../constants'; describe(' edit warning', () => { let testBed: TestBed; @@ -54,6 +54,19 @@ describe(' edit warning', () => { expect(exists('editWarning')).toBe(true); }); + test('an edit warning callout is shown for an existing, managed policy', async () => { + httpRequestsMockHelpers.setLoadPolicies([POLICY_MANAGED_BY_ES]); + + await act(async () => { + testBed = await initTestBed(httpSetup); + }); + const { exists, component } = testBed; + component.update(); + + expect(exists('editWarning')).toBe(true); + expect(exists('editManagedPolicyCallOut')).toBe(true); + }); + test('no indices link if no indices', async () => { httpRequestsMockHelpers.setLoadPolicies([ { ...getDefaultHotPhasePolicy(POLICY_NAME), indices: [] }, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx index 771cf70e3daea..0e8ac17ff86c2 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/policy_table.test.tsx @@ -52,17 +52,27 @@ const testPolicy = { }, }; +const isUsedByAnIndex = (i: number) => i % 2 === 0; +const isDesignatedManagedPolicy = (i: number) => i > 0 && i % 3 === 0; + const policies: PolicyFromES[] = [testPolicy]; for (let i = 1; i < 105; i++) { policies.push({ version: i, modifiedDate: moment().subtract(i, 'days').toISOString(), - indices: i % 2 === 0 ? [`index${i}`] : [], + indices: isUsedByAnIndex(i) ? [`index${i}`] : [], indexTemplates: i % 2 === 0 ? [`indexTemplate${i}`] : [], name: `testy${i}`, policy: { name: `testy${i}`, phases: {}, + ...(isDesignatedManagedPolicy(i) + ? { + _meta: { + managed: true, + }, + } + : {}), }, }); } @@ -89,6 +99,20 @@ const getPolicyNames = (rendered: ReactWrapper): string[] => { return (getPolicyLinks(rendered) as ReactWrapper).map((button) => button.text()); }; +const getPolicies = (rendered: ReactWrapper) => { + const visiblePolicyNames = getPolicyNames(rendered); + const visiblePolicies = visiblePolicyNames.map((name) => { + const version = parseInt(name.replace('testy', ''), 10); + return { + version, + name, + isManagedPolicy: isDesignatedManagedPolicy(version), + isUsedByAnIndex: isUsedByAnIndex(version), + }; + }); + return visiblePolicies; +}; + const testSort = (headerName: string) => { const rendered = mountWithIntl(component); const nameHeader = findTestSubject(rendered, `tableHeaderCell_${headerName}`).find('button'); @@ -114,6 +138,7 @@ const TestComponent = ({ testPolicies }: { testPolicies: PolicyFromES[] }) => { describe('policy table', () => { beforeEach(() => { component = ; + window.localStorage.removeItem('ILM_SHOW_MANAGED_POLICIES_BY_DEFAULT'); }); test('shows empty state when there are no policies', () => { @@ -129,8 +154,23 @@ describe('policy table', () => { rendered.update(); snapshot(getPolicyNames(rendered)); }); + + test('does not show any hidden policies by default', () => { + const rendered = mountWithIntl(component); + const includeHiddenPoliciesSwitch = findTestSubject(rendered, `includeHiddenPoliciesSwitch`); + expect(includeHiddenPoliciesSwitch.prop('aria-checked')).toEqual(false); + const visiblePolicies = getPolicies(rendered); + const hasManagedPolicies = visiblePolicies.some((p) => { + const policyRow = findTestSubject(rendered, `policyTableRow-${p.name}`); + const warningBadge = findTestSubject(policyRow, 'managedPolicyBadge'); + return warningBadge.exists(); + }); + expect(hasManagedPolicies).toEqual(false); + }); + test('shows more policies when "Rows per page" value is increased', () => { const rendered = mountWithIntl(component); + const perPageButton = rendered.find('EuiTablePagination EuiPopover').find('button'); perPageButton.simulate('click'); rendered.update(); @@ -139,6 +179,36 @@ describe('policy table', () => { rendered.update(); expect(getPolicyNames(rendered).length).toBe(25); }); + + test('shows hidden policies with Managed badges when setting is switched on', () => { + const rendered = mountWithIntl(component); + const includeHiddenPoliciesSwitch = findTestSubject(rendered, `includeHiddenPoliciesSwitch`); + includeHiddenPoliciesSwitch.find('button').simulate('click'); + rendered.update(); + + // Increase page size for better sample set that contains managed indices + // Since table is ordered alphabetically and not numerically + const perPageButton = rendered.find('EuiTablePagination EuiPopover').find('button'); + perPageButton.simulate('click'); + rendered.update(); + const numberOfRowsButton = rendered.find('.euiContextMenuItem').at(2); + numberOfRowsButton.simulate('click'); + rendered.update(); + + const visiblePolicies = getPolicies(rendered); + expect(visiblePolicies.filter((p) => p.isManagedPolicy).length).toBeGreaterThan(0); + + visiblePolicies.forEach((p) => { + const policyRow = findTestSubject(rendered, `policyTableRow-${p.name}`); + const warningBadge = findTestSubject(policyRow, 'managedPolicyBadge'); + if (p.isManagedPolicy) { + expect(warningBadge.exists()).toBeTruthy(); + } else { + expect(warningBadge.exists()).toBeFalsy(); + } + }); + }); + test('filters based on content of search input', () => { const rendered = mountWithIntl(component); const searchInput = rendered.find('.euiFieldSearch').first(); @@ -167,7 +237,11 @@ describe('policy table', () => { }); test('delete policy button is enabled when there are no linked indices', () => { const rendered = mountWithIntl(component); - const policyRow = findTestSubject(rendered, `policyTableRow-testy1`); + const visiblePolicies = getPolicies(rendered); + const unusedPolicy = visiblePolicies.find((p) => !p.isUsedByAnIndex); + expect(unusedPolicy).toBeDefined(); + + const policyRow = findTestSubject(rendered, `policyTableRow-${unusedPolicy!.name}`); const deleteButton = findTestSubject(policyRow, 'deletePolicy'); expect(deleteButton.props().disabled).toBeFalsy(); }); @@ -179,6 +253,36 @@ describe('policy table', () => { rendered.update(); expect(findTestSubject(rendered, 'deletePolicyModal').exists()).toBeTruthy(); }); + + test('confirmation modal shows warning when delete button is pressed for a hidden policy', () => { + const rendered = mountWithIntl(component); + + // Toggles switch to show managed policies + const includeHiddenPoliciesSwitch = findTestSubject(rendered, `includeHiddenPoliciesSwitch`); + includeHiddenPoliciesSwitch.find('button').simulate('click'); + rendered.update(); + + // Increase page size for better sample set that contains managed indices + // Since table is ordered alphabetically and not numerically + const perPageButton = rendered.find('EuiTablePagination EuiPopover').find('button'); + perPageButton.simulate('click'); + rendered.update(); + const numberOfRowsButton = rendered.find('.euiContextMenuItem').at(2); + numberOfRowsButton.simulate('click'); + rendered.update(); + + const visiblePolicies = getPolicies(rendered); + const managedPolicy = visiblePolicies.find((p) => p.isManagedPolicy && !p.isUsedByAnIndex); + expect(managedPolicy).toBeDefined(); + + const policyRow = findTestSubject(rendered, `policyTableRow-${managedPolicy!.name}`); + const addPolicyToTemplateButton = findTestSubject(policyRow, 'deletePolicy'); + addPolicyToTemplateButton.simulate('click'); + rendered.update(); + expect(findTestSubject(rendered, 'deletePolicyModal').exists()).toBeTruthy(); + expect(findTestSubject(rendered, 'deleteManagedPolicyCallOut').exists()).toBeTruthy(); + }); + test('add index template modal shows when add policy to index template button is pressed', () => { const rendered = mountWithIntl(component); const policyRow = findTestSubject(rendered, `policyTableRow-${testPolicy.name}`); @@ -190,8 +294,8 @@ describe('policy table', () => { test('displays policy properties', () => { const rendered = mountWithIntl(component); const firstRow = findTestSubject(rendered, 'policyTableRow-testy0'); - const policyName = findTestSubject(firstRow, 'policy-name').text(); - expect(policyName).toBe(`Name${testPolicy.name}`); + const policyName = findTestSubject(firstRow, 'policyTablePolicyNameLink').text(); + expect(policyName).toBe(`${testPolicy.name}`); const policyIndexTemplates = findTestSubject(firstRow, 'policy-indexTemplates').text(); expect(policyIndexTemplates).toBe(`Linked index templates${testPolicy.indexTemplates.length}`); const policyIndices = findTestSubject(firstRow, 'policy-indices').text(); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/settings_local_storage.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/settings_local_storage.ts new file mode 100644 index 0000000000000..0eb5ae22fd01c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/settings_local_storage.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; + +function parseJsonOrDefault(value: string | null, defaultValue: Obj): Obj { + if (!value) { + return defaultValue; + } + try { + return JSON.parse(value) as Obj; + } catch (e) { + return defaultValue; + } +} + +export function useStateWithLocalStorage( + key: string, + defaultState: State +): [State, Dispatch>] { + const storageState = localStorage.getItem(key); + const [state, setState] = useState(parseJsonOrDefault(storageState, defaultState)); + useEffect(() => { + localStorage.setItem(key, JSON.stringify(state)); + }, [key, state]); + return [state, setState]; +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/edit_warning.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/edit_warning.tsx index 8b0c21e9999c0..c2acc89fe34d1 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/edit_warning.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/edit_warning.tsx @@ -6,7 +6,7 @@ */ import React, { FunctionComponent, useState } from 'react'; -import { EuiLink, EuiText } from '@elastic/eui'; +import { EuiCallOut, EuiLink, EuiText, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { useEditPolicyContext } from '../edit_policy_context'; import { getIndicesListPath } from '../../../services/navigation'; @@ -14,7 +14,7 @@ import { useKibana } from '../../../../shared_imports'; import { IndexTemplatesFlyout } from '../../../components/index_templates_flyout'; export const EditWarning: FunctionComponent = () => { - const { isNewPolicy, indices, indexTemplates, policyName } = useEditPolicyContext(); + const { isNewPolicy, indices, indexTemplates, policyName, policy } = useEditPolicyContext(); const { services: { getUrlForApp }, } = useKibana(); @@ -67,6 +67,8 @@ export const EditWarning: FunctionComponent = () => { ) : ( indexTemplatesLink ); + const isManagedPolicy = policy?._meta?.managed; + return ( <> {isIndexTemplatesFlyoutShown && ( @@ -77,6 +79,29 @@ export const EditWarning: FunctionComponent = () => { /> )} + {isManagedPolicy && ( + <> + + } + color="danger" + iconType="alert" + data-test-subj="editManagedPolicyCallOut" + > +

+ +

+
+ + + )}

void; } export class ConfirmDelete extends Component { + public state = { + isDeleteConfirmed: false, + }; + + setIsDeleteConfirmed = (confirmed: boolean) => { + this.setState({ + isDeleteConfirmed: confirmed, + }); + }; + deletePolicy = async () => { const { policyToDelete, callback } = this.props; const policyName = policyToDelete.name; @@ -43,8 +53,12 @@ export class ConfirmDelete extends Component { callback(); } }; + isPolicyPolicy = true; render() { const { policyToDelete, onCancel } = this.props; + const { isDeleteConfirmed } = this.state; + const isManagedPolicy = policyToDelete.policy?._meta?.managed; + const title = i18n.translate('xpack.indexLifecycleMgmt.confirmDelete.title', { defaultMessage: 'Delete policy "{name}"', values: { name: policyToDelete.name }, @@ -68,13 +82,47 @@ export class ConfirmDelete extends Component { /> } buttonColor="danger" + confirmButtonDisabled={isManagedPolicy ? !isDeleteConfirmed : false} > -

- -
+ {isManagedPolicy ? ( + + } + color="danger" + iconType="alert" + data-test-subj="deleteManagedPolicyCallOut" + > +

+ +

+ + } + checked={isDeleteConfirmed} + onChange={(e) => this.setIsDeleteConfirmed(e.target.checked)} + /> +
+ ) : ( +
+ +
+ )} ); } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/policy_table.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/policy_table.tsx index 8a89759a4225e..2d79737baf2bc 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/policy_table.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_list/components/policy_table.tsx @@ -5,8 +5,17 @@ * 2.0. */ -import React from 'react'; -import { EuiButtonEmpty, EuiLink, EuiInMemoryTable, EuiToolTip, EuiButtonIcon } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { + EuiButtonEmpty, + EuiLink, + EuiInMemoryTable, + EuiToolTip, + EuiButtonIcon, + EuiBadge, + EuiFlexItem, + EuiSwitch, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -15,6 +24,8 @@ import { METRIC_TYPE } from '@kbn/analytics'; import { useHistory } from 'react-router-dom'; import { EuiBasicTableColumn } from '@elastic/eui/src/components/basic_table/basic_table'; import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useStateWithLocalStorage } from '../../../lib/settings_local_storage'; import { PolicyFromES } from '../../../../../common/types'; import { useKibana } from '../../../../shared_imports'; import { getIndicesListPath, getPolicyEditPath } from '../../../services/navigation'; @@ -45,17 +56,63 @@ const actionTooltips = { ), }; +const managedPolicyTooltips = { + badge: i18n.translate('xpack.indexLifecycleMgmt.policyTable.templateBadgeType.managedLabel', { + defaultMessage: 'Managed', + }), + badgeTooltip: i18n.translate( + 'xpack.indexLifecycleMgmt.policyTable.templateBadgeType.managedDescription', + { + defaultMessage: + 'This policy is preconfigured and managed by Elastic; editing or deleting this policy might break Kibana.', + } + ), +}; + interface Props { policies: PolicyFromES[]; } +const SHOW_MANAGED_POLICIES_BY_DEFAULT = 'ILM_SHOW_MANAGED_POLICIES_BY_DEFAULT'; + export const PolicyTable: React.FunctionComponent = ({ policies }) => { const history = useHistory(); const { services: { getUrlForApp }, } = useKibana(); - + const [managedPoliciesVisible, setManagedPoliciesVisible] = useStateWithLocalStorage( + SHOW_MANAGED_POLICIES_BY_DEFAULT, + false + ); const { setListAction } = usePolicyListContext(); + const searchOptions = useMemo( + () => ({ + box: { incremental: true, 'data-test-subj': 'ilmSearchBar' }, + toolsRight: ( + + setManagedPoliciesVisible(event.target.checked)} + label={ + + } + /> + + ), + }), + [managedPoliciesVisible, setManagedPoliciesVisible] + ); + + const filteredPolicies = useMemo(() => { + return managedPoliciesVisible + ? policies + : policies.filter((item) => !item.policy?._meta?.managed); + }, [policies, managedPoliciesVisible]); const columns: Array> = [ { @@ -65,17 +122,31 @@ export const PolicyTable: React.FunctionComponent = ({ policies }) => { defaultMessage: 'Name', }), sortable: true, - render: (value: string) => { + render: (value: string, item) => { + const isManaged = item.policy?._meta?.managed; return ( - - trackUiMetric(METRIC_TYPE.CLICK, UIM_EDIT_CLICK) + <> + + trackUiMetric(METRIC_TYPE.CLICK, UIM_EDIT_CLICK) + )} + > + {value} + + + {isManaged && ( + <> +   + + + {managedPolicyTooltips.badge} + + + )} - > - {value} - + ); }, }, @@ -191,11 +262,9 @@ export const PolicyTable: React.FunctionComponent = ({ policies }) => { direction: 'asc', }, }} - search={{ - box: { incremental: true, 'data-test-subj': 'ilmSearchBar' }, - }} + search={searchOptions} tableLayout="auto" - items={policies} + items={filteredPolicies} columns={columns} rowProps={(policy: PolicyFromES) => ({ 'data-test-subj': `policyTableRow-${policy.name}` })} /> From c24488361a0f53b9d71d4248dd1a57d00520adad Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 20 May 2022 10:35:00 -0600 Subject: [PATCH 22/39] [maps] show marker size in legend (#132549) * [Maps] size legend * clean-up * refine spacing * clean up * more cleanup * use euiTheme for colors * fix jest test * do not show marker sizes for icons * remove lodash Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/legend/marker_size_legend.tsx | 164 ++++++++++++++++ .../dynamic_size_property.test.tsx.snap | 176 +++++++++++++++++- .../dynamic_size_property.test.tsx | 48 ++++- .../dynamic_size_property.tsx | 7 +- 4 files changed, 386 insertions(+), 9 deletions(-) create mode 100644 x-pack/plugins/maps/public/classes/styles/vector/components/legend/marker_size_legend.tsx diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/marker_size_legend.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/marker_size_legend.tsx new file mode 100644 index 0000000000000..295e7c57b7a22 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/marker_size_legend.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Component } from 'react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui'; +import { DynamicSizeProperty } from '../../properties/dynamic_size_property'; + +const FONT_SIZE = 10; +const HALF_FONT_SIZE = FONT_SIZE / 2; +const MIN_MARKER_DISTANCE = (FONT_SIZE + 2) / 2; + +const EMPTY_VALUE = ''; + +interface Props { + style: DynamicSizeProperty; +} + +interface State { + label: string; +} + +export class MarkerSizeLegend extends Component { + private _isMounted: boolean = false; + + state: State = { + label: EMPTY_VALUE, + }; + + componentDidMount() { + this._isMounted = true; + this._loadLabel(); + } + + componentDidUpdate() { + this._loadLabel(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async _loadLabel() { + const field = this.props.style.getField(); + if (!field) { + return; + } + const label = await field.getLabel(); + if (this._isMounted && this.state.label !== label) { + this.setState({ label }); + } + } + + _formatValue(value: string | number) { + return value === EMPTY_VALUE ? value : this.props.style.formatField(value); + } + + _renderMarkers() { + const fieldMeta = this.props.style.getRangeFieldMeta(); + const options = this.props.style.getOptions(); + if (!fieldMeta || !options) { + return null; + } + + const circleStyle = { + fillOpacity: 0, + stroke: euiThemeVars.euiTextColor, + strokeWidth: 1, + }; + + const svgHeight = options.maxSize * 2 + HALF_FONT_SIZE + circleStyle.strokeWidth * 2; + const circleCenterX = options.maxSize + circleStyle.strokeWidth; + const circleBottomY = svgHeight - circleStyle.strokeWidth; + + function makeMarker(radius: number, formattedValue: string | number) { + const circleCenterY = circleBottomY - radius; + const circleTopY = circleCenterY - radius; + return ( + + + + {formattedValue} + + + + ); + } + + function getMarkerRadius(percentage: number) { + const delta = options.maxSize - options.minSize; + return percentage * delta + options.minSize; + } + + function getValue(percentage: number) { + // Markers interpolated by area instead of radius to be more consistent with how the human eye+brain perceive shapes + // and their visual relevance + // This function mirrors output of maplibre expression created from DynamicSizeProperty.getMbSizeExpression + const value = Math.pow(percentage * Math.sqrt(fieldMeta!.delta), 2) + fieldMeta!.min; + return fieldMeta!.delta > 3 ? Math.round(value) : value; + } + + const markers = []; + + if (fieldMeta.delta > 0) { + const smallestMarker = makeMarker(options.minSize, this._formatValue(fieldMeta.min)); + markers.push(smallestMarker); + + const markerDelta = options.maxSize - options.minSize; + if (markerDelta > MIN_MARKER_DISTANCE * 3) { + markers.push(makeMarker(getMarkerRadius(0.25), this._formatValue(getValue(0.25)))); + markers.push(makeMarker(getMarkerRadius(0.5), this._formatValue(getValue(0.5)))); + markers.push(makeMarker(getMarkerRadius(0.75), this._formatValue(getValue(0.75)))); + } else if (markerDelta > MIN_MARKER_DISTANCE) { + markers.push(makeMarker(getMarkerRadius(0.5), this._formatValue(getValue(0.5)))); + } + } + + const largestMarker = makeMarker(options.maxSize, this._formatValue(fieldMeta.max)); + markers.push(largestMarker); + + return ( + + {markers} + + ); + } + + render() { + return ( +
+ + + + + + {this.state.label} + + + + + + {this._renderMarkers()} +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/__snapshots__/dynamic_size_property.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/__snapshots__/dynamic_size_property.test.tsx.snap index 9dc0e99669c79..bf239aa40e33a 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/__snapshots__/dynamic_size_property.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/__snapshots__/dynamic_size_property.test.tsx.snap @@ -1,6 +1,165 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`renderLegendDetailRow Should render as range 1`] = ` +exports[`renderLegendDetailRow Should render icon size scale 1`] = ` +
+ + + + + + + foobar_label + + + + + + + + + + + 0_format + + + + + + + 25_format + + + + + + + 100_format + + + + +
+`; + +exports[`renderLegendDetailRow Should render line width simple range 1`] = ` @@ -36,9 +196,10 @@ exports[`renderLegendDetailRow Should render as range 1`] = ` @@ -56,8 +217,9 @@ exports[`renderLegendDetailRow Should render as range 1`] = ` `; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx index 0446b9e30f47b..9f92d81313da7 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx @@ -20,7 +20,53 @@ import { IField } from '../../../../fields/field'; import { IVectorLayer } from '../../../../layers/vector_layer'; describe('renderLegendDetailRow', () => { - test('Should render as range', async () => { + test('Should render line width simple range', async () => { + const field = { + getLabel: async () => { + return 'foobar_label'; + }, + getName: () => { + return 'foodbar'; + }, + getOrigin: () => { + return FIELD_ORIGIN.SOURCE; + }, + supportsFieldMetaFromEs: () => { + return true; + }, + supportsFieldMetaFromLocalData: () => { + return true; + }, + } as unknown as IField; + const sizeProp = new DynamicSizeProperty( + { minSize: 0, maxSize: 10, fieldMetaOptions: { isEnabled: true } }, + VECTOR_STYLES.LINE_WIDTH, + field, + {} as unknown as IVectorLayer, + () => { + return (value: RawValue) => value + '_format'; + }, + false + ); + sizeProp.getRangeFieldMeta = () => { + return { + min: 0, + max: 100, + delta: 100, + }; + }; + + const legendRow = sizeProp.renderLegendDetailRow(); + const component = shallow(legendRow); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(component).toMatchSnapshot(); + }); + + test('Should render icon size scale', async () => { const field = { getLabel: async () => { return 'foobar_label'; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx index d8fe8463edba8..83ac50c7b4eaa 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx @@ -9,6 +9,7 @@ import React from 'react'; import type { Map as MbMap } from '@kbn/mapbox-gl'; import { DynamicStyleProperty } from '../dynamic_style_property'; import { OrdinalLegend } from '../../components/legend/ordinal_legend'; +import { MarkerSizeLegend } from '../../components/legend/marker_size_legend'; import { makeMbClampedNumberExpression } from '../../style_util'; import { FieldFormatter, @@ -141,6 +142,10 @@ export class DynamicSizeProperty extends DynamicStyleProperty; + return this.getStyleName() === VECTOR_STYLES.ICON_SIZE && !this._isSymbolizedAsIcon ? ( + + ) : ( + + ); } } From 583d2b78e085ec7e51f6d7608b0d1fe75f1bfcc4 Mon Sep 17 00:00:00 2001 From: Byron Hulcher Date: Fri, 20 May 2022 13:12:32 -0400 Subject: [PATCH 23/39] [Workplace Search] Add documentation links for v8.3.0 connectors (#132547) --- packages/kbn-doc-links/src/get_doc_links.ts | 6 ++++++ packages/kbn-doc-links/src/types.ts | 6 ++++++ .../shared/doc_links/doc_links.ts | 20 +++++++++++++++++++ .../views/content_sources/source_data.tsx | 12 +++++------ 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 55909e360b0e5..53f69411c43dd 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -125,7 +125,9 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { apiKeys: `${WORKPLACE_SEARCH_DOCS}workplace-search-api-authentication.html`, box: `${WORKPLACE_SEARCH_DOCS}workplace-search-box-connector.html`, confluenceCloud: `${WORKPLACE_SEARCH_DOCS}workplace-search-confluence-cloud-connector.html`, + confluenceCloudConnectorPackage: `${WORKPLACE_SEARCH_DOCS}confluence-cloud.html`, confluenceServer: `${WORKPLACE_SEARCH_DOCS}workplace-search-confluence-server-connector.html`, + customConnectorPackage: `${WORKPLACE_SEARCH_DOCS}custom-connector-package.html`, customSources: `${WORKPLACE_SEARCH_DOCS}workplace-search-custom-api-sources.html`, customSourcePermissions: `${WORKPLACE_SEARCH_DOCS}workplace-search-custom-api-sources.html#custom-api-source-document-level-access-control`, documentPermissions: `${WORKPLACE_SEARCH_DOCS}workplace-search-sources-document-permissions.html`, @@ -139,7 +141,9 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { indexingSchedule: `${WORKPLACE_SEARCH_DOCS}workplace-search-customizing-indexing-rules.html#_indexing_schedule`, jiraCloud: `${WORKPLACE_SEARCH_DOCS}workplace-search-jira-cloud-connector.html`, jiraServer: `${WORKPLACE_SEARCH_DOCS}workplace-search-jira-server-connector.html`, + networkDrive: `${WORKPLACE_SEARCH_DOCS}network-drives.html`, oneDrive: `${WORKPLACE_SEARCH_DOCS}workplace-search-onedrive-connector.html`, + outlook: `${WORKPLACE_SEARCH_DOCS}microsoft-outlook.html`, permissions: `${WORKPLACE_SEARCH_DOCS}workplace-search-permissions.html#organizational-sources-private-sources`, salesforce: `${WORKPLACE_SEARCH_DOCS}workplace-search-salesforce-connector.html`, security: `${WORKPLACE_SEARCH_DOCS}workplace-search-security.html`, @@ -148,7 +152,9 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { sharePointServer: `${WORKPLACE_SEARCH_DOCS}sharepoint-server.html`, slack: `${WORKPLACE_SEARCH_DOCS}workplace-search-slack-connector.html`, synch: `${WORKPLACE_SEARCH_DOCS}workplace-search-customizing-indexing-rules.html`, + teams: `${WORKPLACE_SEARCH_DOCS}microsoft-teams.html`, zendesk: `${WORKPLACE_SEARCH_DOCS}workplace-search-zendesk-connector.html`, + zoom: `${WORKPLACE_SEARCH_DOCS}zoom.html`, }, metricbeat: { base: `${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index c492509e80511..6dc3ad0f5fdda 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -111,7 +111,9 @@ export interface DocLinks { readonly apiKeys: string; readonly box: string; readonly confluenceCloud: string; + readonly confluenceCloudConnectorPackage: string; readonly confluenceServer: string; + readonly customConnectorPackage: string; readonly customSources: string; readonly customSourcePermissions: string; readonly documentPermissions: string; @@ -125,7 +127,9 @@ export interface DocLinks { readonly indexingSchedule: string; readonly jiraCloud: string; readonly jiraServer: string; + readonly networkDrive: string; readonly oneDrive: string; + readonly outlook: string; readonly permissions: string; readonly salesforce: string; readonly security: string; @@ -134,7 +138,9 @@ export interface DocLinks { readonly sharePointServer: string; readonly slack: string; readonly synch: string; + readonly teams: string; readonly zendesk: string; + readonly zoom: string; }; readonly heartbeat: { readonly base: string; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts index b037a5aed6217..1d38cb584fa43 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts @@ -64,7 +64,9 @@ class DocLinks { public workplaceSearchApiKeys: string; public workplaceSearchBox: string; public workplaceSearchConfluenceCloud: string; + public workplaceSearchConfluenceCloudConnectorPackage: string; public workplaceSearchConfluenceServer: string; + public workplaceSearchCustomConnectorPackage: string; public workplaceSearchCustomSources: string; public workplaceSearchCustomSourcePermissions: string; public workplaceSearchDocumentPermissions: string; @@ -78,7 +80,9 @@ class DocLinks { public workplaceSearchIndexingSchedule: string; public workplaceSearchJiraCloud: string; public workplaceSearchJiraServer: string; + public workplaceSearchNetworkDrive: string; public workplaceSearchOneDrive: string; + public workplaceSearchOutlook: string; public workplaceSearchPermissions: string; public workplaceSearchSalesforce: string; public workplaceSearchSecurity: string; @@ -87,7 +91,9 @@ class DocLinks { public workplaceSearchSharePointServer: string; public workplaceSearchSlack: string; public workplaceSearchSynch: string; + public workplaceSearchTeams: string; public workplaceSearchZendesk: string; + public workplaceSearchZoom: string; constructor() { this.appSearchApis = ''; @@ -146,7 +152,9 @@ class DocLinks { this.workplaceSearchApiKeys = ''; this.workplaceSearchBox = ''; this.workplaceSearchConfluenceCloud = ''; + this.workplaceSearchConfluenceCloudConnectorPackage = ''; this.workplaceSearchConfluenceServer = ''; + this.workplaceSearchCustomConnectorPackage = ''; this.workplaceSearchCustomSources = ''; this.workplaceSearchCustomSourcePermissions = ''; this.workplaceSearchDocumentPermissions = ''; @@ -160,7 +168,9 @@ class DocLinks { this.workplaceSearchIndexingSchedule = ''; this.workplaceSearchJiraCloud = ''; this.workplaceSearchJiraServer = ''; + this.workplaceSearchNetworkDrive = ''; this.workplaceSearchOneDrive = ''; + this.workplaceSearchOutlook = ''; this.workplaceSearchPermissions = ''; this.workplaceSearchSalesforce = ''; this.workplaceSearchSecurity = ''; @@ -169,7 +179,9 @@ class DocLinks { this.workplaceSearchSharePointServer = ''; this.workplaceSearchSlack = ''; this.workplaceSearchSynch = ''; + this.workplaceSearchTeams = ''; this.workplaceSearchZendesk = ''; + this.workplaceSearchZoom = ''; } public setDocLinks(docLinks: DocLinksStart): void { @@ -230,7 +242,11 @@ class DocLinks { this.workplaceSearchApiKeys = docLinks.links.workplaceSearch.apiKeys; this.workplaceSearchBox = docLinks.links.workplaceSearch.box; this.workplaceSearchConfluenceCloud = docLinks.links.workplaceSearch.confluenceCloud; + this.workplaceSearchConfluenceCloudConnectorPackage = + docLinks.links.workplaceSearch.confluenceCloudConnectorPackage; this.workplaceSearchConfluenceServer = docLinks.links.workplaceSearch.confluenceServer; + this.workplaceSearchCustomConnectorPackage = + docLinks.links.workplaceSearch.customConnectorPackage; this.workplaceSearchCustomSources = docLinks.links.workplaceSearch.customSources; this.workplaceSearchCustomSourcePermissions = docLinks.links.workplaceSearch.customSourcePermissions; @@ -246,7 +262,9 @@ class DocLinks { this.workplaceSearchIndexingSchedule = docLinks.links.workplaceSearch.indexingSchedule; this.workplaceSearchJiraCloud = docLinks.links.workplaceSearch.jiraCloud; this.workplaceSearchJiraServer = docLinks.links.workplaceSearch.jiraServer; + this.workplaceSearchNetworkDrive = docLinks.links.workplaceSearch.networkDrive; this.workplaceSearchOneDrive = docLinks.links.workplaceSearch.oneDrive; + this.workplaceSearchOutlook = docLinks.links.workplaceSearch.outlook; this.workplaceSearchPermissions = docLinks.links.workplaceSearch.permissions; this.workplaceSearchSalesforce = docLinks.links.workplaceSearch.salesforce; this.workplaceSearchSecurity = docLinks.links.workplaceSearch.security; @@ -255,7 +273,9 @@ class DocLinks { this.workplaceSearchSharePointServer = docLinks.links.workplaceSearch.sharePointServer; this.workplaceSearchSlack = docLinks.links.workplaceSearch.slack; this.workplaceSearchSynch = docLinks.links.workplaceSearch.synch; + this.workplaceSearchTeams = docLinks.links.workplaceSearch.teams; this.workplaceSearchZendesk = docLinks.links.workplaceSearch.zendesk; + this.workplaceSearchZoom = docLinks.links.workplaceSearch.zoom; } } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx index db3da678e1e00..181cd8b7c9a73 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx @@ -25,7 +25,7 @@ export const staticGenericExternalSourceData: SourceDataItem = { isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, // TODO Update this when we have a doclink + documentationUrl: docLinks.workplaceSearchCustomConnectorPackage, applicationPortalUrl: '', }, objTypes: [], @@ -107,7 +107,7 @@ export const staticSourceData: SourceDataItem[] = [ isPublicKey: false, hasOauthRedirect: true, needsBaseUrl: true, - documentationUrl: docLinks.workplaceSearchConfluenceCloud, // TODO Update this when we have a doclink + documentationUrl: docLinks.workplaceSearchConfluenceCloudConnectorPackage, applicationPortalUrl: 'https://developer.atlassian.com/console/myapps/', }, objTypes: [ @@ -387,7 +387,7 @@ export const staticSourceData: SourceDataItem[] = [ isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, // TODO Update this when we have a doclink + documentationUrl: docLinks.workplaceSearchNetworkDrive, applicationPortalUrl: '', githubRepository: 'elastic/enterprise-search-network-drive-connector', }, @@ -433,7 +433,7 @@ export const staticSourceData: SourceDataItem[] = [ isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, // TODO Update this when we have a doclink + documentationUrl: docLinks.workplaceSearchOutlook, applicationPortalUrl: '', githubRepository: 'elastic/enterprise-search-outlook-connector', }, @@ -649,7 +649,7 @@ export const staticSourceData: SourceDataItem[] = [ isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, // TODO Update this when we have a doclink + documentationUrl: docLinks.workplaceSearchTeams, applicationPortalUrl: '', githubRepository: 'elastic/enterprise-search-teams-connector', }, @@ -691,7 +691,7 @@ export const staticSourceData: SourceDataItem[] = [ isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, // TODO Update this when we have a doclink + documentationUrl: docLinks.workplaceSearchZoom, applicationPortalUrl: '', githubRepository: 'elastic/enterprise-search-zoom-connector', }, From 065ea3e772f50cd0e5357ba98ba5bb04ccd4323f Mon Sep 17 00:00:00 2001 From: Byron Hulcher Date: Fri, 20 May 2022 13:12:49 -0400 Subject: [PATCH 24/39] [Workplace Search] Remove Custom API Source Integration tile (#132538) --- .../apis/custom_integration/integrations.ts | 2 +- .../assets/source_icons/custom_api_source.svg | 1 - .../enterprise_search/server/integrations.ts | 18 ------------------ .../translations/translations/fr-FR.json | 2 -- .../translations/translations/ja-JP.json | 2 -- .../translations/translations/zh-CN.json | 2 -- 6 files changed, 1 insertion(+), 26 deletions(-) delete mode 100644 x-pack/plugins/enterprise_search/public/assets/source_icons/custom_api_source.svg diff --git a/test/api_integration/apis/custom_integration/integrations.ts b/test/api_integration/apis/custom_integration/integrations.ts index c1b6518f6684a..c4fda918328f8 100644 --- a/test/api_integration/apis/custom_integration/integrations.ts +++ b/test/api_integration/apis/custom_integration/integrations.ts @@ -22,7 +22,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body).to.be.an('array'); - expect(resp.body.length).to.be(43); + expect(resp.body.length).to.be(42); // Test for sample data card expect(resp.body.findIndex((c: { id: string }) => c.id === 'sample_data_all')).to.be.above( diff --git a/x-pack/plugins/enterprise_search/public/assets/source_icons/custom_api_source.svg b/x-pack/plugins/enterprise_search/public/assets/source_icons/custom_api_source.svg deleted file mode 100644 index cc07fbbc50877..0000000000000 --- a/x-pack/plugins/enterprise_search/public/assets/source_icons/custom_api_source.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/x-pack/plugins/enterprise_search/server/integrations.ts b/x-pack/plugins/enterprise_search/server/integrations.ts index d2d3b5d4d6829..140e36ba15555 100644 --- a/x-pack/plugins/enterprise_search/server/integrations.ts +++ b/x-pack/plugins/enterprise_search/server/integrations.ts @@ -338,24 +338,6 @@ const workplaceSearchIntegrations: WorkplaceSearchIntegration[] = [ categories: ['enterprise_search', 'communications', 'productivity'], uiInternalPath: '/app/enterprise_search/workplace_search/sources/add/zoom', }, - { - id: 'custom_api_source', - title: i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceName', - { - defaultMessage: 'Custom API Source', - } - ), - description: i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceDescription', - { - defaultMessage: - 'Search over anything by building your own integration with Workplace Search.', - } - ), - categories: ['custom'], - uiInternalPath: '/app/enterprise_search/workplace_search/sources/add/custom', - }, ]; export const registerEnterpriseSearchIntegrations = ( diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 85ea8a0ffc348..b62a957cfa927 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -11801,8 +11801,6 @@ "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceCloudName": "Cloud Confluence", "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceServerDescription": "Effectuez des recherches sur le contenu de votre organisation sur le serveur Confluence avec Workplace Search.", "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceServerName": "Serveur Confluence", - "xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceDescription": "Effectuez n'importe quelle recherche en créant votre propre intégration avec Workplace Search.", - "xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceName": "Source d'API personnalisée", "xpack.enterpriseSearch.workplaceSearch.integrations.dropboxDescription": "Effectuez des recherches dans vos fichiers et dossiers stockés sur Dropbox avec Workplace Search.", "xpack.enterpriseSearch.workplaceSearch.integrations.dropboxName": "Dropbox", "xpack.enterpriseSearch.workplaceSearch.integrations.githubDescription": "Effectuez des recherches sur vos projets et référentiels sur GitHub avec Workplace Search.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index cf84dbd2d6305..fe7056a5e3ec1 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11900,8 +11900,6 @@ "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceCloudName": "Confluence Cloud", "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceServerDescription": "Workplace Searchを使用して、Confluence Serverの組織コンテンツを検索します。", "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceServerName": "Confluence Server", - "xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceDescription": "Workplace Searchを使用して、独自の統合を構築し、項目を検索します。", - "xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceName": "カスタムAPIソース", "xpack.enterpriseSearch.workplaceSearch.integrations.dropboxDescription": "Workplace Searchを使用して、Dropboxに保存されたファイルとフォルダーを検索します。", "xpack.enterpriseSearch.workplaceSearch.integrations.dropboxName": "Dropbox", "xpack.enterpriseSearch.workplaceSearch.integrations.githubDescription": "Workplace Searchを使用して、GitHubのプロジェクトとリポジトリを検索します。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b15cacd8dc8ab..990a113fcd9d6 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11922,8 +11922,6 @@ "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceCloudName": "Confluence Cloud", "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceServerDescription": "通过 Workplace Search 搜索 Confluence Server 上的组织内容。", "xpack.enterpriseSearch.workplaceSearch.integrations.confluenceServerName": "Confluence Server", - "xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceDescription": "通过使用 Workplace Search 构建自己的集成来搜索任何内容。", - "xpack.enterpriseSearch.workplaceSearch.integrations.customApiSourceName": "定制 API 源", "xpack.enterpriseSearch.workplaceSearch.integrations.dropboxDescription": "通过 Workplace Search 搜索存储在 Dropbox 上的文件和文件夹。", "xpack.enterpriseSearch.workplaceSearch.integrations.dropboxName": "Dropbox", "xpack.enterpriseSearch.workplaceSearch.integrations.githubDescription": "通过 Workplace Search 搜索 GitHub 上的项目和存储库。", From ecca23166e0a619c4a529d463aefecf31da39830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Fri, 20 May 2022 19:37:03 +0200 Subject: [PATCH 25/39] [Stack Monitoring] Convert setup routes to TypeScript (#131265) --- x-pack/plugins/infra/server/mocks.ts | 34 ++++++ .../log_views/log_views_service.mock.ts | 6 +- .../http_api/cluster/index.ts} | 5 +- .../common/http_api/cluster/post_cluster.ts | 29 +++++ .../common/http_api/cluster/post_clusters.ts | 20 ++++ .../monitoring/common/http_api/setup/index.ts | 10 ++ .../setup/post_cluster_setup_status.ts | 44 +++++++ .../setup/post_disable_internal_collection.ts | 14 +++ .../http_api/setup/post_node_setup_status.ts | 43 +++++++ .../http_api/shared/literal_value.test.ts | 30 +++++ .../shared/query_string_boolean.test.ts | 23 ++++ .../plugins/monitoring/server/debug_logger.ts | 19 +-- .../lib/cluster/flag_supported_clusters.ts | 18 ++- .../server/lib/cluster/get_index_patterns.ts | 6 +- .../elasticsearch/verify_monitoring_auth.ts | 4 +- ....test.js => get_collection_status.test.ts} | 108 ++++++++++++------ .../setup/collection/get_collection_status.ts | 23 ++-- x-pack/plugins/monitoring/server/mocks.ts | 25 ++++ .../server/routes/api/v1/alerts/enable.ts | 8 +- .../server/routes/api/v1/alerts/index.ts | 10 +- .../server/routes/api/v1/alerts/status.ts | 7 +- .../server/routes/api/v1/apm/index.ts | 13 ++- .../server/routes/api/v1/beats/index.ts | 13 ++- .../api/v1/check_access/check_access.ts | 9 +- .../routes/api/v1/check_access/index.ts | 7 +- .../server/routes/api/v1/cluster/cluster.ts | 46 ++++---- .../server/routes/api/v1/cluster/clusters.ts | 40 +++---- .../server/routes/api/v1/cluster/index.ts | 10 +- .../routes/api/v1/elasticsearch/index.ts | 28 +++-- .../check/internal_monitoring.ts | 4 +- .../api/v1/elasticsearch_settings/index.ts | 22 +++- .../monitoring/server/routes/api/v1/index.ts | 16 +++ .../server/routes/api/v1/logstash/index.ts | 25 ++-- .../api/v1/setup/cluster_setup_status.js | 72 ------------ .../api/v1/setup/cluster_setup_status.ts | 62 ++++++++++ ...able_elasticsearch_internal_collection.ts} | 18 ++- .../server/routes/api/v1/setup/index.ts | 17 +++ .../routes/api/v1/setup/node_setup_status.js | 74 ------------ .../routes/api/v1/setup/node_setup_status.ts | 64 +++++++++++ .../monitoring/server/routes/api/v1/ui.js | 42 ------- .../monitoring/server/routes/api/v1/ui.ts | 14 +++ .../plugins/monitoring/server/routes/index.ts | 35 ++++-- x-pack/plugins/monitoring/server/types.ts | 3 +- 43 files changed, 753 insertions(+), 367 deletions(-) create mode 100644 x-pack/plugins/infra/server/mocks.ts rename x-pack/plugins/monitoring/{server/routes/api/v1/setup/index.js => common/http_api/cluster/index.ts} (52%) create mode 100644 x-pack/plugins/monitoring/common/http_api/cluster/post_cluster.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/cluster/post_clusters.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/setup/index.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/setup/post_cluster_setup_status.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/setup/post_disable_internal_collection.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/setup/post_node_setup_status.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/shared/literal_value.test.ts create mode 100644 x-pack/plugins/monitoring/common/http_api/shared/query_string_boolean.test.ts rename x-pack/plugins/monitoring/server/lib/setup/collection/{get_collection_status.test.js => get_collection_status.test.ts} (79%) create mode 100644 x-pack/plugins/monitoring/server/mocks.ts create mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/index.ts delete mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js create mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.ts rename x-pack/plugins/monitoring/server/routes/api/v1/setup/{disable_elasticsearch_internal_collection.js => disable_elasticsearch_internal_collection.ts} (74%) create mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/setup/index.ts delete mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js create mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.ts delete mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/ui.js create mode 100644 x-pack/plugins/monitoring/server/routes/api/v1/ui.ts diff --git a/x-pack/plugins/infra/server/mocks.ts b/x-pack/plugins/infra/server/mocks.ts new file mode 100644 index 0000000000000..5b587a1fe80d5 --- /dev/null +++ b/x-pack/plugins/infra/server/mocks.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + createLogViewsServiceSetupMock, + createLogViewsServiceStartMock, +} from './services/log_views/log_views_service.mock'; +import { InfraPluginSetup, InfraPluginStart } from './types'; + +const createInfraSetupMock = () => { + const infraSetupMock: jest.Mocked = { + defineInternalSourceConfiguration: jest.fn(), + logViews: createLogViewsServiceSetupMock(), + }; + + return infraSetupMock; +}; + +const createInfraStartMock = () => { + const infraStartMock: jest.Mocked = { + getMetricIndices: jest.fn(), + logViews: createLogViewsServiceStartMock(), + }; + return infraStartMock; +}; + +export const infraPluginMock = { + createSetupContract: createInfraSetupMock, + createStartContract: createInfraStartMock, +}; diff --git a/x-pack/plugins/infra/server/services/log_views/log_views_service.mock.ts b/x-pack/plugins/infra/server/services/log_views/log_views_service.mock.ts index becd5a015b2ec..e472e30fae2b4 100644 --- a/x-pack/plugins/infra/server/services/log_views/log_views_service.mock.ts +++ b/x-pack/plugins/infra/server/services/log_views/log_views_service.mock.ts @@ -6,7 +6,11 @@ */ import { createLogViewsClientMock } from './log_views_client.mock'; -import { LogViewsServiceStart } from './types'; +import { LogViewsServiceSetup, LogViewsServiceStart } from './types'; + +export const createLogViewsServiceSetupMock = (): jest.Mocked => ({ + defineInternalLogView: jest.fn(), +}); export const createLogViewsServiceStartMock = (): jest.Mocked => ({ getClient: jest.fn((_savedObjectsClient: any, _elasticsearchClient: any) => diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/index.js b/x-pack/plugins/monitoring/common/http_api/cluster/index.ts similarity index 52% rename from x-pack/plugins/monitoring/server/routes/api/v1/setup/index.js rename to x-pack/plugins/monitoring/common/http_api/cluster/index.ts index f450fc906d076..af53ade67f610 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/setup/index.js +++ b/x-pack/plugins/monitoring/common/http_api/cluster/index.ts @@ -5,6 +5,5 @@ * 2.0. */ -export { clusterSetupStatusRoute } from './cluster_setup_status'; -export { nodeSetupStatusRoute } from './node_setup_status'; -export { disableElasticsearchInternalCollectionRoute } from './disable_elasticsearch_internal_collection'; +export * from './post_cluster'; +export * from './post_clusters'; diff --git a/x-pack/plugins/monitoring/common/http_api/cluster/post_cluster.ts b/x-pack/plugins/monitoring/common/http_api/cluster/post_cluster.ts new file mode 100644 index 0000000000000..faa26989fec37 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/cluster/post_cluster.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 * as rt from 'io-ts'; +import { ccsRT, clusterUuidRT, timeRangeRT } from '../shared'; + +export const postClusterRequestParamsRT = rt.type({ + clusterUuid: clusterUuidRT, +}); + +export const postClusterRequestPayloadRT = rt.intersection([ + rt.partial({ + ccs: ccsRT, + }), + rt.type({ + timeRange: timeRangeRT, + codePaths: rt.array(rt.string), + }), +]); + +export type PostClusterRequestPayload = rt.TypeOf; + +export const postClusterResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/monitoring/common/http_api/cluster/post_clusters.ts b/x-pack/plugins/monitoring/common/http_api/cluster/post_clusters.ts new file mode 100644 index 0000000000000..ad3214c354bc5 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/cluster/post_clusters.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { timeRangeRT } from '../shared'; + +export const postClustersRequestPayloadRT = rt.type({ + timeRange: timeRangeRT, + codePaths: rt.array(rt.string), +}); + +export type PostClustersRequestPayload = rt.TypeOf; + +export const postClustersResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/monitoring/common/http_api/setup/index.ts b/x-pack/plugins/monitoring/common/http_api/setup/index.ts new file mode 100644 index 0000000000000..33cce5833c3c5 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/setup/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './post_cluster_setup_status'; +export * from './post_node_setup_status'; +export * from './post_disable_internal_collection'; diff --git a/x-pack/plugins/monitoring/common/http_api/setup/post_cluster_setup_status.ts b/x-pack/plugins/monitoring/common/http_api/setup/post_cluster_setup_status.ts new file mode 100644 index 0000000000000..2c4f1293fb89e --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/setup/post_cluster_setup_status.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { + booleanFromStringRT, + ccsRT, + clusterUuidRT, + createLiteralValueFromUndefinedRT, + timeRangeRT, +} from '../shared'; + +export const postClusterSetupStatusRequestParamsRT = rt.partial({ + clusterUuid: clusterUuidRT, +}); + +export const postClusterSetupStatusRequestQueryRT = rt.partial({ + // This flag is not intended to be used in production. It was introduced + // as a way to ensure consistent API testing - the typical data source + // for API tests are archived data, where the cluster configuration and data + // are consistent from environment to environment. However, this endpoint + // also attempts to retrieve data from the running stack products (ES and Kibana) + // which will vary from environment to environment making it difficult + // to write tests against. Therefore, this flag exists and should only be used + // in our testing environment. + skipLiveData: rt.union([booleanFromStringRT, createLiteralValueFromUndefinedRT(false)]), +}); + +export const postClusterSetupStatusRequestPayloadRT = rt.partial({ + ccs: ccsRT, + timeRange: timeRangeRT, +}); + +export type PostClusterSetupStatusRequestPayload = rt.TypeOf< + typeof postClusterSetupStatusRequestPayloadRT +>; + +export const postClusterSetupStatusResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/monitoring/common/http_api/setup/post_disable_internal_collection.ts b/x-pack/plugins/monitoring/common/http_api/setup/post_disable_internal_collection.ts new file mode 100644 index 0000000000000..d44794d7e1829 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/setup/post_disable_internal_collection.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { clusterUuidRT } from '../shared'; + +export const postDisableInternalCollectionRequestParamsRT = rt.partial({ + // the cluster uuid seems to be required but never used + clusterUuid: clusterUuidRT, +}); diff --git a/x-pack/plugins/monitoring/common/http_api/setup/post_node_setup_status.ts b/x-pack/plugins/monitoring/common/http_api/setup/post_node_setup_status.ts new file mode 100644 index 0000000000000..1d51d36ae4477 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/setup/post_node_setup_status.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { + booleanFromStringRT, + ccsRT, + createLiteralValueFromUndefinedRT, + timeRangeRT, +} from '../shared'; + +export const postNodeSetupStatusRequestParamsRT = rt.type({ + nodeUuid: rt.string, +}); + +export const postNodeSetupStatusRequestQueryRT = rt.partial({ + // This flag is not intended to be used in production. It was introduced + // as a way to ensure consistent API testing - the typical data source + // for API tests are archived data, where the cluster configuration and data + // are consistent from environment to environment. However, this endpoint + // also attempts to retrieve data from the running stack products (ES and Kibana) + // which will vary from environment to environment making it difficult + // to write tests against. Therefore, this flag exists and should only be used + // in our testing environment. + skipLiveData: rt.union([booleanFromStringRT, createLiteralValueFromUndefinedRT(false)]), +}); + +export const postNodeSetupStatusRequestPayloadRT = rt.partial({ + ccs: ccsRT, + timeRange: timeRangeRT, +}); + +export type PostNodeSetupStatusRequestPayload = rt.TypeOf< + typeof postNodeSetupStatusRequestPayloadRT +>; + +export const postNodeSetupStatusResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/monitoring/common/http_api/shared/literal_value.test.ts b/x-pack/plugins/monitoring/common/http_api/shared/literal_value.test.ts new file mode 100644 index 0000000000000..3d70e86620602 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/shared/literal_value.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { either } from 'fp-ts'; +import * as rt from 'io-ts'; +import { createLiteralValueFromUndefinedRT } from './literal_value'; + +describe('LiteralValueFromUndefined runtime type', () => { + it('decodes undefined to a given literal value', () => { + expect(createLiteralValueFromUndefinedRT('SOME_VALUE').decode(undefined)).toEqual( + either.right('SOME_VALUE') + ); + }); + + it('can be used to define default values when decoding', () => { + expect( + rt.union([rt.boolean, createLiteralValueFromUndefinedRT(true)]).decode(undefined) + ).toEqual(either.right(true)); + }); + + it('rejects other values', () => { + expect( + either.isLeft(createLiteralValueFromUndefinedRT('SOME_VALUE').decode('DEFINED')) + ).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/monitoring/common/http_api/shared/query_string_boolean.test.ts b/x-pack/plugins/monitoring/common/http_api/shared/query_string_boolean.test.ts new file mode 100644 index 0000000000000..1801c6746feb2 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/shared/query_string_boolean.test.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 { either } from 'fp-ts'; +import { booleanFromStringRT } from './query_string_boolean'; + +describe('BooleanFromString runtime type', () => { + it('decodes string "true" to a boolean', () => { + expect(booleanFromStringRT.decode('true')).toEqual(either.right(true)); + }); + + it('decodes string "false" to a boolean', () => { + expect(booleanFromStringRT.decode('false')).toEqual(either.right(false)); + }); + + it('rejects other strings', () => { + expect(either.isLeft(booleanFromStringRT.decode('maybe'))).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/monitoring/server/debug_logger.ts b/x-pack/plugins/monitoring/server/debug_logger.ts index 0add1f12f0304..cce00f834cbb2 100644 --- a/x-pack/plugins/monitoring/server/debug_logger.ts +++ b/x-pack/plugins/monitoring/server/debug_logger.ts @@ -4,18 +4,19 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { RouteMethod } from '@kbn/core/server'; import fs from 'fs'; import { MonitoringConfig } from './config'; -import { RouteDependencies } from './types'; +import { LegacyRequest, MonitoringCore, MonitoringRouteConfig, RouteDependencies } from './types'; export function decorateDebugServer( - _server: any, + server: MonitoringCore, config: MonitoringConfig, logger: RouteDependencies['logger'] -) { +): MonitoringCore { // bail if the proper config value is not set (extra protection) if (!config.ui.debug_mode) { - return _server; + return server; } // create a debug logger that will either write to file (if debug_log_path exists) or log out via logger @@ -23,14 +24,16 @@ export function decorateDebugServer( return { // maintain the rest of _server untouched - ..._server, + ...server, // TODO: replace any - route: (options: any) => { + route: ( + options: MonitoringRouteConfig + ) => { const apiPath = options.path; - return _server.route({ + return server.route({ ...options, // TODO: replace any - handler: async (req: any) => { + handler: async (req: LegacyRequest): Promise => { const { elasticsearch: cached } = req.server.plugins; const apiRequestHeaders = req.headers; req.server.plugins.elasticsearch = { diff --git a/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.ts b/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.ts index 80d17a8ad0627..f93c3f8ad7590 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.ts +++ b/x-pack/plugins/monitoring/server/lib/cluster/flag_supported_clusters.ts @@ -6,13 +6,18 @@ */ import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../../common/constants'; +import { TimeRange } from '../../../common/http_api/shared'; import { ElasticsearchResponse } from '../../../common/types/es'; -import { LegacyRequest, Cluster } from '../../types'; -import { getNewIndexPatterns } from './get_index_patterns'; import { Globals } from '../../static_globals'; +import { Cluster, LegacyRequest } from '../../types'; +import { getNewIndexPatterns } from './get_index_patterns'; + +export interface FindSupportClusterRequestPayload { + timeRange: TimeRange; +} async function findSupportedBasicLicenseCluster( - req: LegacyRequest, + req: LegacyRequest, clusters: Cluster[], ccs: string, kibanaUuid: string, @@ -53,7 +58,7 @@ async function findSupportedBasicLicenseCluster( }, }, { term: { 'kibana_stats.kibana.uuid': kibanaUuid } }, - { range: { timestamp: { gte, lte, format: 'strict_date_optional_time' } } }, + { range: { timestamp: { gte, lte, format: 'epoch_millis' } } }, ], }, }, @@ -86,7 +91,10 @@ async function findSupportedBasicLicenseCluster( * Non-Basic license clusters and any cluster in a single-cluster environment * are also flagged as supported in this method. */ -export function flagSupportedClusters(req: LegacyRequest, ccs: string) { +export function flagSupportedClusters( + req: LegacyRequest, + ccs: string +) { const serverLog = (message: string) => req.getLogger('supported-clusters').debug(message); const flagAllSupported = (clusters: Cluster[]) => { clusters.forEach((cluster) => { diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_index_patterns.ts b/x-pack/plugins/monitoring/server/lib/cluster/get_index_patterns.ts index 7d470857dfe5a..2ebf4fe6b480e 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_index_patterns.ts +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_index_patterns.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { LegacyServer } from '../../types'; import { prefixIndexPatternWithCcs } from '../../../common/ccs_utils'; import { INDEX_PATTERN_ELASTICSEARCH, @@ -20,14 +19,13 @@ import { INDEX_PATTERN_ENTERPRISE_SEARCH, CCS_REMOTE_PATTERN, } from '../../../common/constants'; -import { MonitoringConfig } from '../..'; +import { MonitoringConfig } from '../../config'; export function getIndexPatterns( - server: LegacyServer, + config: MonitoringConfig, additionalPatterns: Record = {}, ccs: string = CCS_REMOTE_PATTERN ) { - const config = server.config; const esIndexPattern = prefixIndexPatternWithCcs(config, INDEX_PATTERN_ELASTICSEARCH, ccs); const kbnIndexPattern = prefixIndexPatternWithCcs(config, INDEX_PATTERN_KIBANA, ccs); const lsIndexPattern = prefixIndexPatternWithCcs(config, INDEX_PATTERN_LOGSTASH, ccs); diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts index 3bd9f6d2265dc..a5ee876012c1d 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts @@ -19,7 +19,7 @@ import { LegacyRequest } from '../../types'; */ // TODO: replace LegacyRequest with current request object + plugin retrieval -export async function verifyMonitoringAuth(req: LegacyRequest) { +export async function verifyMonitoringAuth(req: LegacyRequest) { const xpackInfo = get(req.server.plugins.monitoring, 'info'); if (xpackInfo) { @@ -42,7 +42,7 @@ export async function verifyMonitoringAuth(req: LegacyRequest) { */ // TODO: replace LegacyRequest with current request object + plugin retrieval -async function verifyHasPrivileges(req: LegacyRequest) { +async function verifyHasPrivileges(req: LegacyRequest): Promise { const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); let response; diff --git a/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.js b/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.ts similarity index 79% rename from x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.js rename to x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.ts index 214e8d5907443..ed92948be8e3b 100644 --- a/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.js +++ b/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.test.ts @@ -5,49 +5,50 @@ * 2.0. */ -import { getCollectionStatus } from '.'; +import { featuresPluginMock } from '@kbn/features-plugin/server/mocks'; +import { infraPluginMock } from '@kbn/infra-plugin/server/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks'; +import { configSchema, createConfig } from '../../../config'; +import { monitoringPluginMock } from '../../../mocks'; +import { LegacyRequest } from '../../../types'; import { getIndexPatterns } from '../../cluster/get_index_patterns'; +import { getCollectionStatus } from './get_collection_status'; const liveClusterUuid = 'a12'; const mockReq = ( - searchResult = {}, - securityEnabled = true, - userHasPermissions = true, - securityErrorMessage = null -) => { + searchResult: object = {}, + securityEnabled: boolean = true, + userHasPermissions: boolean = true, + securityErrorMessage: string | null = null +): LegacyRequest => { + const usageCollectionSetup = usageCollectionPluginMock.createSetupContract(); + const licenseService = monitoringPluginMock.createLicenseServiceMock(); + licenseService.getSecurityFeature.mockReturnValue({ + isAvailable: securityEnabled, + isEnabled: securityEnabled, + }); + const logger = loggerMock.create(); + return { server: { instanceUuid: 'kibana-1234', newPlatform: { setup: { plugins: { - usageCollection: { - getCollectorByType: () => ({ - isReady: () => false, - }), - }, + usageCollection: usageCollectionSetup, + features: featuresPluginMock.createSetup(), + infra: infraPluginMock.createSetupContract(), }, }, }, - config: { ui: { ccs: { enabled: false } } }, - usage: { - collectorSet: { - getCollectorByType: () => ({ - isReady: () => false, - }), - }, - }, + config: createConfig(configSchema.validate({ ui: { ccs: { enabled: false } } })), + log: logger, + route: jest.fn(), plugins: { monitoring: { info: { - getLicenseService: () => ({ - getSecurityFeature: () => { - return { - isAvailable: securityEnabled, - isEnabled: securityEnabled, - }; - }, - }), + getLicenseService: () => licenseService, }, }, elasticsearch: { @@ -86,6 +87,17 @@ const mockReq = ( }, }, }, + logger, + getLogger: () => logger, + params: {}, + payload: {}, + query: {}, + headers: {}, + getKibanaStatsCollector: () => null, + getUiSettingsService: () => null, + getActionTypeRegistry: () => null, + getRulesClient: () => null, + getActionsClient: () => null, }; }; @@ -124,7 +136,7 @@ describe('getCollectionStatus', () => { }, }); - const result = await getCollectionStatus(req, getIndexPatterns(req.server)); + const result = await getCollectionStatus(req, getIndexPatterns(req.server.config)); expect(result.kibana.totalUniqueInstanceCount).toBe(1); expect(result.kibana.totalUniqueFullyMigratedCount).toBe(0); @@ -173,7 +185,7 @@ describe('getCollectionStatus', () => { }, }); - const result = await getCollectionStatus(req, getIndexPatterns(req.server)); + const result = await getCollectionStatus(req, getIndexPatterns(req.server.config)); expect(result.kibana.totalUniqueInstanceCount).toBe(1); expect(result.kibana.totalUniqueFullyMigratedCount).toBe(1); @@ -229,7 +241,7 @@ describe('getCollectionStatus', () => { }, }); - const result = await getCollectionStatus(req, getIndexPatterns(req.server)); + const result = await getCollectionStatus(req, getIndexPatterns(req.server.config)); expect(result.kibana.totalUniqueInstanceCount).toBe(2); expect(result.kibana.totalUniqueFullyMigratedCount).toBe(1); @@ -251,7 +263,11 @@ describe('getCollectionStatus', () => { it('should detect products based on other indices', async () => { const req = mockReq({ hits: { total: { value: 1 } } }); - const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + const result = await getCollectionStatus( + req, + getIndexPatterns(req.server.config), + liveClusterUuid + ); expect(result.kibana.detected.doesExist).toBe(true); expect(result.elasticsearch.detected.doesExist).toBe(true); @@ -261,13 +277,21 @@ describe('getCollectionStatus', () => { it('should work properly when security is disabled', async () => { const req = mockReq({ hits: { total: { value: 1 } } }, false); - const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + const result = await getCollectionStatus( + req, + getIndexPatterns(req.server.config), + liveClusterUuid + ); expect(result.kibana.detected.doesExist).toBe(true); }); it('should work properly with an unknown security message', async () => { const req = mockReq({ hits: { total: { value: 1 } } }, true, true, 'foobar'); - const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + const result = await getCollectionStatus( + req, + getIndexPatterns(req.server.config), + liveClusterUuid + ); expect(result._meta.hasPermissions).toBe(false); }); @@ -278,7 +302,11 @@ describe('getCollectionStatus', () => { true, 'no handler found for uri [/_security/user/_has_privileges] and method [POST]' ); - const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + const result = await getCollectionStatus( + req, + getIndexPatterns(req.server.config), + liveClusterUuid + ); expect(result.kibana.detected.doesExist).toBe(true); }); @@ -289,13 +317,21 @@ describe('getCollectionStatus', () => { true, 'Invalid index name [_security]' ); - const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + const result = await getCollectionStatus( + req, + getIndexPatterns(req.server.config), + liveClusterUuid + ); expect(result.kibana.detected.doesExist).toBe(true); }); it('should not work if the user does not have the necessary permissions', async () => { const req = mockReq({ hits: { total: { value: 1 } } }, true, false); - const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + const result = await getCollectionStatus( + req, + getIndexPatterns(req.server.config), + liveClusterUuid + ); expect(result._meta.hasPermissions).toBe(false); }); }); diff --git a/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.ts b/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.ts index b06b74fd255f4..568b8bbaef567 100644 --- a/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.ts +++ b/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.ts @@ -5,17 +5,18 @@ * 2.0. */ -import { get, uniq } from 'lodash'; import { CollectorFetchContext, UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; +import { get, uniq } from 'lodash'; import { - METRICBEAT_INDEX_NAME_UNIQUE_TOKEN, - ELASTICSEARCH_SYSTEM_ID, APM_SYSTEM_ID, - KIBANA_SYSTEM_ID, BEATS_SYSTEM_ID, - LOGSTASH_SYSTEM_ID, + ELASTICSEARCH_SYSTEM_ID, KIBANA_STATS_TYPE_MONITORING, + KIBANA_SYSTEM_ID, + LOGSTASH_SYSTEM_ID, + METRICBEAT_INDEX_NAME_UNIQUE_TOKEN, } from '../../../../common/constants'; +import { TimeRange } from '../../../../common/http_api/shared'; import { LegacyRequest } from '../../../types'; import { getLivesNodes } from '../../elasticsearch/nodes/get_nodes/get_live_nodes'; @@ -31,7 +32,7 @@ interface Bucket { const NUMBER_OF_SECONDS_AGO_TO_LOOK = 30; const getRecentMonitoringDocuments = async ( - req: LegacyRequest, + req: LegacyRequest, indexPatterns: Record, clusterUuid?: string, nodeUuid?: string, @@ -300,7 +301,7 @@ function isBeatFromAPM(bucket: Bucket) { return get(beatType, 'buckets[0].key') === 'apm-server'; } -async function hasNecessaryPermissions(req: LegacyRequest) { +async function hasNecessaryPermissions(req: LegacyRequest) { const licenseService = await req.server.plugins.monitoring.info.getLicenseService(); const securityFeature = licenseService.getSecurityFeature(); if (!securityFeature.isAvailable || !securityFeature.isEnabled) { @@ -366,7 +367,7 @@ async function getLiveKibanaInstance(usageCollection?: UsageCollectionSetup) { ); } -async function getLiveElasticsearchClusterUuid(req: LegacyRequest) { +async function getLiveElasticsearchClusterUuid(req: LegacyRequest) { const params = { path: '/_cluster/state/cluster_uuid', method: 'GET', @@ -377,7 +378,9 @@ async function getLiveElasticsearchClusterUuid(req: LegacyRequest) { return clusterUuid; } -async function getLiveElasticsearchCollectionEnabled(req: LegacyRequest) { +async function getLiveElasticsearchCollectionEnabled( + req: LegacyRequest +) { const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('admin'); const response = await callWithRequest(req, 'transport.request', { method: 'GET', @@ -425,7 +428,7 @@ async function getLiveElasticsearchCollectionEnabled(req: LegacyRequest) { * @param {*} skipLiveData Optional and will not make any live api calls if set to true */ export const getCollectionStatus = async ( - req: LegacyRequest, + req: LegacyRequest, indexPatterns: Record, clusterUuid?: string, nodeUuid?: string, diff --git a/x-pack/plugins/monitoring/server/mocks.ts b/x-pack/plugins/monitoring/server/mocks.ts new file mode 100644 index 0000000000000..5adeae22acfc0 --- /dev/null +++ b/x-pack/plugins/monitoring/server/mocks.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ILicense } from '@kbn/licensing-plugin/server'; +import { Subject } from 'rxjs'; +import { MonitoringLicenseService } from './types'; + +const createLicenseServiceMock = (): jest.Mocked => ({ + refresh: jest.fn(), + license$: new Subject(), + getMessage: jest.fn(), + getWatcherFeature: jest.fn(), + getMonitoringFeature: jest.fn(), + getSecurityFeature: jest.fn(), + stop: jest.fn(), +}); + +// this might be incomplete and is added to as needed +export const monitoringPluginMock = { + createLicenseServiceMock, +}; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts index 9188215137565..b773e25b81152 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/enable.ts @@ -8,15 +8,15 @@ // @ts-ignore import { ActionResult } from '@kbn/actions-plugin/common'; import { RuleTypeParams, SanitizedRule } from '@kbn/alerting-plugin/common'; -import { handleError } from '../../../../lib/errors'; -import { AlertsFactory } from '../../../../alerts'; -import { LegacyServer, RouteDependencies } from '../../../../types'; import { ALERT_ACTION_TYPE_LOG } from '../../../../../common/constants'; +import { AlertsFactory } from '../../../../alerts'; import { disableWatcherClusterAlerts } from '../../../../lib/alerts/disable_watcher_cluster_alerts'; +import { handleError } from '../../../../lib/errors'; +import { MonitoringCore, RouteDependencies } from '../../../../types'; const DEFAULT_SERVER_LOG_NAME = 'Monitoring: Write to Kibana log'; -export function enableAlertsRoute(server: LegacyServer, npRoute: RouteDependencies) { +export function enableAlertsRoute(server: MonitoringCore, npRoute: RouteDependencies) { npRoute.router.post( { path: '/api/monitoring/v1/alerts/enable', diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.ts index 11782c73d9b55..c2511e1d24c0a 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.ts @@ -5,5 +5,11 @@ * 2.0. */ -export { enableAlertsRoute } from './enable'; -export { alertStatusRoute } from './status'; +import { MonitoringCore, RouteDependencies } from '../../../../types'; +import { enableAlertsRoute } from './enable'; +import { alertStatusRoute } from './status'; + +export function registerV1AlertRoutes(server: MonitoringCore, npRoute: RouteDependencies) { + alertStatusRoute(npRoute); + enableAlertsRoute(server, npRoute); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/status.ts b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/status.ts index a145d92921634..a9efc14c8c458 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/status.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/status.ts @@ -6,13 +6,12 @@ */ import { schema } from '@kbn/config-schema'; -// @ts-ignore +import { CommonAlertFilter } from '../../../../../common/types/alerts'; +import { fetchStatus } from '../../../../lib/alerts/fetch_status'; import { handleError } from '../../../../lib/errors'; import { RouteDependencies } from '../../../../types'; -import { fetchStatus } from '../../../../lib/alerts/fetch_status'; -import { CommonAlertFilter } from '../../../../../common/types/alerts'; -export function alertStatusRoute(server: any, npRoute: RouteDependencies) { +export function alertStatusRoute(npRoute: RouteDependencies) { npRoute.router.post( { path: '/api/monitoring/v1/alert/{clusterUuid}/status', diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/apm/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/apm/index.ts index 0fb4dd78c9be6..97d9a2f9789d7 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/apm/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/apm/index.ts @@ -5,6 +5,13 @@ * 2.0. */ -export { apmInstanceRoute } from './instance'; -export { apmInstancesRoute } from './instances'; -export { apmOverviewRoute } from './overview'; +import { MonitoringCore } from '../../../../types'; +import { apmInstanceRoute } from './instance'; +import { apmInstancesRoute } from './instances'; +import { apmOverviewRoute } from './overview'; + +export function registerV1ApmRoutes(server: MonitoringCore) { + apmInstanceRoute(server); + apmInstancesRoute(server); + apmOverviewRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/beats/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/beats/index.ts index 57423052760bf..935ca35c3a384 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/beats/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/beats/index.ts @@ -5,6 +5,13 @@ * 2.0. */ -export { beatsOverviewRoute } from './overview'; -export { beatsListingRoute } from './beats'; -export { beatsDetailRoute } from './beat_detail'; +import { MonitoringCore } from '../../../../types'; +import { beatsListingRoute } from './beats'; +import { beatsDetailRoute } from './beat_detail'; +import { beatsOverviewRoute } from './overview'; + +export function registerV1BeatsRoutes(server: MonitoringCore) { + beatsDetailRoute(server); + beatsListingRoute(server); + beatsOverviewRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.ts b/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.ts index 450872049a3de..2db7481882b89 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.ts @@ -7,18 +7,19 @@ import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; import { handleError } from '../../../../lib/errors'; -import { LegacyRequest, LegacyServer } from '../../../../types'; +import { LegacyRequest, MonitoringCore } from '../../../../types'; /* * API for checking read privilege on Monitoring Data * Used for the "Access Denied" page as something to auto-retry with. */ -// TODO: Replace this LegacyServer call with the "new platform" core Kibana route method -export function checkAccessRoute(server: LegacyServer) { +// TODO: Replace this legacy route registration with the "new platform" core Kibana route method +export function checkAccessRoute(server: MonitoringCore) { server.route({ - method: 'GET', + method: 'get', path: '/api/monitoring/v1/check_access', + validate: {}, handler: async (req: LegacyRequest) => { const response: { has_access?: boolean } = {}; try { diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.ts index 0fb8228f82442..5209ec8b92e9a 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.ts @@ -5,4 +5,9 @@ * 2.0. */ -export { checkAccessRoute } from './check_access'; +import { MonitoringCore } from '../../../../types'; +import { checkAccessRoute } from './check_access'; + +export function registerV1CheckAccessRoutes(server: MonitoringCore) { + checkAccessRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/cluster.ts b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/cluster.ts index 30749f2e95c9f..6bd0a19d79c5f 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/cluster.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/cluster.ts @@ -5,39 +5,36 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; +import { + postClusterRequestParamsRT, + postClusterRequestPayloadRT, + postClusterResponsePayloadRT, +} from '../../../../../common/http_api/cluster'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; import { getClustersFromRequest } from '../../../../lib/cluster/get_clusters_from_request'; -// @ts-ignore -import { handleError } from '../../../../lib/errors'; import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; -import { LegacyRequest, LegacyServer } from '../../../../types'; +import { handleError } from '../../../../lib/errors'; +import { MonitoringCore } from '../../../../types'; -export function clusterRoute(server: LegacyServer) { +export function clusterRoute(server: MonitoringCore) { /* * Cluster Overview */ + + const validateParams = createValidationFunction(postClusterRequestParamsRT); + const validateBody = createValidationFunction(postClusterRequestPayloadRT); + server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/clusters/{clusterUuid}', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - }), - body: schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.object({ - min: schema.string(), - max: schema.string(), - }), - codePaths: schema.arrayOf(schema.string()), - }), - }, + validate: { + params: validateParams, + body: validateBody, }, - handler: async (req: LegacyRequest) => { + handler: async (req) => { const config = server.config; - const indexPatterns = getIndexPatterns(server, { + const indexPatterns = getIndexPatterns(config, { filebeatIndexPattern: config.ui.logs.index, }); const options = { @@ -47,13 +44,12 @@ export function clusterRoute(server: LegacyServer) { codePaths: req.payload.codePaths, }; - let clusters = []; try { - clusters = await getClustersFromRequest(req, indexPatterns, options); + const clusters = await getClustersFromRequest(req, indexPatterns, options); + return postClusterResponsePayloadRT.encode(clusters); } catch (err) { throw handleError(err, req); } - return clusters; }, }); } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.ts b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.ts index 81acd0e53f319..9591dda205487 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.ts @@ -5,36 +5,33 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; -import { LegacyRequest, LegacyServer } from '../../../../types'; +import { + postClustersRequestPayloadRT, + postClustersResponsePayloadRT, +} from '../../../../../common/http_api/cluster'; import { getClustersFromRequest } from '../../../../lib/cluster/get_clusters_from_request'; +import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; import { handleError } from '../../../../lib/errors'; -import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; +import { MonitoringCore } from '../../../../types'; -export function clustersRoute(server: LegacyServer) { +export function clustersRoute(server: MonitoringCore) { /* * Monitoring Home * Route Init (for checking license and compatibility for multi-cluster monitoring */ + const validateBody = createValidationFunction(postClustersRequestPayloadRT); + // TODO switch from the LegacyServer route() method to the "new platform" route methods server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/clusters', - config: { - validate: { - body: schema.object({ - timeRange: schema.object({ - min: schema.string(), - max: schema.string(), - }), - codePaths: schema.arrayOf(schema.string()), - }), - }, + validate: { + body: validateBody, }, - handler: async (req: LegacyRequest) => { - let clusters = []; + handler: async (req) => { const config = server.config; // NOTE using try/catch because checkMonitoringAuth is expected to throw @@ -42,17 +39,16 @@ export function clustersRoute(server: LegacyServer) { // the monitoring data. `try/catch` makes it a little more explicit. try { await verifyMonitoringAuth(req); - const indexPatterns = getIndexPatterns(server, { + const indexPatterns = getIndexPatterns(config, { filebeatIndexPattern: config.ui.logs.index, }); - clusters = await getClustersFromRequest(req, indexPatterns, { - codePaths: req.payload.codePaths as string[], // TODO remove this cast when we can properly type req by using the right route handler + const clusters = await getClustersFromRequest(req, indexPatterns, { + codePaths: req.payload.codePaths, }); + return postClustersResponsePayloadRT.encode(clusters); } catch (err) { throw handleError(err, req); } - - return clusters; }, }); } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.ts index 769f315480d9c..9534398db52c1 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.ts @@ -5,5 +5,11 @@ * 2.0. */ -export { clusterRoute } from './cluster'; -export { clustersRoute } from './clusters'; +import { clusterRoute } from './cluster'; +import { clustersRoute } from './clusters'; +import { MonitoringCore } from '../../../../types'; + +export function registerV1ClusterRoutes(server: MonitoringCore) { + clusterRoute(server); + clustersRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/index.ts index b2d432a5e35b5..e706dc61c0a41 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/index.ts @@ -5,11 +5,23 @@ * 2.0. */ -export { esIndexRoute } from './index_detail'; -export { esIndicesRoute } from './indices'; -export { esNodeRoute } from './node_detail'; -export { esNodesRoute } from './nodes'; -export { esOverviewRoute } from './overview'; -export { mlJobRoute } from './ml_jobs'; -export { ccrRoute } from './ccr'; -export { ccrShardRoute } from './ccr_shard'; +import { MonitoringCore } from '../../../../types'; +import { ccrRoute } from './ccr'; +import { ccrShardRoute } from './ccr_shard'; +import { esIndexRoute } from './index_detail'; +import { esIndicesRoute } from './indices'; +import { mlJobRoute } from './ml_jobs'; +import { esNodesRoute } from './nodes'; +import { esNodeRoute } from './node_detail'; +import { esOverviewRoute } from './overview'; + +export function registerV1ElasticsearchRoutes(server: MonitoringCore) { + esIndexRoute(server); + esIndicesRoute(server); + esNodeRoute(server); + esNodesRoute(server); + esOverviewRoute(server); + mlJobRoute(server); + ccrRoute(server); + ccrShardRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts index 11e0eec3f08f0..f8742144b28f8 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts @@ -19,7 +19,7 @@ import { } from '../../../../../../common/http_api/elasticsearch_settings'; import { createValidationFunction } from '../../../../../lib/create_route_validation_function'; import { handleError } from '../../../../../lib/errors'; -import { LegacyServer, RouteDependencies } from '../../../../../types'; +import { MonitoringCore, RouteDependencies } from '../../../../../types'; const queryBody = { size: 0, @@ -72,7 +72,7 @@ const checkLatestMonitoringIsLegacy = async (context: RequestHandlerContext, ind return counts; }; -export function internalMonitoringCheckRoute(server: LegacyServer, npRoute: RouteDependencies) { +export function internalMonitoringCheckRoute(server: MonitoringCore, npRoute: RouteDependencies) { const validateBody = createValidationFunction( postElasticsearchSettingsInternalMonitoringRequestPayloadRT ); diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.ts index 61bb1ba804a5a..dfc68068bf80d 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.ts @@ -5,8 +5,20 @@ * 2.0. */ -export { clusterSettingsCheckRoute } from './check/cluster'; -export { internalMonitoringCheckRoute } from './check/internal_monitoring'; -export { nodesSettingsCheckRoute } from './check/nodes'; -export { setCollectionEnabledRoute } from './set/collection_enabled'; -export { setCollectionIntervalRoute } from './set/collection_interval'; +import { MonitoringCore, RouteDependencies } from '../../../../types'; +import { clusterSettingsCheckRoute } from './check/cluster'; +import { internalMonitoringCheckRoute } from './check/internal_monitoring'; +import { nodesSettingsCheckRoute } from './check/nodes'; +import { setCollectionEnabledRoute } from './set/collection_enabled'; +import { setCollectionIntervalRoute } from './set/collection_interval'; + +export function registerV1ElasticsearchSettingsRoutes( + server: MonitoringCore, + npRoute: RouteDependencies +) { + clusterSettingsCheckRoute(server); + internalMonitoringCheckRoute(server, npRoute); + nodesSettingsCheckRoute(server); + setCollectionEnabledRoute(server); + setCollectionIntervalRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/index.ts new file mode 100644 index 0000000000000..e0f5e55c6c128 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/index.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. + */ + +export { registerV1AlertRoutes } from './alerts'; +export { registerV1ApmRoutes } from './apm'; +export { registerV1BeatsRoutes } from './beats'; +export { registerV1CheckAccessRoutes } from './check_access'; +export { registerV1ClusterRoutes } from './cluster'; +export { registerV1ElasticsearchRoutes } from './elasticsearch'; +export { registerV1ElasticsearchSettingsRoutes } from './elasticsearch_settings'; +export { registerV1LogstashRoutes } from './logstash'; +export { registerV1SetupRoutes } from './setup'; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.ts index b267c17fc3346..a4975726cf0a1 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/index.ts @@ -5,10 +5,21 @@ * 2.0. */ -export { logstashNodesRoute } from './nodes'; -export { logstashNodeRoute } from './node'; -export { logstashOverviewRoute } from './overview'; -export { logstashPipelineRoute } from './pipeline'; -export { logstashNodePipelinesRoute } from './pipelines/node_pipelines'; -export { logstashClusterPipelinesRoute } from './pipelines/cluster_pipelines'; -export { logstashClusterPipelineIdsRoute } from './pipelines/cluster_pipeline_ids'; +import { MonitoringCore } from '../../../../types'; +import { logstashNodeRoute } from './node'; +import { logstashNodesRoute } from './nodes'; +import { logstashOverviewRoute } from './overview'; +import { logstashPipelineRoute } from './pipeline'; +import { logstashClusterPipelinesRoute } from './pipelines/cluster_pipelines'; +import { logstashClusterPipelineIdsRoute } from './pipelines/cluster_pipeline_ids'; +import { logstashNodePipelinesRoute } from './pipelines/node_pipelines'; + +export function registerV1LogstashRoutes(server: MonitoringCore) { + logstashClusterPipelineIdsRoute(server); + logstashClusterPipelinesRoute(server); + logstashNodePipelinesRoute(server); + logstashNodeRoute(server); + logstashNodesRoute(server); + logstashOverviewRoute(server); + logstashPipelineRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js b/x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js deleted file mode 100644 index bc8b722d22214..0000000000000 --- a/x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { schema } from '@kbn/config-schema'; -import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; -import { handleError } from '../../../../lib/errors'; -import { getCollectionStatus } from '../../../../lib/setup/collection'; -import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; - -export function clusterSetupStatusRoute(server) { - /* - * Monitoring Home - * Route Init (for checking license and compatibility for multi-cluster monitoring - */ - server.route({ - method: 'POST', - path: '/api/monitoring/v1/setup/collection/cluster/{clusterUuid?}', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.maybe(schema.string()), - }), - query: schema.object({ - // This flag is not intended to be used in production. It was introduced - // as a way to ensure consistent API testing - the typical data source - // for API tests are archived data, where the cluster configuration and data - // are consistent from environment to environment. However, this endpoint - // also attempts to retrieve data from the running stack products (ES and Kibana) - // which will vary from environment to environment making it difficult - // to write tests against. Therefore, this flag exists and should only be used - // in our testing environment. - skipLiveData: schema.boolean({ defaultValue: false }), - }), - body: schema.nullable( - schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.object({ - min: schema.string({ defaultValue: '' }), - max: schema.string({ defaultValue: '' }), - }), - }) - ), - }, - }, - handler: async (req) => { - let status = null; - - // NOTE using try/catch because checkMonitoringAuth is expected to throw - // an error when current logged-in user doesn't have permission to read - // the monitoring data. `try/catch` makes it a little more explicit. - try { - await verifyMonitoringAuth(req); - const indexPatterns = getIndexPatterns(server, {}, req.payload.ccs); - status = await getCollectionStatus( - req, - indexPatterns, - req.params.clusterUuid, - null, - req.query.skipLiveData - ); - } catch (err) { - throw handleError(err, req); - } - - return status; - }, - }); -} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.ts b/x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.ts new file mode 100644 index 0000000000000..370947df46b42 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.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 { + postClusterSetupStatusRequestParamsRT, + postClusterSetupStatusRequestPayloadRT, + postClusterSetupStatusRequestQueryRT, + postClusterSetupStatusResponsePayloadRT, +} from '../../../../../common/http_api/setup'; +import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; +import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; +import { handleError } from '../../../../lib/errors'; +import { getCollectionStatus } from '../../../../lib/setup/collection'; +import { MonitoringCore } from '../../../../types'; + +export function clusterSetupStatusRoute(server: MonitoringCore) { + /* + * Monitoring Home + * Route Init (for checking license and compatibility for multi-cluster monitoring + */ + + const validateParams = createValidationFunction(postClusterSetupStatusRequestParamsRT); + const validateQuery = createValidationFunction(postClusterSetupStatusRequestQueryRT); + const validateBody = createValidationFunction(postClusterSetupStatusRequestPayloadRT); + + server.route({ + method: 'post', + path: '/api/monitoring/v1/setup/collection/cluster/{clusterUuid?}', + validate: { + params: validateParams, + query: validateQuery, + body: validateBody, + }, + handler: async (req) => { + const clusterUuid = req.params.clusterUuid; + const skipLiveData = req.query.skipLiveData; + + // NOTE using try/catch because checkMonitoringAuth is expected to throw + // an error when current logged-in user doesn't have permission to read + // the monitoring data. `try/catch` makes it a little more explicit. + try { + await verifyMonitoringAuth(req); + const indexPatterns = getIndexPatterns(server.config, {}, req.payload.ccs); + const status = await getCollectionStatus( + req, + indexPatterns, + clusterUuid, + undefined, + skipLiveData + ); + return postClusterSetupStatusResponsePayloadRT.encode(status); + } catch (err) { + throw handleError(err, req); + } + }, + }); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/disable_elasticsearch_internal_collection.js b/x-pack/plugins/monitoring/server/routes/api/v1/setup/disable_elasticsearch_internal_collection.ts similarity index 74% rename from x-pack/plugins/monitoring/server/routes/api/v1/setup/disable_elasticsearch_internal_collection.js rename to x-pack/plugins/monitoring/server/routes/api/v1/setup/disable_elasticsearch_internal_collection.ts index 9590d91c357ee..cdecf346bae9d 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/setup/disable_elasticsearch_internal_collection.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/setup/disable_elasticsearch_internal_collection.ts @@ -5,21 +5,19 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; +import { postDisableInternalCollectionRequestParamsRT } from '../../../../../common/http_api/setup'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; -import { handleError } from '../../../../lib/errors'; import { setCollectionDisabled } from '../../../../lib/elasticsearch_settings/set/collection_disabled'; +import { handleError } from '../../../../lib/errors'; +import { MonitoringCore } from '../../../../types'; -export function disableElasticsearchInternalCollectionRoute(server) { +export function disableElasticsearchInternalCollectionRoute(server: MonitoringCore) { server.route({ - method: 'POST', + method: 'post', path: '/api/monitoring/v1/setup/collection/{clusterUuid}/disable_internal_collection', - config: { - validate: { - params: schema.object({ - clusterUuid: schema.string(), - }), - }, + validate: { + params: createValidationFunction(postDisableInternalCollectionRequestParamsRT), }, handler: async (req) => { // NOTE using try/catch because checkMonitoringAuth is expected to throw diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/setup/index.ts new file mode 100644 index 0000000000000..6a8ecac8597a8 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/setup/index.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 { MonitoringCore } from '../../../../types'; +import { clusterSetupStatusRoute } from './cluster_setup_status'; +import { disableElasticsearchInternalCollectionRoute } from './disable_elasticsearch_internal_collection'; +import { nodeSetupStatusRoute } from './node_setup_status'; + +export function registerV1SetupRoutes(server: MonitoringCore) { + clusterSetupStatusRoute(server); + disableElasticsearchInternalCollectionRoute(server); + nodeSetupStatusRoute(server); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js b/x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js deleted file mode 100644 index 1f93e92843ea8..0000000000000 --- a/x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { schema } from '@kbn/config-schema'; -import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; -import { handleError } from '../../../../lib/errors'; -import { getCollectionStatus } from '../../../../lib/setup/collection'; -import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; - -export function nodeSetupStatusRoute(server) { - /* - * Monitoring Home - * Route Init (for checking license and compatibility for multi-cluster monitoring - */ - server.route({ - method: 'POST', - path: '/api/monitoring/v1/setup/collection/node/{nodeUuid}', - config: { - validate: { - params: schema.object({ - nodeUuid: schema.string(), - }), - query: schema.object({ - // This flag is not intended to be used in production. It was introduced - // as a way to ensure consistent API testing - the typical data source - // for API tests are archived data, where the cluster configuration and data - // are consistent from environment to environment. However, this endpoint - // also attempts to retrieve data from the running stack products (ES and Kibana) - // which will vary from environment to environment making it difficult - // to write tests against. Therefore, this flag exists and should only be used - // in our testing environment. - skipLiveData: schema.boolean({ defaultValue: false }), - }), - body: schema.nullable( - schema.object({ - ccs: schema.maybe(schema.string()), - timeRange: schema.maybe( - schema.object({ - min: schema.string(), - max: schema.string(), - }) - ), - }) - ), - }, - }, - handler: async (req) => { - let status = null; - - // NOTE using try/catch because checkMonitoringAuth is expected to throw - // an error when current logged-in user doesn't have permission to read - // the monitoring data. `try/catch` makes it a little more explicit. - try { - await verifyMonitoringAuth(req); - const indexPatterns = getIndexPatterns(server, {}, req.payload.ccs); - status = await getCollectionStatus( - req, - indexPatterns, - null, - req.params.nodeUuid, - req.query.skipLiveData - ); - } catch (err) { - throw handleError(err, req); - } - - return status; - }, - }); -} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.ts b/x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.ts new file mode 100644 index 0000000000000..327b741a0e64a --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.ts @@ -0,0 +1,64 @@ +/* + * 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 { + postNodeSetupStatusRequestParamsRT, + postNodeSetupStatusRequestPayloadRT, + postNodeSetupStatusRequestQueryRT, + postNodeSetupStatusResponsePayloadRT, +} from '../../../../../common/http_api/setup'; +import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; +import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; +import { handleError } from '../../../../lib/errors'; +import { getCollectionStatus } from '../../../../lib/setup/collection'; +import { MonitoringCore } from '../../../../types'; + +export function nodeSetupStatusRoute(server: MonitoringCore) { + /* + * Monitoring Home + * Route Init (for checking license and compatibility for multi-cluster monitoring + */ + + const validateParams = createValidationFunction(postNodeSetupStatusRequestParamsRT); + const validateQuery = createValidationFunction(postNodeSetupStatusRequestQueryRT); + const validateBody = createValidationFunction(postNodeSetupStatusRequestPayloadRT); + + server.route({ + method: 'post', + path: '/api/monitoring/v1/setup/collection/node/{nodeUuid}', + validate: { + params: validateParams, + query: validateQuery, + body: validateBody, + }, + handler: async (req) => { + const nodeUuid = req.params.nodeUuid; + const skipLiveData = req.query.skipLiveData; + const ccs = req.payload.ccs; + + // NOTE using try/catch because checkMonitoringAuth is expected to throw + // an error when current logged-in user doesn't have permission to read + // the monitoring data. `try/catch` makes it a little more explicit. + try { + await verifyMonitoringAuth(req); + const indexPatterns = getIndexPatterns(server.config, {}, ccs); + const status = await getCollectionStatus( + req, + indexPatterns, + undefined, + nodeUuid, + skipLiveData + ); + + return postNodeSetupStatusResponsePayloadRT.encode(status); + } catch (err) { + throw handleError(err, req); + } + }, + }); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/ui.js b/x-pack/plugins/monitoring/server/routes/api/v1/ui.js deleted file mode 100644 index 618d12afedef7..0000000000000 --- a/x-pack/plugins/monitoring/server/routes/api/v1/ui.js +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// all routes for the app -export { checkAccessRoute } from './check_access'; -export * from './alerts'; -export { beatsDetailRoute, beatsListingRoute, beatsOverviewRoute } from './beats'; -export { clusterRoute, clustersRoute } from './cluster'; -export { - esIndexRoute, - esIndicesRoute, - esNodeRoute, - esNodesRoute, - esOverviewRoute, - mlJobRoute, - ccrRoute, - ccrShardRoute, -} from './elasticsearch'; -export { - internalMonitoringCheckRoute, - clusterSettingsCheckRoute, - nodesSettingsCheckRoute, - setCollectionEnabledRoute, - setCollectionIntervalRoute, -} from './elasticsearch_settings'; -export { kibanaInstanceRoute, kibanaInstancesRoute, kibanaOverviewRoute } from './kibana'; -export { apmInstanceRoute, apmInstancesRoute, apmOverviewRoute } from './apm'; -export { - logstashClusterPipelinesRoute, - logstashNodePipelinesRoute, - logstashNodeRoute, - logstashNodesRoute, - logstashOverviewRoute, - logstashPipelineRoute, - logstashClusterPipelineIdsRoute, -} from './logstash'; -export { entSearchOverviewRoute } from './enterprise_search'; -export * from './setup'; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/ui.ts b/x-pack/plugins/monitoring/server/routes/api/v1/ui.ts new file mode 100644 index 0000000000000..7aaa6591e868e --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/ui.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. + */ + +// these are the remaining routes not yet converted to TypeScript +// all others are registered through index.ts + +// @ts-expect-error +export { kibanaInstanceRoute, kibanaInstancesRoute, kibanaOverviewRoute } from './kibana'; +// @ts-expect-error +export { entSearchOverviewRoute } from './enterprise_search'; diff --git a/x-pack/plugins/monitoring/server/routes/index.ts b/x-pack/plugins/monitoring/server/routes/index.ts index 05a8de96b4c07..f38612d5a42da 100644 --- a/x-pack/plugins/monitoring/server/routes/index.ts +++ b/x-pack/plugins/monitoring/server/routes/index.ts @@ -8,22 +8,43 @@ import { MonitoringConfig } from '../config'; import { decorateDebugServer } from '../debug_logger'; -import { RouteDependencies } from '../types'; -// @ts-ignore -import * as uiRoutes from './api/v1/ui'; // namespace import +import { MonitoringCore, RouteDependencies } from '../types'; +import { + registerV1AlertRoutes, + registerV1ApmRoutes, + registerV1BeatsRoutes, + registerV1CheckAccessRoutes, + registerV1ClusterRoutes, + registerV1ElasticsearchRoutes, + registerV1ElasticsearchSettingsRoutes, + registerV1LogstashRoutes, + registerV1SetupRoutes, +} from './api/v1'; +import * as uiRoutes from './api/v1/ui'; export function requireUIRoutes( - _server: any, + server: MonitoringCore, config: MonitoringConfig, npRoute: RouteDependencies ) { const routes = Object.keys(uiRoutes); - const server = config.ui.debug_mode - ? decorateDebugServer(_server, config, npRoute.logger) - : _server; + const decoratedServer = config.ui.debug_mode + ? decorateDebugServer(server, config, npRoute.logger) + : server; routes.forEach((route) => { + // @ts-expect-error const registerRoute = uiRoutes[route]; // computed reference to module objects imported via namespace registerRoute(server, npRoute); }); + + registerV1AlertRoutes(decoratedServer, npRoute); + registerV1ApmRoutes(server); + registerV1BeatsRoutes(server); + registerV1CheckAccessRoutes(server); + registerV1ClusterRoutes(server); + registerV1ElasticsearchRoutes(server); + registerV1ElasticsearchSettingsRoutes(server, npRoute); + registerV1LogstashRoutes(server); + registerV1SetupRoutes(server); } diff --git a/x-pack/plugins/monitoring/server/types.ts b/x-pack/plugins/monitoring/server/types.ts index 86447a24fdf04..64931f5888514 100644 --- a/x-pack/plugins/monitoring/server/types.ts +++ b/x-pack/plugins/monitoring/server/types.ts @@ -34,7 +34,7 @@ import { LicensingPluginStart } from '@kbn/licensing-plugin/server'; import { PluginSetupContract as FeaturesPluginSetupContract } from '@kbn/features-plugin/server'; import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; import { CloudSetup } from '@kbn/cloud-plugin/server'; -import { RouteConfig, RouteMethod } from '@kbn/core/server'; +import { RouteConfig, RouteMethod, Headers } from '@kbn/core/server'; import { ElasticsearchModifiedSource } from '../common/types/es'; import { RulesByType } from '../common/types/alerts'; import { configSchema, MonitoringConfig } from './config'; @@ -124,6 +124,7 @@ export interface LegacyRequest { payload: Body; params: Params; query: Query; + headers: Headers; getKibanaStatsCollector: () => any; getUiSettingsService: () => any; getActionTypeRegistry: () => any; From 6fc2fff3f2dfc263f767bbc54f46eb4946438e4c Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Fri, 20 May 2022 10:48:15 -0700 Subject: [PATCH 26/39] [ML] Minor edits in prebuilt job descriptions (#132633) --- .../modules/security_auth/ml/auth_high_count_logon_events.json | 2 +- .../ml/auth_high_count_logon_events_for_a_source_ip.json | 2 +- .../modules/security_auth/ml/auth_high_count_logon_fails.json | 2 +- .../models/data_recognizer/modules/security_linux/manifest.json | 2 +- .../security_network/ml/high_count_by_destination_country.json | 2 +- .../modules/security_network/ml/high_count_network_denies.json | 2 +- .../modules/security_network/ml/high_count_network_events.json | 2 +- .../data_recognizer/modules/security_windows/manifest.json | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events.json index 35fc14e23624f..fa87299dfb464 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Authentication - looks for an unusually large spike in successful authentication events. This can be due to password spraying, user enumeration or brute force activity.", + "description": "Security: Authentication - Looks for an unusually large spike in successful authentication events. This can be due to password spraying, user enumeration, or brute force activity.", "groups": [ "security", "authentication" diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events_for_a_source_ip.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events_for_a_source_ip.json index cdf219152c7fd..9f2f10973a35b 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events_for_a_source_ip.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events_for_a_source_ip.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Authentication - looks for an unusually large spike in successful authentication events from a particular source IP address. This can be due to password spraying, user enumeration or brute force activity.", + "description": "Security: Authentication - Looks for an unusually large spike in successful authentication events from a particular source IP address. This can be due to password spraying, user enumeration, or brute force activity.", "groups": [ "security", "authentication" diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_fails.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_fails.json index cde52bf7d33cc..c74dff5257864 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_fails.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_fails.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Authentication - looks for an unusually large spike in authentication failure events. This can be due to password spraying, user enumeration or brute force activity and may be a precursor to account takeover or credentialed access.", + "description": "Security: Authentication - Looks for an unusually large spike in authentication failure events. This can be due to password spraying, user enumeration, or brute force activity and may be a precursor to account takeover or credentialed access.", "groups": [ "security", "authentication" diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json index efed4a3c9e9b1..cfa9f45c5d1ac 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_linux/manifest.json @@ -1,7 +1,7 @@ { "id": "security_linux_v3", "title": "Security: Linux", - "description": "Anomaly detection jobs for Linux host based threat hunting and detection.", + "description": "Anomaly detection jobs for Linux host-based threat hunting and detection.", "type": "linux data", "logoFile": "logo.json", "defaultIndexPattern": "auditbeat-*,logs-*", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_by_destination_country.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_by_destination_country.json index 2360233937c2b..45375ad939f36 100755 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_by_destination_country.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_by_destination_country.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Network - looks for an unusually large spike in network activity to one destination country in the network logs. This could be due to unusually large amounts of reconnaissance or enumeration traffic. Data exfiltration activity may also produce such a surge in traffic to a destination country which does not normally appear in network traffic or business work-flows. Malware instances and persistence mechanisms may communicate with command-and-control (C2) infrastructure in their country of origin, which may be an unusual destination country for the source network.", + "description": "Security: Network - Looks for an unusually large spike in network activity to one destination country in the network logs. This could be due to unusually large amounts of reconnaissance or enumeration traffic. Data exfiltration activity may also produce such a surge in traffic to a destination country which does not normally appear in network traffic or business work-flows. Malware instances and persistence mechanisms may communicate with command-and-control (C2) infrastructure in their country of origin, which may be an unusual destination country for the source network.", "groups": [ "security", "network" diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_denies.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_denies.json index 2a3b4b0100183..45c22599f37d2 100755 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_denies.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_denies.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Network - looks for an unusually large spike in network traffic that was denied by network ACLs or firewall rules. Such a burst of denied traffic is usually either 1) a misconfigured application or firewall or 2) suspicious or malicious activity. Unsuccessful attempts at network transit, in order to connect to command-and-control (C2), or engage in data exfiltration, may produce a burst of failed connections. This could also be due to unusually large amounts of reconnaissance or enumeration traffic. Denial-of-service attacks or traffic floods may also produce such a surge in traffic.", + "description": "Security: Network - Looks for an unusually large spike in network traffic that was denied by network ACLs or firewall rules. Such a burst of denied traffic is usually either 1) a misconfigured application or firewall or 2) suspicious or malicious activity. Unsuccessful attempts at network transit, in order to connect to command-and-control (C2), or engage in data exfiltration, may produce a burst of failed connections. This could also be due to unusually large amounts of reconnaissance or enumeration traffic. Denial-of-service attacks or traffic floods may also produce such a surge in traffic.", "groups": [ "security", "network" diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_events.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_events.json index 792d7f2513985..a3bb734ad9bdc 100755 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_events.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_network/ml/high_count_network_events.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Network - looks for an unusually large spike in network traffic. Such a burst of traffic, if not caused by a surge in business activity, can be due to suspicious or malicious activity. Large-scale data exfiltration may produce a burst of network traffic; this could also be due to unusually large amounts of reconnaissance or enumeration traffic. Denial-of-service attacks or traffic floods may also produce such a surge in traffic.", + "description": "Security: Network - Looks for an unusually large spike in network traffic. Such a burst of traffic, if not caused by a surge in business activity, can be due to suspicious or malicious activity. Large-scale data exfiltration may produce a burst of network traffic; this could also be due to unusually large amounts of reconnaissance or enumeration traffic. Denial-of-service attacks or traffic floods may also produce such a surge in traffic.", "groups": [ "security", "network" diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json index bf39cd7ec7902..8d01d0d91e0c2 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_windows/manifest.json @@ -1,7 +1,7 @@ { "id": "security_windows_v3", "title": "Security: Windows", - "description": "Anomaly detection jobs for Windows host based threat hunting and detection.", + "description": "Anomaly detection jobs for Windows host-based threat hunting and detection.", "type": "windows data", "logoFile": "logo.json", "defaultIndexPattern": "winlogbeat-*,logs-*", From e857b30f8a9b4b1ccaa9527b3809875b892dec01 Mon Sep 17 00:00:00 2001 From: Vadim Kibana <82822460+vadimkibana@users.noreply.github.com> Date: Fri, 20 May 2022 20:36:59 +0200 Subject: [PATCH 27/39] remove human-readable automatic slug generation (#132593) * remove human-readable automatic slug generation * make change non-breaking * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * remove test Co-authored-by: streamich Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/share/README.mdx | 13 ------------- .../share/common/url_service/short_urls/types.ts | 6 ------ .../short_urls/short_url_client.test.ts | 2 -- .../url_service/short_urls/short_url_client.ts | 3 --- .../http/short_urls/register_create_route.ts | 12 ++++++++++-- .../short_urls/short_url_client.test.ts | 13 ------------- .../url_service/short_urls/short_url_client.ts | 4 +--- .../apis/short_url/create_short_url/main.ts | 16 ---------------- 8 files changed, 11 insertions(+), 58 deletions(-) diff --git a/src/plugins/share/README.mdx b/src/plugins/share/README.mdx index 1a1e2e721c2ab..1a1fef0587812 100644 --- a/src/plugins/share/README.mdx +++ b/src/plugins/share/README.mdx @@ -215,19 +215,6 @@ const url = await shortUrls.create({ }); ``` -You can make the short URL slug human-readable by specifying the -`humanReadableSlug` flag: - -```ts -const url = await shortUrls.create({ - locator, - params: { - dashboardId: '123', - }, - humanReadableSlug: true, -}); -``` - Or you can manually specify the slug for the short URL using the `slug` option: ```ts diff --git a/src/plugins/share/common/url_service/short_urls/types.ts b/src/plugins/share/common/url_service/short_urls/types.ts index 44e11a0610a66..a7c4d106873f9 100644 --- a/src/plugins/share/common/url_service/short_urls/types.ts +++ b/src/plugins/share/common/url_service/short_urls/types.ts @@ -79,12 +79,6 @@ export interface ShortUrlCreateParams

{ * URL. This part will be visible to the user, it can have user-friendly text. */ slug?: string; - - /** - * Whether to generate a slug automatically. If `true`, the slug will be - * a human-readable text consisting of three worlds: "--". - */ - humanReadableSlug?: boolean; } /** diff --git a/src/plugins/share/public/url_service/short_urls/short_url_client.test.ts b/src/plugins/share/public/url_service/short_urls/short_url_client.test.ts index 8a125206d1c80..693d06538e63e 100644 --- a/src/plugins/share/public/url_service/short_urls/short_url_client.test.ts +++ b/src/plugins/share/public/url_service/short_urls/short_url_client.test.ts @@ -88,7 +88,6 @@ describe('create()', () => { body: expect.any(String), }); expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toStrictEqual({ - humanReadableSlug: false, locatorId: LEGACY_SHORT_URL_LOCATOR_ID, params: { url: 'https://example.com/foo/bar', @@ -173,7 +172,6 @@ describe('createFromLongUrl()', () => { body: expect.any(String), }); expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toStrictEqual({ - humanReadableSlug: true, locatorId: LEGACY_SHORT_URL_LOCATOR_ID, params: { url: '/a/b/c', diff --git a/src/plugins/share/public/url_service/short_urls/short_url_client.ts b/src/plugins/share/public/url_service/short_urls/short_url_client.ts index 63dcdc0b78718..4a9dbf3909288 100644 --- a/src/plugins/share/public/url_service/short_urls/short_url_client.ts +++ b/src/plugins/share/public/url_service/short_urls/short_url_client.ts @@ -59,7 +59,6 @@ export class BrowserShortUrlClient implements IShortUrlClient { locator, params, slug = undefined, - humanReadableSlug = false, }: ShortUrlCreateParams

): Promise> { const { http } = this.dependencies; const data = await http.fetch>('/api/short_url', { @@ -67,7 +66,6 @@ export class BrowserShortUrlClient implements IShortUrlClient { body: JSON.stringify({ locatorId: locator.id, slug, - humanReadableSlug, params, }), }); @@ -113,7 +111,6 @@ export class BrowserShortUrlClient implements IShortUrlClient { const result = await this.createWithLocator({ locator, - humanReadableSlug: true, params: { url: relativeUrl, }, diff --git a/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts b/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts index 1208f6fda4d1e..97594837f0720 100644 --- a/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts +++ b/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts @@ -26,6 +26,15 @@ export const registerCreateRoute = (router: IRouter, url: ServerUrlService) => { minLength: 3, maxLength: 255, }), + /** + * @deprecated + * + * This field is deprecated as the API does not support automatic + * human-readable slug generation. + * + * @todo This field will be removed in a future version. It is left + * here for backwards compatibility. + */ humanReadableSlug: schema.boolean({ defaultValue: false, }), @@ -36,7 +45,7 @@ export const registerCreateRoute = (router: IRouter, url: ServerUrlService) => { router.handleLegacyErrors(async (ctx, req, res) => { const savedObjects = (await ctx.core).savedObjects.client; const shortUrls = url.shortUrls.get({ savedObjects }); - const { locatorId, params, slug, humanReadableSlug } = req.body; + const { locatorId, params, slug } = req.body; const locator = url.locators.get(locatorId); if (!locator) { @@ -51,7 +60,6 @@ export const registerCreateRoute = (router: IRouter, url: ServerUrlService) => { locator, params, slug, - humanReadableSlug, }); return res.ok({ diff --git a/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts b/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts index 5fc108cdbf56c..fe6365d498628 100644 --- a/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts +++ b/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts @@ -128,19 +128,6 @@ describe('ServerShortUrlClient', () => { }) ).rejects.toThrowError(new UrlServiceError(`Slug "lala" already exists.`, 'SLUG_EXISTS')); }); - - test('can automatically generate human-readable slug', async () => { - const { client, locator } = setup(); - const shortUrl = await client.create({ - locator, - humanReadableSlug: true, - params: { - url: '/app/test#foo/bar/baz', - }, - }); - - expect(shortUrl.data.slug.split('-').length).toBe(3); - }); }); describe('.get()', () => { diff --git a/src/plugins/share/server/url_service/short_urls/short_url_client.ts b/src/plugins/share/server/url_service/short_urls/short_url_client.ts index 762ded11bf8ee..cecc4c3127135 100644 --- a/src/plugins/share/server/url_service/short_urls/short_url_client.ts +++ b/src/plugins/share/server/url_service/short_urls/short_url_client.ts @@ -8,7 +8,6 @@ import type { SerializableRecord } from '@kbn/utility-types'; import { SavedObjectReference } from '@kbn/core/server'; -import { generateSlug } from 'random-word-slugs'; import { ShortUrlRecord } from '.'; import type { IShortUrlClient, @@ -60,14 +59,13 @@ export class ServerShortUrlClient implements IShortUrlClient { locator, params, slug = '', - humanReadableSlug = false, }: ShortUrlCreateParams

): Promise> { if (slug) { validateSlug(slug); } if (!slug) { - slug = humanReadableSlug ? generateSlug() : randomStr(4); + slug = randomStr(5); } const { storage, currentVersion } = this.dependencies; diff --git a/test/api_integration/apis/short_url/create_short_url/main.ts b/test/api_integration/apis/short_url/create_short_url/main.ts index 4eb6fa489b725..d0b57a9873135 100644 --- a/test/api_integration/apis/short_url/create_short_url/main.ts +++ b/test/api_integration/apis/short_url/create_short_url/main.ts @@ -70,22 +70,6 @@ export default function ({ getService }: FtrProviderContext) { expect(response.body.url).to.be(''); }); - it('can generate a human-readable slug, composed of three words', async () => { - const response = await supertest.post('/api/short_url').send({ - locatorId: 'LEGACY_SHORT_URL_LOCATOR', - params: {}, - humanReadableSlug: true, - }); - - expect(response.status).to.be(200); - expect(typeof response.body.slug).to.be('string'); - const words = response.body.slug.split('-'); - expect(words.length).to.be(3); - for (const word of words) { - expect(word.length > 0).to.be(true); - } - }); - it('can create a short URL with custom slug', async () => { const rnd = Math.round(Math.random() * 1e6) + 1; const slug = 'test-slug-' + Date.now() + '-' + rnd; From 46cd72911c5e96b8dc8df6e12d47df004efba381 Mon Sep 17 00:00:00 2001 From: Jan Monschke Date: Fri, 20 May 2022 22:02:00 +0200 Subject: [PATCH 28/39] [SecuritySolution] Disable agent status filters and timeline interaction (#132586) * fix: disable drag-ability and hover actions for agent statuses The agent fields cannot be queried with ECS and therefore should not provide Filter In/Out functionality nor should users be able to add their representative fields to timeline investigations. Therefore users should not be able to add them to a timeline query by dragging them. * chore: make code more readable --- .../table/summary_value_cell.tsx | 46 +++++++++++-------- .../body/renderers/agent_statuses.tsx | 38 +++------------ 2 files changed, 32 insertions(+), 52 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/summary_value_cell.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/summary_value_cell.tsx index 1c9c0292ed912..d4677d22485b4 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/summary_value_cell.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/summary_value_cell.tsx @@ -16,6 +16,8 @@ import { AGENT_STATUS_FIELD_NAME } from '../../../../timelines/components/timeli const FIELDS_WITHOUT_ACTIONS: { [field: string]: boolean } = { [AGENT_STATUS_FIELD_NAME]: true }; +const style = { flexGrow: 0 }; + export const SummaryValueCell: React.FC = ({ data, eventId, @@ -25,32 +27,36 @@ export const SummaryValueCell: React.FC = ({ timelineId, values, isReadOnly, -}) => ( - <> - - {timelineId !== TimelineId.active && !isReadOnly && !FIELDS_WITHOUT_ACTIONS[data.field] && ( - { + const hoverActionsEnabled = !FIELDS_WITHOUT_ACTIONS[data.field]; + + return ( + <> + - )} - -); + {timelineId !== TimelineId.active && !isReadOnly && hoverActionsEnabled && ( + + )} + + ); +}; SummaryValueCell.displayName = 'SummaryValueCell'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx index edc8faff1b5fc..c459a9f05a678 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx @@ -7,7 +7,6 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import { DefaultDraggable } from '../../../../../common/components/draggables'; import { EndpointHostIsolationStatus } from '../../../../../common/components/endpoint/host_isolation'; import { useHostIsolationStatus } from '../../../../../detections/containers/detection_engine/alerts/use_host_isolation_status'; import { AgentStatus } from '../../../../../common/components/endpoint/agent_status'; @@ -33,26 +32,11 @@ export const AgentStatuses = React.memo( }) => { const { isIsolated, agentStatus, pendingIsolation, pendingUnisolation } = useHostIsolationStatus({ agentId: value }); - const isolationFieldName = 'host.isolation'; return ( {agentStatus !== undefined ? ( - {isDraggable ? ( - - - - ) : ( - - )} + ) : ( @@ -60,21 +44,11 @@ export const AgentStatuses = React.memo( )} - - - + ); From e55bf409976a22c429d3f48f4a3c198e2591cbe3 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 20 May 2022 14:15:00 -0600 Subject: [PATCH 29/39] [Maps] create MVT_VECTOR when using choropleth wizard (#132648) --- .../create_choropleth_layer_descriptor.ts | 61 +++++++++++-------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/create_choropleth_layer_descriptor.ts b/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/create_choropleth_layer_descriptor.ts index 92045f5911176..36e07d7383d18 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/create_choropleth_layer_descriptor.ts +++ b/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/create_choropleth_layer_descriptor.ts @@ -10,6 +10,7 @@ import { AGG_TYPE, COLOR_MAP_TYPE, FIELD_ORIGIN, + LAYER_TYPE, SCALING_TYPES, SOURCE_TYPES, STYLE_TYPE, @@ -21,10 +22,11 @@ import { CountAggDescriptor, EMSFileSourceDescriptor, ESSearchSourceDescriptor, + JoinDescriptor, VectorStylePropertiesDescriptor, } from '../../../../../common/descriptor_types'; import { VectorStyle } from '../../../styles/vector/vector_style'; -import { GeoJsonVectorLayer } from '../../vector_layer'; +import { GeoJsonVectorLayer, MvtVectorLayer } from '../../vector_layer'; import { EMSFileSource } from '../../../sources/ems_file_source'; // @ts-ignore import { ESSearchSource } from '../../../sources/es_search_source'; @@ -38,14 +40,14 @@ function createChoroplethLayerDescriptor({ rightIndexPatternId, rightIndexPatternTitle, rightTermField, - setLabelStyle, + layerType, }: { sourceDescriptor: EMSFileSourceDescriptor | ESSearchSourceDescriptor; leftField: string; rightIndexPatternId: string; rightIndexPatternTitle: string; rightTermField: string; - setLabelStyle: boolean; + layerType: LAYER_TYPE.GEOJSON_VECTOR | LAYER_TYPE.MVT_VECTOR; }) { const metricsDescriptor: CountAggDescriptor = { type: AGG_TYPE.COUNT }; const joinId = uuid(); @@ -75,7 +77,8 @@ function createChoroplethLayerDescriptor({ }, }, }; - if (setLabelStyle) { + // Styling label by join metric with MVT is not supported + if (layerType === LAYER_TYPE.GEOJSON_VECTOR) { styleProperties[VECTOR_STYLES.LABEL_TEXT] = { type: STYLE_TYPE.DYNAMIC, options: { @@ -88,26 +91,34 @@ function createChoroplethLayerDescriptor({ }; } - return GeoJsonVectorLayer.createDescriptor({ - joins: [ - { - leftField, - right: { - type: SOURCE_TYPES.ES_TERM_SOURCE, - id: joinId, - indexPatternId: rightIndexPatternId, - indexPatternTitle: rightIndexPatternTitle, - term: rightTermField, - metrics: [metricsDescriptor], - applyGlobalQuery: true, - applyGlobalTime: true, - applyForceRefresh: true, - }, + const joins = [ + { + leftField, + right: { + type: SOURCE_TYPES.ES_TERM_SOURCE, + id: joinId, + indexPatternId: rightIndexPatternId, + indexPatternTitle: rightIndexPatternTitle, + term: rightTermField, + metrics: [metricsDescriptor], + applyGlobalQuery: true, + applyGlobalTime: true, + applyForceRefresh: true, }, - ], - sourceDescriptor, - style: VectorStyle.createDescriptor(styleProperties), - }); + } as JoinDescriptor, + ]; + + return layerType === LAYER_TYPE.MVT_VECTOR + ? MvtVectorLayer.createDescriptor({ + joins, + sourceDescriptor, + style: VectorStyle.createDescriptor(styleProperties), + }) + : GeoJsonVectorLayer.createDescriptor({ + joins, + sourceDescriptor, + style: VectorStyle.createDescriptor(styleProperties), + }); } export function createEmsChoroplethLayerDescriptor({ @@ -132,7 +143,7 @@ export function createEmsChoroplethLayerDescriptor({ rightIndexPatternId, rightIndexPatternTitle, rightTermField, - setLabelStyle: true, + layerType: LAYER_TYPE.GEOJSON_VECTOR, }); } @@ -165,6 +176,6 @@ export function createEsChoroplethLayerDescriptor({ rightIndexPatternId, rightIndexPatternTitle, rightTermField, - setLabelStyle: false, // Styling label by join metric with MVT is not supported + layerType: LAYER_TYPE.MVT_VECTOR, }); } From 791ebfad8c589b91555a7e252d99ea1841f75906 Mon Sep 17 00:00:00 2001 From: debadair Date: Fri, 20 May 2022 13:34:04 -0700 Subject: [PATCH 30/39] [DOCS] Remove obsolete license expiration info (#131474) * [DOCS] Remove obsolete license expiration info As of https://github.com/elastic/elasticsearch/pull/79671, Elasticsearch does a more stringent license check rather than operating in a semi-degraded mode. Closes #127845 Closes #125702 * Update docs/management/managing-licenses.asciidoc Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/management/managing-licenses.asciidoc | 192 +++------------------ 1 file changed, 22 insertions(+), 170 deletions(-) diff --git a/docs/management/managing-licenses.asciidoc b/docs/management/managing-licenses.asciidoc index cf501518ea534..837a83f0aae38 100644 --- a/docs/management/managing-licenses.asciidoc +++ b/docs/management/managing-licenses.asciidoc @@ -1,191 +1,43 @@ [[managing-licenses]] == License Management -When you install the default distribution of {kib}, you receive free features -with no expiration date. For the full list of features, refer to -{subscriptions}. +By default, new installations have a Basic license that never expires. +For the full list of features available at the Free and Open Basic subscription level, +refer to {subscriptions}. -If you want to try out the full set of features, you can activate a free 30-day -trial. To view the status of your license, start a trial, or install a new -license, open the main menu, then click *Stack Management > License Management*. - -NOTE: You can start a trial only if your cluster has not already activated a -trial license for the current major product version. For example, if you have -already activated a trial for 6.0, you cannot start a new trial until -7.0. You can, however, request an extended trial at {extendtrial}. - -When you activate a new license level, new features appear in *Stack Management*. - -[role="screenshot"] -image::images/management-license.png[] +To explore all of the available solutions and features, start a 30-day free trial. +You can activate a trial subscription once per major product version. +If you need more than 30 days to complete your evaluation, +request an extended trial at {extendtrial}. -At the end of the trial period, some features operate in a -<>. You can revert to Basic, extend the trial, -or purchase a subscription. - -TIP: If {security-features} are enabled, unless you have a trial license, -you must configure Transport Layer Security (TLS) in {es}. -See {ref}/encrypting-communications.html[Encrypting communications]. -{kib} and the {ref}/start-basic.html[start basic API] provide a list of all of -the features that will no longer be supported if you revert to a basic license. +To view the status of your license, start a trial, or install a new +license, open the main menu, then click *Stack Management > License Management*. -[float] +[discrete] === Required permissions The `manage` cluster privilege is required to access *License Management*. To add the privilege, open the main menu, then click *Stack Management > Roles*. -[discrete] -[[update-license]] -=== Update your license - -You can update your license at runtime without shutting down your {es} nodes. -License updates take effect immediately. The license is provided as a _JSON_ -file that you install in {kib} or by using the -{ref}/update-license.html[update license API]. - -TIP: If you are using a basic or trial license, {security-features} are disabled -by default. In all other licenses, {security-features} are enabled by default; -you must secure the {stack} or disable the {security-features}. - [discrete] [[license-expiration]] === License expiration -Your license is time based and expires at a future date. If you're using -{monitor-features} and your license will expire within 30 days, a license -expiration warning is displayed prominently. Warnings are also displayed on -startup and written to the {es} log starting 30 days from the expiration date. -These error messages tell you when the license expires and what features will be -disabled if you do not update the license. - -IMPORTANT: You should update your license as soon as possible. You are -essentially flying blind when running with an expired license. Access to the -cluster health and stats APIs is critical for monitoring and managing an {es} -cluster. - -[discrete] -[[expiration-beats]] -==== Beats - -* Beats will continue to poll centrally-managed configuration. - -[discrete] -[[expiration-elasticsearch]] -==== {es} - -// Upgrade API is disabled -* The deprecation API is disabled. -* SQL support is disabled. -* Aggregations provided by the analytics plugin are no longer usable. -* All searchable snapshots indices are unassigned and cannot be searched. - -[discrete] -[[expiration-watcher]] -==== {stack} {alert-features} - -* The PUT and GET watch APIs are disabled. The DELETE watch API continues to work. -* Watches execute and write to the history. -* The actions of the watches do not execute. - -[discrete] -[[expiration-graph]] -==== {stack} {graph-features} - -* Graph explore APIs are disabled. - -[discrete] -[[expiration-ml]] -==== {stack} {ml-features} +Licenses are valid for a specific time period. +30 days before the license expiration date, {es} starts logging expiration warnings. +If monitoring is enabled, expiration warnings are displayed prominently in {kib}. -* APIs to create {anomaly-jobs}, open jobs, send data to jobs, create {dfeeds}, -and start {dfeeds} are disabled. -* All started {dfeeds} are stopped. -* All open {anomaly-jobs} are closed. -* APIs to create and start {dfanalytics-jobs} are disabled. -* Existing {anomaly-job} and {dfanalytics-job} results continue to be available -by using {kib} or APIs. +If your license expires, your subscription level reverts to Basic and +you will no longer be able to use https://www.elastic.co/subscriptions[Platinum or Enterprise features]. [discrete] -[[expiration-monitoring]] -==== {stack} {monitor-features} - -* The agent stops collecting cluster and indices metrics. -* The agent stops automatically cleaning indices older than -`xpack.monitoring.history.duration`. - -[discrete] -[[expiration-security]] -==== {stack} {security-features} - -* Cluster health, cluster stats, and indices stats operations are blocked. -* All data operations (read and write) continue to work. - -Once the license expires, calls to the cluster health, cluster stats, and index -stats APIs fail with a `security_exception` and return a 403 HTTP status code. - -[source,sh] ------------------------------------------------------ -{ - "error": { - "root_cause": [ - { - "type": "security_exception", - "reason": "current license is non-compliant for [security]", - "license.expired.feature": "security" - } - ], - "type": "security_exception", - "reason": "current license is non-compliant for [security]", - "license.expired.feature": "security" - }, - "status": 403 -} ------------------------------------------------------ - -This message enables automatic monitoring systems to easily detect the license -failure without immediately impacting other users. - -[discrete] -[[expiration-logstash]] -==== {ls} pipeline management - -* Cannot create new pipelines or edit or delete existing pipelines from the UI. -* Cannot list or view existing pipelines from the UI. -* Cannot run Logstash instances which are registered to listen to existing pipelines. -//TBD: * Logstash will continue to poll centrally-managed pipelines - -[discrete] -[[expiration-kibana]] -==== {kib} - -* Users can still log into {kib}. -* {kib} works for data exploration and visualization, but some features -are disabled. -* The license management UI is available to easily upgrade your license. See -<> and <>. - -[discrete] -[[expiration-reporting]] -==== {kib} {report-features} - -* Reporting is no longer available in {kib}. -* Report generation URLs stop working. -* Existing reports are no longer accessible. - -[discrete] -[[expiration-rollups]] -==== {rollups-cap} - -* {rollup-jobs-cap} cannot be created or started. -* Existing {rollup-jobs} can be stopped and deleted. -* The get rollup caps and rollup search APIs continue to function. +[[update-license]] +=== Update your license -[discrete] -[[expiration-transforms]] -==== {transforms-cap} +Licenses are provided as a _JSON_ file and have an effective date and an expiration date. +You cannot install a new license before its effective date. +License updates take effect immediately and do not require restarting {es}. -* {transforms-cap} cannot be created, previewed, started, or updated. -* Existing {transforms} can be stopped and deleted. -* Existing {transform} results continue to be available. +You can update your license from *Stack Management > License Management* or through the +{ref}/update-license.html[update license API]. From 41635e288f790a8e79e8294f65d43212fa479366 Mon Sep 17 00:00:00 2001 From: Karl Godard Date: Fri, 20 May 2022 13:35:30 -0700 Subject: [PATCH 31/39] fixed search highlighting. was only showing highlighted text w/o context (#132650) Co-authored-by: mitodrummer --- .../public/components/process_tree_node/index.test.tsx | 10 ++++++++-- .../public/components/process_tree_node/index.tsx | 2 +- .../public/components/process_tree_node/styles.ts | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx index 1316313427c5e..cff05c5c1003b 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx @@ -295,13 +295,19 @@ describe('ProcessTreeNode component', () => { describe('Search', () => { it('highlights text within the process node line item if it matches the searchQuery', () => { // set a mock search matched indicator for the process (typically done by ProcessTree/helpers.ts) - processMock.searchMatched = '/vagrant'; + processMock.searchMatched = '/vagr'; renderResult = mockedContext.render(); expect( renderResult.getByTestId('sessionView:processNodeSearchHighlight').textContent - ).toEqual('/vagrant'); + ).toEqual('/vagr'); + + // ensures we are showing the rest of the info, and not replacing it with just the match. + const { process } = props.process.getDetails(); + expect(renderResult.container.textContent).toContain( + process?.working_directory + '\xA0' + (process?.args && process.args.join(' ')) + ); }); }); }); diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx index 4d6074497af5a..f65cb0f25530a 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx @@ -146,7 +146,7 @@ export function ProcessTreeNode({ }); // eslint-disable-next-line no-unsanitized/property - textRef.current.innerHTML = html; + textRef.current.innerHTML = '' + html + ''; } } }, [searchMatched, styles.searchHighlight]); diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts b/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts index b68df480064b3..54dbdb1bc4565 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts +++ b/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts @@ -117,7 +117,6 @@ export const useStyles = ({ fontSize: FONT_SIZE, lineHeight: LINE_HEIGHT, verticalAlign: 'middle', - display: 'inline-block', }, }; @@ -165,6 +164,7 @@ export const useStyles = ({ paddingLeft: size.xxl, position: 'relative', lineHeight: LINE_HEIGHT, + marginTop: '1px', }; const alertDetails: CSSObject = { From e0ea600d54ec68159fc6fa89eb761b36988cc1a6 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Fri, 20 May 2022 14:55:31 -0600 Subject: [PATCH 32/39] Add group 6 to FTR config (#132655) --- .buildkite/ftr_configs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index e070baa844ea9..4a59641e29af2 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -68,6 +68,7 @@ enabled: - test/functional/apps/dashboard/group3/config.ts - test/functional/apps/dashboard/group4/config.ts - test/functional/apps/dashboard/group5/config.ts + - test/functional/apps/dashboard/group6/config.ts - test/functional/apps/discover/config.ts - test/functional/apps/getting_started/config.ts - test/functional/apps/home/config.ts From eb6a061a930a0c48fa4a28b66197b7681d3fd5cf Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Fri, 20 May 2022 16:57:49 -0400 Subject: [PATCH 33/39] [docs] Add 'yarn dev-docs' for managing and starting dev docs (#132647) --- package.json | 1 + scripts/dev_docs.sh | 103 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100755 scripts/dev_docs.sh diff --git a/package.json b/package.json index 9b01ec9decdcb..36a1cd9a5ffad 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "cover:report": "nyc report --temp-dir target/kibana-coverage/functional --report-dir target/coverage/report --reporter=lcov && open ./target/coverage/report/lcov-report/index.html", "debug": "node --nolazy --inspect scripts/kibana --dev", "debug-break": "node --nolazy --inspect-brk scripts/kibana --dev", + "dev-docs": "scripts/dev_docs.sh", "docs:acceptApiChanges": "node --max-old-space-size=6144 scripts/check_published_api_changes.js --accept", "es": "node scripts/es", "preinstall": "node ./preinstall_check", diff --git a/scripts/dev_docs.sh b/scripts/dev_docs.sh new file mode 100755 index 0000000000000..55d8f4d51e8dc --- /dev/null +++ b/scripts/dev_docs.sh @@ -0,0 +1,103 @@ +#!/bin/bash +set -euo pipefail + +KIBANA_DIR=$(cd "$(dirname "$0")"/.. && pwd) +WORKSPACE=$(cd "$KIBANA_DIR/.." && pwd)/kibana-docs +export NVM_DIR="$WORKSPACE/.nvm" + +DOCS_DIR="$WORKSPACE/docs.elastic.dev" + +# These are the other repos with docs currently required to build the docs in this repo and not get errors +# For example, kibana docs link to docs in these repos, and if they aren't built, you'll get errors +DEV_DIR="$WORKSPACE/dev" +TEAM_DIR="$WORKSPACE/kibana-team" + +cd "$KIBANA_DIR" +origin=$(git remote get-url origin || true) +GIT_PREFIX="git@github.com:" +if [[ "$origin" == "https"* ]]; then + GIT_PREFIX="https://github.com/" +fi + +mkdir -p "$WORKSPACE" +cd "$WORKSPACE" + +if [[ ! -d "$NVM_DIR" ]]; then + echo "Installing a separate copy of nvm" + git clone https://github.com/nvm-sh/nvm.git "$NVM_DIR" + cd "$NVM_DIR" + git checkout "$(git describe --abbrev=0 --tags --match "v[0-9]*" "$(git rev-list --tags --max-count=1)")" + cd "$WORKSPACE" +fi +source "$NVM_DIR/nvm.sh" + +if [[ ! -d "$DOCS_DIR" ]]; then + echo "Cloning docs.elastic.dev repo..." + git clone --depth 1 "${GIT_PREFIX}elastic/docs.elastic.dev.git" +else + cd "$DOCS_DIR" + git pull + cd "$WORKSPACE" +fi + +if [[ ! -d "$DEV_DIR" ]]; then + echo "Cloning dev repo..." + git clone --depth 1 "${GIT_PREFIX}elastic/dev.git" +else + cd "$DEV_DIR" + git pull + cd "$WORKSPACE" +fi + +if [[ ! -d "$TEAM_DIR" ]]; then + echo "Cloning kibana-team repo..." + git clone --depth 1 "${GIT_PREFIX}elastic/kibana-team.git" +else + cd "$TEAM_DIR" + git pull + cd "$WORKSPACE" +fi + +# The minimum sources required to build kibana docs +cat << EOF > "$DOCS_DIR/sources-dev.json" +{ + "sources": [ + { + "type": "file", + "location": "$KIBANA_DIR" + }, + { + "type": "file", + "location": "$DEV_DIR" + }, + { + "type": "file", + "location": "$TEAM_DIR" + } + ] +} +EOF + +cd "$DOCS_DIR" +nvm install + +if ! which yarn; then + npm install -g yarn +fi + +yarn + +if [[ ! -d .docsmobile ]]; then + yarn init-docs +fi + +echo "" +echo "The docs.elastic.dev project is located at:" +echo "$DOCS_DIR" +echo "" + +if [[ "${1:-}" ]]; then + yarn "$@" +else + yarn dev +fi From 642290b0f11a6647aef0170648b1a24da712ba56 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 20 May 2022 15:11:15 -0600 Subject: [PATCH 34/39] [maps] convert ESPewPewSource to typescript (#132656) * [maps] convert ESPewPewSource to typescript * move @ts-expect-error moved by fix --- .../security/create_layer_descriptors.ts | 2 - ...ew_pew_source.js => es_pew_pew_source.tsx} | 102 ++++++++++++------ .../es_pew_pew_source/{index.js => index.ts} | 0 .../point_2_point_layer_wizard.tsx | 9 +- 4 files changed, 73 insertions(+), 40 deletions(-) rename x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/{es_pew_pew_source.js => es_pew_pew_source.tsx} (67%) rename x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/{index.js => index.ts} (100%) diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.ts b/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.ts index 5792d861f6f5c..f295464126c96 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.ts +++ b/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/create_layer_descriptors.ts @@ -24,9 +24,7 @@ import { } from '../../../../../../common/constants'; import { GeoJsonVectorLayer } from '../../../vector_layer'; import { VectorStyle } from '../../../../styles/vector/vector_style'; -// @ts-ignore import { ESSearchSource } from '../../../../sources/es_search_source'; -// @ts-ignore import { ESPewPewSource } from '../../../../sources/es_pew_pew_source'; import { getDefaultDynamicProperties } from '../../../../styles/vector/vector_style_defaults'; import { APM_INDEX_PATTERN_TITLE } from '../observability'; diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.tsx similarity index 67% rename from x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js rename to x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.tsx index a38c769205304..910181d6a2868 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.tsx @@ -8,17 +8,35 @@ import React from 'react'; import turfBbox from '@turf/bbox'; import { multiPoint } from '@turf/helpers'; +import { Adapters } from '@kbn/inspector-plugin/common/adapters'; +import { type Filter, buildExistsFilter } from '@kbn/es-query'; +import { lastValueFrom } from 'rxjs'; +import type { + AggregationsGeoBoundsAggregate, + LatLonGeoLocation, + TopLeftBottomRightGeoBounds, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { UpdateSourceEditor } from './update_source_editor'; import { i18n } from '@kbn/i18n'; +// @ts-expect-error +import { UpdateSourceEditor } from './update_source_editor'; import { SOURCE_TYPES, VECTOR_SHAPE_TYPE } from '../../../../common/constants'; import { getDataSourceLabel, getDataViewLabel } from '../../../../common/i18n_getters'; +// @ts-expect-error import { convertToLines } from './convert_to_lines'; import { AbstractESAggSource } from '../es_agg_source'; import { registerSource } from '../source_registry'; import { turfBboxToBounds } from '../../../../common/elasticsearch_util'; import { DataRequestAbortError } from '../../util/data_request'; import { makePublicExecutionContext } from '../../../util'; +import { SourceEditorArgs } from '../source'; +import { + ESPewPewSourceDescriptor, + MapExtent, + VectorSourceRequestMeta, +} from '../../../../common/descriptor_types'; +import { isValidStringConfig } from '../../util/valid_string_config'; +import { BoundsRequestMeta, GeoJsonWithMeta } from '../vector_source'; const MAX_GEOTILE_LEVEL = 29; @@ -27,20 +45,30 @@ export const sourceTitle = i18n.translate('xpack.maps.source.pewPewTitle', { }); export class ESPewPewSource extends AbstractESAggSource { - static type = SOURCE_TYPES.ES_PEW_PEW; + readonly _descriptor: ESPewPewSourceDescriptor; - static createDescriptor(descriptor) { + static createDescriptor(descriptor: Partial): ESPewPewSourceDescriptor { const normalizedDescriptor = AbstractESAggSource.createDescriptor(descriptor); + if (!isValidStringConfig(descriptor.sourceGeoField)) { + throw new Error('Cannot create ESPewPewSourceDescriptor, sourceGeoField is not provided'); + } + if (!isValidStringConfig(descriptor.destGeoField)) { + throw new Error('Cannot create ESPewPewSourceDescriptor, destGeoField is not provided'); + } return { ...normalizedDescriptor, - type: ESPewPewSource.type, - indexPatternId: descriptor.indexPatternId, - sourceGeoField: descriptor.sourceGeoField, - destGeoField: descriptor.destGeoField, + type: SOURCE_TYPES.ES_PEW_PEW, + sourceGeoField: descriptor.sourceGeoField!, + destGeoField: descriptor.destGeoField!, }; } - renderSourceSettingsEditor({ onChange }) { + constructor(descriptor: ESPewPewSourceDescriptor) { + super(descriptor); + this._descriptor = descriptor; + } + + renderSourceSettingsEditor({ onChange }: SourceEditorArgs) { return ( void) => void, + isRequestStillActive: () => boolean, + inspectorAdapters: Adapters + ): Promise { const indexPattern = await this.getIndexPattern(); const searchSource = await this.makeSearchSource(searchFilters, 0); searchSource.setField('trackTotalHits', false); @@ -151,14 +179,10 @@ export class ESPewPewSource extends AbstractESAggSource { // Some underlying indices may not contain geo fields // Filter out documents without geo fields to avoid shard failures for those indices searchSource.setField('filter', [ - ...searchSource.getField('filter'), + ...(searchSource.getField('filter') as Filter[]), // destGeoField exists ensured by buffer filter // so only need additional check for sourceGeoField - { - exists: { - field: this._descriptor.sourceGeoField, - }, - }, + buildExistsFilter({ name: this._descriptor.sourceGeoField, type: 'geo_point' }, indexPattern), ]); const esResponse = await this._runEsQuery({ @@ -188,7 +212,10 @@ export class ESPewPewSource extends AbstractESAggSource { return this._descriptor.destGeoField; } - async getBoundsForFilters(boundsFilters, registerCancelCallback) { + async getBoundsForFilters( + boundsFilters: BoundsRequestMeta, + registerCancelCallback: (callback: () => void) => void + ): Promise { const searchSource = await this.makeSearchSource(boundsFilters, 0); searchSource.setField('trackTotalHits', false); searchSource.setField('aggs', { @@ -208,31 +235,36 @@ export class ESPewPewSource extends AbstractESAggSource { try { const abortController = new AbortController(); registerCancelCallback(() => abortController.abort()); - const { rawResponse: esResp } = await searchSource - .fetch$({ + const { rawResponse: esResp } = await lastValueFrom( + searchSource.fetch$({ abortSignal: abortController.signal, legacyHitsTotal: false, executionContext: makePublicExecutionContext('es_pew_pew_source:bounds'), }) - .toPromise(); - if (esResp.aggregations.destFitToBounds.bounds) { + ); + const destBounds = (esResp.aggregations?.destFitToBounds as AggregationsGeoBoundsAggregate) + .bounds as TopLeftBottomRightGeoBounds; + if (destBounds) { corners.push([ - esResp.aggregations.destFitToBounds.bounds.top_left.lon, - esResp.aggregations.destFitToBounds.bounds.top_left.lat, + (destBounds.top_left as LatLonGeoLocation).lon, + (destBounds.top_left as LatLonGeoLocation).lat, ]); corners.push([ - esResp.aggregations.destFitToBounds.bounds.bottom_right.lon, - esResp.aggregations.destFitToBounds.bounds.bottom_right.lat, + (destBounds.bottom_right as LatLonGeoLocation).lon, + (destBounds.bottom_right as LatLonGeoLocation).lat, ]); } - if (esResp.aggregations.sourceFitToBounds.bounds) { + const sourceBounds = ( + esResp.aggregations?.sourceFitToBounds as AggregationsGeoBoundsAggregate + ).bounds as TopLeftBottomRightGeoBounds; + if (sourceBounds) { corners.push([ - esResp.aggregations.sourceFitToBounds.bounds.top_left.lon, - esResp.aggregations.sourceFitToBounds.bounds.top_left.lat, + (sourceBounds.top_left as LatLonGeoLocation).lon, + (sourceBounds.top_left as LatLonGeoLocation).lat, ]); corners.push([ - esResp.aggregations.sourceFitToBounds.bounds.bottom_right.lon, - esResp.aggregations.sourceFitToBounds.bounds.bottom_right.lat, + (sourceBounds.bottom_right as LatLonGeoLocation).lon, + (sourceBounds.bottom_right as LatLonGeoLocation).lat, ]); } } catch (error) { diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/index.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/index.ts similarity index 100% rename from x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/index.js rename to x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/index.ts diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx index 37ecbfdebab11..aa128e3c7d8ff 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { getDefaultDynamicProperties } from '../../styles/vector/vector_style_defaults'; import { GeoJsonVectorLayer } from '../../layers/vector_layer'; -// @ts-ignore import { ESPewPewSource, sourceTitle } from './es_pew_pew_source'; import { VectorStyle } from '../../styles/vector/vector_style'; import { @@ -24,7 +23,11 @@ import { NUMERICAL_COLOR_PALETTES } from '../../styles/color_palettes'; // @ts-ignore import { CreateSourceEditor } from './create_source_editor'; import { LayerWizard, RenderWizardArguments } from '../../layers'; -import { ColorDynamicOptions, SizeDynamicOptions } from '../../../../common/descriptor_types'; +import { + ColorDynamicOptions, + ESPewPewSourceDescriptor, + SizeDynamicOptions, +} from '../../../../common/descriptor_types'; import { Point2PointLayerIcon } from '../../layers/wizards/icons/point_2_point_layer_icon'; export const point2PointLayerWizardConfig: LayerWizard = { @@ -36,7 +39,7 @@ export const point2PointLayerWizardConfig: LayerWizard = { }), icon: Point2PointLayerIcon, renderWizard: ({ previewLayers }: RenderWizardArguments) => { - const onSourceConfigChange = (sourceConfig: unknown) => { + const onSourceConfigChange = (sourceConfig: Partial) => { if (!sourceConfig) { previewLayers([]); return; From 51ae0208dc381c82d8ba224f155b7ced2ba73d1b Mon Sep 17 00:00:00 2001 From: Constance Date: Fri, 20 May 2022 14:30:36 -0700 Subject: [PATCH 35/39] Upgrade EUI to v55.1.3 (#132451) * Upgrade EUI to 55.1.3 backport * [Deprecation] Remove `watchedItemProps` from EuiContextMenu usage - should no longer be necessary * Update snapshots with new data-popover attr * Fix failing FTR test - Now that EuiContextMenu focus is restored correctly, there is a tooltip around the popover toggle that's blocking an above item that the test wants to click - swapping the order so that the tooltip does not block the clicked item should work * Fix 2nd maps FTR test with blocking tooltip Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 2 +- src/dev/license_checker/config.ts | 2 +- .../public/components/field_picker/field_search.tsx | 1 - .../saved_objects/public/finder/saved_object_finder.tsx | 6 +----- .../footer/settings/__snapshots__/settings.test.tsx.snap | 6 +++++- .../markdown_editor/plugins/lens/saved_objects_finder.tsx | 6 +----- .../lens/public/indexpattern_datasource/datapanel.tsx | 1 - .../application/components/anomalies_table/links_menu.tsx | 6 +----- .../edit_role/spaces_popover_list/spaces_popover_list.tsx | 1 - .../spaces/public/nav_control/components/spaces_menu.tsx | 1 - .../test/functional/apps/maps/group1/layer_visibility.js | 2 ++ x-pack/test/functional/apps/maps/group1/sample_data.js | 2 +- yarn.lock | 8 ++++---- 13 files changed, 17 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 36a1cd9a5ffad..e5fffb5b3a394 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "@elastic/datemath": "5.0.3", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@8.2.0-canary.2", "@elastic/ems-client": "8.3.2", - "@elastic/eui": "55.1.2", + "@elastic/eui": "55.1.3", "@elastic/filesaver": "1.1.2", "@elastic/node-crypto": "1.2.1", "@elastic/numeral": "^2.5.1", diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index f10fb0231352d..66e2664b2e8b4 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -77,6 +77,6 @@ export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint '@elastic/ems-client@8.3.2': ['Elastic License 2.0'], - '@elastic/eui@55.1.2': ['SSPL-1.0 OR Elastic License 2.0'], + '@elastic/eui@55.1.3': ['SSPL-1.0 OR Elastic License 2.0'], 'language-subtag-registry@0.3.21': ['CC-BY-4.0'], // retired ODC‑By license https://github.com/mattcg/language-subtag-registry }; diff --git a/src/plugins/presentation_util/public/components/field_picker/field_search.tsx b/src/plugins/presentation_util/public/components/field_picker/field_search.tsx index 3f3dcfdef5c8b..d3307f71988f1 100644 --- a/src/plugins/presentation_util/public/components/field_picker/field_search.tsx +++ b/src/plugins/presentation_util/public/components/field_picker/field_search.tsx @@ -103,7 +103,6 @@ export function FieldSearch({ })} ( } > - + {this.props.showFilter && ( ( can navigate Autoplay Settings 1`] = ` aria-live="off" aria-modal="true" class="euiPanel euiPanel--borderRadiusMedium euiPanel--plain euiPanel--noShadow euiPopover__panel euiPopover__panel--top" + data-popover-panel="true" role="dialog" style="top: -16px; left: -22px; will-change: transform, opacity; z-index: 2000;" tabindex="0" @@ -108,6 +109,7 @@ exports[` can navigate Autoplay Settings 2`] = ` aria-live="off" aria-modal="true" class="euiPanel euiPanel--borderRadiusMedium euiPanel--plain euiPanel--noShadow euiPopover__panel euiPopover__panel--top euiPopover__panel-isOpen" + data-popover-panel="true" role="dialog" style="top: -16px; left: -22px; z-index: 2000;" tabindex="0" @@ -359,6 +361,7 @@ exports[` can navigate Toolbar Settings, closes when activated 1`] = aria-live="off" aria-modal="true" class="euiPanel euiPanel--borderRadiusMedium euiPanel--plain euiPanel--noShadow euiPopover__panel euiPopover__panel--top" + data-popover-panel="true" role="dialog" style="top: -16px; left: -22px; will-change: transform, opacity; z-index: 2000;" tabindex="0" @@ -457,6 +460,7 @@ exports[` can navigate Toolbar Settings, closes when activated 2`] = aria-live="off" aria-modal="true" class="euiPanel euiPanel--borderRadiusMedium euiPanel--plain euiPanel--noShadow euiPopover__panel euiPopover__panel--top euiPopover__panel-isOpen" + data-popover-panel="true" role="dialog" style="top: -16px; left: -22px; z-index: 2000;" tabindex="0" @@ -631,4 +635,4 @@ exports[` can navigate Toolbar Settings, closes when activated 2`] = `; -exports[` can navigate Toolbar Settings, closes when activated 3`] = `"

You are in a dialog. To close this dialog, hit escape.

Settings
Hide Toolbar
Hide the toolbar when the mouse is not within the Canvas?
"`; +exports[` can navigate Toolbar Settings, closes when activated 3`] = `"

You are in a dialog. To close this dialog, hit escape.

Settings
Hide Toolbar
Hide the toolbar when the mouse is not within the Canvas?
"`; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/saved_objects_finder.tsx b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/saved_objects_finder.tsx index 7d7ce5d638489..3f2b3c2420629 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/saved_objects_finder.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/saved_objects_finder.tsx @@ -394,10 +394,7 @@ export class SavedObjectFinderUi extends React.Component< } > - + {this.props.showFilter && ( ( ( { ]); return ( - + ); }; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx b/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx index 7e1c5eb545a28..9ddc698ef2c2b 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx @@ -73,7 +73,6 @@ export class SpacesPopoverList extends Component { title: i18n.translate('xpack.security.management.editRole.spacesPopoverList.popoverTitle', { defaultMessage: 'Spaces', }), - watchedItemProps: ['data-search-term'], }; if (this.props.spaces.length >= SPACE_SEARCH_COUNT_THRESHOLD) { diff --git a/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx b/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx index 6e268d4711bb5..6f5158423ca51 100644 --- a/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx +++ b/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx @@ -69,7 +69,6 @@ class SpacesMenuUI extends Component { id: 'xpack.spaces.navControl.spacesMenu.changeCurrentSpaceTitle', defaultMessage: 'Change current space', }), - watchedItemProps: ['data-search-term'], }; if (this.props.spaces.length >= SPACE_SEARCH_COUNT_THRESHOLD) { diff --git a/x-pack/test/functional/apps/maps/group1/layer_visibility.js b/x-pack/test/functional/apps/maps/group1/layer_visibility.js index cf6051cde8be7..a9bbefbff86ca 100644 --- a/x-pack/test/functional/apps/maps/group1/layer_visibility.js +++ b/x-pack/test/functional/apps/maps/group1/layer_visibility.js @@ -10,6 +10,7 @@ import expect from '@kbn/expect'; export default function ({ getPageObjects, getService }) { const PageObjects = getPageObjects(['maps']); const inspector = getService('inspector'); + const testSubjects = getService('testSubjects'); const security = getService('security'); describe('layer visibility', () => { @@ -31,6 +32,7 @@ export default function ({ getPageObjects, getService }) { it('should fetch layer data when layer is made visible', async () => { await PageObjects.maps.toggleLayerVisibility('logstash'); + await testSubjects.click('mapLayerTOC'); // Tooltip blocks clicks otherwise const hits = await PageObjects.maps.getHits(); expect(hits).to.equal('5'); }); diff --git a/x-pack/test/functional/apps/maps/group1/sample_data.js b/x-pack/test/functional/apps/maps/group1/sample_data.js index cf8bd4c85cf26..62df1d3859a45 100644 --- a/x-pack/test/functional/apps/maps/group1/sample_data.js +++ b/x-pack/test/functional/apps/maps/group1/sample_data.js @@ -165,8 +165,8 @@ export default function ({ getPageObjects, getService, updateBaselines }) { describe('web logs', () => { before(async () => { await PageObjects.maps.loadSavedMap('[Logs] Total Requests and Bytes'); - await PageObjects.maps.toggleLayerVisibility('Road map - desaturated'); await PageObjects.maps.toggleLayerVisibility('Total Requests by Destination'); + await PageObjects.maps.toggleLayerVisibility('Road map - desaturated'); await PageObjects.timePicker.setCommonlyUsedTime('sample_data range'); await PageObjects.maps.enterFullScreen(); await PageObjects.maps.closeLegend(); diff --git a/yarn.lock b/yarn.lock index 88a23a226d0e8..35c60d9444f32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1503,10 +1503,10 @@ resolved "https://registry.yarnpkg.com/@elastic/eslint-plugin-eui/-/eslint-plugin-eui-0.0.2.tgz#56b9ef03984a05cc213772ae3713ea8ef47b0314" integrity sha512-IoxURM5zraoQ7C8f+mJb9HYSENiZGgRVcG4tLQxE61yHNNRDXtGDWTZh8N1KIHcsqN1CEPETjuzBXkJYF/fDiQ== -"@elastic/eui@55.1.2": - version "55.1.2" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-55.1.2.tgz#dd0b42f5b26c5800d6a9cb2d4c2fe1afce9d3f07" - integrity sha512-wwZz5KxMIMFlqEsoCRiQBJDc4CrluS1d0sCOmQ5lhIzKhYc91MdxnqCk2i6YkhL4sSDf2Y9KAEuMXa+uweOWUA== +"@elastic/eui@55.1.3": + version "55.1.3" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-55.1.3.tgz#976142b88156caab2ce896102a1e35fecdaa2647" + integrity sha512-Hf6eN9YKOKAQMMS9OV5pHLUkzpKKAxGYNVSfc/KK7hN9BlhlHH4OaZIQP3Psgf5GKoqhZrldT/N65hujk3rlLA== dependencies: "@types/chroma-js" "^2.0.0" "@types/lodash" "^4.14.160" From 788dd2e718bb4af112c43319cffcb900281d7073 Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Fri, 20 May 2022 16:02:05 -0600 Subject: [PATCH 36/39] [Security Solution] Fixes sorting and tooltips on columns for non-ECS fields that are only one level deep (#132570) ## [Security Solution] Fixes sorting and tooltips on columns for non-ECS fields that are only one level deep This PR fixes , an issue where Timeline columns for non-ECS fields that are only one level deep couldn't be sorted, and displayed incomplete metadata in the column's tooltip. ### Before ![test_field_1_actual_tooltip](https://user-images.githubusercontent.com/4459398/169208299-51d9296a-15e1-4eb0-bc31-a0df6a63f0c5.png) _Before: The column is **not** sortable, and the tooltip displays incomplete metadata_ ### After ![after](https://user-images.githubusercontent.com/4459398/169414767-7274a795-015f-4805-8c3f-b233ead994ea.png) _After: The column is sortable, and the tooltip displays the expected metadata_ ### Desk testing See the _Steps to reproduce_ section of for testing details. --- .../body/column_headers/helpers.test.ts | 232 +++++++++++++++++- .../timeline/body/column_headers/helpers.ts | 27 +- .../components/t_grid/body/helpers.test.tsx | 2 +- 3 files changed, 251 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts index 84cc6e60d928c..2a23b5e993637 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts @@ -6,11 +6,12 @@ */ import { mockBrowserFields } from '../../../../../common/containers/source/mock'; - -import { defaultHeaders } from './default_headers'; -import { getColumnWidthFromType, getColumnHeaders } from './helpers'; -import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../constants'; import '../../../../../common/mock/match_media'; +import { BrowserFields } from '../../../../../../common/search_strategy'; +import { ColumnHeaderOptions } from '../../../../../../common/types'; +import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../constants'; +import { defaultHeaders } from './default_headers'; +import { getColumnWidthFromType, getColumnHeaders, getRootCategory } from './helpers'; describe('helpers', () => { describe('getColumnWidthFromType', () => { @@ -23,6 +24,32 @@ describe('helpers', () => { }); }); + describe('getRootCategory', () => { + const baseFields = ['@timestamp', '_id', 'message']; + + baseFields.forEach((field) => { + test(`it returns the 'base' category for the ${field} field`, () => { + expect( + getRootCategory({ + field, + browserFields: mockBrowserFields, + }) + ).toEqual('base'); + }); + }); + + test(`it echos the field name for a field that's NOT in the base category`, () => { + const field = 'test_field_1'; + + expect( + getRootCategory({ + field, + browserFields: mockBrowserFields, + }) + ).toEqual(field); + }); + }); + describe('getColumnHeaders', () => { test('should return a full object of ColumnHeader from the default header', () => { const expectedData = [ @@ -80,5 +107,202 @@ describe('helpers', () => { ); expect(getColumnHeaders(mockHeader, mockBrowserFields)).toEqual(expectedData); }); + + test('it should return the expected metadata for the `_id` field, which is one level deep, and belongs to the `base` category', () => { + const headers: ColumnHeaderOptions[] = [ + { + columnHeaderType: 'not-filtered', + id: '_id', + initialWidth: 180, + }, + ]; + + expect(getColumnHeaders(headers, mockBrowserFields)).toEqual([ + { + aggregatable: false, + category: 'base', + columnHeaderType: 'not-filtered', + description: 'Each document has an _id that uniquely identifies it', + esTypes: [], + example: 'Y-6TfmcB0WOhS6qyMv3s', + id: '_id', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + initialWidth: 180, + name: '_id', + searchable: true, + type: 'string', + }, + ]); + }); + + test('it should return the expected metadata for a field one level deep that does NOT belong to the `base` category', () => { + const headers: ColumnHeaderOptions[] = [ + { + columnHeaderType: 'not-filtered', + id: 'test_field_1', // one level deep, but does NOT belong to the `base` category + initialWidth: 180, + }, + ]; + + const oneLevelDeep: BrowserFields = { + test_field_1: { + fields: { + test_field_1: { + aggregatable: true, + category: 'test_field_1', + esTypes: ['keyword'], + format: 'string', + indexes: [ + '-*elastic-cloud-logs-*', + '.alerts-security.alerts-default', + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'traces-apm*', + 'winlogbeat-*', + ], + name: 'test_field_1', + readFromDocValues: true, + searchable: true, + type: 'string', + }, + }, + }, + }; + + expect(getColumnHeaders(headers, oneLevelDeep)).toEqual([ + { + aggregatable: true, + category: 'test_field_1', + columnHeaderType: 'not-filtered', + esTypes: ['keyword'], + format: 'string', + id: 'test_field_1', + indexes: [ + '-*elastic-cloud-logs-*', + '.alerts-security.alerts-default', + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'traces-apm*', + 'winlogbeat-*', + ], + initialWidth: 180, + name: 'test_field_1', + readFromDocValues: true, + searchable: true, + type: 'string', + }, + ]); + }); + + test('it should return the expected metadata for a field that is more than one level deep', () => { + const headers: ColumnHeaderOptions[] = [ + { + columnHeaderType: 'not-filtered', + id: 'foo.bar', // two levels deep + initialWidth: 180, + }, + ]; + + const twoLevelsDeep: BrowserFields = { + foo: { + fields: { + 'foo.bar': { + aggregatable: true, + category: 'foo', + esTypes: ['keyword'], + format: 'string', + indexes: [ + '-*elastic-cloud-logs-*', + '.alerts-security.alerts-default', + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'traces-apm*', + 'winlogbeat-*', + ], + name: 'foo.bar', + readFromDocValues: true, + searchable: true, + type: 'string', + }, + }, + }, + }; + + expect(getColumnHeaders(headers, twoLevelsDeep)).toEqual([ + { + aggregatable: true, + category: 'foo', + columnHeaderType: 'not-filtered', + esTypes: ['keyword'], + format: 'string', + id: 'foo.bar', + indexes: [ + '-*elastic-cloud-logs-*', + '.alerts-security.alerts-default', + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'traces-apm*', + 'winlogbeat-*', + ], + initialWidth: 180, + name: 'foo.bar', + readFromDocValues: true, + searchable: true, + type: 'string', + }, + ]); + }); + + test('it should return the expected metadata for an UNKNOWN field one level deep', () => { + const headers: ColumnHeaderOptions[] = [ + { + columnHeaderType: 'not-filtered', + id: 'unknown', // one level deep, but not contained in the `BrowserFields` + initialWidth: 180, + }, + ]; + + expect(getColumnHeaders(headers, mockBrowserFields)).toEqual([ + { + columnHeaderType: 'not-filtered', + id: 'unknown', + initialWidth: 180, + }, + ]); + }); + + test('it should return the expected metadata for an UNKNOWN field that is more than one level deep', () => { + const headers: ColumnHeaderOptions[] = [ + { + columnHeaderType: 'not-filtered', + id: 'unknown.more.than.one.level', // more than one level deep, and not contained in the `BrowserFields` + initialWidth: 180, + }, + ]; + + expect(getColumnHeaders(headers, mockBrowserFields)).toEqual([ + { + columnHeaderType: 'not-filtered', + id: 'unknown.more.than.one.level', + initialWidth: 180, + }, + ]); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts index b1ea4899615a6..1779c39ce7b31 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts @@ -5,12 +5,28 @@ * 2.0. */ -import { get } from 'lodash/fp'; +import { has, get } from 'lodash/fp'; import { ColumnHeaderOptions } from '../../../../../../common/types'; import { BrowserFields } from '../../../../../common/containers/source'; import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../constants'; +/** + * Returns the root category for fields that are only one level, e.g. `_id` or `test_field_1` + * + * The `base` category will be returned for fields that are members of `base`, + * e.g. the `@timestamp`, `_id`, and `message` fields. + * + * The field name will be echoed-back for all other fields, e.g. `test_field_1` + */ +export const getRootCategory = ({ + browserFields, + field, +}: { + browserFields: BrowserFields; + field: string; +}): string => (has(`base.fields.${field}`, browserFields) ? 'base' : field); + /** Enriches the column headers with field details from the specified browserFields */ export const getColumnHeaders = ( headers: ColumnHeaderOptions[], @@ -19,13 +35,14 @@ export const getColumnHeaders = ( return headers ? headers.map((header) => { const splitHeader = header.id.split('.'); // source.geo.city_name -> [source, geo, city_name] + const category = + splitHeader.length > 1 + ? splitHeader[0] + : getRootCategory({ field: header.id, browserFields }); return { ...header, - ...get( - [splitHeader.length > 1 ? splitHeader[0] : 'base', 'fields', header.id], - browserFields - ), + ...get([category, 'fields', header.id], browserFields), }; }) : []; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.tsx index 444ba878d6709..253c3ca78b487 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.test.tsx @@ -138,7 +138,7 @@ describe('helpers', () => { ]); }); - test('it defaults to a `columnType` of empty string when a column does NOT has a corresponding entry in `columnHeaders`', () => { + test('it defaults to a `columnType` of empty string when a column does NOT have a corresponding entry in `columnHeaders`', () => { const withUnknownColumn: Array<{ id: string; direction: 'asc' | 'desc'; From fb1eeb0945e25928a2516133055f048ff098166f Mon Sep 17 00:00:00 2001 From: Georgii Gorbachev Date: Sat, 21 May 2022 00:21:53 +0200 Subject: [PATCH 37/39] [Security Solution][Detections] Add new fields to the rule model: Related Integrations, Required Fields, and Setup (#132409) **Addresses partially:** https://github.com/elastic/security-team/issues/2083, https://github.com/elastic/security-team/issues/558, https://github.com/elastic/security-team/issues/2856, https://github.com/elastic/security-team/issues/1801 (internal tickets) ## Summary **TL;DR:** With this PR, it's now possible to specify `related_integrations`, `required_fields`, and `setup` fields in prebuilt rules in https://github.com/elastic/detection-rules. They are returned within rules in the API responses. This PR: - Adds 3 new fields to the model of Security detection rules. These fields are common to all of the rule types we have. - **Related Integrations**. It's a list of Fleet integrations associated with a given rule. It's assumed that if the user installs them, the rule might start to work properly because it will start receiving source events potentially matching the rule's query. - **Required Fields**. It's a list of event fields that must be present in the source indices of a given rule. - **Setup Guide**. It's any instructions for the user for setting up their environment in order to start receiving source events for a given rule. It's a text. Markdown is supported. It's similar to the Investigation Guide that we show on the Details page. - Adjusts API endpoints accordingly: - These fields are for prebuilt rules only and are supposed to be read-only in the UI. - Specifying these fields in the request parameters of the create/update/patch rule API endpoints is not supported. - These fields are returned in all responses that contain rules. If they are missing in a rule, default values are returned (empty array, empty string). - When duplicating a prebuilt rule, these fields are being reset to their default value (empty array, empty string). - Export/Import is supported. Edge case / supported hack: it's possible to specify these fields manually in a ndjson doc and import with a rule. - The fields are being copied to `kibana.alert.rule.parameters` field of an alert document, which is mapped as a flattened field type. No special handling for the new fields was needed there. - Adjusts tests accordingly. ## Related Integrations Example (part of a rule returned from the API): ```json { "related_integrations": [ { "package": "windows", "version": "1.5.x" }, { "package": "azure", "integration": "activitylogs", "version": "~1.1.6" } ], } ``` Schema: ```ts /** * Related integration is a potential dependency of a rule. It's assumed that if the user installs * one of the related integrations of a rule, the rule might start to work properly because it will * have source events (generated by this integration) potentially matching the rule's query. * * NOTE: Proper work is not guaranteed, because a related integration, if installed, can be * configured differently or generate data that is not necessarily relevant for this rule. * * Related integration is a combination of a Fleet package and (optionally) one of the * package's "integrations" that this package contains. It is represented by 3 properties: * * - `package`: name of the package (required, unique id) * - `version`: version of the package (required, semver-compatible) * - `integration`: name of the integration of this package (optional, id within the package) * * There are Fleet packages like `windows` that contain only one integration; in this case, * `integration` should be unspecified. There are also packages like `aws` and `azure` that contain * several integrations; in this case, `integration` should be specified. * * @example * const x: RelatedIntegration = { * package: 'windows', * version: '1.5.x', * }; * * @example * const x: RelatedIntegration = { * package: 'azure', * version: '~1.1.6', * integration: 'activitylogs', * }; */ export type RelatedIntegration = t.TypeOf; export const RelatedIntegration = t.exact( t.intersection([ t.type({ package: NonEmptyString, version: NonEmptyString, }), t.partial({ integration: NonEmptyString, }), ]) ); ``` ## Required Fields Example (part of a rule returned from the API): ```json { "required_fields": [ { "name": "event.action", "type": "keyword", "ecs": true }, { "name": "event.code", "type": "keyword", "ecs": true }, { "name": "winlog.event_data.AttributeLDAPDisplayName", "type": "keyword", "ecs": false } ], } ``` Schema: ```ts /** * Almost all types of Security rules check source event documents for a match to some kind of * query or filter. If a document has certain field with certain values, then it's a match and * the rule will generate an alert. * * Required field is an event field that must be present in the source indices of a given rule. * * @example * const standardEcsField: RequiredField = { * name: 'event.action', * type: 'keyword', * ecs: true, * }; * * @example * const nonEcsField: RequiredField = { * name: 'winlog.event_data.AttributeLDAPDisplayName', * type: 'keyword', * ecs: false, * }; */ export type RequiredField = t.TypeOf; export const RequiredField = t.exact( t.type({ name: NonEmptyString, type: NonEmptyString, ecs: t.boolean, }) ); ``` ## Setup Guide Example (part of a rule returned from the API): ```json { "setup": "## Config\n\nThe 'PowerShell Script Block Logging' logging policy must be enabled.\nSteps to implement the logging policy with with Advanced Audit Configuration:\n\n```\nComputer Configuration > \nAdministrative Templates > \nWindows PowerShell > \nTurn on PowerShell Script Block Logging (Enable)\n```\n\nSteps to implement the logging policy via registry:\n\n```\nreg add \"hklm\\SOFTWARE\\Policies\\Microsoft\\Windows\\PowerShell\\ScriptBlockLogging\" /v EnableScriptBlockLogging /t REG_DWORD /d 1\n```\n", } ``` Schema: ```ts /** * Any instructions for the user for setting up their environment in order to start receiving * source events for a given rule. * * It's a multiline text. Markdown is supported. */ export type SetupGuide = t.TypeOf; export const SetupGuide = t.string; ``` ## Details on the schema This PR adjusts all the 6 rule schemas we have: 1. Alerting Framework rule `params` schema: - `security_solution/server/lib/detection_engine/schemas/rule_schemas.ts` - `security_solution/server/lib/detection_engine/schemas/rule_converters.ts` 2. HTTP API main old schema: - `security_solution/common/detection_engine/schemas/response/rules_schema.ts` 3. HTTP API main new schema: - `security_solution/common/detection_engine/schemas/request/rule_schemas.ts` 4. Prebuilt rule schema: - `security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts` 5. Import rule schema: - `security_solution/common/detection_engine/schemas/request/import_rules_schema.ts` 6. Rule schema used on the frontend side: - `security_solution/public/detections/containers/detection_engine/rules/types.ts` Names of the fields on the HTTP API level: - `related_integrations` - `required_fields` - `setup` Names of the fields on the Alerting Framework level: - `params.relatedIntegrations` - `params.requiredFields` - `params.setup` ## Next steps - Create a new endpoint for returning installed Fleet integrations (gonna be a separate PR). - Rebase https://github.com/elastic/kibana/pull/131475 on top of this PR after merge. - Cover the new fields with dedicated tests (gonna be a separate PR). - Update API docs (gonna be a separate PR). - Address the tech debt of having 6 different schemas (gonna create a ticket for that). ### Checklist - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../detection_engine/schemas/common/index.ts | 1 + .../schemas/common/rule_params.ts | 146 +++++++ .../request/add_prepackaged_rules_schema.ts | 8 +- .../schemas/request/import_rules_schema.ts | 8 +- .../schemas/request/patch_rules_schema.ts | 2 +- .../schemas/request/rule_schemas.ts | 11 + .../schemas/response/rules_schema.mocks.ts | 6 + .../schemas/response/rules_schema.ts | 6 + .../security_solution/cypress/objects/rule.ts | 9 +- .../containers/detection_engine/rules/mock.ts | 9 + .../detection_engine/rules/types.ts | 6 + .../detection_engine/rules/use_rule.test.tsx | 3 + .../rules/use_rule_with_fallback.test.tsx | 3 + .../rules/all/__mocks__/mock.ts | 6 + .../schedule_notification_actions.test.ts | 3 + ...dule_throttle_notification_actions.test.ts | 3 + .../routes/__mocks__/utils.ts | 3 + .../routes/rules/utils/import_rules_utils.ts | 9 + .../routes/rules/validate.test.ts | 3 + .../factories/utils/build_alert.test.ts | 6 + .../rules/create_rules.mock.ts | 9 + .../detection_engine/rules/create_rules.ts | 6 + .../rules/duplicate_rule.test.ts | 391 +++++++++++++----- .../detection_engine/rules/duplicate_rule.ts | 16 +- .../rules/get_export_all.test.ts | 3 + .../rules/get_export_by_object_ids.test.ts | 6 + .../rules/install_prepacked_rules.ts | 6 + .../lib/detection_engine/rules/patch_rules.ts | 9 + .../lib/detection_engine/rules/types.ts | 9 + .../rules/update_prepacked_rules.ts | 9 + .../detection_engine/rules/update_rules.ts | 3 + .../lib/detection_engine/rules/utils.test.ts | 9 + .../lib/detection_engine/rules/utils.ts | 8 +- .../schemas/rule_converters.ts | 6 + .../schemas/rule_schemas.mock.ts | 3 + .../detection_engine/schemas/rule_schemas.ts | 8 +- .../signals/__mocks__/es_results.ts | 9 + .../basic/tests/create_rules.ts | 3 + .../group1/create_rules.ts | 3 + .../group6/alerts/alerts_compatibility.ts | 6 + .../utils/get_complex_rule_output.ts | 3 + .../utils/get_simple_rule_output.ts | 3 + 42 files changed, 660 insertions(+), 119 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/detection_engine/schemas/common/rule_params.ts diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/index.ts index 4ef5d6178d5a5..615eb3f05876e 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/index.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/index.ts @@ -6,4 +6,5 @@ */ export * from './rule_monitoring'; +export * from './rule_params'; export * from './schemas'; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/rule_params.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/rule_params.ts new file mode 100644 index 0000000000000..b9588a26bb35b --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/rule_params.ts @@ -0,0 +1,146 @@ +/* + * 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 t from 'io-ts'; +import { NonEmptyString } from '@kbn/securitysolution-io-ts-types'; + +// ------------------------------------------------------------------------------------------------- +// Related integrations + +/** + * Related integration is a potential dependency of a rule. It's assumed that if the user installs + * one of the related integrations of a rule, the rule might start to work properly because it will + * have source events (generated by this integration) potentially matching the rule's query. + * + * NOTE: Proper work is not guaranteed, because a related integration, if installed, can be + * configured differently or generate data that is not necessarily relevant for this rule. + * + * Related integration is a combination of a Fleet package and (optionally) one of the + * package's "integrations" that this package contains. It is represented by 3 properties: + * + * - `package`: name of the package (required, unique id) + * - `version`: version of the package (required, semver-compatible) + * - `integration`: name of the integration of this package (optional, id within the package) + * + * There are Fleet packages like `windows` that contain only one integration; in this case, + * `integration` should be unspecified. There are also packages like `aws` and `azure` that contain + * several integrations; in this case, `integration` should be specified. + * + * @example + * const x: RelatedIntegration = { + * package: 'windows', + * version: '1.5.x', + * }; + * + * @example + * const x: RelatedIntegration = { + * package: 'azure', + * version: '~1.1.6', + * integration: 'activitylogs', + * }; + */ +export type RelatedIntegration = t.TypeOf; +export const RelatedIntegration = t.exact( + t.intersection([ + t.type({ + package: NonEmptyString, + version: NonEmptyString, + }), + t.partial({ + integration: NonEmptyString, + }), + ]) +); + +/** + * Array of related integrations. + * + * @example + * const x: RelatedIntegrationArray = [ + * { + * package: 'windows', + * version: '1.5.x', + * }, + * { + * package: 'azure', + * version: '~1.1.6', + * integration: 'activitylogs', + * }, + * ]; + */ +export type RelatedIntegrationArray = t.TypeOf; +export const RelatedIntegrationArray = t.array(RelatedIntegration); + +// ------------------------------------------------------------------------------------------------- +// Required fields + +/** + * Almost all types of Security rules check source event documents for a match to some kind of + * query or filter. If a document has certain field with certain values, then it's a match and + * the rule will generate an alert. + * + * Required field is an event field that must be present in the source indices of a given rule. + * + * @example + * const standardEcsField: RequiredField = { + * name: 'event.action', + * type: 'keyword', + * ecs: true, + * }; + * + * @example + * const nonEcsField: RequiredField = { + * name: 'winlog.event_data.AttributeLDAPDisplayName', + * type: 'keyword', + * ecs: false, + * }; + */ +export type RequiredField = t.TypeOf; +export const RequiredField = t.exact( + t.type({ + name: NonEmptyString, + type: NonEmptyString, + ecs: t.boolean, + }) +); + +/** + * Array of event fields that must be present in the source indices of a given rule. + * + * @example + * const x: RequiredFieldArray = [ + * { + * name: 'event.action', + * type: 'keyword', + * ecs: true, + * }, + * { + * name: 'event.code', + * type: 'keyword', + * ecs: true, + * }, + * { + * name: 'winlog.event_data.AttributeLDAPDisplayName', + * type: 'keyword', + * ecs: false, + * }, + * ]; + */ +export type RequiredFieldArray = t.TypeOf; +export const RequiredFieldArray = t.array(RequiredField); + +// ------------------------------------------------------------------------------------------------- +// Setup guide + +/** + * Any instructions for the user for setting up their environment in order to start receiving + * source events for a given rule. + * + * It's a multiline text. Markdown is supported. + */ +export type SetupGuide = t.TypeOf; +export const SetupGuide = t.string; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts index 618aee3379316..27ebf9a608ffa 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts @@ -72,7 +72,10 @@ import { Author, event_category_override, namespace, -} from '../common/schemas'; + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, +} from '../common'; /** * Big differences between this schema and the createRulesSchema @@ -117,8 +120,11 @@ export const addPrepackagedRulesSchema = t.intersection([ meta, // defaults to "undefined" if not set during decode machine_learning_job_id, // defaults to "undefined" if not set during decode max_signals: DefaultMaxSignalsNumber, // defaults to DEFAULT_MAX_SIGNALS (100) if not set during decode + related_integrations: RelatedIntegrationArray, // defaults to "undefined" if not set during decode + required_fields: RequiredFieldArray, // defaults to "undefined" if not set during decode risk_score_mapping: DefaultRiskScoreMappingArray, // defaults to empty risk score mapping array if not set during decode rule_name_override, // defaults to "undefined" if not set during decode + setup: SetupGuide, // defaults to "undefined" if not set during decode severity_mapping: DefaultSeverityMappingArray, // defaults to empty actions array if not set during decode tags: DefaultStringArray, // defaults to empty string array if not set during decode to: DefaultToString, // defaults to "now" if not set during decode diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts index 63c41e45e42d0..8cee4183d6ee7 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts @@ -80,7 +80,10 @@ import { timestamp_override, Author, event_category_override, -} from '../common/schemas'; + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, +} from '../common'; /** * Differences from this and the createRulesSchema are @@ -129,8 +132,11 @@ export const importRulesSchema = t.intersection([ meta, // defaults to "undefined" if not set during decode machine_learning_job_id, // defaults to "undefined" if not set during decode max_signals: DefaultMaxSignalsNumber, // defaults to DEFAULT_MAX_SIGNALS (100) if not set during decode + related_integrations: RelatedIntegrationArray, // defaults to "undefined" if not set during decode + required_fields: RequiredFieldArray, // defaults to "undefined" if not set during decode risk_score_mapping: DefaultRiskScoreMappingArray, // defaults to empty risk score mapping array if not set during decode rule_name_override, // defaults to "undefined" if not set during decode + setup: SetupGuide, // defaults to "undefined" if not set during decode severity_mapping: DefaultSeverityMappingArray, // defaults to empty actions array if not set during decode tags: DefaultStringArray, // defaults to empty string array if not set during decode to: DefaultToString, // defaults to "now" if not set during decode diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts index 8c801e75af08c..6678681471b38 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts @@ -61,7 +61,7 @@ import { rule_name_override, timestamp_override, event_category_override, -} from '../common/schemas'; +} from '../common'; /** * All of the patch elements should default to undefined if not set diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts index 69a748c3bd95c..9aef9ac8f2651 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts @@ -67,6 +67,9 @@ import { created_by, namespace, ruleExecutionSummary, + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, } from '../common'; export const createSchema = < @@ -412,6 +415,14 @@ const responseRequiredFields = { updated_by, created_at, created_by, + + // NOTE: For now, Related Integrations, Required Fields and Setup Guide are supported for prebuilt + // rules only. We don't want to allow users to edit these 3 fields via the API. If we added them + // to baseParams.defaultable, they would become a part of the request schema as optional fields. + // This is why we add them here, in order to add them only to the response schema. + related_integrations: RelatedIntegrationArray, + required_fields: RequiredFieldArray, + setup: SetupGuide, }; const responseOptionalFields = { diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts index 0642481b62a6a..eeaab6dc50021 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts @@ -68,6 +68,9 @@ export const getRulesSchemaMock = (anchorDate: string = ANCHOR_DATE): RulesSchem rule_id: 'query-rule-id', interval: '5m', exceptions_list: getListArrayMock(), + related_integrations: [], + required_fields: [], + setup: '', }); export const getRulesMlSchemaMock = (anchorDate: string = ANCHOR_DATE): RulesSchema => { @@ -132,6 +135,9 @@ export const getThreatMatchingSchemaPartialMock = (enabled = false): Partial; diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 32c55e22ae7c9..de2de9bd78160 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -442,7 +442,9 @@ export const expectedExportedRule = (ruleResponse: Cypress.Response severity, query, } = ruleResponse.body; - const rule = { + + // NOTE: Order of the properties in this object matters for the tests to work. + const rule: RulesSchema = { id, updated_at: updatedAt, updated_by: updatedBy, @@ -469,6 +471,9 @@ export const expectedExportedRule = (ruleResponse: Cypress.Response version: 1, exceptions_list: [], immutable: false, + related_integrations: [], + required_fields: [], + setup: '', type: 'query', language: 'kuery', index: getIndexPatterns(), @@ -476,6 +481,8 @@ export const expectedExportedRule = (ruleResponse: Cypress.Response throttle: 'no_actions', actions: [], }; + + // NOTE: Order of the properties in this object matters for the tests to work. const details = { exported_count: 1, exported_rules_count: 1, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts index 8c1737a4519a7..8a23cbf9e4318 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts @@ -38,6 +38,9 @@ export const savedRuleMock: Rule = { max_signals: 100, query: "user.email: 'root@elastic.co'", references: [], + related_integrations: [], + required_fields: [], + setup: '', severity: 'high', severity_mapping: [], tags: ['APM'], @@ -80,6 +83,9 @@ export const rulesMock: FetchRulesResponse = { 'event.kind:alert and event.module:endgame and event.action:cred_theft_event and endgame.metadata.type:detection', filters: [], references: [], + related_integrations: [], + required_fields: [], + setup: '', severity: 'high', severity_mapping: [], updated_by: 'elastic', @@ -115,6 +121,9 @@ export const rulesMock: FetchRulesResponse = { query: 'event.kind:alert and event.module:endgame and event.action:rules_engine_event', filters: [], references: [], + related_integrations: [], + required_fields: [], + setup: '', severity: 'medium', severity_mapping: [], updated_by: 'elastic', diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts index ddd65674274be..d6e278599d62d 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts @@ -34,6 +34,9 @@ import { BulkAction, BulkActionEditPayload, ruleExecutionSummary, + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, } from '../../../../../common/detection_engine/schemas/common'; import { @@ -102,11 +105,14 @@ export const RuleSchema = t.intersection([ name: t.string, max_signals: t.number, references: t.array(t.string), + related_integrations: RelatedIntegrationArray, + required_fields: RequiredFieldArray, risk_score: t.number, risk_score_mapping, rule_id: t.string, severity, severity_mapping, + setup: SetupGuide, tags: t.array(t.string), type, to: t.string, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.test.tsx index 096463872fc01..3ca18552a85ef 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.test.tsx @@ -67,9 +67,12 @@ describe('useRule', () => { max_signals: 100, query: "user.email: 'root@elastic.co'", references: [], + related_integrations: [], + required_fields: [], risk_score: 75, risk_score_mapping: [], rule_id: 'bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf', + setup: '', severity: 'high', severity_mapping: [], tags: ['APM'], diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx index d7c4ad8772bd2..1816fd4c5a7af 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_with_fallback.test.tsx @@ -78,9 +78,12 @@ describe('useRuleWithFallback', () => { "name": "Test rule", "query": "user.email: 'root@elastic.co'", "references": Array [], + "related_integrations": Array [], + "required_fields": Array [], "risk_score": 75, "risk_score_mapping": Array [], "rule_id": "bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf", + "setup": "", "severity": "high", "severity_mapping": Array [], "tags": Array [ diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts index 77de8902be33a..d9f16242a544a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -70,6 +70,9 @@ export const mockRule = (id: string): Rule => ({ timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', timeline_title: 'Untitled timeline', meta: { from: '0m' }, + related_integrations: [], + required_fields: [], + setup: '', severity: 'low', severity_mapping: [], updated_by: 'elastic', @@ -133,6 +136,9 @@ export const mockRuleWithEverything = (id: string): Rule => ({ timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', timeline_title: 'Titled timeline', meta: { from: '0m' }, + related_integrations: [], + required_fields: [], + setup: '', severity: 'low', severity_mapping: [], updated_by: 'elastic', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.test.ts index d97eff43aeb8d..04e8f2130e88f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_notification_actions.test.ts @@ -51,6 +51,9 @@ describe('schedule_notification_actions', () => { note: '# sample markdown', version: 1, exceptionsList: [], + relatedIntegrations: [], + requiredFields: [], + setup: '', }; it('Should schedule actions with unflatted and legacy context', () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_throttle_notification_actions.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_throttle_notification_actions.test.ts index d7293275c9c49..72ddb96301c47 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_throttle_notification_actions.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/schedule_throttle_notification_actions.test.ts @@ -59,6 +59,9 @@ describe('schedule_throttle_notification_actions', () => { note: '# sample markdown', version: 1, exceptionsList: [], + relatedIntegrations: [], + requiredFields: [], + setup: '', }; }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts index 2622493a51dc1..54bf6133f9e37 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts @@ -90,4 +90,7 @@ export const getOutputRuleAlertForRest = (): Omit< note: '# Investigative notes', version: 1, execution_summary: undefined, + related_integrations: [], + required_fields: [], + setup: '', }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts index d603784fc7081..8f87c1cdc0467 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts @@ -117,10 +117,13 @@ export const importRules = async ({ index, interval, max_signals: maxSignals, + related_integrations: relatedIntegrations, + required_fields: requiredFields, risk_score: riskScore, risk_score_mapping: riskScoreMapping, rule_name_override: ruleNameOverride, name, + setup, severity, severity_mapping: severityMapping, tags, @@ -192,9 +195,12 @@ export const importRules = async ({ interval, maxSignals, name, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, + setup, severity, severityMapping, tags, @@ -250,10 +256,13 @@ export const importRules = async ({ index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, name, + setup, severity, severityMapping, tags, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts index 0b8c49cdb4d17..833361e7e22bf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/validate.test.ts @@ -63,6 +63,9 @@ export const ruleOutput = (): RulesSchema => ({ note: '# Investigative notes', timeline_title: 'some-timeline-title', timeline_id: 'some-timeline-id', + related_integrations: [], + required_fields: [], + setup: '', }); describe('validate', () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts index 5768306999f79..083f495366480 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts @@ -126,6 +126,9 @@ describe('buildAlert', () => { ], to: 'now', references: ['http://example.com', 'https://example.com'], + related_integrations: [], + required_fields: [], + setup: '', version: 1, exceptions_list: [ { @@ -303,6 +306,9 @@ describe('buildAlert', () => { ], to: 'now', references: ['http://example.com', 'https://example.com'], + related_integrations: [], + required_fields: [], + setup: '', version: 1, exceptions_list: [ { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts index 1a41adb4f6da5..3c7acccae703a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts @@ -32,11 +32,14 @@ export const getCreateRulesOptionsMock = (): CreateRulesOptions => ({ index: ['index-123'], interval: '5m', maxSignals: 100, + relatedIntegrations: undefined, + requiredFields: undefined, riskScore: 80, riskScoreMapping: [], ruleNameOverride: undefined, outputIndex: 'output-1', name: 'Query with a rule id', + setup: undefined, severity: 'high', severityMapping: [], tags: [], @@ -85,11 +88,14 @@ export const getCreateMlRulesOptionsMock = (): CreateRulesOptions => ({ index: ['index-123'], interval: '5m', maxSignals: 100, + relatedIntegrations: undefined, + requiredFields: undefined, riskScore: 80, riskScoreMapping: [], ruleNameOverride: undefined, outputIndex: 'output-1', name: 'Machine Learning Job', + setup: undefined, severity: 'high', severityMapping: [], tags: [], @@ -141,12 +147,15 @@ export const getCreateThreatMatchRulesOptionsMock = (): CreateRulesOptions => ({ outputIndex: 'output-1', query: 'user.name: root or user.name: admin', references: ['http://www.example.com'], + relatedIntegrations: undefined, + requiredFields: undefined, riskScore: 80, riskScoreMapping: [], ruleId: 'rule-1', ruleNameOverride: undefined, rulesClient: rulesClientMock.create(), savedId: 'savedId-123', + setup: undefined, severity: 'high', severityMapping: [], tags: [], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts index 24017adc20626..726964cdf3596 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts @@ -46,11 +46,14 @@ export const createRules = async ({ index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, outputIndex, name, + setup, severity, severityMapping, tags, @@ -109,9 +112,12 @@ export const createRules = async ({ : undefined, filters, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, + setup, severity, severityMapping, threat, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts index 04d8e66a076fb..cab22e136f529 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts @@ -6,6 +6,8 @@ */ import uuid from 'uuid'; +import { SanitizedRule } from '@kbn/alerting-plugin/common'; +import { RuleParams } from '../schemas/rule_schemas'; import { duplicateRule } from './duplicate_rule'; jest.mock('uuid', () => ({ @@ -13,120 +15,287 @@ jest.mock('uuid', () => ({ })); describe('duplicateRule', () => { - it('should return a copy of rule with new ruleId', () => { - (uuid.v4 as jest.Mock).mockReturnValue('newId'); - - expect( - duplicateRule({ - id: 'oldTestRuleId', - notifyWhen: 'onActiveAlert', - name: 'test', - tags: ['test'], - alertTypeId: 'siem.signals', - consumer: 'siem', - params: { - savedId: undefined, - author: [], - description: 'test', - ruleId: 'oldTestRuleId', - falsePositives: [], - from: 'now-360s', - immutable: false, - license: '', - outputIndex: '.siem-signals-default', - meta: undefined, - maxSignals: 100, - riskScore: 42, - riskScoreMapping: [], - severity: 'low', - severityMapping: [], - threat: [], - to: 'now', - references: [], - version: 1, - exceptionsList: [], - type: 'query', - language: 'kuery', - index: [], - query: 'process.args : "chmod"', - filters: [], - buildingBlockType: undefined, - namespace: undefined, - note: undefined, - timelineId: undefined, - timelineTitle: undefined, - ruleNameOverride: undefined, - timestampOverride: undefined, - }, - schedule: { - interval: '5m', - }, + const createTestRule = (): SanitizedRule => ({ + id: 'some id', + notifyWhen: 'onActiveAlert', + name: 'Some rule', + tags: ['some tag'], + alertTypeId: 'siem.queryRule', + consumer: 'siem', + params: { + savedId: undefined, + author: [], + description: 'Some description.', + ruleId: 'some ruleId', + falsePositives: [], + from: 'now-360s', + immutable: false, + license: '', + outputIndex: '.siem-signals-default', + meta: undefined, + maxSignals: 100, + relatedIntegrations: [], + requiredFields: [], + riskScore: 42, + riskScoreMapping: [], + severity: 'low', + severityMapping: [], + setup: 'Some setup guide.', + threat: [], + to: 'now', + references: [], + version: 1, + exceptionsList: [], + type: 'query', + language: 'kuery', + index: [], + query: 'process.args : "chmod"', + filters: [], + buildingBlockType: undefined, + namespace: undefined, + note: undefined, + timelineId: undefined, + timelineTitle: undefined, + ruleNameOverride: undefined, + timestampOverride: undefined, + }, + schedule: { + interval: '5m', + }, + enabled: false, + actions: [], + throttle: null, + apiKeyOwner: 'kibana', + createdBy: 'kibana', + updatedBy: 'kibana', + muteAll: false, + mutedInstanceIds: [], + updatedAt: new Date(2021, 0), + createdAt: new Date(2021, 0), + scheduledTaskId: undefined, + executionStatus: { + lastExecutionDate: new Date(2021, 0), + status: 'ok', + }, + }); + + beforeAll(() => { + (uuid.v4 as jest.Mock).mockReturnValue('new ruleId'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('returns an object with fields copied from a given rule', () => { + const rule = createTestRule(); + const result = duplicateRule(rule); + + expect(result).toEqual({ + name: expect.anything(), // covered in a separate test + params: { + ...rule.params, + ruleId: expect.anything(), // covered in a separate test + }, + tags: rule.tags, + alertTypeId: rule.alertTypeId, + consumer: rule.consumer, + schedule: rule.schedule, + actions: rule.actions, + throttle: null, // TODO: fix? + notifyWhen: null, // TODO: fix? + enabled: false, // covered in a separate test + }); + }); + + it('appends [Duplicate] to the name', () => { + const rule = createTestRule(); + rule.name = 'PowerShell Keylogging Script'; + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + name: 'PowerShell Keylogging Script [Duplicate]', + }) + ); + }); + + it('generates a new ruleId', () => { + const rule = createTestRule(); + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + ruleId: 'new ruleId', + }), + }) + ); + }); + + it('makes sure the duplicated rule is disabled', () => { + const rule = createTestRule(); + rule.enabled = true; + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ enabled: false, - actions: [], - throttle: null, - apiKeyOwner: 'kibana', - createdBy: 'kibana', - updatedBy: 'kibana', - muteAll: false, - mutedInstanceIds: [], - updatedAt: new Date(2021, 0), - createdAt: new Date(2021, 0), - scheduledTaskId: undefined, - executionStatus: { - lastExecutionDate: new Date(2021, 0), - status: 'ok', - }, }) - ).toMatchInlineSnapshot(` - Object { - "actions": Array [], - "alertTypeId": "siem.queryRule", - "consumer": "siem", - "enabled": false, - "name": "test [Duplicate]", - "notifyWhen": null, - "params": Object { - "author": Array [], - "buildingBlockType": undefined, - "description": "test", - "exceptionsList": Array [], - "falsePositives": Array [], - "filters": Array [], - "from": "now-360s", - "immutable": false, - "index": Array [], - "language": "kuery", - "license": "", - "maxSignals": 100, - "meta": undefined, - "namespace": undefined, - "note": undefined, - "outputIndex": ".siem-signals-default", - "query": "process.args : \\"chmod\\"", - "references": Array [], - "riskScore": 42, - "riskScoreMapping": Array [], - "ruleId": "newId", - "ruleNameOverride": undefined, - "savedId": undefined, - "severity": "low", - "severityMapping": Array [], - "threat": Array [], - "timelineId": undefined, - "timelineTitle": undefined, - "timestampOverride": undefined, - "to": "now", - "type": "query", - "version": 1, + ); + }); + + describe('when duplicating a prebuilt (immutable) rule', () => { + const createPrebuiltRule = () => { + const rule = createTestRule(); + rule.params.immutable = true; + return rule; + }; + + it('transforms it to a custom (mutable) rule', () => { + const rule = createPrebuiltRule(); + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + immutable: false, + }), + }) + ); + }); + + it('resets related integrations to an empty array', () => { + const rule = createPrebuiltRule(); + rule.params.relatedIntegrations = [ + { + package: 'aws', + version: '~1.2.3', + integration: 'route53', }, - "schedule": Object { - "interval": "5m", + ]; + + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + relatedIntegrations: [], + }), + }) + ); + }); + + it('resets required fields to an empty array', () => { + const rule = createPrebuiltRule(); + rule.params.requiredFields = [ + { + name: 'event.action', + type: 'keyword', + ecs: true, }, - "tags": Array [ - "test", - ], - "throttle": null, - } - `); + ]; + + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + requiredFields: [], + }), + }) + ); + }); + + it('resets setup guide to an empty string', () => { + const rule = createPrebuiltRule(); + rule.params.setup = `## Config\n\nThe 'Audit Detailed File Share' audit policy must be configured...`; + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + setup: '', + }), + }) + ); + }); + }); + + describe('when duplicating a custom (mutable) rule', () => { + const createCustomRule = () => { + const rule = createTestRule(); + rule.params.immutable = false; + return rule; + }; + + it('keeps it custom', () => { + const rule = createCustomRule(); + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + immutable: false, + }), + }) + ); + }); + + it('copies related integrations as is', () => { + const rule = createCustomRule(); + rule.params.relatedIntegrations = [ + { + package: 'aws', + version: '~1.2.3', + integration: 'route53', + }, + ]; + + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + relatedIntegrations: rule.params.relatedIntegrations, + }), + }) + ); + }); + + it('copies required fields as is', () => { + const rule = createCustomRule(); + rule.params.requiredFields = [ + { + name: 'event.action', + type: 'keyword', + ecs: true, + }, + ]; + + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + requiredFields: rule.params.requiredFields, + }), + }) + ); + }); + + it('copies setup guide as is', () => { + const rule = createCustomRule(); + rule.params.setup = `## Config\n\nThe 'Audit Detailed File Share' audit policy must be configured...`; + const result = duplicateRule(rule); + + expect(result).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + setup: rule.params.setup, + }), + }) + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.ts index 4ef21d0450517..81af1533498ee 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.ts @@ -22,7 +22,16 @@ const DUPLICATE_TITLE = i18n.translate( ); export const duplicateRule = (rule: SanitizedRule): InternalRuleCreate => { - const newRuleId = uuid.v4(); + // Generate a new static ruleId + const ruleId = uuid.v4(); + + // If it's a prebuilt rule, reset Related Integrations, Required Fields and Setup Guide. + // We do this because for now we don't allow the users to edit these fields for custom rules. + const isPrebuilt = rule.params.immutable; + const relatedIntegrations = isPrebuilt ? [] : rule.params.relatedIntegrations; + const requiredFields = isPrebuilt ? [] : rule.params.requiredFields; + const setup = isPrebuilt ? '' : rule.params.setup; + return { name: `${rule.name} [${DUPLICATE_TITLE}]`, tags: rule.tags, @@ -31,7 +40,10 @@ export const duplicateRule = (rule: SanitizedRule): InternalRuleCrea params: { ...rule.params, immutable: false, - ruleId: newRuleId, + ruleId, + relatedIntegrations, + requiredFields, + setup, }, schedule: rule.schedule, enabled: false, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts index de80a8ba8c26b..68fad65a8ff7e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts @@ -85,6 +85,9 @@ describe('getExportAll', () => { name: 'Detect Root/Admin Users', query: 'user.name: root or user.name: admin', references: ['http://example.com', 'https://example.com'], + related_integrations: [], + required_fields: [], + setup: '', timeline_id: 'some-timeline-id', timeline_title: 'some-timeline-title', meta: { someMeta: 'someField' }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts index f297f375dda0b..e31c1444cd9fc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts @@ -82,6 +82,9 @@ describe('get_export_by_object_ids', () => { name: 'Detect Root/Admin Users', query: 'user.name: root or user.name: admin', references: ['http://example.com', 'https://example.com'], + related_integrations: [], + required_fields: [], + setup: '', timeline_id: 'some-timeline-id', timeline_title: 'some-timeline-title', meta: { someMeta: 'someField' }, @@ -191,6 +194,9 @@ describe('get_export_by_object_ids', () => { name: 'Detect Root/Admin Users', query: 'user.name: root or user.name: admin', references: ['http://example.com', 'https://example.com'], + related_integrations: [], + required_fields: [], + setup: '', timeline_id: 'some-timeline-id', timeline_title: 'some-timeline-title', meta: { someMeta: 'someField' }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts index bffa0bc39eb91..1ef4f14b17b6b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts @@ -39,10 +39,13 @@ export const installPrepackagedRules = ( index, interval, max_signals: maxSignals, + related_integrations: relatedIntegrations, + required_fields: requiredFields, risk_score: riskScore, risk_score_mapping: riskScoreMapping, rule_name_override: ruleNameOverride, name, + setup, severity, severity_mapping: severityMapping, tags, @@ -95,10 +98,13 @@ export const installPrepackagedRules = ( index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, name, + setup, severity, severityMapping, tags, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts index ad2443b34fa95..e5f87b7cdb2e2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts @@ -54,11 +54,14 @@ export const patchRules = async ({ index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, rule, name, + setup, severity, severityMapping, tags, @@ -108,10 +111,13 @@ export const patchRules = async ({ index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, name, + setup, severity, severityMapping, tags, @@ -158,9 +164,12 @@ export const patchRules = async ({ filters, index, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, + setup, severity, severityMapping, threat, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index 8b560d0edea0f..eeb0e88e53d47 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -93,6 +93,9 @@ import { RuleNameOverrideOrUndefined, EventCategoryOverrideOrUndefined, NamespaceOrUndefined, + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, } from '../../../../common/detection_engine/schemas/common'; import { PartialFilter } from '../types'; @@ -161,11 +164,14 @@ export interface CreateRulesOptions { interval: Interval; license: LicenseOrUndefined; maxSignals: MaxSignals; + relatedIntegrations: RelatedIntegrationArray | undefined; + requiredFields: RequiredFieldArray | undefined; riskScore: RiskScore; riskScoreMapping: RiskScoreMapping; ruleNameOverride: RuleNameOverrideOrUndefined; outputIndex: OutputIndex; name: Name; + setup: SetupGuide | undefined; severity: Severity; severityMapping: SeverityMapping; tags: Tags; @@ -225,11 +231,14 @@ interface PatchRulesFieldsOptions { interval: IntervalOrUndefined; license: LicenseOrUndefined; maxSignals: MaxSignalsOrUndefined; + relatedIntegrations: RelatedIntegrationArray | undefined; + requiredFields: RequiredFieldArray | undefined; riskScore: RiskScoreOrUndefined; riskScoreMapping: RiskScoreMappingOrUndefined; ruleNameOverride: RuleNameOverrideOrUndefined; outputIndex: OutputIndexOrUndefined; name: NameOrUndefined; + setup: SetupGuide | undefined; severity: SeverityOrUndefined; severityMapping: SeverityMappingOrUndefined; tags: TagsOrUndefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts index ad35e11d35668..079af5b82d608 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -83,10 +83,13 @@ export const createPromises = ( index, interval, max_signals: maxSignals, + related_integrations: relatedIntegrations, + required_fields: requiredFields, risk_score: riskScore, risk_score_mapping: riskScoreMapping, rule_name_override: ruleNameOverride, name, + setup, severity, severity_mapping: severityMapping, tags, @@ -169,10 +172,13 @@ export const createPromises = ( index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, name, + setup, severity, severityMapping, tags, @@ -220,10 +226,13 @@ export const createPromises = ( index, interval, maxSignals, + relatedIntegrations, + requiredFields, riskScore, riskScoreMapping, ruleNameOverride, name, + setup, severity, severityMapping, tags, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts index ba65b76f01c4a..7c981a5481ff9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts @@ -54,9 +54,12 @@ export const updateRules = async ({ timelineTitle: ruleUpdate.timeline_title, meta: ruleUpdate.meta, maxSignals: ruleUpdate.max_signals ?? DEFAULT_MAX_SIGNALS, + relatedIntegrations: existingRule.params.relatedIntegrations, + requiredFields: existingRule.params.requiredFields, riskScore: ruleUpdate.risk_score, riskScoreMapping: ruleUpdate.risk_score_mapping ?? [], ruleNameOverride: ruleUpdate.rule_name_override, + setup: existingRule.params.setup, severity: ruleUpdate.severity, severityMapping: ruleUpdate.severity_mapping ?? [], threat: ruleUpdate.threat ?? [], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts index 0952da3182e01..43ac38f447abc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts @@ -127,10 +127,13 @@ describe('utils', () => { index: undefined, interval: undefined, maxSignals: undefined, + relatedIntegrations: undefined, + requiredFields: undefined, riskScore: undefined, riskScoreMapping: undefined, ruleNameOverride: undefined, name: undefined, + setup: undefined, severity: undefined, severityMapping: undefined, tags: undefined, @@ -179,10 +182,13 @@ describe('utils', () => { index: undefined, interval: undefined, maxSignals: undefined, + relatedIntegrations: undefined, + requiredFields: undefined, riskScore: undefined, riskScoreMapping: undefined, ruleNameOverride: undefined, name: undefined, + setup: undefined, severity: undefined, severityMapping: undefined, tags: undefined, @@ -231,10 +237,13 @@ describe('utils', () => { index: undefined, interval: undefined, maxSignals: undefined, + relatedIntegrations: undefined, + requiredFields: undefined, riskScore: undefined, riskScoreMapping: undefined, ruleNameOverride: undefined, name: undefined, + setup: undefined, severity: undefined, severityMapping: undefined, tags: undefined, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts index dd25676a758e4..4ac138e1629f3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts @@ -56,7 +56,10 @@ import { TimestampOverrideOrUndefined, EventCategoryOverrideOrUndefined, NamespaceOrUndefined, -} from '../../../../common/detection_engine/schemas/common/schemas'; + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, +} from '../../../../common/detection_engine/schemas/common'; import { PartialFilter } from '../types'; import { RuleParams } from '../schemas/rule_schemas'; import { @@ -107,11 +110,14 @@ export interface UpdateProperties { index: IndexOrUndefined; interval: IntervalOrUndefined; maxSignals: MaxSignalsOrUndefined; + relatedIntegrations: RelatedIntegrationArray | undefined; + requiredFields: RequiredFieldArray | undefined; riskScore: RiskScoreOrUndefined; riskScoreMapping: RiskScoreMappingOrUndefined; ruleNameOverride: RuleNameOverrideOrUndefined; outputIndex: OutputIndexOrUndefined; name: NameOrUndefined; + setup: SetupGuide | undefined; severity: SeverityOrUndefined; severityMapping: SeverityMappingOrUndefined; tags: TagsOrUndefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts index fd80bec1f6ad9..356436058b55c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts @@ -161,6 +161,9 @@ export const convertCreateAPIToInternalSchema = ( note: input.note, version: input.version ?? 1, exceptionsList: input.exceptions_list ?? [], + relatedIntegrations: [], + requiredFields: [], + setup: '', ...typeSpecificParams, }, schedule: { interval: input.interval ?? '5m' }, @@ -276,6 +279,9 @@ export const commonParamsCamelToSnake = (params: BaseRuleParams) => { version: params.version, exceptions_list: params.exceptionsList, immutable: params.immutable, + related_integrations: params.relatedIntegrations ?? [], + required_fields: params.requiredFields ?? [], + setup: params.setup ?? '', }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts index edaacf38d7712..9e3fa6a906da9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.mock.ts @@ -51,6 +51,9 @@ const getBaseRuleParams = (): BaseRuleParams => { threat: getThreatMock(), version: 1, exceptionsList: getListArrayMock(), + relatedIntegrations: [], + requiredFields: [], + setup: '', }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts index 47e49e5f9c467..d1776136f6513 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts @@ -72,7 +72,10 @@ import { updatedByOrNull, created_at, updated_at, -} from '../../../../common/detection_engine/schemas/common/schemas'; + RelatedIntegrationArray, + RequiredFieldArray, + SetupGuide, +} from '../../../../common/detection_engine/schemas/common'; import { SERVER_APP_ID } from '../../../../common/constants'; const nonEqlLanguages = t.keyof({ kuery: null, lucene: null }); @@ -105,6 +108,9 @@ export const baseRuleParams = t.exact( references, version, exceptionsList: listArray, + relatedIntegrations: t.union([RelatedIntegrationArray, t.undefined]), + requiredFields: t.union([RequiredFieldArray, t.undefined]), + setup: t.union([SetupGuide, t.undefined]), }) ); export type BaseRuleParams = t.TypeOf; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts index 9213d6c5b278c..03074b9560553 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -157,6 +157,9 @@ export const expectedRule = (): RulesSchema => { timeline_id: 'some-timeline-id', timeline_title: 'some-timeline-title', exceptions_list: getListArrayMock(), + related_integrations: [], + required_fields: [], + setup: '', }; }; @@ -624,6 +627,9 @@ export const sampleSignalHit = (): SignalHit => ({ rule_id: 'query-rule-id', interval: '5m', exceptions_list: getListArrayMock(), + related_integrations: [], + required_fields: [], + setup: '', }, depth: 1, }, @@ -685,6 +691,9 @@ export const sampleThresholdSignalHit = (): SignalHit => ({ rule_id: 'query-rule-id', interval: '5m', exceptions_list: getListArrayMock(), + related_integrations: [], + required_fields: [], + setup: '', }, depth: 1, }, diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/create_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/create_rules.ts index 7d32af43d1913..aff63d635c976 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/create_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/create_rules.ts @@ -89,6 +89,9 @@ export default ({ getService }: FtrProviderContext) => { name: 'Simple Rule Query', query: 'user.name: root or user.name: admin', references: [], + related_integrations: [], + required_fields: [], + setup: '', severity: 'high', severity_mapping: [], updated_by: 'elastic', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_rules.ts index 1b7e22fb21c57..966420c90b8d2 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_rules.ts @@ -171,6 +171,9 @@ export default ({ getService }: FtrProviderContext) => { name: 'Simple Rule Query', query: 'user.name: root or user.name: admin', references: [], + related_integrations: [], + required_fields: [], + setup: '', severity: 'high', severity_mapping: [], updated_by: 'elastic', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts index 865185387c57c..5382ba5fd18f4 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts @@ -353,6 +353,9 @@ export default ({ getService }: FtrProviderContext) => { language: 'kuery', index: ['.siem-signals-*'], query: '*:*', + related_integrations: [], + required_fields: [], + setup: '', }, 'kibana.alert.rule.actions': [], 'kibana.alert.rule.created_by': 'elastic', @@ -518,6 +521,9 @@ export default ({ getService }: FtrProviderContext) => { language: 'kuery', index: ['.alerts-security.alerts-default'], query: '*:*', + related_integrations: [], + required_fields: [], + setup: '', }, 'kibana.alert.rule.actions': [], 'kibana.alert.rule.created_by': 'elastic', diff --git a/x-pack/test/detection_engine_api_integration/utils/get_complex_rule_output.ts b/x-pack/test/detection_engine_api_integration/utils/get_complex_rule_output.ts index 98fdfa99cbd3c..81a169636605b 100644 --- a/x-pack/test/detection_engine_api_integration/utils/get_complex_rule_output.ts +++ b/x-pack/test/detection_engine_api_integration/utils/get_complex_rule_output.ts @@ -97,4 +97,7 @@ export const getComplexRuleOutput = (ruleId = 'rule-1'): Partial => version: 1, query: 'user.name: root or user.name: admin', exceptions_list: [], + related_integrations: [], + required_fields: [], + setup: '', }); diff --git a/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_output.ts b/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_output.ts index 30dc7eecb9256..ca8b04e66f3fc 100644 --- a/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_output.ts +++ b/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_output.ts @@ -26,11 +26,14 @@ export const getSimpleRuleOutput = (ruleId = 'rule-1', enabled = false): Partial language: 'kuery', output_index: '.siem-signals-default', max_signals: 100, + related_integrations: [], + required_fields: [], risk_score: 1, risk_score_mapping: [], name: 'Simple Rule Query', query: 'user.name: root or user.name: admin', references: [], + setup: '', severity: 'high', severity_mapping: [], updated_by: 'elastic', From 383239e77c165bfb77100a68c4a92c4ed7fb8fe2 Mon Sep 17 00:00:00 2001 From: Kfir Peled <61654899+kfirpeled@users.noreply.github.com> Date: Sun, 22 May 2022 13:18:42 +0300 Subject: [PATCH 38/39] [Cloud Posture] Findings - Group by resource - Fixed bug not showing results (#132529) --- .../findings_by_resource_table.test.tsx | 30 ++++++++---- .../findings_by_resource_table.tsx | 48 +++++++++++++++---- .../use_findings_by_resource.ts | 34 +++++++++---- .../create_indices/latest_findings_mapping.ts | 26 +++++++--- 4 files changed, 103 insertions(+), 35 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx index a6b8f3b863401..9cc87d98e54f8 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx @@ -21,14 +21,23 @@ import { TestProvider } from '../../../test/test_provider'; const chance = new Chance(); -const getFakeFindingsByResource = (): CspFindingsByResource => ({ - resource_id: chance.guid(), - cis_sections: [chance.word(), chance.word()], - failed_findings: { - total: chance.integer(), - normalized: chance.integer({ min: 0, max: 1 }), - }, -}); +const getFakeFindingsByResource = (): CspFindingsByResource => { + const count = chance.integer(); + const total = chance.integer() + count + 1; + const normalized = count / total; + + return { + resource_id: chance.guid(), + resource_name: chance.word(), + resource_subtype: chance.word(), + cis_sections: [chance.word(), chance.word()], + failed_findings: { + count, + normalized, + total_findings: total, + }, + }; +}; type TableProps = PropsOf; @@ -74,8 +83,11 @@ describe('', () => { ); expect(row).toBeInTheDocument(); expect(within(row).getByText(item.resource_id)).toBeInTheDocument(); + if (item.resource_name) expect(within(row).getByText(item.resource_name)).toBeInTheDocument(); + if (item.resource_subtype) + expect(within(row).getByText(item.resource_subtype)).toBeInTheDocument(); expect(within(row).getByText(item.cis_sections.join(', '))).toBeInTheDocument(); - expect(within(row).getByText(formatNumber(item.failed_findings.total))).toBeInTheDocument(); + expect(within(row).getByText(formatNumber(item.failed_findings.count))).toBeInTheDocument(); expect( within(row).getByText(new RegExp(numeral(item.failed_findings.normalized).format('0%'))) ).toBeInTheDocument(); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx index 2e96306ad3a69..80da922225893 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx @@ -9,12 +9,12 @@ import { EuiEmptyPrompt, EuiBasicTable, EuiTextColor, - EuiFlexGroup, - EuiFlexItem, type EuiTableFieldDataColumnType, type CriteriaWithPagination, type Pagination, + EuiToolTip, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import numeral from '@elastic/numeral'; import { Link, generatePath } from 'react-router-dom'; @@ -81,6 +81,26 @@ const columns: Array> = [ ), }, + { + field: 'resource_subtype', + truncateText: true, + name: ( + + ), + }, + { + field: 'resource_name', + truncateText: true, + name: ( + + ), + }, { field: 'cis_sections', truncateText: true, @@ -102,14 +122,22 @@ const columns: Array> = [ /> ), render: (failedFindings: CspFindingsByResource['failed_findings']) => ( - - - {formatNumber(failedFindings.total)} - - - ({numeral(failedFindings.normalized).format('0%')}) - - + + <> + + {formatNumber(failedFindings.count)} + + ({numeral(failedFindings.normalized).format('0%')}) + + ), }, ]; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts index 880b2be868e6f..e2da77c8ba2a2 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts @@ -14,7 +14,7 @@ import { showErrorToast } from '../latest_findings/use_latest_findings'; import type { FindingsBaseEsQuery, FindingsQueryResult } from '../types'; // a large number to probably get all the buckets -const MAX_BUCKETS = 60 * 1000; +const MAX_BUCKETS = 1000 * 1000; interface UseResourceFindingsOptions extends FindingsBaseEsQuery { from: NonNullable; @@ -43,6 +43,8 @@ interface FindingsByResourceAggs { interface FindingsAggBucket extends estypes.AggregationsStringRareTermsBucketKeys { failed_findings: estypes.AggregationsMultiBucketBase; + name: estypes.AggregationsMultiBucketAggregateBase; + subtype: estypes.AggregationsMultiBucketAggregateBase; cis_sections: estypes.AggregationsMultiBucketAggregateBase; } @@ -57,10 +59,16 @@ export const getFindingsByResourceAggQuery = ({ query, size: 0, aggs: { - resource_total: { cardinality: { field: 'resource.id.keyword' } }, + resource_total: { cardinality: { field: 'resource.id' } }, resources: { - terms: { field: 'resource.id.keyword', size: MAX_BUCKETS }, + terms: { field: 'resource.id', size: MAX_BUCKETS }, aggs: { + name: { + terms: { field: 'resource.name', size: 1 }, + }, + subtype: { + terms: { field: 'resource.sub_type', size: 1 }, + }, cis_sections: { terms: { field: 'rule.section.keyword' }, }, @@ -117,16 +125,24 @@ export const useFindingsByResource = ({ index, query, from, size }: UseResourceF ); }; -const createFindingsByResource = (bucket: FindingsAggBucket) => { - if (!Array.isArray(bucket.cis_sections.buckets)) +const createFindingsByResource = (resource: FindingsAggBucket) => { + if ( + !Array.isArray(resource.cis_sections.buckets) || + !Array.isArray(resource.name.buckets) || + !Array.isArray(resource.subtype.buckets) + ) throw new Error('expected buckets to be an array'); return { - resource_id: bucket.key, - cis_sections: bucket.cis_sections.buckets.map((v) => v.key), + resource_id: resource.key, + resource_name: resource.name.buckets.map((v) => v.key).at(0), + resource_subtype: resource.subtype.buckets.map((v) => v.key).at(0), + cis_sections: resource.cis_sections.buckets.map((v) => v.key), failed_findings: { - total: bucket.failed_findings.doc_count, - normalized: bucket.doc_count > 0 ? bucket.failed_findings.doc_count / bucket.doc_count : 0, + count: resource.failed_findings.doc_count, + normalized: + resource.doc_count > 0 ? resource.failed_findings.doc_count / resource.doc_count : 0, + total_findings: resource.doc_count, }, }; }; diff --git a/x-pack/plugins/cloud_security_posture/server/create_indices/latest_findings_mapping.ts b/x-pack/plugins/cloud_security_posture/server/create_indices/latest_findings_mapping.ts index 9ebe4c3cf4038..57305fd2df7c4 100644 --- a/x-pack/plugins/cloud_security_posture/server/create_indices/latest_findings_mapping.ts +++ b/x-pack/plugins/cloud_security_posture/server/create_indices/latest_findings_mapping.ts @@ -50,20 +50,32 @@ export const latestFindingsMapping: MappingTypeMapping = { properties: { type: { type: 'keyword', - ignore_above: 256, + ignore_above: 1024, }, id: { - type: 'text', + type: 'keyword', + ignore_above: 1024, + fields: { + text: { + type: 'text', + }, + }, }, name: { - type: 'text', + type: 'keyword', + ignore_above: 1024, + fields: { + text: { + type: 'text', + }, + }, }, sub_type: { - type: 'text', + ignore_above: 1024, + type: 'keyword', fields: { - keyword: { - ignore_above: 1024, - type: 'keyword', + text: { + type: 'text', }, }, }, From fbaf0588d0ed72ba5f1f405252b93bb6584333f8 Mon Sep 17 00:00:00 2001 From: Jiawei Wu <74562234+JiaweiWu@users.noreply.github.com> Date: Sun, 22 May 2022 17:14:23 -0700 Subject: [PATCH 39/39] [RAM] Add shareable rules list (#132437) * Shareable rules list * Hide snooze panel in rules list * Address comments and added tests * Fix tests * Fix tests * Fix lint * Address design comments and fix tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/experimental_features.ts | 4 +- .../hooks/use_load_rule_aggregations.test.ts | 111 +++ .../hooks/use_load_rule_aggregations.ts | 83 ++ .../application/hooks/use_load_rules.test.ts | 378 +++++++ .../application/hooks/use_load_rules.ts | 185 ++++ .../application/hooks/use_load_tags.test.ts | 54 + .../public/application/hooks/use_load_tags.ts | 45 + .../rule_event_log_list_sandbox.tsx | 3 +- .../rules_list_sandbox.tsx | 16 + .../shareable_components_sandbox.tsx | 2 + .../application/lib/rule_api/aggregate.ts | 20 +- .../public/application/lib/rule_api/index.ts | 2 + .../public/application/lib/rule_api/rules.ts | 24 +- .../public/application/sections/index.tsx | 3 + .../components/action_type_filter.tsx | 89 +- .../rule_execution_status_filter.tsx | 108 +- .../components/rule_status_dropdown.tsx | 100 +- .../components/rule_status_filter.test.tsx | 14 +- .../components/rule_status_filter.tsx | 33 +- .../rules_list/components/rule_tag_filter.tsx | 50 +- .../rules_list/components/rules_list.test.tsx | 90 +- .../rules_list/components/rules_list.tsx | 941 +++--------------- .../rules_list_auto_refresh.test.tsx | 87 ++ .../components/rules_list_auto_refresh.tsx | 122 +++ .../components/rules_list_notify_badge.tsx | 224 +++++ .../components/rules_list_table.tsx | 724 ++++++++++++++ .../rules_list/components/type_filter.tsx | 102 +- .../public/common/get_rules_list.tsx | 13 + .../triggers_actions_ui/public/mocks.ts | 4 + .../triggers_actions_ui/public/plugin.ts | 5 + .../apps/triggers_actions_ui/index.ts | 1 + .../triggers_actions_ui/rule_tag_filter.ts | 20 - .../apps/triggers_actions_ui/rules_list.ts | 34 + 33 files changed, 2624 insertions(+), 1067 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.test.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.test.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.test.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rules_list_sandbox.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/common/get_rules_list.tsx create mode 100644 x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts diff --git a/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts b/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts index 33f5fdc44afcd..3265469bea640 100644 --- a/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts +++ b/x-pack/plugins/triggers_actions_ui/common/experimental_features.ts @@ -15,8 +15,8 @@ export const allowedExperimentalValues = Object.freeze({ rulesListDatagrid: true, internalAlertsTable: false, internalShareableComponentsSandbox: false, - ruleTagFilter: false, - ruleStatusFilter: false, + ruleTagFilter: true, + ruleStatusFilter: true, rulesDetailLogs: true, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.test.ts new file mode 100644 index 0000000000000..b00101da6be83 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useLoadRuleAggregations } from './use_load_rule_aggregations'; +import { RuleStatus } from '../../types'; + +const MOCK_TAGS = ['a', 'b', 'c']; + +const MOCK_AGGS = { + ruleEnabledStatus: { enabled: 2, disabled: 0 }, + ruleExecutionStatus: { ok: 1, active: 2, error: 3, pending: 4, unknown: 5, warning: 6 }, + ruleMutedStatus: { muted: 0, unmuted: 2 }, + ruleTags: MOCK_TAGS, +}; + +jest.mock('../lib/rule_api', () => ({ + loadRuleAggregations: jest.fn(), +})); + +const { loadRuleAggregations } = jest.requireMock('../lib/rule_api'); + +const onError = jest.fn(); + +describe('useLoadRuleAggregations', () => { + beforeEach(() => { + loadRuleAggregations.mockResolvedValue(MOCK_AGGS); + jest.clearAllMocks(); + }); + + it('should call loadRuleAggregations API and handle result', async () => { + const params = { + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRuleAggregations({ + ...params, + onError, + }) + ); + + await act(async () => { + result.current.loadRuleAggregations(); + await waitForNextUpdate(); + }); + + expect(loadRuleAggregations).toBeCalledWith(expect.objectContaining(params)); + expect(result.current.rulesStatusesTotal).toEqual(MOCK_AGGS.ruleExecutionStatus); + }); + + it('should call loadRuleAggregation API with params and handle result', async () => { + const params = { + searchText: 'test', + typesFilter: ['type1', 'type2'], + actionTypesFilter: ['action1', 'action2'], + ruleExecutionStatusesFilter: ['status1', 'status2'], + ruleStatusesFilter: ['enabled', 'snoozed'] as RuleStatus[], + tagsFilter: ['tag1', 'tag2'], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRuleAggregations({ + ...params, + onError, + }) + ); + + await act(async () => { + result.current.loadRuleAggregations(); + await waitForNextUpdate(); + }); + + expect(loadRuleAggregations).toBeCalledWith(expect.objectContaining(params)); + expect(result.current.rulesStatusesTotal).toEqual(MOCK_AGGS.ruleExecutionStatus); + }); + + it('should call onError if API fails', async () => { + loadRuleAggregations.mockRejectedValue(''); + const params = { + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result } = renderHook(() => + useLoadRuleAggregations({ + ...params, + onError, + }) + ); + + await act(async () => { + result.current.loadRuleAggregations(); + }); + + expect(onError).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.ts new file mode 100644 index 0000000000000..75f9e18ec2328 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.ts @@ -0,0 +1,83 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { useState, useCallback, useMemo } from 'react'; +import { RuleExecutionStatusValues } from '@kbn/alerting-plugin/common'; +import { loadRuleAggregations, LoadRuleAggregationsProps } from '../lib/rule_api'; +import { useKibana } from '../../common/lib/kibana'; + +type UseLoadRuleAggregationsProps = Omit & { + onError: (message: string) => void; +}; + +export function useLoadRuleAggregations({ + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + onError, +}: UseLoadRuleAggregationsProps) { + const { http } = useKibana().services; + + const [rulesStatusesTotal, setRulesStatusesTotal] = useState>( + RuleExecutionStatusValues.reduce>( + (prev: Record, status: string) => ({ + ...prev, + [status]: 0, + }), + {} + ) + ); + + const internalLoadRuleAggregations = useCallback(async () => { + try { + const rulesAggs = await loadRuleAggregations({ + http, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + }); + if (rulesAggs?.ruleExecutionStatus) { + setRulesStatusesTotal(rulesAggs.ruleExecutionStatus); + } + } catch (e) { + onError( + i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleStatusInfoMessage', + { + defaultMessage: 'Unable to load rule status info', + } + ) + ); + } + }, [ + http, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + onError, + setRulesStatusesTotal, + ]); + + return useMemo( + () => ({ + loadRuleAggregations: internalLoadRuleAggregations, + rulesStatusesTotal, + setRulesStatusesTotal, + }), + [internalLoadRuleAggregations, rulesStatusesTotal, setRulesStatusesTotal] + ); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.test.ts new file mode 100644 index 0000000000000..a309beeca58aa --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.test.ts @@ -0,0 +1,378 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useLoadRules } from './use_load_rules'; +import { + RuleExecutionStatusErrorReasons, + RuleExecutionStatusWarningReasons, +} from '@kbn/alerting-plugin/common'; +import { RuleStatus } from '../../types'; + +jest.mock('../lib/rule_api', () => ({ + loadRules: jest.fn(), +})); + +const { loadRules } = jest.requireMock('../lib/rule_api'); + +const onError = jest.fn(); +const onPage = jest.fn(); + +const mockedRulesData = [ + { + id: '1', + name: 'test rule', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '1s' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [ + { + success: true, + duration: 1000000, + }, + { + success: true, + duration: 200000, + }, + { + success: false, + duration: 300000, + }, + ], + calculated_metrics: { + success_ratio: 0.66, + p50: 200000, + p95: 300000, + p99: 300000, + }, + }, + }, + }, + { + id: '2', + name: 'test rule ok', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'ok', + lastDuration: 61000, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [ + { + success: true, + duration: 100000, + }, + { + success: true, + duration: 500000, + }, + ], + calculated_metrics: { + success_ratio: 1, + p50: 0, + p95: 100000, + p99: 500000, + }, + }, + }, + }, + { + id: '3', + name: 'test rule pending', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'pending', + lastDuration: 30234, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [{ success: false, duration: 100 }], + calculated_metrics: { + success_ratio: 0, + }, + }, + }, + }, + { + id: '4', + name: 'test rule error', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'error', + lastDuration: 122000, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: RuleExecutionStatusErrorReasons.Unknown, + message: 'test', + }, + }, + }, + { + id: '5', + name: 'test rule license error', + tags: [], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'error', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: RuleExecutionStatusErrorReasons.License, + message: 'test', + }, + }, + }, + { + id: '6', + name: 'test rule warning', + tags: [], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'warning', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + warning: { + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: 'test', + }, + }, + }, +]; + +const MOCK_RULE_DATA = { + page: 1, + perPage: 10000, + total: 4, + data: mockedRulesData, +}; + +describe('useLoadRules', () => { + beforeEach(() => { + loadRules.mockResolvedValue(MOCK_RULE_DATA); + jest.clearAllMocks(); + }); + + it('should call loadRules API and handle result', async () => { + const params = { + page: { + index: 0, + size: 25, + }, + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + expect(result.current.initialLoad).toBeTruthy(); + expect(result.current.noData).toBeTruthy(); + expect(result.current.rulesState.isLoading).toBeFalsy(); + + await act(async () => { + result.current.loadRules(); + await waitForNextUpdate(); + }); + + expect(result.current.initialLoad).toBeFalsy(); + expect(result.current.noData).toBeFalsy(); + expect(result.current.rulesState.isLoading).toBeFalsy(); + + expect(onPage).toBeCalledTimes(0); + expect(loadRules).toBeCalledWith(expect.objectContaining(params)); + expect(result.current.rulesState.data).toEqual(expect.arrayContaining(MOCK_RULE_DATA.data)); + expect(result.current.rulesState.totalItemCount).toEqual(MOCK_RULE_DATA.total); + }); + + it('should call loadRules API with params and handle result', async () => { + const params = { + page: { + index: 0, + size: 25, + }, + searchText: 'test', + typesFilter: ['type1', 'type2'], + actionTypesFilter: ['action1', 'action2'], + ruleExecutionStatusesFilter: ['status1', 'status2'], + ruleStatusesFilter: ['enabled', 'snoozed'] as RuleStatus[], + tagsFilter: ['tag1', 'tag2'], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + await act(async () => { + result.current.loadRules(); + await waitForNextUpdate(); + }); + + expect(loadRules).toBeCalledWith(expect.objectContaining(params)); + }); + + it('should reset the page if the data is fetched while paged', async () => { + loadRules.mockResolvedValue({ + ...MOCK_RULE_DATA, + data: [], + }); + + const params = { + page: { + index: 1, + size: 25, + }, + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + await act(async () => { + result.current.loadRules(); + await waitForNextUpdate(); + }); + + expect(onPage).toHaveBeenCalledWith({ + index: 0, + size: 25, + }); + }); + + it('should call onError if API fails', async () => { + loadRules.mockRejectedValue(''); + const params = { + page: { + index: 0, + size: 25, + }, + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + await act(async () => { + result.current.loadRules(); + }); + + expect(onError).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts new file mode 100644 index 0000000000000..4afdfd4f26a72 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useMemo, useCallback, useReducer } from 'react'; +import { i18n } from '@kbn/i18n'; +import { isEmpty } from 'lodash'; +import { Rule, Pagination } from '../../types'; +import { loadRules, LoadRulesProps } from '../lib/rule_api'; +import { useKibana } from '../../common/lib/kibana'; + +interface RuleState { + isLoading: boolean; + data: Rule[]; + totalItemCount: number; +} + +type UseLoadRulesProps = Omit & { + onPage: (pagination: Pagination) => void; + onError: (message: string) => void; +}; + +interface UseLoadRulesState { + rulesState: RuleState; + noData: boolean; + initialLoad: boolean; +} + +enum ActionTypes { + SET_RULE_STATE = 'SET_RULE_STATE', + SET_LOADING = 'SET_LOADING', + SET_INITIAL_LOAD = 'SET_INITIAL_LOAD', + SET_NO_DATA = 'SET_NO_DATA', +} + +interface Action { + type: ActionTypes; + payload: boolean | RuleState; +} + +const initialState: UseLoadRulesState = { + rulesState: { + isLoading: false, + data: [], + totalItemCount: 0, + }, + noData: true, + initialLoad: true, +}; + +const reducer = (state: UseLoadRulesState, action: Action) => { + const { type, payload } = action; + switch (type) { + case ActionTypes.SET_RULE_STATE: + return { + ...state, + rulesState: payload as RuleState, + }; + case ActionTypes.SET_LOADING: + return { + ...state, + rulesState: { + ...state.rulesState, + isLoading: payload as boolean, + }, + }; + case ActionTypes.SET_INITIAL_LOAD: + return { + ...state, + initialLoad: payload as boolean, + }; + case ActionTypes.SET_NO_DATA: + return { + ...state, + noData: payload as boolean, + }; + default: + return state; + } +}; + +export function useLoadRules({ + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + onPage, + onError, +}: UseLoadRulesProps) { + const { http } = useKibana().services; + const [state, dispatch] = useReducer(reducer, initialState); + + const setRulesState = useCallback( + (rulesState: RuleState) => { + dispatch({ + type: ActionTypes.SET_RULE_STATE, + payload: rulesState, + }); + }, + [dispatch] + ); + + const internalLoadRules = useCallback(async () => { + dispatch({ type: ActionTypes.SET_LOADING, payload: true }); + + try { + const rulesResponse = await loadRules({ + http, + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + }); + + dispatch({ + type: ActionTypes.SET_RULE_STATE, + payload: { + isLoading: false, + data: rulesResponse.data, + totalItemCount: rulesResponse.total, + }, + }); + + if (!rulesResponse.data?.length && page.index > 0) { + onPage({ ...page, index: 0 }); + } + + const isFilterApplied = !( + isEmpty(searchText) && + isEmpty(typesFilter) && + isEmpty(actionTypesFilter) && + isEmpty(ruleExecutionStatusesFilter) && + isEmpty(ruleStatusesFilter) && + isEmpty(tagsFilter) + ); + + dispatch({ + type: ActionTypes.SET_NO_DATA, + payload: rulesResponse.data.length === 0 && !isFilterApplied, + }); + } catch (e) { + onError( + i18n.translate('xpack.triggersActionsUI.sections.rulesList.unableToLoadRulesMessage', { + defaultMessage: 'Unable to load rules', + }) + ); + dispatch({ type: ActionTypes.SET_LOADING, payload: false }); + } + dispatch({ type: ActionTypes.SET_INITIAL_LOAD, payload: false }); + }, [ + http, + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + dispatch, + onPage, + onError, + ]); + + return useMemo( + () => ({ + rulesState: state.rulesState, + noData: state.noData, + initialLoad: state.initialLoad, + loadRules: internalLoadRules, + setRulesState, + }), + [state, setRulesState, internalLoadRules] + ); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.test.ts new file mode 100644 index 0000000000000..8973d869e0724 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.test.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 { renderHook, act } from '@testing-library/react-hooks'; +import { useLoadTags } from './use_load_tags'; + +const MOCK_TAGS = ['a', 'b', 'c']; + +jest.mock('../lib/rule_api', () => ({ + loadRuleTags: jest.fn(), +})); + +const { loadRuleTags } = jest.requireMock('../lib/rule_api'); + +const onError = jest.fn(); + +describe('useLoadTags', () => { + beforeEach(() => { + loadRuleTags.mockResolvedValue({ + ruleTags: MOCK_TAGS, + }); + jest.clearAllMocks(); + }); + + it('should call loadRuleTags API and handle result', async () => { + const { result, waitForNextUpdate } = renderHook(() => useLoadTags({ onError })); + + await act(async () => { + result.current.loadTags(); + await waitForNextUpdate(); + }); + + expect(loadRuleTags).toBeCalled(); + expect(result.current.tags).toEqual(MOCK_TAGS); + }); + + it('should call onError if API fails', async () => { + loadRuleTags.mockRejectedValue(''); + + const { result } = renderHook(() => useLoadTags({ onError })); + + await act(async () => { + result.current.loadTags(); + }); + + expect(loadRuleTags).toBeCalled(); + expect(onError).toBeCalled(); + expect(result.current.tags).toEqual([]); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts new file mode 100644 index 0000000000000..3357f43a012f1 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { useState, useCallback, useMemo } from 'react'; +import { loadRuleTags } from '../lib/rule_api'; +import { useKibana } from '../../common/lib/kibana'; + +interface UseLoadTagsProps { + onError: (message: string) => void; +} + +export function useLoadTags(props: UseLoadTagsProps) { + const { onError } = props; + const { http } = useKibana().services; + const [tags, setTags] = useState([]); + + const internalLoadTags = useCallback(async () => { + try { + const ruleTagsAggs = await loadRuleTags({ http }); + if (ruleTagsAggs?.ruleTags) { + setTags(ruleTagsAggs.ruleTags); + } + } catch (e) { + onError( + i18n.translate('xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleTags', { + defaultMessage: 'Unable to load rule tags', + }) + ); + } + }, [http, setTags, onError]); + + return useMemo( + () => ({ + tags, + loadTags: internalLoadTags, + setTags, + }), + [tags, internalLoadTags, setTags] + ); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_event_log_list_sandbox.tsx b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_event_log_list_sandbox.tsx index 4af95523dce29..ba45800e49bcb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_event_log_list_sandbox.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rule_event_log_list_sandbox.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import React from 'react'; import { getRuleEventLogListLazy } from '../../../common/get_rule_event_log_list'; export const RuleEventLogListSandbox = () => { @@ -39,5 +40,5 @@ export const RuleEventLogListSandbox = () => { }), }; - return getRuleEventLogListLazy(props); + return
{getRuleEventLogListLazy(props)}
; }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rules_list_sandbox.tsx b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rules_list_sandbox.tsx new file mode 100644 index 0000000000000..7702b914cfd36 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/rules_list_sandbox.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { getRulesListLazy } from '../../../common/get_rules_list'; + +const style = { + flex: 1, +}; + +export const RulesListSandbox = () => { + return
{getRulesListLazy()}
; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx index af5a05acdf19a..018f0a8794c33 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/internal/shareable_components_sandbox/shareable_components_sandbox.tsx @@ -11,6 +11,7 @@ import { RuleTagFilterSandbox } from './rule_tag_filter_sandbox'; import { RuleStatusFilterSandbox } from './rule_status_filter_sandbox'; import { RuleTagBadgeSandbox } from './rule_tag_badge_sandbox'; import { RuleEventLogListSandbox } from './rule_event_log_list_sandbox'; +import { RulesListSandbox } from './rules_list_sandbox'; export const InternalShareableComponentsSandbox: React.FC<{}> = () => { return ( @@ -19,6 +20,7 @@ export const InternalShareableComponentsSandbox: React.FC<{}> = () => { + ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts index 1df6177443657..5df7cfc374f89 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/aggregate.ts @@ -44,6 +44,16 @@ export async function loadRuleTags({ http }: { http: HttpSetup }): Promise { +}: LoadRuleAggregationsProps): Promise { const filters = mapFiltersToKql({ typesFilter, actionTypesFilter, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts index d0e7728498c5b..64d6b18b7ca5c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts @@ -7,6 +7,7 @@ export { alertingFrameworkHealth } from './health'; export { mapFiltersToKql } from './map_filters_to_kql'; +export type { LoadRuleAggregationsProps } from './aggregate'; export { loadRuleAggregations, loadRuleTags } from './aggregate'; export { createRule } from './create'; export { deleteRules } from './delete'; @@ -17,6 +18,7 @@ export { loadRuleSummary } from './rule_summary'; export { muteAlertInstance } from './mute_alert'; export { muteRule, muteRules } from './mute'; export { loadRuleTypes } from './rule_types'; +export type { LoadRulesProps } from './rules'; export { loadRules } from './rules'; export { loadRuleState } from './state'; export type { LoadExecutionLogAggregationsProps } from './load_execution_log_aggregations'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts index 6e527989cc91f..3db1cb8b0214d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rules.ts @@ -11,6 +11,18 @@ import { Rule, Pagination, Sorting, RuleStatus } from '../../../types'; import { mapFiltersToKql } from './map_filters_to_kql'; import { transformRule } from './common_transformations'; +export interface LoadRulesProps { + http: HttpSetup; + page: Pagination; + searchText?: string; + typesFilter?: string[]; + actionTypesFilter?: string[]; + tagsFilter?: string[]; + ruleExecutionStatusesFilter?: string[]; + ruleStatusesFilter?: RuleStatus[]; + sort?: Sorting; +} + const rewriteResponseRes = (results: Array>): Rule[] => { return results.map((item) => transformRule(item)); }; @@ -25,17 +37,7 @@ export async function loadRules({ ruleStatusesFilter, tagsFilter, sort = { field: 'name', direction: 'asc' }, -}: { - http: HttpSetup; - page: Pagination; - searchText?: string; - typesFilter?: string[]; - actionTypesFilter?: string[]; - tagsFilter?: string[]; - ruleExecutionStatusesFilter?: string[]; - ruleStatusesFilter?: RuleStatus[]; - sort?: Sorting; -}): Promise<{ +}: LoadRulesProps): Promise<{ page: number; perPage: number; total: number; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx index 979630d2a5a99..bd2ef041535f3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/index.tsx @@ -44,3 +44,6 @@ export const RuleTagBadge = suspendedComponentWithProps( export const RuleEventLogList = suspendedComponentWithProps( lazy(() => import('./rule_details/components/rule_event_log_list')) ); +export const RulesList = suspendedComponentWithProps( + lazy(() => import('./rules_list/components/rules_list')) +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx index a136413d53e42..38d1a62de699a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiFilterGroup, EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; +import { EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; import { ActionType } from '../../../../types'; interface ActionTypeFilterProps { @@ -29,47 +29,52 @@ export const ActionTypeFilter: React.FunctionComponent = // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedValues]); + const onClick = useCallback( + (item: ActionType) => { + return () => { + const isPreviouslyChecked = selectedValues.includes(item.id); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter((val) => val !== item.id)); + } else { + setSelectedValues(selectedValues.concat(item.id)); + } + }; + }, + [selectedValues, setSelectedValues] + ); + return ( - - setIsPopoverOpen(false)} - button={ - 0} - numActiveFilters={selectedValues.length} - numFilters={selectedValues.length} - onClick={() => setIsPopoverOpen(!isPopoverOpen)} - data-test-subj="actionTypeFilterButton" + setIsPopoverOpen(false)} + button={ + 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + data-test-subj="actionTypeFilterButton" + > + + + } + > +
+ {actionTypes.map((item) => ( + - - - } - > -
- {actionTypes.map((item) => ( - { - const isPreviouslyChecked = selectedValues.includes(item.id); - if (isPreviouslyChecked) { - setSelectedValues(selectedValues.filter((val) => val !== item.id)); - } else { - setSelectedValues(selectedValues.concat(item.id)); - } - }} - checked={selectedValues.includes(item.id) ? 'on' : undefined} - data-test-subj={`actionType${item.id}FilterOption`} - > - {item.name} - - ))} -
- - + {item.name} +
+ ))} +
+
); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx index 9acb8489fa09a..e5bb7ffd1b0e4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx @@ -5,15 +5,9 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiFilterGroup, - EuiPopover, - EuiFilterButton, - EuiFilterSelectItem, - EuiHealth, -} from '@elastic/eui'; +import { EuiPopover, EuiFilterButton, EuiFilterSelectItem, EuiHealth } from '@elastic/eui'; import { RuleExecutionStatuses, RuleExecutionStatusValues } from '@kbn/alerting-plugin/common'; import { rulesStatusesTranslationsMapping } from '../translations'; @@ -22,6 +16,8 @@ interface RuleExecutionStatusFilterProps { onChange?: (selectedRuleStatusesIds: string[]) => void; } +const sortedRuleExecutionStatusValues = [...RuleExecutionStatusValues].sort(); + export const RuleExecutionStatusFilter: React.FunctionComponent = ({ selectedStatuses, onChange, @@ -29,6 +25,14 @@ export const RuleExecutionStatusFilter: React.FunctionComponent(selectedStatuses); const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const onTogglePopover = useCallback(() => { + setIsPopoverOpen((prevIsPopoverOpen) => !prevIsPopoverOpen); + }, [setIsPopoverOpen]); + + const onClosePopover = useCallback(() => { + setIsPopoverOpen(false); + }, [setIsPopoverOpen]); + useEffect(() => { if (onChange) { onChange(selectedValues); @@ -41,51 +45,49 @@ export const RuleExecutionStatusFilter: React.FunctionComponent - setIsPopoverOpen(false)} - button={ - 0} - numActiveFilters={selectedValues.length} - numFilters={selectedValues.length} - onClick={() => setIsPopoverOpen(!isPopoverOpen)} - data-test-subj="ruleExecutionStatusFilterButton" - > - - - } - > -
- {[...RuleExecutionStatusValues].sort().map((item: RuleExecutionStatuses) => { - const healthColor = getHealthColor(item); - return ( - { - const isPreviouslyChecked = selectedValues.includes(item); - if (isPreviouslyChecked) { - setSelectedValues(selectedValues.filter((val) => val !== item)); - } else { - setSelectedValues(selectedValues.concat(item)); - } - }} - checked={selectedValues.includes(item) ? 'on' : undefined} - data-test-subj={`ruleExecutionStatus${item}FilterOption`} - > - {rulesStatusesTranslationsMapping[item]} - - ); - })} -
-
-
+ 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={onTogglePopover} + data-test-subj="ruleExecutionStatusFilterButton" + > + + + } + > +
+ {sortedRuleExecutionStatusValues.map((item: RuleExecutionStatuses) => { + const healthColor = getHealthColor(item); + return ( + { + const isPreviouslyChecked = selectedValues.includes(item); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter((val) => val !== item)); + } else { + setSelectedValues(selectedValues.concat(item)); + } + }} + checked={selectedValues.includes(item) ? 'on' : undefined} + data-test-subj={`ruleExecutionStatus${item}FilterOption`} + > + {rulesStatusesTranslationsMapping[item]} + + ); + })} +
+
); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx index 7c6a71e893f96..194bf86030e56 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_dropdown.tsx @@ -33,7 +33,7 @@ import { parseInterval } from '../../../../../common'; import { Rule } from '../../../../types'; -type SnoozeUnit = 'm' | 'h' | 'd' | 'w' | 'M'; +export type SnoozeUnit = 'm' | 'h' | 'd' | 'w' | 'M'; const SNOOZE_END_TIME_FORMAT = 'LL @ LT'; type DropdownRuleRecord = Pick; @@ -48,6 +48,7 @@ export interface ComponentOpts { isEditable: boolean; previousSnoozeInterval?: string | null; direction?: 'column' | 'row'; + hideSnoozeOption?: boolean; } const COMMON_SNOOZE_TIMES: Array<[number, SnoozeUnit]> = [ @@ -58,9 +59,9 @@ const COMMON_SNOOZE_TIMES: Array<[number, SnoozeUnit]> = [ ]; const PREV_SNOOZE_INTERVAL_KEY = 'triggersActionsUi_previousSnoozeInterval'; -const usePreviousSnoozeInterval: (p?: string | null) => [string | null, (n: string) => void] = ( - propsInterval -) => { +export const usePreviousSnoozeInterval: ( + p?: string | null +) => [string | null, (n: string) => void] = (propsInterval) => { const intervalFromStorage = localStorage.getItem(PREV_SNOOZE_INTERVAL_KEY); const usePropsInterval = typeof propsInterval !== 'undefined'; const interval = usePropsInterval ? propsInterval : intervalFromStorage; @@ -74,7 +75,7 @@ const usePreviousSnoozeInterval: (p?: string | null) => [string | null, (n: stri return [previousSnoozeInterval, storeAndSetPreviousSnoozeInterval]; }; -const isRuleSnoozed = (rule: { isSnoozedUntil?: Date | null; muteAll: boolean }) => +export const isRuleSnoozed = (rule: { isSnoozedUntil?: Date | null; muteAll: boolean }) => Boolean( (rule.isSnoozedUntil && new Date(rule.isSnoozedUntil).getTime() > Date.now()) || rule.muteAll ); @@ -88,6 +89,7 @@ export const RuleStatusDropdown: React.FunctionComponent = ({ unsnoozeRule, isEditable, previousSnoozeInterval: propsPreviousSnoozeInterval, + hideSnoozeOption = false, direction = 'column', }: ComponentOpts) => { const [isEnabled, setIsEnabled] = useState(rule.enabled); @@ -224,6 +226,7 @@ export const RuleStatusDropdown: React.FunctionComponent = ({ isSnoozed={isSnoozed} snoozeEndTime={rule.isSnoozedUntil} previousSnoozeInterval={previousSnoozeInterval} + hideSnoozeOption={hideSnoozeOption} />
) : ( @@ -245,6 +248,7 @@ interface RuleStatusMenuProps { isSnoozed: boolean; snoozeEndTime?: Date | null; previousSnoozeInterval: string | null; + hideSnoozeOption?: boolean; } const RuleStatusMenu: React.FunctionComponent = ({ @@ -255,6 +259,7 @@ const RuleStatusMenu: React.FunctionComponent = ({ isSnoozed, snoozeEndTime, previousSnoozeInterval, + hideSnoozeOption = false, }) => { const enableRule = useCallback(() => { if (isSnoozed) { @@ -290,6 +295,44 @@ const RuleStatusMenu: React.FunctionComponent = ({ ); } + const getSnoozeMenuItem = () => { + if (!hideSnoozeOption) { + return [ + { + name: snoozeButtonTitle, + icon: isEnabled && isSnoozed ? 'check' : 'empty', + panel: 1, + disabled: !isEnabled, + 'data-test-subj': 'statusDropdownSnoozeItem', + }, + ]; + } + return []; + }; + + const getSnoozePanel = () => { + if (!hideSnoozeOption) { + return [ + { + id: 1, + width: 360, + title: SNOOZE, + content: ( + + + + ), + }, + ]; + } + return []; + }; + const panels = [ { id: 0, @@ -307,28 +350,10 @@ const RuleStatusMenu: React.FunctionComponent = ({ onClick: disableRule, 'data-test-subj': 'statusDropdownDisabledItem', }, - { - name: snoozeButtonTitle, - icon: isEnabled && isSnoozed ? 'check' : 'empty', - panel: 1, - disabled: !isEnabled, - 'data-test-subj': 'statusDropdownSnoozeItem', - }, + ...getSnoozeMenuItem(), ], }, - { - id: 1, - width: 360, - title: SNOOZE, - content: ( - - ), - }, + ...getSnoozePanel(), ]; return ; @@ -336,13 +361,15 @@ const RuleStatusMenu: React.FunctionComponent = ({ interface SnoozePanelProps { interval?: string; + isLoading?: boolean; applySnooze: (value: number | -1, unit?: SnoozeUnit) => void; showCancel: boolean; previousSnoozeInterval: string | null; } -const SnoozePanel: React.FunctionComponent = ({ +export const SnoozePanel: React.FunctionComponent = ({ interval = '3d', + isLoading = false, applySnooze, showCancel, previousSnoozeInterval, @@ -394,9 +421,9 @@ const SnoozePanel: React.FunctionComponent = ({ ); return ( - + <> - + = ({ /> - + {i18n.translate('xpack.triggersActionsUI.sections.rulesList.applySnooze', { defaultMessage: 'Apply', })} @@ -471,7 +502,12 @@ const SnoozePanel: React.FunctionComponent = ({ - + Cancel snooze @@ -479,11 +515,11 @@ const SnoozePanel: React.FunctionComponent = ({ )} - + ); }; -const futureTimeToInterval = (time?: Date | null) => { +export const futureTimeToInterval = (time?: Date | null) => { if (!time) return; const relativeTime = moment(time).locale('en').fromNow(true); const [valueStr, unitStr] = relativeTime.split(' '); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.test.tsx index f1f2957f9cada..a7d3bdfb8e2e0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.test.tsx @@ -7,12 +7,12 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; -import { EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; +import { EuiFilterButton, EuiSelectableListItem } from '@elastic/eui'; import { RuleStatusFilter } from './rule_status_filter'; const onChangeMock = jest.fn(); -describe('rule_state_filter', () => { +describe('RuleStatusFilter', () => { beforeEach(() => { onChangeMock.mockReset(); }); @@ -22,7 +22,7 @@ describe('rule_state_filter', () => { ); - expect(wrapper.find(EuiFilterSelectItem).exists()).toBeFalsy(); + expect(wrapper.find(EuiSelectableListItem).exists()).toBeFalsy(); expect(wrapper.find(EuiFilterButton).exists()).toBeTruthy(); expect(wrapper.find('.euiNotificationBadge').text()).toEqual('0'); @@ -37,7 +37,7 @@ describe('rule_state_filter', () => { wrapper.find(EuiFilterButton).simulate('click'); - const statusItems = wrapper.find(EuiFilterSelectItem); + const statusItems = wrapper.find(EuiSelectableListItem); expect(statusItems.length).toEqual(3); }); @@ -48,17 +48,17 @@ describe('rule_state_filter', () => { wrapper.find(EuiFilterButton).simulate('click'); - wrapper.find(EuiFilterSelectItem).at(0).simulate('click'); + wrapper.find(EuiSelectableListItem).at(0).simulate('click'); expect(onChangeMock).toHaveBeenCalledWith(['enabled']); wrapper.setProps({ selectedStatuses: ['enabled'], }); - wrapper.find(EuiFilterSelectItem).at(0).simulate('click'); + wrapper.find(EuiSelectableListItem).at(0).simulate('click'); expect(onChangeMock).toHaveBeenCalledWith([]); - wrapper.find(EuiFilterSelectItem).at(1).simulate('click'); + wrapper.find(EuiSelectableListItem).at(1).simulate('click'); expect(onChangeMock).toHaveBeenCalledWith(['enabled', 'disabled']); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx index 6d286ec6d09d7..f26b3f54c587e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_status_filter.tsx @@ -6,7 +6,13 @@ */ import React, { useState, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiFilterButton, EuiPopover, EuiFilterGroup, EuiFilterSelectItem } from '@elastic/eui'; +import { + EuiFilterButton, + EuiPopover, + EuiFilterGroup, + EuiSelectableListItem, + EuiButtonEmpty, +} from '@elastic/eui'; import { RuleStatus } from '../../../../types'; const statuses: RuleStatus[] = ['enabled', 'disabled', 'snoozed']; @@ -53,6 +59,24 @@ export const RuleStatusFilter = (props: RuleStatusFilterProps) => { setIsPopoverOpen((prevIsOpen) => !prevIsOpen); }, [setIsPopoverOpen]); + const renderClearAll = () => { + return ( +
+ onChange([])} + > + Clear all + +
+ ); + }; + return ( { > } @@ -77,7 +101,7 @@ export const RuleStatusFilter = (props: RuleStatusFilterProps) => {
{statuses.map((status) => { return ( - { checked={selectedStatuses.includes(status) ? 'on' : undefined} > {status} - + ); })} + {renderClearAll()}
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx index 636bcaf1acb22..47b93ff19c6ea 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_tag_filter.tsx @@ -9,7 +9,6 @@ import React, { useMemo, useState, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiSelectable, - EuiFilterGroup, EuiFilterButton, EuiPopover, EuiSelectableProps, @@ -103,29 +102,32 @@ export const RuleTagFilter = (props: RuleTagFilterProps) => { }; return ( - - - - {(list, search) => ( - <> - {search} - - {list} - - )} - - - + + + {(list, search) => ( + <> + {search} + + {list} + + )} + + ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx index 7827033138fbb..893d6cf7bc5ad 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx @@ -365,7 +365,7 @@ describe('Update Api Key', () => { fireEvent.click(screen.getByText('Update')); }); expect(updateAPIKey).toHaveBeenCalledWith(expect.objectContaining({ id: '2' })); - expect(loadRules).toHaveBeenCalledTimes(2); + expect(loadRules).toHaveBeenCalledTimes(3); expect(screen.queryByText("You can't recover the old API key")).not.toBeInTheDocument(); expect(addSuccess).toHaveBeenCalledWith('API key has been updated'); }); @@ -390,7 +390,7 @@ describe('Update Api Key', () => { fireEvent.click(screen.getByText('Update')); }); expect(updateAPIKey).toHaveBeenCalledWith(expect.objectContaining({ id: '2' })); - expect(loadRules).toHaveBeenCalledTimes(2); + expect(loadRules).toHaveBeenCalledTimes(3); expect( screen.queryByText('You will not be able to recover the old API key') ).not.toBeInTheDocument(); @@ -514,7 +514,6 @@ describe('rules_list component with items', () => { // eslint-disable-next-line react-hooks/rules-of-hooks useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; wrapper = mountWithIntl(); - await act(async () => { await nextTick(); wrapper.update(); @@ -561,7 +560,7 @@ describe('rules_list component with items', () => { .simulate('mouseOver'); // Run the timers so the EuiTooltip will be visible - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect(wrapper.find('.euiToolTipPopover').text()).toBe('Start time of the last run.'); @@ -580,7 +579,7 @@ describe('rules_list component with items', () => { wrapper.find('[data-test-subj="ruleInterval-config-tooltip-0"]').first().simulate('mouseOver'); // Run the timers so the EuiTooltip will be visible - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect(wrapper.find('.euiToolTipPopover').text()).toBe( @@ -605,7 +604,7 @@ describe('rules_list component with items', () => { wrapper.find('[data-test-subj="rulesTableCell-durationTooltip"]').first().simulate('mouseOver'); // Run the timers so the EuiTooltip will be visible - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect(wrapper.find('.euiToolTipPopover').text()).toBe( @@ -627,7 +626,7 @@ describe('rules_list component with items', () => { wrapper.find('EuiButtonEmpty[data-test-subj="ruleStatus-error-license-fix"]').length ).toEqual(1); - expect(wrapper.find('[data-test-subj="refreshRulesButton"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="rulesListAutoRefresh"]').exists()).toBeTruthy(); expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-error"]').first().text()).toEqual( 'Error' @@ -724,7 +723,7 @@ describe('rules_list component with items', () => { .first() .simulate('click'); - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); // Percentile Selection @@ -740,7 +739,7 @@ describe('rules_list component with items', () => { // Select P95 percentileOptions.at(1).simulate('click'); - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect( @@ -795,18 +794,6 @@ describe('rules_list component with items', () => { jest.clearAllMocks(); }); - it('loads rules when refresh button is clicked', async () => { - await setup(); - wrapper.find('[data-test-subj="refreshRulesButton"]').first().simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(loadRules).toHaveBeenCalled(); - }); - it('renders license errors and manage license modal on click', async () => { global.open = jest.fn(); await setup(); @@ -854,7 +841,7 @@ describe('rules_list component with items', () => { it('sorts rules when clicking the status control column', async () => { await setup(); wrapper - .find('[data-test-subj="tableHeaderCell_enabled_8"] .euiTableHeaderButton') + .find('[data-test-subj="tableHeaderCell_enabled_9"] .euiTableHeaderButton') .first() .simulate('click'); @@ -923,21 +910,37 @@ describe('rules_list component with items', () => { loadRules.mockReset(); await setup(); - expect(loadRules.mock.calls[0][0].ruleStatusesFilter).toEqual([]); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: [], + }) + ); wrapper.find('[data-test-subj="ruleStatusFilterButton"] button').simulate('click'); wrapper.find('[data-test-subj="ruleStatusFilterOption-enabled"]').first().simulate('click'); - expect(loadRules.mock.calls[1][0].ruleStatusesFilter).toEqual(['enabled']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: ['enabled'], + }) + ); wrapper.find('[data-test-subj="ruleStatusFilterOption-snoozed"]').first().simulate('click'); - expect(loadRules.mock.calls[2][0].ruleStatusesFilter).toEqual(['enabled', 'snoozed']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: ['enabled', 'snoozed'], + }) + ); wrapper.find('[data-test-subj="ruleStatusFilterOption-snoozed"]').first().simulate('click'); - expect(loadRules.mock.calls[3][0].ruleStatusesFilter).toEqual(['enabled']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: ['enabled'], + }) + ); }); it('does not render the tag filter is the feature flag is off', async () => { @@ -956,7 +959,11 @@ describe('rules_list component with items', () => { loadRules.mockReset(); await setup(); - expect(loadRules.mock.calls[0][0].tagsFilter).toEqual([]); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + tagsFilter: [], + }) + ); wrapper.find('[data-test-subj="ruleTagFilterButton"] button').simulate('click'); @@ -967,11 +974,19 @@ describe('rules_list component with items', () => { tagFilterListItems.at(0).simulate('click'); - expect(loadRules.mock.calls[1][0].tagsFilter).toEqual(['a']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + tagsFilter: ['a'], + }) + ); tagFilterListItems.at(1).simulate('click'); - expect(loadRules.mock.calls[2][0].tagsFilter).toEqual(['a', 'b']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + tagsFilter: ['a', 'b'], + }) + ); }); }); @@ -1255,4 +1270,21 @@ describe('rules_list with disabled items', () => { wrapper.find('EuiIconTip[data-test-subj="ruleDisabledByLicenseTooltip"]').props().content ).toEqual('This rule type requires a Platinum license.'); }); + + it('clicking the notify badge shows the snooze panel', async () => { + await setup(); + + expect(wrapper.find('[data-test-subj="snoozePanel"]').exists()).toBeFalsy(); + + wrapper + .find('[data-test-subj="rulesTableCell-rulesListNotify"]') + .first() + .simulate('mouseenter'); + + expect(wrapper.find('[data-test-subj="rulesListNotifyBadge"]').exists()).toBeTruthy(); + + wrapper.find('[data-test-subj="rulesListNotifyBadge"]').first().simulate('click'); + + expect(wrapper.find('[data-test-subj="snoozePanel"]').exists()).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index 9c3f1415e6641..b8afb2d3124ef 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -8,49 +8,36 @@ /* eslint-disable react-hooks/exhaustive-deps */ import { i18n } from '@kbn/i18n'; -import { capitalize, sortBy } from 'lodash'; import moment from 'moment'; +import { capitalize, sortBy } from 'lodash'; import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useEffect, useState, useMemo, ReactNode, useCallback } from 'react'; +import React, { useEffect, useState, ReactNode, useCallback, useMemo } from 'react'; import { - EuiBasicTable, EuiButton, EuiFieldSearch, EuiFlexGroup, EuiFlexItem, - EuiIconTip, + EuiFilterGroup, EuiSpacer, EuiLink, EuiEmptyPrompt, - EuiButtonEmpty, EuiHealth, EuiText, - EuiToolTip, EuiTableSortingType, EuiButtonIcon, EuiHorizontalRule, EuiSelectableOption, EuiIcon, - EuiScreenReaderOnly, - RIGHT_ALIGNMENT, EuiDescriptionList, - EuiTableFieldDataColumnType, - EuiTableComputedColumnType, - EuiTableActionsColumnType, EuiCallOut, } from '@elastic/eui'; import { EuiSelectableOptionCheckedType } from '@elastic/eui/src/components/selectable/selectable_option'; import { useHistory } from 'react-router-dom'; -import { isEmpty } from 'lodash'; import { RuleExecutionStatus, - RuleExecutionStatusValues, ALERTS_FEATURE_ID, RuleExecutionStatusErrorReasons, - formatDuration, - parseDuration, - MONITORING_HISTORY_LIMIT, } from '@kbn/alerting-plugin/common'; import { ActionType, @@ -69,11 +56,8 @@ import { RuleQuickEditButtonsWithApi as RuleQuickEditButtons } from '../../commo import { CollapsedItemActionsWithApi as CollapsedItemActions } from './collapsed_item_actions'; import { TypeFilter } from './type_filter'; import { ActionTypeFilter } from './action_type_filter'; -import { RuleExecutionStatusFilter, getHealthColor } from './rule_execution_status_filter'; +import { RuleExecutionStatusFilter } from './rule_execution_status_filter'; import { - loadRules, - loadRuleAggregations, - loadRuleTags, loadRuleTypes, disableRule, enableRule, @@ -87,23 +71,21 @@ import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capab import { routeToRuleDetails, DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants'; import { DeleteModalConfirmation } from '../../../components/delete_modal_confirmation'; import { EmptyPrompt } from '../../../components/prompts/empty_prompt'; -import { rulesStatusesTranslationsMapping, ALERT_STATUS_LICENSE_ERROR } from '../translations'; +import { ALERT_STATUS_LICENSE_ERROR } from '../translations'; import { useKibana } from '../../../../common/lib/kibana'; import { DEFAULT_HIDDEN_ACTION_TYPES } from '../../../../common/constants'; import './rules_list.scss'; import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner'; import { ManageLicenseModal } from './manage_license_modal'; -import { checkRuleTypeEnabled } from '../../../lib/check_rule_type_enabled'; -import { RuleStatusDropdown } from './rule_status_dropdown'; -import { RuleTagBadge } from './rule_tag_badge'; -import { PercentileSelectablePopover } from './percentile_selectable_popover'; -import { RuleDurationFormat } from './rule_duration_format'; -import { shouldShowDurationWarning } from '../../../lib/execution_duration_utils'; -import { getFormattedSuccessRatio } from '../../../lib/monitoring_utils'; import { triggersActionsUiConfig } from '../../../../common/lib/config_api'; import { RuleTagFilter } from './rule_tag_filter'; import { RuleStatusFilter } from './rule_status_filter'; import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; +import { useLoadRules } from '../../../hooks/use_load_rules'; +import { useLoadTags } from '../../../hooks/use_load_tags'; +import { useLoadRuleAggregations } from '../../../hooks/use_load_rule_aggregations'; +import { RulesListTable, convertRulesToTableItems } from './rules_list_table'; +import { RulesListAutoRefresh } from './rules_list_auto_refresh'; import { UpdateApiKeyModalConfirmation } from '../../../components/update_api_key_modal_confirmation'; const ENTER_KEY = 13; @@ -113,17 +95,6 @@ interface RuleTypeState { isInitialized: boolean; data: RuleTypeIndex; } -interface RuleState { - isLoading: boolean; - data: Rule[]; - totalItemCount: number; -} - -const percentileOrdinals = { - [Percentiles.P50]: '50th', - [Percentiles.P95]: '95th', - [Percentiles.P99]: '99th', -}; export const percentileFields = { [Percentiles.P50]: 'monitoring.execution.calculated_metrics.p50', @@ -149,8 +120,6 @@ export const RulesList: React.FunctionComponent = () => { } = useKibana().services; const canExecuteActions = hasExecuteActionsCapability(capabilities); - const [initialLoad, setInitialLoad] = useState(true); - const [noData, setNoData] = useState(true); const [config, setConfig] = useState({ isUsingSecurity: false }); const [actionTypes, setActionTypes] = useState([]); const [selectedIds, setSelectedIds] = useState([]); @@ -162,16 +131,15 @@ export const RulesList: React.FunctionComponent = () => { const [actionTypesFilter, setActionTypesFilter] = useState([]); const [ruleExecutionStatusesFilter, setRuleExecutionStatusesFilter] = useState([]); const [ruleStatusesFilter, setRuleStatusesFilter] = useState([]); - const [tags, setTags] = useState([]); const [tagsFilter, setTagsFilter] = useState([]); const [ruleFlyoutVisible, setRuleFlyoutVisibility] = useState(false); const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); const [currentRuleToEdit, setCurrentRuleToEdit] = useState(null); - const [tagPopoverOpenIndex, setTagPopoverOpenIndex] = useState(-1); const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState>( {} ); const [showErrors, setShowErrors] = useState(false); + const [lastUpdate, setLastUpdate] = useState(''); const isRuleTagFilterEnabled = getIsExperimentalFeatureEnabled('ruleTagFilter'); const isRuleStatusFilterEnabled = getIsExperimentalFeatureEnabled('ruleStatusFilter'); @@ -185,13 +153,6 @@ export const RulesList: React.FunctionComponent = () => { const [percentileOptions, setPercentileOptions] = useState(initialPercentileOptions); - const selectedPercentile = useMemo(() => { - const selectedOption = percentileOptions.find((option) => option.checked === 'on'); - if (selectedOption) { - return Percentiles[selectedOption.key as Percentiles]; - } - }, [percentileOptions]); - const [sort, setSort] = useState['sort']>({ field: 'name', direction: 'asc', @@ -200,27 +161,52 @@ export const RulesList: React.FunctionComponent = () => { licenseType: string; ruleTypeId: string; } | null>(null); - const [rulesStatusesTotal, setRulesStatusesTotal] = useState>( - RuleExecutionStatusValues.reduce( - (prev: Record, status: string) => - ({ - ...prev, - [status]: 0, - } as Record), - {} - ) - ); const [ruleTypesState, setRuleTypesState] = useState({ isLoading: false, isInitialized: false, data: new Map(), }); - const [rulesState, setRulesState] = useState({ - isLoading: false, - data: [], - totalItemCount: 0, - }); + const [rulesToDelete, setRulesToDelete] = useState([]); + + const hasAnyAuthorizedRuleType = useMemo(() => { + return ruleTypesState.isInitialized && ruleTypesState.data.size > 0; + }, [ruleTypesState]); + + const onError = useCallback( + (message: string) => { + toasts.addDanger(message); + }, + [toasts] + ); + + const { rulesState, setRulesState, loadRules, noData, initialLoad } = useLoadRules({ + page, + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + sort, + onPage: setPage, + onError, + }); + + const { tags, loadTags } = useLoadTags({ + onError, + }); + + const { loadRuleAggregations, rulesStatusesTotal } = useLoadRuleAggregations({ + searchText, + typesFilter, + actionTypesFilter, + ruleExecutionStatusesFilter, + ruleStatusesFilter, + tagsFilter, + onError, + }); + const [rulesToUpdateAPIKey, setRulesToUpdateAPIKey] = useState([]); const onRuleEdit = (ruleItem: RuleTableItem) => { setEditFlyoutVisibility(true); @@ -230,20 +216,30 @@ export const RulesList: React.FunctionComponent = () => { const isRuleTypeEditableInContext = (ruleTypeId: string) => ruleTypeRegistry.has(ruleTypeId) ? !ruleTypeRegistry.get(ruleTypeId).requiresAppContext : false; - useEffect(() => { - loadRulesData(); + const loadData = useCallback(async () => { + if (!ruleTypesState || !hasAnyAuthorizedRuleType) { + return; + } + await loadRules(); + await loadRuleAggregations(); + if (isRuleStatusFilterEnabled) { + await loadTags(); + } + setLastUpdate(moment().format()); }, [ + loadRules, + loadTags, + loadRuleAggregations, + setLastUpdate, + isRuleStatusFilterEnabled, + hasAnyAuthorizedRuleType, ruleTypesState, - page, - searchText, - percentileOptions, - JSON.stringify(typesFilter), - JSON.stringify(actionTypesFilter), - JSON.stringify(ruleExecutionStatusesFilter), - JSON.stringify(ruleStatusesFilter), - JSON.stringify(tagsFilter), ]); + useEffect(() => { + loadData(); + }, [loadData, percentileOptions]); + useEffect(() => { (async () => { try { @@ -289,218 +285,6 @@ export const RulesList: React.FunctionComponent = () => { })(); }, []); - async function loadRulesData() { - const hasAnyAuthorizedRuleType = ruleTypesState.isInitialized && ruleTypesState.data.size > 0; - if (hasAnyAuthorizedRuleType) { - setRulesState({ ...rulesState, isLoading: true }); - try { - const rulesResponse = await loadRules({ - http, - page, - searchText, - typesFilter, - actionTypesFilter, - ruleExecutionStatusesFilter, - ruleStatusesFilter, - tagsFilter, - sort, - }); - await loadRuleTagsAggs(); - await loadRuleAggs(); - setRulesState({ - isLoading: false, - data: rulesResponse.data, - totalItemCount: rulesResponse.total, - }); - - if (!rulesResponse.data?.length && page.index > 0) { - setPage({ ...page, index: 0 }); - } - - const isFilterApplied = !( - isEmpty(searchText) && - isEmpty(typesFilter) && - isEmpty(actionTypesFilter) && - isEmpty(ruleExecutionStatusesFilter) && - isEmpty(ruleStatusesFilter) && - isEmpty(tagsFilter) - ); - - setNoData(rulesResponse.data.length === 0 && !isFilterApplied); - } catch (e) { - toasts.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.unableToLoadRulesMessage', - { - defaultMessage: 'Unable to load rules', - } - ), - }); - setRulesState({ ...rulesState, isLoading: false }); - } - setInitialLoad(false); - } - } - - async function loadRuleAggs() { - try { - const rulesAggs = await loadRuleAggregations({ - http, - searchText, - typesFilter, - actionTypesFilter, - ruleExecutionStatusesFilter, - ruleStatusesFilter, - tagsFilter, - }); - if (rulesAggs?.ruleExecutionStatus) { - setRulesStatusesTotal(rulesAggs.ruleExecutionStatus); - } - } catch (e) { - toasts.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleStatusInfoMessage', - { - defaultMessage: 'Unable to load rule status info', - } - ), - }); - } - } - - async function loadRuleTagsAggs() { - if (!isRuleTagFilterEnabled) { - return; - } - try { - const ruleTagsAggs = await loadRuleTags({ http }); - if (ruleTagsAggs?.ruleTags) { - setTags(ruleTagsAggs.ruleTags); - } - } catch (e) { - toasts.addDanger({ - title: i18n.translate('xpack.triggersActionsUI.sections.rulesList.unableToLoadRuleTags', { - defaultMessage: 'Unable to load rule tags', - }), - }); - } - } - - const renderRuleStatusDropdown = (ruleEnabled: boolean | undefined, item: RuleTableItem) => { - return ( - await disableRule({ http, id: item.id })} - enableRule={async () => await enableRule({ http, id: item.id })} - snoozeRule={async (snoozeEndTime: string | -1, interval: string | null) => { - await snoozeRule({ http, id: item.id, snoozeEndTime }); - }} - unsnoozeRule={async () => await unsnoozeRule({ http, id: item.id })} - rule={item} - onRuleChanged={() => loadRulesData()} - isEditable={item.isEditable && isRuleTypeEditableInContext(item.ruleTypeId)} - /> - ); - }; - - const renderRuleExecutionStatus = (executionStatus: RuleExecutionStatus, item: RuleTableItem) => { - const healthColor = getHealthColor(executionStatus.status); - const tooltipMessage = - executionStatus.status === 'error' ? `Error: ${executionStatus?.error?.message}` : null; - const isLicenseError = - executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License; - const statusMessage = isLicenseError - ? ALERT_STATUS_LICENSE_ERROR - : rulesStatusesTranslationsMapping[executionStatus.status]; - - const health = ( - - {statusMessage} - - ); - - const healthWithTooltip = tooltipMessage ? ( - - {health} - - ) : ( - health - ); - - return ( - - {healthWithTooltip} - {isLicenseError && ( - - - setManageLicenseModalOpts({ - licenseType: ruleTypesState.data.get(item.ruleTypeId)?.minimumLicenseRequired!, - ruleTypeId: item.ruleTypeId, - }) - } - > - - - - )} - - ); - }; - - const renderPercentileColumnName = () => { - return ( - - - - {selectedPercentile}  - - - - - - ); - }; - - const renderPercentileCellValue = (value: number) => { - return ( - - - - ); - }; - - const getPercentileColumn = () => { - return { - mobileOptions: { header: false }, - field: percentileFields[selectedPercentile!], - width: '16%', - name: renderPercentileColumnName(), - 'data-test-subj': 'rulesTableCell-ruleExecutionPercentile', - sortable: true, - truncateText: false, - render: renderPercentileCellValue, - }; - }; - const buildErrorListItems = (_executionStatus: RuleExecutionStatus) => { const hasErrorMessage = _executionStatus.status === 'error'; const errorMessage = _executionStatus?.error?.message; @@ -563,383 +347,6 @@ export const RulesList: React.FunctionComponent = () => { }); }, [showErrors, rulesState]); - const getRulesTableColumns = (): Array< - | EuiTableFieldDataColumnType - | EuiTableComputedColumnType - | EuiTableActionsColumnType - > => { - return [ - { - field: 'name', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.nameTitle', - { defaultMessage: 'Name' } - ), - sortable: true, - truncateText: true, - width: '30%', - 'data-test-subj': 'rulesTableCell-name', - render: (name: string, rule: RuleTableItem) => { - const ruleType = ruleTypesState.data.get(rule.ruleTypeId); - const checkEnabledResult = checkRuleTypeEnabled(ruleType); - const link = ( - <> - - - - - { - history.push(routeToRuleDetails.replace(`:ruleId`, rule.id)); - }} - > - {name} - - - - {!checkEnabledResult.isEnabled && ( - - )} - - - - - - {rule.ruleType} - - - - - ); - return <>{link}; - }, - }, - { - field: 'tags', - name: '', - sortable: false, - width: '50px', - 'data-test-subj': 'rulesTableCell-tagsPopover', - render: (ruleTags: string[], item: RuleTableItem) => { - return ruleTags.length > 0 ? ( - setTagPopoverOpenIndex(item.index)} - onClose={() => setTagPopoverOpenIndex(-1)} - /> - ) : null; - }, - }, - { - field: 'executionStatus.lastExecutionDate', - name: ( - - - Last run{' '} - - - - ), - sortable: true, - width: '15%', - 'data-test-subj': 'rulesTableCell-lastExecutionDate', - render: (date: Date) => { - if (date) { - return ( - <> - - - {moment(date).format('MMM D, YYYY HH:mm:ssa')} - - - - {moment(date).fromNow()} - - - - - ); - } - }, - }, - { - field: 'schedule.interval', - width: '6%', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.scheduleTitle', - { defaultMessage: 'Interval' } - ), - sortable: false, - truncateText: false, - 'data-test-subj': 'rulesTableCell-interval', - render: (interval: string, item: RuleTableItem) => { - const durationString = formatDuration(interval); - return ( - <> - - {durationString} - - {item.showIntervalWarning && ( - - { - if (item.isEditable && isRuleTypeEditableInContext(item.ruleTypeId)) { - onRuleEdit(item); - } - }} - iconType="flag" - aria-label={i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.intervalIconAriaLabel', - { defaultMessage: 'Below configured minimum interval' } - )} - /> - - )} - - - - ); - }, - }, - { - field: 'executionStatus.lastDuration', - width: '12%', - name: ( - - - Duration{' '} - - - - ), - sortable: true, - truncateText: false, - 'data-test-subj': 'rulesTableCell-duration', - render: (value: number, item: RuleTableItem) => { - const showDurationWarning = shouldShowDurationWarning( - ruleTypesState.data.get(item.ruleTypeId), - value - ); - - return ( - <> - {} - {showDurationWarning && ( - - )} - - ); - }, - }, - getPercentileColumn(), - { - field: 'monitoring.execution.calculated_metrics.success_ratio', - width: '12%', - name: ( - - - Success ratio{' '} - - - - ), - sortable: true, - truncateText: false, - 'data-test-subj': 'rulesTableCell-successRatio', - render: (value: number) => { - return ( - - {value !== undefined ? getFormattedSuccessRatio(value) : 'N/A'} - - ); - }, - }, - { - field: 'executionStatus.status', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.lastResponseTitle', - { defaultMessage: 'Last response' } - ), - sortable: true, - truncateText: false, - width: '120px', - 'data-test-subj': 'rulesTableCell-lastResponse', - render: (_executionStatus: RuleExecutionStatus, item: RuleTableItem) => { - return renderRuleExecutionStatus(item.executionStatus, item); - }, - }, - { - field: 'enabled', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.stateTitle', - { defaultMessage: 'State' } - ), - sortable: true, - truncateText: false, - width: '10%', - 'data-test-subj': 'rulesTableCell-status', - render: (_enabled: boolean | undefined, item: RuleTableItem) => { - return renderRuleStatusDropdown(item.enabled, item); - }, - }, - { - name: '', - width: '90px', - render(item: RuleTableItem) { - return ( - - - - {item.isEditable && isRuleTypeEditableInContext(item.ruleTypeId) ? ( - - onRuleEdit(item)} - iconType={'pencil'} - aria-label={i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.editAriaLabel', - { defaultMessage: 'Edit' } - )} - /> - - ) : null} - {item.isEditable ? ( - - setRulesToDelete([item.id])} - iconType={'trash'} - aria-label={i18n.translate( - 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.deleteAriaLabel', - { defaultMessage: 'Delete' } - )} - /> - - ) : null} - - - - loadRulesData()} - setRulesToDelete={setRulesToDelete} - onEditRule={() => onRuleEdit(item)} - onUpdateAPIKey={setRulesToUpdateAPIKey} - /> - - - ); - }, - }, - { - align: RIGHT_ALIGNMENT, - width: '40px', - isExpander: true, - name: ( - - Expand rows - - ), - render: (item: RuleTableItem) => { - const _executionStatus = item.executionStatus; - const hasErrorMessage = _executionStatus.status === 'error'; - const isLicenseError = - _executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License; - - return isLicenseError || hasErrorMessage ? ( - toggleErrorMessage(_executionStatus, item)} - aria-label={itemIdToExpandedRowMap[item.id] ? 'Collapse' : 'Expand'} - iconType={itemIdToExpandedRowMap[item.id] ? 'arrowUp' : 'arrowDown'} - /> - ) : null; - }, - }, - ]; - }; - const authorizedRuleTypes = [...ruleTypesState.data.values()]; const authorizedToCreateAnyRules = authorizedRuleTypes.some( (ruleType) => ruleType.authorizedConsumers[ALERTS_FEATURE_ID]?.all @@ -979,13 +386,29 @@ export const RulesList: React.FunctionComponent = () => { return []; }; - const getRuleStatusFilter = () => { + const renderRuleStatusFilter = () => { if (isRuleStatusFilterEnabled) { - return [ - , - ]; + return ( + + ); } - return []; + return null; + }; + + const onDisableRule = (rule: RuleTableItem) => { + return disableRule({ http, id: rule.id }); + }; + + const onEnableRule = (rule: RuleTableItem) => { + return enableRule({ http, id: rule.id }); + }; + + const onSnoozeRule = (rule: RuleTableItem, snoozeEndTime: string | -1) => { + return snoozeRule({ http, id: rule.id, snoozeEndTime }); + }; + + const onUnsnoozeRule = (rule: RuleTableItem) => { + return unsnoozeRule({ http, id: rule.id }); }; const toolsRight = [ @@ -999,8 +422,6 @@ export const RulesList: React.FunctionComponent = () => { }) )} />, - ...getRuleTagFilter(), - ...getRuleStatusFilter(), { selectedStatuses={ruleExecutionStatusesFilter} onChange={(ids: string[]) => setRuleExecutionStatusesFilter(ids)} />, - - - , + ...getRuleTagFilter(), ]; const authorizedToModifySelectedRules = selectedIds.length @@ -1074,7 +484,7 @@ export const RulesList: React.FunctionComponent = () => { })} onPerformingAction={() => setIsPerformingAction(true)} onActionPerformed={() => { - loadRulesData(); + loadData(); setIsPerformingAction(false); }} setRulesToDelete={setRulesToDelete} @@ -1119,20 +529,19 @@ export const RulesList: React.FunctionComponent = () => { )} /> + {renderRuleStatusFilter()} - + {toolsRight.map((tool, index: number) => ( - - {tool} - + {tool} ))} - + - + { /> + {rulesStatusesTotal.error > 0 && ( @@ -1235,64 +645,66 @@ export const RulesList: React.FunctionComponent = () => { )} - - ({ - 'data-test-subj': 'rule-row', - className: !ruleTypesState.data.get(item.ruleTypeId)?.enabledInLicense - ? 'actRulesList__tableRowDisabled' - : '', - })} - cellProps={(item: RuleTableItem) => ({ - 'data-test-subj': 'cell', - className: !ruleTypesState.data.get(item.ruleTypeId)?.enabledInLicense - ? 'actRulesList__tableCellDisabled' - : '', - })} - data-test-subj="rulesList" - pagination={{ - pageIndex: page.index, - pageSize: page.size, - /* Don't display rule count until we have the rule types initialized */ - totalItemCount: ruleTypesState.isInitialized === false ? 0 : rulesState.totalItemCount, - }} - selection={{ - selectable: (rule: RuleTableItem) => rule.isEditable, - onSelectionChange(updatedSelectedItemsList: RuleTableItem[]) { - setSelectedIds(updatedSelectedItemsList.map((item) => item.id)); - }, + loadData()} + onRuleClick={(rule) => { + history.push(routeToRuleDetails.replace(`:ruleId`, rule.id)); }} - onChange={({ - page: changedPage, - sort: changedSort, - }: { - page?: Pagination; - sort?: EuiTableSortingType['sort']; - }) => { - if (changedPage) { - setPage(changedPage); - } - if (changedSort) { - setSort(changedSort); + onRuleEditClick={(rule) => { + if (rule.isEditable && isRuleTypeEditableInContext(rule.ruleTypeId)) { + onRuleEdit(rule); } }} - itemIdToExpandedRowMap={itemIdToExpandedRowMap} - isExpandable={true} + onRuleDeleteClick={(rule) => setRulesToDelete([rule.id])} + onManageLicenseClick={(rule) => + setManageLicenseModalOpts({ + licenseType: ruleTypesState.data.get(rule.ruleTypeId)?.minimumLicenseRequired!, + ruleTypeId: rule.ruleTypeId, + }) + } + onSelectionChange={(updatedSelectedItemsList) => + setSelectedIds(updatedSelectedItemsList.map((item) => item.id)) + } + onPercentileOptionsChange={setPercentileOptions} + onDisableRule={onDisableRule} + onEnableRule={onEnableRule} + onSnoozeRule={onSnoozeRule} + onUnsnoozeRule={onUnsnoozeRule} + renderCollapsedItemActions={(rule) => ( + loadData()} + setRulesToDelete={setRulesToDelete} + onEditRule={() => onRuleEdit(rule)} + onUpdateAPIKey={setRulesToUpdateAPIKey} + /> + )} + renderRuleError={(rule) => { + const _executionStatus = rule.executionStatus; + const hasErrorMessage = _executionStatus.status === 'error'; + const isLicenseError = + _executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License; + + return isLicenseError || hasErrorMessage ? ( + toggleErrorMessage(_executionStatus, rule)} + aria-label={itemIdToExpandedRowMap[rule.id] ? 'Collapse' : 'Expand'} + iconType={itemIdToExpandedRowMap[rule.id] ? 'arrowUp' : 'arrowDown'} + /> + ) : null; + }} + config={config} /> {manageLicenseModalOpts && ( { onDeleted={async () => { setRulesToDelete([]); setSelectedIds([]); - await loadRulesData(); + await loadData(); }} onErrors={async () => { - // Refresh the rules from the server, some rules may have been deleted - await loadRulesData(); + // Refresh the rules from the server, some rules may have beend deleted + await loadData(); setRulesToDelete([]); }} onCancel={() => { @@ -1364,7 +776,7 @@ export const RulesList: React.FunctionComponent = () => { }} onUpdated={async () => { setRulesToUpdateAPIKey([]); - await loadRulesData(); + await loadData(); }} /> @@ -1378,7 +790,7 @@ export const RulesList: React.FunctionComponent = () => { actionTypeRegistry={actionTypeRegistry} ruleTypeRegistry={ruleTypeRegistry} ruleTypeIndex={ruleTypesState.data} - onSave={loadRulesData} + onSave={loadData} /> )} {editFlyoutVisible && currentRuleToEdit && ( @@ -1392,7 +804,7 @@ export const RulesList: React.FunctionComponent = () => { ruleType={ ruleTypesState.data.get(currentRuleToEdit.ruleTypeId) as RuleType } - onSave={loadRulesData} + onSave={loadData} /> )} @@ -1427,30 +839,3 @@ const noPermissionPrompt = ( function filterRulesById(rules: Rule[], ids: string[]): Rule[] { return rules.filter((rule) => ids.includes(rule.id)); } - -interface ConvertRulesToTableItemsOpts { - rules: Rule[]; - ruleTypeIndex: RuleTypeIndex; - canExecuteActions: boolean; - config: TriggersActionsUiConfig; -} - -function convertRulesToTableItems(opts: ConvertRulesToTableItemsOpts): RuleTableItem[] { - const { rules, ruleTypeIndex, canExecuteActions, config } = opts; - const minimumDuration = config.minimumScheduleInterval - ? parseDuration(config.minimumScheduleInterval.value) - : 0; - return rules.map((rule, index: number) => { - return { - ...rule, - index, - actionsCount: rule.actions.length, - ruleType: ruleTypeIndex.get(rule.ruleTypeId)?.name ?? rule.ruleTypeId, - isEditable: - hasAllPrivilege(rule, ruleTypeIndex.get(rule.ruleTypeId)) && - (canExecuteActions || (!canExecuteActions && !rule.actions.length)), - enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense, - showIntervalWarning: parseDuration(rule.schedule.interval) < minimumDuration, - }; - }); -} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.test.tsx new file mode 100644 index 0000000000000..9e17561ce652b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import moment from 'moment'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { act } from 'react-dom/test-utils'; +import { RulesListAutoRefresh } from './rules_list_auto_refresh'; + +const onRefresh = jest.fn(); + +describe('RulesListAutoRefresh', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the update text correctly', async () => { + jest.useFakeTimers('modern').setSystemTime(moment('1990-01-01').toDate()); + + const wrapper = mountWithIntl( + + ); + + expect( + wrapper.find('[data-test-subj="rulesListAutoRefresh-lastUpdateText"]').first().text() + ).toEqual('Updated a few seconds ago'); + + await act(async () => { + jest.advanceTimersByTime(1 * 60 * 1000); + }); + + expect( + wrapper.find('[data-test-subj="rulesListAutoRefresh-lastUpdateText"]').first().text() + ).toEqual('Updated a minute ago'); + + await act(async () => { + jest.advanceTimersByTime(1 * 60 * 1000); + }); + + expect( + wrapper.find('[data-test-subj="rulesListAutoRefresh-lastUpdateText"]').first().text() + ).toEqual('Updated 2 minutes ago'); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + }); + + it('calls onRefresh when it auto refreshes', async () => { + jest.useFakeTimers('modern').setSystemTime(moment('1990-01-01').toDate()); + + mountWithIntl( + + ); + + expect(onRefresh).toHaveBeenCalledTimes(0); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(onRefresh).toHaveBeenCalledTimes(1); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(onRefresh).toHaveBeenCalledTimes(2); + + await act(async () => { + jest.advanceTimersByTime(10 * 1000); + }); + + expect(onRefresh).toHaveBeenCalledTimes(12); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx new file mode 100644 index 0000000000000..eea8d8e5f1bbe --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useEffect, useState, useRef } from 'react'; +import moment from 'moment'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiAutoRefreshButton } from '@elastic/eui'; + +interface RulesListAutoRefreshProps { + lastUpdate: string; + initialUpdateInterval?: number; + onRefresh: () => void; +} + +const flexGroupStyle = { + marginLeft: 'auto', +}; + +const getLastUpdateText = (lastUpdate: string) => { + if (!moment(lastUpdate).isValid()) { + return ''; + } + return i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListAutoRefresh.lastUpdateText', + { + defaultMessage: 'Updated {lastUpdateText}', + values: { + lastUpdateText: moment(lastUpdate).fromNow(), + }, + } + ); +}; + +const TEXT_UPDATE_INTERVAL = 60 * 1000; +const DEFAULT_REFRESH_INTERVAL = 5 * 60 * 1000; +const MIN_REFRESH_INTERVAL = 1000; + +export const RulesListAutoRefresh = (props: RulesListAutoRefreshProps) => { + const { lastUpdate, initialUpdateInterval = DEFAULT_REFRESH_INTERVAL, onRefresh } = props; + + const [isPaused, setIsPaused] = useState(false); + const [refreshInterval, setRefreshInterval] = useState( + Math.max(initialUpdateInterval, MIN_REFRESH_INTERVAL) + ); + const [lastUpdateText, setLastUpdateText] = useState(''); + + const cachedOnRefresh = useRef<() => void>(() => {}); + const textUpdateTimeout = useRef(); + const refreshTimeout = useRef(); + + useEffect(() => { + cachedOnRefresh.current = onRefresh; + }, [onRefresh]); + + useEffect(() => { + setLastUpdateText(getLastUpdateText(lastUpdate)); + + const poll = () => { + textUpdateTimeout.current = window.setTimeout(() => { + setLastUpdateText(getLastUpdateText(lastUpdate)); + poll(); + }, TEXT_UPDATE_INTERVAL); + }; + poll(); + + return () => { + if (textUpdateTimeout.current) { + clearTimeout(textUpdateTimeout.current); + } + }; + }, [lastUpdate, setLastUpdateText]); + + useEffect(() => { + if (isPaused) { + return; + } + + const poll = () => { + refreshTimeout.current = window.setTimeout(() => { + cachedOnRefresh.current(); + poll(); + }, refreshInterval); + }; + poll(); + + return () => { + if (refreshTimeout.current) { + clearTimeout(refreshTimeout.current); + } + }; + }, [isPaused, refreshInterval]); + + const onRefreshChange = useCallback( + ({ isPaused: newIsPaused, refreshInterval: newRefreshInterval }) => { + setIsPaused(newIsPaused); + setRefreshInterval(newRefreshInterval); + }, + [setIsPaused, setRefreshInterval] + ); + + return ( + + + + {lastUpdateText} + + + + + + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx new file mode 100644 index 0000000000000..1f03c76a7de0b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx @@ -0,0 +1,224 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import moment from 'moment'; +import { EuiButton, EuiButtonIcon, EuiPopover, EuiText, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { isRuleSnoozed } from './rule_status_dropdown'; +import { RuleTableItem } from '../../../../types'; +import { + SnoozePanel, + futureTimeToInterval, + usePreviousSnoozeInterval, + SnoozeUnit, +} from './rule_status_dropdown'; + +export interface RulesListNotifyBadgeProps { + rule: RuleTableItem; + isOpen: boolean; + previousSnoozeInterval?: string | null; + onClick: React.MouseEventHandler; + onClose: () => void; + onRuleChanged: () => void; + snoozeRule: (snoozeEndTime: string | -1, interval: string | null) => Promise; + unsnoozeRule: () => Promise; +} + +const openSnoozePanelAriaLabel = i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListNotifyBadge.openSnoozePanel', + { defaultMessage: 'Open snooze panel' } +); + +export const RulesListNotifyBadge: React.FunctionComponent = (props) => { + const { + rule, + isOpen, + previousSnoozeInterval: propsPreviousSnoozeInterval, + onClick, + onClose, + onRuleChanged, + snoozeRule, + unsnoozeRule, + } = props; + + const { isSnoozedUntil, muteAll } = rule; + + const [previousSnoozeInterval, setPreviousSnoozeInterval] = usePreviousSnoozeInterval( + propsPreviousSnoozeInterval + ); + + const [isLoading, setIsLoading] = useState(false); + + const isSnoozedIndefinitely = muteAll; + + const isSnoozed = useMemo(() => { + return isRuleSnoozed(rule); + }, [rule]); + + const isScheduled = useMemo(() => { + // TODO: Implement scheduled check + return false; + }, []); + + const formattedSnoozeText = useMemo(() => { + if (!isSnoozedUntil) { + return ''; + } + return moment(isSnoozedUntil).format('MMM D'); + }, [isSnoozedUntil]); + + const snoozeTooltipText = useMemo(() => { + if (isSnoozedIndefinitely) { + return i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListNotifyBadge.snoozedIndefinitelyTooltip', + { defaultMessage: 'Notifications snoozed indefinitely' } + ); + } + if (isScheduled) { + return ''; + // TODO: Implement scheduled tooltip + } + if (isSnoozed) { + return i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListNotifyBadge.snoozedTooltip', + { + defaultMessage: 'Notifications snoozed for {snoozeTime}', + values: { + snoozeTime: moment(isSnoozedUntil).fromNow(true), + }, + } + ); + } + return ''; + }, [isSnoozedIndefinitely, isScheduled, isSnoozed, isSnoozedUntil]); + + const snoozedButton = useMemo(() => { + return ( + + {formattedSnoozeText} + + ); + }, [formattedSnoozeText, onClick]); + + const scheduledSnoozeButton = useMemo(() => { + // TODO: Implement scheduled snooze button + return ( + + {formattedSnoozeText} + + ); + }, [formattedSnoozeText, onClick]); + + const unsnoozedButton = useMemo(() => { + return ( + + ); + }, [isOpen, onClick]); + + const indefiniteSnoozeButton = useMemo(() => { + return ( + + ); + }, [onClick]); + + const button = useMemo(() => { + if (isScheduled) { + return scheduledSnoozeButton; + } + if (isSnoozedIndefinitely) { + return indefiniteSnoozeButton; + } + if (isSnoozed) { + return snoozedButton; + } + return unsnoozedButton; + }, [ + isSnoozed, + isScheduled, + isSnoozedIndefinitely, + scheduledSnoozeButton, + snoozedButton, + indefiniteSnoozeButton, + unsnoozedButton, + ]); + + const buttonWithToolTip = useMemo(() => { + if (isOpen) { + return button; + } + return {button}; + }, [isOpen, button, snoozeTooltipText]); + + const snoozeRuleAndStoreInterval = useCallback( + (newSnoozeEndTime: string | -1, interval: string | null) => { + if (interval) { + setPreviousSnoozeInterval(interval); + } + return snoozeRule(newSnoozeEndTime, interval); + }, + [setPreviousSnoozeInterval, snoozeRule] + ); + + const onChangeSnooze = useCallback( + async (value: number, unit?: SnoozeUnit) => { + setIsLoading(true); + try { + if (value === -1) { + await snoozeRuleAndStoreInterval(-1, null); + } else if (value !== 0) { + const newSnoozeEndTime = moment().add(value, unit).toISOString(); + await snoozeRuleAndStoreInterval(newSnoozeEndTime, `${value}${unit}`); + } else await unsnoozeRule(); + onRuleChanged(); + } finally { + onClose(); + setIsLoading(false); + } + }, + [onRuleChanged, onClose, snoozeRuleAndStoreInterval, unsnoozeRule, setIsLoading] + ); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx new file mode 100644 index 0000000000000..53a3b4b69f8c0 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx @@ -0,0 +1,724 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useMemo, useState } from 'react'; +import moment from 'moment'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiBasicTable, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiLink, + EuiButtonEmpty, + EuiHealth, + EuiText, + EuiToolTip, + EuiTableSortingType, + EuiButtonIcon, + EuiSelectableOption, + EuiIcon, + EuiScreenReaderOnly, + RIGHT_ALIGNMENT, + EuiTableFieldDataColumnType, + EuiTableComputedColumnType, + EuiTableActionsColumnType, +} from '@elastic/eui'; +import { + RuleExecutionStatus, + RuleExecutionStatusErrorReasons, + formatDuration, + parseDuration, + MONITORING_HISTORY_LIMIT, +} from '@kbn/alerting-plugin/common'; +import { rulesStatusesTranslationsMapping, ALERT_STATUS_LICENSE_ERROR } from '../translations'; +import { getHealthColor } from './rule_execution_status_filter'; +import { + Rule, + RuleTableItem, + RuleTypeIndex, + Pagination, + Percentiles, + TriggersActionsUiConfig, + RuleTypeRegistryContract, +} from '../../../../types'; +import { shouldShowDurationWarning } from '../../../lib/execution_duration_utils'; +import { PercentileSelectablePopover } from './percentile_selectable_popover'; +import { RuleDurationFormat } from './rule_duration_format'; +import { checkRuleTypeEnabled } from '../../../lib/check_rule_type_enabled'; +import { getFormattedSuccessRatio } from '../../../lib/monitoring_utils'; +import { hasAllPrivilege } from '../../../lib/capabilities'; +import { RuleTagBadge } from './rule_tag_badge'; +import { RuleStatusDropdown } from './rule_status_dropdown'; +import { RulesListNotifyBadge } from './rules_list_notify_badge'; + +interface RuleTypeState { + isLoading: boolean; + isInitialized: boolean; + data: RuleTypeIndex; +} + +export interface RuleState { + isLoading: boolean; + data: Rule[]; + totalItemCount: number; +} + +const percentileOrdinals = { + [Percentiles.P50]: '50th', + [Percentiles.P95]: '95th', + [Percentiles.P99]: '99th', +}; + +export const percentileFields = { + [Percentiles.P50]: 'monitoring.execution.calculated_metrics.p50', + [Percentiles.P95]: 'monitoring.execution.calculated_metrics.p95', + [Percentiles.P99]: 'monitoring.execution.calculated_metrics.p99', +}; + +const EMPTY_OBJECT = {}; +const EMPTY_HANDLER = () => {}; +const EMPTY_RENDER = () => null; + +interface ConvertRulesToTableItemsOpts { + rules: Rule[]; + ruleTypeIndex: RuleTypeIndex; + canExecuteActions: boolean; + config: TriggersActionsUiConfig; +} + +export interface RulesListTableProps { + rulesState: RuleState; + ruleTypesState: RuleTypeState; + ruleTypeRegistry: RuleTypeRegistryContract; + isLoading?: boolean; + sort: EuiTableSortingType['sort']; + page: Pagination; + percentileOptions: EuiSelectableOption[]; + canExecuteActions?: boolean; + itemIdToExpandedRowMap?: Record; + config: TriggersActionsUiConfig; + onSort?: (sort: EuiTableSortingType['sort']) => void; + onPage?: (page: Pagination) => void; + onRuleClick?: (rule: RuleTableItem) => void; + onRuleEditClick?: (rule: RuleTableItem) => void; + onRuleDeleteClick?: (rule: RuleTableItem) => void; + onManageLicenseClick?: (rule: RuleTableItem) => void; + onTagClick?: (rule: RuleTableItem) => void; + onTagClose?: (rule: RuleTableItem) => void; + onSelectionChange?: (updatedSelectedItemsList: RuleTableItem[]) => void; + onPercentileOptionsChange?: (options: EuiSelectableOption[]) => void; + onRuleChanged: () => void; + onEnableRule: (rule: RuleTableItem) => Promise; + onDisableRule: (rule: RuleTableItem) => Promise; + onSnoozeRule: (rule: RuleTableItem, snoozeEndTime: string | -1) => Promise; + onUnsnoozeRule: (rule: RuleTableItem) => Promise; + renderCollapsedItemActions?: (rule: RuleTableItem) => React.ReactNode; + renderRuleError?: (rule: RuleTableItem) => React.ReactNode; +} + +interface ConvertRulesToTableItemsOpts { + rules: Rule[]; + ruleTypeIndex: RuleTypeIndex; + canExecuteActions: boolean; + config: TriggersActionsUiConfig; +} + +export function convertRulesToTableItems(opts: ConvertRulesToTableItemsOpts): RuleTableItem[] { + const { rules, ruleTypeIndex, canExecuteActions, config } = opts; + const minimumDuration = config.minimumScheduleInterval + ? parseDuration(config.minimumScheduleInterval.value) + : 0; + return rules.map((rule, index: number) => { + return { + ...rule, + index, + actionsCount: rule.actions.length, + ruleType: ruleTypeIndex.get(rule.ruleTypeId)?.name ?? rule.ruleTypeId, + isEditable: + hasAllPrivilege(rule, ruleTypeIndex.get(rule.ruleTypeId)) && + (canExecuteActions || (!canExecuteActions && !rule.actions.length)), + enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense, + showIntervalWarning: parseDuration(rule.schedule.interval) < minimumDuration, + }; + }); +} + +export const RulesListTable = (props: RulesListTableProps) => { + const { + rulesState, + ruleTypesState, + ruleTypeRegistry, + isLoading = false, + canExecuteActions = false, + sort, + page, + percentileOptions, + itemIdToExpandedRowMap = EMPTY_OBJECT, + config = EMPTY_OBJECT as TriggersActionsUiConfig, + onSort = EMPTY_HANDLER, + onPage = EMPTY_HANDLER, + onRuleClick = EMPTY_HANDLER, + onRuleEditClick = EMPTY_HANDLER, + onRuleDeleteClick = EMPTY_HANDLER, + onManageLicenseClick = EMPTY_HANDLER, + onSelectionChange = EMPTY_HANDLER, + onPercentileOptionsChange = EMPTY_HANDLER, + onRuleChanged = EMPTY_HANDLER, + onEnableRule = EMPTY_HANDLER, + onDisableRule = EMPTY_HANDLER, + onSnoozeRule = EMPTY_HANDLER, + onUnsnoozeRule = EMPTY_HANDLER, + renderCollapsedItemActions = EMPTY_RENDER, + renderRuleError = EMPTY_RENDER, + } = props; + + const [tagPopoverOpenIndex, setTagPopoverOpenIndex] = useState(-1); + const [currentlyOpenNotify, setCurrentlyOpenNotify] = useState(); + + const selectedPercentile = useMemo(() => { + const selectedOption = percentileOptions.find((option) => option.checked === 'on'); + if (selectedOption) { + return Percentiles[selectedOption.key as Percentiles]; + } + }, [percentileOptions]); + + const renderPercentileColumnName = () => { + return ( + + + + {selectedPercentile}  + + + + + + ); + }; + + const renderPercentileCellValue = (value: number) => { + return ( + + + + ); + }; + + const renderRuleStatusDropdown = (ruleEnabled: boolean | undefined, rule: RuleTableItem) => { + return ( + await onDisableRule(rule)} + enableRule={async () => await onEnableRule(rule)} + snoozeRule={async (snoozeEndTime: string | -1, interval: string | null) => { + await onSnoozeRule(rule, snoozeEndTime); + }} + unsnoozeRule={async () => await onUnsnoozeRule(rule)} + rule={rule} + onRuleChanged={onRuleChanged} + isEditable={rule.isEditable && isRuleTypeEditableInContext(rule.ruleTypeId)} + /> + ); + }; + + const isRuleTypeEditableInContext = (ruleTypeId: string) => + ruleTypeRegistry.has(ruleTypeId) ? !ruleTypeRegistry.get(ruleTypeId).requiresAppContext : false; + + const renderRuleExecutionStatus = (executionStatus: RuleExecutionStatus, rule: RuleTableItem) => { + const healthColor = getHealthColor(executionStatus.status); + const tooltipMessage = + executionStatus.status === 'error' ? `Error: ${executionStatus?.error?.message}` : null; + const isLicenseError = + executionStatus.error?.reason === RuleExecutionStatusErrorReasons.License; + const statusMessage = isLicenseError + ? ALERT_STATUS_LICENSE_ERROR + : rulesStatusesTranslationsMapping[executionStatus.status]; + + const health = ( + + {statusMessage} + + ); + + const healthWithTooltip = tooltipMessage ? ( + + {health} + + ) : ( + health + ); + + return ( + + {healthWithTooltip} + {isLicenseError && ( + + onManageLicenseClick(rule)} + > + + + + )} + + ); + }; + + const getRulesTableColumns = (): Array< + | EuiTableFieldDataColumnType + | EuiTableComputedColumnType + | EuiTableActionsColumnType + > => { + return [ + { + field: 'name', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.nameTitle', + { defaultMessage: 'Name' } + ), + sortable: true, + truncateText: true, + width: '30%', + 'data-test-subj': 'rulesTableCell-name', + render: (name: string, rule: RuleTableItem) => { + const ruleType = ruleTypesState.data.get(rule.ruleTypeId); + const checkEnabledResult = checkRuleTypeEnabled(ruleType); + const link = ( + <> + + + + + onRuleClick(rule)}> + {name} + + + + {!checkEnabledResult.isEnabled && ( + + )} + + + + + + {rule.ruleType} + + + + + ); + return <>{link}; + }, + }, + { + field: 'tags', + name: '', + sortable: false, + width: '50px', + 'data-test-subj': 'rulesTableCell-tagsPopover', + render: (ruleTags: string[], rule: RuleTableItem) => { + return ruleTags.length > 0 ? ( + setTagPopoverOpenIndex(rule.index)} + onClose={() => setTagPopoverOpenIndex(-1)} + /> + ) : null; + }, + }, + { + field: 'executionStatus.lastExecutionDate', + name: ( + + + Last run{' '} + + + + ), + sortable: true, + width: '15%', + 'data-test-subj': 'rulesTableCell-lastExecutionDate', + render: (date: Date) => { + if (date) { + return ( + <> + + + {moment(date).format('MMM D, YYYY HH:mm:ssa')} + + + + {moment(date).fromNow()} + + + + + ); + } + }, + }, + { + name: 'Notify', + width: '16%', + 'data-test-subj': 'rulesTableCell-rulesListNotify', + render: (rule: RuleTableItem) => { + return ( + setCurrentlyOpenNotify(rule.id)} + onClose={() => setCurrentlyOpenNotify('')} + onRuleChanged={onRuleChanged} + snoozeRule={async (snoozeEndTime: string | -1, interval: string | null) => { + await onSnoozeRule(rule, snoozeEndTime); + }} + unsnoozeRule={async () => await onUnsnoozeRule(rule)} + /> + ); + }, + }, + { + field: 'schedule.interval', + width: '6%', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.scheduleTitle', + { defaultMessage: 'Interval' } + ), + sortable: false, + truncateText: false, + 'data-test-subj': 'rulesTableCell-interval', + render: (interval: string, rule: RuleTableItem) => { + const durationString = formatDuration(interval); + return ( + <> + + {durationString} + + {rule.showIntervalWarning && ( + + onRuleEditClick(rule)} + iconType="flag" + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.intervalIconAriaLabel', + { defaultMessage: 'Below configured minimum interval' } + )} + /> + + )} + + + + ); + }, + }, + { + field: 'executionStatus.lastDuration', + width: '12%', + name: ( + + + Duration{' '} + + + + ), + sortable: true, + truncateText: false, + 'data-test-subj': 'rulesTableCell-duration', + render: (value: number, rule: RuleTableItem) => { + const showDurationWarning = shouldShowDurationWarning( + ruleTypesState.data.get(rule.ruleTypeId), + value + ); + + return ( + <> + {} + {showDurationWarning && ( + + )} + + ); + }, + }, + { + mobileOptions: { header: false }, + field: percentileFields[selectedPercentile!], + width: '16%', + name: renderPercentileColumnName(), + 'data-test-subj': 'rulesTableCell-ruleExecutionPercentile', + sortable: true, + truncateText: false, + render: renderPercentileCellValue, + }, + { + field: 'monitoring.execution.calculated_metrics.success_ratio', + width: '12%', + name: ( + + + Success ratio{' '} + + + + ), + sortable: true, + truncateText: false, + 'data-test-subj': 'rulesTableCell-successRatio', + render: (value: number) => { + return ( + + {value !== undefined ? getFormattedSuccessRatio(value) : 'N/A'} + + ); + }, + }, + { + field: 'executionStatus.status', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.lastResponseTitle', + { defaultMessage: 'Last response' } + ), + sortable: true, + truncateText: false, + width: '120px', + 'data-test-subj': 'rulesTableCell-lastResponse', + render: (_executionStatus: RuleExecutionStatus, rule: RuleTableItem) => { + return renderRuleExecutionStatus(rule.executionStatus, rule); + }, + }, + { + field: 'enabled', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.stateTitle', + { defaultMessage: 'State' } + ), + sortable: true, + truncateText: false, + width: '10%', + 'data-test-subj': 'rulesTableCell-status', + render: (_enabled: boolean | undefined, rule: RuleTableItem) => { + return renderRuleStatusDropdown(rule.enabled, rule); + }, + }, + { + name: '', + width: '90px', + render(rule: RuleTableItem) { + return ( + + + + {rule.isEditable && isRuleTypeEditableInContext(rule.ruleTypeId) ? ( + + onRuleEditClick(rule)} + iconType={'pencil'} + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.editAriaLabel', + { defaultMessage: 'Edit' } + )} + /> + + ) : null} + {rule.isEditable ? ( + + onRuleDeleteClick(rule)} + iconType={'trash'} + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.deleteAriaLabel', + { defaultMessage: 'Delete' } + )} + /> + + ) : null} + + + {renderCollapsedItemActions(rule)} + + ); + }, + }, + { + align: RIGHT_ALIGNMENT, + width: '40px', + isExpander: true, + name: ( + + Expand rows + + ), + render: renderRuleError, + }, + ]; + }; + + return ( + ({ + 'data-test-subj': 'rule-row', + className: !ruleTypesState.data.get(rule.ruleTypeId)?.enabledInLicense + ? 'actRulesList__tableRowDisabled' + : '', + })} + cellProps={(rule: RuleTableItem) => ({ + 'data-test-subj': 'cell', + className: !ruleTypesState.data.get(rule.ruleTypeId)?.enabledInLicense + ? 'actRulesList__tableCellDisabled' + : '', + })} + data-test-subj="rulesList" + pagination={{ + pageIndex: page.index, + pageSize: page.size, + /* Don't display rule count until we have the rule types initialized */ + totalItemCount: ruleTypesState.isInitialized === false ? 0 : rulesState.totalItemCount, + }} + selection={{ + selectable: (rule: RuleTableItem) => rule.isEditable, + onSelectionChange, + }} + onChange={({ + page: changedPage, + sort: changedSort, + }: { + page?: Pagination; + sort?: EuiTableSortingType['sort']; + }) => { + if (changedPage) { + onPage(changedPage); + } + if (changedSort) { + onSort(changedSort); + } + }} + itemIdToExpandedRowMap={itemIdToExpandedRowMap} + isExpandable={true} + /> + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/type_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/type_filter.tsx index 6ce697f65f898..f8cb70745911c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/type_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/type_filter.tsx @@ -7,13 +7,7 @@ import React, { Fragment, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiFilterGroup, - EuiPopover, - EuiFilterButton, - EuiFilterSelectItem, - EuiTitle, -} from '@elastic/eui'; +import { EuiPopover, EuiFilterButton, EuiFilterSelectItem, EuiTitle } from '@elastic/eui'; interface TypeFilterProps { options: Array<{ @@ -41,53 +35,51 @@ export const TypeFilter: React.FunctionComponent = ({ }, [selectedValues]); return ( - - setIsPopoverOpen(false)} - button={ - 0} - numActiveFilters={selectedValues.length} - numFilters={selectedValues.length} - onClick={() => setIsPopoverOpen(!isPopoverOpen)} - data-test-subj="ruleTypeFilterButton" - > - - - } - > -
- {options.map((groupItem, groupIndex) => ( - - -

{groupItem.groupName}

-
- {groupItem.subOptions.map((item, index) => ( - { - const isPreviouslyChecked = selectedValues.includes(item.value); - if (isPreviouslyChecked) { - setSelectedValues(selectedValues.filter((val) => val !== item.value)); - } else { - setSelectedValues(selectedValues.concat(item.value)); - } - }} - checked={selectedValues.includes(item.value) ? 'on' : undefined} - data-test-subj={`ruleType${item.value}FilterOption`} - > - {item.name} - - ))} -
- ))} -
-
-
+ setIsPopoverOpen(false)} + button={ + 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + data-test-subj="ruleTypeFilterButton" + > + + + } + > +
+ {options.map((groupItem, groupIndex) => ( + + +

{groupItem.groupName}

+
+ {groupItem.subOptions.map((item, index) => ( + { + const isPreviouslyChecked = selectedValues.includes(item.value); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter((val) => val !== item.value)); + } else { + setSelectedValues(selectedValues.concat(item.value)); + } + }} + checked={selectedValues.includes(item.value) ? 'on' : undefined} + data-test-subj={`ruleType${item.value}FilterOption`} + > + {item.name} + + ))} +
+ ))} +
+
); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/get_rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/common/get_rules_list.tsx new file mode 100644 index 0000000000000..b315668c4fab9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/get_rules_list.tsx @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { RulesList } from '../application/sections'; + +export const getRulesListLazy = () => { + return ; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/mocks.ts b/x-pack/plugins/triggers_actions_ui/public/mocks.ts index 75ca6d8fd2987..605d83a8eb32e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/mocks.ts +++ b/x-pack/plugins/triggers_actions_ui/public/mocks.ts @@ -30,6 +30,7 @@ import { getRuleTagFilterLazy } from './common/get_rule_tag_filter'; import { getRuleStatusFilterLazy } from './common/get_rule_status_filter'; import { getRuleTagBadgeLazy } from './common/get_rule_tag_badge'; import { getRuleEventLogListLazy } from './common/get_rule_event_log_list'; +import { getRulesListLazy } from './common/get_rules_list'; import { getAlertsTableStateLazy } from './common/get_alerts_table_state'; import { AlertsTableStateProps } from './application/sections/alerts_table/alerts_table_state'; @@ -85,6 +86,9 @@ function createStartMock(): TriggersAndActionsUIPublicPluginStart { getRuleEventLogList: (props) => { return getRuleEventLogListLazy(props); }, + getRulesList: () => { + return getRulesListLazy(); + }, }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index f9df34a5e4abb..f2237ff22f4ae 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -35,6 +35,7 @@ import { getRuleTagFilterLazy } from './common/get_rule_tag_filter'; import { getRuleStatusFilterLazy } from './common/get_rule_status_filter'; import { getRuleTagBadgeLazy } from './common/get_rule_tag_badge'; import { getRuleEventLogListLazy } from './common/get_rule_event_log_list'; +import { getRulesListLazy } from './common/get_rules_list'; import { ExperimentalFeaturesService } from './common/experimental_features_service'; import { ExperimentalFeatures, @@ -91,6 +92,7 @@ export interface TriggersAndActionsUIPublicPluginStart { getRuleStatusFilter: (props: RuleStatusFilterProps) => ReactElement; getRuleTagBadge: (props: RuleTagBadgeProps) => ReactElement; getRuleEventLogList: (props: RuleEventLogListProps) => ReactElement; + getRulesList: () => ReactElement; } interface PluginsSetup { @@ -279,6 +281,9 @@ export class Plugin getRuleEventLogList: (props: RuleEventLogListProps) => { return getRuleEventLogListLazy(props); }, + getRulesList: () => { + return getRulesListLazy(); + }, }; } diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts index c5ed118c105bb..832cf6c7a9078 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts @@ -20,5 +20,6 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => { loadTestFile(require.resolve('./rule_status_filter')); loadTestFile(require.resolve('./rule_tag_badge')); loadTestFile(require.resolve('./rule_event_log_list')); + loadTestFile(require.resolve('./rules_list')); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts index 77d57e2819db5..15ea8fc302622 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rule_tag_filter.ts @@ -10,7 +10,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { const testSubjects = getService('testSubjects'); - const find = getService('find'); const PageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); const esArchiver = getService('esArchiver'); @@ -31,24 +30,5 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const exists = await testSubjects.exists('ruleTagFilter'); expect(exists).to.be(true); }); - - it('should allow tag filters to be selected', async () => { - let badge = await find.byCssSelector('.euiFilterButton__notification'); - expect(await badge.getVisibleText()).to.be('0'); - - await testSubjects.click('ruleTagFilter'); - await testSubjects.click('ruleTagFilterOption-tag1'); - - badge = await find.byCssSelector('.euiFilterButton__notification'); - expect(await badge.getVisibleText()).to.be('1'); - - await testSubjects.click('ruleTagFilterOption-tag2'); - - badge = await find.byCssSelector('.euiFilterButton__notification'); - expect(await badge.getVisibleText()).to.be('2'); - - await testSubjects.click('ruleTagFilterOption-tag1'); - expect(await badge.getVisibleText()).to.be('1'); - }); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts new file mode 100644 index 0000000000000..30baba0caaa08 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); + const esArchiver = getService('esArchiver'); + + describe('Rules list', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts'); + await PageObjects.common.navigateToUrlWithBrowserHistory( + 'triggersActions', + '/__components_sandbox' + ); + }); + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/observability/alerts'); + }); + + it('shoud load from shareable lazy loader', async () => { + await testSubjects.find('rulesList'); + const exists = await testSubjects.exists('rulesList'); + expect(exists).to.be(true); + }); + }); +};