diff --git a/examples/search_examples/public/application.tsx b/examples/search_examples/public/application.tsx index c77c3c24be1476..9bd5bb0f3f8a2b 100644 --- a/examples/search_examples/public/application.tsx +++ b/examples/search_examples/public/application.tsx @@ -16,12 +16,17 @@ import { SearchExamplePage, ExampleLink } from './common/example_page'; import { SearchExamplesApp } from './search/app'; import { SearchSessionsExampleApp } from './search_sessions/app'; import { RedirectAppLinks } from '../../../src/plugins/kibana_react/public'; +import { SqlSearchExampleApp } from './sql_search/app'; const LINKS: ExampleLink[] = [ { path: '/search', title: 'Search', }, + { + path: '/sql-search', + title: 'SQL Search', + }, { path: '/search-sessions', title: 'Search Sessions', @@ -51,12 +56,16 @@ export const renderApp = ( /> + + + + diff --git a/examples/search_examples/public/sql_search/app.tsx b/examples/search_examples/public/sql_search/app.tsx new file mode 100644 index 00000000000000..acb640c4d82dbe --- /dev/null +++ b/examples/search_examples/public/sql_search/app.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; + +import { + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiPanel, + EuiSuperUpdateButton, + EuiText, + EuiTextArea, + EuiTitle, +} from '@elastic/eui'; + +import { CoreStart } from '../../../../src/core/public'; + +import { + DataPublicPluginStart, + IKibanaSearchResponse, + isCompleteResponse, + isErrorResponse, +} from '../../../../src/plugins/data/public'; +import { + SQL_SEARCH_STRATEGY, + SqlSearchStrategyRequest, + SqlSearchStrategyResponse, +} from '../../../../src/plugins/data/common'; + +interface SearchExamplesAppDeps { + notifications: CoreStart['notifications']; + data: DataPublicPluginStart; +} + +export const SqlSearchExampleApp = ({ notifications, data }: SearchExamplesAppDeps) => { + const [sqlQuery, setSqlQuery] = useState(''); + const [request, setRequest] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const [rawResponse, setRawResponse] = useState>({}); + + function setResponse(response: IKibanaSearchResponse) { + setRawResponse(response.rawResponse); + } + + const doSearch = async () => { + const req: SqlSearchStrategyRequest = { + params: { + query: sqlQuery, + }, + }; + + // Submit the search request using the `data.search` service. + setRequest(req.params!); + setIsLoading(true); + + data.search + .search(req, { + strategy: SQL_SEARCH_STRATEGY, + }) + .subscribe({ + next: (res) => { + if (isCompleteResponse(res)) { + setIsLoading(false); + setResponse(res); + } else if (isErrorResponse(res)) { + setIsLoading(false); + setResponse(res); + notifications.toasts.addDanger('An error has occurred'); + } + }, + error: (e) => { + setIsLoading(false); + data.search.showError(e); + }, + }); + }; + + return ( + + + +

SQL search example

+
+
+ + + + + + setSqlQuery(e.target.value)} + fullWidth + data-test-subj="sqlQueryInput" + /> + + + + + + + + + + + +

Request

+
+ + {JSON.stringify(request, null, 2)} + +
+
+ + + +

Response

+
+ + {JSON.stringify(rawResponse, null, 2)} + +
+
+
+
+
+
+ ); +}; diff --git a/package.json b/package.json index 24367fa77216ee..af0168e1255445 100644 --- a/package.json +++ b/package.json @@ -241,7 +241,7 @@ "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", "del": "^5.1.0", - "elastic-apm-node": "^3.30.0", + "elastic-apm-node": "^3.31.0", "execa": "^4.0.2", "exit-hook": "^2.2.0", "expiry-js": "0.1.7", diff --git a/src/plugins/data/common/search/index.ts b/src/plugins/data/common/search/index.ts index badbb94e9752fc..d0d103abe1ea2a 100644 --- a/src/plugins/data/common/search/index.ts +++ b/src/plugins/data/common/search/index.ts @@ -17,3 +17,4 @@ export * from './poll_search'; export * from './strategies/es_search'; export * from './strategies/eql_search'; export * from './strategies/ese_search'; +export * from './strategies/sql_search'; diff --git a/src/plugins/data/common/search/strategies/sql_search/index.ts b/src/plugins/data/common/search/strategies/sql_search/index.ts new file mode 100644 index 00000000000000..12594660136d8f --- /dev/null +++ b/src/plugins/data/common/search/strategies/sql_search/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './types'; diff --git a/src/plugins/data/common/search/strategies/sql_search/types.ts b/src/plugins/data/common/search/strategies/sql_search/types.ts new file mode 100644 index 00000000000000..e51d0bf4a6b6cd --- /dev/null +++ b/src/plugins/data/common/search/strategies/sql_search/types.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 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 { + SqlGetAsyncRequest, + SqlQueryRequest, + SqlQueryResponse, +} from '@elastic/elasticsearch/lib/api/types'; +import { IKibanaSearchRequest, IKibanaSearchResponse } from '../../types'; + +export const SQL_SEARCH_STRATEGY = 'sql'; + +export type SqlRequestParams = + | Omit + | Omit; +export type SqlSearchStrategyRequest = IKibanaSearchRequest; + +export type SqlSearchStrategyResponse = IKibanaSearchResponse; diff --git a/src/plugins/data/server/search/README.md b/src/plugins/data/server/search/README.md index b564c34a7f8b3a..d663cdc38da1b1 100644 --- a/src/plugins/data/server/search/README.md +++ b/src/plugins/data/server/search/README.md @@ -10,3 +10,4 @@ The `search` plugin includes: - ES_SEARCH_STRATEGY - hitting regular es `_search` endpoint using query DSL - (default) ESE_SEARCH_STRATEGY (Enhanced ES) - hitting `_async_search` endpoint and works with search sessions - EQL_SEARCH_STRATEGY +- SQL_SEARCH_STRATEGY diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index 8fb92136bc2598..7c01fefc92d659 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -77,6 +77,7 @@ import { eqlRawResponse, ENHANCED_ES_SEARCH_STRATEGY, EQL_SEARCH_STRATEGY, + SQL_SEARCH_STRATEGY, } from '../../common/search'; import { getEsaggs, getEsdsl, getEql } from './expressions'; import { @@ -93,6 +94,7 @@ import { enhancedEsSearchStrategyProvider } from './strategies/ese_search'; import { eqlSearchStrategyProvider } from './strategies/eql_search'; import { NoSearchIdInSessionError } from './errors/no_search_id_in_session'; import { CachedUiSettingsClient } from './services'; +import { sqlSearchStrategyProvider } from './strategies/sql_search'; type StrategyMap = Record>; @@ -176,6 +178,7 @@ export class SearchService implements Plugin { ); this.registerSearchStrategy(EQL_SEARCH_STRATEGY, eqlSearchStrategyProvider(this.logger)); + this.registerSearchStrategy(SQL_SEARCH_STRATEGY, sqlSearchStrategyProvider(this.logger)); registerBsearchRoute( bfetch, diff --git a/src/plugins/data/server/search/strategies/sql_search/index.ts b/src/plugins/data/server/search/strategies/sql_search/index.ts new file mode 100644 index 00000000000000..9af70ddcb618db --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { sqlSearchStrategyProvider } from './sql_search_strategy'; diff --git a/src/plugins/data/server/search/strategies/sql_search/request_utils.test.ts b/src/plugins/data/server/search/strategies/sql_search/request_utils.test.ts new file mode 100644 index 00000000000000..9944de7be17bed --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/request_utils.test.ts @@ -0,0 +1,110 @@ +/* + * 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 { getDefaultAsyncSubmitParams, getDefaultAsyncGetParams } from './request_utils'; +import moment from 'moment'; +import { SearchSessionsConfigSchema } from '../../../../config'; + +const getMockSearchSessionsConfig = ({ + enabled = true, + defaultExpiration = moment.duration(7, 'd'), +} = {}) => + ({ + enabled, + defaultExpiration, + } as SearchSessionsConfigSchema); + +describe('request utils', () => { + describe('getDefaultAsyncSubmitParams', () => { + test('Uses `keep_alive` from default params if no `sessionId` is provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + }); + const params = getDefaultAsyncSubmitParams(mockConfig, {}); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Uses `keep_alive` from config if enabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + }); + const params = getDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_alive', '259200000ms'); + }); + + test('Uses `keepAlive` of `1m` if disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Uses `keep_on_completion` if enabled', async () => { + const mockConfig = getMockSearchSessionsConfig({}); + const params = getDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_on_completion', true); + }); + + test('Does not use `keep_on_completion` if disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_on_completion', false); + }); + }); + + describe('getDefaultAsyncGetParams', () => { + test('Uses `wait_for_completion_timeout`', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getDefaultAsyncGetParams(mockConfig, {}); + expect(params).toHaveProperty('wait_for_completion_timeout'); + }); + + test('Uses `keep_alive` if `sessionId` is not provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getDefaultAsyncGetParams(mockConfig, {}); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Has no `keep_alive` if `sessionId` is provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' }); + expect(params).not.toHaveProperty('keep_alive'); + }); + + test('Uses `keep_alive` if `sessionId` is provided but sessions disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' }); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + }); +}); diff --git a/src/plugins/data/server/search/strategies/sql_search/request_utils.ts b/src/plugins/data/server/search/strategies/sql_search/request_utils.ts new file mode 100644 index 00000000000000..d05b2710b07ea1 --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/request_utils.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { SqlGetAsyncRequest, SqlQueryRequest } from '@elastic/elasticsearch/lib/api/types'; +import { ISearchOptions } from '../../../../common'; +import { SearchSessionsConfigSchema } from '../../../../config'; + +/** + @internal + */ +export function getDefaultAsyncSubmitParams( + searchSessionsConfig: SearchSessionsConfigSchema | null, + options: ISearchOptions +): Pick { + const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; + + const keepAlive = useSearchSessions + ? `${searchSessionsConfig!.defaultExpiration.asMilliseconds()}ms` + : '1m'; + + return { + // Wait up to 100ms for the response to return + wait_for_completion_timeout: '100ms', + // If search sessions are used, store and get an async ID even for short running requests. + keep_on_completion: useSearchSessions, + // The initial keepalive is as defined in defaultExpiration if search sessions are used or 1m otherwise. + keep_alive: keepAlive, + }; +} + +/** + @internal + */ +export function getDefaultAsyncGetParams( + searchSessionsConfig: SearchSessionsConfigSchema | null, + options: ISearchOptions +): Pick { + const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; + + return { + // Wait up to 100ms for the response to return + wait_for_completion_timeout: '100ms', + ...(useSearchSessions + ? // Don't change the expiration of search requests that are tracked in a search session + undefined + : { + // We still need to do polling for searches not within the context of a search session or when search session disabled + keep_alive: '1m', + }), + }; +} diff --git a/src/plugins/data/server/search/strategies/sql_search/response_utils.ts b/src/plugins/data/server/search/strategies/sql_search/response_utils.ts new file mode 100644 index 00000000000000..9d6e3f4fd3ebc5 --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/response_utils.ts @@ -0,0 +1,26 @@ +/* + * 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 { SqlQueryResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { SqlSearchStrategyResponse } from '../../../../common'; + +/** + * Get the Kibana representation of an async search response + */ +export function toAsyncKibanaSearchResponse( + response: SqlQueryResponse, + warning?: string +): SqlSearchStrategyResponse { + return { + id: response.id, + rawResponse: response, + isPartial: response.is_partial, + isRunning: response.is_running, + ...(warning ? { warning } : {}), + }; +} diff --git a/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.test.ts b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.test.ts new file mode 100644 index 00000000000000..2734a512e046bc --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.test.ts @@ -0,0 +1,264 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { KbnServerError } from '../../../../../kibana_utils/server'; +import { errors } from '@elastic/elasticsearch'; +import * as indexNotFoundException from '../../../../common/search/test_data/index_not_found_exception.json'; +import { SearchStrategyDependencies } from '../../types'; +import { sqlSearchStrategyProvider } from './sql_search_strategy'; +import { createSearchSessionsClientMock } from '../../mocks'; +import { SqlSearchStrategyRequest } from '../../../../common'; + +const mockSqlResponse = { + body: { + id: 'foo', + is_partial: false, + is_running: false, + rows: [], + }, +}; + +describe('SQL search strategy', () => { + const mockSqlGetAsync = jest.fn(); + const mockSqlQuery = jest.fn(); + const mockSqlDelete = jest.fn(); + const mockLogger: any = { + debug: () => {}, + }; + const mockDeps = { + esClient: { + asCurrentUser: { + sql: { + getAsync: mockSqlGetAsync, + query: mockSqlQuery, + deleteAsync: mockSqlDelete, + }, + }, + }, + searchSessionsClient: createSearchSessionsClientMock(), + } as unknown as SearchStrategyDependencies; + + beforeEach(() => { + mockSqlGetAsync.mockClear(); + mockSqlQuery.mockClear(); + mockSqlDelete.mockClear(); + }); + + it('returns a strategy with `search and `cancel`, `extend`', async () => { + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + expect(typeof esSearch.search).toBe('function'); + expect(typeof esSearch.cancel).toBe('function'); + expect(typeof esSearch.extend).toBe('function'); + }); + + describe('search', () => { + describe('no sessionId', () => { + it('makes a POST request with params when no ID provided', async () => { + mockSqlQuery.mockResolvedValueOnce(mockSqlResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ params }, {}, mockDeps).toPromise(); + + expect(mockSqlQuery).toBeCalled(); + const request = mockSqlQuery.mock.calls[0][0]; + expect(request.query).toEqual(params.query); + expect(request).toHaveProperty('format', 'json'); + expect(request).toHaveProperty('keep_alive', '1m'); + expect(request).toHaveProperty('wait_for_completion_timeout'); + }); + + it('makes a GET request to async search with ID', async () => { + mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ id: 'foo', params }, {}, mockDeps).toPromise(); + + expect(mockSqlGetAsync).toBeCalled(); + const request = mockSqlGetAsync.mock.calls[0][0]; + expect(request.id).toEqual('foo'); + expect(request).toHaveProperty('wait_for_completion_timeout'); + expect(request).toHaveProperty('keep_alive', '1m'); + expect(request).toHaveProperty('format', 'json'); + }); + }); + + // skip until full search session support https://github.com/elastic/kibana/issues/127880 + describe.skip('with sessionId', () => { + it('makes a POST request with params (long keepalive)', async () => { + mockSqlQuery.mockResolvedValueOnce(mockSqlResponse); + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ params }, { sessionId: '1' }, mockDeps).toPromise(); + + expect(mockSqlQuery).toBeCalled(); + const request = mockSqlQuery.mock.calls[0][0]; + expect(request.query).toEqual(params.query); + + expect(request).toHaveProperty('keep_alive', '604800000ms'); + }); + + it('makes a GET request to async search without keepalive', async () => { + mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ id: 'foo', params }, { sessionId: '1' }, mockDeps).toPromise(); + + expect(mockSqlGetAsync).toBeCalled(); + const request = mockSqlGetAsync.mock.calls[0][0]; + expect(request.id).toEqual('foo'); + expect(request).toHaveProperty('wait_for_completion_timeout'); + expect(request).not.toHaveProperty('keep_alive'); + }); + }); + + describe('with sessionId (until SQL ignores session Id)', () => { + it('makes a POST request with params (long keepalive)', async () => { + mockSqlQuery.mockResolvedValueOnce(mockSqlResponse); + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ params }, { sessionId: '1' }, mockDeps).toPromise(); + + expect(mockSqlQuery).toBeCalled(); + const request = mockSqlQuery.mock.calls[0][0]; + expect(request.query).toEqual(params.query); + + expect(request).toHaveProperty('wait_for_completion_timeout'); + expect(request).toHaveProperty('keep_alive', '1m'); + }); + + it('makes a GET request to async search with keepalive', async () => { + mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ id: 'foo', params }, { sessionId: '1' }, mockDeps).toPromise(); + + expect(mockSqlGetAsync).toBeCalled(); + const request = mockSqlGetAsync.mock.calls[0][0]; + expect(request.id).toEqual('foo'); + expect(request).toHaveProperty('wait_for_completion_timeout'); + expect(request).toHaveProperty('keep_alive', '1m'); + }); + }); + + it('throws normalized error if ResponseError is thrown', async () => { + const errResponse = new errors.ResponseError({ + body: indexNotFoundException, + statusCode: 404, + headers: {}, + warnings: [], + meta: {} as any, + }); + + mockSqlQuery.mockRejectedValue(errResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.search({ params }, {}, mockDeps).toPromise(); + } catch (e) { + err = e; + } + expect(mockSqlQuery).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(404); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(indexNotFoundException); + }); + + it('throws normalized error if Error is thrown', async () => { + const errResponse = new Error('not good'); + + mockSqlQuery.mockRejectedValue(errResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.search({ params }, {}, mockDeps).toPromise(); + } catch (e) { + err = e; + } + expect(mockSqlQuery).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(500); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(undefined); + }); + }); + + describe('cancel', () => { + it('makes a DELETE request to async search with the provided ID', async () => { + mockSqlDelete.mockResolvedValueOnce(200); + + const id = 'some_id'; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.cancel!(id, {}, mockDeps); + + expect(mockSqlDelete).toBeCalled(); + const request = mockSqlDelete.mock.calls[0][0]; + expect(request).toEqual({ id }); + }); + }); + + describe('extend', () => { + it('makes a GET request to async search with the provided ID and keepAlive', async () => { + mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse); + + const id = 'some_other_id'; + const keepAlive = '1d'; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + await esSearch.extend!(id, keepAlive, {}, mockDeps); + + expect(mockSqlGetAsync).toBeCalled(); + const request = mockSqlGetAsync.mock.calls[0][0]; + expect(request).toEqual({ id, keep_alive: keepAlive }); + }); + }); +}); diff --git a/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts new file mode 100644 index 00000000000000..51ab35af3db0f1 --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts @@ -0,0 +1,138 @@ +/* + * 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 { IScopedClusterClient, Logger } from 'kibana/server'; +import { catchError, tap } from 'rxjs/operators'; +import { SqlGetAsyncRequest, SqlQueryRequest } from '@elastic/elasticsearch/lib/api/types'; +import type { ISearchStrategy, SearchStrategyDependencies } from '../../types'; +import type { + IAsyncSearchOptions, + SqlSearchStrategyRequest, + SqlSearchStrategyResponse, +} from '../../../../common'; +import { pollSearch } from '../../../../common'; +import { getDefaultAsyncGetParams, getDefaultAsyncSubmitParams } from './request_utils'; +import { toAsyncKibanaSearchResponse } from './response_utils'; +import { getKbnServerError } from '../../../../../kibana_utils/server'; + +export const sqlSearchStrategyProvider = ( + logger: Logger, + useInternalUser: boolean = false +): ISearchStrategy => { + async function cancelAsyncSearch(id: string, esClient: IScopedClusterClient) { + try { + const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser; + await client.sql.deleteAsync({ id }); + } catch (e) { + throw getKbnServerError(e); + } + } + + function asyncSearch( + { id, ...request }: SqlSearchStrategyRequest, + options: IAsyncSearchOptions, + { esClient }: SearchStrategyDependencies + ) { + const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser; + + // disable search sessions until session task manager supports SQL + // https://github.com/elastic/kibana/issues/127880 + // const sessionConfig = searchSessionsClient.getConfig(); + const sessionConfig = null; + + const search = async () => { + if (id) { + const params: SqlGetAsyncRequest = { + format: request.params?.format ?? 'json', + ...getDefaultAsyncGetParams(sessionConfig, options), + id, + }; + + const { body, headers } = await client.sql.getAsync(params, { + signal: options.abortSignal, + meta: true, + }); + + return toAsyncKibanaSearchResponse(body, headers?.warning); + } else { + const params: SqlQueryRequest = { + format: request.params?.format ?? 'json', + ...getDefaultAsyncSubmitParams(sessionConfig, options), + ...request.params, + }; + + const { headers, body } = await client.sql.query(params, { + signal: options.abortSignal, + meta: true, + }); + + return toAsyncKibanaSearchResponse(body, headers?.warning); + } + }; + + const cancel = async () => { + if (id) { + await cancelAsyncSearch(id, esClient); + } + }; + + return pollSearch(search, cancel, options).pipe( + tap((response) => (id = response.id)), + catchError((e) => { + throw getKbnServerError(e); + }) + ); + } + + return { + /** + * @param request + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Observable>` + * @throws `KbnServerError` + */ + search: (request, options: IAsyncSearchOptions, deps) => { + logger.debug(`sql search: search request=${JSON.stringify(request)}`); + + return asyncSearch(request, options, deps); + }, + /** + * @param id async search ID to cancel, as returned from _async_search API + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Promise` + * @throws `KbnServerError` + */ + cancel: async (id, options, { esClient }) => { + logger.debug(`sql search: cancel async_search_id=${id}`); + await cancelAsyncSearch(id, esClient); + }, + /** + * + * @param id async search ID to extend, as returned from _async_search API + * @param keepAlive + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Promise` + * @throws `KbnServerError` + */ + extend: async (id, keepAlive, options, { esClient }) => { + logger.debug(`sql search: extend async_search_id=${id} keep_alive=${keepAlive}`); + try { + const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser; + await client.sql.getAsync({ + id, + keep_alive: keepAlive, + }); + } catch (e) { + throw getKbnServerError(e); + } + }, + }; +}; diff --git a/test/api_integration/apis/search/index.ts b/test/api_integration/apis/search/index.ts index d5d6e928b5483c..cde0c925d91ff8 100644 --- a/test/api_integration/apis/search/index.ts +++ b/test/api_integration/apis/search/index.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('search', () => { loadTestFile(require.resolve('./search')); + loadTestFile(require.resolve('./sql_search')); loadTestFile(require.resolve('./bsearch')); }); } diff --git a/test/api_integration/apis/search/sql_search.ts b/test/api_integration/apis/search/sql_search.ts new file mode 100644 index 00000000000000..c57d424e56fc73 --- /dev/null +++ b/test/api_integration/apis/search/sql_search.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const sqlQuery = `SELECT index, bytes FROM "logstash-*" ORDER BY "@timestamp" DESC`; + + describe('SQL search', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + }); + + after(async () => { + await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + }); + describe('post', () => { + it('should return 200 when correctly formatted searches are provided', async () => { + const resp = await supertest + .post(`/internal/search/sql`) + .send({ + params: { + query: sqlQuery, + }, + }) + .expect(200); + + expect(resp.body).to.have.property('id'); + expect(resp.body).to.have.property('isPartial'); + expect(resp.body).to.have.property('isRunning'); + expect(resp.body).to.have.property('rawResponse'); + }); + + it('should fetch search results by id', async () => { + const resp1 = await supertest + .post(`/internal/search/sql`) + .send({ + params: { + query: sqlQuery, + keep_on_completion: true, // force keeping the results even if completes early + }, + }) + .expect(200); + const id = resp1.body.id; + + const resp2 = await supertest.post(`/internal/search/sql/${id}`).send({}); + + expect(resp2.status).to.be(200); + expect(resp2.body.id).to.be(id); + expect(resp2.body).to.have.property('isPartial'); + expect(resp2.body).to.have.property('isRunning'); + expect(resp2.body).to.have.property('rawResponse'); + }); + }); + + describe('delete', () => { + it('should delete search', async () => { + const resp1 = await supertest + .post(`/internal/search/sql`) + .send({ + params: { + query: sqlQuery, + keep_on_completion: true, // force keeping the results even if completes early + }, + }) + .expect(200); + const id = resp1.body.id; + + // confirm it was saved + await supertest.post(`/internal/search/sql/${id}`).send({}).expect(200); + + // delete it + await supertest.delete(`/internal/search/sql/${id}`).send().expect(200); + + // check it was deleted + await supertest.post(`/internal/search/sql/${id}`).send({}).expect(404); + }); + }); + }); +} diff --git a/test/functional/apps/console/_autocomplete.ts b/test/functional/apps/console/_autocomplete.ts index cd17244b1f4983..4b424b2a79c66b 100644 --- a/test/functional/apps/console/_autocomplete.ts +++ b/test/functional/apps/console/_autocomplete.ts @@ -31,7 +31,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(PageObjects.console.isAutocompleteVisible()).to.be.eql(true); }); - describe('with a missing comma in query', () => { + // FLAKY: https://github.com/elastic/kibana/issues/126414 + describe.skip('with a missing comma in query', () => { const LINE_NUMBER = 4; beforeEach(async () => { await PageObjects.console.clearTextArea(); diff --git a/x-pack/plugins/alerting/common/alert.ts b/x-pack/plugins/alerting/common/alert.ts index dd85fadb498789..1628abff7efc17 100644 --- a/x-pack/plugins/alerting/common/alert.ts +++ b/x-pack/plugins/alerting/common/alert.ts @@ -76,6 +76,7 @@ export interface AlertAggregations { alertExecutionStatus: { [status: string]: number }; ruleEnabledStatus: { enabled: number; disabled: number }; ruleMutedStatus: { muted: number; unmuted: number }; + ruleSnoozedStatus: { snoozed: number }; } export interface MappedParamsProperties { diff --git a/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts b/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts index 81fb66ef5cf554..038e923f28f0c1 100644 --- a/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts +++ b/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts @@ -57,6 +57,9 @@ describe('aggregateRulesRoute', () => { muted: 2, unmuted: 39, }, + ruleSnoozedStatus: { + snoozed: 4, + }, }; rulesClient.aggregate.mockResolvedValueOnce(aggregateResult); @@ -88,6 +91,9 @@ describe('aggregateRulesRoute', () => { "muted": 2, "unmuted": 39, }, + "rule_snoozed_status": Object { + "snoozed": 4, + }, }, } `); @@ -120,6 +126,9 @@ describe('aggregateRulesRoute', () => { muted: 2, unmuted: 39, }, + rule_snoozed_status: { + snoozed: 4, + }, }, }); }); diff --git a/x-pack/plugins/alerting/server/routes/aggregate_rules.ts b/x-pack/plugins/alerting/server/routes/aggregate_rules.ts index ee05897848ecfc..8c44f57b83789a 100644 --- a/x-pack/plugins/alerting/server/routes/aggregate_rules.ts +++ b/x-pack/plugins/alerting/server/routes/aggregate_rules.ts @@ -49,12 +49,14 @@ const rewriteBodyRes: RewriteResponseCase = ({ alertExecutionStatus, ruleEnabledStatus, ruleMutedStatus, + ruleSnoozedStatus, ...rest }) => ({ ...rest, rule_execution_status: alertExecutionStatus, rule_enabled_status: ruleEnabledStatus, rule_muted_status: ruleMutedStatus, + rule_snoozed_status: ruleSnoozedStatus, }); export const aggregateRulesRoute = ( diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 666617dcf3fd89..5f5baf41affaeb 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -125,6 +125,13 @@ export interface RuleAggregation { doc_count: number; }>; }; + snoozed: { + buckets: Array<{ + key: number; + key_as_string: string; + doc_count: number; + }>; + }; } export interface ConstructorOptions { @@ -191,6 +198,7 @@ export interface AggregateResult { alertExecutionStatus: { [status: string]: number }; ruleEnabledStatus?: { enabled: number; disabled: number }; ruleMutedStatus?: { muted: number; unmuted: number }; + ruleSnoozedStatus?: { snoozed: number }; } export interface FindResult { @@ -368,19 +376,12 @@ export class RulesClient { await this.validateActions(ruleType, data.actions); - // Validate that schedule interval is not less than configured minimum + // Throw error if schedule interval is less than the minimum and we are enforcing it const intervalInMs = parseDuration(data.schedule.interval); - if (intervalInMs < this.minimumScheduleIntervalInMs) { - if (this.minimumScheduleInterval.enforce) { - throw Boom.badRequest( - `Error creating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` - ); - } else { - // just log warning but allow rule to be created - this.logger.warn( - `Rule schedule interval (${data.schedule.interval}) is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.` - ); - } + if (intervalInMs < this.minimumScheduleIntervalInMs && this.minimumScheduleInterval.enforce) { + throw Boom.badRequest( + `Error creating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` + ); } // Extract saved object references for this rule @@ -472,6 +473,14 @@ export class RulesClient { }); createdAlert.attributes.scheduledTaskId = scheduledTask.id; } + + // Log warning if schedule interval is less than the minimum but we're not enforcing it + if (intervalInMs < this.minimumScheduleIntervalInMs && !this.minimumScheduleInterval.enforce) { + this.logger.warn( + `Rule schedule interval (${data.schedule.interval}) for "${createdAlert.attributes.alertTypeId}" rule type with ID "${createdAlert.id}" is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.` + ); + } + return this.getAlertFromRaw( createdAlert.id, createdAlert.attributes.alertTypeId, @@ -858,6 +867,7 @@ export class RulesClient { ); throw error; } + const { filter: authorizationFilter } = authorizationTuple; const resp = await this.unsecuredSavedObjectsClient.find({ ...options, @@ -878,6 +888,13 @@ export class RulesClient { muted: { terms: { field: 'alert.attributes.muteAll' }, }, + snoozed: { + date_range: { + field: 'alert.attributes.snoozeEndTime', + format: 'strict_date_time', + ranges: [{ from: 'now' }], + }, + }, }, }); @@ -893,6 +910,7 @@ export class RulesClient { muted: 0, unmuted: 0, }, + ruleSnoozedStatus: { snoozed: 0 }, }; for (const key of RuleExecutionStatusValues) { @@ -934,6 +952,11 @@ export class RulesClient { unmuted: mutedBuckets.find((bucket) => bucket.key === 0)?.doc_count ?? 0, }; + const snoozedBuckets = resp.aggregations.snoozed.buckets; + ret.ruleSnoozedStatus = { + snoozed: snoozedBuckets.reduce((acc, bucket) => acc + bucket.doc_count, 0), + }; + return ret; } @@ -1117,19 +1140,12 @@ export class RulesClient { const validatedAlertTypeParams = validateRuleTypeParams(data.params, ruleType.validate?.params); await this.validateActions(ruleType, data.actions); - // Validate that schedule interval is not less than configured minimum + // Throw error if schedule interval is less than the minimum and we are enforcing it const intervalInMs = parseDuration(data.schedule.interval); - if (intervalInMs < this.minimumScheduleIntervalInMs) { - if (this.minimumScheduleInterval.enforce) { - throw Boom.badRequest( - `Error updating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` - ); - } else { - // just log warning but allow rule to be updated - this.logger.warn( - `Rule schedule interval (${data.schedule.interval}) is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` - ); - } + if (intervalInMs < this.minimumScheduleIntervalInMs && this.minimumScheduleInterval.enforce) { + throw Boom.badRequest( + `Error updating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` + ); } // Extract saved object references for this rule @@ -1192,6 +1208,13 @@ export class RulesClient { throw e; } + // Log warning if schedule interval is less than the minimum but we're not enforcing it + if (intervalInMs < this.minimumScheduleIntervalInMs && !this.minimumScheduleInterval.enforce) { + this.logger.warn( + `Rule schedule interval (${data.schedule.interval}) for "${ruleType.id}" rule type with ID "${id}" is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` + ); + } + return this.getPartialRuleFromRaw( id, ruleType, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts index aa910f4203f46a..af27decb73a2ae 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts @@ -101,6 +101,17 @@ describe('aggregate()', () => { { key: 1, key_as_string: '1', doc_count: 3 }, ], }, + snoozed: { + buckets: [ + { + key: '2022-03-21T20:22:01.501Z-*', + format: 'strict_date_time', + from: 1.647894121501e12, + from_as_string: '2022-03-21T20:22:01.501Z', + doc_count: 2, + }, + ], + }, }, }); @@ -146,6 +157,9 @@ describe('aggregate()', () => { "muted": 3, "unmuted": 27, }, + "ruleSnoozedStatus": Object { + "snoozed": 2, + }, } `); expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledTimes(1); @@ -166,6 +180,13 @@ describe('aggregate()', () => { muted: { terms: { field: 'alert.attributes.muteAll' }, }, + snoozed: { + date_range: { + field: 'alert.attributes.snoozeEndTime', + format: 'strict_date_time', + ranges: [{ from: 'now' }], + }, + }, }, }, ]); @@ -193,6 +214,13 @@ describe('aggregate()', () => { muted: { terms: { field: 'alert.attributes.muteAll' }, }, + snoozed: { + date_range: { + field: 'alert.attributes.snoozeEndTime', + format: 'strict_date_time', + ranges: [{ from: 'now' }], + }, + }, }, }, ]); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts index df0e806e5e7983..91be42ecd9e1f6 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts @@ -2602,7 +2602,7 @@ describe('create()', () => { await rulesClient.create({ data }); expect(rulesClientParams.logger.warn).toHaveBeenCalledWith( - `Rule schedule interval (1s) is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.` + `Rule schedule interval (1s) for "123" rule type with ID "1" is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.` ); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalled(); expect(taskManager.schedule).toHaveBeenCalled(); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts index a087dfd4368171..4bc0276a9ae1a7 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts @@ -1947,7 +1947,7 @@ describe('update()', () => { }, }); expect(rulesClientParams.logger.warn).toHaveBeenCalledWith( - `Rule schedule interval (1s) is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` + `Rule schedule interval (1s) for "myType" rule type with ID "1" is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` ); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts index 68e29f7afcc791..d69740c51d04d5 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts @@ -61,6 +61,7 @@ describe('APMEventClient', () => { apm: { events: [], }, + body: { size: 0 }, }); return res.ok({ body: 'ok' }); diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts index fdf023e197b7cc..4b8f63e33799c8 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts @@ -40,6 +40,9 @@ export type APMEventESSearchRequest = Omit & { events: ProcessorEvent[]; includeLegacyData?: boolean; }; + body: { + size: number; + }; }; export type APMEventESTermsEnumRequest = Omit & { diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts index d3f0fca0bb2596..3b17c656b06e35 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts @@ -14,7 +14,10 @@ describe('unpackProcessorEvents', () => { beforeEach(() => { const request = { apm: { events: ['transaction', 'error'] }, - body: { query: { bool: { filter: [{ terms: { foo: 'bar' } }] } } }, + body: { + size: 0, + query: { bool: { filter: [{ terms: { foo: 'bar' } }] } }, + }, } as APMEventESSearchRequest; const indices = { diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index 6d3789837d2d91..ae47abb01942e0 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -108,7 +108,7 @@ describe('setupRequest', () => { const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search('foo', { apm: { events: [ProcessorEvent.transaction] }, - body: { foo: 'bar' }, + body: { size: 10 }, }); expect( @@ -117,7 +117,7 @@ describe('setupRequest', () => { { index: ['apm-*'], body: { - foo: 'bar', + size: 10, query: { bool: { filter: [{ terms: { 'processor.event': ['transaction'] } }], @@ -172,6 +172,7 @@ describe('with includeFrozen=false', () => { apm: { events: [], }, + body: { size: 10 }, }); const params = @@ -193,6 +194,7 @@ describe('with includeFrozen=true', () => { await apmEventClient.search('foo', { apm: { events: [] }, + body: { size: 10 }, }); const params = diff --git a/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap b/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap index 56d735b5df1153..06e80110b6f20c 100644 --- a/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap @@ -31,6 +31,7 @@ Object { ], }, }, + "size": 1, }, "terminate_after": 1, } @@ -55,6 +56,7 @@ Object { ], }, }, + "size": 1, }, "terminate_after": 1, } @@ -82,6 +84,7 @@ Array [ ], }, }, + "size": 1, }, "terminate_after": 1, }, @@ -100,6 +103,7 @@ Array [ "filter": Array [], }, }, + "size": 0, }, "terminate_after": 1, }, diff --git a/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts b/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts index 12c47936374e1f..a28fe1ad1eceaa 100644 --- a/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts +++ b/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts @@ -64,6 +64,7 @@ async function getHasTransactions({ events: [ProcessorEvent.transaction], }, body: { + size: 0, query: { bool: { filter: [ diff --git a/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts b/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts index 577a7544d93ea4..573cb0a3cf6b49 100644 --- a/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts @@ -33,6 +33,7 @@ export async function getHasAggregatedTransactions({ events: [ProcessorEvent.metric], }, body: { + size: 1, query: { bool: { filter: [ diff --git a/x-pack/plugins/apm/server/projections/typings.ts b/x-pack/plugins/apm/server/projections/typings.ts index d252fd311b4fe5..5558fba4cde2a6 100644 --- a/x-pack/plugins/apm/server/projections/typings.ts +++ b/x-pack/plugins/apm/server/projections/typings.ts @@ -11,8 +11,9 @@ import { APMEventESSearchRequest } from '../lib/helpers/create_es_client/create_ export type Projection = Omit & { body: Omit< Required['body'], - 'aggs' | 'aggregations' + 'aggs' | 'aggregations' | 'size' > & { + size?: number; aggs?: { [key: string]: { terms: AggregationOptionsByType['terms'] & { field: string }; diff --git a/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts b/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts index 521a846c3e1df8..d8e4cf7af0bc58 100644 --- a/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts +++ b/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts @@ -64,7 +64,7 @@ export async function getOverallLatencyDistribution( { // TODO: add support for metrics apm: { events: [ProcessorEvent.transaction] }, - body: histogramIntervalRequestBody, + body: { size: 0, ...histogramIntervalRequestBody }, } )) as { aggregations?: { @@ -101,7 +101,7 @@ export async function getOverallLatencyDistribution( { // TODO: add support for metrics apm: { events: [ProcessorEvent.transaction] }, - body: transactionDurationRangesRequestBody, + body: { size: 0, ...transactionDurationRangesRequestBody }, } )) as { aggregations?: { diff --git a/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts b/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts index 3961b1a2ca6034..c40834919f7f55 100644 --- a/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts +++ b/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts @@ -31,7 +31,7 @@ export async function getPercentileThresholdValue( { // TODO: add support for metrics apm: { events: [ProcessorEvent.transaction] }, - body: transactionDurationPercentilesRequestBody, + body: { size: 0, ...transactionDurationPercentilesRequestBody }, } )) as { aggregations?: { diff --git a/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts b/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts index 009d974e337218..3713b4faa73d95 100644 --- a/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts +++ b/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts @@ -141,6 +141,7 @@ function getProfilesWithStacks({ events: [ProcessorEvent.profile], }, body: { + size: 0, query: { bool: { filter, diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap b/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap index 921129cf2c1da4..06011abc193c58 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap +++ b/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap @@ -42,8 +42,8 @@ Object { ], }, }, + "size": 1, }, - "size": 1, "terminate_after": 1, } `; @@ -61,8 +61,8 @@ Object { "filter": Array [], }, }, + "size": 1, }, - "size": 1, "terminate_after": 1, } `; diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts b/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts index 88d2ae9f339ac7..d4e21f219f3724 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts +++ b/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts @@ -36,8 +36,8 @@ export async function getTransaction({ apm: { events: [ProcessorEvent.transaction as const], }, - size: 1, body: { + size: 1, query: { bool: { filter: esFilters, diff --git a/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts b/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts index abfe089e82a383..aa8c2c0e3aa001 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts @@ -17,6 +17,7 @@ export async function getSearchStatus( asyncId: string ): Promise> { // TODO: Handle strategies other than the default one + // https://github.com/elastic/kibana/issues/127880 try { // @ts-expect-error start_time_in_millis: EpochMillis is string | number const apiResponse: TransportResult = await client.asyncSearch.status( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx similarity index 88% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx index 6a93291a28cb32..4917877c0ec306 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import '../../../../../__mocks__/shallow_useeffect.mock'; -import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; -import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; +import '../../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -18,8 +18,8 @@ import { EuiSteps } from '@elastic/eui'; import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, -} from '../../../../components/layout'; -import { staticSourceData } from '../../source_data'; +} from '../../../../../components/layout'; +import { staticSourceData } from '../../../source_data'; import { ExternalConnectorConfig } from './external_connector_config'; import { ExternalConnectorFormFields } from './external_connector_form_fields'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx similarity index 81% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx index 637be68929ac07..002cafa2e32298 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx @@ -21,17 +21,20 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { AppLogic } from '../../../../app_logic'; +import { AppLogic } from '../../../../../app_logic'; import { PersonalDashboardLayout, WorkplaceSearchPageTemplate, -} from '../../../../components/layout'; -import { NAV, REMOVE_BUTTON } from '../../../../constants'; -import { SourceDataItem } from '../../../../types'; +} from '../../../../../components/layout'; +import { NAV, REMOVE_BUTTON } from '../../../../../constants'; +import { SourceDataItem } from '../../../../../types'; -import { AddSourceHeader } from './add_source_header'; -import { ConfigDocsLinks } from './config_docs_links'; -import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from './constants'; +import { staticExternalSourceData } from '../../../source_data'; + +import { AddSourceHeader } from './../add_source_header'; +import { ConfigDocsLinks } from './../config_docs_links'; +import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from './../constants'; +import { ExternalConnectorDocumentation } from './external_connector_documentation'; import { ExternalConnectorFormFields } from './external_connector_form_fields'; import { ExternalConnectorLogic } from './external_connector_logic'; @@ -69,10 +72,14 @@ export const ExternalConnectorConfig: React.FC = ({ const { name, categories } = sourceConfigData; const { - configuration: { documentationUrl, applicationLinkTitle, applicationPortalUrl }, + configuration: { applicationLinkTitle, applicationPortalUrl }, } = sourceData; const { isOrganization } = useValues(AppLogic); + const { + configuration: { documentationUrl }, + } = staticExternalSourceData; + const saveButton = ( {OAUTH_SAVE_CONFIG_BUTTON} @@ -135,6 +142,8 @@ export const ExternalConnectorConfig: React.FC = ({ {header} + +
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx new file mode 100644 index 00000000000000..13b8967637ee1a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiText } from '@elastic/eui'; + +import { ExternalConnectorDocumentation } from './external_connector_documentation'; + +describe('ExternalDocumentation', () => { + it('renders', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(EuiText)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx new file mode 100644 index 00000000000000..437bf6f683198c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiText, EuiLink } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n-react'; + +interface ExternalConnectorDocumentationProps { + name: string; + documentationUrl: string; +} + +export const ExternalConnectorDocumentation: React.FC = ({ + name, + documentationUrl, +}) => { + return ( + +

+ +

+

+ + + + ), + }} + /> +

+

+ + + +

+

+ + + +

+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.test.tsx similarity index 95% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.test.tsx index 931a2f3517fbba..45a7dd122eabf3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import '../../../../../__mocks__/shallow_useeffect.mock'; -import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; +import '../../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../../../../__mocks__/kea_logic'; import React from 'react'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts similarity index 97% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts index 38bf74052541c4..0e9ad386a353df 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts @@ -10,18 +10,19 @@ import { mockFlashMessageHelpers, mockHttpValues, mockKibanaValues, -} from '../../../../../__mocks__/kea_logic'; -import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; +} from '../../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock'; import { nextTick } from '@kbn/test-jest-helpers'; -import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers'; +import { itShowsServerErrorAsFlashMessage } from '../../../../../../test_helpers'; -jest.mock('../../../../app_logic', () => ({ +jest.mock('../../../../../app_logic', () => ({ AppLogic: { values: { isOrganization: true } }, })); -import { AddSourceLogic, SourceConfigData } from './add_source_logic'; +import { AddSourceLogic, SourceConfigData } from '../add_source_logic'; + import { ExternalConnectorLogic, ExternalConnectorValues } from './external_connector_logic'; describe('ExternalConnectorLogic', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts similarity index 94% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts index 1f7edf0d8e2a94..3bf96a31dd8c57 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts @@ -13,14 +13,14 @@ import { flashAPIErrors, flashSuccessToast, clearFlashMessages, -} from '../../../../../shared/flash_messages'; -import { HttpLogic } from '../../../../../shared/http'; -import { KibanaLogic } from '../../../../../shared/kibana'; -import { AppLogic } from '../../../../app_logic'; +} from '../../../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../../../shared/http'; +import { KibanaLogic } from '../../../../../../shared/kibana'; +import { AppLogic } from '../../../../../app_logic'; -import { getAddPath, getSourcesPath } from '../../../../routes'; +import { getAddPath, getSourcesPath } from '../../../../../routes'; -import { AddSourceLogic, SourceConfigData } from './add_source_logic'; +import { AddSourceLogic, SourceConfigData } from '../add_source_logic'; export interface ExternalConnectorActions { fetchExternalSource: () => true; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts new file mode 100644 index 00000000000000..7f2871a9f5c752 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts @@ -0,0 +1,11 @@ +/* + * 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 { ExternalConnectorConfig } from './external_connector_config'; +export { ExternalConnectorFormFields } from './external_connector_form_fields'; +export { ExternalConnectorLogic } from './external_connector_logic'; +export { ExternalConnectorDocumentation } from './external_connector_documentation'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts index 21246defbb8630..6b335b1f7ffe41 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts @@ -29,6 +29,7 @@ import { FeatureIds } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; import { SourcesLogic } from '../../sources_logic'; +import { ExternalConnectorLogic } from './add_external_connector/external_connector_logic'; import { AddSourceLogic, AddSourceSteps, @@ -38,7 +39,6 @@ import { AddSourceValues, AddSourceProps, } from './add_source_logic'; -import { ExternalConnectorLogic } from './external_connector_logic'; describe('AddSourceLogic', () => { const { mount } = new LogicMounter(AddSourceLogic); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts index 8693cffc17e21a..c621e0ee16bd57 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts @@ -25,7 +25,10 @@ import { SourceDataItem } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; import { SourcesLogic } from '../../sources_logic'; -import { ExternalConnectorLogic, isValidExternalUrl } from './external_connector_logic'; +import { + ExternalConnectorLogic, + isValidExternalUrl, +} from './add_external_connector/external_connector_logic'; export interface AddSourceProps { sourceData: SourceDataItem; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx index 9a5673451cd1ab..8d8311d2a0a6f1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx @@ -30,7 +30,7 @@ interface CardProps { description: string; buttonText: string; onClick: () => void; - betaBadgeLabel?: string; + badgeLabel?: string; } export const ConfigurationChoice: React.FC = ({ @@ -75,14 +75,14 @@ export const ConfigurationChoice: React.FC = ({ description, buttonText, onClick, - betaBadgeLabel, + badgeLabel, }: CardProps) => ( {buttonText} @@ -96,13 +96,14 @@ export const ConfigurationChoice: React.FC = ({ title: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.title', { - defaultMessage: 'Default connector', + defaultMessage: 'Connector', } ), description: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.description', { - defaultMessage: 'Use our out-of-the-box connector to get started quickly.', + defaultMessage: + 'Use this connector to get started quickly without deploying additional infrastructure.', } ), buttonText: i18n.translate( @@ -111,6 +112,12 @@ export const ConfigurationChoice: React.FC = ({ defaultMessage: 'Connect', } ), + badgeLabel: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.recommendedLabel', + { + defaultMessage: 'Recommended', + } + ), onClick: goToInternal, }; @@ -118,13 +125,14 @@ export const ConfigurationChoice: React.FC = ({ title: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.title', { - defaultMessage: 'Custom connector', + defaultMessage: 'Connector Package', } ), description: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.description', { - defaultMessage: 'Set up a custom connector for more configurability and control.', + defaultMessage: + 'Deploy this connector package on self-managed infrastructure for advanced use cases.', } ), buttonText: i18n.translate( @@ -134,7 +142,7 @@ export const ConfigurationChoice: React.FC = ({ } ), onClick: goToExternal, - betaBadgeLabel: i18n.translate( + badgeLabel: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.betaLabel', { defaultMessage: 'Beta', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx index 5c234be583b9d0..3e35c608fcee89 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx @@ -18,8 +18,8 @@ import { EuiSteps, EuiButton, EuiButtonEmpty } from '@elastic/eui'; import { ApiKey } from '../../../../components/shared/api_key'; import { staticSourceData } from '../../source_data'; +import { ExternalConnectorFormFields } from './add_external_connector'; import { ConfigDocsLinks } from './config_docs_links'; -import { ExternalConnectorFormFields } from './external_connector_form_fields'; import { SaveConfig } from './save_config'; describe('SaveConfig', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx index d56efcdab95d66..eb887a9f8cc42f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx @@ -35,10 +35,11 @@ import { } from '../../../../constants'; import { Configuration } from '../../../../types'; +import { ExternalConnectorFormFields } from './add_external_connector'; +import { ExternalConnectorDocumentation } from './add_external_connector'; import { AddSourceLogic } from './add_source_logic'; import { ConfigDocsLinks } from './config_docs_links'; import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON, OAUTH_STEP_2 } from './constants'; -import { ExternalConnectorFormFields } from './external_connector_form_fields'; interface SaveConfigProps { header: React.ReactNode; @@ -224,6 +225,12 @@ export const SaveConfig: React.FC = ({ <> {header} + {serviceType === 'external' && ( + <> + + + + )}
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 361eccbe8da380..5b1e4d97ef4cdc 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 @@ -12,6 +12,35 @@ import { docLinks } from '../../../shared/doc_links'; import { SOURCE_NAMES, SOURCE_OBJ_TYPES, GITHUB_LINK_TITLE } from '../../constants'; import { FeatureIds, SourceDataItem } from '../../types'; +export const staticExternalSourceData: SourceDataItem = { + name: SOURCE_NAMES.SHAREPOINT, + iconName: SOURCE_NAMES.SHAREPOINT, + serviceType: 'external', + configuration: { + isPublicKey: false, + hasOauthRedirect: true, + needsBaseUrl: false, + documentationUrl: docLinks.workplaceSearchExternalSharePointOnline, + applicationPortalUrl: 'https://portal.azure.com/', + }, + objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.SITES, SOURCE_OBJ_TYPES.ALL_FILES], + features: { + basicOrgContext: [ + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + FeatureIds.GlobalAccessPermissions, + ], + basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], + platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], + platinumPrivateContext: [FeatureIds.Private, FeatureIds.SyncFrequency, FeatureIds.SyncedItems], + }, + accountContextOnly: false, + internalConnectorAvailable: true, + externalConnectorAvailable: false, + customConnectorAvailable: false, + isBeta: true, +}; + export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.BOX, @@ -502,39 +531,7 @@ export const staticSourceData: SourceDataItem[] = [ internalConnectorAvailable: true, externalConnectorAvailable: true, }, - // TODO: temporary hack until backend sends us stuff - { - name: SOURCE_NAMES.SHAREPOINT, - iconName: SOURCE_NAMES.SHAREPOINT, - serviceType: 'external', - configuration: { - isPublicKey: false, - hasOauthRedirect: true, - needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchExternalSharePointOnline, - applicationPortalUrl: 'https://portal.azure.com/', - }, - objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.SITES, SOURCE_OBJ_TYPES.ALL_FILES], - features: { - basicOrgContext: [ - FeatureIds.SyncFrequency, - FeatureIds.SyncedItems, - FeatureIds.GlobalAccessPermissions, - ], - basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], - platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], - platinumPrivateContext: [ - FeatureIds.Private, - FeatureIds.SyncFrequency, - FeatureIds.SyncedItems, - ], - }, - accountContextOnly: false, - internalConnectorAvailable: true, - externalConnectorAvailable: false, - customConnectorAvailable: false, - isBeta: true, - }, + staticExternalSourceData, { name: SOURCE_NAMES.SHAREPOINT_SERVER, iconName: SOURCE_NAMES.SHAREPOINT_SERVER, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx index e735119f687cc1..19af955f8780ca 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx @@ -30,8 +30,8 @@ import { hasMultipleConnectorOptions } from '../../utils'; import { AddSource, AddSourceList, GitHubViaApp } from './components/add_source'; import { AddCustomSource } from './components/add_source/add_custom_source'; +import { ExternalConnectorConfig } from './components/add_source/add_external_connector'; import { ConfigurationChoice } from './components/add_source/configuration_choice'; -import { ExternalConnectorConfig } from './components/add_source/external_connector_config'; import { OrganizationSources } from './organization_sources'; import { PrivateSources } from './private_sources'; import { staticCustomSourceData, staticSourceData as sources } from './source_data'; diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 8b62b8d0c120ce..105b9d24bb09b5 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -294,8 +294,8 @@ export function XYChart({ // This is a safe formatter for the xAccessor that abstracts the knowledge of already formatted layers const safeXAccessorLabelRenderer = (value: unknown): string => xAxisColumn && layersAlreadyFormatted[xAxisColumn.id] - ? (value as string) - : xAxisFormatter.convert(value); + ? String(value) + : String(xAxisFormatter.convert(value)); const chartHasMoreThanOneSeries = filteredLayers.length > 1 || diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx index cf6ae92d1b9c8d..939223feb87c0d 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx @@ -44,6 +44,7 @@ interface RuleStatsState { disabled: number; muted: number; error: number; + snoozed: number; } export interface TopAlert { @@ -90,6 +91,7 @@ function AlertsPage() { disabled: 0, muted: 0, error: 0, + snoozed: 0, }); useEffect(() => { @@ -111,18 +113,21 @@ function AlertsPage() { const response = await loadRuleAggregations({ http, }); - const { ruleExecutionStatus, ruleMutedStatus, ruleEnabledStatus } = response; - if (ruleExecutionStatus && ruleMutedStatus && ruleEnabledStatus) { + const { ruleExecutionStatus, ruleMutedStatus, ruleEnabledStatus, ruleSnoozedStatus } = + response; + if (ruleExecutionStatus && ruleMutedStatus && ruleEnabledStatus && ruleSnoozedStatus) { const total = Object.values(ruleExecutionStatus).reduce((acc, value) => acc + value, 0); const { disabled } = ruleEnabledStatus; const { muted } = ruleMutedStatus; const { error } = ruleExecutionStatus; + const { snoozed } = ruleSnoozedStatus; setRuleStats({ ...ruleStats, total, disabled, muted, error, + snoozed, }); } setRuleStatsLoading(false); @@ -263,9 +268,9 @@ function AlertsPage() { data-test-subj="statDisabled" />, ; + +export const BadArgument = memo( + ({ parsedInput, commandDefinition, children = null }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + + return ( + <> + + + + + {children} + + + + ); + } +); +BadArgument.displayName = 'BadArgument'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_failure.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_failure.tsx new file mode 100644 index 00000000000000..2205bb38d0aea9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_failure.tsx @@ -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 React, { memo } from 'react'; +import { EuiCallOut } from '@elastic/eui'; + +export interface CommandExecutionFailureProps { + error: Error; +} +export const CommandExecutionFailure = memo(({ error }) => { + return {error}; +}); +CommandExecutionFailure.displayName = 'CommandExecutionOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx new file mode 100644 index 00000000000000..8bb97699809142 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx @@ -0,0 +1,107 @@ +/* + * 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, { memo, ReactNode, useCallback, useEffect, useState } from 'react'; +import { EuiButton, EuiLoadingChart } from '@elastic/eui'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { CommandExecutionFailure } from './command_execution_failure'; +import { UserCommandInput } from './user_command_input'; +import { Command } from '../types'; +import { useCommandService } from '../hooks/state_selectors/use_command_service'; +import { useConsoleStateDispatch } from '../hooks/state_selectors/use_console_state_dispatch'; + +const CommandOutputContainer = styled.div` + position: relative; + + .run-in-background { + position: absolute; + right: 0; + top: 1em; + } +`; + +export interface CommandExecutionOutputProps { + command: Command; +} +export const CommandExecutionOutput = memo(({ command }) => { + const commandService = useCommandService(); + const [isRunning, setIsRunning] = useState(true); + const [output, setOutput] = useState(null); + const dispatch = useConsoleStateDispatch(); + + // FIXME:PT implement the `run in the background` functionality + const [showRunInBackground, setShowRunInTheBackground] = useState(false); + const handleRunInBackgroundClick = useCallback(() => { + setShowRunInTheBackground(false); + }, []); + + useEffect(() => { + (async () => { + const timeoutId = setTimeout(() => { + setShowRunInTheBackground(true); + }, 15000); + + try { + const commandOutput = await commandService.executeCommand(command); + setOutput(commandOutput.result); + + // FIXME: PT the console should scroll the bottom as well + } catch (error) { + setOutput(); + } + + clearTimeout(timeoutId); + setIsRunning(false); + setShowRunInTheBackground(false); + })(); + }, [command, commandService]); + + useEffect(() => { + if (!isRunning) { + dispatch({ type: 'scrollDown' }); + } + }, [isRunning, dispatch]); + + return ( + + {showRunInBackground && ( +
+ + + +
+ )} +
+ + {isRunning && ( + <> + + + )} +
+
{output}
+
+ ); +}); +CommandExecutionOutput.displayName = 'CommandExecutionOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.test.tsx new file mode 100644 index 00000000000000..e61318227cb1f9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConsoleProps } from '../../console'; +import { AppContextTestRender } from '../../../../../common/mock/endpoint'; +import { ConsoleTestSetup, getConsoleTestSetup } from '../../mocks'; + +describe('When entering data into the Console input', () => { + let render: (props?: Partial) => ReturnType; + let renderResult: ReturnType; + let enterCommand: ConsoleTestSetup['enterCommand']; + + beforeEach(() => { + const testSetup = getConsoleTestSetup(); + + ({ enterCommand } = testSetup); + render = (props = {}) => (renderResult = testSetup.renderConsole(props)); + }); + + it('should display what the user is typing', () => { + render(); + + enterCommand('c', { inputOnly: true }); + expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('c'); + + enterCommand('m', { inputOnly: true }); + expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('cm'); + }); + + it('should delete last character when BACKSPACE is pressed', () => { + render(); + + enterCommand('cm', { inputOnly: true }); + expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('cm'); + + enterCommand('{backspace}', { inputOnly: true, useKeyboard: true }); + expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('c'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx new file mode 100644 index 00000000000000..f9b12391e6f6bb --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, MouseEventHandler, useCallback, useRef, useState } from 'react'; +import { CommonProps, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; +import classNames from 'classnames'; +import { KeyCapture, KeyCaptureProps } from './key_capture'; +import { useConsoleStateDispatch } from '../../hooks/state_selectors/use_console_state_dispatch'; +import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../../hooks/state_selectors/use_data_test_subj'; + +const CommandInputContainer = styled.div` + .prompt { + padding-right: 1ch; + } + + .textEntered { + white-space: break-spaces; + } + + .cursor { + display: inline-block; + width: 0.5em; + height: 1em; + background-color: ${({ theme }) => theme.eui.euiTextColors.default}; + + animation: cursor-blink-animation 1s steps(5, start) infinite; + -webkit-animation: cursor-blink-animation 1s steps(5, start) infinite; + @keyframes cursor-blink-animation { + to { + visibility: hidden; + } + } + @-webkit-keyframes cursor-blink-animation { + to { + visibility: hidden; + } + } + + &.inactive { + background-color: transparent !important; + } + } +`; + +export interface CommandInputProps extends CommonProps { + prompt?: string; + isWaiting?: boolean; + focusRef?: KeyCaptureProps['focusRef']; +} + +export const CommandInput = memo( + ({ prompt = '>', focusRef, ...commonProps }) => { + const dispatch = useConsoleStateDispatch(); + const [textEntered, setTextEntered] = useState(''); + const [isKeyInputBeingCaptured, setIsKeyInputBeingCaptured] = useState(false); + const _focusRef: KeyCaptureProps['focusRef'] = useRef(null); + const textDisplayRef = useRef(null); + const getTestId = useTestIdGenerator(useDataTestSubj()); + + const keyCaptureFocusRef = focusRef || _focusRef; + + const handleKeyCaptureOnStateChange = useCallback< + NonNullable + >((isCapturing) => { + setIsKeyInputBeingCaptured(isCapturing); + }, []); + + const handleTypingAreaClick = useCallback( + (ev) => { + if (keyCaptureFocusRef.current) { + keyCaptureFocusRef.current(); + } + }, + [keyCaptureFocusRef] + ); + + const handleKeyCapture = useCallback( + ({ value, eventDetails }) => { + setTextEntered((prevState) => { + let updatedState = prevState + value; + + switch (eventDetails.keyCode) { + // BACKSPACE + // remove the last character from the text entered + case 8: + if (updatedState.length) { + updatedState = updatedState.replace(/.$/, ''); + } + break; + + // ENTER + // Execute command and blank out the input area + case 13: + dispatch({ type: 'executeCommand', payload: { input: updatedState } }); + return ''; + } + + return updatedState; + }); + }, + [dispatch] + ); + + return ( + + + + {prompt} + + + {textEntered} + + + + + + + + ); + } +); +CommandInput.displayName = 'CommandInput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/index.ts b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/index.ts new file mode 100644 index 00000000000000..4db81ade86011a --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { CommandInput } from './command_input'; +export type { CommandInputProps } from './command_input'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/key_capture.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/key_capture.tsx new file mode 100644 index 00000000000000..03bb133f88d793 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/key_capture.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, { + FormEventHandler, + KeyboardEventHandler, + memo, + MutableRefObject, + useCallback, + useRef, + useState, +} from 'react'; +import { pick } from 'lodash'; +import styled from 'styled-components'; +import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../../hooks/state_selectors/use_data_test_subj'; + +const NOOP = () => undefined; + +const KeyCaptureContainer = styled.span` + display: inline-block; + position: relative; + width: 1px; + height: 1em; + overflow: hidden; + + .invisible-input { + &, + &:focus { + border: none; + background-image: none; + background-color: transparent; + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; + animation: none !important; + width: 1ch !important; + position: absolute; + left: -100px; + top: -100px; + } + } +`; + +export interface KeyCaptureProps { + onCapture: (params: { + value: string | undefined; + eventDetails: Pick< + KeyboardEvent, + 'key' | 'altKey' | 'ctrlKey' | 'keyCode' | 'metaKey' | 'repeat' | 'shiftKey' + >; + }) => void; + onStateChange?: (isCapturing: boolean) => void; + focusRef?: MutableRefObject<((force?: boolean) => void) | null>; +} + +/** + * Key Capture is an invisible INPUT field that we set focus to when the user clicks inside of + * the console. It's sole purpose is to capture what the user types, which is then pass along to be + * displayed in a more UX friendly way + */ +export const KeyCapture = memo(({ onCapture, focusRef, onStateChange }) => { + // We don't need the actual value that was last input in this component, because + // `setLastInput()` is used with a function that returns the typed character. + // This state is used like this: + // 1. user presses a keyboard key + // 2. `input` event is triggered - we store the letter typed + // 3. the next event to be triggered (after `input`) that we listen for is `keyup`, + // and when that is triggered, we take the input letter (already stored) and + // call `onCapture()` with it and then set the lastInput state back to an empty string + const [, setLastInput] = useState(''); + const getTestId = useTestIdGenerator(useDataTestSubj()); + + const handleBlurAndFocus = useCallback( + (ev) => { + if (!onStateChange) { + return; + } + + onStateChange(ev.type === 'focus'); + }, + [onStateChange] + ); + + const handleOnKeyUp = useCallback>( + (ev) => { + ev.stopPropagation(); + + const eventDetails = pick(ev, [ + 'key', + 'altKey', + 'ctrlKey', + 'keyCode', + 'metaKey', + 'repeat', + 'shiftKey', + ]); + + setLastInput((value) => { + onCapture({ + value, + eventDetails, + }); + + return ''; + }); + }, + [onCapture] + ); + + const handleOnInput = useCallback>((ev) => { + const newValue = ev.currentTarget.value; + + setLastInput((prevState) => { + return `${prevState || ''}${newValue}`; + }); + }, []); + + const inputRef = useRef(null); + + const setFocus = useCallback((force: boolean = false) => { + // If user selected text and `force` is not true, then don't focus (else they lose selection) + if (!force && (window.getSelection()?.toString() ?? '').length > 0) { + return; + } + + inputRef.current?.focus(); + }, []); + + if (focusRef) { + focusRef.current = setFocus; + } + + // FIXME:PT probably need to add `aria-` type properties to the input? + return ( + + + + ); +}); +KeyCapture.displayName = 'KeyCapture'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx new file mode 100644 index 00000000000000..d7464e2f97391c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx @@ -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 React, { memo, useMemo } from 'react'; +import { EuiDescriptionList, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { CommandDefinition } from '../types'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export interface CommandListProps { + commands: CommandDefinition[]; +} + +export const CommandList = memo(({ commands }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + + const footerMessage = useMemo(() => { + return ( + {'some-command --help'}, + }} + /> + ); + }, []); + + return ( + <> + + {commands.map(({ name, about }) => { + return ( + + + + ); + })} + + {footerMessage} + + ); +}); +CommandList.displayName = 'CommandList'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx new file mode 100644 index 00000000000000..9d17d83f0266f9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx @@ -0,0 +1,114 @@ +/* + * 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, { memo, useMemo } from 'react'; +import { + EuiDescriptionList, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { usageFromCommandDefinition } from '../service/usage_from_command_definition'; +import { CommandDefinition } from '../types'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export const CommandInputUsage = memo>(({ commandDef }) => { + const usageHelp = useMemo(() => { + return usageFromCommandDefinition(commandDef); + }, [commandDef]); + + return ( + + + + + + + + + + {usageHelp} + + + + + ); +}); +CommandInputUsage.displayName = 'CommandInputUsage'; + +export interface CommandUsageProps { + commandDef: CommandDefinition; +} + +export const CommandUsage = memo(({ commandDef }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + const hasArgs = useMemo(() => Object.keys(commandDef.args ?? []).length > 0, [commandDef.args]); + const commandOptions = useMemo(() => { + // `command.args` only here to silence TS check + if (!hasArgs || !commandDef.args) { + return []; + } + + return Object.entries(commandDef.args).map(([option, { about: description }]) => ({ + title: `--${option}`, + description, + })); + }, [commandDef.args, hasArgs]); + const additionalProps = useMemo( + () => ({ + className: 'euiTruncateText', + }), + [] + ); + + return ( + + {commandDef.about} + + {hasArgs && ( + <> + +

+ + + {commandDef.mustHaveArgs && commandDef.args && hasArgs && ( + + + + )} + +

+ {commandDef.args && ( + + )} + + )} +
+ ); +}); +CommandUsage.displayName = 'CommandUsage'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_magenement_provider/index.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_magenement_provider/index.ts new file mode 100644 index 00000000000000..8d7de159bbc5a4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_magenement_provider/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. + */ + +// FIXME:PT implement a React context to manage consoles diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx new file mode 100644 index 00000000000000..852b2b1ab58fe2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useReducer, memo, createContext, PropsWithChildren, useContext } from 'react'; +import { InitialStateInterface, initiateState, stateDataReducer } from './state_reducer'; +import { ConsoleStore } from './types'; + +const ConsoleStateContext = createContext(null); + +type ConsoleStateProviderProps = PropsWithChildren<{}> & InitialStateInterface; + +/** + * A Console wide data store for internal state management between inner components + */ +export const ConsoleStateProvider = memo( + ({ commandService, scrollToBottom, dataTestSubj, children }) => { + const [state, dispatch] = useReducer( + stateDataReducer, + { commandService, scrollToBottom, dataTestSubj }, + initiateState + ); + + // FIXME:PT should handle cases where props that are in the store change + // Probably need to have a `useAffect()` that just does a `dispatch()` to update those. + + return ( + + {children} + + ); + } +); +ConsoleStateProvider.displayName = 'ConsoleStateProvider'; + +export const useConsoleStore = (): ConsoleStore => { + const store = useContext(ConsoleStateContext); + + if (!store) { + throw new Error(`ConsoleStateContext not defined`); + } + + return store; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/index.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/index.ts new file mode 100644 index 00000000000000..dc59ac1c2acef6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/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 { ConsoleStateProvider } from './console_state'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts new file mode 100644 index 00000000000000..94175d9821ae72 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts @@ -0,0 +1,42 @@ +/* + * 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 { ConsoleDataState, ConsoleStoreReducer } from './types'; +import { handleExecuteCommand } from './state_update_handlers/handle_execute_command'; +import { ConsoleBuiltinCommandsService } from '../../service/builtin_command_service'; + +export type InitialStateInterface = Pick< + ConsoleDataState, + 'commandService' | 'scrollToBottom' | 'dataTestSubj' +>; + +export const initiateState = ({ + commandService, + scrollToBottom, + dataTestSubj, +}: InitialStateInterface): ConsoleDataState => { + return { + commandService, + scrollToBottom, + dataTestSubj, + commandHistory: [], + builtinCommandService: new ConsoleBuiltinCommandsService(), + }; +}; + +export const stateDataReducer: ConsoleStoreReducer = (state, action) => { + switch (action.type) { + case 'scrollDown': + state.scrollToBottom(); + return state; + + case 'executeCommand': + return handleExecuteCommand(state, action); + } + + return state; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx new file mode 100644 index 00000000000000..b6a8e4db523406 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx @@ -0,0 +1,192 @@ +/* + * 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 { ConsoleProps } from '../../../console'; +import { AppContextTestRender } from '../../../../../../common/mock/endpoint'; +import { getConsoleTestSetup } from '../../../mocks'; +import type { ConsoleTestSetup } from '../../../mocks'; +import { waitFor } from '@testing-library/react'; + +describe('When a Console command is entered by the user', () => { + let render: (props?: Partial) => ReturnType; + let renderResult: ReturnType; + let commandServiceMock: ConsoleTestSetup['commandServiceMock']; + let enterCommand: ConsoleTestSetup['enterCommand']; + + beforeEach(() => { + const testSetup = getConsoleTestSetup(); + + ({ commandServiceMock, enterCommand } = testSetup); + render = (props = {}) => (renderResult = testSetup.renderConsole(props)); + }); + + it('should display all available commands when `help` command is entered', async () => { + render(); + enterCommand('help'); + + expect(renderResult.getByTestId('test-helpOutput')).toBeTruthy(); + + await waitFor(() => { + expect(renderResult.getAllByTestId('test-commandList-command')).toHaveLength( + // `+2` to account for builtin commands + commandServiceMock.getCommandList().length + 2 + ); + }); + }); + + it('should display custom help output when Command service has `getHelp()` defined', async () => { + commandServiceMock.getHelp = async () => { + return { + result:
{'help output'}
, + }; + }; + render(); + enterCommand('help'); + + await waitFor(() => { + expect(renderResult.getByTestId('custom-help')).toBeTruthy(); + }); + }); + + it('should clear the command output history when `clear` is entered', async () => { + render(); + enterCommand('help'); + enterCommand('help'); + + expect(renderResult.getByTestId('test-historyOutput').childElementCount).toBe(2); + + enterCommand('clear'); + + expect(renderResult.getByTestId('test-historyOutput').childElementCount).toBe(0); + }); + + it('should show individual command help when `--help` option is used', async () => { + render(); + enterCommand('cmd2 --help'); + + await waitFor(() => expect(renderResult.getByTestId('test-commandUsage')).toBeTruthy()); + }); + + it('should should custom command `--help` output when Command service defines `getCommandUsage()`', async () => { + commandServiceMock.getCommandUsage = async () => { + return { + result:
{'command help here'}
, + }; + }; + render(); + enterCommand('cmd2 --help'); + + await waitFor(() => expect(renderResult.getByTestId('cmd-help')).toBeTruthy()); + }); + + it('should execute a command entered', async () => { + render(); + enterCommand('cmd1'); + + await waitFor(() => { + expect(renderResult.getByTestId('exec-output')).toBeTruthy(); + }); + }); + + it('should allow multiple of the same options if `allowMultiples` is `true`', async () => { + render(); + enterCommand('cmd3 --foo one --foo two'); + + await waitFor(() => { + expect(renderResult.getByTestId('exec-output')).toBeTruthy(); + }); + }); + + it('should show error if unknown command', async () => { + render(); + enterCommand('foo-foo'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-unknownCommandError').textContent).toEqual( + 'Unknown commandFor a list of available command, enter: help' + ); + }); + }); + + it('should show error if options are used but command supports none', async () => { + render(); + enterCommand('cmd1 --foo'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'command does not support any argumentsUsage:cmd1' + ); + }); + }); + + it('should show error if unknown option is used', async () => { + render(); + enterCommand('cmd2 --file test --foo'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'unsupported argument: --fooUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it('should show error if any required option is not set', async () => { + render(); + enterCommand('cmd2 --ext one'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'missing required argument: --fileUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it('should show error if argument is used more than one', async () => { + render(); + enterCommand('cmd2 --file one --file two'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'argument can only be used once: --fileUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it("should show error returned by the option's `validate()` callback", async () => { + render(); + enterCommand('cmd2 --file one --bad foo'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'invalid argument value: --bad. This is a bad valueUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it('should show error no options were provided, bug command requires some', async () => { + render(); + enterCommand('cmd2'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'missing required arguments: --fileUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it('should show error if all arguments are optional, but at least 1 must be defined', async () => { + render(); + enterCommand('cmd4'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'at least one argument must be usedUsage:cmd4 [--foo --bar]' + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx new file mode 100644 index 00000000000000..2815ec46059171 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.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. + */ + +/* eslint complexity: ["error", 40]*/ +// FIXME:PT remove the complexity + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { ConsoleDataAction, ConsoleDataState, ConsoleStoreReducer } from '../types'; +import { parseCommandInput } from '../../../service/parsed_command_input'; +import { HistoryItem } from '../../history_item'; +import { UnknownCommand } from '../../unknow_comand'; +import { HelpOutput } from '../../help_output'; +import { BadArgument } from '../../bad_argument'; +import { CommandExecutionOutput } from '../../command_execution_output'; +import { CommandDefinition } from '../../../types'; + +const toCliArgumentOption = (argName: string) => `--${argName}`; + +const getRequiredArguments = (argDefinitions: CommandDefinition['args']): string[] => { + if (!argDefinitions) { + return []; + } + + return Object.entries(argDefinitions) + .filter(([_, argDef]) => argDef.required) + .map(([argName]) => argName); +}; + +const updateStateWithNewCommandHistoryItem = ( + state: ConsoleDataState, + newHistoryItem: ConsoleDataState['commandHistory'][number] +): ConsoleDataState => { + return { + ...state, + commandHistory: [...state.commandHistory, newHistoryItem], + }; +}; + +export const handleExecuteCommand: ConsoleStoreReducer< + ConsoleDataAction & { type: 'executeCommand' } +> = (state, action) => { + const parsedInput = parseCommandInput(action.payload.input); + + if (parsedInput.name === '') { + return state; + } + + const { commandService, builtinCommandService } = state; + + // Is it an internal command? + if (builtinCommandService.isBuiltin(parsedInput.name)) { + const commandOutput = builtinCommandService.executeBuiltinCommand(parsedInput, commandService); + + if (commandOutput.clearBuffer) { + return { + ...state, + commandHistory: [], + }; + } + + return updateStateWithNewCommandHistoryItem(state, commandOutput.result); + } + + // ---------------------------------------------------- + // Validate and execute the user defined command + // ---------------------------------------------------- + const commandDefinition = commandService + .getCommandList() + .find((definition) => definition.name === parsedInput.name); + + // Unknown command + if (!commandDefinition) { + return updateStateWithNewCommandHistoryItem( + state, + + + + ); + } + + const requiredArgs = getRequiredArguments(commandDefinition.args); + + // If args were entered, then validate them + if (parsedInput.hasArgs()) { + // Show command help + if (parsedInput.hasArg('help')) { + return updateStateWithNewCommandHistoryItem( + state, + + + {(commandService.getCommandUsage || builtinCommandService.getCommandUsage)( + commandDefinition + )} + + + ); + } + + // Command supports no arguments + if (!commandDefinition.args || Object.keys(commandDefinition.args).length === 0) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate( + 'xpack.securitySolution.console.commandValidation.noArgumentsSupported', + { + defaultMessage: 'command does not support any arguments', + } + )} + + + ); + } + + // no unknown arguments allowed? + if (parsedInput.unknownArgs && parsedInput.unknownArgs.length) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate('xpack.securitySolution.console.commandValidation.unknownArgument', { + defaultMessage: 'unknown argument(s): {unknownArgs}', + values: { + unknownArgs: parsedInput.unknownArgs.join(', '), + }, + })} + + + ); + } + + // Missing required Arguments + for (const requiredArg of requiredArgs) { + if (!parsedInput.args[requiredArg]) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate( + 'xpack.securitySolution.console.commandValidation.missingRequiredArg', + { + defaultMessage: 'missing required argument: {argName}', + values: { + argName: toCliArgumentOption(requiredArg), + }, + } + )} + + + ); + } + } + + // Validate each argument given to the command + for (const argName of Object.keys(parsedInput.args)) { + const argDefinition = commandDefinition.args[argName]; + const argInput = parsedInput.args[argName]; + + // Unknown argument + if (!argDefinition) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate('xpack.securitySolution.console.commandValidation.unsupportedArg', { + defaultMessage: 'unsupported argument: {argName}', + values: { argName: toCliArgumentOption(argName) }, + })} + + + ); + } + + // does not allow multiple values + if ( + !argDefinition.allowMultiples && + Array.isArray(argInput.values) && + argInput.values.length > 0 + ) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate( + 'xpack.securitySolution.console.commandValidation.argSupportedOnlyOnce', + { + defaultMessage: 'argument can only be used once: {argName}', + values: { argName: toCliArgumentOption(argName) }, + } + )} + + + ); + } + + if (argDefinition.validate) { + const validationResult = argDefinition.validate(argInput); + + if (validationResult !== true) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate( + 'xpack.securitySolution.console.commandValidation.invalidArgValue', + { + defaultMessage: 'invalid argument value: {argName}. {error}', + values: { argName: toCliArgumentOption(argName), error: validationResult }, + } + )} + + + ); + } + } + } + } else if (requiredArgs.length > 0) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate('xpack.securitySolution.console.commandValidation.mustHaveArgs', { + defaultMessage: 'missing required arguments: {requiredArgs}', + values: { + requiredArgs: requiredArgs.map((argName) => toCliArgumentOption(argName)).join(', '), + }, + })} + + + ); + } else if (commandDefinition.mustHaveArgs) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate('xpack.securitySolution.console.commandValidation.oneArgIsRequired', { + defaultMessage: 'at least one argument must be used', + })} + + + ); + } + + // All is good. Execute the command + return updateStateWithNewCommandHistoryItem( + state, + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts new file mode 100644 index 00000000000000..72810d31e3248b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Dispatch, Reducer } from 'react'; +import { CommandServiceInterface } from '../../types'; +import { HistoryItemComponent } from '../history_item'; +import { BuiltinCommandServiceInterface } from '../../service/types.builtin_command_service'; + +export interface ConsoleDataState { + /** Command service defined on input to the `Console` component by consumers of the component */ + commandService: CommandServiceInterface; + /** Command service for builtin console commands */ + builtinCommandService: BuiltinCommandServiceInterface; + /** UI function that scrolls the console down to the bottom */ + scrollToBottom: () => void; + /** + * List of commands entered by the user and being shown in the UI + */ + commandHistory: Array>; + dataTestSubj?: string; +} + +export type ConsoleDataAction = + | { type: 'scrollDown' } + | { type: 'executeCommand'; payload: { input: string } }; + +export interface ConsoleStore { + state: ConsoleDataState; + dispatch: Dispatch; +} + +export type ConsoleStoreReducer = Reducer< + ConsoleDataState, + A +>; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx new file mode 100644 index 00000000000000..b0a2217e169c43 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, ReactNode, useEffect, useState } from 'react'; +import { EuiCallOut, EuiCallOutProps, EuiLoadingChart } from '@elastic/eui'; +import { UserCommandInput } from './user_command_input'; +import { CommandExecutionFailure } from './command_execution_failure'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export interface HelpOutputProps extends Pick { + input: string; + children: ReactNode | Promise<{ result: ReactNode }>; +} +export const HelpOutput = memo(({ input, children, ...euiCalloutProps }) => { + const [content, setContent] = useState(); + const getTestId = useTestIdGenerator(useDataTestSubj()); + + useEffect(() => { + if (children instanceof Promise) { + (async () => { + try { + const response = await (children as Promise<{ + result: ReactNode; + }>); + setContent(response.result); + } catch (error) { + setContent(); + } + })(); + + return; + } + + setContent(children); + }, [children]); + + return ( +
+
+ +
+ + {content} + +
+ ); +}); +HelpOutput.displayName = 'HelpOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/history_item.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/history_item.tsx new file mode 100644 index 00000000000000..0143d36f0e766e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/history_item.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, PropsWithChildren } from 'react'; +import { EuiFlexItem } from '@elastic/eui'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export type HistoryItemProps = PropsWithChildren<{}>; + +export const HistoryItem = memo(({ children }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + + return ( + + {children} + + ); +}); + +HistoryItem.displayName = 'HistoryItem'; + +export type HistoryItemComponent = typeof HistoryItem; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx new file mode 100644 index 00000000000000..088a6fac57ae4e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx @@ -0,0 +1,42 @@ +/* + * 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, { memo, useEffect } from 'react'; +import { CommonProps, EuiFlexGroup } from '@elastic/eui'; +import { useCommandHistory } from '../hooks/state_selectors/use_command_history'; +import { useConsoleStateDispatch } from '../hooks/state_selectors/use_console_state_dispatch'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export type OutputHistoryProps = CommonProps; + +export const HistoryOutput = memo((commonProps) => { + const historyItems = useCommandHistory(); + const dispatch = useConsoleStateDispatch(); + const getTestId = useTestIdGenerator(useDataTestSubj()); + + // Anytime we add a new item to the history + // scroll down so that command input remains visible + useEffect(() => { + dispatch({ type: 'scrollDown' }); + }, [dispatch, historyItems.length]); + + return ( + + {historyItems} + + ); +}); + +HistoryOutput.displayName = 'HistoryOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx new file mode 100644 index 00000000000000..5529457cbb05a5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx @@ -0,0 +1,46 @@ +/* + * 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, { memo } from 'react'; +import { EuiCallOut, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { UserCommandInput } from './user_command_input'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; + +export interface UnknownCommand { + input: string; +} +export const UnknownCommand = memo(({ input }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + + return ( + <> +
+ +
+ + + + + + {'help'}, + }} + /> + + + + ); +}); +UnknownCommand.displayName = 'UnknownCommand'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/user_command_input.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/user_command_input.tsx new file mode 100644 index 00000000000000..84afff3f28209c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/user_command_input.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; + +export interface UserCommandInputProps { + input: string; +} + +export const UserCommandInput = memo(({ input }) => { + return ( + <> + {'$ '} + {input} + + ); +}); +UserCommandInput.displayName = 'UserCommandInput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/console.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/console.test.tsx new file mode 100644 index 00000000000000..9adeaa72d683ee --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/console.test.tsx @@ -0,0 +1,41 @@ +/* + * 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 { AppContextTestRender } from '../../../common/mock/endpoint'; +import { ConsoleProps } from './console'; +import { getConsoleTestSetup } from './mocks'; +import userEvent from '@testing-library/user-event'; + +describe('When using Console component', () => { + let render: (props?: Partial) => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const testSetup = getConsoleTestSetup(); + + render = (props = {}) => (renderResult = testSetup.renderConsole(props)); + }); + + it('should render console', () => { + render(); + + expect(renderResult.getByTestId('test')).toBeTruthy(); + }); + + it('should display prompt given on input', () => { + render({ prompt: 'MY PROMPT>>' }); + + expect(renderResult.getByTestId('test-cmdInput-prompt').textContent).toEqual('MY PROMPT>>'); + }); + + it('should focus on input area when it gains focus', () => { + render(); + userEvent.click(renderResult.getByTestId('test-mainPanel')); + + expect(document.activeElement!.classList.contains('invisible-input')).toBe(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/console/console.tsx b/x-pack/plugins/security_solution/public/management/components/console/console.tsx new file mode 100644 index 00000000000000..6c64a045c86fe4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/console.tsx @@ -0,0 +1,100 @@ +/* + * 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, { memo, useCallback, useRef } from 'react'; +import { CommonProps, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import styled from 'styled-components'; +import { HistoryOutput } from './components/history_output'; +import { CommandInput, CommandInputProps } from './components/command_input'; +import { CommandServiceInterface } from './types'; +import { ConsoleStateProvider } from './components/console_state'; +import { useTestIdGenerator } from '../hooks/use_test_id_generator'; + +// FIXME:PT implement dark mode for the console or light mode switch + +const ConsoleWindow = styled.div` + height: 100%; + + // FIXME: IMPORTANT - this should NOT be used in production + // dark mode on light theme / light mode on dark theme + filter: invert(100%); + + .ui-panel { + min-width: ${({ theme }) => theme.eui.euiBreakpoints.s}; + height: 100%; + min-height: 300px; + overflow-y: auto; + } + + .descriptionList-20_80 { + &.euiDescriptionList { + > .euiDescriptionList__title { + width: 20%; + } + + > .euiDescriptionList__description { + width: 80%; + } + } + } +`; + +export interface ConsoleProps extends CommonProps, Pick { + commandService: CommandServiceInterface; +} + +export const Console = memo(({ prompt, commandService, ...commonProps }) => { + const consoleWindowRef = useRef(null); + const inputFocusRef: CommandInputProps['focusRef'] = useRef(null); + const getTestId = useTestIdGenerator(commonProps['data-test-subj']); + + const scrollToBottom = useCallback(() => { + // We need the `setTimeout` here because in some cases, the command output + // will take a bit of time to populate its content due to the use of Promises + setTimeout(() => { + if (consoleWindowRef.current) { + consoleWindowRef.current.scrollTop = consoleWindowRef.current.scrollHeight; + } + }, 1); + + // NOTE: its IMPORTANT that this callback does NOT have any dependencies, because + // it is stored in State and currently not updated if it changes + }, []); + + const handleConsoleClick = useCallback(() => { + if (inputFocusRef.current) { + inputFocusRef.current(); + } + }, []); + + return ( + + + + + + + + + + + + + + + ); +}); + +Console.displayName = 'Console'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts new file mode 100644 index 00000000000000..22167d50667433 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useConsoleStore } from '../../components/console_state/console_state'; +import { CommandServiceInterface } from '../../types'; + +export const useCommandService = (): CommandServiceInterface => { + return useConsoleStore().state.builtinCommandService; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_history.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_history.ts new file mode 100644 index 00000000000000..ded51471a1c3e6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_history.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 { useConsoleStore } from '../../components/console_state/console_state'; + +export const useCommandHistory = () => { + return useConsoleStore().state.commandHistory; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts new file mode 100644 index 00000000000000..66ce0c2b5eb439 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useConsoleStore } from '../../components/console_state/console_state'; +import { CommandServiceInterface } from '../../types'; + +export const useCommandService = (): CommandServiceInterface => { + return useConsoleStore().state.commandService; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_console_state_dispatch.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_console_state_dispatch.ts new file mode 100644 index 00000000000000..90e5fe094f9c7b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_console_state_dispatch.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useConsoleStore } from '../../components/console_state/console_state'; +import { ConsoleStore } from '../../components/console_state/types'; + +export const useConsoleStateDispatch = (): ConsoleStore['dispatch'] => { + return useConsoleStore().dispatch; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts new file mode 100644 index 00000000000000..144a5a63cd71b6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.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 { useConsoleStore } from '../../components/console_state/console_state'; + +export const useDataTestSubj = (): string | undefined => { + return useConsoleStore().state.dataTestSubj; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/index.ts b/x-pack/plugins/security_solution/public/management/components/console/index.ts new file mode 100644 index 00000000000000..81244b3013b364 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/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 { Console } from './console'; +export type { ConsoleProps } from './console'; +export type { CommandServiceInterface, CommandDefinition, Command } from './types'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx b/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx new file mode 100644 index 00000000000000..693daf83ed6ea9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx @@ -0,0 +1,176 @@ +/* + * 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. + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +import React from 'react'; +import { EuiCode } from '@elastic/eui'; +import userEvent from '@testing-library/user-event'; +import { act } from '@testing-library/react'; +import { Console } from './console'; +import type { ConsoleProps } from './console'; +import type { Command, CommandServiceInterface } from './types'; +import type { AppContextTestRender } from '../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; +import { CommandDefinition } from './types'; + +export interface ConsoleTestSetup { + renderConsole(props?: Partial): ReturnType; + + commandServiceMock: jest.Mocked; + + enterCommand( + cmd: string, + options?: Partial<{ + /** If true, the ENTER key will not be pressed */ + inputOnly: boolean; + /** + * if true, then the keyboard keys will be used to send the command. + * Use this if wanting ot press keyboard keys other than letter/punctuation + */ + useKeyboard: boolean; + }> + ): void; +} + +export const getConsoleTestSetup = (): ConsoleTestSetup => { + const mockedContext = createAppRootMockRenderer(); + + let renderResult: ReturnType; + + const commandServiceMock = getCommandServiceMock(); + + const renderConsole: ConsoleTestSetup['renderConsole'] = ({ + prompt = '$$>', + commandService = commandServiceMock, + 'data-test-subj': dataTestSubj = 'test', + ...others + } = {}) => { + if (commandService !== commandServiceMock) { + throw new Error('Must use CommandService provided by test setup'); + } + + return (renderResult = mockedContext.render( + + )); + }; + + const enterCommand: ConsoleTestSetup['enterCommand'] = ( + cmd, + { inputOnly = false, useKeyboard = false } = {} + ) => { + const keyCaptureInput = renderResult.getByTestId('test-keyCapture-input'); + + act(() => { + if (useKeyboard) { + userEvent.click(keyCaptureInput); + userEvent.keyboard(cmd); + } else { + userEvent.type(keyCaptureInput, cmd); + } + + if (!inputOnly) { + userEvent.keyboard('{enter}'); + } + }); + }; + + return { + renderConsole, + commandServiceMock, + enterCommand, + }; +}; + +export const getCommandServiceMock = (): jest.Mocked => { + return { + getCommandList: jest.fn(() => { + const commands: CommandDefinition[] = [ + { + name: 'cmd1', + about: 'a command with no options', + }, + { + name: 'cmd2', + about: 'runs cmd 2', + args: { + file: { + about: 'Includes file in the run', + required: true, + allowMultiples: false, + validate: () => { + return true; + }, + }, + ext: { + about: 'optional argument', + required: false, + allowMultiples: false, + }, + bad: { + about: 'will fail validation', + required: false, + allowMultiples: false, + validate: () => 'This is a bad value', + }, + }, + }, + { + name: 'cmd3', + about: 'allows argument to be used multiple times', + args: { + foo: { + about: 'foo stuff', + required: true, + allowMultiples: true, + }, + }, + }, + { + name: 'cmd4', + about: 'all options optinal, but at least one is required', + mustHaveArgs: true, + args: { + foo: { + about: 'foo stuff', + required: false, + allowMultiples: true, + }, + bar: { + about: 'bar stuff', + required: false, + allowMultiples: true, + }, + }, + }, + ]; + + return commands; + }), + + executeCommand: jest.fn(async (command: Command) => { + await new Promise((r) => setTimeout(r, 1)); + + return { + result: ( +
+
{`${command.commandDefinition.name}`}
+
{`command input: ${command.input}`}
+ + {JSON.stringify(command.args, null, 2)} + +
+ ), + }; + }), + }; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx b/x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx new file mode 100644 index 00000000000000..6cd8af0dc6eff6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx @@ -0,0 +1,102 @@ +/* + * 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, { ReactNode } from 'react'; +import { i18n } from '@kbn/i18n'; +import { HistoryItem, HistoryItemComponent } from '../components/history_item'; +import { HelpOutput } from '../components/help_output'; +import { ParsedCommandInput } from './parsed_command_input'; +import { CommandList } from '../components/command_list'; +import { CommandUsage } from '../components/command_usage'; +import { Command, CommandDefinition, CommandServiceInterface } from '../types'; +import { BuiltinCommandServiceInterface } from './types.builtin_command_service'; + +const builtInCommands = (): CommandDefinition[] => { + return [ + { + name: 'help', + about: i18n.translate('xpack.securitySolution.console.builtInCommands.helpAbout', { + defaultMessage: 'View list of available commands', + }), + }, + { + name: 'clear', + about: i18n.translate('xpack.securitySolution.console.builtInCommands.clearAbout', { + defaultMessage: 'Clear the console buffer', + }), + }, + ]; +}; + +export class ConsoleBuiltinCommandsService implements BuiltinCommandServiceInterface { + constructor(private commandList = builtInCommands()) {} + + getCommandList(): CommandDefinition[] { + return this.commandList; + } + + async executeCommand(command: Command): Promise<{ result: ReactNode }> { + return { + result: null, + }; + } + + executeBuiltinCommand( + parsedInput: ParsedCommandInput, + contextConsoleService: CommandServiceInterface + ): { result: ReturnType | null; clearBuffer?: boolean } { + switch (parsedInput.name) { + case 'help': + return { + result: ( + + + {this.getHelpContent(parsedInput, contextConsoleService)} + + + ), + }; + + case 'clear': + return { + result: null, + clearBuffer: true, + }; + } + + return { result: null }; + } + + async getHelpContent( + parsedInput: ParsedCommandInput, + commandService: CommandServiceInterface + ): Promise<{ result: ReactNode }> { + let helpOutput: ReactNode; + + if (commandService.getHelp) { + helpOutput = (await commandService.getHelp()).result; + } else { + helpOutput = ( + + ); + } + + return { + result: helpOutput, + }; + } + + isBuiltin(name: string): boolean { + return !!this.commandList.find((command) => command.name === name); + } + + async getCommandUsage(command: CommandDefinition): Promise<{ result: ReactNode }> { + return { + result: , + }; + } +} diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts b/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts new file mode 100644 index 00000000000000..55e0b3dc6267bb --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts @@ -0,0 +1,95 @@ +/* + * 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. + */ + +// @ts-ignore +// eslint-disable-next-line import/no-extraneous-dependencies +import argsplit from 'argsplit'; + +// FIXME:PT use a 3rd party lib for arguments parsing +// For now, just using what I found in kibana package.json devDependencies, so this will NOT work for production + +// FIXME:PT Type `ParsedCommandInput` should be a generic that allows for the args's keys to be defined + +export interface ParsedArgData { + /** For arguments that were used only once. Will be `undefined` if multiples were used */ + value: undefined | string; + /** For arguments that were used multiple times */ + values: undefined | string[]; +} + +export interface ParsedCommandInput { + input: string; + name: string; + args: { + [argName: string]: ParsedArgData; + }; + unknownArgs: undefined | string[]; + hasArgs(): boolean; + hasArg(argName: string): boolean; +} + +const PARSED_COMMAND_INPUT_PROTOTYPE: Pick = Object.freeze({ + hasArgs(this: ParsedCommandInput) { + return Object.keys(this.args).length > 0 || Array.isArray(this.unknownArgs); + }, + + hasArg(argName: string): boolean { + // @ts-ignore + return Object.prototype.hasOwnProperty.call(this.args, argName); + }, +}); + +export const parseCommandInput = (input: string): ParsedCommandInput => { + const inputTokens: string[] = argsplit(input) || []; + const name: string = inputTokens.shift() || ''; + const args: ParsedCommandInput['args'] = {}; + let unknownArgs: ParsedCommandInput['unknownArgs']; + + // All options start with `--` + let argName = ''; + + for (const inputToken of inputTokens) { + if (inputToken.startsWith('--')) { + argName = inputToken.substr(2); + + if (!args[argName]) { + args[argName] = { + value: undefined, + values: undefined, + }; + } + + // eslint-disable-next-line no-continue + continue; + } else if (!argName) { + (unknownArgs = unknownArgs || []).push(inputToken); + + // eslint-disable-next-line no-continue + continue; + } + + if (Array.isArray(args[argName].values)) { + // @ts-ignore + args[argName].values.push(inputToken); + } else { + // Do we have multiple values for this argumentName, then create array for values + if (args[argName].value !== undefined) { + args[argName].values = [args[argName].value ?? '', inputToken]; + args[argName].value = undefined; + } else { + args[argName].value = inputToken; + } + } + } + + return Object.assign(Object.create(PARSED_COMMAND_INPUT_PROTOTYPE), { + input, + name, + args, + unknownArgs, + }); +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts b/x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts new file mode 100644 index 00000000000000..dbd5347ea99c22 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ReactNode } from 'react'; +import { CommandDefinition, CommandServiceInterface } from '../types'; +import { ParsedCommandInput } from './parsed_command_input'; +import { HistoryItemComponent } from '../components/history_item'; + +export interface BuiltinCommandServiceInterface extends CommandServiceInterface { + executeBuiltinCommand( + parsedInput: ParsedCommandInput, + contextConsoleService: CommandServiceInterface + ): { result: ReturnType | null; clearBuffer?: boolean }; + + getHelpContent( + parsedInput: ParsedCommandInput, + commandService: CommandServiceInterface + ): Promise<{ result: ReactNode }>; + + isBuiltin(name: string): boolean; + + getCommandUsage(command: CommandDefinition): Promise<{ result: ReactNode }>; +} diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/usage_from_command_definition.ts b/x-pack/plugins/security_solution/public/management/components/console/service/usage_from_command_definition.ts new file mode 100644 index 00000000000000..edc7d404fd8dde --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/service/usage_from_command_definition.ts @@ -0,0 +1,33 @@ +/* + * 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 { CommandDefinition } from '../types'; + +export const usageFromCommandDefinition = (command: CommandDefinition): string => { + let requiredArgs = ''; + let optionalArgs = ''; + + if (command.args) { + for (const [argName, argDefinition] of Object.entries(command.args)) { + if (argDefinition.required) { + if (requiredArgs.length) { + requiredArgs += ' '; + } + requiredArgs += `--${argName}`; + } else { + if (optionalArgs.length) { + optionalArgs += ' '; + } + optionalArgs += `--${argName}`; + } + } + } + + return `${command.name} ${requiredArgs} ${ + optionalArgs.length > 0 ? `[${optionalArgs}]` : '' + }`.trim(); +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/types.ts b/x-pack/plugins/security_solution/public/management/components/console/types.ts new file mode 100644 index 00000000000000..e2b6d5c2a84aa5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/types.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 { ReactNode } from 'react'; +import { ParsedArgData, ParsedCommandInput } from './service/parsed_command_input'; + +export interface CommandDefinition { + name: string; + about: string; + validator?: () => Promise; + /** If all args are optional, but at least one must be defined, set to true */ + mustHaveArgs?: boolean; + args?: { + [longName: string]: { + required: boolean; + allowMultiples: boolean; + about: string; + /** + * Validate the individual values given to this argument. + * Should return `true` if valid or a string with the error message + */ + validate?: (argData: ParsedArgData) => true | string; + // Selector: Idea is that the schema can plugin in a rich component for the + // user to select something (ex. a file) + // FIXME: implement selector + selector?: () => unknown; + }; + }; +} + +/** + * A command to be executed (as entered by the user) + */ +export interface Command { + /** The raw input entered by the user */ + input: string; + // FIXME:PT this should be a generic that allows for the arguments type to be used + /** An object with the arguments entered by the user and their value */ + args: ParsedCommandInput; + /** The command defined associated with this user command */ + commandDefinition: CommandDefinition; +} + +export interface CommandServiceInterface { + getCommandList(): CommandDefinition[]; + + executeCommand(command: Command): Promise<{ result: ReactNode }>; + + /** + * If defined, then the `help` builtin command will display this output instead of the default one + * which is generated out of the Command list + */ + getHelp?: () => Promise<{ result: ReactNode }>; + + /** + * If defined, then the output of this function will be used to display individual + * command help (`--help`) + */ + getCommandUsage?: (command: CommandDefinition) => Promise<{ result: ReactNode }>; +} diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx new file mode 100644 index 00000000000000..28472e123380ad --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.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, { memo, useMemo } from 'react'; +import { Console } from '../console'; +import { EndpointConsoleCommandService } from './endpoint_console_command_service'; +import type { HostMetadata } from '../../../../common/endpoint/types'; + +export interface EndpointConsoleProps { + endpoint: HostMetadata; +} + +export const EndpointConsole = memo((props) => { + const consoleService = useMemo(() => { + return new EndpointConsoleCommandService(); + }, []); + + return `} commandService={consoleService} />; +}); + +EndpointConsole.displayName = 'EndpointConsole'; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx new file mode 100644 index 00000000000000..5028879bc1a49b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ReactNode } from 'react'; +import { CommandServiceInterface, CommandDefinition, Command } from '../console'; + +/** + * Endpoint specific Response Actions (commands) for use with Console. + */ +export class EndpointConsoleCommandService implements CommandServiceInterface { + getCommandList(): CommandDefinition[] { + return []; + } + + async executeCommand(command: Command): Promise<{ result: ReactNode }> { + return { result: <> }; + } +} diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts b/x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts new file mode 100644 index 00000000000000..97f7fb61ae607d --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_console/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 { EndpointConsole } from './endpoint_console'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/dev_console.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/dev_console.tsx new file mode 100644 index 00000000000000..7fb057809919e8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/dev_console.tsx @@ -0,0 +1,95 @@ +/* + * 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, { memo, useMemo } from 'react'; +import { EuiCode } from '@elastic/eui'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { useUrlParams } from '../../../components/hooks/use_url_params'; +import { + Command, + CommandDefinition, + CommandServiceInterface, + Console, +} from '../../../components/console'; + +const delay = async (ms: number = 4000) => new Promise((r) => setTimeout(r, ms)); + +class DevCommandService implements CommandServiceInterface { + getCommandList(): CommandDefinition[] { + return [ + { + name: 'cmd1', + about: 'Runs cmd1', + }, + { + name: 'cmd2', + about: 'runs cmd 2', + args: { + file: { + required: true, + allowMultiples: false, + about: 'Includes file in the run', + validate: () => { + return true; + }, + }, + bad: { + required: false, + allowMultiples: false, + about: 'will fail validation', + validate: () => 'This is a bad value', + }, + }, + }, + { + name: 'cmd-long-delay', + about: 'runs cmd 2', + }, + ]; + } + + async executeCommand(command: Command): Promise<{ result: React.ReactNode }> { + await delay(); + + if (command.commandDefinition.name === 'cmd-long-delay') { + await delay(20000); + } + + return { + result: ( +
+
{`${command.commandDefinition.name}`}
+
{`command input: ${command.input}`}
+ {JSON.stringify(command.args, null, 2)} +
+ ), + }; + } +} + +// ------------------------------------------------------------ +// FOR DEV PURPOSES ONLY +// FIXME:PT Delete once we have support via row actions menu +// ------------------------------------------------------------ +export const DevConsole = memo(() => { + const isConsoleEnabled = useIsExperimentalFeatureEnabled('responseActionsConsoleEnabled'); + + const consoleService = useMemo(() => { + return new DevCommandService(); + }, []); + + const { + urlParams: { showConsole = false }, + } = useUrlParams(); + + return isConsoleEnabled && showConsole ? ( +
+ +
+ ) : null; +}); +DevConsole.displayName = 'DevConsole'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index da6f3b54323c5b..3946edb9a09813 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -68,6 +68,7 @@ import { BackToExternalAppButton, BackToExternalAppButtonProps, } from '../../../components/back_to_external_app_button/back_to_external_app_button'; +import { DevConsole } from './dev_console'; const MAX_PAGINATED_ITEM = 9999; const TRANSFORM_URL = '/data/transform'; @@ -664,6 +665,9 @@ export const EndpointList = () => { } headerBackComponent={routeState.backLink && backToPolicyList} > + {/* FIXME: Remove once Console is implemented via ConsoleManagementProvider */} + + {hasSelectedEndpoint && } <> {areEndpointsEnrolling && !hasErrorFindingTotals && ( diff --git a/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts b/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts index bcc87c3c54fae8..337b851466f493 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts @@ -14,6 +14,7 @@ const FEATURES = { HOST_ISOLATION_EXCEPTION_BY_POLICY: 'Host isolation exception by policy', TRUSTED_APP_BY_POLICY: 'Trusted app by policy', EVENT_FILTERS_BY_POLICY: 'Event filters by policy', + BLOCKLIST_BY_POLICY: 'Blocklists by policy', RANSOMWARE_PROTECTION: 'Ransomeware protection', MEMORY_THREAT_PROTECTION: 'Memory threat protection', BEHAVIOR_PROTECTION: 'Behavior protection', diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts index b5bc4622423e2a..add586c6cb67ff 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts @@ -14,6 +14,7 @@ import { EventFilterValidator, TrustedAppValidator, HostIsolationExceptionsValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreCreateItemServerExtension['callback']; @@ -56,6 +57,14 @@ export const getExceptionsPreCreateItemHandler = ( return validatedItem; } + // Validate blocklists + if (BlocklistValidator.isBlocklist(data)) { + const blocklistValidator = new BlocklistValidator(endpointAppContext, request); + const validatedItem = await blocklistValidator.validatePreCreateItem(data); + blocklistValidator.notifyFeatureUsage(data, 'BLOCKLIST_BY_POLICY'); + return validatedItem; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts index 37d2e9e774c6a2..095e4b56315405 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts @@ -12,6 +12,7 @@ import { TrustedAppValidator, HostIsolationExceptionsValidator, EventFilterValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreDeleteItemServerExtension['callback']; @@ -57,6 +58,12 @@ export const getExceptionsPreDeleteItemHandler = ( return data; } + // Validate Blocklists + if (BlocklistValidator.isBlocklist({ listId })) { + await new BlocklistValidator(endpointAppContextService, request).validatePreDeleteItem(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts index 5750080d930e40..8067356532a3a1 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts @@ -11,6 +11,7 @@ import { TrustedAppValidator, HostIsolationExceptionsValidator, EventFilterValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreExportServerExtension['callback']; @@ -38,6 +39,7 @@ export const getExceptionsPreExportHandler = ( await new TrustedAppValidator(endpointAppContextService, request).validatePreExport(); return data; } + // Host Isolation Exceptions validations if (HostIsolationExceptionsValidator.isHostIsolationException({ listId })) { await new HostIsolationExceptionsValidator( @@ -53,6 +55,12 @@ export const getExceptionsPreExportHandler = ( return data; } + // Validate Blocklists + if (BlocklistValidator.isBlocklist({ listId })) { + await new BlocklistValidator(endpointAppContextService, request).validatePreExport(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts index 31aeb330095fef..a21a99eea3a9d5 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts @@ -12,6 +12,7 @@ import { TrustedAppValidator, HostIsolationExceptionsValidator, EventFilterValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreGetOneItemServerExtension['callback']; @@ -57,6 +58,12 @@ export const getExceptionsPreGetOneHandler = ( return data; } + // Validate Blocklists + if (BlocklistValidator.isBlocklist({ listId })) { + await new BlocklistValidator(endpointAppContextService, request).validatePreGetOneItem(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts index 323507dfb2b855..5cfe7311eb9e30 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts @@ -11,6 +11,7 @@ import { TrustedAppValidator, HostIsolationExceptionsValidator, EventFilterValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreMultiListFindServerExtension['callback']; @@ -21,6 +22,7 @@ export const getExceptionsPreMultiListFindHandler = ( if (!data.namespaceType.includes('agnostic')) { return data; } + // validate Trusted application if (data.listId.some((id) => TrustedAppValidator.isTrustedApp({ listId: id }))) { await new TrustedAppValidator(endpointAppContextService, request).validatePreMultiListFind(); @@ -46,6 +48,12 @@ export const getExceptionsPreMultiListFindHandler = ( return data; } + // validate Blocklist + if (data.listId.some((id) => BlocklistValidator.isBlocklist({ listId: id }))) { + await new BlocklistValidator(endpointAppContextService, request).validatePreMultiListFind(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts index c33ae013b20994..917e6c97b1bfd4 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts @@ -11,6 +11,7 @@ import { TrustedAppValidator, HostIsolationExceptionsValidator, EventFilterValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreSingleListFindServerExtension['callback']; @@ -24,7 +25,7 @@ export const getExceptionsPreSingleListFindHandler = ( const { listId } = data; - // Validate Host Isolation Exceptions + // Validate Trusted applications if (TrustedAppValidator.isTrustedApp({ listId })) { await new TrustedAppValidator(endpointAppContextService, request).validatePreSingleListFind(); return data; @@ -48,6 +49,12 @@ export const getExceptionsPreSingleListFindHandler = ( return data; } + // Validate Blocklists + if (BlocklistValidator.isBlocklist({ listId })) { + await new BlocklistValidator(endpointAppContextService, request).validatePreSingleListFind(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts index c2509790589622..93c1abdcb7d7ac 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts @@ -11,6 +11,7 @@ import { TrustedAppValidator, HostIsolationExceptionsValidator, EventFilterValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreSummaryServerExtension['callback']; @@ -38,6 +39,7 @@ export const getExceptionsPreSummaryHandler = ( await new TrustedAppValidator(endpointAppContextService, request).validatePreGetListSummary(); return data; } + // Host Isolation Exceptions if (HostIsolationExceptionsValidator.isHostIsolationException({ listId })) { await new HostIsolationExceptionsValidator( @@ -53,6 +55,12 @@ export const getExceptionsPreSummaryHandler = ( return data; } + // Validate Blocklists + if (BlocklistValidator.isBlocklist({ listId })) { + await new BlocklistValidator(endpointAppContextService, request).validatePreGetListSummary(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts index 67b2e5cc03efee..acedbf7d1ed25e 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts @@ -15,6 +15,7 @@ import { EventFilterValidator, TrustedAppValidator, HostIsolationExceptionsValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreUpdateItemServerExtension['callback']; @@ -86,6 +87,17 @@ export const getExceptionsPreUpdateItemHandler = ( return validatedItem; } + // Validate Blocklists + if (BlocklistValidator.isBlocklist({ listId })) { + const blocklistValidator = new BlocklistValidator(endpointAppContextService, request); + const validatedItem = await blocklistValidator.validatePreUpdateItem(data, currentSavedItem); + blocklistValidator.notifyFeatureUsage( + data as ExceptionItemLikeOptions, + 'BLOCKLIST_BY_POLICY' + ); + return validatedItem; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts new file mode 100644 index 00000000000000..e51190467aee44 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts @@ -0,0 +1,279 @@ +/* + * 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 { ENDPOINT_BLOCKLISTS_LIST_ID } from '@kbn/securitysolution-list-constants'; +import { schema, Type, TypeOf } from '@kbn/config-schema'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; +import { BaseValidator } from './base_validator'; +import { ExceptionItemLikeOptions } from '../types'; +import { + CreateExceptionListItemOptions, + UpdateExceptionListItemOptions, +} from '../../../../../lists/server'; +import { isValidHash } from '../../../../common/endpoint/service/trusted_apps/validations'; +import { EndpointArtifactExceptionValidationError } from './errors'; + +const allowedHashes: Readonly = ['file.hash.md5', 'file.hash.sha1', 'file.hash.sha256']; + +const FileHashField = schema.oneOf( + allowedHashes.map((hash) => schema.literal(hash)) as [Type] +); + +const FilePath = schema.literal('file.path'); +const FileCodeSigner = schema.literal('file.Ext.code_signature'); + +const ConditionEntryTypeSchema = schema.literal('match_any'); +const ConditionEntryOperatorSchema = schema.literal('included'); + +type ConditionEntryFieldAllowedType = + | TypeOf + | TypeOf + | TypeOf; + +type BlocklistConditionEntry = + | { + field: ConditionEntryFieldAllowedType; + type: 'match_any'; + operator: 'included'; + value: string[]; + } + | TypeOf; + +/* + * A generic Entry schema to be used for a specific entry schema depending on the OS + */ +const CommonEntrySchema = { + field: schema.oneOf([FileHashField, FilePath]), + type: ConditionEntryTypeSchema, + operator: ConditionEntryOperatorSchema, + // If field === HASH then validate hash with custom method, else validate string with minLength = 1 + value: schema.conditional( + schema.siblingRef('field'), + FileHashField, + schema.arrayOf( + schema.string({ + validate: (hash: string) => + isValidHash(hash) ? undefined : `invalid hash value [${hash}]`, + }), + { minSize: 1 } + ), + schema.conditional( + schema.siblingRef('field'), + FilePath, + schema.arrayOf( + schema.string({ + validate: (pathValue: string) => + pathValue.length > 0 ? undefined : `invalid path value [${pathValue}]`, + }), + { minSize: 1 } + ), + schema.arrayOf( + schema.string({ + validate: (signerValue: string) => + signerValue.length > 0 ? undefined : `invalid signer value [${signerValue}]`, + }), + { minSize: 1 } + ) + ) + ), +}; + +// Windows Signer entries use a Nested field that checks to ensure +// that the certificate is trusted +const WindowsSignerEntrySchema = schema.object({ + type: schema.literal('nested'), + field: FileCodeSigner, + entries: schema.arrayOf( + schema.object({ + field: schema.literal('subject_name'), + value: schema.arrayOf(schema.string({ minLength: 1 })), + type: schema.literal('match_any'), + operator: schema.literal('included'), + }), + { minSize: 1 } + ), +}); + +const WindowsEntrySchema = schema.oneOf([ + WindowsSignerEntrySchema, + schema.object({ + ...CommonEntrySchema, + field: schema.oneOf([FileHashField, FilePath]), + }), +]); + +const LinuxEntrySchema = schema.object({ + ...CommonEntrySchema, +}); + +const MacEntrySchema = schema.object({ + ...CommonEntrySchema, +}); + +// Hash entries validator method. +const hashEntriesValidation = (entries: BlocklistConditionEntry[]) => { + const currentHashes = entries.map((entry) => entry.field); + // If there are more hashes than allowed (three) then return an error + if (currentHashes.length > allowedHashes.length) { + const allowedHashesMessage = allowedHashes + .map((hash) => hash.replace('file.hash.', '')) + .join(','); + return `There are more hash types than allowed [${allowedHashesMessage}]`; + } + + const hashesCount: { [key: string]: boolean } = {}; + const duplicatedHashes: string[] = []; + const invalidHash: string[] = []; + + // Check hash entries individually + currentHashes.forEach((hash) => { + if (!allowedHashes.includes(hash)) invalidHash.push(hash); + if (hashesCount[hash]) { + duplicatedHashes.push(hash); + } else { + hashesCount[hash] = true; + } + }); + + // There is more than one entry with the same hash type + if (duplicatedHashes.length) { + return `There are some duplicated hashes: ${duplicatedHashes.join(',')}`; + } + + // There is an entry with an invalid hash type + if (invalidHash.length) { + return `There are some invalid fields for hash type: ${invalidHash.join(',')}`; + } +}; + +// Validate there is only one entry when signer or path and the allowed entries for hashes +const entriesSchemaOptions = { + minSize: 1, + validate(entries: BlocklistConditionEntry[]) { + if (allowedHashes.includes(entries[0].field)) { + return hashEntriesValidation(entries); + } else { + if (entries.length > 1) { + return 'Only one entry is allowed when no using hash field type'; + } + } + }, +}; + +/* + * Entities array schema depending on Os type using schema.conditional. + * If OS === WINDOWS then use Windows schema, + * else if OS === LINUX then use Linux schema, + * else use Mac schema + * + * The validate function checks there is only one item for entries excepts for hash + */ +const EntriesSchema = schema.conditional( + schema.contextRef('os'), + OperatingSystem.WINDOWS, + schema.arrayOf(WindowsEntrySchema, entriesSchemaOptions), + schema.conditional( + schema.contextRef('os'), + OperatingSystem.LINUX, + schema.arrayOf(LinuxEntrySchema, entriesSchemaOptions), + schema.arrayOf(MacEntrySchema, entriesSchemaOptions) + ) +); + +/** + * Schema to validate Blocklist data for create and update. + * When called, it must be given an `context` with a `os` property set + * + * @example + * + * BlocklistDataSchema.validate(item, { os: 'windows' }); + */ +const BlocklistDataSchema = schema.object( + { + entries: EntriesSchema, + }, + + // Because we are only validating some fields from the Exception Item, we set `unknowns` to `ignore` here + { unknowns: 'ignore' } +); + +export class BlocklistValidator extends BaseValidator { + static isBlocklist(item: { listId: string }): boolean { + return item.listId === ENDPOINT_BLOCKLISTS_LIST_ID; + } + + async validatePreCreateItem( + item: CreateExceptionListItemOptions + ): Promise { + await this.validateCanManageEndpointArtifacts(); + await this.validateBlocklistData(item); + await this.validateCanCreateByPolicyArtifacts(item); + await this.validateByPolicyItem(item); + + return item; + } + + async validatePreDeleteItem(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreGetOneItem(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreMultiListFind(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreExport(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreSingleListFind(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreGetListSummary(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreUpdateItem( + _updatedItem: UpdateExceptionListItemOptions, + currentItem: ExceptionListItemSchema + ): Promise { + const updatedItem = _updatedItem as ExceptionItemLikeOptions; + + await this.validateCanManageEndpointArtifacts(); + await this.validateBlocklistData(updatedItem); + + try { + await this.validateCanCreateByPolicyArtifacts(updatedItem); + } catch (noByPolicyAuthzError) { + // Not allowed to create/update by policy data. Validate that the effective scope of the item + // remained unchanged with this update or was set to `global` (only allowed update). If not, + // then throw the validation error that was catch'ed + if (this.wasByPolicyEffectScopeChanged(updatedItem, currentItem)) { + throw noByPolicyAuthzError; + } + } + + await this.validateByPolicyItem(updatedItem); + + return _updatedItem; + } + + private async validateBlocklistData(item: ExceptionItemLikeOptions): Promise { + await this.validateBasicData(item); + + try { + BlocklistDataSchema.validate(item, { os: item.osTypes[0] }); + } catch (error) { + throw new EndpointArtifactExceptionValidationError(error.message); + } + } +} diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts index 05b38470018694..ccd6ebd8e08d6c 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts @@ -8,3 +8,4 @@ export { TrustedAppValidator } from './trusted_app_validator'; export { EventFilterValidator } from './event_filter_validator'; export { HostIsolationExceptionsValidator } from './host_isolation_exceptions_validator'; +export { BlocklistValidator } from './blocklist_validator'; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts index dc539e76e79468..b2171ebd018bd3 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts @@ -230,7 +230,7 @@ export class TrustedAppValidator extends BaseValidator { await this.validateByPolicyItem(updatedItem); - return updatedItem as UpdateExceptionListItemOptions; + return _updatedItem; } private async validateTrustedAppData(item: ExceptionItemLikeOptions): Promise { diff --git a/x-pack/plugins/task_manager/server/task_events.test.ts b/x-pack/plugins/task_manager/server/task_events.test.ts index 5d72120da725ca..607453b7ea92f3 100644 --- a/x-pack/plugins/task_manager/server/task_events.test.ts +++ b/x-pack/plugins/task_manager/server/task_events.test.ts @@ -45,7 +45,8 @@ describe('task_events', () => { expect(result.eventLoopBlockMs).toBe(undefined); }); - describe('startTaskTimerWithEventLoopMonitoring', () => { + // FLAKY: https://github.com/elastic/kibana/issues/128441 + describe.skip('startTaskTimerWithEventLoopMonitoring', () => { test('non-blocking', async () => { const stopTaskTimer = startTaskTimerWithEventLoopMonitoring({ monitor: true, diff --git a/x-pack/plugins/transform/common/api_schemas/common.ts b/x-pack/plugins/transform/common/api_schemas/common.ts index 285f3879681c71..4cd7d865a69f32 100644 --- a/x-pack/plugins/transform/common/api_schemas/common.ts +++ b/x-pack/plugins/transform/common/api_schemas/common.ts @@ -29,12 +29,12 @@ export const transformStateSchema = schema.oneOf([ schema.literal(TRANSFORM_STATE.WAITING), ]); -export const indexPatternTitleSchema = schema.object({ +export const dataViewTitleSchema = schema.object({ /** Title of the data view for which to return stats. */ - indexPatternTitle: schema.string(), + dataViewTitle: schema.string(), }); -export type IndexPatternTitleSchema = TypeOf; +export type DataViewTitleSchema = TypeOf; export const transformIdParamSchema = schema.object({ transformId: schema.string(), diff --git a/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts b/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts index 05fefc278e350c..e12c144b60af62 100644 --- a/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts +++ b/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts @@ -20,7 +20,7 @@ export const deleteTransformsRequestSchema = schema.object({ }) ), deleteDestIndex: schema.maybe(schema.boolean()), - deleteDestIndexPattern: schema.maybe(schema.boolean()), + deleteDestDataView: schema.maybe(schema.boolean()), forceDelete: schema.maybe(schema.boolean()), }); @@ -29,7 +29,7 @@ export type DeleteTransformsRequestSchema = TypeOf { + test('isDataView()', () => { + expect(isDataView(0)).toBe(false); + expect(isDataView('')).toBe(false); + expect(isDataView(null)).toBe(false); + expect(isDataView({})).toBe(false); + expect(isDataView({ attribute: 'value' })).toBe(false); + expect(isDataView({ fields: [], title: 'Data View Title', getComputedFields: () => {} })).toBe( + true + ); + }); +}); diff --git a/x-pack/plugins/transform/common/types/index_pattern.ts b/x-pack/plugins/transform/common/types/data_view.ts similarity index 61% rename from x-pack/plugins/transform/common/types/index_pattern.ts rename to x-pack/plugins/transform/common/types/data_view.ts index 0485de8982e1a7..c09b84dea1e4e4 100644 --- a/x-pack/plugins/transform/common/types/index_pattern.ts +++ b/x-pack/plugins/transform/common/types/data_view.ts @@ -5,18 +5,18 @@ * 2.0. */ -import type { IndexPattern } from '../../../../../src/plugins/data/common'; +import type { DataView } from '../../../../../src/plugins/data_views/common'; import { isPopulatedObject } from '../shared_imports'; -// Custom minimal type guard for IndexPattern to check against the attributes used in transforms code. -export function isIndexPattern(arg: any): arg is IndexPattern { +// Custom minimal type guard for DataView to check against the attributes used in transforms code. +export function isDataView(arg: any): arg is DataView { return ( isPopulatedObject(arg, ['title', 'fields']) && // `getComputedFields` is inherited, so it's not possible to // check with `hasOwnProperty` which is used by isPopulatedObject() - 'getComputedFields' in (arg as IndexPattern) && - typeof (arg as IndexPattern).getComputedFields === 'function' && + 'getComputedFields' in (arg as DataView) && + typeof (arg as DataView).getComputedFields === 'function' && typeof arg.title === 'string' && Array.isArray(arg.fields) ); diff --git a/x-pack/plugins/transform/common/types/index_pattern.test.ts b/x-pack/plugins/transform/common/types/index_pattern.test.ts deleted file mode 100644 index 57d57473d99de6..00000000000000 --- a/x-pack/plugins/transform/common/types/index_pattern.test.ts +++ /dev/null @@ -1,21 +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 { isIndexPattern } from './index_pattern'; - -describe('index_pattern', () => { - test('isIndexPattern()', () => { - expect(isIndexPattern(0)).toBe(false); - expect(isIndexPattern('')).toBe(false); - expect(isIndexPattern(null)).toBe(false); - expect(isIndexPattern({})).toBe(false); - expect(isIndexPattern({ attribute: 'value' })).toBe(false); - expect( - isIndexPattern({ fields: [], title: 'Data View Title', getComputedFields: () => {} }) - ).toBe(true); - }); -}); diff --git a/x-pack/plugins/transform/common/types/transform.ts b/x-pack/plugins/transform/common/types/transform.ts index 92ffc0b99bc3dd..a196111bf66783 100644 --- a/x-pack/plugins/transform/common/types/transform.ts +++ b/x-pack/plugins/transform/common/types/transform.ts @@ -13,7 +13,7 @@ import type { PivotAggDict } from './pivot_aggs'; import type { TransformHealthAlertRule } from './alerting'; export type IndexName = string; -export type IndexPattern = string; +export type DataView = string; export type TransformId = string; /** diff --git a/x-pack/plugins/transform/public/app/common/data_grid.ts b/x-pack/plugins/transform/public/app/common/data_grid.ts index 082e73651bb725..43d2b27f13cf97 100644 --- a/x-pack/plugins/transform/public/app/common/data_grid.ts +++ b/x-pack/plugins/transform/public/app/common/data_grid.ts @@ -15,8 +15,8 @@ export const getPivotPreviewDevConsoleStatement = (request: PostTransformsPrevie return `POST _transform/_preview\n${JSON.stringify(request, null, 2)}\n`; }; -export const getIndexDevConsoleStatement = (query: PivotQuery, indexPatternTitle: string) => { - return `GET ${indexPatternTitle}/_search\n${JSON.stringify( +export const getIndexDevConsoleStatement = (query: PivotQuery, dataViewTitle: string) => { + return `GET ${dataViewTitle}/_search\n${JSON.stringify( { query, }, diff --git a/x-pack/plugins/transform/public/app/common/request.test.ts b/x-pack/plugins/transform/public/app/common/request.test.ts index cd34b20cc87a66..f8c5a64099ba20 100644 --- a/x-pack/plugins/transform/public/app/common/request.test.ts +++ b/x-pack/plugins/transform/public/app/common/request.test.ts @@ -80,7 +80,7 @@ describe('Transform: Common', () => { test('getPreviewTransformRequestBody()', () => { const query = getPivotQuery('the-query'); - const request = getPreviewTransformRequestBody('the-index-pattern-title', query, { + const request = getPreviewTransformRequestBody('the-data-view-title', query, { pivot: { aggregations: { 'the-agg-agg-name': { avg: { field: 'the-agg-field' } } }, group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, @@ -93,7 +93,7 @@ describe('Transform: Common', () => { group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, }, source: { - index: ['the-index-pattern-title'], + index: ['the-data-view-title'], query: { query_string: { default_operator: 'AND', query: 'the-query' } }, }, }); @@ -101,16 +101,12 @@ describe('Transform: Common', () => { test('getPreviewTransformRequestBody() with comma-separated index pattern', () => { const query = getPivotQuery('the-query'); - const request = getPreviewTransformRequestBody( - 'the-index-pattern-title,the-other-title', - query, - { - pivot: { - aggregations: { 'the-agg-agg-name': { avg: { field: 'the-agg-field' } } }, - group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, - }, - } - ); + const request = getPreviewTransformRequestBody('the-data-view-title,the-other-title', query, { + pivot: { + aggregations: { 'the-agg-agg-name': { avg: { field: 'the-agg-field' } } }, + group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, + }, + }); expect(request).toEqual({ pivot: { @@ -118,7 +114,7 @@ describe('Transform: Common', () => { group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, }, source: { - index: ['the-index-pattern-title', 'the-other-title'], + index: ['the-data-view-title', 'the-other-title'], query: { query_string: { default_operator: 'AND', query: 'the-query' } }, }, }); @@ -178,7 +174,7 @@ describe('Transform: Common', () => { test('getPreviewTransformRequestBody() with missing_buckets config', () => { const query = getPivotQuery('the-query'); const request = getPreviewTransformRequestBody( - 'the-index-pattern-title', + 'the-data-view-title', query, getRequestPayload([aggsAvg], [{ ...groupByTerms, ...{ missing_bucket: true } }]) ); @@ -191,7 +187,7 @@ describe('Transform: Common', () => { }, }, source: { - index: ['the-index-pattern-title'], + index: ['the-data-view-title'], query: { query_string: { default_operator: 'AND', query: 'the-query' } }, }, }); @@ -226,7 +222,7 @@ describe('Transform: Common', () => { const transformDetailsState: StepDetailsExposedState = { continuousModeDateField: 'the-continuous-mode-date-field', continuousModeDelay: 'the-continuous-mode-delay', - createIndexPattern: false, + createDataView: false, isContinuousModeEnabled: false, isRetentionPolicyEnabled: false, retentionPolicyDateField: '', @@ -243,7 +239,7 @@ describe('Transform: Common', () => { }; const request = getCreateTransformRequestBody( - 'the-index-pattern-title', + 'the-data-view-title', pivotState, transformDetailsState ); @@ -261,7 +257,7 @@ describe('Transform: Common', () => { docs_per_second: 400, }, source: { - index: ['the-index-pattern-title'], + index: ['the-data-view-title'], query: { query_string: { default_operator: 'AND', query: 'the-search-query' } }, }, }); @@ -305,7 +301,7 @@ describe('Transform: Common', () => { const transformDetailsState: StepDetailsExposedState = { continuousModeDateField: 'the-continuous-mode-date-field', continuousModeDelay: 'the-continuous-mode-delay', - createIndexPattern: false, + createDataView: false, isContinuousModeEnabled: false, isRetentionPolicyEnabled: false, retentionPolicyDateField: '', @@ -322,7 +318,7 @@ describe('Transform: Common', () => { }; const request = getCreateTransformRequestBody( - 'the-index-pattern-title', + 'the-data-view-title', pivotState, transformDetailsState ); @@ -340,7 +336,7 @@ describe('Transform: Common', () => { docs_per_second: 400, }, source: { - index: ['the-index-pattern-title'], + index: ['the-data-view-title'], query: { query_string: { default_operator: 'AND', query: 'the-search-query' } }, runtime_mappings: runtimeMappings, }, diff --git a/x-pack/plugins/transform/public/app/common/request.ts b/x-pack/plugins/transform/public/app/common/request.ts index 36776759eb47a5..0f94f82355fd22 100644 --- a/x-pack/plugins/transform/public/app/common/request.ts +++ b/x-pack/plugins/transform/public/app/common/request.ts @@ -8,7 +8,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { HttpFetchError } from '../../../../../../src/core/public'; -import type { IndexPattern } from '../../../../../../src/plugins/data/public'; +import type { DataView } from '../../../../../../src/plugins/data_views/public'; import type { PivotTransformPreviewRequestSchema, @@ -19,7 +19,7 @@ import type { } from '../../../common/api_schemas/transforms'; import { isPopulatedObject } from '../../../common/shared_imports'; import { DateHistogramAgg, HistogramAgg, TermsAgg } from '../../../common/types/pivot_group_by'; -import { isIndexPattern } from '../../../common/types/index_pattern'; +import { isDataView } from '../../../common/types/data_view'; import type { SavedSearchQuery } from '../hooks/use_search_items'; import type { StepDefineExposedState } from '../sections/create_transform/components/step_define'; @@ -78,14 +78,14 @@ export function isDefaultQuery(query: PivotQuery): boolean { } export function getCombinedRuntimeMappings( - indexPattern: IndexPattern | undefined, + dataView: DataView | undefined, runtimeMappings?: StepDefineExposedState['runtimeMappings'] ): StepDefineExposedState['runtimeMappings'] | undefined { let combinedRuntimeMappings = {}; // And runtime field mappings defined by index pattern - if (isIndexPattern(indexPattern)) { - const computedFields = indexPattern.getComputedFields(); + if (isDataView(dataView)) { + const computedFields = dataView.getComputedFields(); if (computedFields?.runtimeFields !== undefined) { const ipRuntimeMappings = computedFields.runtimeFields; if (isPopulatedObject(ipRuntimeMappings)) { @@ -167,12 +167,12 @@ export const getRequestPayload = ( }; export function getPreviewTransformRequestBody( - indexPatternTitle: IndexPattern['title'], + dataViewTitle: DataView['title'], query: PivotQuery, partialRequest?: StepDefineExposedState['previewRequest'] | undefined, runtimeMappings?: StepDefineExposedState['runtimeMappings'] ): PostTransformsPreviewRequestSchema { - const index = indexPatternTitle.split(',').map((name: string) => name.trim()); + const index = dataViewTitle.split(',').map((name: string) => name.trim()); return { source: { @@ -199,12 +199,12 @@ export const getCreateTransformSettingsRequestBody = ( }; export const getCreateTransformRequestBody = ( - indexPatternTitle: IndexPattern['title'], + dataViewTitle: DataView['title'], pivotState: StepDefineExposedState, transformDetailsState: StepDetailsExposedState ): PutTransformsPivotRequestSchema | PutTransformsLatestRequestSchema => ({ ...getPreviewTransformRequestBody( - indexPatternTitle, + dataViewTitle, getPivotQuery(pivotState.searchQuery), pivotState.previewRequest, pivotState.runtimeMappings diff --git a/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts index 979a98ececabbe..cd46caf931e17e 100644 --- a/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts @@ -166,7 +166,7 @@ const apiFactory = () => ({ return Promise.resolve([]); }, async getHistogramsForFields( - indexPatternTitle: string, + dataViewTitle: string, fields: FieldHistogramRequestConfig[], query: string | SavedSearchQuery, samplerShardSize = DEFAULT_SAMPLER_SHARD_SIZE diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index 7119ad2719f5ee..65c0d2050a5edb 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -226,14 +226,14 @@ export const useApi = () => { } }, async getHistogramsForFields( - indexPatternTitle: string, + dataViewTitle: string, fields: FieldHistogramRequestConfig[], query: string | SavedSearchQuery, runtimeMappings?: FieldHistogramsRequestSchema['runtimeMappings'], samplerShardSize = DEFAULT_SAMPLER_SHARD_SIZE ): Promise { try { - return await http.post(`${API_BASE_PATH}field_histograms/${indexPatternTitle}`, { + return await http.post(`${API_BASE_PATH}field_histograms/${dataViewTitle}`, { body: JSON.stringify({ query, fields, diff --git a/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx b/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx index ff93f027fc3a42..65a20f2d24ddf6 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx +++ b/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx @@ -30,24 +30,24 @@ export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { const toastNotifications = useToastNotifications(); const [deleteDestIndex, setDeleteDestIndex] = useState(true); - const [deleteIndexPattern, setDeleteIndexPattern] = useState(true); + const [deleteDataView, setDeleteDataView] = useState(true); const [userCanDeleteIndex, setUserCanDeleteIndex] = useState(false); - const [indexPatternExists, setIndexPatternExists] = useState(false); + const [dataViewExists, setDataViewExists] = useState(false); const [userCanDeleteDataView, setUserCanDeleteDataView] = useState(false); const toggleDeleteIndex = useCallback( () => setDeleteDestIndex(!deleteDestIndex), [deleteDestIndex] ); - const toggleDeleteIndexPattern = useCallback( - () => setDeleteIndexPattern(!deleteIndexPattern), - [deleteIndexPattern] + const toggleDeleteDataView = useCallback( + () => setDeleteDataView(!deleteDataView), + [deleteDataView] ); - const checkIndexPatternExists = useCallback( + const checkDataViewExists = useCallback( async (indexName: string) => { try { - if (await indexService.indexPatternExists(savedObjects.client, indexName)) { - setIndexPatternExists(true); + if (await indexService.dataViewExists(savedObjects.client, indexName)) { + setDataViewExists(true); } } catch (e) { const error = extractErrorMessage(e); @@ -77,7 +77,7 @@ export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { capabilities.indexPatterns.save === true; setUserCanDeleteDataView(canDeleteDataView); if (canDeleteDataView === false) { - setDeleteIndexPattern(false); + setDeleteDataView(false); } } catch (e) { toastNotifications.addDanger( @@ -100,20 +100,20 @@ export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { const destinationIndex = Array.isArray(config.dest.index) ? config.dest.index[0] : config.dest.index; - checkIndexPatternExists(destinationIndex); + checkDataViewExists(destinationIndex); } else { - setIndexPatternExists(true); + setDataViewExists(true); } - }, [checkIndexPatternExists, checkUserIndexPermission, items]); + }, [checkDataViewExists, checkUserIndexPermission, items]); return { userCanDeleteIndex, userCanDeleteDataView, deleteDestIndex, - indexPatternExists, - deleteIndexPattern, + dataViewExists, + deleteDataView, toggleDeleteIndex, - toggleDeleteIndexPattern, + toggleDeleteDataView, }; }; @@ -149,7 +149,7 @@ export const useDeleteTransforms = () => { const successCount: Record = { transformDeleted: 0, destIndexDeleted: 0, - destIndexPatternDeleted: 0, + destDataViewDeleted: 0, }; for (const transformId in results) { // hasOwnProperty check to ensure only properties on object itself, and not its prototypes @@ -179,7 +179,7 @@ export const useDeleteTransforms = () => { ) ); } - if (status.destIndexPatternDeleted?.success) { + if (status.destDataViewDeleted?.success) { toastNotifications.addSuccess( i18n.translate( 'xpack.transform.deleteTransform.deleteAnalyticsWithDataViewSuccessMessage', @@ -238,8 +238,8 @@ export const useDeleteTransforms = () => { }); } - if (status.destIndexPatternDeleted?.error) { - const error = status.destIndexPatternDeleted.error.reason; + if (status.destDataViewDeleted?.error) { + const error = status.destDataViewDeleted.error.reason; toastNotifications.addDanger({ title: i18n.translate( 'xpack.transform.deleteTransform.deleteAnalyticsWithDataViewErrorMessage', @@ -283,12 +283,12 @@ export const useDeleteTransforms = () => { }) ); } - if (successCount.destIndexPatternDeleted > 0) { + if (successCount.destDataViewDeleted > 0) { toastNotifications.addSuccess( i18n.translate('xpack.transform.transformList.bulkDeleteDestDataViewSuccessMessage', { defaultMessage: 'Successfully deleted {count} destination data {count, plural, one {view} other {views}}.', - values: { count: successCount.destIndexPatternDeleted }, + values: { count: successCount.destDataViewDeleted }, }) ); } diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx b/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx index 74d5167c12697a..d74c11cbaf607c 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx @@ -46,7 +46,7 @@ const runtimeMappings = { }; describe('Transform: useIndexData()', () => { - test('indexPattern set triggers loading', async () => { + test('dataView set triggers loading', async () => { const mlShared = await getMlSharedImports(); const wrapper: FC = ({ children }) => ( @@ -61,7 +61,7 @@ describe('Transform: useIndexData()', () => { id: 'the-id', title: 'the-title', fields: [], - } as unknown as SearchItems['indexPattern'], + } as unknown as SearchItems['dataView'], query, runtimeMappings ), @@ -81,10 +81,10 @@ describe('Transform: useIndexData()', () => { describe('Transform: with useIndexData()', () => { test('Minimal initialization, no cross cluster search warning.', async () => { // Arrange - const indexPattern = { - title: 'the-index-pattern-title', + const dataView = { + title: 'the-data-view-title', fields: [] as any[], - } as SearchItems['indexPattern']; + } as SearchItems['dataView']; const mlSharedImports = await getMlSharedImports(); @@ -93,7 +93,7 @@ describe('Transform: with useIndexData()', () => { ml: { DataGrid }, } = useAppDependencies(); const props = { - ...useIndexData(indexPattern, { match_all: {} }, runtimeMappings), + ...useIndexData(dataView, { match_all: {} }, runtimeMappings), copyToClipboard: 'the-copy-to-clipboard-code', copyToClipboardDescription: 'the-copy-to-clipboard-description', dataTestSubj: 'the-data-test-subj', @@ -124,10 +124,10 @@ describe('Transform: with useIndexData()', () => { test('Cross-cluster search warning', async () => { // Arrange - const indexPattern = { + const dataView = { title: 'remote:the-index-pattern-title', fields: [] as any[], - } as SearchItems['indexPattern']; + } as SearchItems['dataView']; const mlSharedImports = await getMlSharedImports(); @@ -136,7 +136,7 @@ describe('Transform: with useIndexData()', () => { ml: { DataGrid }, } = useAppDependencies(); const props = { - ...useIndexData(indexPattern, { match_all: {} }, runtimeMappings), + ...useIndexData(dataView, { match_all: {} }, runtimeMappings), copyToClipboard: 'the-copy-to-clipboard-code', copyToClipboardDescription: 'the-copy-to-clipboard-description', dataTestSubj: 'the-data-test-subj', diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts index 1d73413b3e3867..678ec6d291ceb3 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts @@ -31,7 +31,7 @@ import type { StepDefineExposedState } from '../sections/create_transform/compon import { isRuntimeMappings } from '../../../common/shared_imports'; export const useIndexData = ( - indexPattern: SearchItems['indexPattern'], + dataView: SearchItems['dataView'], query: PivotQuery, combinedRuntimeMappings?: StepDefineExposedState['runtimeMappings'] ): UseIndexDataReturnType => { @@ -51,7 +51,7 @@ export const useIndexData = ( }, } = useAppDependencies(); - const [indexPatternFields, setIndexPatternFields] = useState(); + const [dataViewFields, setDataViewFields] = useState(); // Fetch 500 random documents to determine populated fields. // This is a workaround to avoid passing potentially thousands of unpopulated fields @@ -62,7 +62,7 @@ export const useIndexData = ( setStatus(INDEX_STATUS.LOADING); const esSearchRequest = { - index: indexPattern.title, + index: dataView.title, body: { fields: ['*'], _source: false, @@ -84,21 +84,21 @@ export const useIndexData = ( return; } - const isCrossClusterSearch = indexPattern.title.includes(':'); + const isCrossClusterSearch = dataView.title.includes(':'); const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); // Get all field names for each returned doc and flatten it // to a list of unique field names used across all docs. - const allKibanaIndexPatternFields = getFieldsFromKibanaIndexPattern(indexPattern); + const allDataViewFields = getFieldsFromKibanaIndexPattern(dataView); const populatedFields = [...new Set(docs.map(Object.keys).flat(1))] - .filter((d) => allKibanaIndexPatternFields.includes(d)) + .filter((d) => allDataViewFields.includes(d)) .sort(); setCcsWarning(isCrossClusterSearch && isMissingFields); setStatus(INDEX_STATUS.LOADED); - setIndexPatternFields(populatedFields); + setDataViewFields(populatedFields); }; useEffect(() => { @@ -107,7 +107,7 @@ export const useIndexData = ( }, []); const columns: EuiDataGridColumn[] = useMemo(() => { - if (typeof indexPatternFields === 'undefined') { + if (typeof dataViewFields === 'undefined') { return []; } @@ -124,8 +124,8 @@ export const useIndexData = ( } // Combine the runtime field that are defined from API field - indexPatternFields.forEach((id) => { - const field = indexPattern.fields.getByName(id); + dataViewFields.forEach((id) => { + const field = dataView.fields.getByName(id); if (!field?.runtimeField) { const schema = getDataGridSchemaFromKibanaFieldType(field); result.push({ id, schema }); @@ -134,8 +134,8 @@ export const useIndexData = ( return result.sort((a, b) => a.id.localeCompare(b.id)); }, [ - indexPatternFields, - indexPattern.fields, + dataViewFields, + dataView.fields, combinedRuntimeMappings, getDataGridSchemaFromESFieldType, getDataGridSchemaFromKibanaFieldType, @@ -176,7 +176,7 @@ export const useIndexData = ( }, {} as EsSorting); const esSearchRequest = { - index: indexPattern.title, + index: dataView.title, body: { fields: ['*'], _source: false, @@ -198,7 +198,7 @@ export const useIndexData = ( return; } - const isCrossClusterSearch = indexPattern.title.includes(':'); + const isCrossClusterSearch = dataView.title.includes(':'); const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); @@ -215,16 +215,16 @@ export const useIndexData = ( }; const fetchColumnChartsData = async function () { - const allIndexPatternFieldNames = new Set(indexPattern.fields.map((f) => f.name)); + const allDataViewFieldNames = new Set(dataView.fields.map((f) => f.name)); const columnChartsData = await api.getHistogramsForFields( - indexPattern.title, + dataView.title, columns .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) .map((cT) => { // If a column field name has a corresponding keyword field, // fetch the keyword field instead to be able to do aggregations. const fieldName = cT.id; - return hasKeywordDuplicate(fieldName, allIndexPatternFieldNames) + return hasKeywordDuplicate(fieldName, allDataViewFieldNames) ? { fieldName: `${fieldName}.keyword`, type: getFieldType(undefined), @@ -247,7 +247,7 @@ export const useIndexData = ( // revert field names with `.keyword` used to do aggregations to their original column name columnChartsData.map((d) => ({ ...d, - ...(isKeywordDuplicate(d.id, allIndexPatternFieldNames) + ...(isKeywordDuplicate(d.id, allDataViewFieldNames) ? { id: removeKeywordPostfix(d.id) } : {}), })) @@ -259,15 +259,9 @@ export const useIndexData = ( // custom comparison // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - indexPattern.title, + dataView.title, // eslint-disable-next-line react-hooks/exhaustive-deps - JSON.stringify([ - query, - pagination, - sortingColumns, - indexPatternFields, - combinedRuntimeMappings, - ]), + JSON.stringify([query, pagination, sortingColumns, dataViewFields, combinedRuntimeMappings]), ]); useEffect(() => { @@ -278,12 +272,12 @@ export const useIndexData = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [ chartsVisible, - indexPattern.title, + dataView.title, // eslint-disable-next-line react-hooks/exhaustive-deps JSON.stringify([query, dataGrid.visibleColumns, combinedRuntimeMappings]), ]); - const renderCellValue = useRenderCellValue(indexPattern, pagination, tableItems); + const renderCellValue = useRenderCellValue(dataView, pagination, tableItems); return { ...dataGrid, diff --git a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts index 01cb39ac87fa8c..d30237abcdb3f2 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts @@ -96,7 +96,7 @@ export function getCombinedProperties( } export const usePivotData = ( - indexPatternTitle: SearchItems['indexPattern']['title'], + dataViewTitle: SearchItems['dataView']['title'], query: PivotQuery, validationStatus: StepDefineExposedState['validationStatus'], requestPayload: StepDefineExposedState['previewRequest'], @@ -165,7 +165,7 @@ export const usePivotData = ( setStatus(INDEX_STATUS.LOADING); const previewRequest = getPreviewTransformRequestBody( - indexPatternTitle, + dataViewTitle, query, requestPayload, combinedRuntimeMappings @@ -233,7 +233,7 @@ export const usePivotData = ( getPreviewData(); // custom comparison /* eslint-disable react-hooks/exhaustive-deps */ - }, [indexPatternTitle, JSON.stringify([requestPayload, query, combinedRuntimeMappings])]); + }, [dataViewTitle, JSON.stringify([requestPayload, query, combinedRuntimeMappings])]); if (sortingColumns.length > 0) { const sortingColumnsWithTypes = sortingColumns.map((c) => ({ diff --git a/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts b/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts index 19ff063d11acf1..910960cb24eea4 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts @@ -7,45 +7,45 @@ import { buildEsQuery } from '@kbn/es-query'; import { SavedObjectsClientContract, SimpleSavedObject, IUiSettingsClient } from 'src/core/public'; +import { getEsQueryConfig } from '../../../../../../../src/plugins/data/public'; import { - IndexPattern, - getEsQueryConfig, - IndexPatternsContract, - IndexPatternAttributes, -} from '../../../../../../../src/plugins/data/public'; + DataView, + DataViewAttributes, + DataViewsContract, +} from '../../../../../../../src/plugins/data_views/public'; import { matchAllQuery } from '../../common'; -import { isIndexPattern } from '../../../../common/types/index_pattern'; +import { isDataView } from '../../../../common/types/data_view'; export type SavedSearchQuery = object; -type IndexPatternId = string; +type DataViewId = string; -let indexPatternCache: Array>> = []; -let fullIndexPatterns; -let currentIndexPattern = null; +let dataViewCache: Array>> = []; +let fullDataViews; +let currentDataView = null; -export let refreshIndexPatterns: () => Promise; +export let refreshDataViews: () => Promise; -export function loadIndexPatterns( +export function loadDataViews( savedObjectsClient: SavedObjectsClientContract, - indexPatterns: IndexPatternsContract + dataViews: DataViewsContract ) { - fullIndexPatterns = indexPatterns; + fullDataViews = dataViews; return savedObjectsClient - .find({ + .find({ type: 'index-pattern', fields: ['id', 'title', 'type', 'fields'], perPage: 10000, }) .then((response) => { - indexPatternCache = response.savedObjects; + dataViewCache = response.savedObjects; - if (refreshIndexPatterns === null) { - refreshIndexPatterns = () => { + if (refreshDataViews === null) { + refreshDataViews = () => { return new Promise((resolve, reject) => { - loadIndexPatterns(savedObjectsClient, indexPatterns) + loadDataViews(savedObjectsClient, dataViews) .then((resp) => { resolve(resp); }) @@ -56,27 +56,24 @@ export function loadIndexPatterns( }; } - return indexPatternCache; + return dataViewCache; }); } -export function getIndexPatternIdByTitle(indexPatternTitle: string): string | undefined { - return indexPatternCache.find((d) => d?.attributes?.title === indexPatternTitle)?.id; +export function getDataViewIdByTitle(dataViewTitle: string): string | undefined { + return dataViewCache.find((d) => d?.attributes?.title === dataViewTitle)?.id; } type CombinedQuery = Record<'bool', any> | object; -export function loadCurrentIndexPattern( - indexPatterns: IndexPatternsContract, - indexPatternId: IndexPatternId -) { - fullIndexPatterns = indexPatterns; - currentIndexPattern = fullIndexPatterns.get(indexPatternId); - return currentIndexPattern; +export function loadCurrentDataView(dataViews: DataViewsContract, dataViewId: DataViewId) { + fullDataViews = dataViews; + currentDataView = fullDataViews.get(dataViewId); + return currentDataView; } export interface SearchItems { - indexPattern: IndexPattern; + dataView: DataView; savedSearch: any; query: any; combinedQuery: CombinedQuery; @@ -84,7 +81,7 @@ export interface SearchItems { // Helper for creating the items used for searching and job creation. export function createSearchItems( - indexPattern: IndexPattern | undefined, + dataView: DataView | undefined, savedSearch: any, config: IUiSettingsClient ): SearchItems { @@ -103,9 +100,9 @@ export function createSearchItems( }, }; - if (!isIndexPattern(indexPattern) && savedSearch !== null && savedSearch.id !== undefined) { + if (!isDataView(dataView) && savedSearch !== null && savedSearch.id !== undefined) { const searchSource = savedSearch.searchSource; - indexPattern = searchSource.getField('index') as IndexPattern; + dataView = searchSource.getField('index') as DataView; query = searchSource.getField('query'); const fs = searchSource.getField('filter'); @@ -113,15 +110,15 @@ export function createSearchItems( const filters = fs.length ? fs : []; const esQueryConfigs = getEsQueryConfig(config); - combinedQuery = buildEsQuery(indexPattern, [query], filters, esQueryConfigs); + combinedQuery = buildEsQuery(dataView, [query], filters, esQueryConfigs); } - if (!isIndexPattern(indexPattern)) { + if (!isDataView(dataView)) { throw new Error('Data view is not defined.'); } return { - indexPattern, + dataView, savedSearch, query, combinedQuery, diff --git a/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts b/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts index 754cc24b65fec4..76fdc77c523e4d 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts @@ -9,7 +9,7 @@ import { useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { isIndexPattern } from '../../../../common/types/index_pattern'; +import { isDataView } from '../../../../common/types/data_view'; import { getSavedSearch, getSavedSearchUrlConflictMessage } from '../../../shared_imports'; @@ -17,9 +17,9 @@ import { useAppDependencies } from '../../app_dependencies'; import { createSearchItems, - getIndexPatternIdByTitle, - loadCurrentIndexPattern, - loadIndexPatterns, + getDataViewIdByTitle, + loadCurrentDataView, + loadDataViews, SearchItems, } from './common'; @@ -28,22 +28,22 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { const [error, setError] = useState(); const appDeps = useAppDependencies(); - const indexPatterns = appDeps.data.indexPatterns; + const dataViews = appDeps.data.dataViews; const uiSettings = appDeps.uiSettings; const savedObjectsClient = appDeps.savedObjects.client; const [searchItems, setSearchItems] = useState(undefined); async function fetchSavedObject(id: string) { - await loadIndexPatterns(savedObjectsClient, indexPatterns); + await loadDataViews(savedObjectsClient, dataViews); - let fetchedIndexPattern; + let fetchedDataView; let fetchedSavedSearch; try { - fetchedIndexPattern = await loadCurrentIndexPattern(indexPatterns, id); + fetchedDataView = await loadCurrentDataView(dataViews, id); } catch (e) { - // Just let fetchedIndexPattern stay undefined in case it doesn't exist. + // Just let fetchedDataView stay undefined in case it doesn't exist. } try { @@ -61,7 +61,7 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { // Just let fetchedSavedSearch stay undefined in case it doesn't exist. } - if (!isIndexPattern(fetchedIndexPattern) && fetchedSavedSearch === undefined) { + if (!isDataView(fetchedDataView) && fetchedSavedSearch === undefined) { setError( i18n.translate('xpack.transform.searchItems.errorInitializationTitle', { defaultMessage: `An error occurred initializing the Kibana data view or saved search.`, @@ -70,7 +70,7 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { return; } - setSearchItems(createSearchItems(fetchedIndexPattern, fetchedSavedSearch, uiSettings)); + setSearchItems(createSearchItems(fetchedDataView, fetchedSavedSearch, uiSettings)); setError(undefined); } @@ -84,8 +84,8 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { return { error, - getIndexPatternIdByTitle, - loadIndexPatterns, + getDataViewIdByTitle, + loadDataViews, searchItems, setSavedObjectId, }; diff --git a/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx index c84f7cb97c9598..dceb585c5c1907 100644 --- a/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx @@ -36,7 +36,7 @@ import { overrideTransformForCloning } from '../../common/transform'; type Props = RouteComponentProps<{ transformId: string }>; export const CloneTransformSection: FC = ({ match, location }) => { - const { indexPatternId }: Record = parse(location.search, { + const { dataViewId }: Record = parse(location.search, { sort: false, }); // Set breadcrumb and page title @@ -73,7 +73,7 @@ export const CloneTransformSection: FC = ({ match, location }) => { } try { - if (indexPatternId === undefined) { + if (dataViewId === undefined) { throw new Error( i18n.translate('xpack.transform.clone.fetchErrorPromptText', { defaultMessage: 'Could not fetch the Kibana data view ID.', @@ -81,7 +81,7 @@ export const CloneTransformSection: FC = ({ match, location }) => { ); } - setSavedObjectId(indexPatternId); + setSavedObjectId(dataViewId); setTransformConfig(overrideTransformForCloning(transformConfigs.transforms[0])); setErrorMessage(undefined); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx index 5006b898f3bb31..b20909ec9e1280 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx @@ -18,10 +18,10 @@ import { SearchItems } from '../../../../hooks/use_search_items'; import { StepDefineFormHook, QUERY_LANGUAGE_KUERY } from '../step_define'; interface SourceSearchBarProps { - indexPattern: SearchItems['indexPattern']; + dataView: SearchItems['dataView']; searchBar: StepDefineFormHook['searchBar']; } -export const SourceSearchBar: FC = ({ indexPattern, searchBar }) => { +export const SourceSearchBar: FC = ({ dataView, searchBar }) => { const { actions: { searchChangeHandler, searchSubmitHandler, setErrorMessage }, state: { errorMessage, searchInput }, @@ -35,7 +35,7 @@ export const SourceSearchBar: FC = ({ indexPattern, search ', () => { test('Minimal initialization', () => { // Arrange const props: StepCreateFormProps = { - createIndexPattern: false, + createDataView: false, transformId: 'the-transform-id', transformConfig: { dest: { @@ -31,7 +31,7 @@ describe('Transform: ', () => { index: 'the-source-index', }, }, - overrides: { created: false, started: false, indexPatternId: undefined }, + overrides: { created: false, started: false, dataViewId: undefined }, onChange() {}, }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx index 42b50e6ef4c1fa..bac77548425106 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx @@ -56,19 +56,19 @@ import { TransformAlertFlyout } from '../../../../../alerting/transform_alerting export interface StepDetailsExposedState { created: boolean; started: boolean; - indexPatternId: string | undefined; + dataViewId: string | undefined; } export function getDefaultStepCreateState(): StepDetailsExposedState { return { created: false, started: false, - indexPatternId: undefined, + dataViewId: undefined, }; } export interface StepCreateFormProps { - createIndexPattern: boolean; + createDataView: boolean; transformId: string; transformConfig: PutTransformsPivotRequestSchema | PutTransformsLatestRequestSchema; overrides: StepDetailsExposedState; @@ -77,7 +77,7 @@ export interface StepCreateFormProps { } export const StepCreateForm: FC = React.memo( - ({ createIndexPattern, transformConfig, transformId, onChange, overrides, timeFieldName }) => { + ({ createDataView, transformConfig, transformId, onChange, overrides, timeFieldName }) => { const defaults = { ...getDefaultStepCreateState(), ...overrides }; const [redirectToTransformManagement, setRedirectToTransformManagement] = useState(false); @@ -86,7 +86,7 @@ export const StepCreateForm: FC = React.memo( const [created, setCreated] = useState(defaults.created); const [started, setStarted] = useState(defaults.started); const [alertFlyoutVisible, setAlertFlyoutVisible] = useState(false); - const [indexPatternId, setIndexPatternId] = useState(defaults.indexPatternId); + const [dataViewId, setDataViewId] = useState(defaults.dataViewId); const [progressPercentComplete, setProgressPercentComplete] = useState( undefined ); @@ -94,14 +94,14 @@ export const StepCreateForm: FC = React.memo( const deps = useAppDependencies(); const { share } = deps; - const indexPatterns = deps.data.indexPatterns; + const dataViews = deps.data.dataViews; const toastNotifications = useToastNotifications(); const isDiscoverAvailable = deps.application.capabilities.discover?.show ?? false; useEffect(() => { let unmounted = false; - onChange({ created, started, indexPatternId }); + onChange({ created, started, dataViewId }); const getDiscoverUrl = async (): Promise => { const locator = share.url.locators.get(DISCOVER_APP_LOCATOR); @@ -109,7 +109,7 @@ export const StepCreateForm: FC = React.memo( if (!locator) return; const discoverUrl = await locator.getUrl({ - indexPatternId, + indexPatternId: dataViewId, }); if (!unmounted) { @@ -117,7 +117,7 @@ export const StepCreateForm: FC = React.memo( } }; - if (started === true && indexPatternId !== undefined && isDiscoverAvailable) { + if (started === true && dataViewId !== undefined && isDiscoverAvailable) { getDiscoverUrl(); } @@ -126,7 +126,7 @@ export const StepCreateForm: FC = React.memo( }; // custom comparison // eslint-disable-next-line react-hooks/exhaustive-deps - }, [created, started, indexPatternId]); + }, [created, started, dataViewId]); const { overlays, theme } = useAppDependencies(); const api = useApi(); @@ -174,8 +174,8 @@ export const StepCreateForm: FC = React.memo( setCreated(true); setLoading(false); - if (createIndexPattern) { - createKibanaIndexPattern(); + if (createDataView) { + createKibanaDataView(); } return true; @@ -228,7 +228,7 @@ export const StepCreateForm: FC = React.memo( } } - const createKibanaIndexPattern = async () => { + const createKibanaDataView = async () => { setLoading(true); const dataViewName = transformConfig.dest.index; const runtimeMappings = transformConfig.source.runtime_mappings as Record< @@ -237,7 +237,7 @@ export const StepCreateForm: FC = React.memo( >; try { - const newIndexPattern = await indexPatterns.createAndSave( + const newDataView = await dataViews.createAndSave( { title: dataViewName, timeFieldName, @@ -256,7 +256,7 @@ export const StepCreateForm: FC = React.memo( }) ); - setIndexPatternId(newIndexPattern.id); + setDataViewId(newDataView.id); setLoading(false); return true; } catch (e) { @@ -529,7 +529,7 @@ export const StepCreateForm: FC = React.memo( data-test-subj="transformWizardCardManagement" />
- {started === true && createIndexPattern === true && indexPatternId === undefined && ( + {started === true && createDataView === true && dataViewId === undefined && ( diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts index 497f37036725cd..e0c8b30a939987 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts @@ -35,11 +35,11 @@ import { getCombinedRuntimeMappings } from '../../../../../common/request'; export function applyTransformConfigToDefineState( state: StepDefineExposedState, transformConfig?: TransformBaseConfig, - indexPattern?: StepDefineFormProps['searchItems']['indexPattern'] + dataView?: StepDefineFormProps['searchItems']['dataView'] ): StepDefineExposedState { // apply runtime fields from both the index pattern and inline configurations state.runtimeMappings = getCombinedRuntimeMappings( - indexPattern, + dataView, transformConfig?.source?.runtime_mappings ); @@ -88,12 +88,12 @@ export function applyTransformConfigToDefineState( state.latestConfig = { unique_key: transformConfig.latest.unique_key.map((v) => ({ value: v, - label: indexPattern ? indexPattern.fields.find((f) => f.name === v)?.displayName ?? v : v, + label: dataView ? dataView.fields.find((f) => f.name === v)?.displayName ?? v : v, })), sort: { value: transformConfig.latest.sort, - label: indexPattern - ? indexPattern.fields.find((f) => f.name === transformConfig.latest.sort)?.displayName ?? + label: dataView + ? dataView.fields.find((f) => f.name === transformConfig.latest.sort)?.displayName ?? transformConfig.latest.sort : transformConfig.latest.sort, }, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts index 9b8dcc1a623e35..61081e7858b279 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts @@ -6,18 +6,18 @@ */ import { getPivotDropdownOptions } from '../common'; -import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; +import { DataView } from '../../../../../../../../../../src/plugins/data_views/public'; import { FilterAggForm } from './filter_agg/components'; import type { RuntimeField } from '../../../../../../../../../../src/plugins/data/common'; describe('Transform: Define Pivot Common', () => { test('getPivotDropdownOptions()', () => { - // The field name includes the characters []> as well as a leading and ending space charcter + // The field name includes the characters []> as well as a leading and ending space character // which cannot be used for aggregation names. The test results verifies that the characters // should still be present in field and dropDownName values, but should be stripped for aggName values. - const indexPattern = { - id: 'the-index-pattern-id', - title: 'the-index-pattern-title', + const dataView = { + id: 'the-data-view-id', + title: 'the-data-view-title', fields: [ { name: ' the-f[i]e>ld ', @@ -27,9 +27,9 @@ describe('Transform: Define Pivot Common', () => { searchable: true, }, ], - } as IndexPattern; + } as DataView; - const options = getPivotDropdownOptions(indexPattern); + const options = getPivotDropdownOptions(dataView); expect(options).toMatchObject({ aggOptions: [ @@ -120,7 +120,7 @@ describe('Transform: Define Pivot Common', () => { }, } as RuntimeField, }; - const optionsWithRuntimeFields = getPivotDropdownOptions(indexPattern, runtimeMappings); + const optionsWithRuntimeFields = getPivotDropdownOptions(dataView, runtimeMappings); expect(optionsWithRuntimeFields).toMatchObject({ aggOptions: [ { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx index 8c3c649749c2fb..745cd81908ac8a 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx @@ -14,7 +14,7 @@ import { KBN_FIELD_TYPES, RuntimeField, } from '../../../../../../../../../../../../src/plugins/data/common'; -import { IndexPattern } from '../../../../../../../../../../../../src/plugins/data/public'; +import { DataView } from '../../../../../../../../../../../../src/plugins/data_views/public'; import { FilterTermForm } from './filter_term_form'; describe('FilterAggForm', () => { @@ -27,7 +27,7 @@ describe('FilterAggForm', () => { } as RuntimeField, }; - const indexPattern = { + const dataView = { fields: { getByName: jest.fn((fieldName: string) => { if (fieldName === 'test_text_field') { @@ -42,14 +42,14 @@ describe('FilterAggForm', () => { } }), }, - } as unknown as IndexPattern; + } as unknown as DataView; test('should render only select dropdown on empty configuration', async () => { const onChange = jest.fn(); const { getByLabelText, findByTestId, container } = render( - + @@ -74,7 +74,7 @@ describe('FilterAggForm', () => { const { findByTestId } = render( - + @@ -102,7 +102,7 @@ describe('FilterAggForm', () => { const { rerender, findByTestId } = render( - + @@ -111,7 +111,7 @@ describe('FilterAggForm', () => { // re-render the same component with different props rerender( - + @@ -139,7 +139,7 @@ describe('FilterAggForm', () => { const { findByTestId, container } = render( - + { - const { indexPattern, runtimeMappings } = useContext(CreateTransformWizardContext); + const { dataView, runtimeMappings } = useContext(CreateTransformWizardContext); const filterAggsOptions = useMemo( - () => getSupportedFilterAggs(selectedField, indexPattern!, runtimeMappings), - [indexPattern, selectedField, runtimeMappings] + () => getSupportedFilterAggs(selectedField, dataView!, runtimeMappings), + [dataView, selectedField, runtimeMappings] ); useUpdateEffect(() => { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx index 2d24d07fd7019c..11f9dadbb359c6 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx @@ -30,7 +30,7 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm selectedField, }) => { const api = useApi(); - const { indexPattern, runtimeMappings } = useContext(CreateTransformWizardContext); + const { dataView, runtimeMappings } = useContext(CreateTransformWizardContext); const toastNotifications = useToastNotifications(); const [options, setOptions] = useState([]); @@ -40,7 +40,7 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm const fetchOptions = useCallback( debounce(async (searchValue: string) => { const esSearchRequest = { - index: indexPattern!.title, + index: dataView!.title, body: { ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), query: { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts index b17f30d115f4a2..5c4ff5a53f7248 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts @@ -8,9 +8,9 @@ import { EuiComboBoxOptionOption } from '@elastic/eui'; import { ES_FIELD_TYPES, - IndexPattern, KBN_FIELD_TYPES, } from '../../../../../../../../../../src/plugins/data/public'; +import { DataView } from '../../../../../../../../../../src/plugins/data_views/public'; import { getNestedProperty } from '../../../../../../../common/utils/object_utils'; import { removeKeywordPostfix } from '../../../../../../../common/utils/field_utils'; @@ -58,7 +58,7 @@ export function getKibanaFieldTypeFromEsType(type: string): KBN_FIELD_TYPES { } export function getPivotDropdownOptions( - indexPattern: IndexPattern, + dataView: DataView, runtimeMappings?: StepDefineExposedState['runtimeMappings'] ) { // The available group by options @@ -70,7 +70,7 @@ export function getPivotDropdownOptions( const aggOptionsData: PivotAggsConfigWithUiSupportDict = {}; const ignoreFieldNames = ['_id', '_index', '_type']; - const indexPatternFields = indexPattern.fields + const dataViewFields = dataView.fields .filter( (field) => field.aggregatable === true && @@ -93,7 +93,7 @@ export function getPivotDropdownOptions( const sortByLabel = (a: Field, b: Field) => a.name.localeCompare(b.name); - const combinedFields = [...indexPatternFields, ...runtimeFields].sort(sortByLabel); + const combinedFields = [...dataViewFields, ...runtimeFields].sort(sortByLabel); combinedFields.forEach((field) => { const rawFieldName = field.name; const displayFieldName = removeKeywordPostfix(rawFieldName); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts index d6473abb04702c..46d5d1b562a849 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts @@ -30,18 +30,18 @@ export const latestConfigMapper = { /** * Provides available options for unique_key and sort fields - * @param indexPattern + * @param dataView * @param aggConfigs * @param runtimeMappings */ function getOptions( - indexPattern: StepDefineFormProps['searchItems']['indexPattern'], + dataView: StepDefineFormProps['searchItems']['dataView'], aggConfigs: AggConfigs, runtimeMappings?: StepDefineExposedState['runtimeMappings'] ) { const aggConfig = aggConfigs.aggs[0]; const param = aggConfig.type.params.find((p) => p.type === 'field'); - const filteredIndexPatternFields = param + const filteredDataViewFields = param ? (param as unknown as FieldParamType) .getAvailableFields(aggConfig) // runtimeMappings may already include runtime fields defined by the data view @@ -54,7 +54,7 @@ function getOptions( ? Object.keys(runtimeMappings).map((k) => ({ label: k, value: k })) : []; - const uniqueKeyOptions: Array> = filteredIndexPatternFields + const uniqueKeyOptions: Array> = filteredDataViewFields .filter((v) => !ignoreFieldNames.has(v.name)) .map((v) => ({ label: v.displayName, @@ -70,7 +70,7 @@ function getOptions( })) : []; - const indexPatternFieldsSortOptions: Array> = indexPattern.fields + const dataViewFieldsSortOptions: Array> = dataView.fields // The backend API for `latest` allows all field types for sort but the UI will be limited to `date`. .filter((v) => !ignoreFieldNames.has(v.name) && v.sortable && v.type === 'date') .map((v) => ({ @@ -83,9 +83,7 @@ function getOptions( return { uniqueKeyOptions: [...uniqueKeyOptions, ...runtimeFieldsOptions].sort(sortByLabel), - sortFieldOptions: [...indexPatternFieldsSortOptions, ...runtimeFieldsSortOptions].sort( - sortByLabel - ), + sortFieldOptions: [...dataViewFieldsSortOptions, ...runtimeFieldsSortOptions].sort(sortByLabel), }; } @@ -112,7 +110,7 @@ export function validateLatestConfig(config?: LatestFunctionConfig) { export function useLatestFunctionConfig( defaults: StepDefineExposedState['latestConfig'], - indexPattern: StepDefineFormProps['searchItems']['indexPattern'], + dataView: StepDefineFormProps['searchItems']['dataView'], runtimeMappings: StepDefineExposedState['runtimeMappings'] ): { config: LatestFunctionConfigUI; @@ -130,9 +128,9 @@ export function useLatestFunctionConfig( const { data } = useAppDependencies(); const { uniqueKeyOptions, sortFieldOptions } = useMemo(() => { - const aggConfigs = data.search.aggs.createAggConfigs(indexPattern, [{ type: 'terms' }]); - return getOptions(indexPattern, aggConfigs, runtimeMappings); - }, [indexPattern, data.search.aggs, runtimeMappings]); + const aggConfigs = data.search.aggs.createAggConfigs(dataView, [{ type: 'terms' }]); + return getOptions(dataView, aggConfigs, runtimeMappings); + }, [dataView, data.search.aggs, runtimeMappings]); const updateLatestFunctionConfig = useCallback( (update) => diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts index 2415f04c220a66..c16270a6a2dca0 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts @@ -100,13 +100,13 @@ function getRootAggregation(item: PivotAggsConfig) { export const usePivotConfig = ( defaults: StepDefineExposedState, - indexPattern: StepDefineFormProps['searchItems']['indexPattern'] + dataView: StepDefineFormProps['searchItems']['dataView'] ) => { const toastNotifications = useToastNotifications(); const { aggOptions, aggOptionsData, groupByOptions, groupByOptionsData, fields } = useMemo( - () => getPivotDropdownOptions(indexPattern, defaults.runtimeMappings), - [defaults.runtimeMappings, indexPattern] + () => getPivotDropdownOptions(dataView, defaults.runtimeMappings), + [defaults.runtimeMappings, dataView] ); // The list of selected aggregations diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts index be6104d393d3f5..b8c818720f0a99 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts @@ -24,7 +24,7 @@ import { StepDefineFormProps } from '../step_define_form'; export const useSearchBar = ( defaults: StepDefineExposedState, - indexPattern: StepDefineFormProps['searchItems']['indexPattern'] + dataView: StepDefineFormProps['searchItems']['dataView'] ) => { // The internal state of the input query bar updated on every key stroke. const [searchInput, setSearchInput] = useState({ @@ -53,7 +53,7 @@ export const useSearchBar = ( switch (query.language) { case QUERY_LANGUAGE_KUERY: setSearchQuery( - toElasticsearchQuery(fromKueryExpression(query.query as string), indexPattern) + toElasticsearchQuery(fromKueryExpression(query.query as string), dataView) ); return; case QUERY_LANGUAGE_LUCENE: diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts index b56df5e395c881..f4c396808e294c 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts @@ -25,21 +25,21 @@ export type StepDefineFormHook = ReturnType; export const useStepDefineForm = ({ overrides, onChange, searchItems }: StepDefineFormProps) => { const defaults = { ...getDefaultStepDefineState(searchItems), ...overrides }; - const { indexPattern } = searchItems; + const { dataView } = searchItems; const [transformFunction, setTransformFunction] = useState(defaults.transformFunction); - const searchBar = useSearchBar(defaults, indexPattern); - const pivotConfig = usePivotConfig(defaults, indexPattern); + const searchBar = useSearchBar(defaults, dataView); + const pivotConfig = usePivotConfig(defaults, dataView); const latestFunctionConfig = useLatestFunctionConfig( defaults.latestConfig, - indexPattern, + dataView, defaults?.runtimeMappings ); const previewRequest = getPreviewTransformRequestBody( - indexPattern.title, + dataView.title, searchBar.state.pivotQuery, pivotConfig.state.requestPayload, defaults?.runtimeMappings @@ -58,7 +58,7 @@ export const useStepDefineForm = ({ overrides, onChange, searchItems }: StepDefi const runtimeMappings = runtimeMappingsEditor.state.runtimeMappings; if (!advancedSourceEditor.state.isAdvancedSourceEditorEnabled) { const previewRequestUpdate = getPreviewTransformRequestBody( - indexPattern.title, + dataView.title, searchBar.state.pivotQuery, pivotConfig.state.requestPayload, runtimeMappings diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx index 6e80b6162048e0..054deb23eac507 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx @@ -57,10 +57,10 @@ describe('Transform: ', () => { const mlSharedImports = await getMlSharedImports(); const searchItems = { - indexPattern: { - title: 'the-index-pattern-title', + dataView: { + title: 'the-data-view-title', fields: [] as any[], - } as SearchItems['indexPattern'], + } as SearchItems['dataView'], }; // mock services for QueryStringInput @@ -84,7 +84,7 @@ describe('Transform: ', () => { // Act // Assert expect(getByText('Data view')).toBeInTheDocument(); - expect(getByText(searchItems.indexPattern.title)).toBeInTheDocument(); + expect(getByText(searchItems.dataView.title)).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx index 8d023e2ae430d3..32bc4023f06f11 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx @@ -67,7 +67,7 @@ export interface StepDefineFormProps { export const StepDefineForm: FC = React.memo((props) => { const { searchItems } = props; - const { indexPattern } = searchItems; + const { dataView } = searchItems; const { ml: { DataGrid }, } = useAppDependencies(); @@ -88,7 +88,7 @@ export const StepDefineForm: FC = React.memo((props) => { const indexPreviewProps = { ...useIndexData( - indexPattern, + dataView, stepDefineForm.searchBar.state.pivotQuery, stepDefineForm.runtimeMappingsEditor.state.runtimeMappings ), @@ -101,7 +101,7 @@ export const StepDefineForm: FC = React.memo((props) => { : stepDefineForm.latestFunctionConfig; const previewRequest = getPreviewTransformRequestBody( - indexPattern.title, + dataView.title, pivotQuery, stepDefineForm.transformFunction === TRANSFORM_FUNCTION.PIVOT ? stepDefineForm.pivotConfig.state.requestPayload @@ -109,7 +109,7 @@ export const StepDefineForm: FC = React.memo((props) => { stepDefineForm.runtimeMappingsEditor.state.runtimeMappings ); - const copyToClipboardSource = getIndexDevConsoleStatement(pivotQuery, indexPattern.title); + const copyToClipboardSource = getIndexDevConsoleStatement(pivotQuery, dataView.title); const copyToClipboardSourceDescription = i18n.translate( 'xpack.transform.indexPreview.copyClipboardTooltip', { @@ -127,7 +127,7 @@ export const StepDefineForm: FC = React.memo((props) => { const pivotPreviewProps = { ...usePivotData( - indexPattern.title, + dataView.title, pivotQuery, validationStatus, requestPayload, @@ -211,7 +211,7 @@ export const StepDefineForm: FC = React.memo((props) => { defaultMessage: 'Data view', })} > - {indexPattern.title} + {dataView.title} )} @@ -233,10 +233,7 @@ export const StepDefineForm: FC = React.memo((props) => { {searchItems.savedSearch === undefined && ( <> {!isAdvancedSourceEditorEnabled && ( - + )} {isAdvancedSourceEditorEnabled && } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx index 1e3fa2026061b7..1b2d5872e53b66 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx @@ -33,10 +33,10 @@ describe('Transform: ', () => { const mlSharedImports = await getMlSharedImports(); const searchItems = { - indexPattern: { - title: 'the-index-pattern-title', + dataView: { + title: 'the-data-view-title', fields: [] as any[], - } as SearchItems['indexPattern'], + } as SearchItems['dataView'], }; const groupBy: PivotGroupByConfig = { agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx index 2abb3f4c4cda85..2bae20da65067e 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx @@ -56,14 +56,14 @@ export const StepDefineSummary: FC = ({ const pivotQuery = getPivotQuery(searchQuery); const previewRequest = getPreviewTransformRequestBody( - searchItems.indexPattern.title, + searchItems.dataView.title, pivotQuery, partialPreviewRequest, runtimeMappings ); const pivotPreviewProps = usePivotData( - searchItems.indexPattern.title, + searchItems.dataView.title, pivotQuery, validationStatus, partialPreviewRequest, @@ -92,7 +92,7 @@ export const StepDefineSummary: FC = ({ defaultMessage: 'Data view', })} > - {searchItems.indexPattern.title} + {searchItems.dataView.title} {typeof searchString === 'string' && ( ; } @@ -40,7 +40,7 @@ export function getDefaultStepDetailsState(): StepDetailsExposedState { return { continuousModeDateField: '', continuousModeDelay: defaultContinuousModeDelay, - createIndexPattern: true, + createDataView: true, isContinuousModeEnabled: false, isRetentionPolicyEnabled: false, retentionPolicyDateField: '', @@ -53,7 +53,7 @@ export function getDefaultStepDetailsState(): StepDetailsExposedState { destinationIngestPipeline: '', touched: false, valid: false, - indexPatternTimeField: undefined, + dataViewTimeField: undefined, }; } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx index 75ed5c10f0483c..aa08049ac9d64c 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx @@ -49,7 +49,7 @@ import { getPreviewTransformRequestBody, isTransformIdValid, } from '../../../../common'; -import { EsIndexName, IndexPatternTitle } from './common'; +import { EsIndexName, DataViewTitle } from './common'; import { continuousModeDelayValidator, retentionPolicyMaxAgeValidator, @@ -99,14 +99,12 @@ export const StepDetailsForm: FC = React.memo( ); // Index pattern state - const [indexPatternTitles, setIndexPatternTitles] = useState([]); - const [createIndexPattern, setCreateIndexPattern] = useState( - canCreateDataView === false ? false : defaults.createIndexPattern + const [dataViewTitles, setDataViewTitles] = useState([]); + const [createDataView, setCreateDataView] = useState( + canCreateDataView === false ? false : defaults.createDataView ); - const [indexPatternAvailableTimeFields, setIndexPatternAvailableTimeFields] = useState< - string[] - >([]); - const [indexPatternTimeField, setIndexPatternTimeField] = useState(); + const [dataViewAvailableTimeFields, setDataViewAvailableTimeFields] = useState([]); + const [dataViewTimeField, setDataViewTimeField] = useState(); const onTimeFieldChanged = React.useCallback( (e: React.ChangeEvent) => { @@ -117,11 +115,11 @@ export const StepDetailsForm: FC = React.memo( } // Find the time field based on the selected value // this is to account for undefined when user chooses not to use a date field - const timeField = indexPatternAvailableTimeFields.find((col) => col === value); + const timeField = dataViewAvailableTimeFields.find((col) => col === value); - setIndexPatternTimeField(timeField); + setDataViewTimeField(timeField); }, - [setIndexPatternTimeField, indexPatternAvailableTimeFields] + [setDataViewTimeField, dataViewAvailableTimeFields] ); const { overlays, theme } = useAppDependencies(); @@ -134,7 +132,7 @@ export const StepDetailsForm: FC = React.memo( const { searchQuery, previewRequest: partialPreviewRequest } = stepDefineState; const pivotQuery = getPivotQuery(searchQuery); const previewRequest = getPreviewTransformRequestBody( - searchItems.indexPattern.title, + searchItems.dataView.title, pivotQuery, partialPreviewRequest, stepDefineState.runtimeMappings @@ -148,8 +146,8 @@ export const StepDetailsForm: FC = React.memo( (col) => properties[col].type === 'date' ); - setIndexPatternAvailableTimeFields(timeFields); - setIndexPatternTimeField(timeFields[0]); + setDataViewAvailableTimeFields(timeFields); + setDataViewTimeField(timeFields[0]); } else { toastNotifications.addDanger({ title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingTransformPreview', { @@ -228,7 +226,7 @@ export const StepDetailsForm: FC = React.memo( } try { - setIndexPatternTitles(await deps.data.indexPatterns.getTitles()); + setDataViewTitles(await deps.data.dataViews.getTitles()); } catch (e) { toastNotifications.addDanger({ title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingDataViewTitles', { @@ -245,7 +243,7 @@ export const StepDetailsForm: FC = React.memo( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const dateFieldNames = searchItems.indexPattern.fields + const dateFieldNames = searchItems.dataView.fields .filter((f) => f.type === KBN_FIELD_TYPES.DATE) .map((f) => f.name) .sort(); @@ -291,7 +289,7 @@ export const StepDetailsForm: FC = React.memo( const indexNameExists = indexNames.some((name) => destinationIndex === name); const indexNameEmpty = destinationIndex === ''; const indexNameValid = isValidIndexName(destinationIndex); - const indexPatternTitleExists = indexPatternTitles.some((name) => destinationIndex === name); + const dataViewTitleExists = dataViewTitles.some((name) => destinationIndex === name); const [transformFrequency, setTransformFrequency] = useState(defaults.transformFrequency); const isTransformFrequencyValid = transformFrequencyValidator(transformFrequency); @@ -313,7 +311,7 @@ export const StepDetailsForm: FC = React.memo( isTransformSettingsMaxPageSearchSizeValid && !indexNameEmpty && indexNameValid && - (!indexPatternTitleExists || !createIndexPattern) && + (!dataViewTitleExists || !createDataView) && (!isContinuousModeAvailable || (isContinuousModeAvailable && isContinuousModeDelayValid)) && (!isRetentionPolicyAvailable || !isRetentionPolicyEnabled || @@ -327,7 +325,7 @@ export const StepDetailsForm: FC = React.memo( onChange({ continuousModeDateField, continuousModeDelay, - createIndexPattern, + createDataView, isContinuousModeEnabled, isRetentionPolicyEnabled, retentionPolicyDateField, @@ -341,7 +339,7 @@ export const StepDetailsForm: FC = React.memo( destinationIngestPipeline, touched: true, valid, - indexPatternTimeField, + dataViewTimeField, _meta: defaults._meta, }); // custom comparison @@ -349,7 +347,7 @@ export const StepDetailsForm: FC = React.memo( }, [ continuousModeDateField, continuousModeDelay, - createIndexPattern, + createDataView, isContinuousModeEnabled, isRetentionPolicyEnabled, retentionPolicyDateField, @@ -361,7 +359,7 @@ export const StepDetailsForm: FC = React.memo( destinationIndex, destinationIngestPipeline, valid, - indexPatternTimeField, + dataViewTimeField, /* eslint-enable react-hooks/exhaustive-deps */ ]); @@ -530,9 +528,7 @@ export const StepDetailsForm: FC = React.memo( ) : null} = React.memo( , ] : []), - ...(createIndexPattern && indexPatternTitleExists + ...(createDataView && dataViewTitleExists ? [ i18n.translate('xpack.transform.stepDetailsForm.dataViewTitleError', { defaultMessage: 'A data view with this title already exists.', @@ -553,25 +549,23 @@ export const StepDetailsForm: FC = React.memo( ]} > setCreateIndexPattern(!createIndexPattern)} - data-test-subj="transformCreateIndexPatternSwitch" + checked={createDataView === true} + onChange={() => setCreateDataView(!createDataView)} + data-test-subj="transformCreateDataViewSwitch" /> - {createIndexPattern && - !indexPatternTitleExists && - indexPatternAvailableTimeFields.length > 0 && ( - - )} + {createDataView && !dataViewTitleExists && dataViewAvailableTimeFields.length > 0 && ( + + )} {/* Continuous mode */} = React.memo((props) => { const { continuousModeDateField, - createIndexPattern, + createDataView, isContinuousModeEnabled, isRetentionPolicyEnabled, retentionPolicyDateField, @@ -28,14 +28,14 @@ export const StepDetailsSummary: FC = React.memo((props destinationIndex, destinationIngestPipeline, touched, - indexPatternTimeField, + dataViewTimeField, } = props; if (touched === false) { return null; } - const destinationIndexHelpText = createIndexPattern + const destinationIndexHelpText = createDataView ? i18n.translate('xpack.transform.stepDetailsSummary.createDataViewMessage', { defaultMessage: 'A Kibana data view will be created for this transform.', }) @@ -69,13 +69,13 @@ export const StepDetailsSummary: FC = React.memo((props > {destinationIndex} - {createIndexPattern && indexPatternTimeField !== undefined && indexPatternTimeField !== '' && ( + {createDataView && dataViewTimeField !== undefined && dataViewTimeField !== '' && ( - {indexPatternTimeField} + {dataViewTimeField} )} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_time_field.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_time_field.tsx index 8d7f6b451f985d..d750bf6c7e1fdd 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_time_field.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_time_field.tsx @@ -11,14 +11,14 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; interface Props { - indexPatternAvailableTimeFields: string[]; - indexPatternTimeField: string | undefined; + dataViewAvailableTimeFields: string[]; + dataViewTimeField: string | undefined; onTimeFieldChanged: (e: React.ChangeEvent) => void; } export const StepDetailsTimeField: FC = ({ - indexPatternAvailableTimeFields, - indexPatternTimeField, + dataViewAvailableTimeFields, + dataViewTimeField, onTimeFieldChanged, }) => { const noTimeFieldLabel = i18n.translate( @@ -56,13 +56,13 @@ export const StepDetailsTimeField: FC = ({ > ({ text })), + ...dataViewAvailableTimeFields.map((text) => ({ text })), disabledDividerOption, noTimeFieldOption, ]} - value={indexPatternTimeField} + value={dataViewTimeField} onChange={onTimeFieldChanged} - data-test-subj="transformIndexPatternTimeFieldSelect" + data-test-subj="transformDataViewTimeFieldSelect" /> ); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx index 27c43ed01a9345..c16756d0923e96 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx @@ -31,7 +31,7 @@ import { StepDetailsSummary, } from '../step_details'; import { WizardNav } from '../wizard_nav'; -import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; +import { DataView } from '../../../../../../../../../src/plugins/data_views/public'; import type { RuntimeMappings } from '../step_define/common/types'; enum WIZARD_STEPS { @@ -86,26 +86,22 @@ interface WizardProps { } export const CreateTransformWizardContext = createContext<{ - indexPattern: IndexPattern | null; + dataView: DataView | null; runtimeMappings: RuntimeMappings | undefined; }>({ - indexPattern: null, + dataView: null, runtimeMappings: undefined, }); export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) => { - const { indexPattern } = searchItems; + const { dataView } = searchItems; // The current WIZARD_STEP const [currentStep, setCurrentStep] = useState(WIZARD_STEPS.DEFINE); // The DEFINE state const [stepDefineState, setStepDefineState] = useState( - applyTransformConfigToDefineState( - getDefaultStepDefineState(searchItems), - cloneConfig, - indexPattern - ) + applyTransformConfigToDefineState(getDefaultStepDefineState(searchItems), cloneConfig, dataView) ); // The DETAILS state @@ -117,7 +113,7 @@ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) const [stepCreateState, setStepCreateState] = useState(getDefaultStepCreateState); const transformConfig = getCreateTransformRequestBody( - indexPattern.title, + dataView.title, stepDefineState, stepDetailsState ); @@ -180,12 +176,12 @@ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) {currentStep === WIZARD_STEPS.CREATE ? ( ) : ( @@ -200,19 +196,19 @@ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) }, [ currentStep, setCurrentStep, - stepDetailsState.createIndexPattern, + stepDetailsState.createDataView, stepDetailsState.transformId, transformConfig, setStepCreateState, stepCreateState, - stepDetailsState.indexPatternTimeField, + stepDetailsState.dataViewTimeField, ]); const stepsConfig = [stepDefine, stepDetails, stepCreate]; return ( diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx index cf2ec765dc06b3..f6c700aef67ccc 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx @@ -22,23 +22,23 @@ export const useCloneAction = (forceDisable: boolean, transformNodes: number) => const history = useHistory(); const appDeps = useAppDependencies(); const savedObjectsClient = appDeps.savedObjects.client; - const indexPatterns = appDeps.data.indexPatterns; + const dataViews = appDeps.data.dataViews; const toastNotifications = useToastNotifications(); - const { getIndexPatternIdByTitle, loadIndexPatterns } = useSearchItems(undefined); + const { getDataViewIdByTitle, loadDataViews } = useSearchItems(undefined); const { canCreateTransform } = useContext(AuthorizationContext).capabilities; const clickHandler = useCallback( async (item: TransformListRow) => { try { - await loadIndexPatterns(savedObjectsClient, indexPatterns); + await loadDataViews(savedObjectsClient, dataViews); const dataViewTitle = Array.isArray(item.config.source.index) ? item.config.source.index.join(',') : item.config.source.index; - const indexPatternId = getIndexPatternIdByTitle(dataViewTitle); + const dataViewId = getDataViewIdByTitle(dataViewTitle); - if (indexPatternId === undefined) { + if (dataViewId === undefined) { toastNotifications.addDanger( i18n.translate('xpack.transform.clone.noDataViewErrorPromptText', { defaultMessage: @@ -47,9 +47,7 @@ export const useCloneAction = (forceDisable: boolean, transformNodes: number) => }) ); } else { - history.push( - `/${SECTION_SLUG.CLONE_TRANSFORM}/${item.id}?indexPatternId=${indexPatternId}` - ); + history.push(`/${SECTION_SLUG.CLONE_TRANSFORM}/${item.id}?dataViewId=${dataViewId}`); } } catch (e) { toastNotifications.addError(e, { @@ -62,10 +60,10 @@ export const useCloneAction = (forceDisable: boolean, transformNodes: number) => [ history, savedObjectsClient, - indexPatterns, + dataViews, toastNotifications, - loadIndexPatterns, - getIndexPatternIdByTitle, + loadDataViews, + getDataViewIdByTitle, ] ); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx index d5436d51c218bb..e369d9e992e302 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx @@ -23,12 +23,12 @@ export const DeleteActionModal: FC = ({ closeModal, deleteAndCloseModal, deleteDestIndex, - deleteIndexPattern, - indexPatternExists, + deleteDataView, + dataViewExists, items, shouldForceDelete, toggleDeleteIndex, - toggleDeleteIndexPattern, + toggleDeleteDataView, userCanDeleteIndex, userCanDeleteDataView, }) => { @@ -81,15 +81,15 @@ export const DeleteActionModal: FC = ({ { } @@ -130,11 +130,11 @@ export const DeleteActionModal: FC = ({ /> )} - {userCanDeleteIndex && indexPatternExists && ( + {userCanDeleteIndex && dataViewExists && ( = ({ values: { destinationIndex: items[0] && items[0].config.dest.index }, } )} - checked={deleteIndexPattern} - onChange={toggleDeleteIndexPattern} + checked={deleteDataView} + onChange={toggleDeleteDataView} disabled={userCanDeleteDataView === false} /> diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx index b41dfe1c06a8a3..357809b54746b5 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx @@ -40,18 +40,18 @@ export const useDeleteAction = (forceDisable: boolean) => { userCanDeleteIndex, userCanDeleteDataView, deleteDestIndex, - indexPatternExists, - deleteIndexPattern, + dataViewExists, + deleteDataView, toggleDeleteIndex, - toggleDeleteIndexPattern, + toggleDeleteDataView, } = useDeleteIndexAndTargetIndex(items); const deleteAndCloseModal = () => { setModalVisible(false); const shouldDeleteDestIndex = userCanDeleteIndex && deleteDestIndex; - const shouldDeleteDestIndexPattern = - userCanDeleteIndex && userCanDeleteDataView && indexPatternExists && deleteIndexPattern; + const shouldDeleteDestDataView = + userCanDeleteIndex && userCanDeleteDataView && dataViewExists && deleteDataView; // if we are deleting multiple transforms, then force delete all if at least one item has failed // else, force delete only when the item user picks has failed const forceDelete = isBulkAction @@ -64,7 +64,7 @@ export const useDeleteAction = (forceDisable: boolean) => { state: i.stats.state, })), deleteDestIndex: shouldDeleteDestIndex, - deleteDestIndexPattern: shouldDeleteDestIndexPattern, + deleteDestDataView: shouldDeleteDestDataView, forceDelete, }); }; @@ -103,14 +103,14 @@ export const useDeleteAction = (forceDisable: boolean) => { closeModal, deleteAndCloseModal, deleteDestIndex, - deleteIndexPattern, - indexPatternExists, + deleteDataView, + dataViewExists, isModalVisible, items, openModal, shouldForceDelete, toggleDeleteIndex, - toggleDeleteIndexPattern, + toggleDeleteDataView, userCanDeleteIndex, userCanDeleteDataView, }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx index 9c8945264f000b..0f73f6aac40d38 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx @@ -52,7 +52,7 @@ describe('Transform: Transform List Actions ', () => { // prepare render( - + ); @@ -72,7 +72,7 @@ describe('Transform: Transform List Actions ', () => { itemCopy.stats.checkpointing.last.checkpoint = 0; render( - + ); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx index 0a5342b3b0c251..f7cc72c2236b05 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx @@ -23,7 +23,7 @@ export const discoverActionNameText = i18n.translate( export const isDiscoverActionDisabled = ( items: TransformListRow[], forceDisable: boolean, - indexPatternExists: boolean + dataViewExists: boolean ) => { if (items.length !== 1) { return true; @@ -38,14 +38,14 @@ export const isDiscoverActionDisabled = ( const transformNeverStarted = stoppedTransform === true && transformProgress === undefined && isBatchTransform === true; - return forceDisable === true || indexPatternExists === false || transformNeverStarted === true; + return forceDisable === true || dataViewExists === false || transformNeverStarted === true; }; export interface DiscoverActionNameProps { - indexPatternExists: boolean; + dataViewExists: boolean; items: TransformListRow[]; } -export const DiscoverActionName: FC = ({ indexPatternExists, items }) => { +export const DiscoverActionName: FC = ({ dataViewExists, items }) => { const isBulkAction = items.length > 1; const item = items[0]; @@ -65,7 +65,7 @@ export const DiscoverActionName: FC = ({ indexPatternEx defaultMessage: 'Links to Discover are not supported as a bulk action.', } ); - } else if (!indexPatternExists) { + } else if (!dataViewExists) { disabledTransformMessage = i18n.translate( 'xpack.transform.transformList.discoverTransformNoDataViewToolTip', { diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx index 9b1d7ed0664045..71a45b572f833d 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx @@ -20,7 +20,7 @@ import { DiscoverActionName, } from './discover_action_name'; -const getIndexPatternTitleFromTargetIndex = (item: TransformListRow) => +const getDataViewTitleFromTargetIndex = (item: TransformListRow) => Array.isArray(item.config.dest.index) ? item.config.dest.index.join(',') : item.config.dest.index; export type DiscoverAction = ReturnType; @@ -28,60 +28,59 @@ export const useDiscoverAction = (forceDisable: boolean) => { const appDeps = useAppDependencies(); const { share } = appDeps; const savedObjectsClient = appDeps.savedObjects.client; - const indexPatterns = appDeps.data.indexPatterns; + const dataViews = appDeps.data.dataViews; const isDiscoverAvailable = !!appDeps.application.capabilities.discover?.show; - const { getIndexPatternIdByTitle, loadIndexPatterns } = useSearchItems(undefined); + const { getDataViewIdByTitle, loadDataViews } = useSearchItems(undefined); - const [indexPatternsLoaded, setIndexPatternsLoaded] = useState(false); + const [dataViewsLoaded, setDataViewsLoaded] = useState(false); useEffect(() => { - async function checkIndexPatternAvailability() { - await loadIndexPatterns(savedObjectsClient, indexPatterns); - setIndexPatternsLoaded(true); + async function checkDataViewAvailability() { + await loadDataViews(savedObjectsClient, dataViews); + setDataViewsLoaded(true); } - checkIndexPatternAvailability(); - }, [indexPatterns, loadIndexPatterns, savedObjectsClient]); + checkDataViewAvailability(); + }, [dataViews, loadDataViews, savedObjectsClient]); const clickHandler = useCallback( (item: TransformListRow) => { const locator = share.url.locators.get(DISCOVER_APP_LOCATOR); if (!locator) return; - const indexPatternTitle = getIndexPatternTitleFromTargetIndex(item); - const indexPatternId = getIndexPatternIdByTitle(indexPatternTitle); + const dataViewTitle = getDataViewTitleFromTargetIndex(item); + const dataViewId = getDataViewIdByTitle(dataViewTitle); locator.navigateSync({ - indexPatternId, + indexPatternId: dataViewId, }); }, - [getIndexPatternIdByTitle, share] + [getDataViewIdByTitle, share] ); - const indexPatternExists = useCallback( + const dataViewExists = useCallback( (item: TransformListRow) => { - const indexPatternTitle = getIndexPatternTitleFromTargetIndex(item); - const indexPatternId = getIndexPatternIdByTitle(indexPatternTitle); - return indexPatternId !== undefined; + const dataViewTitle = getDataViewTitleFromTargetIndex(item); + const dataViewId = getDataViewIdByTitle(dataViewTitle); + return dataViewId !== undefined; }, - [getIndexPatternIdByTitle] + [getDataViewIdByTitle] ); const action: TransformListAction = useMemo( () => ({ name: (item: TransformListRow) => { - return ; + return ; }, available: () => isDiscoverAvailable, enabled: (item: TransformListRow) => - indexPatternsLoaded && - !isDiscoverActionDisabled([item], forceDisable, indexPatternExists(item)), + dataViewsLoaded && !isDiscoverActionDisabled([item], forceDisable, dataViewExists(item)), description: discoverActionNameText, icon: 'visTable', type: 'icon', onClick: clickHandler, 'data-test-subj': 'transformActionDiscover', }), - [forceDisable, indexPatternExists, indexPatternsLoaded, isDiscoverAvailable, clickHandler] + [forceDisable, dataViewExists, dataViewsLoaded, isDiscoverAvailable, clickHandler] ); return { action }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx index f789327a051e29..e4927fff97070d 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx @@ -22,14 +22,14 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => const [config, setConfig] = useState(); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); - const [indexPatternId, setIndexPatternId] = useState(); + const [dataViewId, setDataViewId] = useState(); const closeFlyout = () => setIsFlyoutVisible(false); - const { getIndexPatternIdByTitle } = useSearchItems(undefined); + const { getDataViewIdByTitle } = useSearchItems(undefined); const toastNotifications = useToastNotifications(); const appDeps = useAppDependencies(); - const indexPatterns = appDeps.data.indexPatterns; + const dataViews = appDeps.data.dataViews; const clickHandler = useCallback( async (item: TransformListRow) => { @@ -37,9 +37,9 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => const dataViewTitle = Array.isArray(item.config.source.index) ? item.config.source.index.join(',') : item.config.source.index; - const currentIndexPatternId = getIndexPatternIdByTitle(dataViewTitle); + const currentDataViewId = getDataViewIdByTitle(dataViewTitle); - if (currentIndexPatternId === undefined) { + if (currentDataViewId === undefined) { toastNotifications.addWarning( i18n.translate('xpack.transform.edit.noDataViewErrorPromptText', { defaultMessage: @@ -48,7 +48,7 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => }) ); } - setIndexPatternId(currentIndexPatternId); + setDataViewId(currentDataViewId); setConfig(item.config); setIsFlyoutVisible(true); } catch (e) { @@ -60,7 +60,7 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [indexPatterns, toastNotifications, getIndexPatternIdByTitle] + [dataViews, toastNotifications, getDataViewIdByTitle] ); const action: TransformListAction = useMemo( @@ -81,6 +81,6 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => config, closeFlyout, isFlyoutVisible, - indexPatternId, + dataViewId, }; }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx index b988b61c5b0b73..e6648c5214dac9 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx @@ -44,13 +44,13 @@ import { isManagedTransform } from '../../../../common/managed_transforms_utils' interface EditTransformFlyoutProps { closeFlyout: () => void; config: TransformConfigUnion; - indexPatternId?: string; + dataViewId?: string; } export const EditTransformFlyout: FC = ({ closeFlyout, config, - indexPatternId, + dataViewId, }) => { const api = useApi(); const toastNotifications = useToastNotifications(); @@ -110,10 +110,7 @@ export const EditTransformFlyout: FC = ({ /> ) : null} }> - + {errorMessage !== undefined && ( <> diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx index 22f31fc6139e80..fd0ca655f30564 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx @@ -29,12 +29,12 @@ import { KBN_FIELD_TYPES } from '../../../../../../../../../src/plugins/data/com interface EditTransformFlyoutFormProps { editTransformFlyout: UseEditTransformFlyoutReturnType; - indexPatternId?: string; + dataViewId?: string; } export const EditTransformFlyoutForm: FC = ({ editTransformFlyout: [state, dispatch], - indexPatternId, + dataViewId, }) => { const { formFields, formSections } = state; const [dateFieldNames, setDateFieldNames] = useState([]); @@ -43,16 +43,16 @@ export const EditTransformFlyoutForm: FC = ({ const isRetentionPolicyAvailable = dateFieldNames.length > 0; const appDeps = useAppDependencies(); - const indexPatternsClient = appDeps.data.indexPatterns; + const dataViewsClient = appDeps.data.dataViews; const api = useApi(); useEffect( function getDateFields() { let unmounted = false; - if (indexPatternId !== undefined) { - indexPatternsClient.get(indexPatternId).then((indexPattern) => { - if (indexPattern) { - const dateTimeFields = indexPattern.fields + if (dataViewId !== undefined) { + dataViewsClient.get(dataViewId).then((dataView) => { + if (dataView) { + const dateTimeFields = dataView.fields .filter((f) => f.type === KBN_FIELD_TYPES.DATE) .map((f) => f.name) .sort(); @@ -66,7 +66,7 @@ export const EditTransformFlyoutForm: FC = ({ }; } }, - [indexPatternId, indexPatternsClient] + [dataViewId, dataViewsClient] ); useEffect(function fetchPipelinesOnMount() { @@ -153,7 +153,7 @@ export const EditTransformFlyoutForm: FC = ({ { // If data view or date fields info not available // gracefully defaults to text input - indexPatternId ? ( + dataViewId ? ( = ({ transf const pivotQuery = useMemo(() => getPivotQuery(searchQuery), [searchQuery]); - const indexPatternTitle = Array.isArray(transformConfig.source.index) + const dataViewTitle = Array.isArray(transformConfig.source.index) ? transformConfig.source.index.join(',') : transformConfig.source.index; const pivotPreviewProps = usePivotData( - indexPatternTitle, + dataViewTitle, pivotQuery, validationStatus, previewRequest, diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx index 5d480003c7600f..986adb89bd41e7 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx @@ -52,7 +52,7 @@ export const useActions = ({ )} {deleteAction.isModalVisible && } diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx index 066a72c8079568..a5c536990353a7 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx @@ -192,7 +192,7 @@ export const TransformManagement: FC = () => { state: TRANSFORM_STATE.FAILED, })), deleteDestIndex: false, - deleteDestIndexPattern: false, + deleteDestDataView: false, forceDelete: true, } ); diff --git a/x-pack/plugins/transform/public/app/services/es_index_service.ts b/x-pack/plugins/transform/public/app/services/es_index_service.ts index c8d3f625a9281c..88b54a7487f929 100644 --- a/x-pack/plugins/transform/public/app/services/es_index_service.ts +++ b/x-pack/plugins/transform/public/app/services/es_index_service.ts @@ -7,7 +7,7 @@ import { HttpSetup, SavedObjectsClientContract } from 'kibana/public'; import { API_BASE_PATH } from '../../../common/constants'; -import { IIndexPattern } from '../../../../../../src/plugins/data/common'; +import { DataView } from '../../../../../../src/plugins/data_views/public'; export class IndexService { async canDeleteIndex(http: HttpSetup) { @@ -18,8 +18,8 @@ export class IndexService { return privilege.hasAllPrivileges; } - async indexPatternExists(savedObjectsClient: SavedObjectsClientContract, indexName: string) { - const response = await savedObjectsClient.find({ + async dataViewExists(savedObjectsClient: SavedObjectsClientContract, indexName: string) { + const response = await savedObjectsClient.find({ type: 'index-pattern', perPage: 1, search: `"${indexName}"`, diff --git a/x-pack/plugins/transform/server/routes/api/field_histograms.ts b/x-pack/plugins/transform/server/routes/api/field_histograms.ts index bfe2f470785690..5f464949a4fc8b 100644 --- a/x-pack/plugins/transform/server/routes/api/field_histograms.ts +++ b/x-pack/plugins/transform/server/routes/api/field_histograms.ts @@ -5,10 +5,7 @@ * 2.0. */ -import { - indexPatternTitleSchema, - IndexPatternTitleSchema, -} from '../../../common/api_schemas/common'; +import { dataViewTitleSchema, DataViewTitleSchema } from '../../../common/api_schemas/common'; import { fieldHistogramsRequestSchema, FieldHistogramsRequestSchema, @@ -21,23 +18,23 @@ import { addBasePath } from '../index'; import { wrapError, wrapEsError } from './error_utils'; export function registerFieldHistogramsRoutes({ router, license }: RouteDependencies) { - router.post( + router.post( { - path: addBasePath('field_histograms/{indexPatternTitle}'), + path: addBasePath('field_histograms/{dataViewTitle}'), validate: { - params: indexPatternTitleSchema, + params: dataViewTitleSchema, body: fieldHistogramsRequestSchema, }, }, - license.guardApiRoute( + license.guardApiRoute( async (ctx, req, res) => { - const { indexPatternTitle } = req.params; + const { dataViewTitle } = req.params; const { query, fields, runtimeMappings, samplerShardSize } = req.body; try { const resp = await getHistogramsForFields( ctx.core.elasticsearch.client, - indexPatternTitle, + dataViewTitle, query, fields, samplerShardSize, diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index 2f82b9a70389b9..78b51fca58547f 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -61,7 +61,7 @@ import { addBasePath } from '../index'; import { isRequestTimeout, fillResultsWithTimeouts, wrapError, wrapEsError } from './error_utils'; import { registerTransformsAuditMessagesRoutes } from './transforms_audit_messages'; import { registerTransformNodesRoutes } from './transforms_nodes'; -import { IIndexPattern } from '../../../../../../src/plugins/data/common'; +import { DataView } from '../../../../../../src/plugins/data_views/common'; import { isLatestTransform } from '../../../common/types/transform'; import { isKeywordDuplicate } from '../../../common/utils/field_utils'; import { transformHealthServiceProvider } from '../../lib/alerting/transform_health_rule_type/transform_health_service'; @@ -449,11 +449,8 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { registerTransformNodesRoutes(routeDependencies); } -async function getIndexPatternId( - indexName: string, - savedObjectsClient: SavedObjectsClientContract -) { - const response = await savedObjectsClient.find({ +async function getDataViewId(indexName: string, savedObjectsClient: SavedObjectsClientContract) { + const response = await savedObjectsClient.find({ type: 'index-pattern', perPage: 1, search: `"${indexName}"`, @@ -464,11 +461,11 @@ async function getIndexPatternId( return ip?.id; } -async function deleteDestIndexPatternById( - indexPatternId: string, +async function deleteDestDataViewById( + dataViewId: string, savedObjectsClient: SavedObjectsClientContract ) { - return await savedObjectsClient.delete('index-pattern', indexPatternId); + return await savedObjectsClient.delete('index-pattern', dataViewId); } async function deleteTransforms( @@ -480,7 +477,7 @@ async function deleteTransforms( // Cast possible undefineds as booleans const deleteDestIndex = !!reqBody.deleteDestIndex; - const deleteDestIndexPattern = !!reqBody.deleteDestIndexPattern; + const deleteDestDataView = !!reqBody.deleteDestDataView; const shouldForceDelete = !!reqBody.forceDelete; const results: DeleteTransformsResponseSchema = {}; @@ -490,7 +487,7 @@ async function deleteTransforms( const transformDeleted: ResponseStatus = { success: false }; const destIndexDeleted: ResponseStatus = { success: false }; - const destIndexPatternDeleted: ResponseStatus = { + const destDataViewDeleted: ResponseStatus = { success: false, }; const transformId = transformInfo.id; @@ -516,7 +513,7 @@ async function deleteTransforms( results[transformId] = { transformDeleted, destIndexDeleted, - destIndexPatternDeleted, + destDataViewDeleted, destinationIndex, }; // No need to perform further delete attempts @@ -538,18 +535,15 @@ async function deleteTransforms( } // Delete the data view if there's a data view that matches the name of dest index - if (destinationIndex && deleteDestIndexPattern) { + if (destinationIndex && deleteDestDataView) { try { - const indexPatternId = await getIndexPatternId( - destinationIndex, - ctx.core.savedObjects.client - ); - if (indexPatternId) { - await deleteDestIndexPatternById(indexPatternId, ctx.core.savedObjects.client); - destIndexPatternDeleted.success = true; + const dataViewId = await getDataViewId(destinationIndex, ctx.core.savedObjects.client); + if (dataViewId) { + await deleteDestDataViewById(dataViewId, ctx.core.savedObjects.client); + destDataViewDeleted.success = true; } - } catch (deleteDestIndexPatternError) { - destIndexPatternDeleted.error = deleteDestIndexPatternError.meta.body.error; + } catch (deleteDestDataViewError) { + destDataViewDeleted.error = deleteDestDataViewError.meta.body.error; } } @@ -569,7 +563,7 @@ async function deleteTransforms( results[transformId] = { transformDeleted, destIndexDeleted, - destIndexPatternDeleted, + destDataViewDeleted, destinationIndex, }; } catch (e) { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx index e6b5fdbdb18832..dddfc357f2eaa8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx @@ -31,6 +31,11 @@ import { ruleTypeRegistryMock } from '../../../rule_type_registry.mock'; jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/lib/config_api', () => ({ + triggersActionsUiConfig: jest + .fn() + .mockResolvedValue({ minimumScheduleInterval: { value: '1m', enforce: false } }), +})); jest.mock('react-router-dom', () => ({ useHistory: () => ({ push: jest.fn(), @@ -142,6 +147,24 @@ describe('rule_details', () => { ).toBeTruthy(); }); + it('displays a toast message when interval is less than configured minimum', async () => { + const rule = mockRule({ + schedule: { + interval: '1s', + }, + }); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(useKibanaMock().services.notifications.toasts.addInfo).toHaveBeenCalled(); + }); + describe('actions', () => { it('renders an rule action', () => { const rule = mockRule({ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx index de948c2fd21de7..736178cc5ab3e8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx @@ -27,11 +27,18 @@ import { EuiPageTemplate, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { AlertExecutionStatusErrorReasons } from '../../../../../../alerting/common'; +import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; +import { AlertExecutionStatusErrorReasons, parseDuration } from '../../../../../../alerting/common'; import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capabilities'; import { getAlertingSectionBreadcrumb, getRuleDetailsBreadcrumb } from '../../../lib/breadcrumb'; import { getCurrentDocTitle } from '../../../lib/doc_title'; -import { Rule, RuleType, ActionType, ActionConnector } from '../../../../types'; +import { + Rule, + RuleType, + ActionType, + ActionConnector, + TriggersActionsUiConfig, +} from '../../../../types'; import { ComponentOpts as BulkOperationsComponentOpts, withBulkRuleOperations, @@ -47,6 +54,7 @@ import { import { useKibana } from '../../../../common/lib/kibana'; import { ruleReducer } from '../../rule_form/rule_reducer'; import { loadAllActions as loadConnectors } from '../../../lib/action_connector_api'; +import { triggersActionsUiConfig } from '../../../../common/lib/config_api'; export type RuleDetailsProps = { rule: Rule; @@ -75,6 +83,7 @@ export const RuleDetails: React.FunctionComponent = ({ setBreadcrumbs, chrome, http, + notifications: { toasts }, } = useKibana().services; const [{}, dispatch] = useReducer(ruleReducer, { rule }); const setInitialRule = (value: Rule) => { @@ -84,6 +93,14 @@ export const RuleDetails: React.FunctionComponent = ({ const [hasActionsWithBrokenConnector, setHasActionsWithBrokenConnector] = useState(false); + const [config, setConfig] = useState({}); + + useEffect(() => { + (async () => { + setConfig(await triggersActionsUiConfig({ http })); + })(); + }, [http]); + // Set breadcrumb and page title useEffect(() => { setBreadcrumbs([ @@ -141,6 +158,53 @@ export const RuleDetails: React.FunctionComponent = ({ const [dismissRuleErrors, setDismissRuleErrors] = useState(false); const [dismissRuleWarning, setDismissRuleWarning] = useState(false); + // Check whether interval is below configured minium + useEffect(() => { + if (rule.schedule.interval && config.minimumScheduleInterval) { + if ( + parseDuration(rule.schedule.interval) < parseDuration(config.minimumScheduleInterval.value) + ) { + const configurationToast = toasts.addInfo({ + 'data-test-subj': 'intervalConfigToast', + title: i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.scheduleIntervalToastTitle', + { + defaultMessage: 'Configuration settings', + } + ), + text: toMountPoint( + <> +

+ +

+ {hasEditButton && ( + + + { + toasts.remove(configurationToast); + setEditFlyoutVisibility(true); + }} + > + + + + + )} + + ), + }); + } + } + }, [rule.schedule.interval, config.minimumScheduleInterval, toasts, hasEditButton]); + const setRule = async () => { history.push(routeToRuleDetails.replace(`:ruleId`, rule.id)); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx index 1289b81eb81692..032d69fa7ccc4a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx @@ -19,6 +19,11 @@ import { spacesPluginMock } from '../../../../../../spaces/public/mocks'; import { useKibana } from '../../../../common/lib/kibana'; jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/lib/config_api', () => ({ + triggersActionsUiConfig: jest + .fn() + .mockResolvedValue({ minimumScheduleInterval: { value: '1m', enforce: false } }), +})); describe('rule_details_route', () => { beforeEach(() => { jest.clearAllMocks(); 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 36c102c6f54bbb..021ea3c2d00554 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 @@ -169,7 +169,7 @@ describe('rules_list component with items', () => { tags: ['tag1'], enabled: true, ruleTypeId: 'test_rule_type', - schedule: { interval: '5d' }, + schedule: { interval: '1s' }, actions: [], params: { name: 'test rule type name' }, scheduledTaskId: null, @@ -476,6 +476,19 @@ describe('rules_list component with items', () => { wrapper.find('EuiTableRowCell[data-test-subj="rulesTableCell-interval"]').length ).toEqual(mockedRulesData.length); + // Schedule interval tooltip + wrapper.find('[data-test-subj="ruleInterval-config-tooltip-0"]').first().simulate('mouseOver'); + + // Run the timers so the EuiTooltip will be visible + jest.runAllTimers(); + + wrapper.update(); + expect(wrapper.find('.euiToolTipPopover').text()).toBe( + 'Below configured minimum intervalRule interval of 1 second is below the minimum configured interval of 1 minute. This may impact alerting performance.' + ); + + wrapper.find('[data-test-subj="ruleInterval-config-tooltip-0"]').first().simulate('mouseOut'); + // Duration column expect( wrapper.find('EuiTableRowCell[data-test-subj="rulesTableCell-duration"]').length 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 c55f1303120f09..3cb1ac7b93dcaf 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 @@ -48,6 +48,7 @@ import { RuleTypeIndex, Pagination, Percentiles, + TriggersActionsUiConfig, } from '../../../../types'; import { RuleAdd, RuleEdit } from '../../rule_form'; import { BulkOperationPopover } from '../../common/components/bulk_operation_popover'; @@ -75,6 +76,7 @@ import { ALERTS_FEATURE_ID, AlertExecutionStatusErrorReasons, formatDuration, + parseDuration, MONITORING_HISTORY_LIMIT, } from '../../../../../../alerting/common'; import { rulesStatusesTranslationsMapping, ALERT_STATUS_LICENSE_ERROR } from '../translations'; @@ -89,6 +91,7 @@ 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'; const ENTER_KEY = 13; @@ -135,6 +138,7 @@ export const RulesList: React.FunctionComponent = () => { const [initialLoad, setInitialLoad] = useState(true); const [noData, setNoData] = useState(true); + const [config, setConfig] = useState({}); const [actionTypes, setActionTypes] = useState([]); const [selectedIds, setSelectedIds] = useState([]); const [isPerformingAction, setIsPerformingAction] = useState(false); @@ -150,6 +154,12 @@ export const RulesList: React.FunctionComponent = () => { const [currentRuleToEdit, setCurrentRuleToEdit] = useState(null); const [tagPopoverOpenIndex, setTagPopoverOpenIndex] = useState(-1); + useEffect(() => { + (async () => { + setConfig(await triggersActionsUiConfig({ http })); + })(); + }, [http]); + const [percentileOptions, setPercentileOptions] = useState(initialPercentileOptions); @@ -609,7 +619,59 @@ export const RulesList: React.FunctionComponent = () => { sortable: false, truncateText: false, 'data-test-subj': 'rulesTableCell-interval', - render: (interval: string) => formatDuration(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', @@ -850,11 +912,12 @@ export const RulesList: React.FunctionComponent = () => { setIsPerformingAction(true)} onActionPerformed={() => { loadRulesData(); @@ -1037,7 +1100,12 @@ export const RulesList: React.FunctionComponent = () => { items={ ruleTypesState.isInitialized === false ? [] - : convertRulesToTableItems(rulesState.data, ruleTypesState.data, canExecuteActions) + : convertRulesToTableItems({ + rules: rulesState.data, + ruleTypeIndex: ruleTypesState.data, + canExecuteActions, + config, + }) } itemId="id" columns={getRulesTableColumns()} @@ -1202,19 +1270,29 @@ function filterRulesById(rules: Rule[], ids: string[]): Rule[] { return rules.filter((rule) => ids.includes(rule.id)); } -function convertRulesToTableItems( - rules: Rule[], - ruleTypeIndex: RuleTypeIndex, - canExecuteActions: boolean -) { - return rules.map((rule, index: number) => ({ - ...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, - })); +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/common/lib/config_api.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/config_api.ts index aa0321ef8346b3..fe9f921fc7f887 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/lib/config_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/config_api.ts @@ -7,7 +7,12 @@ import { HttpSetup } from 'kibana/public'; import { BASE_TRIGGERS_ACTIONS_UI_API_PATH } from '../../../common'; +import { TriggersActionsUiConfig } from '../../types'; -export async function triggersActionsUiConfig({ http }: { http: HttpSetup }): Promise { +export async function triggersActionsUiConfig({ + http, +}: { + http: HttpSetup; +}): Promise { return await http.get(`${BASE_TRIGGERS_ACTIONS_UI_API_PATH}/_config`); } diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 0835ef2b7453e7..7a1efaed33abf9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -251,6 +251,7 @@ export interface RuleTableItem extends Rule { actionsCount: number; isEditable: boolean; enabledInLicense: boolean; + showIntervalWarning?: boolean; } export interface RuleTypeParamsExpressionProps< diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/privileges.test.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/privileges.test.tsx new file mode 100644 index 00000000000000..3ae0c013d694f0 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/privileges.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; + +import { AppDependencies } from '../../../public/types'; +import { setupEnvironment, kibanaVersion, getAppContextMock } from '../helpers'; +import { AppTestBed, setupAppPage } from './app.helpers'; + +describe('Privileges', () => { + let testBed: AppTestBed; + let httpSetup: ReturnType['httpSetup']; + beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpSetup = mockEnvironment.httpSetup; + }); + + describe('when user is not a Kibana global admin', () => { + beforeEach(async () => { + const appContextMock = getAppContextMock(kibanaVersion) as unknown as AppDependencies; + const servicesMock = { + ...appContextMock.services, + core: { + ...appContextMock.services.core, + application: { + capabilities: { + spaces: { + manage: false, + }, + }, + }, + }, + }; + + await act(async () => { + testBed = await setupAppPage(httpSetup, { services: servicesMock }); + }); + + testBed.component.update(); + }); + + test('renders not authorized message', () => { + const { exists } = testBed; + expect(exists('overview')).toBe(false); + expect(exists('missingKibanaPrivilegesMessage')).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/app_context.mock.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/app_context.mock.ts index 3ddfeb3b057ea2..3ceadecb208df9 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/app_context.mock.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/app_context.mock.ts @@ -88,7 +88,14 @@ export const getAppContextMock = (kibanaVersion: SemVer) => ({ notifications: notificationServiceMock.createStartContract(), docLinks: docLinksServiceMock.createStartContract(), history: scopedHistoryMock.create(), - application: applicationServiceMock.createStartContract(), + application: { + ...applicationServiceMock.createStartContract(), + capabilities: { + spaces: { + manage: true, + }, + }, + }, }, }, plugins: { diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts index f70bfd00e9c070..4ae44f0027069f 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts @@ -7,3 +7,4 @@ export { setupEnvironment, WithAppDependencies, kibanaVersion } from './setup_environment'; export { advanceTime } from './time_manipulation'; +export { getAppContextMock } from './app_context.mock'; diff --git a/x-pack/plugins/upgrade_assistant/public/application/app.tsx b/x-pack/plugins/upgrade_assistant/public/application/app.tsx index 4b2b85638e8bea..00c910fd648f7f 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/app.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/app.tsx @@ -19,6 +19,7 @@ import { AuthorizationProvider, RedirectAppLinks, KibanaThemeProvider, + NotAuthorizedSection, } from '../shared_imports'; import { AppDependencies } from '../types'; import { AppContextProvider, useAppContext } from './app_context'; @@ -35,18 +36,46 @@ const { GlobalFlyoutProvider } = GlobalFlyout; const AppHandlingClusterUpgradeState: React.FunctionComponent = () => { const { isReadOnlyMode, - services: { api }, + services: { api, core }, } = useAppContext(); - const [clusterUpgradeState, setClusterUpradeState] = + const missingManageSpacesPrivilege = core.application.capabilities.spaces.manage !== true; + + const [clusterUpgradeState, setClusterUpgradeState] = useState('isPreparingForUpgrade'); useEffect(() => { api.onClusterUpgradeStateChange((newClusterUpgradeState: ClusterUpgradeState) => { - setClusterUpradeState(newClusterUpgradeState); + setClusterUpgradeState(newClusterUpgradeState); }); }, [api]); + if (missingManageSpacesPrivilege) { + return ( + + + } + message={ + + } + /> + + ); + } + // Read-only mode will be enabled up until the last minor before the next major release if (isReadOnlyMode) { return ; diff --git a/x-pack/plugins/upgrade_assistant/public/shared_imports.ts b/x-pack/plugins/upgrade_assistant/public/shared_imports.ts index 988bb1363398ba..7d23f88a95c441 100644 --- a/x-pack/plugins/upgrade_assistant/public/shared_imports.ts +++ b/x-pack/plugins/upgrade_assistant/public/shared_imports.ts @@ -22,6 +22,7 @@ export { WithPrivileges, AuthorizationProvider, AuthorizationContext, + NotAuthorizedSection, } from '../../../../src/plugins/es_ui_shared/public/'; export { Storage } from '../../../../src/plugins/kibana_utils/public'; diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts index bf7c5336a8b0f2..278ce45cdf5935 100644 --- a/x-pack/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/plugins/uptime/public/apps/plugin.ts @@ -215,7 +215,14 @@ export class UptimePlugin const [coreStart, corePlugins] = await core.getStartServices(); const { renderApp } = await import('./render_app'); - return renderApp(coreStart, plugins, corePlugins, params, config); + return renderApp( + coreStart, + plugins, + corePlugins, + params, + config, + this.initContext.env.mode.dev + ); }, }); } diff --git a/x-pack/plugins/uptime/public/apps/render_app.tsx b/x-pack/plugins/uptime/public/apps/render_app.tsx index 23f8fc9a8e58ce..44e9651c25dd1e 100644 --- a/x-pack/plugins/uptime/public/apps/render_app.tsx +++ b/x-pack/plugins/uptime/public/apps/render_app.tsx @@ -25,7 +25,8 @@ export function renderApp( plugins: ClientPluginsSetup, startPlugins: ClientPluginsStart, appMountParameters: AppMountParameters, - config: UptimeUiConfig + config: UptimeUiConfig, + isDev: boolean ) { const { application: { capabilities }, @@ -45,6 +46,7 @@ export function renderApp( plugins.share.url.locators.create(uptimeOverviewNavigatorParams); const props: UptimeAppProps = { + isDev, plugins, canSave, core, diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index 5df0d1a00f9054..12519143d347a7 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -65,6 +65,7 @@ export interface UptimeAppProps { setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; appMountParameters: AppMountParameters; config: UptimeUiConfig; + isDev: boolean; } const Application = (props: UptimeAppProps) => { diff --git a/x-pack/plugins/uptime/public/components/common/header/inspector_header_link.tsx b/x-pack/plugins/uptime/public/components/common/header/inspector_header_link.tsx index 8f787512aaf9d3..c55ef9bdf781e8 100644 --- a/x-pack/plugins/uptime/public/components/common/header/inspector_header_link.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/inspector_header_link.tsx @@ -11,12 +11,15 @@ import React from 'react'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { enableInspectEsQueries, useInspectorContext } from '../../../../../observability/public'; import { ClientPluginsStart } from '../../../apps/plugin'; +import { useUptimeSettingsContext } from '../../../contexts/uptime_settings_context'; export function InspectorHeaderLink() { const { services: { inspector, uiSettings }, } = useKibana(); + const { isDev } = useUptimeSettingsContext(); + const { inspectorAdapters } = useInspectorContext(); const isInspectorEnabled = uiSettings?.get(enableInspectEsQueries); @@ -25,7 +28,7 @@ export function InspectorHeaderLink() { inspector.open(inspectorAdapters); }; - if (!isInspectorEnabled) { + if (!isInspectorEnabled && !isDev) { return null; } diff --git a/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx b/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx index 63f21a23e30d39..67058be9a9d65b 100644 --- a/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx +++ b/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx @@ -21,6 +21,7 @@ export interface UptimeSettingsContextValues { isLogsAvailable: boolean; config: UptimeUiConfig; commonlyUsedRanges?: CommonlyUsedRange[]; + isDev?: boolean; } const { BASE_PATH } = CONTEXT_DEFAULTS; @@ -39,6 +40,7 @@ const defaultContext: UptimeSettingsContextValues = { isInfraAvailable: true, isLogsAvailable: true, config: {}, + isDev: false, }; export const UptimeSettingsContext = createContext(defaultContext); @@ -50,12 +52,14 @@ export const UptimeSettingsContextProvider: React.FC = ({ childr isLogsAvailable, commonlyUsedRanges, config, + isDev, } = props; const { dateRangeStart, dateRangeEnd } = useGetUrlParams(); const value = useMemo(() => { return { + isDev, basePath, isApmAvailable, isInfraAvailable, @@ -66,6 +70,7 @@ export const UptimeSettingsContextProvider: React.FC = ({ childr dateRangeEnd: dateRangeEnd ?? DATE_RANGE_END, }; }, [ + isDev, basePath, isApmAvailable, isInfraAvailable, diff --git a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts index d9dadc81397cec..fb5c0cd1e69a19 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts @@ -62,6 +62,7 @@ export interface UptimeServerSetup { telemetry: TelemetryEventsSender; uptimeEsClient: UptimeESClient; basePath: IBasePath; + isDev?: boolean; } export interface UptimeCorePluginsSetup { diff --git a/x-pack/plugins/uptime/server/lib/lib.ts b/x-pack/plugins/uptime/server/lib/lib.ts index f61497816e2d9b..220ac5a3797a41 100644 --- a/x-pack/plugins/uptime/server/lib/lib.ts +++ b/x-pack/plugins/uptime/server/lib/lib.ts @@ -49,7 +49,9 @@ export function createUptimeESClient({ esClient, request, savedObjectsClient, + isInspectorEnabled, }: { + isInspectorEnabled?: boolean; esClient: ElasticsearchClient; request?: KibanaRequest; savedObjectsClient: SavedObjectsClientContract; @@ -94,7 +96,7 @@ export function createUptimeESClient({ startTime: startTimeNow, }) ); - if (request) { + if (request && isInspectorEnabled) { debugESCall({ startTime, request, esError, operationName: 'search', params: esParams }); } } @@ -123,7 +125,7 @@ export function createUptimeESClient({ } const inspectableEsQueries = inspectableEsQueriesMap.get(request!); - if (inspectableEsQueries && request) { + if (inspectableEsQueries && request && isInspectorEnabled) { debugESCall({ startTime, request, esError, operationName: 'count', params: esParams }); } diff --git a/x-pack/plugins/uptime/server/plugin.ts b/x-pack/plugins/uptime/server/plugin.ts index 2f329aa83a5c4c..61272651e1ce24 100644 --- a/x-pack/plugins/uptime/server/plugin.ts +++ b/x-pack/plugins/uptime/server/plugin.ts @@ -81,6 +81,7 @@ export class Plugin implements PluginType { basePath: core.http.basePath, logger: this.logger, telemetry: this.telemetryEventsSender, + isDev: this.initContext.env.mode.dev, } as UptimeServerSetup; if (this.isServiceEnabled && this.server.config.service) { diff --git a/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts b/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts index cf03e7d58fd14e..60ba60087382ac 100644 --- a/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts +++ b/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts @@ -41,12 +41,13 @@ export const uptimeRouteWrapper: UMKibanaRouteWrapper = (uptimeRoute, server) => const uptimeEsClient = createUptimeESClient({ request, savedObjectsClient, + isInspectorEnabled, esClient: esClient.asCurrentUser, }); server.uptimeEsClient = uptimeEsClient; - if (isInspectorEnabled) { + if (isInspectorEnabled || server.isDev) { inspectableEsQueriesMap.set(request, []); } @@ -66,7 +67,7 @@ export const uptimeRouteWrapper: UMKibanaRouteWrapper = (uptimeRoute, server) => return response.ok({ body: { ...res, - ...(isInspectorEnabled && uptimeRoute.path !== API_URLS.DYNAMIC_SETTINGS + ...((isInspectorEnabled || server.isDev) && uptimeRoute.path !== API_URLS.DYNAMIC_SETTINGS ? { _inspect: inspectableEsQueriesMap.get(request) } : {}), }, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/aggregate.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/aggregate.ts index b731102ad672b8..588e7132f268c0 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/aggregate.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/aggregate.ts @@ -23,7 +23,6 @@ export default function createAggregateTests({ getService }: FtrProviderContext) const response = await supertest.get( `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_aggregate` ); - expect(response.status).to.eql(200); expect(response.body).to.eql({ rule_enabled_status: { @@ -42,6 +41,9 @@ export default function createAggregateTests({ getService }: FtrProviderContext) muted: 0, unmuted: 0, }, + rule_snoozed_status: { + snoozed: 0, + }, }); }); @@ -96,12 +98,11 @@ export default function createAggregateTests({ getService }: FtrProviderContext) // calls are successful, the call to aggregate may return stale totals if called // too early. await delay(1000); - const reponse = await supertest.get( + const response = await supertest.get( `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_aggregate` ); - - expect(reponse.status).to.eql(200); - expect(reponse.body).to.eql({ + expect(response.status).to.eql(200); + expect(response.body).to.eql({ rule_enabled_status: { disabled: 0, enabled: 7, @@ -118,6 +119,9 @@ export default function createAggregateTests({ getService }: FtrProviderContext) muted: 0, unmuted: 7, }, + rule_snoozed_status: { + snoozed: 0, + }, }); }); @@ -195,6 +199,9 @@ export default function createAggregateTests({ getService }: FtrProviderContext) muted: 0, unmuted: 7, }, + ruleSnoozedStatus: { + snoozed: 0, + }, }); }); }); diff --git a/x-pack/test/api_integration/apis/transform/delete_transforms.ts b/x-pack/test/api_integration/apis/transform/delete_transforms.ts index b823c46509a636..7f14081e5c5742 100644 --- a/x-pack/test/api_integration/apis/transform/delete_transforms.ts +++ b/x-pack/test/api_integration/apis/transform/delete_transforms.ts @@ -66,7 +66,7 @@ export default ({ getService }: FtrProviderContext) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(false); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + expect(body[transformId].destDataViewDeleted.success).to.eql(false); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesToExist(destinationIndex); }); @@ -148,7 +148,7 @@ export default ({ getService }: FtrProviderContext) => { async ({ id: transformId }: { id: string }, idx: number) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(false); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + expect(body[transformId].destDataViewDeleted.success).to.eql(false); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesToExist(destinationIndices[idx]); } @@ -178,7 +178,7 @@ export default ({ getService }: FtrProviderContext) => { async ({ id: transformId }: { id: string }, idx: number) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(false); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + expect(body[transformId].destDataViewDeleted.success).to.eql(false); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesToExist(destinationIndices[idx]); } @@ -219,13 +219,13 @@ export default ({ getService }: FtrProviderContext) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(true); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + expect(body[transformId].destDataViewDeleted.success).to.eql(false); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesNotToExist(destinationIndex); }); }); - describe('with deleteDestIndexPattern setting', function () { + describe('with deleteDestDataView setting', function () { const transformId = 'test3'; const destinationIndex = generateDestIndex(transformId); @@ -244,7 +244,7 @@ export default ({ getService }: FtrProviderContext) => { const reqBody: DeleteTransformsRequestSchema = { transformsInfo: [{ id: transformId, state: TRANSFORM_STATE.STOPPED }], deleteDestIndex: false, - deleteDestIndexPattern: true, + deleteDestDataView: true, }; const { body, status } = await supertest .post(`/api/transform/delete_transforms`) @@ -258,14 +258,14 @@ export default ({ getService }: FtrProviderContext) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(false); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(true); + expect(body[transformId].destDataViewDeleted.success).to.eql(true); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesToExist(destinationIndex); await transform.testResources.assertIndexPatternNotExist(destinationIndex); }); }); - describe('with deleteDestIndex & deleteDestIndexPattern setting', function () { + describe('with deleteDestIndex & deleteDestDataView setting', function () { const transformId = 'test4'; const destinationIndex = generateDestIndex(transformId); @@ -284,7 +284,7 @@ export default ({ getService }: FtrProviderContext) => { const reqBody: DeleteTransformsRequestSchema = { transformsInfo: [{ id: transformId, state: TRANSFORM_STATE.STOPPED }], deleteDestIndex: true, - deleteDestIndexPattern: true, + deleteDestDataView: true, }; const { body, status } = await supertest .post(`/api/transform/delete_transforms`) @@ -298,7 +298,7 @@ export default ({ getService }: FtrProviderContext) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(true); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(true); + expect(body[transformId].destDataViewDeleted.success).to.eql(true); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesNotToExist(destinationIndex); await transform.testResources.assertIndexPatternNotExist(destinationIndex); diff --git a/x-pack/test/examples/search_examples/index.ts b/x-pack/test/examples/search_examples/index.ts index ac9e385d3d391d..18b2acbd565643 100644 --- a/x-pack/test/examples/search_examples/index.ts +++ b/x-pack/test/examples/search_examples/index.ts @@ -33,5 +33,6 @@ export default function ({ getService, loadTestFile }: PluginFunctionalProviderC loadTestFile(require.resolve('./search_example')); loadTestFile(require.resolve('./search_sessions_cache')); loadTestFile(require.resolve('./partial_results_example')); + loadTestFile(require.resolve('./sql_search_example')); }); } diff --git a/x-pack/test/examples/search_examples/sql_search_example.ts b/x-pack/test/examples/search_examples/sql_search_example.ts new file mode 100644 index 00000000000000..a51ea21ea36bde --- /dev/null +++ b/x-pack/test/examples/search_examples/sql_search_example.ts @@ -0,0 +1,42 @@ +/* + * 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 '../../functional/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common']); + const toasts = getService('toasts'); + + describe('SQL search example', () => { + const appId = 'searchExamples'; + + before(async function () { + await PageObjects.common.navigateToApp(appId, { insertTimestamp: false }); + await testSubjects.click('/sql-search'); + }); + + it('should search', async () => { + const sqlQuery = `SELECT index, bytes FROM "logstash-*" ORDER BY "@timestamp" DESC`; + await (await testSubjects.find('sqlQueryInput')).type(sqlQuery); + + await testSubjects.click(`querySubmitButton`); + + await testSubjects.stringExistsInCodeBlockOrFail( + 'requestCodeBlock', + JSON.stringify(sqlQuery) + ); + await testSubjects.stringExistsInCodeBlockOrFail( + 'responseCodeBlock', + `"logstash-2015.09.22"` + ); + expect(await toasts.getToastCount()).to.be(0); + }); + }); +} diff --git a/x-pack/test/functional/apps/lens/show_underlying_data.ts b/x-pack/test/functional/apps/lens/show_underlying_data.ts index 2444e8714e0145..cbe6820ccef4d8 100644 --- a/x-pack/test/functional/apps/lens/show_underlying_data.ts +++ b/x-pack/test/functional/apps/lens/show_underlying_data.ts @@ -16,8 +16,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const find = getService('find'); const browser = getService('browser'); - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/128396 - describe.skip('show underlying data', () => { + describe('show underlying data', () => { it('should show the open button for a compatible saved visualization', async () => { await PageObjects.visualize.gotoVisualizationLandingPage(); await listingTable.searchForItemWithName('lnsXYvis'); @@ -83,7 +82,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('indexPattern-filter-by-input > switchQueryLanguageButton'); // apparently setting a filter requires some time before and after typing to work properly await PageObjects.common.sleep(1000); - await PageObjects.lens.setFilterBy('memory'); + await PageObjects.lens.setFilterBy('memory:*'); await PageObjects.common.sleep(1000); await PageObjects.lens.closeDimensionEditor(); @@ -98,7 +97,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.existOrFail('discoverChart'); // check the query expect(await queryBar.getQueryString()).be.eql( - '( ( ip: "220.120.146.16" ) OR ( ip: "152.56.56.106" ) OR ( ip: "111.55.80.52" ) )' + '( ( ip: "86.252.46.140" ) OR ( ip: "155.34.86.215" ) OR ( ip: "133.198.170.210" ) )' ); const filterPills = await filterBar.getFiltersLabel(); expect(filterPills.length).to.be(1); diff --git a/x-pack/test/functional/apps/transform/cloning.ts b/x-pack/test/functional/apps/transform/cloning.ts index 382f1b5ba75ab1..3cbb0892bd4ecf 100644 --- a/x-pack/test/functional/apps/transform/cloning.ts +++ b/x-pack/test/functional/apps/transform/cloning.ts @@ -333,8 +333,8 @@ export default function ({ getService }: FtrProviderContext) { await transform.wizard.setDestinationIndex(testData.destinationIndex); await transform.testExecution.logTestStep('should display the create data view switch'); - await transform.wizard.assertCreateIndexPatternSwitchExists(); - await transform.wizard.assertCreateIndexPatternSwitchCheckState(true); + await transform.wizard.assertCreateDataViewSwitchExists(); + await transform.wizard.assertCreateDataViewSwitchCheckState(true); await transform.testExecution.logTestStep('should display the continuous mode switch'); await transform.wizard.assertContinuousModeSwitchExists(); diff --git a/x-pack/test/functional/apps/transform/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation_index_pattern.ts index 37647b48d31807..dc8190c877d613 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -589,8 +589,8 @@ export default function ({ getService }: FtrProviderContext) { await transform.wizard.setDestinationIndex(testData.destinationIndex); await transform.testExecution.logTestStep('displays the create data view switch'); - await transform.wizard.assertCreateIndexPatternSwitchExists(); - await transform.wizard.assertCreateIndexPatternSwitchCheckState(true); + await transform.wizard.assertCreateDataViewSwitchExists(); + await transform.wizard.assertCreateDataViewSwitchCheckState(true); await transform.testExecution.logTestStep('displays the continuous mode switch'); await transform.wizard.assertContinuousModeSwitchExists(); diff --git a/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts b/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts index 72467b3060ab14..2c7889572ce74c 100644 --- a/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts +++ b/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts @@ -401,8 +401,8 @@ export default function ({ getService }: FtrProviderContext) { await transform.wizard.setDestinationIndex(testData.destinationIndex); await transform.testExecution.logTestStep('displays the create data view switch'); - await transform.wizard.assertCreateIndexPatternSwitchExists(); - await transform.wizard.assertCreateIndexPatternSwitchCheckState(true); + await transform.wizard.assertCreateDataViewSwitchExists(); + await transform.wizard.assertCreateDataViewSwitchCheckState(true); await transform.testExecution.logTestStep('displays the continuous mode switch'); await transform.wizard.assertContinuousModeSwitchExists(); diff --git a/x-pack/test/functional/apps/transform/creation_saved_search.ts b/x-pack/test/functional/apps/transform/creation_saved_search.ts index acdc0c64ddda20..b33027da24341a 100644 --- a/x-pack/test/functional/apps/transform/creation_saved_search.ts +++ b/x-pack/test/functional/apps/transform/creation_saved_search.ts @@ -232,8 +232,8 @@ export default function ({ getService }: FtrProviderContext) { await transform.wizard.setDestinationIndex(testData.destinationIndex); await transform.testExecution.logTestStep('displays the create data view switch'); - await transform.wizard.assertCreateIndexPatternSwitchExists(); - await transform.wizard.assertCreateIndexPatternSwitchCheckState(true); + await transform.wizard.assertCreateDataViewSwitchExists(); + await transform.wizard.assertCreateDataViewSwitchCheckState(true); await transform.testExecution.logTestStep('displays the continuous mode switch'); await transform.wizard.assertContinuousModeSwitchExists(); diff --git a/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts b/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts index a7368dfbedf077..a5b28a6bf6c06e 100644 --- a/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts +++ b/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts @@ -56,11 +56,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('[SkipCloud] global dashboard read with global_upgrade_assistant_role', function () { this.tags('skipCloud'); - it('should render the "Stack" section with Upgrde Assistant', async function () { + it('should render the "Stack" section with Upgrade Assistant', async function () { await PageObjects.common.navigateToApp('management'); const sections = await managementMenu.getSections(); - expect(sections).to.have.length(3); - expect(sections[2]).to.eql({ + expect(sections).to.have.length(5); + expect(sections[4]).to.eql({ sectionId: 'stack', sectionLinks: ['license_management', 'upgrade_assistant'], }); diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index b7774b463d058e..c32d6f7304aeaa 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -464,9 +464,7 @@ export default async function ({ readConfigFile }) { }, kibana: [ { - feature: { - discover: ['read'], - }, + base: ['all'], spaces: ['*'], }, ], diff --git a/x-pack/test/functional/services/transform/wizard.ts b/x-pack/test/functional/services/transform/wizard.ts index 714ae52a6641b9..2b95570a9fb1a5 100644 --- a/x-pack/test/functional/services/transform/wizard.ts +++ b/x-pack/test/functional/services/transform/wizard.ts @@ -670,13 +670,13 @@ export function TransformWizardProvider({ getService, getPageObjects }: FtrProvi await this.assertDestinationIndexValue(destinationIndex); }, - async assertCreateIndexPatternSwitchExists() { - await testSubjects.existOrFail(`transformCreateIndexPatternSwitch`, { allowHidden: true }); + async assertCreateDataViewSwitchExists() { + await testSubjects.existOrFail(`transformCreateDataViewSwitch`, { allowHidden: true }); }, - async assertCreateIndexPatternSwitchCheckState(expectedCheckState: boolean) { + async assertCreateDataViewSwitchCheckState(expectedCheckState: boolean) { const actualCheckState = - (await testSubjects.getAttribute('transformCreateIndexPatternSwitch', 'aria-checked')) === + (await testSubjects.getAttribute('transformCreateDataViewSwitch', 'aria-checked')) === 'true'; expect(actualCheckState).to.eql( expectedCheckState, diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts index 66d1e83700dede..40fd69246710bb 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts @@ -16,7 +16,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const retry = getService('retry'); const browser = getService('browser'); - describe('cases list', () => { + // Failing: See https://github.com/elastic/kibana/issues/128468 + describe.skip('cases list', () => { before(async () => { await common.navigateToApp('cases'); await cases.api.deleteAllCases(); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts index 0f6e99ccf27f38..14f169d778ebe4 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts @@ -30,7 +30,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('rulesTab'); } - describe('alerts list', function () { + describe('rules list', function () { before(async () => { await pageObjects.common.navigateToApp('triggersActions'); await testSubjects.click('rulesTab'); @@ -390,6 +390,29 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); + it('should render interval info icon when schedule interval is less than configured minimum', async () => { + await createAlert({ + supertest, + objectRemover, + overwrites: { name: 'b', schedule: { interval: '1s' } }, + }); + await createAlert({ + supertest, + objectRemover, + overwrites: { name: 'c' }, + }); + await refreshAlertsList(); + + await testSubjects.existOrFail('ruleInterval-config-icon-0'); + await testSubjects.missingOrFail('ruleInterval-config-icon-1'); + + // open edit flyout when icon is clicked + const infoIcon = await testSubjects.find('ruleInterval-config-icon-0'); + await infoIcon.click(); + + await testSubjects.click('cancelSaveEditedRuleButton'); + }); + it('should delete all selection', async () => { const namePrefix = generateUniqueKey(); const createdAlert = await createAlertManualCleanup({ diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index b280e9a3e78c56..74595e812f42aa 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -91,6 +91,28 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); } + async function createRuleWithSmallInterval( + testRunUuid: string, + params: Record = {} + ) { + const connectors = await createConnectors(testRunUuid); + return await createAlwaysFiringRule({ + name: `test-rule-${testRunUuid}`, + schedule: { + interval: '1s', + }, + actions: connectors.map((connector) => ({ + id: connector.id, + group: 'default', + params: { + message: 'from alert 1s', + level: 'warn', + }, + })), + params, + }); + } + async function getAlertSummary(ruleId: string) { const { body: summary } = await supertest .get(`/internal/alerting/rule/${encodeURIComponent(ruleId)}/_alert_summary`) @@ -116,7 +138,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const testRunUuid = uuid.v4(); before(async () => { await pageObjects.common.navigateToApp('triggersActions'); - const rule = await createRuleWithActionsAndParams(testRunUuid); + const rule = await createRuleWithSmallInterval(testRunUuid); // refresh to see rule await browser.refresh(); @@ -145,6 +167,15 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(connectorType).to.be(`Slack`); }); + it('renders toast when schedule is less than configured minimum', async () => { + await testSubjects.existOrFail('intervalConfigToast'); + + const editButton = await testSubjects.find('ruleIntervalToastEditButton'); + await editButton.click(); + + await testSubjects.click('cancelSaveEditedRuleButton'); + }); + it('should disable the rule', async () => { const enableSwitch = await testSubjects.find('enableSwitch'); diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index e906e239a88925..b2b6735a99c8b6 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -67,7 +67,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--elasticsearch.hosts=https://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, `--plugin-path=${join(__dirname, 'fixtures', 'plugins', 'alerts')}`, - `--xpack.alerting.rules.minimumScheduleInterval.value="1s"`, + `--xpack.alerting.rules.minimumScheduleInterval.value="2s"`, `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, `--xpack.actions.preconfiguredAlertHistoryEsIndex=false`, `--xpack.actions.preconfigured=${JSON.stringify({ diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/blocklists.ts b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/blocklists.ts new file mode 100644 index 00000000000000..7e67c383476036 --- /dev/null +++ b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/blocklists.ts @@ -0,0 +1,343 @@ +/* + * 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 { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { PolicyTestResourceInfo } from '../../../security_solution_endpoint/services/endpoint_policy'; +import { ArtifactTestData } from '../../../security_solution_endpoint/services//endpoint_artifacts'; +import { + BY_POLICY_ARTIFACT_TAG_PREFIX, + GLOBAL_ARTIFACT_TAG, +} from '../../../../plugins/security_solution/common/endpoint/service/artifacts'; +import { ExceptionsListItemGenerator } from '../../../../plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator'; +import { + createUserAndRole, + deleteUserAndRole, + ROLES, +} from '../../../common/services/security_solution'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const endpointPolicyTestResources = getService('endpointPolicyTestResources'); + const endpointArtifactTestResources = getService('endpointArtifactTestResources'); + + describe('Endpoint artifacts (via lists plugin): Blocklists', () => { + let fleetEndpointPolicy: PolicyTestResourceInfo; + + before(async () => { + // Create an endpoint policy in fleet we can work with + fleetEndpointPolicy = await endpointPolicyTestResources.createPolicy(); + + // create role/user + await createUserAndRole(getService, ROLES.detections_admin); + }); + + after(async () => { + if (fleetEndpointPolicy) { + await fleetEndpointPolicy.cleanup(); + } + + // delete role/user + await deleteUserAndRole(getService, ROLES.detections_admin); + }); + + const anEndpointArtifactError = (res: { body: { message: string } }) => { + expect(res.body.message).to.match(/EndpointArtifactError/); + }; + const anErrorMessageWith = ( + value: string | RegExp + ): ((res: { body: { message: string } }) => void) => { + return (res) => { + if (value instanceof RegExp) { + expect(res.body.message).to.match(value); + } else { + expect(res.body.message).to.be(value); + } + }; + }; + + describe('and accessing blocklists', () => { + const exceptionsGenerator = new ExceptionsListItemGenerator(); + let blocklistData: ArtifactTestData; + + type BlocklistApiCallsInterface = Array<{ + method: keyof Pick; + info?: string; + path: string; + // The body just needs to have the properties we care about in the tests. This should cover most + // mocks used for testing that support different interfaces + getBody: () => BodyReturnType; + }>; + + beforeEach(async () => { + blocklistData = await endpointArtifactTestResources.createBlocklist({ + tags: [`${BY_POLICY_ARTIFACT_TAG_PREFIX}${fleetEndpointPolicy.packagePolicy.id}`], + }); + }); + + afterEach(async () => { + if (blocklistData) { + await blocklistData.cleanup(); + } + }); + + const blocklistApiCalls: BlocklistApiCallsInterface< + Pick + > = [ + { + method: 'post', + path: EXCEPTION_LIST_ITEM_URL, + getBody: () => { + return exceptionsGenerator.generateBlocklistForCreate({ tags: [GLOBAL_ARTIFACT_TAG] }); + }, + }, + { + method: 'put', + path: EXCEPTION_LIST_ITEM_URL, + getBody: () => + exceptionsGenerator.generateBlocklistForUpdate({ + id: blocklistData.artifact.id, + item_id: blocklistData.artifact.item_id, + tags: [GLOBAL_ARTIFACT_TAG], + }), + }, + ]; + + describe('and has authorization to manage endpoint security', () => { + for (const blocklistApiCall of blocklistApiCalls) { + it(`should error on [${blocklistApiCall.method}] if invalid condition entry fields are used`, async () => { + const body = blocklistApiCall.getBody(); + + body.entries[0].field = 'some.invalid.field'; + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/types that failed validation:/)); + }); + + it(`should error on [${blocklistApiCall.method}] if the same hash type is present twice`, async () => { + const body = blocklistApiCall.getBody(); + + body.entries = [ + { + field: 'file.hash.sha256', + value: ['a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3'], + type: 'match_any', + operator: 'included', + }, + { + field: 'file.hash.sha256', + value: [ + '2C26B46B68FFC68FF99B453C1D30413413422D706483BFA0F98A5E886266E7AE', + 'FCDE2B2EDBA56BF408601FB721FE9B5C338D10EE429EA04FAE5511B68FBF8FB9', + ], + type: 'match_any', + operator: 'included', + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/duplicated/)); + }); + + it(`should error on [${blocklistApiCall.method}] if an invalid hash is used`, async () => { + const body = blocklistApiCall.getBody(); + + body.entries = [ + { + field: 'file.hash.md5', + operator: 'included', + type: 'match_any', + value: ['1'], + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/invalid hash/)); + }); + + it(`should error on [${blocklistApiCall.method}] if no values`, async () => { + const body = blocklistApiCall.getBody(); + + body.entries = [ + { + field: 'file.hash.md5', + operator: 'included', + type: 'match_any', + value: [], + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anErrorMessageWith(/Invalid value \"\[\]\"/)); + }); + + it(`should error on [${blocklistApiCall.method}] if signer is set for a non windows os entry item`, async () => { + const body = blocklistApiCall.getBody(); + + body.os_types = ['linux']; + body.entries = [ + { + field: 'file.Ext.code_signature', + entries: [ + { + field: 'subject_name', + value: 'foo', + type: 'match', + operator: 'included', + }, + ], + type: 'nested', + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/^.*(?!file\.Ext\.code_signature)/)); + }); + + it(`should error on [${blocklistApiCall.method}] if more than one entry and not a hash`, async () => { + const body = blocklistApiCall.getBody(); + + body.os_types = ['windows']; + body.entries = [ + { + field: 'file.path', + value: ['C:\\some\\path', 'C:\\some\\other\\path', 'C:\\yet\\another\\path'], + type: 'match_any', + operator: 'included', + }, + { + field: 'file.Ext.code_signature', + entries: [ + { + field: 'subject_name', + value: ['notsus.exe', 'verynotsus.exe', 'superlegit.exe'], + type: 'match_any', + operator: 'included', + }, + ], + type: 'nested', + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/one entry is allowed/)); + }); + + it(`should error on [${blocklistApiCall.method}] if more than one OS is set`, async () => { + const body = blocklistApiCall.getBody(); + + body.os_types = ['linux', 'windows']; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/\[osTypes\]: array size is \[2\]/)); + }); + + it(`should error on [${blocklistApiCall.method}] if policy id is invalid`, async () => { + const body = blocklistApiCall.getBody(); + + body.tags = [`${BY_POLICY_ARTIFACT_TAG_PREFIX}123`]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/invalid policy ids/)); + }); + } + }); + + describe('and user DOES NOT have authorization to manage endpoint security', () => { + const allblocklistApiCalls: BlocklistApiCallsInterface = [ + ...blocklistApiCalls, + { + method: 'get', + info: 'single item', + get path() { + return `${EXCEPTION_LIST_ITEM_URL}?item_id=${blocklistData.artifact.item_id}&namespace_type=${blocklistData.artifact.namespace_type}`; + }, + getBody: () => undefined, + }, + { + method: 'get', + info: 'list summary', + get path() { + return `${EXCEPTION_LIST_URL}/summary?list_id=${blocklistData.artifact.list_id}&namespace_type=${blocklistData.artifact.namespace_type}`; + }, + getBody: () => undefined, + }, + { + method: 'delete', + info: 'single item', + get path() { + return `${EXCEPTION_LIST_ITEM_URL}?item_id=${blocklistData.artifact.item_id}&namespace_type=${blocklistData.artifact.namespace_type}`; + }, + getBody: () => undefined, + }, + { + method: 'post', + info: 'list export', + get path() { + return `${EXCEPTION_LIST_URL}/_export?list_id=${blocklistData.artifact.list_id}&namespace_type=${blocklistData.artifact.namespace_type}&id=1`; + }, + getBody: () => undefined, + }, + { + method: 'get', + info: 'single items', + get path() { + return `${EXCEPTION_LIST_ITEM_URL}/_find?list_id=${blocklistData.artifact.list_id}&namespace_type=${blocklistData.artifact.namespace_type}&page=1&per_page=1&sort_field=name&sort_order=asc`; + }, + getBody: () => undefined, + }, + ]; + + for (const blocklistApiCall of allblocklistApiCalls) { + it(`should error on [${blocklistApiCall.method}]`, async () => { + await supertestWithoutAuth[blocklistApiCall.method](blocklistApiCall.path) + .auth(ROLES.detections_admin, 'changeme') + .set('kbn-xsrf', 'true') + .send(blocklistApiCall.getBody()) + .expect(403, { + status_code: 403, + message: 'EndpointArtifactError: Endpoint authorization failure', + }); + }); + } + }); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/index.ts b/x-pack/test/security_solution_endpoint_api_int/apis/index.ts index 5acb9d2e4261d5..94a5a9122f1871 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/index.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/index.ts @@ -35,5 +35,6 @@ export default function endpointAPIIntegrationTests(providerContext: FtrProvider loadTestFile(require.resolve('./endpoint_artifacts/trusted_apps')); loadTestFile(require.resolve('./endpoint_artifacts/event_filters')); loadTestFile(require.resolve('./endpoint_artifacts/host_isolation_exceptions')); + loadTestFile(require.resolve('./endpoint_artifacts/blocklists')); }); } diff --git a/yarn.lock b/yarn.lock index 5163a6e68be50b..cdcf07b3e73410 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12786,10 +12786,10 @@ ejs@^3.1.6: dependencies: jake "^10.6.1" -elastic-apm-http-client@11.0.0: - version "11.0.0" - resolved "https://registry.yarnpkg.com/elastic-apm-http-client/-/elastic-apm-http-client-11.0.0.tgz#a38a85eae078e3f7f09edda86db6d6419a8ecfea" - integrity sha512-HB6+O0C4GGj9k5bd6yL3QK5prGKh+Rf8Tc5iW0T7FCdh2HliICfGmB6wmdQ2XkClblLtISh7tKYgVr9YgdXl3Q== +elastic-apm-http-client@11.0.1: + version "11.0.1" + resolved "https://registry.yarnpkg.com/elastic-apm-http-client/-/elastic-apm-http-client-11.0.1.tgz#15dbe99d56d62b3f732d1bd2b51bef6094b78801" + integrity sha512-5AOWlhs2WlZpI+DfgGqY/8Rk7KF8WeevaO8R961eBylavU6GWhLRNiJncohn5jsvrqhmeT19azBvy/oYRN7bJw== dependencies: agentkeepalive "^4.2.1" breadth-filter "^2.0.0" @@ -12802,10 +12802,10 @@ elastic-apm-http-client@11.0.0: semver "^6.3.0" stream-chopper "^3.0.1" -elastic-apm-node@^3.30.0: - version "3.30.0" - resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.30.0.tgz#4df7110324535089f66f7a3a96bf37d2fe47f38b" - integrity sha512-KumRBDGIE+MGgJfteAi9BDqeGxpAYpbovWjNdB5x8T3/zpnQRJkDMSblliEsMwD6uKf2+Nkxzmyq9UZdh5MbGQ== +elastic-apm-node@^3.31.0: + version "3.31.0" + resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.31.0.tgz#6e0bf622d922c95ff0127a263babcdeaeea71457" + integrity sha512-0OulazfhkXYbOaGkHncqjwOfxtcvzsDyzUKr6Y1k95HwKrjf1Vi+xPutZv4p/WfDdO+JadphI0U2Uu5ncGB2iA== dependencies: "@elastic/ecs-pino-format" "^1.2.0" after-all-results "^2.0.0" @@ -12814,7 +12814,7 @@ elastic-apm-node@^3.30.0: basic-auth "^2.0.1" cookie "^0.4.0" core-util-is "^1.0.2" - elastic-apm-http-client "11.0.0" + elastic-apm-http-client "11.0.1" end-of-stream "^1.4.4" error-callsites "^2.0.4" error-stack-parser "^2.0.6"