diff --git a/api_docs/spaces_oss.json b/api_docs/spaces_oss.json index bce557db8e516..a0ed4297ddc39 100644 --- a/api_docs/spaces_oss.json +++ b/api_docs/spaces_oss.json @@ -577,15 +577,11 @@ "deprecated": false, "children": [ { - "parentPluginId": "spacesOss", - "id": "def-public.SpacesApi.activeSpace$", - "type": "Object", - "tags": [], - "label": "activeSpace$", - "description": [ - "\nObservable representing the currently active space.\nThe details of the space can change without a full page reload (such as display name, color, etc.)" - ], + "id": "def-public.SpacesApi.getActiveSpace$", + "type": "Function", + "label": "getActiveSpace$", "signature": [ + "() => ", "Observable", "<", { @@ -597,11 +593,16 @@ }, ">" ], + "description": [ + "\nObservable representing the currently active space.\nThe details of the space can change without a full page reload (such as display name, color, etc.)" + ], + "children": [], + "tags": [], + "returnComment": [], "source": { "path": "src/plugins/spaces_oss/public/api.ts", "lineNumber": 22 - }, - "deprecated": false + } }, { "parentPluginId": "spacesOss", diff --git a/docs/developer/contributing/development-functional-tests.asciidoc b/docs/developer/contributing/development-functional-tests.asciidoc index f149e9de7aaba..110704a8e569a 100644 --- a/docs/developer/contributing/development-functional-tests.asciidoc +++ b/docs/developer/contributing/development-functional-tests.asciidoc @@ -140,7 +140,7 @@ export default function (/* { providerAPI } */) { ----------- **Services**::: -Services are named singleton values produced by a Service Provider. Tests and other services can retrieve service instances by asking for them by name. All functionality except the mocha API is exposed via services.\ +Services are named singleton values produced by a Service Provider. Tests and other services can retrieve service instances by asking for them by name. All functionality except the mocha API is exposed via services. **Page objects**::: Page objects are a special type of service that encapsulate behaviors common to a particular page or plugin. When you write your own plugin, you’ll likely want to add a page object (or several) that describes the common interactions your tests need to execute. diff --git a/docs/user/dashboard/tsvb.asciidoc b/docs/user/dashboard/tsvb.asciidoc index ff1c16c14d467..5c4ce8e365e86 100644 --- a/docs/user/dashboard/tsvb.asciidoc +++ b/docs/user/dashboard/tsvb.asciidoc @@ -2,7 +2,8 @@ === TSVB *TSVB* enables you to visualize the data from multiple data series, supports <>, multiple visualization types, custom functions, and some math. To use *TSVB*, your data must have a date field. +most {es} metric aggregations>>, multiple visualization types, custom functions, and some math. +To create *TSVB* visualization panels, your data must have a time field. [role="screenshot"] image::visualize/images/tsvb-screenshot.png[TSVB overview] @@ -17,15 +18,19 @@ Open *TSVB*, then make sure the required settings are configured. . On the *New visualization* window, click *TSVB*. -. In *TSVB*, click *Panel options*, then make sure the following settings are configured: +. In *TSVB*, click *Panel options*, then specify the required *Data* settings. -* *Index pattern* -* *Time field* -* *Interval* +.. From the *Index pattern* dropdown, select the index pattern you want to visualize. ++ +To visualize an {es} index, open the *Index pattern select mode* menu, deselect *Use only {kib} index patterns*, then enter the {es} index. + +.. From the *Time field* dropdown, select the field you want to visualize, then enter the field *Interval*. -. Select a *Drop last bucket* option. It is dropped by default because the time filter intersects the time range of the last bucket, but can be enabled to see the partial data. +.. Select a *Drop last bucket* option. ++ +By default, *TSVB* drops the last bucket because the time filter intersects the time range of the last bucket. To view the partial data, select *No*. -. In the *Panel filter* field, specify any <> to select specific documents. +.. In the *Panel filter* field, enter <> to view specific documents. [float] [[configure-the-data-series]] diff --git a/examples/search_examples/common/index.ts b/examples/search_examples/common/index.ts index cc47c0f575973..c61de9d3c6dee 100644 --- a/examples/search_examples/common/index.ts +++ b/examples/search_examples/common/index.ts @@ -6,17 +6,7 @@ * Side Public License, v 1. */ -import { IEsSearchResponse, IEsSearchRequest } from '../../../src/plugins/data/common'; - export const PLUGIN_ID = 'searchExamples'; export const PLUGIN_NAME = 'Search Examples'; -export interface IMyStrategyRequest extends IEsSearchRequest { - get_cool: boolean; -} -export interface IMyStrategyResponse extends IEsSearchResponse { - cool: string; - executed_at: number; -} - export const SERVER_SEARCH_ROUTE_PATH = '/api/examples/search'; diff --git a/examples/search_examples/common/types.ts b/examples/search_examples/common/types.ts new file mode 100644 index 0000000000000..8bb38ea0b2d0d --- /dev/null +++ b/examples/search_examples/common/types.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 { + IEsSearchRequest, + IEsSearchResponse, + IKibanaSearchRequest, + IKibanaSearchResponse, +} from '../../../src/plugins/data/common'; + +export interface IMyStrategyRequest extends IEsSearchRequest { + get_cool: boolean; +} +export interface IMyStrategyResponse extends IEsSearchResponse { + cool: string; + executed_at: number; +} + +export type FibonacciRequest = IKibanaSearchRequest<{ n: number }>; + +export type FibonacciResponse = IKibanaSearchResponse<{ values: number[] }>; diff --git a/examples/search_examples/public/search/app.tsx b/examples/search_examples/public/search/app.tsx index c9ede2ff2b45f..06f9426b4965c 100644 --- a/examples/search_examples/public/search/app.tsx +++ b/examples/search_examples/public/search/app.tsx @@ -26,27 +26,27 @@ import { EuiCode, EuiComboBox, EuiFormLabel, + EuiFieldNumber, + EuiProgress, EuiTabbedContent, + EuiTabbedContentTab, } from '@elastic/eui'; import { CoreStart } from '../../../../src/core/public'; import { mountReactNode } from '../../../../src/core/public/utils'; import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public'; -import { - PLUGIN_ID, - PLUGIN_NAME, - IMyStrategyResponse, - SERVER_SEARCH_ROUTE_PATH, -} from '../../common'; +import { PLUGIN_ID, PLUGIN_NAME, SERVER_SEARCH_ROUTE_PATH } from '../../common'; import { DataPublicPluginStart, + IKibanaSearchResponse, IndexPattern, IndexPatternField, isCompleteResponse, isErrorResponse, } from '../../../../src/plugins/data/public'; +import { IMyStrategyResponse } from '../../common/types'; interface SearchExamplesAppDeps { notifications: CoreStart['notifications']; @@ -88,7 +88,10 @@ export const SearchExamplesApp = ({ }: SearchExamplesAppDeps) => { const { IndexPatternSelect } = data.ui; const [getCool, setGetCool] = useState(false); + const [fibonacciN, setFibonacciN] = useState(10); const [timeTook, setTimeTook] = useState(); + const [total, setTotal] = useState(100); + const [loaded, setLoaded] = useState(0); const [indexPattern, setIndexPattern] = useState(); const [fields, setFields] = useState(); const [selectedFields, setSelectedFields] = useState([]); @@ -99,7 +102,15 @@ export const SearchExamplesApp = ({ IndexPatternField | null | undefined >(); const [request, setRequest] = useState>({}); - const [response, setResponse] = useState>({}); + const [rawResponse, setRawResponse] = useState>({}); + const [selectedTab, setSelectedTab] = useState(0); + + function setResponse(response: IKibanaSearchResponse) { + setRawResponse(response.rawResponse); + setLoaded(response.loaded!); + setTotal(response.total!); + setTimeTook(response.rawResponse.took); + } // Fetch the default index pattern using the `data.indexPatterns` service, as the component is mounted. useEffect(() => { @@ -152,8 +163,7 @@ export const SearchExamplesApp = ({ .subscribe({ next: (res) => { if (isCompleteResponse(res)) { - setResponse(res.rawResponse); - setTimeTook(res.rawResponse.took); + setResponse(res); const avgResult: number | undefined = res.rawResponse.aggregations ? // @ts-expect-error @elastic/elasticsearch no way to declare a type for aggregation in the search response res.rawResponse.aggregations[1].value @@ -234,7 +244,7 @@ export const SearchExamplesApp = ({ setRequest(searchSource.getSearchRequestBody()); const { rawResponse: res } = await searchSource.fetch$().toPromise(); - setResponse(res); + setRawResponse(res); const message = Searched {res.hits.total} documents.; notifications.toasts.addSuccess( @@ -247,7 +257,7 @@ export const SearchExamplesApp = ({ } ); } catch (e) { - setResponse(e.body); + setRawResponse(e.body); notifications.toasts.addWarning(`An error has occurred: ${e.message}`); } }; @@ -260,6 +270,41 @@ export const SearchExamplesApp = ({ doAsyncSearch('myStrategy'); }; + const onPartialResultsClickHandler = () => { + setSelectedTab(1); + const req = { + params: { + n: fibonacciN, + }, + }; + + // Submit the search request using the `data.search` service. + setRequest(req.params); + const searchSubscription$ = data.search + .search(req, { + strategy: 'fibonacciStrategy', + }) + .subscribe({ + next: (res) => { + setResponse(res); + if (isCompleteResponse(res)) { + notifications.toasts.addSuccess({ + title: 'Query result', + text: 'Query finished', + }); + searchSubscription$.unsubscribe(); + } else if (isErrorResponse(res)) { + // TODO: Make response error status clearer + notifications.toasts.addWarning('An error has occurred'); + searchSubscription$.unsubscribe(); + } + }, + error: () => { + notifications.toasts.addDanger('Failed to run search'); + }, + }); + }; + const onClientSideSessionCacheClickHandler = () => { doAsyncSearch('myStrategy', data.search.session.getSessionId()); }; @@ -284,7 +329,7 @@ export const SearchExamplesApp = ({ doSearchSourceSearch(withOtherBucket); }; - const reqTabs = [ + const reqTabs: EuiTabbedContentTab[] = [ { id: 'request', name: Request, @@ -318,6 +363,7 @@ export const SearchExamplesApp = ({ values={{ time: timeTook ?? 'Unknown' }} /> + - {JSON.stringify(response, null, 2)} + {JSON.stringify(rawResponse, null, 2)} ), @@ -484,6 +530,37 @@ export const SearchExamplesApp = ({ + +

Handling partial results

+
+ + The observable returned from data.search provides partial results + when the response is not yet complete. These can be handled to update a chart or + simply a progress bar: + + + <EuiProgress value={response.loaded} max={response.total} + /> + + Below is an example showing a custom search strategy that emits partial Fibonacci + sequences up to the length provided, updates the response with each partial result, + and updates a progress bar (see the Response tab). + setFibonacciN(parseInt(event.target.value, 10))} + /> + + Request Fibonacci sequence + + +

Writing a custom search strategy

@@ -567,8 +644,13 @@ export const SearchExamplesApp = ({ + - + setSelectedTab(reqTabs.indexOf(tab))} + /> diff --git a/examples/search_examples/server/fibonacci_strategy.ts b/examples/search_examples/server/fibonacci_strategy.ts new file mode 100644 index 0000000000000..a37438aba7055 --- /dev/null +++ b/examples/search_examples/server/fibonacci_strategy.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 uuid from 'uuid'; +import { ISearchStrategy } from '../../../src/plugins/data/server'; +import { FibonacciRequest, FibonacciResponse } from '../common/types'; + +export const fibonacciStrategyProvider = (): ISearchStrategy< + FibonacciRequest, + FibonacciResponse +> => { + const responseMap = new Map(); + return ({ + search: (request: FibonacciRequest) => { + const id = request.id ?? uuid(); + const [sequence, total, started] = responseMap.get(id) ?? [ + [], + request.params?.n ?? 0, + Date.now(), + ]; + if (sequence.length < 2) { + if (total > 0) sequence.push(sequence.length); + } else { + const [a, b] = sequence.slice(-2); + sequence.push(a + b); + } + const loaded = sequence.length; + responseMap.set(id, [sequence, total, started]); + if (loaded >= total) { + responseMap.delete(id); + } + + const isRunning = loaded < total; + const isPartial = isRunning; + const took = Date.now() - started; + const values = sequence.slice(0, loaded); + + // Usually we'd do something like "of()" but for some reason it breaks in tests with the error + // "You provided an invalid object where a stream was expected." which is why we have to cast + // down below as well + return [{ id, loaded, total, isRunning, isPartial, rawResponse: { took, values } }]; + }, + cancel: async (id: string) => { + responseMap.delete(id); + }, + } as unknown) as ISearchStrategy; +}; diff --git a/examples/search_examples/server/my_strategy.ts b/examples/search_examples/server/my_strategy.ts index 0a64788960091..db8cd903f23d6 100644 --- a/examples/search_examples/server/my_strategy.ts +++ b/examples/search_examples/server/my_strategy.ts @@ -8,7 +8,7 @@ import { map } from 'rxjs/operators'; import { ISearchStrategy, PluginStart } from '../../../src/plugins/data/server'; -import { IMyStrategyResponse, IMyStrategyRequest } from '../common'; +import { IMyStrategyRequest, IMyStrategyResponse } from '../common/types'; export const mySearchStrategyProvider = ( data: PluginStart diff --git a/examples/search_examples/server/plugin.ts b/examples/search_examples/server/plugin.ts index 84f082d890bb0..984d3201220eb 100644 --- a/examples/search_examples/server/plugin.ts +++ b/examples/search_examples/server/plugin.ts @@ -24,6 +24,7 @@ import { } from './types'; import { mySearchStrategyProvider } from './my_strategy'; import { registerRoutes } from './routes'; +import { fibonacciStrategyProvider } from './fibonacci_strategy'; export class SearchExamplesPlugin implements @@ -48,7 +49,9 @@ export class SearchExamplesPlugin core.getStartServices().then(([_, depsStart]) => { const myStrategy = mySearchStrategyProvider(depsStart.data); + const fibonacciStrategy = fibonacciStrategyProvider(); deps.data.search.registerSearchStrategy('myStrategy', myStrategy); + deps.data.search.registerSearchStrategy('fibonacciStrategy', fibonacciStrategy); registerRoutes(router); }); diff --git a/packages/kbn-es/README.md b/packages/kbn-es/README.md index 4d4c2aa94db07..80850c9e6a09c 100644 --- a/packages/kbn-es/README.md +++ b/packages/kbn-es/README.md @@ -7,6 +7,8 @@ If running elasticsearch from source, elasticsearch needs to be cloned to a sibl To run, go to the Kibana root and run `node scripts/es --help` to get the latest command line options. +The script attempts to preserve the existing interfaces used by Elasticsearch CLI. This includes passing through options with the `-E` argument and the `ES_JAVA_OPTS` environment variable for Java options. + ### Examples Run a snapshot install with a trial license diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index c55e5d3513c44..ad9ecb059031c 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -236,6 +236,7 @@ exports.Cluster = class Cluster { * @param {String} installPath * @param {Object} options * @property {string|Array} options.esArgs + * @property {string} options.esJavaOpts * @return {undefined} */ _exec(installPath, options = {}) { @@ -268,14 +269,17 @@ exports.Cluster = class Cluster { this._log.debug('%s %s', ES_BIN, args.join(' ')); - options.esEnvVars = options.esEnvVars || {}; + let esJavaOpts = `${options.esJavaOpts || ''} ${process.env.ES_JAVA_OPTS || ''}`; // ES now automatically sets heap size to 50% of the machine's available memory // so we need to set it to a smaller size for local dev and CI // especially because we currently run many instances of ES on the same machine during CI - options.esEnvVars.ES_JAVA_OPTS = - (options.esEnvVars.ES_JAVA_OPTS ? `${options.esEnvVars.ES_JAVA_OPTS} ` : '') + - '-Xms1g -Xmx1g'; + // inital and max must be the same, so we only need to check the max + if (!esJavaOpts.includes('Xmx')) { + esJavaOpts += ' -Xms1g -Xmx1g'; + } + + this._log.debug('ES_JAVA_OPTS: %s', esJavaOpts.trim()); this._process = execa(ES_BIN, args, { cwd: installPath, @@ -283,7 +287,7 @@ exports.Cluster = class Cluster { ...(installPath ? { ES_TMPDIR: path.resolve(installPath, 'ES_TMPDIR') } : {}), ...process.env, JAVA_HOME: '', // By default, we want to always unset JAVA_HOME so that the bundled JDK will be used - ...(options.esEnvVars || {}), + ES_JAVA_OPTS: esJavaOpts.trim(), }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/packages/kbn-es/src/integration_tests/cluster.test.js b/packages/kbn-es/src/integration_tests/cluster.test.js index 6b4025840283f..34220b08d2120 100644 --- a/packages/kbn-es/src/integration_tests/cluster.test.js +++ b/packages/kbn-es/src/integration_tests/cluster.test.js @@ -71,11 +71,17 @@ function mockEsBin({ exitCode, start }) { ); } +const initialEnv = { ...process.env }; + beforeEach(() => { jest.resetAllMocks(); extractConfigFiles.mockImplementation((config) => config); }); +afterEach(() => { + process.env = { ...initialEnv }; +}); + describe('#installSource()', () => { it('awaits installSource() promise and returns { installPath }', async () => { let resolveInstallSource; @@ -355,6 +361,25 @@ describe('#run()', () => { ] `); }); + + it('sets default Java heap', async () => { + mockEsBin({ start: true }); + + const cluster = new Cluster({ log }); + await cluster.run(); + + expect(execa.mock.calls[0][2].env.ES_JAVA_OPTS).toEqual('-Xms1g -Xmx1g'); + }); + + it('allows Java heap to be overwritten', async () => { + mockEsBin({ start: true }); + process.env.ES_JAVA_OPTS = '-Xms5g -Xmx5g'; + + const cluster = new Cluster({ log }); + await cluster.run(); + + expect(execa.mock.calls[0][2].env.ES_JAVA_OPTS).toEqual('-Xms5g -Xmx5g'); + }); }); describe('#stop()', () => { diff --git a/packages/kbn-securitysolution-io-ts-utils/src/non_empty_string_array/index.test.ts b/packages/kbn-securitysolution-io-ts-utils/src/non_empty_string_array/index.test.ts index f56fa7faed2a4..9fec36f46dd27 100644 --- a/packages/kbn-securitysolution-io-ts-utils/src/non_empty_string_array/index.test.ts +++ b/packages/kbn-securitysolution-io-ts-utils/src/non_empty_string_array/index.test.ts @@ -8,7 +8,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { left } from 'fp-ts/lib/Either'; -import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; +import { foldLeftRight, getPaths } from '../test_utils'; import { NonEmptyStringArray } from '.'; describe('non_empty_string_array', () => { diff --git a/packages/kbn-test/src/es/test_es_cluster.ts b/packages/kbn-test/src/es/test_es_cluster.ts index e802613fbaedb..658fc9382d616 100644 --- a/packages/kbn-test/src/es/test_es_cluster.ts +++ b/packages/kbn-test/src/es/test_es_cluster.ts @@ -36,7 +36,7 @@ interface TestClusterFactoryOptions { * */ dataArchive?: string; esArgs?: string[]; - esEnvVars?: Record; + esJavaOpts?: string; clusterName?: string; log: ToolingLog; ssl?: boolean; @@ -52,7 +52,7 @@ export function createTestEsCluster(options: TestClusterFactoryOptions) { esFrom = esTestConfig.getBuildFrom(), dataArchive, esArgs: customEsArgs = [], - esEnvVars, + esJavaOpts, clusterName: customClusterName = 'es-test-cluster', ssl, } = options; @@ -107,7 +107,7 @@ export function createTestEsCluster(options: TestClusterFactoryOptions) { await cluster.start(installPath, { password: config.password, esArgs, - esEnvVars, + esJavaOpts, }); } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts index d82b7b83e8f15..65573fdbd6647 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts @@ -172,7 +172,7 @@ export const schema = Joi.object() license: Joi.string().default('basic'), from: Joi.string().default('snapshot'), serverArgs: Joi.array(), - serverEnvVars: Joi.object(), + esJavaOpts: Joi.string(), dataArchive: Joi.string(), ssl: Joi.boolean().default(false), }) diff --git a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts index 83368783da389..7ba9a3c1c4733 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts +++ b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts @@ -29,7 +29,7 @@ export async function runElasticsearch({ const ssl = config.get('esTestCluster.ssl'); const license = config.get('esTestCluster.license'); const esArgs = config.get('esTestCluster.serverArgs'); - const esEnvVars = config.get('esTestCluster.serverEnvVars'); + const esJavaOpts = config.get('esTestCluster.esJavaOpts'); const isSecurityEnabled = esArgs.includes('xpack.security.enabled=true'); const cluster = createTestEsCluster({ @@ -43,7 +43,7 @@ export async function runElasticsearch({ esFrom: esFrom || config.get('esTestCluster.from'), dataArchive: config.get('esTestCluster.dataArchive'), esArgs, - esEnvVars, + esJavaOpts, ssl, }); diff --git a/src/plugins/dashboard/public/application/dashboard_router.tsx b/src/plugins/dashboard/public/application/dashboard_router.tsx index ed68afc5e97b1..9050aee5ce38c 100644 --- a/src/plugins/dashboard/public/application/dashboard_router.tsx +++ b/src/plugins/dashboard/public/application/dashboard_router.tsx @@ -84,7 +84,8 @@ export async function mountApp({ } = pluginsStart; const spacesApi = pluginsStart.spacesOss?.isSpacesAvailable ? pluginsStart.spacesOss : undefined; - const activeSpaceId = spacesApi && (await spacesApi.activeSpace$.pipe(first()).toPromise())?.id; + const activeSpaceId = + spacesApi && (await spacesApi.getActiveSpace$().pipe(first()).toPromise())?.id; let globalEmbedSettings: DashboardEmbedSettings | undefined; const dashboardServices: DashboardAppServices = { diff --git a/src/plugins/data/common/es_query/filters/range_filter.test.ts b/src/plugins/data/common/es_query/filters/range_filter.test.ts index c7ff84f61d0fc..bb7ecc09ebc34 100644 --- a/src/plugins/data/common/es_query/filters/range_filter.test.ts +++ b/src/plugins/data/common/es_query/filters/range_filter.test.ts @@ -64,6 +64,29 @@ describe('Range filter builder', () => { }); }); + it('should convert strings to numbers if the field is scripted and type number', () => { + const field = getField('script number'); + + expect(buildRangeFilter(field, { gte: '1', lte: '3' }, indexPattern)).toEqual({ + meta: { + field: 'script number', + index: 'id', + params: {}, + }, + script: { + script: { + lang: 'expression', + source: '(' + field!.script + ')>=gte && (' + field!.script + ')<=lte', + params: { + value: '>=1 <=3', + gte: 1, + lte: 3, + }, + }, + }, + }); + }); + it('should wrap painless scripts in comparator lambdas', () => { const field = getField('script date'); const expected = diff --git a/src/plugins/data/common/es_query/filters/range_filter.ts b/src/plugins/data/common/es_query/filters/range_filter.ts index ba9055e26dbda..fb8426655583e 100644 --- a/src/plugins/data/common/es_query/filters/range_filter.ts +++ b/src/plugins/data/common/es_query/filters/range_filter.ts @@ -138,7 +138,10 @@ export const buildRangeFilter = ( }; export const getRangeScript = (field: IFieldType, params: RangeFilterParams) => { - const knownParams = pickBy(params, (val, key: any) => key in operators); + const knownParams = mapValues( + pickBy(params, (val, key: any) => key in operators), + (value) => (field.type === 'number' && typeof value === 'string' ? parseFloat(value) : value) + ); let script = map( knownParams, (val: any, key: string) => '(' + field.script + ')' + get(operators, key) + key diff --git a/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/calc_es_interval.ts b/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/calc_es_interval.ts index 72d59af037d90..4b3ec90bb2cc2 100644 --- a/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/calc_es_interval.ts +++ b/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/calc_es_interval.ts @@ -12,7 +12,7 @@ import dateMath, { Unit } from '@elastic/datemath'; import { parseEsInterval } from '../../../utils'; const unitsDesc = dateMath.unitsDesc; -const largeMax = unitsDesc.indexOf('M'); +const largeMax = unitsDesc.indexOf('w'); export interface EsInterval { expression: string; diff --git a/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.test.ts b/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.test.ts index 6fbaddb09b226..fd3f861dce07e 100644 --- a/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/lib/time_buckets/time_buckets.test.ts @@ -10,6 +10,7 @@ import moment from 'moment'; import { TimeBuckets, TimeBucketsConfig } from './time_buckets'; import { autoInterval } from '../../_interval_options'; +import { InvalidEsCalendarIntervalError } from '../../../utils'; describe('TimeBuckets', () => { const timeBucketConfig: TimeBucketsConfig = { @@ -137,4 +138,14 @@ describe('TimeBuckets', () => { const format = timeBuckets.getScaledDateFormat(); expect(format).toEqual('HH:mm'); }); + + test('allows days but throws error on weeks', () => { + const timeBuckets = new TimeBuckets(timeBucketConfig); + timeBuckets.setInterval('14d'); + const interval = timeBuckets.getInterval(false); + expect(interval.esUnit).toEqual('d'); + + timeBuckets.setInterval('2w'); + expect(() => timeBuckets.getInterval(false)).toThrow(InvalidEsCalendarIntervalError); + }); }); diff --git a/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts b/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts index e7f5005e7e837..cec4b9a2dbf9f 100644 --- a/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts +++ b/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts @@ -39,7 +39,7 @@ export const enhancedEsSearchStrategyProvider = ( legacyConfig$: Observable, logger: Logger, usage?: SearchUsage -): ISearchStrategy => { +): ISearchStrategy => { async function cancelAsyncSearch(id: string, esClient: IScopedClusterClient) { try { await esClient.asCurrentUser.asyncSearch.delete({ id }); diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid.test.tsx index 8037022085f02..b2be40c008200 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid.test.tsx @@ -124,6 +124,33 @@ describe('DiscoverGrid', () => { expect(getDisplayedDocNr(component)).toBe(5); }); + test('showing selected documents, underlying data changes, all documents are displayed, selection is gone', async () => { + await toggleDocSelection(component, esHits[0]); + await toggleDocSelection(component, esHits[1]); + expect(getSelectedDocNr(component)).toBe(2); + findTestSubject(component, 'dscGridSelectionBtn').simulate('click'); + findTestSubject(component, 'dscGridShowSelectedDocuments').simulate('click'); + expect(getDisplayedDocNr(component)).toBe(2); + component.setProps({ + rows: [ + { + _index: 'i', + _id: '6', + _score: 1, + _type: '_doc', + _source: { + date: '2020-20-02T12:12:12.128', + name: 'test6', + extension: 'doc', + bytes: 50, + }, + }, + ], + }); + expect(getDisplayedDocNr(component)).toBe(1); + expect(getSelectedDocNr(component)).toBe(0); + }); + test('showing only selected documents and remove filter deselecting each doc manually', async () => { await toggleDocSelection(component, esHits[0]); findTestSubject(component, 'dscGridSelectionBtn').simulate('click'); diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx index be38f166fa1c0..f969eb32f3791 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx @@ -164,17 +164,33 @@ export const DiscoverGrid = ({ const [isFilterActive, setIsFilterActive] = useState(false); const displayedColumns = getDisplayedColumns(columns, indexPattern); const defaultColumns = displayedColumns.includes('_source'); + const usedSelectedDocs = useMemo(() => { + if (!selectedDocs.length || !rows?.length) { + return []; + } + const idMap = rows.reduce((map, row) => map.set(getDocId(row), true), new Map()); + // filter out selected docs that are no longer part of the current data + const result = selectedDocs.filter((docId) => idMap.get(docId)); + if (result.length === 0 && isFilterActive) { + setIsFilterActive(false); + } + return result; + }, [selectedDocs, rows, isFilterActive]); + const displayedRows = useMemo(() => { if (!rows) { return []; } - if (!isFilterActive || selectedDocs.length === 0) { + if (!isFilterActive || usedSelectedDocs.length === 0) { + return rows; + } + const rowsFiltered = rows.filter((row) => usedSelectedDocs.includes(getDocId(row))); + if (!rowsFiltered.length) { + // in case the selected docs are no longer part of the sample of 500, show all docs return rows; } - return rows.filter((row) => { - return selectedDocs.includes(getDocId(row)); - }); - }, [rows, selectedDocs, isFilterActive]); + return rowsFiltered; + }, [rows, usedSelectedDocs, isFilterActive]); /** * Pagination @@ -258,16 +274,16 @@ export const DiscoverGrid = ({ const additionalControls = useMemo( () => - selectedDocs.length ? ( + usedSelectedDocs.length ? ( ) : null, - [selectedDocs, isFilterActive, rows, setIsFilterActive] + [usedSelectedDocs, isFilterActive, rows, setIsFilterActive] ); if (!rowCount) { @@ -291,7 +307,7 @@ export const DiscoverGrid = ({ onFilter, indexPattern, isDarkMode: services.uiSettings.get('theme:darkMode'), - selectedDocs, + selectedDocs: usedSelectedDocs, setSelectedDocs: (newSelectedDocs) => { setSelectedDocs(newSelectedDocs); if (isFilterActive && newSelectedDocs.length === 0) { diff --git a/src/plugins/spaces_oss/public/api.mock.ts b/src/plugins/spaces_oss/public/api.mock.ts index 84b22459a96e2..9ad7599b5ae61 100644 --- a/src/plugins/spaces_oss/public/api.mock.ts +++ b/src/plugins/spaces_oss/public/api.mock.ts @@ -11,7 +11,7 @@ import { of } from 'rxjs'; import type { SpacesApi, SpacesApiUi, SpacesApiUiComponent } from './api'; const createApiMock = (): jest.Mocked => ({ - activeSpace$: of(), + getActiveSpace$: jest.fn().mockReturnValue(of()), getActiveSpace: jest.fn(), ui: createApiUiMock(), }); diff --git a/src/plugins/spaces_oss/public/api.ts b/src/plugins/spaces_oss/public/api.ts index bd452c2fca00e..e460d9a43ef6b 100644 --- a/src/plugins/spaces_oss/public/api.ts +++ b/src/plugins/spaces_oss/public/api.ts @@ -19,7 +19,7 @@ export interface SpacesApi { * Observable representing the currently active space. * The details of the space can change without a full page reload (such as display name, color, etc.) */ - readonly activeSpace$: Observable; + getActiveSpace$(): Observable; /** * Retrieve the currently active space. diff --git a/test/functional/apps/visualize/_area_chart.ts b/test/functional/apps/visualize/_area_chart.ts index f6eaa2c685f5d..2bad91565de72 100644 --- a/test/functional/apps/visualize/_area_chart.ts +++ b/test/functional/apps/visualize/_area_chart.ts @@ -505,7 +505,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should show error when calendar interval invalid', async () => { - await PageObjects.visEditor.setInterval('14d', { type: 'custom' }); + await PageObjects.visEditor.setInterval('2w', { type: 'custom' }); const intervalErrorMessage = await find.byCssSelector( '[data-test-subj="visEditorInterval"] + .euiFormErrorText' ); diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js index c2b414ddbd845..9c881702ba3c6 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js @@ -12,10 +12,11 @@ at createWorker (//node_modules/brace/index.js:17992:5) */ import { stubWebWorker } from '@kbn/test/jest'; // eslint-disable-line no-unused-vars +import { act } from 'react-dom/test-utils'; import { getFollowerIndexMock } from './fixtures/follower_index'; import './mocks'; -import { setupEnvironment, pageHelpers, nextTick, delay, getRandomString } from './helpers'; +import { setupEnvironment, pageHelpers, getRandomString } from './helpers'; const { setup } = pageHelpers.followerIndexList; @@ -53,9 +54,10 @@ describe('', () => { let component; beforeEach(async () => { - ({ exists, component } = setup()); + await act(async () => { + ({ exists, component } = setup()); + }); - await nextTick(); // We need to wait next tick for the mock server response to comes in component.update(); }); @@ -91,15 +93,15 @@ describe('', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadFollowerIndicesResponse({ indices: followerIndices }); - // Mount the component - ({ component, table, actions, form } = setup()); + await act(async () => { + ({ component, table, actions, form } = setup()); + }); - await nextTick(); // Make sure that the http request is fulfilled component.update(); }); - test('pagination works', () => { - actions.clickPaginationNextButton(); + test('pagination works', async () => { + await actions.clickPaginationNextButton(); const { tableCellsValues } = table.getMetaData('followerIndexListTable'); // Pagination defaults to 20 follower indices per page. We loaded 30 follower indices, @@ -134,21 +136,16 @@ describe('', () => { httpRequestsMockHelpers.setLoadFollowerIndicesResponse({ indices: followerIndices }); // Mount the component - ({ find, exists, component, table, actions } = setup()); + await act(async () => { + ({ find, exists, component, table, actions } = setup()); + }); - await nextTick(); // Make sure that the Http request is fulfilled component.update(); // Read the index list table ({ tableCellsValues } = table.getMetaData('followerIndexListTable')); }); - afterEach(async () => { - // The updates are not all synchronouse - // We need to wait for all the updates to ran before unmounting our component - await delay(100); - }); - test('should not display the empty prompt', () => { expect(exists('emptyPrompt')).toBe(false); }); @@ -173,17 +170,17 @@ describe('', () => { }); describe('action menu', () => { - test('should be visible when a follower index is selected', () => { + test('should be visible when a follower index is selected', async () => { expect(exists('contextMenuButton')).toBe(false); - actions.selectFollowerIndexAt(0); + await actions.selectFollowerIndexAt(0); expect(exists('contextMenuButton')).toBe(true); }); test('should have a "pause", "edit" and "unfollow" action when the follower index is active', async () => { - actions.selectFollowerIndexAt(0); - actions.openContextMenu(); + await actions.selectFollowerIndexAt(0); + await actions.openContextMenu(); const contextMenu = find('contextMenu'); @@ -199,8 +196,8 @@ describe('', () => { }); test('should have a "resume", "edit" and "unfollow" action when the follower index is active', async () => { - actions.selectFollowerIndexAt(1); // Select the second follower that is "paused" - actions.openContextMenu(); + await actions.selectFollowerIndexAt(1); // Select the second follower that is "paused" + await actions.openContextMenu(); const contextMenu = find('contextMenu'); @@ -213,22 +210,22 @@ describe('', () => { ]); }); - test('should open a confirmation modal when clicking on "pause replication"', () => { + test('should open a confirmation modal when clicking on "pause replication"', async () => { expect(exists('pauseReplicationConfirmation')).toBe(false); - actions.selectFollowerIndexAt(0); - actions.openContextMenu(); - actions.clickContextMenuButtonAt(0); // first button is the "pause" action + await actions.selectFollowerIndexAt(0); + await actions.openContextMenu(); + await actions.clickContextMenuButtonAt(0); // first button is the "pause" action expect(exists('pauseReplicationConfirmation')).toBe(true); }); - test('should open a confirmation modal when clicking on "unfollow leader index"', () => { + test('should open a confirmation modal when clicking on "unfollow leader index"', async () => { expect(exists('unfollowLeaderConfirmation')).toBe(false); - actions.selectFollowerIndexAt(0); - actions.openContextMenu(); - actions.clickContextMenuButtonAt(2); // third button is the "unfollow" action + await actions.selectFollowerIndexAt(0); + await actions.openContextMenu(); + await actions.clickContextMenuButtonAt(2); // third button is the "unfollow" action expect(exists('unfollowLeaderConfirmation')).toBe(true); }); @@ -238,13 +235,13 @@ describe('', () => { test('should open a context menu when clicking on the button of each row', async () => { expect(component.find('.euiContextMenuPanel').length).toBe(0); - actions.openTableRowContextMenuAt(0); + await actions.openTableRowContextMenuAt(0); expect(component.find('.euiContextMenuPanel').length).toBe(1); }); test('should have the "pause", "edit" and "unfollow" options in the row context menu', async () => { - actions.openTableRowContextMenuAt(0); + await actions.openTableRowContextMenuAt(0); const buttonLabels = component .find('.euiContextMenuPanel') @@ -260,7 +257,7 @@ describe('', () => { test('should have the "resume", "edit" and "unfollow" options in the row context menu', async () => { // We open the context menu of the second row (index 1) as followerIndices[1].status is "paused" - actions.openTableRowContextMenuAt(1); + await actions.openTableRowContextMenuAt(1); const buttonLabels = component .find('.euiContextMenuPanel') @@ -277,8 +274,13 @@ describe('', () => { test('should open a confirmation modal when clicking on "pause replication"', async () => { expect(exists('pauseReplicationConfirmation')).toBe(false); - actions.openTableRowContextMenuAt(0); - find('pauseButton').simulate('click'); + await actions.openTableRowContextMenuAt(0); + + await act(async () => { + find('pauseButton').simulate('click'); + }); + + component.update(); expect(exists('pauseReplicationConfirmation')).toBe(true); }); @@ -286,51 +288,60 @@ describe('', () => { test('should open a confirmation modal when clicking on "resume"', async () => { expect(exists('resumeReplicationConfirmation')).toBe(false); - actions.openTableRowContextMenuAt(1); // open the second row context menu, as it is a "paused" follower index - find('resumeButton').simulate('click'); + await actions.openTableRowContextMenuAt(1); // open the second row context menu, as it is a "paused" follower index + + await act(async () => { + find('resumeButton').simulate('click'); + }); + + component.update(); expect(exists('resumeReplicationConfirmation')).toBe(true); }); - test('should open a confirmation modal when clicking on "unfollow leader index"', () => { + test('should open a confirmation modal when clicking on "unfollow leader index"', async () => { expect(exists('unfollowLeaderConfirmation')).toBe(false); - actions.openTableRowContextMenuAt(0); - find('unfollowButton').simulate('click'); + await actions.openTableRowContextMenuAt(0); + + await act(async () => { + find('unfollowButton').simulate('click'); + }); + + component.update(); expect(exists('unfollowLeaderConfirmation')).toBe(true); }); }); - // FLAKY: https://github.com/elastic/kibana/issues/75124 - describe.skip('detail panel', () => { - test('should open a detail panel when clicking on a follower index', () => { + describe('detail panel', () => { + test('should open a detail panel when clicking on a follower index', async () => { expect(exists('followerIndexDetail')).toBe(false); - actions.clickFollowerIndexAt(0); + await actions.clickFollowerIndexAt(0); expect(exists('followerIndexDetail')).toBe(true); }); - test('should set the title the index that has been selected', () => { - actions.clickFollowerIndexAt(0); // Open the detail panel + test('should set the title the index that has been selected', async () => { + await actions.clickFollowerIndexAt(0); // Open the detail panel expect(find('followerIndexDetail.title').text()).toEqual(index1.name); }); - test('should indicate the correct "status", "remote cluster" and "leader index"', () => { - actions.clickFollowerIndexAt(0); + test('should indicate the correct "status", "remote cluster" and "leader index"', async () => { + await actions.clickFollowerIndexAt(0); expect(find('followerIndexDetail.status').text()).toEqual(index1.status); expect(find('followerIndexDetail.remoteCluster').text()).toEqual(index1.remoteCluster); expect(find('followerIndexDetail.leaderIndex').text()).toEqual(index1.leaderIndex); }); - test('should have a "settings" section', () => { - actions.clickFollowerIndexAt(0); + test('should have a "settings" section', async () => { + await actions.clickFollowerIndexAt(0); expect(find('followerIndexDetail.settingsSection').find('h3').text()).toEqual('Settings'); expect(exists('followerIndexDetail.settingsValues')).toBe(true); }); - test('should set the correct follower index settings values', () => { + test('should set the correct follower index settings values', async () => { const mapSettingsToFollowerIndexProp = { maxReadReqOpCount: 'maxReadRequestOperationCount', maxOutstandingReadReq: 'maxOutstandingReadRequests', @@ -344,7 +355,7 @@ describe('', () => { readPollTimeout: 'readPollTimeout', }; - actions.clickFollowerIndexAt(0); + await actions.clickFollowerIndexAt(0); Object.entries(mapSettingsToFollowerIndexProp).forEach(([setting, prop]) => { const wrapper = find(`settingsValues.${setting}`); @@ -357,21 +368,21 @@ describe('', () => { }); }); - test('should not have settings values for a "paused" follower index', () => { - actions.clickFollowerIndexAt(1); // the second follower index is paused + test('should not have settings values for a "paused" follower index', async () => { + await actions.clickFollowerIndexAt(1); // the second follower index is paused expect(exists('followerIndexDetail.settingsValues')).toBe(false); expect(find('followerIndexDetail.settingsSection').text()).toContain( 'paused follower index does not have settings' ); }); - test('should have a section to render the follower index shards stats', () => { - actions.clickFollowerIndexAt(0); + test('should have a section to render the follower index shards stats', async () => { + await actions.clickFollowerIndexAt(0); expect(exists('followerIndexDetail.shardsStatsSection')).toBe(true); }); - test('should render a EuiCodeEditor for each shards stats', () => { - actions.clickFollowerIndexAt(0); + test('should render a EuiCodeEditor for each shards stats', async () => { + await actions.clickFollowerIndexAt(0); const codeEditors = component.find(`EuiCodeEditor`); diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_list.helpers.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_list.helpers.js index d1fd205c683c0..5e6a48413b18b 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_list.helpers.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_list.helpers.js @@ -4,6 +4,7 @@ * 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 { registerTestBed, findTestSubject } from '@kbn/test/jest'; import { FollowerIndicesList } from '../../../app/sections/home/follower_indices_list'; @@ -37,24 +38,44 @@ export const setup = (props) => { * User Actions */ - const selectFollowerIndexAt = (index = 0) => { - const { rows } = testBed.table.getMetaData(EUI_TABLE); + const selectFollowerIndexAt = async (index = 0) => { + const { table, component } = testBed; + const { rows } = table.getMetaData(EUI_TABLE); const row = rows[index]; const checkBox = row.reactWrapper.find('input').hostNodes(); - checkBox.simulate('change', { target: { checked: true } }); + + await act(async () => { + checkBox.simulate('change', { target: { checked: true } }); + }); + + component.update(); }; - const openContextMenu = () => { - testBed.find('contextMenuButton').simulate('click'); + const openContextMenu = async () => { + const { find, component } = testBed; + + await act(async () => { + find('contextMenuButton').simulate('click'); + }); + + component.update(); }; - const clickContextMenuButtonAt = (index = 0) => { - const contextMenu = testBed.find('contextMenu'); - contextMenu.find('button').at(index).simulate('click'); + const clickContextMenuButtonAt = async (index = 0) => { + const { find, component } = testBed; + + const contextMenu = find('contextMenu'); + + await act(async () => { + contextMenu.find('button').at(index).simulate('click'); + }); + + component.update(); }; - const openTableRowContextMenuAt = (index = 0) => { - const { rows } = testBed.table.getMetaData(EUI_TABLE); + const openTableRowContextMenuAt = async (index = 0) => { + const { table, component } = testBed; + const { rows } = table.getMetaData(EUI_TABLE); const actionsColumnIndex = rows[0].columns.length - 1; // Actions are in the last column const actionsTableCell = rows[index].columns[actionsColumnIndex]; const button = actionsTableCell.reactWrapper.find('button'); @@ -63,17 +84,34 @@ export const setup = (props) => { `No button to open context menu were found on Follower index list table row ${index}` ); } - button.simulate('click'); + + await act(async () => { + button.simulate('click'); + }); + + component.update(); }; - const clickFollowerIndexAt = (index = 0) => { - const { rows } = testBed.table.getMetaData(EUI_TABLE); + const clickFollowerIndexAt = async (index = 0) => { + const { table, component } = testBed; + const { rows } = table.getMetaData(EUI_TABLE); const followerIndexLink = findTestSubject(rows[index].reactWrapper, 'followerIndexLink'); - followerIndexLink.simulate('click'); + + await act(async () => { + followerIndexLink.simulate('click'); + }); + + component.update(); }; - const clickPaginationNextButton = () => { - testBed.find('followerIndexListTable.pagination-button-next').simulate('click'); + const clickPaginationNextButton = async () => { + const { find, component } = testBed; + + await act(async () => { + find('followerIndexListTable.pagination-button-next').simulate('click'); + }); + + component.update(); }; return { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/utils.ts index d9f41f8558b78..13cad349d75b0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/tables/utils.ts @@ -10,7 +10,7 @@ import { EngineDetails } from '../../../engine/types'; export const getConflictingEnginesFromConflictingField = ( conflictingField: SchemaConflictFieldTypes -): string[] => Object.values(conflictingField).flat(); +) => Object.values(conflictingField).flat() as string[]; export const getConflictingEnginesFromSchemaConflicts = ( schemaConflicts: SchemaConflicts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/constants.ts index b9c4e2c0454d5..0ee428e87873e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/constants.ts @@ -10,3 +10,18 @@ import { i18n } from '@kbn/i18n'; export const SCHEMA_TITLE = i18n.translate('xpack.enterpriseSearch.appSearch.engine.schema.title', { defaultMessage: 'Schema', }); + +export const ADD_SCHEMA_ERROR = (fieldName: string) => + i18n.translate('xpack.enterpriseSearch.appSearch.engine.schema.addSchemaErrorMessage', { + defaultMessage: 'Field name already exists: {fieldName}', + values: { fieldName }, + }); +export const ADD_SCHEMA_SUCCESS = (fieldName: string) => + i18n.translate('xpack.enterpriseSearch.appSearch.engine.schema.addSchemaSuccessMessage', { + defaultMessage: 'New field added: {fieldName}', + values: { fieldName }, + }); +export const UPDATE_SCHEMA_SUCCESS = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.schema.updateSchemaSuccessMessage', + { defaultMessage: 'Schema updated' } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_base_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_base_logic.test.ts new file mode 100644 index 0000000000000..e5dbf97b971d9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_base_logic.test.ts @@ -0,0 +1,98 @@ +/* + * 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 { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__'; +import '../../__mocks__/engine_logic.mock'; + +import { nextTick } from '@kbn/test/jest'; + +import { SchemaType } from '../../../shared/schema/types'; + +import { SchemaBaseLogic } from './schema_base_logic'; + +describe('SchemaBaseLogic', () => { + const { mount } = new LogicMounter(SchemaBaseLogic); + const { http } = mockHttpValues; + const { flashAPIErrors } = mockFlashMessageHelpers; + + const MOCK_SCHEMA = { + some_text_field: SchemaType.Text, + some_number_field: SchemaType.Number, + }; + const MOCK_RESPONSE = { + schema: MOCK_SCHEMA, + } as any; + + const DEFAULT_VALUES = { + dataLoading: true, + schema: {}, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has expected default values', () => { + mount(); + expect(SchemaBaseLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('onSchemaLoad', () => { + it('stores schema state and sets dataLoading to false', () => { + mount({ schema: {}, dataLoading: true }); + + SchemaBaseLogic.actions.onSchemaLoad(MOCK_RESPONSE); + + expect(SchemaBaseLogic.values).toEqual({ + ...DEFAULT_VALUES, + dataLoading: false, + schema: MOCK_SCHEMA, + }); + }); + }); + + describe('setSchema', () => { + it('updates schema state', () => { + mount({ schema: {} }); + + SchemaBaseLogic.actions.setSchema(MOCK_SCHEMA); + + expect(SchemaBaseLogic.values).toEqual({ + ...DEFAULT_VALUES, + schema: MOCK_SCHEMA, + }); + }); + }); + }); + + describe('listeners', () => { + describe('loadSchema', () => { + it('should make an API call and then set schema state', async () => { + http.get.mockReturnValueOnce(Promise.resolve(MOCK_RESPONSE)); + mount(); + jest.spyOn(SchemaBaseLogic.actions, 'onSchemaLoad'); + + SchemaBaseLogic.actions.loadSchema(); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/schema'); + expect(SchemaBaseLogic.actions.onSchemaLoad).toHaveBeenCalledWith(MOCK_RESPONSE); + }); + + it('handles errors', async () => { + http.get.mockReturnValueOnce(Promise.reject('error')); + mount(); + + SchemaBaseLogic.actions.loadSchema(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_base_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_base_logic.ts new file mode 100644 index 0000000000000..c2196c01d402b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_base_logic.ts @@ -0,0 +1,66 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; + +import { flashAPIErrors } from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; +import { Schema } from '../../../shared/schema/types'; +import { EngineLogic } from '../engine'; + +import { SchemaApiResponse, MetaEngineSchemaApiResponse } from './types'; + +export interface SchemaBaseValues { + dataLoading: boolean; + schema: Schema; +} + +export interface SchemaBaseActions { + loadSchema(): void; + onSchemaLoad( + response: SchemaApiResponse | MetaEngineSchemaApiResponse + ): SchemaApiResponse | MetaEngineSchemaApiResponse; + setSchema(schema: Schema): { schema: Schema }; +} + +export const SchemaBaseLogic = kea>({ + path: ['enterprise_search', 'app_search', 'schema_base_logic'], + actions: { + loadSchema: true, + onSchemaLoad: (response) => response, + setSchema: (schema) => ({ schema }), + }, + reducers: { + dataLoading: [ + true, + { + loadSchema: () => true, + onSchemaLoad: () => false, + }, + ], + schema: [ + {}, + { + onSchemaLoad: (_, { schema }) => schema, + setSchema: (_, { schema }) => schema, + }, + ], + }, + listeners: ({ actions }) => ({ + loadSchema: async () => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + const response = await http.get(`/api/app_search/engines/${engineName}/schema`); + actions.onSchemaLoad(response); + } catch (e) { + flashAPIErrors(e); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.test.ts new file mode 100644 index 0000000000000..123f62af4eeba --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.test.ts @@ -0,0 +1,291 @@ +/* + * 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 { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__'; +import { mockEngineActions } from '../../__mocks__/engine_logic.mock'; + +import { nextTick } from '@kbn/test/jest'; + +import { SchemaType, Schema } from '../../../shared/schema/types'; + +import { SchemaLogic } from './schema_logic'; + +describe('SchemaLogic', () => { + const { mount } = new LogicMounter(SchemaLogic); + const { http } = mockHttpValues; + const { flashAPIErrors, flashSuccessToast, setErrorMessage } = mockFlashMessageHelpers; + + const MOCK_RESPONSE = { + schema: { + some_text_field: SchemaType.Text, + some_number_field: SchemaType.Number, + }, + mostRecentIndexJob: { + percentageComplete: 100, + numDocumentsWithErrors: 10, + activeReindexJobId: 'some-id', + isActive: false, + hasErrors: true, + }, + unconfirmedFields: ['some_field'], + unsearchedUnconfirmedFields: true, + }; + + const DEFAULT_VALUES = { + dataLoading: true, + schema: {}, + isUpdating: false, + hasSchema: false, + hasSchemaChanged: false, + cachedSchema: {}, + mostRecentIndexJob: {}, + unconfirmedFields: [], + hasUnconfirmedFields: false, + hasNewUnsearchedFields: false, + isModalOpen: false, + }; + + /* + * Unfortunately, we can't mount({ schema: ... }) & have to use an action to set schema + * because of the separate connected logic file - our LogicMounter test helper sets context + * for only the currently tested file + */ + const mountAndSetSchema = ({ schema, ...values }: { schema: Schema; [key: string]: unknown }) => { + mount(values); + SchemaLogic.actions.setSchema(schema); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has expected default values', () => { + mount(); + expect(SchemaLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('onSchemaLoad', () => { + it('stores the API response in state and sets isUpdating & isModalOpen to false', () => { + mount({ isModalOpen: true }); + + SchemaLogic.actions.onSchemaLoad(MOCK_RESPONSE); + + expect(SchemaLogic.values).toEqual({ + ...DEFAULT_VALUES, + // SchemaBaseLogic + dataLoading: false, + schema: MOCK_RESPONSE.schema, + // SchemaLogic + isUpdating: false, + isModalOpen: false, + cachedSchema: MOCK_RESPONSE.schema, + hasSchema: true, + mostRecentIndexJob: MOCK_RESPONSE.mostRecentIndexJob, + unconfirmedFields: MOCK_RESPONSE.unconfirmedFields, + hasUnconfirmedFields: true, + hasNewUnsearchedFields: MOCK_RESPONSE.unsearchedUnconfirmedFields, + }); + }); + }); + + describe('onSchemaUpdateError', () => { + it('sets isUpdating & isModalOpen to false', () => { + mount({ isUpdating: true, isModalOpen: true }); + + SchemaLogic.actions.onSchemaUpdateError(); + + expect(SchemaLogic.values).toEqual({ + ...DEFAULT_VALUES, + isUpdating: false, + isModalOpen: false, + }); + }); + }); + + describe('openModal', () => { + it('sets isModalOpen to true', () => { + mount({ isModalOpen: false }); + + SchemaLogic.actions.openModal(); + + expect(SchemaLogic.values).toEqual({ + ...DEFAULT_VALUES, + isModalOpen: true, + }); + }); + }); + + describe('closeModal', () => { + it('sets isModalOpen to false', () => { + mount({ isModalOpen: true }); + + SchemaLogic.actions.closeModal(); + + expect(SchemaLogic.values).toEqual({ + ...DEFAULT_VALUES, + isModalOpen: false, + }); + }); + }); + }); + + describe('selectors', () => { + describe('hasSchema', () => { + it('returns true when the schema obj has items', () => { + mountAndSetSchema({ schema: { test: SchemaType.Text } }); + expect(SchemaLogic.values.hasSchema).toEqual(true); + }); + + it('returns false when the schema obj is empty', () => { + mountAndSetSchema({ schema: {} }); + expect(SchemaLogic.values.hasSchema).toEqual(false); + }); + }); + + describe('hasSchemaChanged', () => { + it('returns true when the schema state is different from the cachedSchema state', () => { + mountAndSetSchema({ + schema: { test: SchemaType.Text }, + cachedSchema: { test: SchemaType.Number }, + }); + + expect(SchemaLogic.values.hasSchemaChanged).toEqual(true); + }); + + it('returns false when the stored schema is the same as cachedSchema', () => { + mountAndSetSchema({ + schema: { test: SchemaType.Text }, + cachedSchema: { test: SchemaType.Text }, + }); + + expect(SchemaLogic.values.hasSchemaChanged).toEqual(false); + }); + }); + + describe('hasUnconfirmedFields', () => { + it('returns true when the unconfirmedFields array has items', () => { + mount({ unconfirmedFields: ['hello_world'] }); + expect(SchemaLogic.values.hasUnconfirmedFields).toEqual(true); + }); + + it('returns false when the unconfirmedFields array is empty', () => { + mount({ unconfirmedFields: [] }); + expect(SchemaLogic.values.hasUnconfirmedFields).toEqual(false); + }); + }); + }); + + describe('listeners', () => { + describe('addSchemaField', () => { + describe('if the schema field already exists', () => { + it('flashes an error and closes the modal', () => { + mountAndSetSchema({ schema: { existing_field: SchemaType.Text } }); + jest.spyOn(SchemaLogic.actions, 'closeModal'); + + SchemaLogic.actions.addSchemaField('existing_field', SchemaType.Text); + + expect(setErrorMessage).toHaveBeenCalledWith('Field name already exists: existing_field'); + expect(SchemaLogic.actions.closeModal).toHaveBeenCalled(); + }); + }); + + describe('if the schema field does not already exist', () => { + it('updates the schema state and calls updateSchema with a custom success message', () => { + mount(); + jest.spyOn(SchemaLogic.actions, 'setSchema'); + jest.spyOn(SchemaLogic.actions, 'updateSchema'); + + SchemaLogic.actions.addSchemaField('new_field', SchemaType.Date); + + expect(SchemaLogic.actions.setSchema).toHaveBeenCalledWith({ + new_field: SchemaType.Date, + }); + expect(SchemaLogic.actions.updateSchema).toHaveBeenCalledWith( + 'New field added: new_field' + ); + }); + }); + }); + + describe('updateSchemaFieldType', () => { + it("updates an existing schema key's field type value", async () => { + mountAndSetSchema({ schema: { existing_field: SchemaType.Text } }); + jest.spyOn(SchemaLogic.actions, 'setSchema'); + + SchemaLogic.actions.updateSchemaFieldType('existing_field', SchemaType.Geolocation); + + expect(SchemaLogic.actions.setSchema).toHaveBeenCalledWith({ + existing_field: SchemaType.Geolocation, + }); + }); + }); + + describe('updateSchema', () => { + it('sets isUpdating to true', () => { + mount({ isUpdating: false }); + + SchemaLogic.actions.updateSchema(); + + expect(SchemaLogic.values).toEqual({ + ...DEFAULT_VALUES, + isUpdating: true, + }); + }); + + it('should make an API call and then set schema state', async () => { + http.post.mockReturnValueOnce(Promise.resolve(MOCK_RESPONSE)); + mount(); + jest.spyOn(SchemaLogic.actions, 'onSchemaLoad'); + + SchemaLogic.actions.updateSchema(); + await nextTick(); + + expect(http.post).toHaveBeenCalledWith('/api/app_search/engines/some-engine/schema', { + body: '{}', + }); + expect(SchemaLogic.actions.onSchemaLoad).toHaveBeenCalledWith(MOCK_RESPONSE); + }); + + it('should call flashSuccessToast with a custom success message if passed', async () => { + http.post.mockReturnValueOnce(Promise.resolve(MOCK_RESPONSE)); + mount(); + + SchemaLogic.actions.updateSchema('wow it worked!!'); + await nextTick(); + + expect(flashSuccessToast).toHaveBeenCalledWith('wow it worked!!'); + }); + + it('should always call EngineLogic.actions.initializeEngine to refresh engine-wide state', async () => { + mount(); + + SchemaLogic.actions.updateSchema(); + await nextTick(); + + expect(mockEngineActions.initializeEngine).toHaveBeenCalled(); + }); + + it('handles errors and resets bad schema state back to cached/server values', async () => { + const MOCK_ERROR = 'Fields cannot contain more than 64 characters'; + const MOCK_CACHED_SCHEMA = { ok_field: SchemaType.Text }; + + http.post.mockReturnValueOnce(Promise.reject(MOCK_ERROR)); + mount({ cachedSchema: MOCK_CACHED_SCHEMA }); + jest.spyOn(SchemaLogic.actions, 'onSchemaUpdateError'); + jest.spyOn(SchemaLogic.actions, 'setSchema'); + + SchemaLogic.actions.updateSchema(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith(MOCK_ERROR); + expect(SchemaLogic.actions.onSchemaUpdateError).toHaveBeenCalled(); + expect(SchemaLogic.actions.setSchema).toHaveBeenCalledWith(MOCK_CACHED_SCHEMA); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.ts new file mode 100644 index 0000000000000..3215a46c8e299 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_logic.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; +import { isEqual } from 'lodash'; + +import { + flashAPIErrors, + setErrorMessage, + flashSuccessToast, + clearFlashMessages, +} from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; +import { Schema, SchemaType, IndexJob } from '../../../shared/schema/types'; +import { EngineLogic } from '../engine'; + +import { ADD_SCHEMA_ERROR, ADD_SCHEMA_SUCCESS, UPDATE_SCHEMA_SUCCESS } from './constants'; +import { SchemaBaseLogic, SchemaBaseValues, SchemaBaseActions } from './schema_base_logic'; +import { SchemaApiResponse } from './types'; + +interface SchemaValues extends SchemaBaseValues { + isUpdating: boolean; + hasSchema: boolean; + hasSchemaChanged: boolean; + cachedSchema: Schema; + mostRecentIndexJob: Partial; + unconfirmedFields: string[]; + hasUnconfirmedFields: boolean; + hasNewUnsearchedFields: boolean; + isModalOpen: boolean; +} + +interface SchemaActions extends SchemaBaseActions { + onSchemaLoad(response: SchemaApiResponse): SchemaApiResponse; + addSchemaField( + fieldName: string, + fieldType: SchemaType + ): { fieldName: string; fieldType: SchemaType }; + updateSchemaFieldType( + fieldName: string, + fieldType: SchemaType + ): { fieldName: string; fieldType: SchemaType }; + updateSchema(successMessage?: string): string | undefined; + onSchemaUpdateError(): void; + openModal(): void; + closeModal(): void; +} + +export const SchemaLogic = kea>({ + path: ['enterprise_search', 'app_search', 'schema_logic'], + connect: { + values: [SchemaBaseLogic, ['dataLoading', 'schema']], + actions: [SchemaBaseLogic, ['loadSchema', 'onSchemaLoad', 'setSchema']], + }, + actions: { + addSchemaField: (fieldName, fieldType) => ({ fieldName, fieldType }), + updateSchemaFieldType: (fieldName, fieldType) => ({ fieldName, fieldType }), + updateSchema: (successMessage) => successMessage, + onSchemaUpdateError: true, + openModal: true, + closeModal: true, + }, + reducers: { + isUpdating: [ + false, + { + updateSchema: () => true, + onSchemaLoad: () => false, + onSchemaUpdateError: () => false, + }, + ], + cachedSchema: [ + {}, + { + onSchemaLoad: (_, { schema }) => schema, + }, + ], + mostRecentIndexJob: [ + {}, + { + onSchemaLoad: (_, { mostRecentIndexJob }) => mostRecentIndexJob, + }, + ], + unconfirmedFields: [ + [], + { + onSchemaLoad: (_, { unconfirmedFields }) => unconfirmedFields, + }, + ], + hasNewUnsearchedFields: [ + false, + { + onSchemaLoad: (_, { unsearchedUnconfirmedFields }) => unsearchedUnconfirmedFields, + }, + ], + isModalOpen: [ + false, + { + openModal: () => true, + closeModal: () => false, + onSchemaLoad: () => false, + onSchemaUpdateError: () => false, + }, + ], + }, + selectors: { + hasSchema: [(selectors) => [selectors.schema], (schema) => Object.keys(schema).length > 0], + hasSchemaChanged: [ + (selectors) => [selectors.schema, selectors.cachedSchema], + (schema, cachedSchema) => !isEqual(schema, cachedSchema), + ], + hasUnconfirmedFields: [ + (selectors) => [selectors.unconfirmedFields], + (unconfirmedFields) => unconfirmedFields.length > 0, + ], + }, + listeners: ({ actions, values }) => ({ + addSchemaField: ({ fieldName, fieldType }) => { + if (values.schema.hasOwnProperty(fieldName)) { + setErrorMessage(ADD_SCHEMA_ERROR(fieldName)); + actions.closeModal(); + } else { + const updatedSchema = { ...values.schema }; + updatedSchema[fieldName] = fieldType; + actions.setSchema(updatedSchema); + actions.updateSchema(ADD_SCHEMA_SUCCESS(fieldName)); + } + }, + updateSchemaFieldType: ({ fieldName, fieldType }) => { + const updatedSchema = { ...values.schema }; + updatedSchema[fieldName] = fieldType; + actions.setSchema(updatedSchema); + }, + updateSchema: async (successMessage) => { + const { schema } = values; + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + clearFlashMessages(); + + try { + const response = await http.post(`/api/app_search/engines/${engineName}/schema`, { + body: JSON.stringify(schema), + }); + actions.onSchemaLoad(response); + flashSuccessToast(successMessage || UPDATE_SCHEMA_SUCCESS); + } catch (e) { + flashAPIErrors(e); + actions.onSchemaUpdateError(); + // Restore updated schema back to server/cached schema, so we don't keep + // erroneous or bad fields in-state + actions.setSchema(values.cachedSchema); + } finally { + // Re-fetch engine data so that other views also dynamically update + // (e.g. Documents results, nav icons for invalid boosts or unconfirmed flags) + EngineLogic.actions.initializeEngine(); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_meta_engine_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_meta_engine_logic.test.ts new file mode 100644 index 0000000000000..f265fb2d74113 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_meta_engine_logic.test.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. + */ + +import { LogicMounter } from '../../../__mocks__'; + +import { SchemaType } from '../../../shared/schema/types'; + +import { MetaEngineSchemaLogic } from './schema_meta_engine_logic'; + +describe('MetaEngineSchemaLogic', () => { + const { mount } = new LogicMounter(MetaEngineSchemaLogic); + + const MOCK_RESPONSE = { + schema: { + some_text_field: SchemaType.Text, + some_number_field: SchemaType.Number, + }, + fields: { + some_text_field: { + text: ['source-engine-a', 'source-engine-b'], + }, + }, + conflictingFields: { + some_number_field: { + number: ['source-engine-a'], + text: ['source-engine-b'], + }, + }, + }; + + const DEFAULT_VALUES = { + dataLoading: true, + schema: {}, + fields: {}, + conflictingFields: {}, + conflictingFieldsCount: 0, + hasConflicts: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has expected default values', () => { + mount(); + expect(MetaEngineSchemaLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('onSchemaLoad', () => { + it('stores the API response in state', () => { + mount(); + + MetaEngineSchemaLogic.actions.onSchemaLoad(MOCK_RESPONSE); + + expect(MetaEngineSchemaLogic.values).toEqual({ + ...DEFAULT_VALUES, + // SchemaBaseLogic + dataLoading: false, + schema: MOCK_RESPONSE.schema, + // MetaEngineSchemaLogic + fields: MOCK_RESPONSE.fields, + conflictingFields: MOCK_RESPONSE.conflictingFields, + hasConflicts: true, + conflictingFieldsCount: 1, + }); + }); + }); + }); + + describe('selectors', () => { + describe('conflictingFieldsCount', () => { + it('returns the number of conflicting fields', () => { + mount({ conflictingFields: { field_a: {}, field_b: {} } }); + expect(MetaEngineSchemaLogic.values.conflictingFieldsCount).toEqual(2); + }); + }); + + describe('hasConflictingFields', () => { + it('returns true when the conflictingFields obj has items', () => { + mount({ conflictingFields: { field_c: {} } }); + expect(MetaEngineSchemaLogic.values.hasConflicts).toEqual(true); + }); + + it('returns false when the conflictingFields obj is empty', () => { + mount({ conflictingFields: {} }); + expect(MetaEngineSchemaLogic.values.hasConflicts).toEqual(false); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_meta_engine_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_meta_engine_logic.ts new file mode 100644 index 0000000000000..5c8ab0f4662e2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_meta_engine_logic.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { SchemaBaseLogic, SchemaBaseValues, SchemaBaseActions } from './schema_base_logic'; +import { MetaEngineSchemaApiResponse } from './types'; + +interface MetaEngineSchemaValues extends SchemaBaseValues { + fields: MetaEngineSchemaApiResponse['fields']; + conflictingFields: MetaEngineSchemaApiResponse['conflictingFields']; + conflictingFieldsCount: number; + hasConflicts: boolean; +} + +interface MetaEngineSchemaActions extends SchemaBaseActions { + onSchemaLoad(response: MetaEngineSchemaApiResponse): MetaEngineSchemaApiResponse; +} + +export const MetaEngineSchemaLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'app_search', 'meta_engine_schema_logic'], + connect: { + values: [SchemaBaseLogic, ['dataLoading', 'schema']], + actions: [SchemaBaseLogic, ['loadSchema', 'onSchemaLoad']], + }, + reducers: { + fields: [ + {}, + { + onSchemaLoad: (_, { fields }) => fields, + }, + ], + conflictingFields: [ + {}, + { + onSchemaLoad: (_, { conflictingFields }) => conflictingFields, + }, + ], + }, + selectors: { + conflictingFieldsCount: [ + (selectors) => [selectors.conflictingFields], + (conflictingFields) => Object.keys(conflictingFields).length, + ], + hasConflicts: [ + (selectors) => [selectors.conflictingFieldsCount], + (conflictingFieldsCount) => conflictingFieldsCount > 0, + ], + }, +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/types.ts new file mode 100644 index 0000000000000..9d79909e5549b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/types.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + Schema, + IndexJob, + SchemaConflicts, + FieldCoercionErrors, +} from '../../../shared/schema/types'; + +export interface SchemaApiResponse { + schema: Schema; + mostRecentIndexJob: IndexJob; + unconfirmedFields: string[]; + unsearchedUnconfirmedFields: boolean; +} + +export interface MetaEngineSchemaApiResponse { + schema: Schema; + fields: SchemaConflicts; + conflictingFields: SchemaConflicts; +} + +export interface ReindexJobApiResponse { + fieldCoercionErrors: FieldCoercionErrors; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.test.tsx index 8412af6455285..a6e9eef8efa70 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.test.tsx @@ -5,15 +5,29 @@ * 2.0. */ +import { setMockValues, setMockActions } from '../../../../__mocks__'; +import '../../../../__mocks__/shallow_useeffect.mock'; + import React from 'react'; import { shallow } from 'enzyme'; +import { Loading } from '../../../../shared/loading'; + import { MetaEngineSchema } from './'; describe('MetaEngineSchema', () => { + const values = { + dataLoading: false, + }; + const actions = { + loadSchema: jest.fn(), + }; + beforeEach(() => { jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); }); it('renders', () => { @@ -22,4 +36,17 @@ describe('MetaEngineSchema', () => { expect(wrapper.isEmptyRender()).toBe(false); // TODO: Check for schema components }); + + it('calls loadSchema on mount', () => { + shallow(); + + expect(actions.loadSchema).toHaveBeenCalled(); + }); + + it('renders a loading state', () => { + setMockValues({ ...values, dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.tsx index d79ddae3d9b78..234fcdb5a5a50 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/meta_engine_schema.tsx @@ -5,14 +5,28 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; + +import { useValues, useActions } from 'kea'; import { EuiPageHeader, EuiPageContentBody } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FlashMessages } from '../../../../shared/flash_messages'; +import { Loading } from '../../../../shared/loading'; + +import { MetaEngineSchemaLogic } from '../schema_meta_engine_logic'; export const MetaEngineSchema: React.FC = () => { + const { loadSchema } = useActions(MetaEngineSchemaLogic); + const { dataLoading } = useValues(MetaEngineSchemaLogic); + + useEffect(() => { + loadSchema(); + }, []); + + if (dataLoading) return ; + return ( <> { + const values = { + dataLoading: false, + }; + const actions = { + loadSchema: jest.fn(), + }; + beforeEach(() => { jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); }); it('renders', () => { @@ -25,6 +39,19 @@ describe('Schema', () => { // TODO: Check for schema components }); + it('calls loadSchema on mount', () => { + shallow(); + + expect(actions.loadSchema).toHaveBeenCalled(); + }); + + it('renders a loading state', () => { + setMockValues({ ...values, dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + it('renders page action buttons', () => { const wrapper = shallow() .find(EuiPageHeader) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx index ad53fd2c718b2..21dd52b04f4a7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx @@ -5,14 +5,28 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; + +import { useValues, useActions } from 'kea'; import { EuiPageHeader, EuiButton, EuiPageContentBody } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FlashMessages } from '../../../../shared/flash_messages'; +import { Loading } from '../../../../shared/loading'; + +import { SchemaLogic } from '../schema_logic'; export const Schema: React.FC = () => { + const { loadSchema } = useActions(SchemaLogic); + const { dataLoading } = useValues(SchemaLogic); + + useEffect(() => { + loadSchema(); + }, []); + + if (dataLoading) return ; + return ( <> ({ + generatePreviewUrl: jest.fn(), +})); + +import { setMockValues, setMockActions } from '../../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { ActiveField } from '../types'; +import { generatePreviewUrl } from '../utils'; + +import { SearchUIForm } from './search_ui_form'; + +describe('SearchUIForm', () => { + const values = { + validFields: ['title', 'url', 'category', 'size'], + validSortFields: ['title', 'url', 'category', 'size'], + validFacetFields: ['title', 'url', 'category', 'size'], + titleField: 'title', + urlField: 'url', + facetFields: ['category'], + sortFields: ['size'], + }; + const actions = { + onActiveFieldChange: jest.fn(), + onFacetFieldsChange: jest.fn(), + onSortFieldsChange: jest.fn(), + onTitleFieldChange: jest.fn(), + onUrlFieldChange: jest.fn(), + }; + + beforeAll(() => { + setMockValues(values); + setMockActions(actions); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find('[data-test-subj="selectTitle"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="selectFilters"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="selectSort"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="selectUrl"]').exists()).toBe(true); + }); + + describe('title field', () => { + const subject = () => shallow().find('[data-test-subj="selectTitle"]'); + + it('renders with its value set from state', () => { + setMockValues({ + ...values, + titleField: 'foo', + }); + + expect(subject().prop('value')).toBe('foo'); + }); + + it('updates state with new value when changed', () => { + subject().simulate('change', { target: { value: 'foo' } }); + expect(actions.onTitleFieldChange).toHaveBeenCalledWith('foo'); + }); + + it('updates active field in state on focus', () => { + subject().simulate('focus'); + expect(actions.onActiveFieldChange).toHaveBeenCalledWith(ActiveField.Title); + }); + + it('removes active field in state on blur', () => { + subject().simulate('blur'); + expect(actions.onActiveFieldChange).toHaveBeenCalledWith(ActiveField.None); + }); + }); + + describe('url field', () => { + const subject = () => shallow().find('[data-test-subj="selectUrl"]'); + + it('renders with its value set from state', () => { + setMockValues({ + ...values, + urlField: 'foo', + }); + + expect(subject().prop('value')).toBe('foo'); + }); + + it('updates state with new value when changed', () => { + subject().simulate('change', { target: { value: 'foo' } }); + expect(actions.onUrlFieldChange).toHaveBeenCalledWith('foo'); + }); + + it('updates active field in state on focus', () => { + subject().simulate('focus'); + expect(actions.onActiveFieldChange).toHaveBeenCalledWith(ActiveField.Url); + }); + + it('removes active field in state on blur', () => { + subject().simulate('blur'); + expect(actions.onActiveFieldChange).toHaveBeenCalledWith(ActiveField.None); + }); + }); + + describe('filters field', () => { + const subject = () => shallow().find('[data-test-subj="selectFilters"]'); + + it('renders with its value set from state', () => { + setMockValues({ + ...values, + facetFields: ['foo'], + }); + + expect(subject().prop('selectedOptions')).toEqual([ + { label: 'foo', text: 'foo', value: 'foo' }, + ]); + }); + + it('updates state with new value when changed', () => { + subject().simulate('change', [ + { label: 'foo', text: 'foo', value: 'foo' }, + { label: 'bar', text: 'bar', value: 'bar' }, + ]); + expect(actions.onFacetFieldsChange).toHaveBeenCalledWith(['foo', 'bar']); + }); + + it('updates active field in state on focus', () => { + subject().simulate('focus'); + expect(actions.onActiveFieldChange).toHaveBeenCalledWith(ActiveField.Filter); + }); + + it('removes active field in state on blur', () => { + subject().simulate('blur'); + expect(actions.onActiveFieldChange).toHaveBeenCalledWith(ActiveField.None); + }); + }); + + describe('sorts field', () => { + const subject = () => shallow().find('[data-test-subj="selectSort"]'); + + it('renders with its value set from state', () => { + setMockValues({ + ...values, + sortFields: ['foo'], + }); + + expect(subject().prop('selectedOptions')).toEqual([ + { label: 'foo', text: 'foo', value: 'foo' }, + ]); + }); + + it('updates state with new value when changed', () => { + subject().simulate('change', [ + { label: 'foo', text: 'foo', value: 'foo' }, + { label: 'bar', text: 'bar', value: 'bar' }, + ]); + expect(actions.onSortFieldsChange).toHaveBeenCalledWith(['foo', 'bar']); + }); + + it('updates active field in state on focus', () => { + subject().simulate('focus'); + expect(actions.onActiveFieldChange).toHaveBeenCalledWith(ActiveField.Sort); + }); + + it('removes active field in state on blur', () => { + subject().simulate('blur'); + expect(actions.onActiveFieldChange).toHaveBeenCalledWith(ActiveField.None); + }); + }); + + it('includes a link to generate the preview', () => { + (generatePreviewUrl as jest.Mock).mockReturnValue('http://www.example.com?foo=bar'); + + setMockValues({ + ...values, + urlField: 'foo', + titleField: 'bar', + facetFields: ['baz'], + sortFields: ['qux'], + }); + + const subject = () => + shallow().find('[data-test-subj="generateSearchUiPreview"]'); + + expect(subject().prop('href')).toBe('http://www.example.com?foo=bar'); + expect(generatePreviewUrl).toHaveBeenCalledWith({ + urlField: 'foo', + titleField: 'bar', + facets: ['baz'], + sortFields: ['qux'], + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_form.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_form.tsx new file mode 100644 index 0000000000000..15bd699be721c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_form.tsx @@ -0,0 +1,133 @@ +/* + * 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 { useValues, useActions } from 'kea'; + +import { EuiForm, EuiFormRow, EuiSelect, EuiComboBox, EuiButton } from '@elastic/eui'; + +import { + TITLE_FIELD_LABEL, + TITLE_FIELD_HELP_TEXT, + FILTER_FIELD_LABEL, + FILTER_FIELD_HELP_TEXT, + SORT_FIELD_LABEL, + SORT_FIELD_HELP_TEXT, + URL_FIELD_LABEL, + URL_FIELD_HELP_TEXT, + GENERATE_PREVIEW_BUTTON_LABEL, +} from '../i18n'; +import { SearchUILogic } from '../search_ui_logic'; +import { ActiveField } from '../types'; +import { generatePreviewUrl } from '../utils'; + +export const SearchUIForm: React.FC = () => { + const { + validFields, + validSortFields, + validFacetFields, + titleField, + urlField, + facetFields, + sortFields, + } = useValues(SearchUILogic); + const { + onActiveFieldChange, + onFacetFieldsChange, + onSortFieldsChange, + onTitleFieldChange, + onUrlFieldChange, + } = useActions(SearchUILogic); + + const previewHref = generatePreviewUrl({ + titleField, + urlField, + facets: facetFields, + sortFields, + }); + + const formatSelectOption = (fieldName: string) => { + return { text: fieldName, value: fieldName }; + }; + const formatMultiOptions = (fieldNames: string[]) => + fieldNames.map((fieldName) => ({ label: fieldName, text: fieldName, value: fieldName })); + const formatMultiOptionsWithEmptyOption = (fieldNames: string[]) => [ + { label: '', text: '', value: '' }, + ...formatMultiOptions(fieldNames), + ]; + + const optionFields = formatMultiOptionsWithEmptyOption(validFields); + const sortOptionFields = formatMultiOptions(validSortFields); + const facetOptionFields = formatMultiOptions(validFacetFields); + const selectedTitleOption = formatSelectOption(titleField); + const selectedURLOption = formatSelectOption(urlField); + const selectedSortOptions = formatMultiOptions(sortFields); + const selectedFacetOptions = formatMultiOptions(facetFields); + + return ( + + + onTitleFieldChange(e.target.value)} + fullWidth + onFocus={() => onActiveFieldChange(ActiveField.Title)} + onBlur={() => onActiveFieldChange(ActiveField.None)} + hasNoInitialSelection + data-test-subj="selectTitle" + /> + + + onFacetFieldsChange(newValues.map((field) => field.value!))} + onFocus={() => onActiveFieldChange(ActiveField.Filter)} + onBlur={() => onActiveFieldChange(ActiveField.None)} + fullWidth + data-test-subj="selectFilters" + /> + + + onSortFieldsChange(newValues.map((field) => field.value!))} + onFocus={() => onActiveFieldChange(ActiveField.Sort)} + onBlur={() => onActiveFieldChange(ActiveField.None)} + fullWidth + data-test-subj="selectSort" + /> + + + + onUrlFieldChange(e.target.value)} + fullWidth + onFocus={() => onActiveFieldChange(ActiveField.Url)} + onBlur={() => onActiveFieldChange(ActiveField.None)} + hasNoInitialSelection + data-test-subj="selectUrl" + /> + + + {GENERATE_PREVIEW_BUTTON_LABEL} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_graphic.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_graphic.scss new file mode 100644 index 0000000000000..cc49789358a28 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_graphic.scss @@ -0,0 +1,188 @@ +.searchUIGraphic { + transform-style: preserve-3d; + transform: rotate3d(0, 1, 0, -8deg); + + #search-area { + .outerBox { + fill: $euiColorLightShade; + } + .field { + fill: $euiColorEmptyShade; + } + .searchIcon { + fill: $euiColorDarkShade; + } + .type { + fill: $euiColorDarkShade; + } + } + #background { + fill: $euiColorLightestShade; + } + #results { + .outerBox { + fill: $euiColorEmptyShade; + stroke: $euiColorLightShade; + stroke-width: $euiBorderWidthThin; + } + .shoe { + fill: $euiColorMediumShade; + } + .url { + fill: $euiColorDarkShade; + transform: translateY(5px); + } + .titleCopy { + fill: $euiColorDarkShade; + } + .titleBox { + fill: $euiColorEmptyShade; + } + .blockIn { + fill: $euiColorLightShade; + } + } + #filter { + .outerBox { + fill: $euiColorEmptyShade; + stroke: $euiColorLightShade; + stroke-width: 1px; + } + .header { + fill: $euiColorDarkShade; + } + .checkbox { + fill: $euiColorDarkShade; + } + .check { + fill: $euiColorEmptyShade; + } + .filterCopy { + fill: $euiColorDarkShade; + } + } + #sort { + .outerBox { + fill: $euiColorEmptyShade; + stroke: $euiColorLightShade; + stroke-width: 1px; + } + .header { + fill: $euiColorDarkShade; + } + .selectCopy { + fill: $euiColorDarkShade; + } + .selectBox { + fill: $euiColorEmptyShade; + stroke: $euiColorLightShade; + stroke-width: 1px; + } + .selectControl { + fill: $euiColorDarkShade; + } + } + #pagination { + .outerBox { + fill: $euiColorLightShade; + } + .arrow { + fill: $euiColorEmptyShade; + } + } + + &.activeTitle { + #results { + .titleBox { + fill: $euiColorEmptyShade; + stroke: $euiColorPrimary; + stroke-width: 1px; + } + .titleCopy { + fill: $euiColorPrimary; + } + .outerBox { + fill: $euiColorEmptyShade; + stroke: $euiColorPrimary; + } + .url { + fill: $euiColorPrimary; + opacity: .1; + } + .shoe { + fill: $euiColorPrimary; + opacity: .1; + } + } + } + + &.activeUrl { + #results { + .outerBox { + fill: $euiColorEmptyShade; + stroke: $euiColorPrimary; + stroke-width: 1px; + } + .url { + fill: $euiColorPrimary; + } + .titleBox { + fill: $euiColorEmptyShade; + } + .titleCopy { + fill: $euiColorPrimary; + opacity: .1; + } + .shoe { + fill: $euiColorPrimary; + opacity: .1; + } + } + } + + &.activeFilter { + #filter { + .outerBox { + fill: $euiColorEmptyShade; + stroke: $euiColorPrimary; + stroke-width: $euiBorderWidthThick; + } + .header { + fill: $euiColorPrimary; + } + .checkbox { + fill: $euiColorPrimary; + } + .check { + fill: $euiColorEmptyShade; + } + .filterCopy { + fill: $euiColorPrimary; + } + } + } + + &.activeSort { + #sort { + .outerBox { + fill: $euiColorEmptyShade; + stroke: $euiColorPrimary; + stroke-width: 2px; + } + .header { + fill: $euiColorPrimary; + } + .selectCopy { + fill: $euiColorPrimary; + } + .selectBox { + fill: $euiColorEmptyShade; + stroke: $euiColorPrimary; + stroke-width: 1px; + } + .selectControl { + fill: $euiColorPrimary; + } + } + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_graphic.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_graphic.test.tsx new file mode 100644 index 0000000000000..0326cef9a2455 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_graphic.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockValues } from '../../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { ActiveField } from '../types'; + +import { SearchUIGraphic } from './search_ui_graphic'; + +describe('SearchUIGraphic', () => { + const values = { + activeField: ActiveField.Sort, + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + }); + + it('renders an svg with a className determined by the currently active field', () => { + const wrapper = shallow(); + expect(wrapper.hasClass('activeSort')).toBe(true); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_graphic.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_graphic.tsx new file mode 100644 index 0000000000000..02b7ac3423227 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_graphic.tsx @@ -0,0 +1,232 @@ +/* + * 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 { useValues } from 'kea'; + +import { SearchUILogic } from '../search_ui_logic'; + +import './search_ui_graphic.scss'; + +export const SearchUIGraphic: React.FC = () => { + const { activeField } = useValues(SearchUILogic); + const svgClass = 'searchUIGraphic'; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/i18n.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/i18n.ts new file mode 100644 index 0000000000000..dfdc1d1db4c39 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/i18n.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SEARCH_UI_TITLE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.searchUI.title', + { defaultMessage: 'Search UI' } +); + +export const TITLE_FIELD_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.searchUI.titleFieldLabel', + { defaultMessage: 'Title field (Optional)' } +); +export const TITLE_FIELD_HELP_TEXT = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.searchUI.titleFieldHelpText', + { defaultMessage: 'Used as the top-level visual identifier for every rendered result' } +); +export const FILTER_FIELD_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.searchUI.filterFieldLabel', + { defaultMessage: 'Filter fields (Optional)' } +); +export const FILTER_FIELD_HELP_TEXT = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.searchUI.filterFieldHelpText', + { defaultMessage: 'Faceted values rendered as filters and available as query refinement' } +); +export const SORT_FIELD_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.searchUI.sortFieldLabel', + { defaultMessage: 'Sort fields (Optional)' } +); +export const SORT_FIELD_HELP_TEXT = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.searchUI.sortHelpText', + { defaultMessage: 'Used to display result sorting options, ascending and descending' } +); +export const URL_FIELD_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.searchUI.urlFieldLabel', + { defaultMessage: 'URL field (Optional)' } +); +export const URL_FIELD_HELP_TEXT = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.searchUI.urlFieldHelpText', + { defaultMessage: "Used as a result's link target, if applicable" } +); +export const GENERATE_PREVIEW_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.searchUI.generatePreviewButtonLabel', + { defaultMessage: 'Generate search experience' } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/index.ts index 5de1224a9f28a..e87606fa04c65 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/index.ts @@ -5,6 +5,6 @@ * 2.0. */ -export { SEARCH_UI_TITLE } from './constants'; +export { SEARCH_UI_TITLE } from './i18n'; export { SearchUI } from './search_ui'; export { SearchUILogic } from './search_ui_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx index d4e4d72e4740a..e75bc36177151 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui.tsx @@ -9,14 +9,26 @@ import React, { useEffect } from 'react'; import { useActions } from 'kea'; -import { EuiPageHeader, EuiPageContentBody } from '@elastic/eui'; +import { + EuiPageHeader, + EuiPageContentBody, + EuiText, + EuiFlexItem, + EuiFlexGroup, + EuiSpacer, + EuiLink, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { DOCS_PREFIX } from '../../routes'; import { getEngineBreadcrumbs } from '../engine'; -import { SEARCH_UI_TITLE } from './constants'; +import { SearchUIForm } from './components/search_ui_form'; +import { SearchUIGraphic } from './components/search_ui_graphic'; +import { SEARCH_UI_TITLE } from './i18n'; import { SearchUILogic } from './search_ui_logic'; export const SearchUI: React.FC = () => { @@ -31,7 +43,51 @@ export const SearchUI: React.FC = () => { - TODO + + + + +

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

+

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

+
+ + +
+ + + +
+
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui_logic.test.ts index 29261f3a4031f..2191c633131bb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui_logic.test.ts @@ -78,10 +78,10 @@ describe('SearchUILogic', () => { }); }); - describe('onURLFieldChange', () => { + describe('onUrlFieldChange', () => { it('sets the urlField value', () => { mount({ urlField: '' }); - SearchUILogic.actions.onURLFieldChange('foo'); + SearchUILogic.actions.onUrlFieldChange('foo'); expect(SearchUILogic.values).toEqual({ ...DEFAULT_VALUES, urlField: 'foo', @@ -128,6 +128,10 @@ describe('SearchUILogic', () => { validFields: ['test'], validSortFields: ['test'], validFacetFields: ['test'], + defaultValues: { + urlField: 'url', + titleField: 'title', + }, }; describe('loadFieldData', () => { @@ -142,7 +146,13 @@ describe('SearchUILogic', () => { expect(http.get).toHaveBeenCalledWith( '/api/app_search/engines/engine1/search_ui/field_config' ); - expect(SearchUILogic.actions.onFieldDataLoaded).toHaveBeenCalledWith(MOCK_RESPONSE); + expect(SearchUILogic.actions.onFieldDataLoaded).toHaveBeenCalledWith({ + validFields: ['test'], + validSortFields: ['test'], + validFacetFields: ['test'], + urlField: 'url', + titleField: 'title', + }); }); it('handles errors', async () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui_logic.ts index 7b3454c9e8413..c9e2e5623d9fd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui_logic.ts @@ -17,6 +17,8 @@ interface InitialFieldValues { validFields: string[]; validSortFields: string[]; validFacetFields: string[]; + urlField?: string; + titleField?: string; } interface SearchUIActions { loadFieldData(): void; @@ -25,7 +27,7 @@ interface SearchUIActions { onFacetFieldsChange(facetFields: string[]): { facetFields: string[] }; onSortFieldsChange(sortFields: string[]): { sortFields: string[] }; onTitleFieldChange(titleField: string): { titleField: string }; - onURLFieldChange(urlField: string): { urlField: string }; + onUrlFieldChange(urlField: string): { urlField: string }; } interface SearchUIValues { @@ -49,7 +51,7 @@ export const SearchUILogic = kea> onFacetFieldsChange: (facetFields) => ({ facetFields }), onSortFieldsChange: (sortFields) => ({ sortFields }), onTitleFieldChange: (titleField) => ({ titleField }), - onURLFieldChange: (urlField) => ({ urlField }), + onUrlFieldChange: (urlField) => ({ urlField }), }), reducers: () => ({ dataLoading: [ @@ -61,8 +63,20 @@ export const SearchUILogic = kea> validFields: [[], { onFieldDataLoaded: (_, { validFields }) => validFields }], validSortFields: [[], { onFieldDataLoaded: (_, { validSortFields }) => validSortFields }], validFacetFields: [[], { onFieldDataLoaded: (_, { validFacetFields }) => validFacetFields }], - titleField: ['', { onTitleFieldChange: (_, { titleField }) => titleField }], - urlField: ['', { onURLFieldChange: (_, { urlField }) => urlField }], + titleField: [ + '', + { + onTitleFieldChange: (_, { titleField }) => titleField, + onFieldDataLoaded: (_, { titleField }) => titleField || '', + }, + ], + urlField: [ + '', + { + onUrlFieldChange: (_, { urlField }) => urlField, + onFieldDataLoaded: (_, { urlField }) => urlField || '', + }, + ], facetFields: [[], { onFacetFieldsChange: (_, { facetFields }) => facetFields }], sortFields: [[], { onSortFieldsChange: (_, { sortFields }) => sortFields }], activeField: [ActiveField.None, { onActiveFieldChange: (_, { activeField }) => activeField }], @@ -76,8 +90,20 @@ export const SearchUILogic = kea> try { const initialFieldValues = await http.get(url); + const { + defaultValues: { urlField, titleField }, + validFields, + validSortFields, + validFacetFields, + } = initialFieldValues; - actions.onFieldDataLoaded(initialFieldValues); + actions.onFieldDataLoaded({ + validFields, + validSortFields, + validFacetFields, + urlField, + titleField, + }); } catch (e) { flashAPIErrors(e); } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/types.ts index 132ce46bc13fb..224aeab05af35 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/types.ts @@ -6,9 +6,9 @@ */ export enum ActiveField { - Title, - Filter, - Sort, - Url, - None, + Title = 'Title', + Filter = 'Filter', + Sort = 'Sort', + Url = 'Url', + None = '', } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/utils.test.ts new file mode 100644 index 0000000000000..550573367c824 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/utils.test.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 '../../../__mocks__/enterprise_search_url.mock'; + +import { generatePreviewUrl } from './utils'; + +jest.mock('../engine', () => ({ + EngineLogic: { + values: { + engineName: 'national-parks-demo', + }, + }, +})); + +describe('generatePreviewUrl', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('generates a url to the preview application from state', () => { + expect( + generatePreviewUrl({ + titleField: 'foo', + urlField: 'bar', + facets: ['baz', 'qux'], + sortFields: ['quux', 'quuz'], + empty: '', // Empty fields should be stripped + empty2: [''], // Empty fields should be stripped + }) + ).toEqual( + 'http://localhost:3002/as/engines/national-parks-demo/reference_application/preview?facets[]=baz&facets[]=qux&fromKibana=true&sortFields[]=quux&sortFields[]=quuz&titleField=foo&urlField=bar' + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/utils.ts new file mode 100644 index 0000000000000..0bdc03037ae38 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/utils.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import queryString, { ParsedQuery } from 'query-string'; + +import { getAppSearchUrl } from '../../../shared/enterprise_search_url'; +import { EngineLogic } from '../engine'; + +export const generatePreviewUrl = (query: ParsedQuery) => { + const { engineName } = EngineLogic.values; + return queryString.stringifyUrl( + { + query: { + ...query, + fromKibana: 'true', + }, + url: getAppSearchUrl(`/engines/${engineName}/reference_application/preview`), + }, + { arrayFormat: 'bracket', skipEmptyString: true } + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/schema/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/schema/types.ts index 916478a0d9ccf..5a423ad510af1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/schema/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/schema/types.ts @@ -31,7 +31,7 @@ export type Schema = Record; // This is a mapping of schema field types ("text", "number", "geolocation", "date") // to the names of source engines which utilize that type -export type SchemaConflictFieldTypes = Record; +export type SchemaConflictFieldTypes = Partial>; export interface SchemaConflict { fieldTypes: SchemaConflictFieldTypes; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts index 6ecdb8d8857c6..99aaaeeec38b3 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts @@ -16,6 +16,7 @@ import { registerEnginesRoutes } from './engines'; import { registerOnboardingRoutes } from './onboarding'; import { registerResultSettingsRoutes } from './result_settings'; import { registerRoleMappingsRoutes } from './role_mappings'; +import { registerSchemaRoutes } from './schema'; import { registerSearchSettingsRoutes } from './search_settings'; import { registerSearchUIRoutes } from './search_ui'; import { registerSettingsRoutes } from './settings'; @@ -28,6 +29,7 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerAnalyticsRoutes(dependencies); registerDocumentsRoutes(dependencies); registerDocumentRoutes(dependencies); + registerSchemaRoutes(dependencies); registerCurationsRoutes(dependencies); registerSynonymsRoutes(dependencies); registerSearchSettingsRoutes(dependencies); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/schema.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/schema.test.ts new file mode 100644 index 0000000000000..408838a4de31b --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/schema.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; + +import { registerSchemaRoutes } from './schema'; + +describe('schema routes', () => { + describe('GET /api/app_search/engines/{engineName}/schema', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{engineName}/schema', + }); + + registerSchemaRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:engineName/schema', + }); + }); + }); + + describe('POST /api/app_search/engines/{engineName}/schema', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/engines/{engineName}/schema', + }); + + registerSchemaRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:engineName/schema', + }); + }); + }); + + describe('GET /api/app_search/engines/{engineName}/reindex_job/{reindexJobId}', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{engineName}/reindex_job/{reindexJobId}', + }); + + registerSchemaRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:engineName/reindex_job/:reindexJobId', + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/schema.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/schema.ts new file mode 100644 index 0000000000000..74d07bd2bf75d --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/schema.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { skipBodyValidation } from '../../lib/route_config_helpers'; +import { RouteDependencies } from '../../plugin'; + +export function registerSchemaRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/app_search/engines/{engineName}/schema', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/:engineName/schema', + }) + ); + + router.post( + skipBodyValidation({ + path: '/api/app_search/engines/{engineName}/schema', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + }, + }), + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/:engineName/schema', + }) + ); + + router.get( + { + path: '/api/app_search/engines/{engineName}/reindex_job/{reindexJobId}', + validate: { + params: schema.object({ + engineName: schema.string(), + reindexJobId: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/:engineName/reindex_job/:reindexJobId', + }) + ); +} diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/choropleth_map.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/choropleth_map.tsx new file mode 100644 index 0000000000000..acbb77a7e0cac --- /dev/null +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/choropleth_map.tsx @@ -0,0 +1,126 @@ +/* + * 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, { FC, useMemo } from 'react'; +import { EuiFlexItem, EuiSpacer, EuiText, htmlIdGenerator } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + FIELD_ORIGIN, + SOURCE_TYPES, + STYLE_TYPE, + COLOR_MAP_TYPE, +} from '../../../../../../../maps/common/constants'; +import { EMSTermJoinConfig } from '../../../../../../../maps/public'; +import { FieldVisStats } from '../../types'; +import { VectorLayerDescriptor } from '../../../../../../../maps/common/descriptor_types'; +import { EmbeddedMapComponent } from '../../../embedded_map'; + +export const getChoroplethTopValuesLayer = ( + fieldName: string, + topValues: Array<{ key: any; doc_count: number }>, + { layerId, field }: EMSTermJoinConfig +): VectorLayerDescriptor => { + return { + id: htmlIdGenerator()(), + label: i18n.translate('xpack.fileDataVisualizer.choroplethMap.topValuesCount', { + defaultMessage: 'Top values count for {fieldName}', + values: { fieldName }, + }), + joins: [ + { + // Left join is the id from the type of field (e.g. world_countries) + leftField: field, + right: { + id: 'anomaly_count', + type: SOURCE_TYPES.TABLE_SOURCE, + __rows: topValues, + __columns: [ + { + name: 'key', + type: 'string', + }, + { + name: 'doc_count', + type: 'number', + }, + ], + // Right join/term is the field in the doc you’re trying to join it to (foreign key - e.g. US) + term: 'key', + }, + }, + ], + sourceDescriptor: { + type: 'EMS_FILE', + id: layerId, + }, + style: { + type: 'VECTOR', + // @ts-ignore missing style properties. Remove once 'VectorLayerDescriptor' type is updated + properties: { + icon: { type: STYLE_TYPE.STATIC, options: { value: 'marker' } }, + fillColor: { + type: STYLE_TYPE.DYNAMIC, + options: { + color: 'Blue to Red', + colorCategory: 'palette_0', + fieldMetaOptions: { isEnabled: true, sigma: 3 }, + type: COLOR_MAP_TYPE.ORDINAL, + field: { + name: 'doc_count', + origin: FIELD_ORIGIN.JOIN, + }, + useCustomColorRamp: false, + }, + }, + lineColor: { + type: STYLE_TYPE.DYNAMIC, + options: { fieldMetaOptions: { isEnabled: true } }, + }, + lineWidth: { type: STYLE_TYPE.STATIC, options: { size: 1 } }, + }, + isTimeAware: true, + }, + type: 'VECTOR', + }; +}; + +interface Props { + stats: FieldVisStats | undefined; + suggestion: EMSTermJoinConfig; +} + +export const ChoroplethMap: FC = ({ stats, suggestion }) => { + const { fieldName, isTopValuesSampled, topValues, topValuesSamplerShardSize } = stats!; + + const layerList: VectorLayerDescriptor[] = useMemo( + () => [getChoroplethTopValuesLayer(fieldName || '', topValues || [], suggestion)], + [suggestion, fieldName, topValues] + ); + + return ( + +
+ +
+ {isTopValuesSampled === true && ( + <> + + + + + + )} +
+ ); +}; diff --git a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/keyword_content.tsx b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/keyword_content.tsx index 3f1a7aad5463f..6448883bfce73 100644 --- a/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/keyword_content.tsx +++ b/x-pack/plugins/file_data_visualizer/public/application/components/stats_table/components/field_data_expanded_row/keyword_content.tsx @@ -5,21 +5,55 @@ * 2.0. */ -import React, { FC } from 'react'; +import React, { FC, useCallback, useEffect, useState } from 'react'; import type { FieldDataRowProps } from '../../types/field_data_row'; import { TopValues } from '../../../top_values'; +import { EMSTermJoinConfig } from '../../../../../../../maps/public'; +import { useFileDataVisualizerKibana } from '../../../../kibana_context'; import { DocumentStatsTable } from './document_stats'; import { ExpandedRowContent } from './expanded_row_content'; +import { ChoroplethMap } from './choropleth_map'; + +const COMMON_EMS_LAYER_IDS = [ + 'world_countries', + 'administrative_regions_lvl2', + 'usa_zip_codes', + 'usa_states', +]; export const KeywordContent: FC = ({ config }) => { - const { stats } = config; + const [EMSSuggestion, setEMSSuggestion] = useState(); + const { stats, fieldName } = config; const fieldFormat = 'fieldFormat' in config ? config.fieldFormat : undefined; + const { + services: { maps: mapsPlugin }, + } = useFileDataVisualizerKibana(); + + const loadEMSTermSuggestions = useCallback(async () => { + if (!mapsPlugin) return; + const suggestion: EMSTermJoinConfig | null = await mapsPlugin.suggestEMSTermJoinConfig({ + emsLayerIds: COMMON_EMS_LAYER_IDS, + sampleValues: Array.isArray(stats?.topValues) + ? stats?.topValues.map((value) => value.key) + : [], + sampleValuesColumnName: fieldName || '', + }); + setEMSSuggestion(suggestion); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fieldName]); + + useEffect( + function getInitialEMSTermSuggestion() { + loadEMSTermSuggestions(); + }, + [loadEMSTermSuggestions] + ); return ( - + {EMSSuggestion && stats && } ); }; diff --git a/x-pack/plugins/index_management/__jest__/components/index_table.test.js b/x-pack/plugins/index_management/__jest__/components/index_table.test.js index 6b8aa5b445af2..4ac94319d4711 100644 --- a/x-pack/plugins/index_management/__jest__/components/index_table.test.js +++ b/x-pack/plugins/index_management/__jest__/components/index_table.test.js @@ -196,6 +196,20 @@ describe('index table', () => { button = findTestSubject(rendered, 'indexActionsContextMenuButton'); expect(button.length).toEqual(1); }); + test('should update the Actions menu button text when more than one row is selected', () => { + const rendered = mountWithIntl(component); + let button = findTestSubject(rendered, 'indexTableContextMenuButton'); + expect(button.length).toEqual(0); + const checkboxes = findTestSubject(rendered, 'indexTableRowCheckbox'); + checkboxes.at(0).simulate('change', { target: { checked: true } }); + rendered.update(); + button = findTestSubject(rendered, 'indexActionsContextMenuButton'); + expect(button.text()).toEqual('Manage index'); + checkboxes.at(1).simulate('change', { target: { checked: true } }); + rendered.update(); + button = findTestSubject(rendered, 'indexActionsContextMenuButton'); + expect(button.text()).toEqual('Manage 2 indices'); + }); test('should show system indices only when the switch is turned on', () => { const rendered = mountWithIntl(component); snapshot(rendered.find('.euiPagination li').map((item) => item.text())); diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_actions_context_menu/index_actions_context_menu.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_actions_context_menu/index_actions_context_menu.js index 20a4af59bab11..944992fbc9146 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_actions_context_menu/index_actions_context_menu.js +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_actions_context_menu/index_actions_context_menu.js @@ -696,7 +696,8 @@ export class IndexActionsContextMenu extends Component { iconSide = 'right', anchorPosition = 'rightUp', label = i18n.translate('xpack.idxMgmt.indexActionsMenu.manageButtonLabel', { - defaultMessage: 'Manage {selectedIndexCount, plural, one {index} other {indices}}', + defaultMessage: + 'Manage {selectedIndexCount, plural, one {index} other {{selectedIndexCount} indices}}', values: { selectedIndexCount }, }), iconType = 'arrowDown', diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx index 534132eb75fa1..2e79e7e4ab8c4 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx @@ -9,6 +9,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import useInterval from 'react-use/lib/useInterval'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { SavedView } from '../../../../containers/saved_view/saved_view'; import { AutoSizer } from '../../../../components/auto_sizer'; import { convertIntervalToString } from '../../../../utils/convert_interval_to_string'; import { NodesOverview } from './nodes_overview'; @@ -26,182 +27,191 @@ import { ViewSwitcher } from './waffle/view_switcher'; import { IntervalLabel } from './waffle/interval_label'; import { createInventoryMetricFormatter } from '../lib/create_inventory_metric_formatter'; import { createLegend } from '../lib/create_legend'; -import { useSavedViewContext } from '../../../../containers/saved_view/saved_view'; import { useWaffleViewState } from '../hooks/use_waffle_view_state'; import { SavedViewsToolbarControls } from '../../../../components/saved_views/toolbar_control'; import { BottomDrawer } from './bottom_drawer'; import { Legend } from './waffle/legend'; -export const Layout = () => { - const [showLoading, setShowLoading] = useState(true); - const { sourceId, source } = useSourceContext(); - const { currentView, shouldLoadDefault } = useSavedViewContext(); - const { - metric, - groupBy, - sort, - nodeType, - accountId, - region, - changeView, - view, - autoBounds, - boundsOverride, - legend, - } = useWaffleOptionsContext(); - const { currentTime, jumpToTime, isAutoReloading } = useWaffleTimeContext(); - const { filterQueryAsJson, applyFilterQuery } = useWaffleFiltersContext(); - const { loading, nodes, reload, interval } = useSnapshot( - filterQueryAsJson, - [metric], - groupBy, - nodeType, - sourceId, - currentTime, - accountId, - region, - false - ); - - const legendPalette = legend?.palette ?? DEFAULT_LEGEND.palette; - const legendSteps = legend?.steps ?? DEFAULT_LEGEND.steps; - const legendReverseColors = legend?.reverseColors ?? DEFAULT_LEGEND.reverseColors; - - const options = { - formatter: InfraFormatterType.percent, - formatTemplate: '{{value}}', - legend: createLegend(legendPalette, legendSteps, legendReverseColors), - metric, - sort, - fields: source?.configuration?.fields, - groupBy, - }; - - useInterval( - () => { - if (!loading) { - jumpToTime(Date.now()); - } - }, - isAutoReloading ? 5000 : null - ); - - const intervalAsString = convertIntervalToString(interval); - const dataBounds = calculateBoundsFromNodes(nodes); - const bounds = autoBounds ? dataBounds : boundsOverride; - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const formatter = useCallback(createInventoryMetricFormatter(options.metric), [options.metric]); - const { viewState, onViewChange } = useWaffleViewState(); - - useEffect(() => { - if (currentView) { - onViewChange(currentView); - } - }, [currentView, onViewChange]); - - useEffect(() => { - // load snapshot data after default view loaded, unless we're not loading a view - if (currentView != null || !shouldLoadDefault) { - reload(); - } - - /** - * INFO: why disable exhaustive-deps - * We need to wait on the currentView not to be null because it is loaded async and could change the view state. - * We don't actually need to watch the value of currentView though, since the view state will be synched up by the - * changing params in the reload method so we should only "watch" the reload method. - * - * TODO: Should refactor this in the future to make it more clear where all the view state is coming - * from and it's precedence [query params, localStorage, defaultView, out of the box view] - */ +export const Layout = React.memo( + ({ + shouldLoadDefault, + currentView, + }: { + shouldLoadDefault: boolean; + currentView: SavedView | null; + }) => { + const [showLoading, setShowLoading] = useState(true); + const { sourceId, source } = useSourceContext(); + const { + metric, + groupBy, + sort, + nodeType, + accountId, + region, + changeView, + view, + autoBounds, + boundsOverride, + legend, + } = useWaffleOptionsContext(); + const { currentTime, jumpToTime, isAutoReloading } = useWaffleTimeContext(); + const { filterQueryAsJson, applyFilterQuery } = useWaffleFiltersContext(); + const { loading, nodes, reload, interval } = useSnapshot( + filterQueryAsJson, + [metric], + groupBy, + nodeType, + sourceId, + currentTime, + accountId, + region, + false + ); + + const legendPalette = legend?.palette ?? DEFAULT_LEGEND.palette; + const legendSteps = legend?.steps ?? DEFAULT_LEGEND.steps; + const legendReverseColors = legend?.reverseColors ?? DEFAULT_LEGEND.reverseColors; + + const options = { + formatter: InfraFormatterType.percent, + formatTemplate: '{{value}}', + legend: createLegend(legendPalette, legendSteps, legendReverseColors), + metric, + sort, + fields: source?.configuration?.fields, + groupBy, + }; + + useInterval( + () => { + if (!loading) { + jumpToTime(Date.now()); + } + }, + isAutoReloading ? 5000 : null + ); + + const intervalAsString = convertIntervalToString(interval); + const dataBounds = calculateBoundsFromNodes(nodes); + const bounds = autoBounds ? dataBounds : boundsOverride; /* eslint-disable-next-line react-hooks/exhaustive-deps */ - }, [reload, shouldLoadDefault]); - - useEffect(() => { - setShowLoading(true); - }, [options.metric, nodeType]); - - useEffect(() => { - const hasNodes = nodes && nodes.length; - // Don't show loading screen when we're auto-reloading - setShowLoading(!hasNodes); - }, [nodes]); - - return ( - <> - - - {({ measureRef: pageMeasureRef, bounds: { width = 0 } }) => ( - - - {({ measureRef: topActionMeasureRef, bounds: { height: topActionHeight = 0 } }) => ( - <> - - - - - - - - - - - - - - - - - {({ measureRef, bounds: { height = 0 } }) => ( - <> - - {view === 'map' && ( - { + if (currentView) { + onViewChange(currentView); + } + }, [currentView, onViewChange]); + + useEffect(() => { + // load snapshot data after default view loaded, unless we're not loading a view + if (currentView != null || !shouldLoadDefault) { + reload(); + } + + /** + * INFO: why disable exhaustive-deps + * We need to wait on the currentView not to be null because it is loaded async and could change the view state. + * We don't actually need to watch the value of currentView though, since the view state will be synched up by the + * changing params in the reload method so we should only "watch" the reload method. + * + * TODO: Should refactor this in the future to make it more clear where all the view state is coming + * from and it's precedence [query params, localStorage, defaultView, out of the box view] + */ + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [reload, shouldLoadDefault]); + + useEffect(() => { + setShowLoading(true); + }, [options.metric, nodeType]); + + useEffect(() => { + const hasNodes = nodes && nodes.length; + // Don't show loading screen when we're auto-reloading + setShowLoading(!hasNodes); + }, [nodes]); + + return ( + <> + + + {({ measureRef: pageMeasureRef, bounds: { width = 0 } }) => ( + + + {({ + measureRef: topActionMeasureRef, + bounds: { height: topActionHeight = 0 }, + }) => ( + <> + + + + + + + + + + + + + + + + + {({ measureRef, bounds: { height = 0 } }) => ( + <> + - + {view === 'map' && ( + - - )} - - )} - - - )} - - - )} - - - - ); -}; + width={width} + > + + + )} + + )} + + + )} + + + )} + + + + ); + } +); const MainContainer = euiStyled.div` position: relative; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout_view.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout_view.tsx new file mode 100644 index 0000000000000..1e66fe22ac45e --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout_view.tsx @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useSavedViewContext } from '../../../../containers/saved_view/saved_view'; +import { Layout } from './layout'; + +export const LayoutView = () => { + const { shouldLoadDefault, currentView } = useSavedViewContext(); + return ; +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx index 57073fee13c18..81a8d1ae8d294 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx @@ -21,7 +21,7 @@ import { ViewSourceConfigurationButton } from '../../../components/source_config import { Source } from '../../../containers/metrics_source'; import { useTrackPageview } from '../../../../../observability/public'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { Layout } from './components/layout'; +import { LayoutView } from './components/layout_view'; import { useLinkProps } from '../../../hooks/use_link_props'; import { SavedViewProvider } from '../../../containers/saved_view/saved_view'; import { DEFAULT_WAFFLE_VIEW_STATE } from './hooks/use_waffle_view_state'; @@ -69,7 +69,7 @@ export const SnapshotPage = () => { viewType={'inventory-view'} defaultViewState={DEFAULT_WAFFLE_VIEW_STATE} > - + ) : hasFailedLoadingSource ? ( diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/append.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/append.test.tsx new file mode 100644 index 0000000000000..af3651525d6a3 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/append.test.tsx @@ -0,0 +1,118 @@ +/* + * 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 { setup, SetupResult, getProcessorValue } from './processor.helpers'; + +const APPEND_TYPE = 'append'; + +describe('Processor: Append', () => { + let onUpdate: jest.Mock; + let testBed: SetupResult; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(async () => { + onUpdate = jest.fn(); + + await act(async () => { + testBed = await setup({ + value: { + processors: [], + }, + onFlyoutOpen: jest.fn(), + onUpdate, + }); + }); + testBed.component.update(); + const { + actions: { addProcessor, addProcessorType }, + } = testBed; + // Open the processor flyout + addProcessor(); + + // Add type (the other fields are not visible until a type is selected) + await addProcessorType(APPEND_TYPE); + }); + + test('prevents form submission if required fields are not provided', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Click submit button with only the type defined + await saveNewProcessor(); + + // Expect form error as "field" and "value" are required parameters + expect(form.getErrorsMessages()).toEqual([ + 'A field value is required.', + 'A value is required.', + ]); + }); + + test('saves with required parameter values', async () => { + const { + actions: { saveNewProcessor }, + form, + find, + component, + } = testBed; + + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + + await act(async () => { + find('appendValueField.input').simulate('change', [{ label: 'Some_Value' }]); + }); + component.update(); + + // Save the field + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, APPEND_TYPE); + expect(processors[0].append).toEqual({ + field: 'field_1', + value: ['Some_Value'], + }); + }); + + test('allows optional parameters to be set', async () => { + const { + actions: { saveNewProcessor }, + form, + find, + component, + } = testBed; + + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + + // Set optional parameteres + await act(async () => { + find('appendValueField.input').simulate('change', [{ label: 'Some_Value' }]); + component.update(); + }); + + form.toggleEuiSwitch('ignoreFailureSwitch.input'); + // Save the field with new changes + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, APPEND_TYPE); + expect(processors[0].append).toEqual({ + field: 'field_1', + ignore_failure: true, + value: ['Some_Value'], + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/bytes.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/bytes.test.tsx index af4f6e468ca36..51117c3b517f9 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/bytes.test.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/bytes.test.tsx @@ -35,24 +35,21 @@ describe('Processor: Bytes', () => { }); }); testBed.component.update(); - }); - - test('prevents form submission if required fields are not provided', async () => { const { - actions: { addProcessor, saveNewProcessor, addProcessorType }, - form, + actions: { addProcessor, addProcessorType }, } = testBed; - - // Open flyout to add new processor + // Open the processor flyout addProcessor(); - // Click submit button without entering any fields - await saveNewProcessor(); - - // Expect form error as a processor type is required - expect(form.getErrorsMessages()).toEqual(['A type is required.']); // Add type (the other fields are not visible until a type is selected) await addProcessorType(BYTES_TYPE); + }); + + test('prevents form submission if required fields are not provided', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; // Click submit button with only the type defined await saveNewProcessor(); @@ -61,16 +58,12 @@ describe('Processor: Bytes', () => { expect(form.getErrorsMessages()).toEqual(['A field value is required.']); }); - test('saves with default parameter values', async () => { + test('saves with required parameter values', async () => { const { - actions: { addProcessor, saveNewProcessor, addProcessorType }, + actions: { saveNewProcessor }, form, } = testBed; - // Open flyout to add new processor - addProcessor(); - // Add type (the other fields are not visible until a type is selected) - await addProcessorType(BYTES_TYPE); // Add "field" value (required) form.setInputValue('fieldNameField.input', 'field_1'); // Save the field @@ -84,14 +77,10 @@ describe('Processor: Bytes', () => { test('allows optional parameters to be set', async () => { const { - actions: { addProcessor, addProcessorType, saveNewProcessor }, + actions: { saveNewProcessor }, form, } = testBed; - // Open flyout to add new processor - addProcessor(); - // Add type (the other fields are not visible until a type is selected) - await addProcessorType(BYTES_TYPE); // Add "field" value (required) form.setInputValue('fieldNameField.input', 'field_1'); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx index 8616241a43d8d..68f494a56af5d 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx @@ -137,6 +137,7 @@ type TestSubject = | 'addProcessorForm.submitButton' | 'addProcessorButton' | 'addProcessorForm.submitButton' + | 'appendValueField.input' | 'processorTypeSelector.input' | 'fieldNameField.input' | 'mockCodeEditor' diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx index 573adad3247f5..442788a7f75aa 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx @@ -12,8 +12,6 @@ import { setup, SetupResult, getProcessorValue } from './processor.helpers'; const defaultUriPartsParameters = { keep_original: undefined, remove_if_successful: undefined, - ignore_failure: undefined, - description: undefined, }; const URI_PARTS_TYPE = 'uri_parts'; @@ -43,19 +41,22 @@ describe('Processor: URI parts', () => { }); }); testBed.component.update(); - }); - test('prevents form submission if required fields are not provided', async () => { const { - actions: { addProcessor, saveNewProcessor, addProcessorType }, - form, + actions: { addProcessor, addProcessorType }, } = testBed; - - // Open flyout to add new processor + // Open the processor flyout addProcessor(); // Add type (the other fields are not visible until a type is selected) await addProcessorType(URI_PARTS_TYPE); + }); + + test('prevents form submission if required fields are not provided', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; // Click submit button with only the type defined await saveNewProcessor(); @@ -64,16 +65,12 @@ describe('Processor: URI parts', () => { expect(form.getErrorsMessages()).toEqual(['A field value is required.']); }); - test('saves with default parameter values', async () => { + test('saves with required parameter values', async () => { const { - actions: { addProcessor, saveNewProcessor, addProcessorType }, + actions: { saveNewProcessor }, form, } = testBed; - // Open flyout to add new processor - addProcessor(); - // Add type (the other fields are not visible until a type is selected) - await addProcessorType(URI_PARTS_TYPE); // Add "field" value (required) form.setInputValue('fieldNameField.input', 'field_1'); // Save the field @@ -88,14 +85,10 @@ describe('Processor: URI parts', () => { test('allows optional parameters to be set', async () => { const { - actions: { addProcessor, addProcessorType, saveNewProcessor }, + actions: { saveNewProcessor }, form, } = testBed; - // Open flyout to add new processor - addProcessor(); - // Add type (the other fields are not visible until a type is selected) - await addProcessorType(URI_PARTS_TYPE); // Add "field" value (required) form.setInputValue('fieldNameField.input', 'field_1'); @@ -109,9 +102,7 @@ describe('Processor: URI parts', () => { const processors = getProcessorValue(onUpdate, URI_PARTS_TYPE); expect(processors[0].uri_parts).toEqual({ - description: undefined, field: 'field_1', - ignore_failure: undefined, keep_original: false, remove_if_successful: true, target_field: 'target_field', diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/append.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/append.tsx index d10347b4d9655..fde39e7462009 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/append.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/append.tsx @@ -52,7 +52,12 @@ export const Append: FunctionComponent = () => { })} /> - + ); }; diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx index db781344eb6f8..a1474237f6c9c 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx @@ -782,7 +782,7 @@ export class VectorLayer extends AbstractLayer implements IVectorLayer { } const filterExpr = getPointFilterExpression(this.hasJoins()); - if (filterExpr !== mbMap.getFilter(pointLayerId)) { + if (!_.isEqual(filterExpr, mbMap.getFilter(pointLayerId))) { mbMap.setFilter(pointLayerId, filterExpr); mbMap.setFilter(textLayerId, filterExpr); } @@ -818,7 +818,7 @@ export class VectorLayer extends AbstractLayer implements IVectorLayer { } const filterExpr = getPointFilterExpression(this.hasJoins()); - if (filterExpr !== mbMap.getFilter(symbolLayerId)) { + if (!_.isEqual(filterExpr, mbMap.getFilter(symbolLayerId))) { mbMap.setFilter(symbolLayerId, filterExpr); } @@ -900,14 +900,14 @@ export class VectorLayer extends AbstractLayer implements IVectorLayer { this.syncVisibilityWithMb(mbMap, fillLayerId); mbMap.setLayerZoomRange(fillLayerId, this.getMinZoom(), this.getMaxZoom()); const fillFilterExpr = getFillFilterExpression(hasJoins); - if (fillFilterExpr !== mbMap.getFilter(fillLayerId)) { + if (!_.isEqual(fillFilterExpr, mbMap.getFilter(fillLayerId))) { mbMap.setFilter(fillLayerId, fillFilterExpr); } this.syncVisibilityWithMb(mbMap, lineLayerId); mbMap.setLayerZoomRange(lineLayerId, this.getMinZoom(), this.getMaxZoom()); const lineFilterExpr = getLineFilterExpression(hasJoins); - if (lineFilterExpr !== mbMap.getFilter(lineLayerId)) { + if (!_.isEqual(lineFilterExpr, mbMap.getFilter(lineLayerId))) { mbMap.setFilter(lineLayerId, lineFilterExpr); } @@ -931,7 +931,7 @@ export class VectorLayer extends AbstractLayer implements IVectorLayer { } const filterExpr = getCentroidFilterExpression(this.hasJoins()); - if (filterExpr !== mbMap.getFilter(centroidLayerId)) { + if (!_.isEqual(filterExpr, mbMap.getFilter(centroidLayerId))) { mbMap.setFilter(centroidLayerId, filterExpr); } diff --git a/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts b/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts index 4526340d3d865..c798f05df9813 100644 --- a/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts +++ b/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts @@ -15,61 +15,55 @@ import { export const EXCLUDE_TOO_MANY_FEATURES_BOX = ['!=', ['get', KBN_TOO_MANY_FEATURES_PROPERTY], true]; const EXCLUDE_CENTROID_FEATURES = ['!=', ['get', KBN_IS_CENTROID_FEATURE], true]; -const VISIBILITY_FILTER_CLAUSE = ['all', ['==', ['get', FEATURE_VISIBLE_PROPERTY_NAME], true]]; -// Kibana features are features added by kibana that do not exist in real data -const EXCLUDE_KBN_FEATURES = ['all', EXCLUDE_TOO_MANY_FEATURES_BOX, EXCLUDE_CENTROID_FEATURES]; +function getFilterExpression(geometryFilter: unknown[], hasJoins: boolean) { + const filters: unknown[] = [ + EXCLUDE_TOO_MANY_FEATURES_BOX, + EXCLUDE_CENTROID_FEATURES, + geometryFilter, + ]; -const CLOSED_SHAPE_MB_FILTER = [ - ...EXCLUDE_KBN_FEATURES, - [ - 'any', - ['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON], - ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POLYGON], - ], -]; + if (hasJoins) { + filters.push(['==', ['get', FEATURE_VISIBLE_PROPERTY_NAME], true]); + } -const VISIBLE_CLOSED_SHAPE_MB_FILTER = [...VISIBILITY_FILTER_CLAUSE, CLOSED_SHAPE_MB_FILTER]; - -const ALL_SHAPE_MB_FILTER = [ - ...EXCLUDE_KBN_FEATURES, - [ - 'any', - ['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON], - ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POLYGON], - ['==', ['geometry-type'], GEO_JSON_TYPE.LINE_STRING], - ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_LINE_STRING], - ], -]; - -const VISIBLE_ALL_SHAPE_MB_FILTER = [...VISIBILITY_FILTER_CLAUSE, ALL_SHAPE_MB_FILTER]; - -const POINT_MB_FILTER = [ - ...EXCLUDE_KBN_FEATURES, - [ - 'any', - ['==', ['geometry-type'], GEO_JSON_TYPE.POINT], - ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POINT], - ], -]; - -const VISIBLE_POINT_MB_FILTER = [...VISIBILITY_FILTER_CLAUSE, POINT_MB_FILTER]; - -const CENTROID_MB_FILTER = ['all', ['==', ['get', KBN_IS_CENTROID_FEATURE], true]]; - -const VISIBLE_CENTROID_MB_FILTER = [...VISIBILITY_FILTER_CLAUSE, CENTROID_MB_FILTER]; + return ['all', ...filters]; +} export function getFillFilterExpression(hasJoins: boolean): unknown[] { - return hasJoins ? VISIBLE_CLOSED_SHAPE_MB_FILTER : CLOSED_SHAPE_MB_FILTER; + return getFilterExpression( + [ + 'any', + ['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON], + ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POLYGON], + ], + hasJoins + ); } export function getLineFilterExpression(hasJoins: boolean): unknown[] { - return hasJoins ? VISIBLE_ALL_SHAPE_MB_FILTER : ALL_SHAPE_MB_FILTER; + return getFilterExpression( + [ + 'any', + ['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON], + ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POLYGON], + ['==', ['geometry-type'], GEO_JSON_TYPE.LINE_STRING], + ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_LINE_STRING], + ], + hasJoins + ); } export function getPointFilterExpression(hasJoins: boolean): unknown[] { - return hasJoins ? VISIBLE_POINT_MB_FILTER : POINT_MB_FILTER; + return getFilterExpression( + [ + 'any', + ['==', ['geometry-type'], GEO_JSON_TYPE.POINT], + ['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POINT], + ], + hasJoins + ); } export function getCentroidFilterExpression(hasJoins: boolean): unknown[] { - return hasJoins ? VISIBLE_CENTROID_MB_FILTER : CENTROID_MB_FILTER; + return getFilterExpression(['==', ['get', KBN_IS_CENTROID_FEATURE], true], hasJoins); } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/constants.ts b/x-pack/plugins/ml/common/constants/embeddable_map.ts similarity index 58% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/constants.ts rename to x-pack/plugins/ml/common/constants/embeddable_map.ts index 58bd2aa7e5cec..6cb345bae630e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/constants.ts +++ b/x-pack/plugins/ml/common/constants/embeddable_map.ts @@ -5,9 +5,9 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; - -export const SEARCH_UI_TITLE = i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.searchUI.title', - { defaultMessage: 'Search UI' } -); +export const COMMON_EMS_LAYER_IDS = [ + 'world_countries', + 'administrative_regions_lvl2', + 'usa_zip_codes', + 'usa_states', +]; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/choropleth_map/choropleth_map.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/choropleth_map/choropleth_map.tsx new file mode 100644 index 0000000000000..8b7cbf83f7996 --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/choropleth_map/choropleth_map.tsx @@ -0,0 +1,126 @@ +/* + * 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, { FC, useMemo } from 'react'; +import { EuiFlexItem, EuiSpacer, EuiText, htmlIdGenerator } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + FIELD_ORIGIN, + SOURCE_TYPES, + STYLE_TYPE, + COLOR_MAP_TYPE, +} from '../../../../../../../../maps/common/constants'; +import { EMSTermJoinConfig } from '../../../../../../../../maps/public'; +import { FieldVisStats } from '../../../../stats_table/types'; +import { VectorLayerDescriptor } from '../../../../../../../../maps/common/descriptor_types'; +import { MlEmbeddedMapComponent } from '../../../../../components/ml_embedded_map'; + +export const getChoroplethTopValuesLayer = ( + fieldName: string, + topValues: Array<{ key: any; doc_count: number }>, + { layerId, field }: EMSTermJoinConfig +): VectorLayerDescriptor => { + return { + id: htmlIdGenerator()(), + label: i18n.translate('xpack.ml.dataviz.choroplethMap.topValuesCount', { + defaultMessage: 'Top values count for {fieldName}', + values: { fieldName }, + }), + joins: [ + { + // Left join is the id from the type of field (e.g. world_countries) + leftField: field, + right: { + id: 'anomaly_count', + type: SOURCE_TYPES.TABLE_SOURCE, + __rows: topValues, + __columns: [ + { + name: 'key', + type: 'string', + }, + { + name: 'doc_count', + type: 'number', + }, + ], + // Right join/term is the field in the doc you’re trying to join it to (foreign key - e.g. US) + term: 'key', + }, + }, + ], + sourceDescriptor: { + type: 'EMS_FILE', + id: layerId, + }, + style: { + type: 'VECTOR', + // @ts-ignore missing style properties. Remove once 'VectorLayerDescriptor' type is updated + properties: { + icon: { type: STYLE_TYPE.STATIC, options: { value: 'marker' } }, + fillColor: { + type: STYLE_TYPE.DYNAMIC, + options: { + color: 'Blue to Red', + colorCategory: 'palette_0', + fieldMetaOptions: { isEnabled: true, sigma: 3 }, + type: COLOR_MAP_TYPE.ORDINAL, + field: { + name: 'doc_count', + origin: FIELD_ORIGIN.JOIN, + }, + useCustomColorRamp: false, + }, + }, + lineColor: { + type: STYLE_TYPE.DYNAMIC, + options: { fieldMetaOptions: { isEnabled: true } }, + }, + lineWidth: { type: STYLE_TYPE.STATIC, options: { size: 1 } }, + }, + isTimeAware: true, + }, + type: 'VECTOR', + }; +}; + +interface Props { + stats: FieldVisStats | undefined; + suggestion: EMSTermJoinConfig; +} + +export const ChoroplethMap: FC = ({ stats, suggestion }) => { + const { fieldName, isTopValuesSampled, topValues, topValuesSamplerShardSize } = stats!; + + const layerList: VectorLayerDescriptor[] = useMemo( + () => [getChoroplethTopValuesLayer(fieldName || '', topValues || [], suggestion)], + [suggestion, stats] + ); + + return ( + +
+ +
+ {isTopValuesSampled === true && ( + <> + + + + + + )} +
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/choropleth_map/index.ts b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/choropleth_map/index.ts new file mode 100644 index 0000000000000..6159b5e2ad9bb --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/choropleth_map/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 { ChoroplethMap } from './choropleth_map'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/keyword_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/keyword_content.tsx index 9d4e13c291656..9239632a3f909 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/keyword_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/keyword_content.tsx @@ -5,21 +5,50 @@ * 2.0. */ -import React, { FC } from 'react'; +import React, { FC, useEffect, useState } from 'react'; import type { FieldDataRowProps } from '../../types/field_data_row'; import { TopValues } from '../../../index_based/components/field_data_row/top_values'; +import { ChoroplethMap } from '../../../index_based/components/field_data_row/choropleth_map'; +import { useMlKibana } from '../../../../../application/contexts/kibana'; +import { EMSTermJoinConfig } from '../../../../../../../maps/public'; +import { COMMON_EMS_LAYER_IDS } from '../../../../../../common/constants/embeddable_map'; import { DocumentStatsTable } from './document_stats'; import { ExpandedRowContent } from './expanded_row_content'; export const KeywordContent: FC = ({ config }) => { - const { stats } = config; + const [EMSSuggestion, setEMSSuggestion] = useState(); + const { stats, fieldName } = config; const fieldFormat = 'fieldFormat' in config ? config.fieldFormat : undefined; + const { + services: { maps: mapsPlugin }, + } = useMlKibana(); + + const loadEMSTermSuggestions = async () => { + if (!mapsPlugin) return; + const suggestion: EMSTermJoinConfig | null = await mapsPlugin.suggestEMSTermJoinConfig({ + emsLayerIds: COMMON_EMS_LAYER_IDS, + sampleValues: Array.isArray(stats?.topValues) + ? stats?.topValues.map((value) => value.key) + : [], + sampleValuesColumnName: fieldName || '', + }); + setEMSSuggestion(suggestion); + }; + + useEffect( + function getInitialEMSTermSuggestion() { + loadEMSTermSuggestions(); + }, + [config?.fieldName] + ); return ( - - + {EMSSuggestion && stats && } + {EMSSuggestion === null && ( + + )} ); }; diff --git a/x-pack/plugins/ml/public/application/explorer/anomalies_map.tsx b/x-pack/plugins/ml/public/application/explorer/anomalies_map.tsx index 7914061dc81c7..73a6a9d64b60e 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomalies_map.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomalies_map.tsx @@ -28,14 +28,9 @@ import { isDefined } from '../../../common/types/guards'; import { MlEmbeddedMapComponent } from '../components/ml_embedded_map'; import { EMSTermJoinConfig } from '../../../../maps/public'; import { AnomaliesTableRecord } from '../../../common/types/anomalies'; +import { COMMON_EMS_LAYER_IDS } from '../../../common/constants/embeddable_map'; const MAX_ENTITY_VALUES = 3; -const COMMON_EMS_LAYER_IDS = [ - 'world_countries', - 'administrative_regions_lvl2', - 'usa_zip_codes', - 'usa_states', -]; function getAnomalyRows(anomalies: AnomaliesTableRecord[], jobId: string) { const anomalyRows: Record< diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index c58e67b5d4fd4..b9e72bcd625ec 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -414,6 +414,11 @@ export type PolicyInfo = Immutable<{ id: string; }>; +export interface HostMetaDataInfo { + metadata: HostMetadata; + query_strategy_version: MetadataQueryStrategyVersions; +} + export type HostInfo = Immutable<{ metadata: HostMetadata; host_status: HostStatus; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts index a579d8f8d8ef3..3175876a8299c 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts @@ -25,10 +25,16 @@ export interface EndpointFields { endpointPolicy?: Maybe; sensorVersion?: Maybe; policyStatus?: Maybe; + id?: Maybe; +} + +interface AgentFields { + id?: Maybe; } export interface HostItem { _id?: Maybe; + agent?: Maybe; cloud?: Maybe; endpoint?: Maybe; host?: Maybe; @@ -70,6 +76,9 @@ export interface HostAggEsItem { cloud_machine_type?: HostBuckets; cloud_provider?: HostBuckets; cloud_region?: HostBuckets; + endpoint?: { + id: HostBuckets; + }; host_architecture?: HostBuckets; host_id?: HostBuckets; host_ip?: HostBuckets; diff --git a/x-pack/plugins/security_solution/public/common/components/ml/criteria/host_to_criteria.ts b/x-pack/plugins/security_solution/public/common/components/ml/criteria/host_to_criteria.ts index 19eae99757849..ff454da7b1fcd 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/criteria/host_to_criteria.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml/criteria/host_to_criteria.ts @@ -9,6 +9,9 @@ import { HostItem } from '../../../../../common/search_strategy/security_solutio import { CriteriaFields } from '../types'; export const hostToCriteria = (hostItem: HostItem): CriteriaFields[] => { + if (hostItem == null) { + return []; + } if (hostItem.host != null && hostItem.host.name != null) { const criteria: CriteriaFields[] = [ { diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/details/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/hosts/details/index.tsx index dd55bdb4c6948..a0f4386be59a4 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/hosts/details/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/details/index.tsx @@ -145,14 +145,14 @@ export const useHostDetails = ({ } return prevRequest; }); - return () => { - searchSubscription$.current.unsubscribe(); - abortCtrl.current.abort(); - }; }, [endDate, hostName, indexNames, startDate]); useEffect(() => { hostDetailsSearch(hostDetailsRequest); + return () => { + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + }; }, [hostDetailsRequest, hostDetailsSearch]); return [loading, hostDetailsResponse]; diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx index 1ff4abb78b210..d88e4f048f917 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx @@ -50,6 +50,9 @@ import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { useHostDetails } from '../../containers/hosts/details'; +import { manageQuery } from '../../../common/components/page/manage_query'; + +const HostOverviewManage = manageQuery(HostOverview); const HostDetailsComponent: React.FC = ({ detailName, hostDetailsPagePath }) => { const dispatch = useDispatch(); @@ -93,11 +96,12 @@ const HostDetailsComponent: React.FC = ({ detailName, hostDeta ); const { docValueFields, indicesExist, indexPattern, selectedPatterns } = useSourcererScope(); - const [loading, { hostDetails: hostOverview, id }] = useHostDetails({ + const [loading, { hostDetails: hostOverview, id, refetch }] = useHostDetails({ endDate: to, startDate: from, hostName: detailName, indexNames: selectedPatterns, + skip: selectedPatterns.length === 0, }); const filterQuery = convertToBuildEsQuery({ config: esQuery.getEsQueryConfig(kibana.services.uiSettings), @@ -141,7 +145,7 @@ const HostDetailsComponent: React.FC = ({ detailName, hostDeta skip={isInitializing} > {({ isLoadingAnomaliesData, anomaliesData }) => ( - = ({ detailName, hostDeta to: fromTo.to, }); }} + setQuery={setQuery} + refetch={refetch} /> )} 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 d28bf6b38fd31..f654efdd89ce1 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 @@ -321,7 +321,7 @@ export const EndpointList = () => { render: (hostStatus: HostInfo['host_status']) => { return ( diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx index c5d51a9466235..fa644d1cbcdac 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx @@ -86,14 +86,15 @@ export const HostOverview = React.memo( () => [ { title: i18n.HOST_ID, - description: data.host - ? hostIdRenderer({ host: data.host, noLink: true }) - : getEmptyTagValue(), + description: + data && data.host + ? hostIdRenderer({ host: data.host, noLink: true }) + : getEmptyTagValue(), }, { title: i18n.FIRST_SEEN, description: - data.host != null && data.host.name && data.host.name.length ? ( + data && data.host != null && data.host.name && data.host.name.length ? ( ( { title: i18n.LAST_SEEN, description: - data.host != null && data.host.name && data.host.name.length ? ( + data && data.host != null && data.host.name && data.host.name.length ? ( ( )} - {data.endpoint != null ? ( + {data && data.endpoint != null ? ( <> diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks.ts index 40d4b1a877b2b..23ea6cc29c3d2 100644 --- a/x-pack/plugins/security_solution/server/endpoint/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/mocks.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { ILegacyScopedClusterClient, SavedObjectsClientContract } from 'kibana/server'; -import { loggingSystemMock, savedObjectsServiceMock } from 'src/core/server/mocks'; +import { loggingSystemMock, savedObjectsServiceMock } from '../../../../../src/core/server/mocks'; +import { IScopedClusterClient, SavedObjectsClientContract } from '../../../../../src/core/server'; import { listMock } from '../../../lists/server/mocks'; import { securityMock } from '../../../security/server/mocks'; import { alertsMock } from '../../../alerting/server/mocks'; @@ -131,11 +131,11 @@ export const createMockMetadataRequestContext = (): jest.Mocked, + dataClient: jest.Mocked, savedObjectsClient: jest.Mocked ) { - const context = xpackMocks.createRequestHandlerContext(); - context.core.elasticsearch.legacy.client = dataClient; + const context = (xpackMocks.createRequestHandlerContext() as unknown) as jest.Mocked; + context.core.elasticsearch.client = dataClient; context.core.savedObjects.client = savedObjectsClient; return context; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts index 306f37796c3fd..3340ef38d73cb 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts @@ -6,12 +6,7 @@ */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { - ILegacyClusterClient, - KibanaResponseFactory, - RequestHandler, - RouteConfig, -} from 'kibana/server'; +import { KibanaResponseFactory, RequestHandler, RouteConfig } from 'kibana/server'; import { elasticsearchServiceMock, httpServerMock, @@ -78,8 +73,8 @@ describe('Host Isolation', () => { beforeEach(() => { // instantiate... everything - const mockScopedClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - const mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient() as jest.Mocked; + const mockScopedClient = elasticsearchServiceMock.createScopedClusterClient(); + const mockClusterClient = elasticsearchServiceMock.createClusterClient(); mockClusterClient.asScoped.mockReturnValue(mockScopedClient); const routerMock = httpServiceMock.createRouter(); mockResponse = httpServerMock.createResponseFactory(); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts index 0d59ff2f4ed7b..104383f398646 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts @@ -6,11 +6,18 @@ */ import Boom from '@hapi/boom'; -import type { Logger, RequestHandler } from 'kibana/server'; + import { TypeOf } from '@kbn/config-schema'; +import { + IScopedClusterClient, + Logger, + RequestHandler, + SavedObjectsClientContract, +} from '../../../../../../../src/core/server'; import { HostInfo, HostMetadata, + HostMetaDataInfo, HostResultList, HostStatus, MetadataQueryStrategyVersions, @@ -27,9 +34,11 @@ import { findAgentIDsByStatus } from './support/agent_status'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; export interface MetadataRequestContext { + esClient?: IScopedClusterClient; endpointAppContextService: EndpointAppContextService; logger: Logger; - requestHandlerContext: SecuritySolutionRequestHandlerContext; + requestHandlerContext?: SecuritySolutionRequestHandlerContext; + savedObjectsClient?: SavedObjectsClientContract; } const HOST_STATUS_MAPPING = new Map([ @@ -75,9 +84,11 @@ export const getMetadataListRequestHandler = function ( } const metadataRequestContext: MetadataRequestContext = { + esClient: context.core.elasticsearch.client, endpointAppContextService: endpointAppContext.service, logger, requestHandlerContext: context, + savedObjectsClient: context.core.savedObjects.client, }; const unenrolledAgentIds = await findAllUnenrolledAgentIds( @@ -110,9 +121,10 @@ export const getMetadataListRequestHandler = function ( } ); - const hostListQueryResult = queryStrategy!.queryResponseToHostListResult( - await context.core.elasticsearch.legacy.client.callAsCurrentUser('search', queryParams) + const result = await context.core.elasticsearch.client.asCurrentUser.search( + queryParams ); + const hostListQueryResult = queryStrategy!.queryResponseToHostListResult(result.body); return response.ok({ body: await mapToHostResultList(queryParams, hostListQueryResult, metadataRequestContext), }); @@ -136,9 +148,11 @@ export const getMetadataRequestHandler = function ( } const metadataRequestContext: MetadataRequestContext = { + esClient: context.core.elasticsearch.client, endpointAppContextService: endpointAppContext.service, logger, requestHandlerContext: context, + savedObjectsClient: context.core.savedObjects.client, }; try { @@ -164,42 +178,86 @@ export const getMetadataRequestHandler = function ( }; }; -export async function getHostData( +export async function getHostMetaData( metadataRequestContext: MetadataRequestContext, id: string, queryStrategyVersion?: MetadataQueryStrategyVersions -): Promise { +): Promise { + if ( + !metadataRequestContext.esClient && + !metadataRequestContext.requestHandlerContext?.core.elasticsearch.client + ) { + throw Boom.badRequest('esClient not found'); + } + + if ( + !metadataRequestContext.savedObjectsClient && + !metadataRequestContext.requestHandlerContext?.core.savedObjects + ) { + throw Boom.badRequest('savedObjectsClient not found'); + } + + const esClient = (metadataRequestContext?.esClient ?? + metadataRequestContext.requestHandlerContext?.core.elasticsearch + .client) as IScopedClusterClient; + + const esSavedObjectClient = + metadataRequestContext?.savedObjectsClient ?? + (metadataRequestContext.requestHandlerContext?.core.savedObjects + .client as SavedObjectsClientContract); + const queryStrategy = await metadataRequestContext.endpointAppContextService ?.getMetadataService() - ?.queryStrategy( - metadataRequestContext.requestHandlerContext.core.savedObjects.client, - queryStrategyVersion - ); - + ?.queryStrategy(esSavedObjectClient, queryStrategyVersion); const query = getESQueryHostMetadataByID(id, queryStrategy!); - const hostResult = queryStrategy!.queryResponseToHostResult( - await metadataRequestContext.requestHandlerContext.core.elasticsearch.legacy.client.callAsCurrentUser( - 'search', - query - ) - ); + + const response = await esClient.asCurrentUser.search(query); + + const hostResult = queryStrategy!.queryResponseToHostResult(response.body); + const hostMetadata = hostResult.result; if (!hostMetadata) { return undefined; } - const agent = await findAgent(metadataRequestContext, hostMetadata); + return { metadata: hostMetadata, query_strategy_version: hostResult.queryStrategyVersion }; +} + +export async function getHostData( + metadataRequestContext: MetadataRequestContext, + id: string, + queryStrategyVersion?: MetadataQueryStrategyVersions +): Promise { + if (!metadataRequestContext.savedObjectsClient) { + throw Boom.badRequest('savedObjectsClient not found'); + } + + if ( + !metadataRequestContext.esClient && + !metadataRequestContext.requestHandlerContext?.core.elasticsearch.client + ) { + throw Boom.badRequest('esClient not found'); + } + + const hostResult = await getHostMetaData(metadataRequestContext, id, queryStrategyVersion); + + if (!hostResult) { + return undefined; + } + + const agent = await findAgent(metadataRequestContext, hostResult.metadata); if (agent && !agent.active) { throw Boom.badRequest('the requested endpoint is unenrolled'); } const metadata = await enrichHostMetadata( - hostMetadata, + hostResult.metadata, metadataRequestContext, - hostResult.queryStrategyVersion + hostResult.query_strategy_version ); - return { ...metadata, query_strategy_version: hostResult.queryStrategyVersion }; + + return { ...metadata, query_strategy_version: hostResult.query_strategy_version }; } async function findAgent( @@ -207,12 +265,20 @@ async function findAgent( hostMetadata: HostMetadata ): Promise { try { + if ( + !metadataRequestContext.esClient && + !metadataRequestContext.requestHandlerContext?.core.elasticsearch.client + ) { + throw new Error('esClient not found'); + } + + const esClient = (metadataRequestContext?.esClient ?? + metadataRequestContext.requestHandlerContext?.core.elasticsearch + .client) as IScopedClusterClient; + return await metadataRequestContext.endpointAppContextService ?.getAgentService() - ?.getAgent( - metadataRequestContext.requestHandlerContext.core.elasticsearch.client.asCurrentUser, - hostMetadata.elastic.agent.id - ); + ?.getAgent(esClient.asCurrentUser, hostMetadata.elastic.agent.id); } catch (e) { if (e instanceof AgentNotFoundError) { metadataRequestContext.logger.warn( @@ -232,7 +298,7 @@ export async function mapToHostResultList( metadataRequestContext: MetadataRequestContext ): Promise { const totalNumberOfHosts = hostListQueryResult.resultLength; - if (hostListQueryResult.resultList.length > 0) { + if ((hostListQueryResult.resultList?.length ?? 0) > 0) { return { request_page_size: queryParams.size, request_page_index: queryParams.from, @@ -267,6 +333,35 @@ export async function enrichHostMetadata( let hostStatus = HostStatus.UNHEALTHY; let elasticAgentId = hostMetadata?.elastic?.agent?.id; const log = metadataRequestContext.logger; + + try { + if ( + !metadataRequestContext.esClient && + !metadataRequestContext.requestHandlerContext?.core.elasticsearch.client + ) { + throw new Error('esClient not found'); + } + + if ( + !metadataRequestContext.savedObjectsClient && + !metadataRequestContext.requestHandlerContext?.core.savedObjects + ) { + throw new Error('esSavedObjectClient not found'); + } + } catch (e) { + log.error(e); + throw e; + } + + const esClient = (metadataRequestContext?.esClient ?? + metadataRequestContext.requestHandlerContext?.core.elasticsearch + .client) as IScopedClusterClient; + + const esSavedObjectClient = + metadataRequestContext?.savedObjectsClient ?? + (metadataRequestContext.requestHandlerContext?.core.savedObjects + .client as SavedObjectsClientContract); + try { /** * Get agent status by elastic agent id if available or use the endpoint-agent id. @@ -279,10 +374,7 @@ export async function enrichHostMetadata( const status = await metadataRequestContext.endpointAppContextService ?.getAgentService() - ?.getAgentStatusById( - metadataRequestContext.requestHandlerContext.core.elasticsearch.client.asCurrentUser, - elasticAgentId - ); + ?.getAgentStatusById(esClient.asCurrentUser, elasticAgentId); hostStatus = HOST_STATUS_MAPPING.get(status!) || HostStatus.UNHEALTHY; } catch (e) { if (e instanceof AgentNotFoundError) { @@ -297,17 +389,10 @@ export async function enrichHostMetadata( try { const agent = await metadataRequestContext.endpointAppContextService ?.getAgentService() - ?.getAgent( - metadataRequestContext.requestHandlerContext.core.elasticsearch.client.asCurrentUser, - elasticAgentId - ); + ?.getAgent(esClient.asCurrentUser, elasticAgentId); const agentPolicy = await metadataRequestContext.endpointAppContextService .getAgentPolicyService() - ?.get( - metadataRequestContext.requestHandlerContext.core.savedObjects.client, - agent?.policy_id!, - true - ); + ?.get(esSavedObjectClient, agent?.policy_id!, true); const endpointPolicy = ((agentPolicy?.package_policies || []) as PackagePolicy[]).find( (policy: PackagePolicy) => policy.package?.name === 'endpoint' ); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts index f4698cbed6203..b916ec19da17f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts @@ -6,8 +6,6 @@ */ import { - ILegacyClusterClient, - ILegacyScopedClusterClient, KibanaResponseFactory, RequestHandler, RouteConfig, @@ -50,12 +48,17 @@ import { PackageService } from '../../../../../fleet/server/services'; import { metadataTransformPrefix } from '../../../../common/endpoint/constants'; import type { SecuritySolutionPluginRouter } from '../../../types'; import { PackagePolicyServiceInterface } from '../../../../../fleet/server'; +import { + ClusterClientMock, + ScopedClusterClientMock, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../../src/core/server/elasticsearch/client/mocks'; describe('test endpoint route', () => { let routerMock: jest.Mocked; let mockResponse: jest.Mocked; - let mockClusterClient: jest.Mocked; - let mockScopedClient: jest.Mocked; + let mockClusterClient: ClusterClientMock; + let mockScopedClient: ScopedClusterClientMock; let mockSavedObjectClient: jest.Mocked; let mockPackageService: jest.Mocked; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -76,8 +79,8 @@ describe('test endpoint route', () => { }; beforeEach(() => { - mockScopedClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient() as jest.Mocked; + mockScopedClient = elasticsearchServiceMock.createScopedClusterClient(); + mockClusterClient = elasticsearchServiceMock.createClusterClient(); mockSavedObjectClient = savedObjectsClientMock.create(); mockClusterClient.asScoped.mockReturnValue(mockScopedClient); routerMock = httpServiceMock.createRouter(); @@ -119,7 +122,9 @@ describe('test endpoint route', () => { it('test find the latest of all endpoints', async () => { const mockRequest = httpServerMock.createKibanaRequest({}); const response = createV1SearchResponse(new EndpointDocGenerator().generateHostMetadata()); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: response }) + ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => path.startsWith(`${METADATA_REQUEST_ROUTE}`) )!; @@ -131,7 +136,7 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'], @@ -157,7 +162,9 @@ describe('test endpoint route', () => { mockAgentService.getAgent = jest.fn().mockReturnValue(({ active: true, } as unknown) as Agent); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: response }) + ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith(`${METADATA_REQUEST_ROUTE}`) @@ -169,7 +176,7 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'], @@ -214,7 +221,9 @@ describe('test endpoint route', () => { it('test find the latest of all endpoints', async () => { const mockRequest = httpServerMock.createKibanaRequest({}); const response = createV2SearchResponse(new EndpointDocGenerator().generateHostMetadata()); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: response }) + ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => path.startsWith(`${METADATA_REQUEST_ROUTE}`) )!; @@ -226,7 +235,7 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'], @@ -258,8 +267,10 @@ describe('test endpoint route', () => { mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(createV2SearchResponse(new EndpointDocGenerator().generateHostMetadata())) + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ + body: createV2SearchResponse(new EndpointDocGenerator().generateHostMetadata()), + }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => path.startsWith(`${METADATA_REQUEST_ROUTE}`) @@ -270,10 +281,10 @@ describe('test endpoint route', () => { mockRequest, mockResponse ); - - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect( - mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body?.query.bool.must_not + (mockScopedClient.asCurrentUser.search as jest.Mock).mock.calls[0][0]?.body?.query.bool + .must_not ).toContainEqual({ terms: { 'elastic.agent.id': [ @@ -315,8 +326,10 @@ describe('test endpoint route', () => { mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(createV2SearchResponse(new EndpointDocGenerator().generateHostMetadata())) + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ + body: createV2SearchResponse(new EndpointDocGenerator().generateHostMetadata()), + }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => path.startsWith(`${METADATA_REQUEST_ROUTE}`) @@ -328,10 +341,10 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toBeCalled(); + expect(mockScopedClient.asCurrentUser.search).toBeCalled(); expect( // KQL filter to be passed through - mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body?.query.bool.must + (mockScopedClient.asCurrentUser.search as jest.Mock).mock.calls[0][0]?.body?.query.bool.must ).toContainEqual({ bool: { must_not: { @@ -349,7 +362,7 @@ describe('test endpoint route', () => { }, }); expect( - mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body?.query.bool.must + (mockScopedClient.asCurrentUser.search as jest.Mock).mock.calls[0][0]?.body?.query.bool.must ).toContainEqual({ bool: { must_not: [ @@ -393,8 +406,8 @@ describe('test endpoint route', () => { it('should return 404 on no results', async () => { const mockRequest = httpServerMock.createKibanaRequest({ params: { id: 'BADID' } }); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(createV2SearchResponse()) + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: createV2SearchResponse() }) ); mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); @@ -411,7 +424,7 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'], @@ -431,7 +444,9 @@ describe('test endpoint route', () => { mockAgentService.getAgent = jest.fn().mockReturnValue(({ active: true, } as unknown) as Agent); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: response }) + ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith(`${METADATA_REQUEST_ROUTE}`) @@ -443,7 +458,7 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'], @@ -470,7 +485,9 @@ describe('test endpoint route', () => { SavedObjectsErrorHelpers.createGenericNotFoundError(); }); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: response }) + ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith(`${METADATA_REQUEST_ROUTE}`) @@ -482,7 +499,7 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'], @@ -503,7 +520,9 @@ describe('test endpoint route', () => { mockAgentService.getAgent = jest.fn().mockReturnValue(({ active: true, } as unknown) as Agent); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: response }) + ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith(`${METADATA_REQUEST_ROUTE}`) @@ -515,7 +534,7 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'], @@ -531,7 +550,9 @@ describe('test endpoint route', () => { const mockRequest = httpServerMock.createKibanaRequest({ params: { id: response.hits.hits[0]._id }, }); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: response }) + ); mockAgentService.getAgent = jest.fn().mockReturnValue(({ active: false, } as unknown) as Agent); @@ -546,7 +567,7 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect(mockResponse.customError).toBeCalled(); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata_v1.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata_v1.test.ts index e3f859c26601e..0d56514e7d395 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata_v1.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata_v1.test.ts @@ -6,14 +6,17 @@ */ import { - ILegacyClusterClient, - ILegacyScopedClusterClient, KibanaResponseFactory, RequestHandler, RouteConfig, SavedObjectsClientContract, -} from 'kibana/server'; -import { SavedObjectsErrorHelpers } from '../../../../../../../src/core/server/'; + SavedObjectsErrorHelpers, +} from '../../../../../../../src/core/server'; +import { + ClusterClientMock, + ScopedClusterClientMock, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../../src/core/server/elasticsearch/client/mocks'; import { elasticsearchServiceMock, httpServerMock, @@ -49,8 +52,8 @@ import { PackagePolicyServiceInterface } from '../../../../../fleet/server'; describe('test endpoint route v1', () => { let routerMock: jest.Mocked; let mockResponse: jest.Mocked; - let mockClusterClient: jest.Mocked; - let mockScopedClient: jest.Mocked; + let mockClusterClient: ClusterClientMock; + let mockScopedClient: ScopedClusterClientMock; let mockSavedObjectClient: jest.Mocked; let mockPackageService: jest.Mocked; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -71,8 +74,8 @@ describe('test endpoint route v1', () => { }; beforeEach(() => { - mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient() as jest.Mocked; - mockScopedClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockClusterClient = elasticsearchServiceMock.createClusterClient(); + mockScopedClient = elasticsearchServiceMock.createScopedClusterClient(); mockSavedObjectClient = savedObjectsClientMock.create(); mockClusterClient.asScoped.mockReturnValue(mockScopedClient); routerMock = httpServiceMock.createRouter(); @@ -110,7 +113,9 @@ describe('test endpoint route v1', () => { it('test find the latest of all endpoints', async () => { const mockRequest = httpServerMock.createKibanaRequest({}); const response = createV1SearchResponse(new EndpointDocGenerator().generateHostMetadata()); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: response }) + ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => path.startsWith(`${METADATA_REQUEST_V1_ROUTE}`) )!; @@ -122,7 +127,7 @@ describe('test endpoint route v1', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'] }); expect(mockResponse.ok).toBeCalled(); const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; @@ -151,8 +156,10 @@ describe('test endpoint route v1', () => { mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(createV1SearchResponse(new EndpointDocGenerator().generateHostMetadata())) + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ + body: createV1SearchResponse(new EndpointDocGenerator().generateHostMetadata()), + }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => path.startsWith(`${METADATA_REQUEST_V1_ROUTE}`) @@ -164,9 +171,10 @@ describe('test endpoint route v1', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect( - mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body?.query.bool.must_not + (mockScopedClient.asCurrentUser.search as jest.Mock).mock.calls[0][0]?.body?.query.bool + .must_not ).toContainEqual({ terms: { 'elastic.agent.id': [ @@ -205,8 +213,10 @@ describe('test endpoint route v1', () => { mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(createV1SearchResponse(new EndpointDocGenerator().generateHostMetadata())) + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ + body: createV1SearchResponse(new EndpointDocGenerator().generateHostMetadata()), + }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => path.startsWith(`${METADATA_REQUEST_V1_ROUTE}`) @@ -218,10 +228,10 @@ describe('test endpoint route v1', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toBeCalled(); + expect(mockScopedClient.asCurrentUser.search).toBeCalled(); // needs to have the KQL filter passed through expect( - mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body?.query.bool.must + (mockScopedClient.asCurrentUser.search as jest.Mock).mock.calls[0][0]?.body?.query.bool.must ).toContainEqual({ bool: { must_not: { @@ -240,7 +250,7 @@ describe('test endpoint route v1', () => { }); // and unenrolled should be filtered out. expect( - mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body?.query.bool.must + (mockScopedClient.asCurrentUser.search as jest.Mock).mock.calls[0][0]?.body?.query.bool.must ).toContainEqual({ bool: { must_not: [ @@ -281,8 +291,8 @@ describe('test endpoint route v1', () => { it('should return 404 on no results', async () => { const mockRequest = httpServerMock.createKibanaRequest({ params: { id: 'BADID' } }); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(createV1SearchResponse()) + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: createV1SearchResponse() }) ); mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); @@ -299,7 +309,7 @@ describe('test endpoint route v1', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'], @@ -319,7 +329,9 @@ describe('test endpoint route v1', () => { mockAgentService.getAgent = jest.fn().mockReturnValue(({ active: true, } as unknown) as Agent); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: response }) + ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith(`${METADATA_REQUEST_V1_ROUTE}`) @@ -331,7 +343,7 @@ describe('test endpoint route v1', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'], @@ -357,7 +369,9 @@ describe('test endpoint route v1', () => { SavedObjectsErrorHelpers.createGenericNotFoundError(); }); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: response }) + ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith(`${METADATA_REQUEST_V1_ROUTE}`) @@ -369,7 +383,7 @@ describe('test endpoint route v1', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'], @@ -390,7 +404,9 @@ describe('test endpoint route v1', () => { mockAgentService.getAgent = jest.fn().mockReturnValue(({ active: true, } as unknown) as Agent); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: response }) + ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith(`${METADATA_REQUEST_V1_ROUTE}`) @@ -402,7 +418,7 @@ describe('test endpoint route v1', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'], @@ -418,7 +434,9 @@ describe('test endpoint route v1', () => { const mockRequest = httpServerMock.createKibanaRequest({ params: { id: response.hits.hits[0]._id }, }); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: response }) + ); mockAgentService.getAgent = jest.fn().mockReturnValue(({ active: false, } as unknown) as Agent); @@ -433,7 +451,7 @@ describe('test endpoint route v1', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); expect(mockResponse.customError).toBeCalled(); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts index 5c09fd5ce05e4..e790c1de1a5b8 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts @@ -12,6 +12,7 @@ import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__ import { metadataCurrentIndexPattern } from '../../../../common/endpoint/constants'; import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; import { metadataQueryStrategyV2 } from './support/query_strategies'; +import { get } from 'lodash'; describe('query builder', () => { describe('MetadataListESQuery', () => { @@ -204,7 +205,7 @@ describe('query builder', () => { const mockID = 'AABBCCDD-0011-2233-AA44-DEADBEEF8899'; const query = getESQueryHostMetadataByID(mockID, metadataQueryStrategyV2()); - expect(query.body.query.bool.filter[0].bool.should).toContainEqual({ + expect(get(query, 'body.query.bool.filter.0.bool.should')).toContainEqual({ term: { 'agent.id': mockID }, }); }); @@ -213,7 +214,7 @@ describe('query builder', () => { const mockID = 'AABBCCDD-0011-2233-AA44-DEADBEEF8899'; const query = getESQueryHostMetadataByID(mockID, metadataQueryStrategyV2()); - expect(query.body.query.bool.filter[0].bool.should).toContainEqual({ + expect(get(query, 'body.query.bool.filter.0.bool.should')).toContainEqual({ term: { 'HostDetails.agent.id': mockID }, }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts index a5259dd44cf2b..51e3495938606 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { KibanaRequest } from 'kibana/server'; +import { SearchRequest, SortContainer } from '@elastic/elasticsearch/api/types'; +import { KibanaRequest } from '../../../../../../../src/core/server'; import { esKuery } from '../../../../../../../src/plugins/data/server'; import { EndpointAppContext, MetadataQueryStrategy } from '../../types'; @@ -19,7 +20,7 @@ export interface QueryBuilderOptions { // using unmapped_type avoids errors when the given field doesn't exist, and sets to the 0-value for that type // effectively ignoring it // https://www.elastic.co/guide/en/elasticsearch/reference/current/sort-search-results.html#_ignoring_unmapped_fields -const MetadataSortMethod = [ +const MetadataSortMethod: SortContainer[] = [ { 'event.created': { order: 'desc', @@ -146,7 +147,7 @@ function buildQueryBody( export function getESQueryHostMetadataByID( agentID: string, metadataQueryStrategy: MetadataQueryStrategy -) { +): SearchRequest { return { body: { query: { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders_v1.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders_v1.test.ts index 9ce6130ff7dd3..c18c585cd3d34 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders_v1.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders_v1.test.ts @@ -12,6 +12,7 @@ import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__ import { metadataIndexPattern } from '../../../../common/endpoint/constants'; import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; import { metadataQueryStrategyV1 } from './support/query_strategies'; +import { get } from 'lodash'; describe('query builder v1', () => { describe('MetadataListESQuery', () => { @@ -179,7 +180,7 @@ describe('query builder v1', () => { const mockID = 'AABBCCDD-0011-2233-AA44-DEADBEEF8899'; const query = getESQueryHostMetadataByID(mockID, metadataQueryStrategyV1()); - expect(query.body.query.bool.filter[0].bool.should).toContainEqual({ + expect(get(query, 'body.query.bool.filter.0.bool.should')).toContainEqual({ term: { 'agent.id': mockID }, }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/query_strategies.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/query_strategies.ts index 2f875ec2754a4..506c02fc2f1ec 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/query_strategies.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/query_strategies.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SearchResponse } from 'elasticsearch'; +import { SearchResponse } from '@elastic/elasticsearch/api/types'; import { metadataCurrentIndexPattern, metadataIndexPattern, @@ -13,10 +13,6 @@ import { import { HostMetadata, MetadataQueryStrategyVersions } from '../../../../../common/endpoint/types'; import { HostListQueryResult, HostQueryResult, MetadataQueryStrategy } from '../../../types'; -interface HitSource { - _source: HostMetadata; -} - export function metadataQueryStrategyV1(): MetadataQueryStrategy { return { index: metadataIndexPattern, @@ -42,11 +38,13 @@ export function metadataQueryStrategyV1(): MetadataQueryStrategy { ): HostListQueryResult => { const response = searchResponse as SearchResponse; return { - resultLength: response?.aggregations?.total?.value || 0, + resultLength: + ((response?.aggregations?.total as unknown) as { value?: number; relation: string }) + ?.value || 0, resultList: response.hits.hits - .map((hit) => hit.inner_hits.most_recent.hits.hits) - .flatMap((data) => data as HitSource) - .map((entry) => entry._source), + .map((hit) => hit.inner_hits?.most_recent.hits.hits) + .flatMap((data) => data) + .map((entry) => (entry?._source ?? {}) as HostMetadata), queryStrategyVersion: MetadataQueryStrategyVersions.VERSION_1, }; }, @@ -75,7 +73,7 @@ export function metadataQueryStrategyV2(): MetadataQueryStrategy { >; const list = response.hits.hits.length > 0 - ? response.hits.hits.map((entry) => stripHostDetails(entry._source)) + ? response.hits.hits.map((entry) => stripHostDetails(entry?._source as HostMetadata)) : []; return { @@ -95,7 +93,7 @@ export function metadataQueryStrategyV2(): MetadataQueryStrategy { resultLength: response.hits.hits.length, result: response.hits.hits.length > 0 - ? stripHostDetails(response.hits.hits[0]._source) + ? stripHostDetails(response.hits.hits[0]._source as HostMetadata) : undefined, queryStrategyVersion: MetadataQueryStrategyVersions.VERSION_2, }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts index ca9b8832bebd0..c8b36a22b359a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts @@ -13,10 +13,9 @@ import { import { createMockAgentService } from '../../../../../fleet/server/mocks'; import { getHostPolicyResponseHandler, getAgentPolicySummaryHandler } from './handlers'; import { - ILegacyScopedClusterClient, KibanaResponseFactory, SavedObjectsClientContract, -} from 'kibana/server'; +} from '../../../../../../../src/core/server'; import { elasticsearchServiceMock, httpServerMock, @@ -30,16 +29,19 @@ import { parseExperimentalConfigValue } from '../../../../common/experimental_fe import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; import { Agent } from '../../../../../fleet/common/types/models'; import { AgentService } from '../../../../../fleet/server/services'; +import { get } from 'lodash'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ScopedClusterClientMock } from '../../../../../../../src/core/server/elasticsearch/client/mocks'; describe('test policy response handler', () => { let endpointAppContextService: EndpointAppContextService; - let mockScopedClient: jest.Mocked; + let mockScopedClient: ScopedClusterClientMock; let mockSavedObjectClient: jest.Mocked; let mockResponse: jest.Mocked; describe('test policy response handler', () => { beforeEach(() => { - mockScopedClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClient = elasticsearchServiceMock.createScopedClusterClient(); mockSavedObjectClient = savedObjectsClientMock.create(); mockResponse = httpServerMock.createResponseFactory(); endpointAppContextService = new EndpointAppContextService(); @@ -52,7 +54,9 @@ describe('test policy response handler', () => { const response = createSearchResponse(new EndpointDocGenerator().generatePolicyResponse()); const hostPolicyResponseHandler = getHostPolicyResponseHandler(); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: response }) + ); const mockRequest = httpServerMock.createKibanaRequest({ params: { agentId: 'id' }, }); @@ -65,14 +69,16 @@ describe('test policy response handler', () => { expect(mockResponse.ok).toBeCalled(); const result = mockResponse.ok.mock.calls[0][0]?.body as GetHostPolicyResponse; - expect(result.policy_response.agent.id).toEqual(response.hits.hits[0]._source.agent.id); + expect(result.policy_response.agent.id).toEqual( + get(response, 'hits.hits.0._source.agent.id') + ); }); it('should return not found when there is no response policy for host', async () => { const hostPolicyResponseHandler = getHostPolicyResponseHandler(); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(createSearchResponse()) + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: createSearchResponse() }) ); const mockRequest = httpServerMock.createKibanaRequest({ @@ -109,7 +115,7 @@ describe('test policy response handler', () => { }; beforeEach(() => { - mockScopedClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClient = elasticsearchServiceMock.createScopedClusterClient(); mockSavedObjectClient = savedObjectsClientMock.create(); mockResponse = httpServerMock.createResponseFactory(); endpointAppContextService = new EndpointAppContextService(); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.ts index ec1fad80701b6..45b6201c47773 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.ts @@ -25,7 +25,7 @@ export const getHostPolicyResponseHandler = function (): RequestHandler< const doc = await getPolicyResponseByAgentId( policyIndexPattern, request.query.agentId, - context.core.elasticsearch.legacy.client + context.core.elasticsearch.client ); if (doc) { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.test.ts index 8043eae20b30e..8646a05900f80 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.test.ts @@ -24,7 +24,7 @@ describe('test policy query', () => { it('queries for the correct host', async () => { const agentId = 'f757d3c0-e874-11ea-9ad9-015510b487f4'; const query = getESQueryPolicyResponseByAgentID(agentId, 'anyindex'); - expect(query.body.query.bool.filter.term).toEqual({ 'agent.id': agentId }); + expect(query.body?.query?.bool?.filter).toEqual({ term: { 'agent.id': agentId } }); }); it('filters out initial policy by ID', async () => { @@ -32,8 +32,10 @@ describe('test policy query', () => { 'f757d3c0-e874-11ea-9ad9-015510b487f4', 'anyindex' ); - expect(query.body.query.bool.must_not.term).toEqual({ - 'Endpoint.policy.applied.id': '00000000-0000-0000-0000-000000000000', + expect(query.body?.query?.bool?.must_not).toEqual({ + term: { + 'Endpoint.policy.applied.id': '00000000-0000-0000-0000-000000000000', + }, }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.ts index af5a885b78040..987bef15afe98 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.ts @@ -5,18 +5,21 @@ * 2.0. */ -import { SearchResponse } from 'elasticsearch'; import { ElasticsearchClient, - ILegacyScopedClusterClient, + IScopedClusterClient, SavedObjectsClientContract, -} from 'kibana/server'; +} from '../../../../../../../src/core/server'; import { GetHostPolicyResponse, HostPolicyResponse } from '../../../../common/endpoint/types'; import { INITIAL_POLICY_ID } from './index'; import { Agent } from '../../../../../fleet/common/types/models'; import { EndpointAppContext } from '../../types'; +import { ISearchRequestParams } from '../../../../../../../src/plugins/data/common'; -export function getESQueryPolicyResponseByAgentID(agentID: string, index: string) { +export const getESQueryPolicyResponseByAgentID = ( + agentID: string, + index: string +): ISearchRequestParams => { return { body: { query: { @@ -44,26 +47,23 @@ export function getESQueryPolicyResponseByAgentID(agentID: string, index: string }, index, }; -} +}; export async function getPolicyResponseByAgentId( index: string, agentID: string, - dataClient: ILegacyScopedClusterClient + dataClient: IScopedClusterClient ): Promise { const query = getESQueryPolicyResponseByAgentID(agentID, index); - const response = (await dataClient.callAsCurrentUser( - 'search', - query - )) as SearchResponse; + const response = await dataClient.asCurrentUser.search(query); - if (response.hits.hits.length === 0) { - return undefined; + if (response.body.hits.hits.length > 0 && response.body.hits.hits[0]._source != null) { + return { + policy_response: response.body.hits.hits[0]._source, + }; } - return { - policy_response: response.hits.hits[0]._source, - }; + return undefined; } const transformAgentVersionMap = (versionMap: Map): { [key: string]: number } => { diff --git a/x-pack/plugins/security_solution/server/endpoint/types.ts b/x-pack/plugins/security_solution/server/endpoint/types.ts index 8006bf20d4517..b3c7e58afe991 100644 --- a/x-pack/plugins/security_solution/server/endpoint/types.ts +++ b/x-pack/plugins/security_solution/server/endpoint/types.ts @@ -6,7 +6,8 @@ */ import { LoggerFactory } from 'kibana/server'; -import { SearchResponse } from 'elasticsearch'; + +import { SearchResponse } from '@elastic/elasticsearch/api/types'; import { ConfigType } from '../config'; import { EndpointAppContextService } from './endpoint_app_context_services'; import { JsonObject } from '../../../../../src/plugins/kibana_utils/common'; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 46467a21ca7ab..158c2e94b2d7a 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -305,7 +305,10 @@ export class Plugin implements IPlugin { - const securitySolutionSearchStrategy = securitySolutionSearchStrategyProvider(depsStart.data); + const securitySolutionSearchStrategy = securitySolutionSearchStrategyProvider( + depsStart.data, + endpointContext + ); const securitySolutionTimelineSearchStrategy = securitySolutionTimelineSearchStrategyProvider( depsStart.data ); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/index.tsx b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/index.tsx index 9e85eefe21e8a..fa78a8d59803d 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/index.tsx +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/index.tsx @@ -58,7 +58,6 @@ export const authentications: SecuritySolutionFactory fakeTotalCount; - return { ...response, inspect, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/__mocks__/index.ts index 7561682e070fc..9dfff5e11715d 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/__mocks__/index.ts @@ -1370,6 +1370,20 @@ export const formattedSearchStrategyResponse = { terms: { field: 'cloud.region', size: 10, order: { timestamp: 'desc' } }, aggs: { timestamp: { max: { field: '@timestamp' } } }, }, + endpoint_id: { + filter: { + term: { + 'agent.type': 'endpoint', + }, + }, + aggs: { + value: { + terms: { + field: 'agent.id', + }, + }, + }, + }, }, query: { bool: { @@ -1413,6 +1427,20 @@ export const expectedDsl = { track_total_hits: false, body: { aggregations: { + endpoint_id: { + filter: { + term: { + 'agent.type': 'endpoint', + }, + }, + aggs: { + value: { + terms: { + field: 'agent.id', + }, + }, + }, + }, host_architecture: { terms: { field: 'host.architecture', diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts index a581370cb5720..1b6e927f33638 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/helpers.ts @@ -7,16 +7,23 @@ import { set } from '@elastic/safer-lodash-set/fp'; import { get, has, head } from 'lodash/fp'; +import { + IScopedClusterClient, + SavedObjectsClientContract, +} from '../../../../../../../../../src/core/server'; import { hostFieldsMap } from '../../../../../../common/ecs/ecs_fields'; -import { toObjectArrayOfStrings } from '../../../../../../common/utils/to_array'; import { Direction } from '../../../../../../common/search_strategy/common'; import { AggregationRequest, + EndpointFields, HostAggEsItem, HostBuckets, HostItem, HostValue, } from '../../../../../../common/search_strategy/security_solution/hosts'; +import { toObjectArrayOfStrings } from '../../../../../../common/utils/to_array'; +import { getHostMetaData } from '../../../../../endpoint/routes/metadata/handlers'; +import { EndpointAppContext } from '../../../../../endpoint/types'; export const HOST_FIELDS = [ '_id', @@ -38,6 +45,8 @@ export const HOST_FIELDS = [ 'endpoint.endpointPolicy', 'endpoint.policyStatus', 'endpoint.sensorVersion', + 'agent.type', + 'endpoint.id', ]; export const buildFieldsTermAggregation = (esFields: readonly string[]): AggregationRequest => @@ -99,8 +108,8 @@ const getTermsAggregationTypeFromField = (field: string): AggregationRequest => }; }; -export const formatHostItem = (bucket: HostAggEsItem): HostItem => - HOST_FIELDS.reduce((flattenedFields, fieldName) => { +export const formatHostItem = (bucket: HostAggEsItem): HostItem => { + return HOST_FIELDS.reduce((flattenedFields, fieldName) => { const fieldValue = getHostFieldValue(fieldName, bucket); if (fieldValue != null) { if (fieldName === '_id') { @@ -114,11 +123,13 @@ export const formatHostItem = (bucket: HostAggEsItem): HostItem => } return flattenedFields; }, {}); +}; const getHostFieldValue = (fieldName: string, bucket: HostAggEsItem): string | string[] | null => { const aggField = hostFieldsMap[fieldName] ? hostFieldsMap[fieldName].replace(/\./g, '_') : fieldName.replace(/\./g, '_'); + if ( [ 'host.ip', @@ -134,10 +145,7 @@ const getHostFieldValue = (fieldName: string, bucket: HostAggEsItem): string | s return data.buckets.map((obj) => obj.key); } else if (has(`${aggField}.buckets`, bucket)) { return getFirstItem(get(`${aggField}`, bucket)); - } else if (has(aggField, bucket)) { - const valueObj: HostValue = get(aggField, bucket); - return valueObj.value_as_string; - } else if (['host.name', 'host.os.name', 'host.os.version'].includes(fieldName)) { + } else if (['host.name', 'host.os.name', 'host.os.version', 'endpoint.id'].includes(fieldName)) { switch (fieldName) { case 'host.name': return get('key', bucket) || null; @@ -145,7 +153,12 @@ const getHostFieldValue = (fieldName: string, bucket: HostAggEsItem): string | s return get('os.hits.hits[0]._source.host.os.name', bucket) || null; case 'host.os.version': return get('os.hits.hits[0]._source.host.os.version', bucket) || null; + case 'endpoint.id': + return get('endpoint_id.value.buckets[0].key', bucket) || null; } + } else if (has(aggField, bucket)) { + const valueObj: HostValue = get(aggField, bucket); + return valueObj.value_as_string; } else if (aggField === '_id') { const hostName = get(`host_name`, bucket); return hostName ? getFirstItem(hostName) : null; @@ -160,3 +173,42 @@ const getFirstItem = (data: HostBuckets): string | null => { } return firstItem.key; }; + +export const getHostEndpoint = async ( + id: string | null, + deps: { + esClient: IScopedClusterClient; + savedObjectsClient: SavedObjectsClientContract; + endpointContext: EndpointAppContext; + } +): Promise => { + const { esClient, endpointContext, savedObjectsClient } = deps; + const logger = endpointContext.logFactory.get('metadata'); + try { + const agentService = endpointContext.service.getAgentService(); + if (agentService === undefined) { + throw new Error('agentService not available'); + } + const metadataRequestContext = { + esClient, + endpointAppContextService: endpointContext.service, + logger, + savedObjectsClient, + }; + const endpointData = + id != null && metadataRequestContext.endpointAppContextService.getAgentService() != null + ? await getHostMetaData(metadataRequestContext, id, undefined) + : null; + + return endpointData != null && endpointData.metadata + ? { + endpointPolicy: endpointData.metadata.Endpoint.policy.applied.name, + policyStatus: endpointData.metadata.Endpoint.policy.applied.status, + sensorVersion: endpointData.metadata.agent.version, + } + : null; + } catch (err) { + logger.warn(JSON.stringify(err, null, 2)); + return null; + } +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/index.test.tsx b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/index.test.tsx index 244b826c7caeb..4474b9f288570 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/index.test.tsx +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/index.test.tsx @@ -12,6 +12,32 @@ import { mockSearchStrategyResponse, formattedSearchStrategyResponse, } from './__mocks__'; +import { + IScopedClusterClient, + SavedObjectsClientContract, +} from '../../../../../../../../../src/core/server'; +import { EndpointAppContext } from '../../../../../endpoint/types'; +import { EndpointAppContextService } from '../../../../../endpoint/endpoint_app_context_services'; + +const mockDeps = { + esClient: {} as IScopedClusterClient, + savedObjectsClient: {} as SavedObjectsClientContract, + endpointContext: { + logFactory: { + get: jest.fn().mockReturnValue({ + warn: jest.fn(), + }), + }, + config: jest.fn().mockResolvedValue({}), + experimentalFeatures: { + trustedAppsByPolicyEnabled: false, + metricsEntitiesEnabled: false, + eventFilteringEnabled: false, + hostIsolationEnabled: false, + }, + service: {} as EndpointAppContextService, + } as EndpointAppContext, +}; describe('hostDetails search strategy', () => { const buildHostDetailsQuery = jest.spyOn(buildQuery, 'buildHostDetailsQuery'); @@ -29,7 +55,7 @@ describe('hostDetails search strategy', () => { describe('parse', () => { test('should parse data correctly', async () => { - const result = await hostDetails.parse(mockOptions, mockSearchStrategyResponse); + const result = await hostDetails.parse(mockOptions, mockSearchStrategyResponse, mockDeps); expect(result).toMatchObject(formattedSearchStrategyResponse); }); }); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/index.ts index 5da64cc8f7a90..562b7e4fbc167 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/index.ts @@ -10,28 +10,58 @@ import { get } from 'lodash/fp'; import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; import { HostAggEsData, - HostAggEsItem, HostDetailsStrategyResponse, HostsQueries, HostDetailsRequestOptions, + EndpointFields, } from '../../../../../../common/search_strategy/security_solution/hosts'; import { inspectStringifyObject } from '../../../../../utils/build_query'; import { SecuritySolutionFactory } from '../../types'; import { buildHostDetailsQuery } from './query.host_details.dsl'; -import { formatHostItem } from './helpers'; +import { formatHostItem, getHostEndpoint } from './helpers'; +import { EndpointAppContext } from '../../../../../endpoint/types'; +import { + IScopedClusterClient, + SavedObjectsClientContract, +} from '../../../../../../../../../src/core/server'; export const hostDetails: SecuritySolutionFactory = { buildDsl: (options: HostDetailsRequestOptions) => buildHostDetailsQuery(options), parse: async ( options: HostDetailsRequestOptions, - response: IEsSearchResponse + response: IEsSearchResponse, + deps?: { + esClient: IScopedClusterClient; + savedObjectsClient: SavedObjectsClientContract; + endpointContext: EndpointAppContext; + } ): Promise => { - const aggregations: HostAggEsItem = get('aggregations', response.rawResponse) || {}; + const aggregations = get('aggregations', response.rawResponse); + const inspect = { dsl: [inspectStringifyObject(buildHostDetailsQuery(options))], }; + + if (aggregations == null) { + return { ...response, inspect, hostDetails: {} }; + } + const formattedHostItem = formatHostItem(aggregations); - return { ...response, inspect, hostDetails: formattedHostItem }; + const ident = // endpoint-generated ID, NOT elastic-agent-id + formattedHostItem.endpoint && formattedHostItem.endpoint.id + ? Array.isArray(formattedHostItem.endpoint.id) + ? formattedHostItem.endpoint.id[0] + : formattedHostItem.endpoint.id + : null; + if (deps == null) { + return { ...response, inspect, hostDetails: { ...formattedHostItem } }; + } + const endpoint: EndpointFields | null = await getHostEndpoint(ident, deps); + return { + ...response, + inspect, + hostDetails: endpoint != null ? { ...formattedHostItem, endpoint } : formattedHostItem, + }; }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/query.host_details.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/query.host_details.dsl.ts index fb8296d6593b0..45afed2526aa3 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/query.host_details.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/details/query.host_details.dsl.ts @@ -16,7 +16,10 @@ export const buildHostDetailsQuery = ({ defaultIndex, timerange: { from, to }, }: HostDetailsRequestOptions): ISearchRequestParams => { - const esFields = reduceFields(HOST_FIELDS, { ...hostFieldsMap, ...cloudFieldsMap }); + const esFields = reduceFields(HOST_FIELDS, { + ...hostFieldsMap, + ...cloudFieldsMap, + }); const filter = [ { term: { 'host.name': hostName } }, @@ -39,6 +42,20 @@ export const buildHostDetailsQuery = ({ body: { aggregations: { ...buildFieldsTermAggregation(esFields.filter((field) => !['@timestamp'].includes(field))), + endpoint_id: { + filter: { + term: { + 'agent.type': 'endpoint', + }, + }, + aggs: { + value: { + terms: { + field: 'agent.id', + }, + }, + }, + }, }, query: { bool: { filter } }, size: 0, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/types.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/types.ts index 3455b627144bf..4bdf97b489805 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/types.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/types.ts @@ -5,6 +5,10 @@ * 2.0. */ +import { + IScopedClusterClient, + SavedObjectsClientContract, +} from '../../../../../../../src/core/server'; import { IEsSearchResponse, ISearchRequestParams, @@ -14,11 +18,17 @@ import { StrategyRequestType, StrategyResponseType, } from '../../../../common/search_strategy/security_solution'; +import { EndpointAppContext } from '../../../endpoint/types'; export interface SecuritySolutionFactory { buildDsl: (options: StrategyRequestType) => ISearchRequestParams; parse: ( options: StrategyRequestType, - response: IEsSearchResponse + response: IEsSearchResponse, + deps?: { + esClient: IScopedClusterClient; + savedObjectsClient: SavedObjectsClientContract; + endpointContext: EndpointAppContext; + } ) => Promise>; } diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/index.ts index 2980f63df8a67..0883a144615bc 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/index.ts @@ -19,9 +19,11 @@ import { } from '../../../common/search_strategy/security_solution'; import { securitySolutionFactory } from './factory'; import { SecuritySolutionFactory } from './factory/types'; +import { EndpointAppContext } from '../../endpoint/types'; export const securitySolutionSearchStrategyProvider = ( - data: PluginStart + data: PluginStart, + endpointContext: EndpointAppContext ): ISearchStrategy, StrategyResponseType> => { const es = data.search.getSearchStrategy(ENHANCED_ES_SEARCH_STRATEGY); @@ -42,7 +44,13 @@ export const securitySolutionSearchStrategyProvider = queryFactory.parse(request, esSearchRes)) + mergeMap((esSearchRes) => + queryFactory.parse(request, esSearchRes, { + esClient: deps.esClient, + savedObjectsClient: deps.savedObjectsClient, + endpointContext, + }) + ) ); }, cancel: async (id, options, deps) => { diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index 8892671551896..1ba1cd1a1f3d4 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -59,7 +59,7 @@ export class SpacesPlugin implements Plugin this.spacesManager.onActiveSpaceChange$, getActiveSpace: () => this.spacesManager.getActiveSpace(), }; diff --git a/x-pack/test/api_integration/apis/management/index_management/indices.js b/x-pack/test/api_integration/apis/management/index_management/indices.js index 3653d9916466d..cef1bdbba754b 100644 --- a/x-pack/test/api_integration/apis/management/index_management/indices.js +++ b/x-pack/test/api_integration/apis/management/index_management/indices.js @@ -34,8 +34,7 @@ export default function ({ getService }) { clearCache, } = registerHelpers({ supertest }); - // Failing: See https://github.com/elastic/kibana/issues/64473 - describe.skip('indices', () => { + describe('indices', () => { after(() => Promise.all([cleanUpEsResources()])); describe('clear cache', () => { diff --git a/x-pack/test/examples/search_examples/index.ts b/x-pack/test/examples/search_examples/index.ts index 65e214cda4cf8..eaaeb22410183 100644 --- a/x-pack/test/examples/search_examples/index.ts +++ b/x-pack/test/examples/search_examples/index.ts @@ -26,5 +26,6 @@ export default function ({ getService, loadTestFile }: PluginFunctionalProviderC loadTestFile(require.resolve('./search_session_example')); loadTestFile(require.resolve('./search_example')); loadTestFile(require.resolve('./search_sessions_cache')); + loadTestFile(require.resolve('./partial_results_example')); }); } diff --git a/x-pack/test/examples/search_examples/partial_results_example.ts b/x-pack/test/examples/search_examples/partial_results_example.ts new file mode 100644 index 0000000000000..269b2e79ab38f --- /dev/null +++ b/x-pack/test/examples/search_examples/partial_results_example.ts @@ -0,0 +1,38 @@ +/* + * 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 retry = getService('retry'); + + describe('Partial results example', () => { + before(async () => { + await PageObjects.common.navigateToApp('searchExamples'); + await testSubjects.click('/search'); + }); + + it('should update a progress bar', async () => { + await testSubjects.click('responseTab'); + const progressBar = await testSubjects.find('progressBar'); + + const value = await progressBar.getAttribute('value'); + expect(value).to.be('0'); + + await testSubjects.click('requestFibonacci'); + + await retry.waitFor('update progress bar', async () => { + const newValue = await progressBar.getAttribute('value'); + return parseFloat(newValue) > 0; + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/maps/mapbox_styles.js b/x-pack/test/functional/apps/maps/mapbox_styles.js index b483b95e0ca1f..31cb366f826c8 100644 --- a/x-pack/test/functional/apps/maps/mapbox_styles.js +++ b/x-pack/test/functional/apps/maps/mapbox_styles.js @@ -40,13 +40,10 @@ export default function ({ getPageObjects, getService }) { maxzoom: 24, filter: [ 'all', + ['!=', ['get', '__kbn_too_many_features__'], true], + ['!=', ['get', '__kbn_is_centroid_feature__'], true], + ['any', ['==', ['geometry-type'], 'Point'], ['==', ['geometry-type'], 'MultiPoint']], ['==', ['get', '__kbn_isvisibleduetojoin__'], true], - [ - 'all', - ['!=', ['get', '__kbn_too_many_features__'], true], - ['!=', ['get', '__kbn_is_centroid_feature__'], true], - ['any', ['==', ['geometry-type'], 'Point'], ['==', ['geometry-type'], 'MultiPoint']], - ], ], layout: { visibility: 'visible' }, paint: { @@ -124,17 +121,10 @@ export default function ({ getPageObjects, getService }) { maxzoom: 24, filter: [ 'all', + ['!=', ['get', '__kbn_too_many_features__'], true], + ['!=', ['get', '__kbn_is_centroid_feature__'], true], + ['any', ['==', ['geometry-type'], 'Polygon'], ['==', ['geometry-type'], 'MultiPolygon']], ['==', ['get', '__kbn_isvisibleduetojoin__'], true], - [ - 'all', - ['!=', ['get', '__kbn_too_many_features__'], true], - ['!=', ['get', '__kbn_is_centroid_feature__'], true], - [ - 'any', - ['==', ['geometry-type'], 'Polygon'], - ['==', ['geometry-type'], 'MultiPolygon'], - ], - ], ], layout: { visibility: 'visible' }, paint: { @@ -208,19 +198,16 @@ export default function ({ getPageObjects, getService }) { maxzoom: 24, filter: [ 'all', - ['==', ['get', '__kbn_isvisibleduetojoin__'], true], + ['!=', ['get', '__kbn_too_many_features__'], true], + ['!=', ['get', '__kbn_is_centroid_feature__'], true], [ - 'all', - ['!=', ['get', '__kbn_too_many_features__'], true], - ['!=', ['get', '__kbn_is_centroid_feature__'], true], - [ - 'any', - ['==', ['geometry-type'], 'Polygon'], - ['==', ['geometry-type'], 'MultiPolygon'], - ['==', ['geometry-type'], 'LineString'], - ['==', ['geometry-type'], 'MultiLineString'], - ], + 'any', + ['==', ['geometry-type'], 'Polygon'], + ['==', ['geometry-type'], 'MultiPolygon'], + ['==', ['geometry-type'], 'LineString'], + ['==', ['geometry-type'], 'MultiLineString'], ], + ['==', ['get', '__kbn_isvisibleduetojoin__'], true], ], layout: { visibility: 'visible' }, paint: { 'line-color': '#41937c', 'line-opacity': 0.75, 'line-width': 1 }, diff --git a/x-pack/test/security_api_integration/kerberos.config.ts b/x-pack/test/security_api_integration/kerberos.config.ts index bdd96bae0d9fb..c3f647aadc6d1 100644 --- a/x-pack/test/security_api_integration/kerberos.config.ts +++ b/x-pack/test/security_api_integration/kerberos.config.ts @@ -32,11 +32,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { 'xpack.security.authc.realms.kerberos.kerb1.order=0', `xpack.security.authc.realms.kerberos.kerb1.keytab.path=${kerberosKeytabPath}`, ], - serverEnvVars: { - // We're going to use the same TGT multiple times and during a short period of time, so we - // have to disable replay cache so that ES doesn't complain about that. - ES_JAVA_OPTS: `-Djava.security.krb5.conf=${kerberosConfigPath} -Dsun.security.krb5.rcache=none`, - }, + + // We're going to use the same TGT multiple times and during a short period of time, so we + // have to disable replay cache so that ES doesn't complain about that. + esJavaOpts: `-Djava.security.krb5.conf=${kerberosConfigPath} -Dsun.security.krb5.rcache=none`, }, kbnTestServer: { diff --git a/x-pack/test/security_api_integration/login_selector.config.ts b/x-pack/test/security_api_integration/login_selector.config.ts index 9603356568011..f9ef0903b39aa 100644 --- a/x-pack/test/security_api_integration/login_selector.config.ts +++ b/x-pack/test/security_api_integration/login_selector.config.ts @@ -96,11 +96,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `xpack.security.authc.realms.saml.saml2.sp.acs=http://localhost:${kibanaPort}/api/security/saml/callback`, 'xpack.security.authc.realms.saml.saml2.attributes.principal=urn:oid:0.0.7', ], - serverEnvVars: { - // We're going to use the same TGT multiple times and during a short period of time, so we - // have to disable replay cache so that ES doesn't complain about that. - ES_JAVA_OPTS: `-Djava.security.krb5.conf=${kerberosConfigPath} -Dsun.security.krb5.rcache=none`, - }, + + // We're going to use the same TGT multiple times and during a short period of time, so we + // have to disable replay cache so that ES doesn't complain about that. + esJavaOpts: `-Djava.security.krb5.conf=${kerberosConfigPath} -Dsun.security.krb5.rcache=none`, }, kbnTestServer: { diff --git a/x-pack/test/stack_functional_integration/apps/ccs/ccs_discover.js b/x-pack/test/stack_functional_integration/apps/ccs/ccs_discover.js index 588ff9a6e9f92..7de23c2899b67 100644 --- a/x-pack/test/stack_functional_integration/apps/ccs/ccs_discover.js +++ b/x-pack/test/stack_functional_integration/apps/ccs/ccs_discover.js @@ -24,6 +24,7 @@ export default ({ getService, getPageObjects }) => { const kibanaServer = getService('kibanaServer'); const queryBar = getService('queryBar'); const filterBar = getService('filterBar'); + const supertest = getService('supertest'); before(async () => { await browser.setWindowSize(1200, 800); @@ -97,6 +98,8 @@ export default ({ getService, getPageObjects }) => { ); await PageObjects.security.logout(); } + // visit app/security so to create .siem-signals-* as side effect + await PageObjects.common.navigateToApp('security', { insertTimestamp: false }); const url = await browser.getCurrentUrl(); log.debug(url); if (!url.includes('kibana')) { @@ -135,6 +138,35 @@ export default ({ getService, getPageObjects }) => { expect(patternName).to.be('*:makelogs工程-*'); }); + it('create local siem signals index pattern', async () => { + log.debug('Add index pattern: .siem-signals-*'); + await supertest + .post('/api/index_patterns/index_pattern') + .set('kbn-xsrf', 'true') + .send({ + index_pattern: { + title: '.siem-signals-*', + }, + override: true, + }) + .expect(200); + }); + + it('create remote monitoring ES index pattern', async () => { + log.debug('Add index pattern: data:.monitoring-es-*'); + await supertest + .post('/api/index_patterns/index_pattern') + .set('kbn-xsrf', 'true') + .send({ + index_pattern: { + title: 'data:.monitoring-es-*', + timeFieldName: 'timestamp', + }, + override: true, + }) + .expect(200); + }); + it('local:makelogs(star) should discover data from the local cluster', async () => { await PageObjects.common.navigateToApp('discover', { insertTimestamp: false }); @@ -203,5 +235,36 @@ export default ({ getService, getPageObjects }) => { expect(hitCount).to.be.lessThan(originalHitCount); }); }); + + it('should generate alerts based on remote events', async () => { + log.debug('Add detection rule type:shards on data:.monitoring-es-*'); + await supertest + .post('/api/detection_engine/rules') + .set('kbn-xsrf', 'true') + .send({ + description: 'This is the description of the rule', + risk_score: 17, + severity: 'low', + interval: '10s', + name: 'CCS_Detection_test', + type: 'query', + from: 'now-1d', + index: ['data:.monitoring-es-*'], + timestamp_override: 'timestamp', + query: 'type:shards', + language: 'kuery', + enabled: true, + }) + .expect(200); + + log.debug('Check if any alert got to .siem-signals-*'); + await PageObjects.common.navigateToApp('discover', { insertTimestamp: false }); + await PageObjects.discover.selectIndexPattern('.siem-signals-*'); + await retry.tryForTime(40000, async () => { + const hitCount = await PageObjects.discover.getHitCount(); + log.debug('### hit count = ' + hitCount); + expect(hitCount).to.be.greaterThan('0'); + }); + }); }); };