diff --git a/.eslintignore b/.eslintignore index 972c818d791bf4..93c69b4f9b2077 100644 --- a/.eslintignore +++ b/.eslintignore @@ -39,7 +39,7 @@ target /x-pack/legacy/plugins/maps/public/vendor/** # package overrides -/packages/eslint-config-kibana +/packages/elastic-eslint-config-kibana /packages/kbn-interpreter/src/common/lib/grammar.js /packages/kbn-plugin-generator/template /packages/kbn-pm/dist diff --git a/.eslintrc.js b/.eslintrc.js index ff4ac180c3774b..9b75c36c95abdb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -94,12 +94,6 @@ module.exports = { 'jsx-a11y/no-onchange': 'off', }, }, - { - files: ['src/plugins/es_ui_shared/**/*.{js,mjs,ts,tsx}'], - rules: { - 'react-hooks/exhaustive-deps': 'off', - }, - }, { files: ['src/plugins/kibana_react/**/*.{js,mjs,ts,tsx}'], rules: { @@ -125,25 +119,12 @@ module.exports = { 'jsx-a11y/click-events-have-key-events': 'off', }, }, - { - files: ['x-pack/legacy/plugins/index_management/**/*.{js,mjs,ts,tsx}'], - rules: { - 'react-hooks/exhaustive-deps': 'off', - 'react-hooks/rules-of-hooks': 'off', - }, - }, { files: ['x-pack/plugins/ml/**/*.{js,mjs,ts,tsx}'], rules: { 'react-hooks/exhaustive-deps': 'off', }, }, - { - files: ['x-pack/legacy/plugins/snapshot_restore/**/*.{js,mjs,ts,tsx}'], - rules: { - 'react-hooks/exhaustive-deps': 'off', - }, - }, /** * Files that require Apache 2.0 headers, settings diff --git a/docs/developer/getting-started/running-kibana-advanced.asciidoc b/docs/developer/getting-started/running-kibana-advanced.asciidoc index 44897184f88f25..277e52a3dc8e91 100644 --- a/docs/developer/getting-started/running-kibana-advanced.asciidoc +++ b/docs/developer/getting-started/running-kibana-advanced.asciidoc @@ -48,7 +48,7 @@ If you’re installing dependencies and seeing an error that looks something like .... -Unsupported URL Type: link:packages/eslint-config-kibana +Unsupported URL Type: link:packages/elastic-eslint-config-kibana .... you’re likely running `npm`. To install dependencies in {kib} you diff --git a/packages/eslint-config-kibana/.eslintrc.js b/packages/elastic-eslint-config-kibana/.eslintrc.js similarity index 100% rename from packages/eslint-config-kibana/.eslintrc.js rename to packages/elastic-eslint-config-kibana/.eslintrc.js diff --git a/packages/eslint-config-kibana/.gitignore b/packages/elastic-eslint-config-kibana/.gitignore similarity index 100% rename from packages/eslint-config-kibana/.gitignore rename to packages/elastic-eslint-config-kibana/.gitignore diff --git a/packages/eslint-config-kibana/.npmignore b/packages/elastic-eslint-config-kibana/.npmignore similarity index 100% rename from packages/eslint-config-kibana/.npmignore rename to packages/elastic-eslint-config-kibana/.npmignore diff --git a/packages/eslint-config-kibana/README.md b/packages/elastic-eslint-config-kibana/README.md similarity index 94% rename from packages/eslint-config-kibana/README.md rename to packages/elastic-eslint-config-kibana/README.md index 68c1639b834a5e..2049440cd8ff77 100644 --- a/packages/eslint-config-kibana/README.md +++ b/packages/elastic-eslint-config-kibana/README.md @@ -1,4 +1,4 @@ -# eslint-config-kibana +# elastic-eslint-config-kibana The eslint config used by the kibana team diff --git a/packages/eslint-config-kibana/javascript.js b/packages/elastic-eslint-config-kibana/javascript.js similarity index 100% rename from packages/eslint-config-kibana/javascript.js rename to packages/elastic-eslint-config-kibana/javascript.js diff --git a/packages/eslint-config-kibana/jest.js b/packages/elastic-eslint-config-kibana/jest.js similarity index 100% rename from packages/eslint-config-kibana/jest.js rename to packages/elastic-eslint-config-kibana/jest.js diff --git a/packages/eslint-config-kibana/package.json b/packages/elastic-eslint-config-kibana/package.json similarity index 89% rename from packages/eslint-config-kibana/package.json rename to packages/elastic-eslint-config-kibana/package.json index 4ec3bcdfd7c052..a4bb8d5449ee8f 100644 --- a/packages/eslint-config-kibana/package.json +++ b/packages/elastic-eslint-config-kibana/package.json @@ -5,15 +5,15 @@ "main": ".eslintrc.js", "repository": { "type": "git", - "url": "git+https://github.com/elastic/eslint-config-kibana.git" + "url": "git+https://github.com/elastic/kibana.git" }, "keywords": [], "author": "Spencer Alger ", "license": "Apache-2.0", "bugs": { - "url": "https://github.com/elastic/kibana/tree/master/packages/eslint-config-kibana" + "url": "https://github.com/elastic/kibana/tree/master/packages/elastic-eslint-config-kibana" }, - "homepage": "https://github.com/elastic/kibana/tree/master/packages/eslint-config-kibana", + "homepage": "https://github.com/elastic/kibana/tree/master/packages/elastic-eslint-config-kibana", "peerDependencies": { "@typescript-eslint/eslint-plugin": "^3.10.0", "@typescript-eslint/parser": "^3.10.0", diff --git a/packages/eslint-config-kibana/react.js b/packages/elastic-eslint-config-kibana/react.js similarity index 100% rename from packages/eslint-config-kibana/react.js rename to packages/elastic-eslint-config-kibana/react.js diff --git a/packages/eslint-config-kibana/restricted_globals.js b/packages/elastic-eslint-config-kibana/restricted_globals.js similarity index 100% rename from packages/eslint-config-kibana/restricted_globals.js rename to packages/elastic-eslint-config-kibana/restricted_globals.js diff --git a/packages/eslint-config-kibana/typescript.js b/packages/elastic-eslint-config-kibana/typescript.js similarity index 100% rename from packages/eslint-config-kibana/typescript.js rename to packages/elastic-eslint-config-kibana/typescript.js diff --git a/packages/kbn-pm/README.md b/packages/kbn-pm/README.md index 05405e7b98374f..c169b5c75e178b 100644 --- a/packages/kbn-pm/README.md +++ b/packages/kbn-pm/README.md @@ -19,7 +19,7 @@ From a plugin perspective there are two different types of Kibana dependencies: runtime and static dependencies. Runtime dependencies are things that are instantiated at runtime and that are injected into the plugin, for example config and elasticsearch clients. Static dependencies are those dependencies -that we want to `import`. `eslint-config-kibana` is one example of this, and +that we want to `import`. `elastic-eslint-config-kibana` is one example of this, and it's actually needed because eslint requires it to be a separate package. But we also have dependencies like `datemath`, `flot`, `eui` and others that we control, but where we want to `import` them in plugins instead of injecting them diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/global_flyout.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/global_flyout.tsx index aa575cd64944cd..548e477c7c411e 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/global_flyout.tsx +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/global_flyout.tsx @@ -160,6 +160,8 @@ export const useGlobalFlyout = () => { Array.from(getContents()).forEach(removeContent); } }; + // https://github.com/elastic/kibana/issues/73970 + /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [removeContent]); return { ...ctx, addContent }; diff --git a/src/plugins/es_ui_shared/public/components/json_editor/json_editor.tsx b/src/plugins/es_ui_shared/public/components/json_editor/json_editor.tsx index 8c63cc8494a8b6..7d21722781d607 100644 --- a/src/plugins/es_ui_shared/public/components/json_editor/json_editor.tsx +++ b/src/plugins/es_ui_shared/public/components/json_editor/json_editor.tsx @@ -52,6 +52,8 @@ export const JsonEditor = React.memo( isControlled, }); + // https://github.com/elastic/kibana/issues/73971 + /* eslint-disable-next-line react-hooks/exhaustive-deps */ const debouncedSetContent = useCallback(debounce(setContent, 300), [setContent]); // We let the consumer control the validation and the error message. @@ -76,6 +78,7 @@ export const JsonEditor = React.memo( debouncedSetContent(updated); } }, + /* eslint-disable-next-line react-hooks/exhaustive-deps */ [isControlled] ); diff --git a/src/plugins/es_ui_shared/public/components/json_editor/use_json.ts b/src/plugins/es_ui_shared/public/components/json_editor/use_json.ts index 1b5ca5d7f43849..0ba39f5f05fe62 100644 --- a/src/plugins/es_ui_shared/public/components/json_editor/use_json.ts +++ b/src/plugins/es_ui_shared/public/components/json_editor/use_json.ts @@ -84,6 +84,8 @@ export const useJson = ({ } else { didMount.current = true; } + // https://github.com/elastic/kibana/issues/73971 + /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [content]); return { diff --git a/src/plugins/es_ui_shared/public/index.ts b/src/plugins/es_ui_shared/public/index.ts index bdea5ccf5fe26f..995ae0ba428374 100644 --- a/src/plugins/es_ui_shared/public/index.ts +++ b/src/plugins/es_ui_shared/public/index.ts @@ -39,7 +39,7 @@ export { UseRequestResponse, sendRequest, useRequest, -} from './request/np_ready_request'; +} from './request'; export { indices } from './indices'; diff --git a/src/plugins/es_ui_shared/public/request/index.ts b/src/plugins/es_ui_shared/public/request/index.ts index f942a9cc3932bb..9e38bfe5ee16b3 100644 --- a/src/plugins/es_ui_shared/public/request/index.ts +++ b/src/plugins/es_ui_shared/public/request/index.ts @@ -17,11 +17,5 @@ * under the License. */ -export { - SendRequestConfig, - SendRequestResponse, - UseRequestConfig, - UseRequestResponse, - sendRequest, - useRequest, -} from './request'; +export { SendRequestConfig, SendRequestResponse, sendRequest } from './send_request'; +export { UseRequestConfig, UseRequestResponse, useRequest } from './use_request'; diff --git a/src/plugins/es_ui_shared/public/request/np_ready_request.ts b/src/plugins/es_ui_shared/public/request/np_ready_request.ts deleted file mode 100644 index 06af698f2ce023..00000000000000 --- a/src/plugins/es_ui_shared/public/request/np_ready_request.ts +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { useEffect, useState, useRef, useMemo } from 'react'; - -import { HttpSetup, HttpFetchQuery } from '../../../../../src/core/public'; - -export interface SendRequestConfig { - path: string; - method: 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head'; - query?: HttpFetchQuery; - body?: any; -} - -export interface SendRequestResponse { - data: D | null; - error: E | null; -} - -export interface UseRequestConfig extends SendRequestConfig { - pollIntervalMs?: number; - initialData?: any; - deserializer?: (data: any) => any; -} - -export interface UseRequestResponse { - isInitialRequest: boolean; - isLoading: boolean; - error: E | null; - data?: D | null; - sendRequest: (...args: any[]) => Promise>; -} - -export const sendRequest = async ( - httpClient: HttpSetup, - { path, method, body, query }: SendRequestConfig -): Promise> => { - try { - const stringifiedBody = typeof body === 'string' ? body : JSON.stringify(body); - const response = await httpClient[method](path, { body: stringifiedBody, query }); - - return { - data: response.data ? response.data : response, - error: null, - }; - } catch (e) { - return { - data: null, - error: e.response && e.response.data ? e.response.data : e.body, - }; - } -}; - -export const useRequest = ( - httpClient: HttpSetup, - { - path, - method, - query, - body, - pollIntervalMs, - initialData, - deserializer = (data: any): any => data, - }: UseRequestConfig -): UseRequestResponse => { - const sendRequestRef = useRef<() => Promise>>(); - // Main states for tracking request status and data - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [data, setData] = useState(initialData); - - // Consumers can use isInitialRequest to implement a polling UX. - const [isInitialRequest, setIsInitialRequest] = useState(true); - const pollInterval = useRef(null); - const pollIntervalId = useRef(null); - - // We always want to use the most recently-set interval in scheduleRequest. - pollInterval.current = pollIntervalMs; - - // Tied to every render and bound to each request. - let isOutdatedRequest = false; - - const scheduleRequest = () => { - // Clear current interval - if (pollIntervalId.current) { - clearTimeout(pollIntervalId.current); - } - - // Set new interval - if (pollInterval.current) { - pollIntervalId.current = setTimeout( - () => (sendRequestRef.current ?? _sendRequest)(), - pollInterval.current - ); - } - }; - - const _sendRequest = async () => { - // We don't clear error or data, so it's up to the consumer to decide whether to display the - // "old" error/data or loading state when a new request is in-flight. - setIsLoading(true); - - const requestBody = { - path, - method, - query, - body, - }; - - const response = await sendRequest(httpClient, requestBody); - const { data: serializedResponseData, error: responseError } = response; - - // If an outdated request has resolved, DON'T update state, but DO allow the processData handler - // to execute side effects like update telemetry. - if (isOutdatedRequest) { - return { data: null, error: null }; - } - - setError(responseError); - - if (!responseError) { - const responseData = deserializer(serializedResponseData); - setData(responseData); - } - - setIsLoading(false); - setIsInitialRequest(false); - - // If we're on an interval, we need to schedule the next request. This also allows us to reset - // the interval if the user has manually requested the data, to avoid doubled-up requests. - scheduleRequest(); - - return { data: serializedResponseData, error: responseError }; - }; - - useEffect(() => { - sendRequestRef.current = _sendRequest; - }, [_sendRequest]); - - const stringifiedQuery = useMemo(() => JSON.stringify(query), [query]); - - useEffect(() => { - (sendRequestRef.current ?? _sendRequest)(); - // To be functionally correct we'd send a new request if the method, path, query or body changes. - // But it doesn't seem likely that the method will change and body is likely to be a new - // object even if its shape hasn't changed, so for now we're just watching the path and the query. - }, [path, stringifiedQuery]); - - useEffect(() => { - scheduleRequest(); - - // Clean up intervals and inflight requests and corresponding state changes - return () => { - isOutdatedRequest = true; - if (pollIntervalId.current) { - clearTimeout(pollIntervalId.current); - } - }; - }, [pollIntervalMs]); - - return { - isInitialRequest, - isLoading, - error, - data, - sendRequest: sendRequestRef.current ?? _sendRequest, // Gives the user the ability to manually request data - }; -}; diff --git a/src/plugins/es_ui_shared/public/request/request.test.js b/src/plugins/es_ui_shared/public/request/request.test.js deleted file mode 100644 index 190c32517eefed..00000000000000 --- a/src/plugins/es_ui_shared/public/request/request.test.js +++ /dev/null @@ -1,262 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import sinon from 'sinon'; -// import { sendRequest as sendRequestUnbound, useRequest as useRequestUnbound } from './request'; - -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { mount } from 'enzyme'; - -const TestHook = ({ callback }) => { - callback(); - return null; -}; - -let element; - -const testHook = (callback) => { - element = mount(); -}; - -const wait = async (wait) => new Promise((resolve) => setTimeout(resolve, wait || 1)); - -// FLAKY: -// - https://github.com/elastic/kibana/issues/42561 -// - https://github.com/elastic/kibana/issues/42562 -// - https://github.com/elastic/kibana/issues/42563 -// - https://github.com/elastic/kibana/issues/42225 -describe.skip('request lib', () => { - const successRequest = { path: '/success', method: 'post', body: {} }; - const errorRequest = { path: '/error', method: 'post', body: {} }; - const successResponse = { statusCode: 200, data: { message: 'Success message' } }; - const errorResponse = { statusCode: 400, statusText: 'Error message' }; - - let sendPost; - let sendRequest; - let useRequest; - - /** - * - * commented out due to hooks being called regardless of skip - * https://github.com/facebook/jest/issues/8379 - - beforeEach(() => { - sendPost = sinon.stub(); - sendPost.withArgs(successRequest.path, successRequest.body).returns(successResponse); - sendPost.withArgs(errorRequest.path, errorRequest.body).throws(errorResponse); - - const httpClient = { - post: (...args) => { - return sendPost(...args); - }, - }; - - sendRequest = sendRequestUnbound.bind(null, httpClient); - useRequest = useRequestUnbound.bind(null, httpClient); - }); - - */ - - describe('sendRequest function', () => { - it('uses the provided path, method, and body to send the request', async () => { - const response = await sendRequest({ ...successRequest }); - sinon.assert.calledOnce(sendPost); - expect(response).toEqual({ data: successResponse.data, error: null }); - }); - - it('surfaces errors', async () => { - try { - await sendRequest({ ...errorRequest }); - } catch (e) { - sinon.assert.calledOnce(sendPost); - expect(e).toBe(errorResponse.error); - } - }); - }); - - describe('useRequest hook', () => { - let hook; - - function initUseRequest(config) { - act(() => { - testHook(() => { - hook = useRequest(config); - }); - }); - } - - describe('parameters', () => { - describe('path, method, body', () => { - it('is used to send the request', async () => { - initUseRequest({ ...successRequest }); - await wait(50); - expect(hook.data).toBe(successResponse.data); - }); - }); - - describe('pollIntervalMs', () => { - it('sends another request after the specified time has elapsed', async () => { - initUseRequest({ ...successRequest, pollIntervalMs: 10 }); - await wait(50); - // We just care that multiple requests have been sent out. We don't check the specific - // timing because that risks introducing flakiness into the tests, and it's unlikely - // we could break the implementation by getting the exact timing wrong. - expect(sendPost.callCount).toBeGreaterThan(1); - - // We have to manually clean up or else the interval will continue to fire requests, - // interfering with other tests. - element.unmount(); - }); - }); - - describe('initialData', () => { - it('sets the initial data value', () => { - initUseRequest({ ...successRequest, initialData: 'initialData' }); - expect(hook.data).toBe('initialData'); - }); - }); - - describe('deserializer', () => { - it('is called once the request resolves', async () => { - const deserializer = sinon.stub(); - initUseRequest({ ...successRequest, deserializer }); - sinon.assert.notCalled(deserializer); - - await wait(50); - sinon.assert.calledOnce(deserializer); - sinon.assert.calledWith(deserializer, successResponse.data); - }); - - it('processes data', async () => { - initUseRequest({ ...successRequest, deserializer: () => 'intercepted' }); - await wait(50); - expect(hook.data).toBe('intercepted'); - }); - }); - }); - - describe('state', () => { - describe('isInitialRequest', () => { - it('is true for the first request and false for subsequent requests', async () => { - initUseRequest({ ...successRequest }); - expect(hook.isInitialRequest).toBe(true); - - hook.sendRequest(); - await wait(50); - expect(hook.isInitialRequest).toBe(false); - }); - }); - - describe('isLoading', () => { - it('represents in-flight request status', async () => { - initUseRequest({ ...successRequest }); - expect(hook.isLoading).toBe(true); - - await wait(50); - expect(hook.isLoading).toBe(false); - }); - }); - - describe('error', () => { - it('surfaces errors from requests', async () => { - initUseRequest({ ...errorRequest }); - await wait(50); - expect(hook.error).toBe(errorResponse); - }); - - it('persists while a request is in-flight', async () => { - initUseRequest({ ...errorRequest }); - await wait(50); - hook.sendRequest(); - expect(hook.isLoading).toBe(true); - expect(hook.error).toBe(errorResponse); - }); - - it('is null when the request is successful', async () => { - initUseRequest({ ...successRequest }); - await wait(50); - expect(hook.isLoading).toBe(false); - expect(hook.error).toBeNull(); - }); - }); - - describe('data', () => { - it('surfaces payloads from requests', async () => { - initUseRequest({ ...successRequest }); - await wait(50); - expect(hook.data).toBe(successResponse.data); - }); - - it('persists while a request is in-flight', async () => { - initUseRequest({ ...successRequest }); - await wait(50); - hook.sendRequest(); - expect(hook.isLoading).toBe(true); - expect(hook.data).toBe(successResponse.data); - }); - - it('is null when the request fails', async () => { - initUseRequest({ ...errorRequest }); - await wait(50); - expect(hook.isLoading).toBe(false); - expect(hook.data).toBeNull(); - }); - }); - }); - - describe('callbacks', () => { - describe('sendRequest', () => { - it('sends the request', () => { - initUseRequest({ ...successRequest }); - sinon.assert.calledOnce(sendPost); - hook.sendRequest(); - sinon.assert.calledTwice(sendPost); - }); - - it('resets the pollIntervalMs', async () => { - initUseRequest({ ...successRequest, pollIntervalMs: 800 }); - await wait(200); // 200ms - hook.sendRequest(); - expect(sendPost.callCount).toBe(2); - - await wait(200); // 400ms - hook.sendRequest(); - - await wait(200); // 600ms - hook.sendRequest(); - - await wait(200); // 800ms - hook.sendRequest(); - - await wait(200); // 1000ms - hook.sendRequest(); - - // If sendRequest didn't reset the interval, the interval would have triggered another - // request by now, and the callCount would be 7. - expect(sendPost.callCount).toBe(6); - - // We have to manually clean up or else the interval will continue to fire requests, - // interfering with other tests. - element.unmount(); - }); - }); - }); - }); -}); diff --git a/src/plugins/es_ui_shared/public/request/request.ts b/src/plugins/es_ui_shared/public/request/request.ts deleted file mode 100644 index fd6980367136eb..00000000000000 --- a/src/plugins/es_ui_shared/public/request/request.ts +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { useEffect, useState, useRef } from 'react'; - -export interface SendRequestConfig { - path: string; - method: 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head'; - body?: any; -} - -export interface SendRequestResponse { - data: any; - error: Error | null; -} - -export interface UseRequestConfig extends SendRequestConfig { - pollIntervalMs?: number; - initialData?: any; - deserializer?: (data: any) => any; -} - -export interface UseRequestResponse { - isInitialRequest: boolean; - isLoading: boolean; - error: null | unknown; - data: any; - sendRequest: (...args: any[]) => Promise; -} - -export const sendRequest = async ( - httpClient: ng.IHttpService, - { path, method, body }: SendRequestConfig -): Promise => { - try { - const response = await (httpClient as any)[method](path, body); - - if (typeof response.data === 'undefined') { - throw new Error(response.statusText); - } - - return { data: response.data, error: null }; - } catch (e) { - return { - data: null, - error: e.response ? e.response : e, - }; - } -}; - -export const useRequest = ( - httpClient: ng.IHttpService, - { - path, - method, - body, - pollIntervalMs, - initialData, - deserializer = (data: any): any => data, - }: UseRequestConfig -): UseRequestResponse => { - // Main states for tracking request status and data - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [data, setData] = useState(initialData); - - // Consumers can use isInitialRequest to implement a polling UX. - const [isInitialRequest, setIsInitialRequest] = useState(true); - const pollInterval = useRef(null); - const pollIntervalId = useRef(null); - - // We always want to use the most recently-set interval in scheduleRequest. - pollInterval.current = pollIntervalMs; - - // Tied to every render and bound to each request. - let isOutdatedRequest = false; - - const scheduleRequest = () => { - // Clear current interval - if (pollIntervalId.current) { - clearTimeout(pollIntervalId.current); - } - - // Set new interval - if (pollInterval.current) { - pollIntervalId.current = setTimeout(_sendRequest, pollInterval.current); - } - }; - - const _sendRequest = async () => { - // We don't clear error or data, so it's up to the consumer to decide whether to display the - // "old" error/data or loading state when a new request is in-flight. - setIsLoading(true); - - const requestBody = { - path, - method, - body, - }; - - const response = await sendRequest(httpClient, requestBody); - const { data: serializedResponseData, error: responseError } = response; - const responseData = deserializer(serializedResponseData); - - // If an outdated request has resolved, DON'T update state, but DO allow the processData handler - // to execute side effects like update telemetry. - if (isOutdatedRequest) { - return { data: null, error: null }; - } - - setError(responseError); - setData(responseData); - setIsLoading(false); - setIsInitialRequest(false); - - // If we're on an interval, we need to schedule the next request. This also allows us to reset - // the interval if the user has manually requested the data, to avoid doubled-up requests. - scheduleRequest(); - - return { data: serializedResponseData, error: responseError }; - }; - - useEffect(() => { - _sendRequest(); - // To be functionally correct we'd send a new request if the method, path, or body changes. - // But it doesn't seem likely that the method will change and body is likely to be a new - // object even if its shape hasn't changed, so for now we're just watching the path. - }, [path]); - - useEffect(() => { - scheduleRequest(); - - // Clean up intervals and inflight requests and corresponding state changes - return () => { - isOutdatedRequest = true; - if (pollIntervalId.current) { - clearTimeout(pollIntervalId.current); - } - }; - }, [pollIntervalMs]); - - return { - isInitialRequest, - isLoading, - error, - data, - sendRequest: _sendRequest, // Gives the user the ability to manually request data - }; -}; diff --git a/src/plugins/es_ui_shared/public/request/send_request.test.helpers.ts b/src/plugins/es_ui_shared/public/request/send_request.test.helpers.ts new file mode 100644 index 00000000000000..4312780e74c0f3 --- /dev/null +++ b/src/plugins/es_ui_shared/public/request/send_request.test.helpers.ts @@ -0,0 +1,83 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import sinon from 'sinon'; + +import { HttpSetup, HttpFetchOptions } from '../../../../../src/core/public'; +import { + SendRequestConfig, + SendRequestResponse, + sendRequest as originalSendRequest, +} from './send_request'; + +export interface SendRequestHelpers { + getSendRequestSpy: () => sinon.SinonStub; + sendSuccessRequest: () => Promise; + getSuccessResponse: () => SendRequestResponse; + sendErrorRequest: () => Promise; + getErrorResponse: () => SendRequestResponse; +} + +const successRequest: SendRequestConfig = { method: 'post', path: '/success', body: {} }; +const successResponse = { statusCode: 200, data: { message: 'Success message' } }; + +const errorValue = { statusCode: 400, statusText: 'Error message' }; +const errorRequest: SendRequestConfig = { method: 'post', path: '/error', body: {} }; +const errorResponse = { response: { data: errorValue } }; + +export const createSendRequestHelpers = (): SendRequestHelpers => { + const sendRequestSpy = sinon.stub(); + const httpClient = { + post: (path: string, options: HttpFetchOptions) => sendRequestSpy(path, options), + }; + const sendRequest = originalSendRequest.bind(null, httpClient as HttpSetup) as ( + config: SendRequestConfig + ) => Promise>; + + // Set up successful request helpers. + sendRequestSpy + .withArgs(successRequest.path, { + body: JSON.stringify(successRequest.body), + query: undefined, + }) + .resolves(successResponse); + const sendSuccessRequest = () => sendRequest({ ...successRequest }); + const getSuccessResponse = () => ({ data: successResponse.data, error: null }); + + // Set up failed request helpers. + sendRequestSpy + .withArgs(errorRequest.path, { + body: JSON.stringify(errorRequest.body), + query: undefined, + }) + .rejects(errorResponse); + const sendErrorRequest = () => sendRequest({ ...errorRequest }); + const getErrorResponse = () => ({ + data: null, + error: errorResponse.response.data, + }); + + return { + getSendRequestSpy: () => sendRequestSpy, + sendSuccessRequest, + getSuccessResponse, + sendErrorRequest, + getErrorResponse, + }; +}; diff --git a/src/plugins/es_ui_shared/public/request/send_request.test.ts b/src/plugins/es_ui_shared/public/request/send_request.test.ts new file mode 100644 index 00000000000000..e4deaeaba817e7 --- /dev/null +++ b/src/plugins/es_ui_shared/public/request/send_request.test.ts @@ -0,0 +1,47 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import sinon from 'sinon'; + +import { SendRequestHelpers, createSendRequestHelpers } from './send_request.test.helpers'; + +describe('sendRequest function', () => { + let helpers: SendRequestHelpers; + + beforeEach(() => { + helpers = createSendRequestHelpers(); + }); + + it('uses the provided path, method, and body to send the request', async () => { + const { sendSuccessRequest, getSendRequestSpy, getSuccessResponse } = helpers; + + const response = await sendSuccessRequest(); + sinon.assert.calledOnce(getSendRequestSpy()); + expect(response).toEqual(getSuccessResponse()); + }); + + it('surfaces errors', async () => { + const { sendErrorRequest, getSendRequestSpy, getErrorResponse } = helpers; + + // For some reason sinon isn't throwing an error on rejection, as an awaited Promise normally would. + const error = await sendErrorRequest(); + sinon.assert.calledOnce(getSendRequestSpy()); + expect(error).toEqual(getErrorResponse()); + }); +}); diff --git a/src/plugins/es_ui_shared/public/request/send_request.ts b/src/plugins/es_ui_shared/public/request/send_request.ts new file mode 100644 index 00000000000000..453e91570cd856 --- /dev/null +++ b/src/plugins/es_ui_shared/public/request/send_request.ts @@ -0,0 +1,52 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { HttpSetup, HttpFetchQuery } from '../../../../../src/core/public'; + +export interface SendRequestConfig { + path: string; + method: 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head'; + query?: HttpFetchQuery; + body?: any; +} + +export interface SendRequestResponse { + data: D | null; + error: E | null; +} + +export const sendRequest = async ( + httpClient: HttpSetup, + { path, method, body, query }: SendRequestConfig +): Promise> => { + try { + const stringifiedBody = typeof body === 'string' ? body : JSON.stringify(body); + const response = await httpClient[method](path, { body: stringifiedBody, query }); + + return { + data: response.data ? response.data : response, + error: null, + }; + } catch (e) { + return { + data: null, + error: e.response?.data ?? e.body, + }; + } +}; diff --git a/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx b/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx new file mode 100644 index 00000000000000..0d6fd122ad22ce --- /dev/null +++ b/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx @@ -0,0 +1,184 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mount, ReactWrapper } from 'enzyme'; +import sinon from 'sinon'; + +import { HttpSetup, HttpFetchOptions } from '../../../../../src/core/public'; +import { SendRequestConfig, SendRequestResponse } from './send_request'; +import { useRequest, UseRequestResponse, UseRequestConfig } from './use_request'; + +export interface UseRequestHelpers { + advanceTime: (ms: number) => Promise; + completeRequest: () => Promise; + hookResult: UseRequestResponse; + getSendRequestSpy: () => sinon.SinonStub; + setupSuccessRequest: (overrides?: {}, requestTimings?: number[]) => void; + getSuccessResponse: () => SendRequestResponse; + setupErrorRequest: (overrides?: {}, requestTimings?: number[]) => void; + getErrorResponse: () => SendRequestResponse; + setErrorResponse: (overrides?: {}) => void; + setupErrorWithBodyRequest: (overrides?: {}) => void; + getErrorWithBodyResponse: () => SendRequestResponse; +} + +// Each request will take 1s to resolve. +export const REQUEST_TIME = 1000; + +const successRequest: SendRequestConfig = { method: 'post', path: '/success', body: {} }; +const successResponse = { statusCode: 200, data: { message: 'Success message' } }; + +const errorValue = { statusCode: 400, statusText: 'Error message' }; +const errorRequest: SendRequestConfig = { method: 'post', path: '/error', body: {} }; +const errorResponse = { response: { data: errorValue } }; + +const errorWithBodyRequest: SendRequestConfig = { + method: 'post', + path: '/errorWithBody', + body: {}, +}; +const errorWithBodyResponse = { body: errorValue }; + +export const createUseRequestHelpers = (): UseRequestHelpers => { + // The behavior we're testing involves state changes over time, so we need finer control over + // timing. + jest.useFakeTimers(); + + const flushPromiseJobQueue = async () => { + // See https://stackoverflow.com/questions/52177631/jest-timer-and-promise-dont-work-well-settimeout-and-async-function + await Promise.resolve(); + }; + + const completeRequest = async () => { + await act(async () => { + jest.runAllTimers(); + await flushPromiseJobQueue(); + }); + }; + + const advanceTime = async (ms: number) => { + await act(async () => { + jest.advanceTimersByTime(ms); + await flushPromiseJobQueue(); + }); + }; + + let element: ReactWrapper; + // We'll use this object to observe the state of the hook and access its callback(s). + const hookResult = {} as UseRequestResponse; + const sendRequestSpy = sinon.stub(); + + const setupUseRequest = (config: UseRequestConfig, requestTimings?: number[]) => { + let requestCount = 0; + + const httpClient = { + post: (path: string, options: HttpFetchOptions) => { + return new Promise((resolve, reject) => { + // Increase the time it takes to resolve a request so we have time to inspect the hook + // as it goes through various states. + setTimeout(() => { + try { + resolve(sendRequestSpy(path, options)); + } catch (e) { + reject(e); + } + }, (requestTimings && requestTimings[requestCount++]) || REQUEST_TIME); + }); + }, + }; + + const TestComponent = ({ requestConfig }: { requestConfig: UseRequestConfig }) => { + const { isInitialRequest, isLoading, error, data, sendRequest } = useRequest( + httpClient as HttpSetup, + requestConfig + ); + + hookResult.isInitialRequest = isInitialRequest; + hookResult.isLoading = isLoading; + hookResult.error = error; + hookResult.data = data; + hookResult.sendRequest = sendRequest; + + return null; + }; + + act(() => { + element = mount(); + }); + }; + + // Set up successful request helpers. + sendRequestSpy + .withArgs(successRequest.path, { + body: JSON.stringify(successRequest.body), + query: undefined, + }) + .resolves(successResponse); + const setupSuccessRequest = (overrides = {}, requestTimings?: number[]) => + setupUseRequest({ ...successRequest, ...overrides }, requestTimings); + const getSuccessResponse = () => ({ data: successResponse.data, error: null }); + + // Set up failed request helpers. + sendRequestSpy + .withArgs(errorRequest.path, { + body: JSON.stringify(errorRequest.body), + query: undefined, + }) + .rejects(errorResponse); + const setupErrorRequest = (overrides = {}, requestTimings?: number[]) => + setupUseRequest({ ...errorRequest, ...overrides }, requestTimings); + const getErrorResponse = () => ({ + data: null, + error: errorResponse.response.data, + }); + // We'll use this to change a success response to an error response, to test how the state changes. + const setErrorResponse = (overrides = {}) => { + element.setProps({ requestConfig: { ...errorRequest, ...overrides } }); + }; + + // Set up failed request helpers with the alternative error shape. + sendRequestSpy + .withArgs(errorWithBodyRequest.path, { + body: JSON.stringify(errorWithBodyRequest.body), + query: undefined, + }) + .rejects(errorWithBodyResponse); + const setupErrorWithBodyRequest = (overrides = {}) => + setupUseRequest({ ...errorWithBodyRequest, ...overrides }); + const getErrorWithBodyResponse = () => ({ + data: null, + error: errorWithBodyResponse.body, + }); + + return { + advanceTime, + completeRequest, + hookResult, + getSendRequestSpy: () => sendRequestSpy, + setupSuccessRequest, + getSuccessResponse, + setupErrorRequest, + getErrorResponse, + setErrorResponse, + setupErrorWithBodyRequest, + getErrorWithBodyResponse, + }; +}; diff --git a/src/plugins/es_ui_shared/public/request/use_request.test.ts b/src/plugins/es_ui_shared/public/request/use_request.test.ts new file mode 100644 index 00000000000000..f7902218d93140 --- /dev/null +++ b/src/plugins/es_ui_shared/public/request/use_request.test.ts @@ -0,0 +1,353 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { act } from 'react-dom/test-utils'; +import sinon from 'sinon'; + +import { + UseRequestHelpers, + REQUEST_TIME, + createUseRequestHelpers, +} from './use_request.test.helpers'; + +describe('useRequest hook', () => { + let helpers: UseRequestHelpers; + + beforeEach(() => { + helpers = createUseRequestHelpers(); + }); + + describe('parameters', () => { + describe('path, method, body', () => { + it('is used to send the request', async () => { + const { setupSuccessRequest, completeRequest, hookResult, getSuccessResponse } = helpers; + setupSuccessRequest(); + await completeRequest(); + expect(hookResult.data).toBe(getSuccessResponse().data); + }); + }); + + describe('pollIntervalMs', () => { + it('sends another request after the specified time has elapsed', async () => { + const { setupSuccessRequest, advanceTime, getSendRequestSpy } = helpers; + setupSuccessRequest({ pollIntervalMs: REQUEST_TIME }); + + await advanceTime(REQUEST_TIME); + expect(getSendRequestSpy().callCount).toBe(1); + + // We need to advance (1) the pollIntervalMs and (2) the request time. + await advanceTime(REQUEST_TIME * 2); + expect(getSendRequestSpy().callCount).toBe(2); + + // We need to advance (1) the pollIntervalMs and (2) the request time. + await advanceTime(REQUEST_TIME * 2); + expect(getSendRequestSpy().callCount).toBe(3); + }); + }); + + describe('initialData', () => { + it('sets the initial data value', async () => { + const { setupSuccessRequest, completeRequest, hookResult, getSuccessResponse } = helpers; + setupSuccessRequest({ initialData: 'initialData' }); + expect(hookResult.data).toBe('initialData'); + + // The initial data value will be overwritten once the request resolves. + await completeRequest(); + expect(hookResult.data).toBe(getSuccessResponse().data); + }); + }); + + describe('deserializer', () => { + it('is called with the response once the request resolves', async () => { + const { setupSuccessRequest, completeRequest, getSuccessResponse } = helpers; + + const deserializer = sinon.stub(); + setupSuccessRequest({ deserializer }); + sinon.assert.notCalled(deserializer); + await completeRequest(); + + sinon.assert.calledOnce(deserializer); + sinon.assert.calledWith(deserializer, getSuccessResponse().data); + }); + + it('provides the data return value', async () => { + const { setupSuccessRequest, completeRequest, hookResult } = helpers; + setupSuccessRequest({ deserializer: () => 'intercepted' }); + await completeRequest(); + expect(hookResult.data).toBe('intercepted'); + }); + }); + }); + + describe('state', () => { + describe('isInitialRequest', () => { + it('is true for the first request and false for subsequent requests', async () => { + const { setupSuccessRequest, completeRequest, hookResult } = helpers; + setupSuccessRequest(); + expect(hookResult.isInitialRequest).toBe(true); + + hookResult.sendRequest(); + await completeRequest(); + expect(hookResult.isInitialRequest).toBe(false); + }); + }); + + describe('isLoading', () => { + it('represents in-flight request status', async () => { + const { setupSuccessRequest, completeRequest, hookResult } = helpers; + setupSuccessRequest(); + expect(hookResult.isLoading).toBe(true); + + await completeRequest(); + expect(hookResult.isLoading).toBe(false); + }); + }); + + describe('error', () => { + it('surfaces errors from requests', async () => { + const { setupErrorRequest, completeRequest, hookResult, getErrorResponse } = helpers; + setupErrorRequest(); + await completeRequest(); + expect(hookResult.error).toBe(getErrorResponse().error); + }); + + it('surfaces body-shaped errors from requests', async () => { + const { + setupErrorWithBodyRequest, + completeRequest, + hookResult, + getErrorWithBodyResponse, + } = helpers; + + setupErrorWithBodyRequest(); + await completeRequest(); + expect(hookResult.error).toBe(getErrorWithBodyResponse().error); + }); + + it('persists while a request is in-flight', async () => { + const { setupErrorRequest, completeRequest, hookResult, getErrorResponse } = helpers; + setupErrorRequest(); + await completeRequest(); + expect(hookResult.isLoading).toBe(false); + expect(hookResult.error).toBe(getErrorResponse().error); + + act(() => { + hookResult.sendRequest(); + }); + expect(hookResult.isLoading).toBe(true); + expect(hookResult.error).toBe(getErrorResponse().error); + }); + + it('is null when the request is successful', async () => { + const { setupSuccessRequest, completeRequest, hookResult } = helpers; + setupSuccessRequest(); + expect(hookResult.error).toBeNull(); + + await completeRequest(); + expect(hookResult.isLoading).toBe(false); + expect(hookResult.error).toBeNull(); + }); + }); + + describe('data', () => { + it('surfaces payloads from requests', async () => { + const { setupSuccessRequest, completeRequest, hookResult, getSuccessResponse } = helpers; + setupSuccessRequest(); + expect(hookResult.data).toBeUndefined(); + + await completeRequest(); + expect(hookResult.data).toBe(getSuccessResponse().data); + }); + + it('persists while a request is in-flight', async () => { + const { setupSuccessRequest, completeRequest, hookResult, getSuccessResponse } = helpers; + setupSuccessRequest(); + await completeRequest(); + expect(hookResult.isLoading).toBe(false); + expect(hookResult.data).toBe(getSuccessResponse().data); + + act(() => { + hookResult.sendRequest(); + }); + expect(hookResult.isLoading).toBe(true); + expect(hookResult.data).toBe(getSuccessResponse().data); + }); + + it('persists from last successful request when the next request fails', async () => { + const { + setupSuccessRequest, + completeRequest, + hookResult, + getErrorResponse, + setErrorResponse, + getSuccessResponse, + } = helpers; + + setupSuccessRequest(); + await completeRequest(); + expect(hookResult.isLoading).toBe(false); + expect(hookResult.error).toBeNull(); + expect(hookResult.data).toBe(getSuccessResponse().data); + + setErrorResponse(); + await completeRequest(); + expect(hookResult.isLoading).toBe(false); + expect(hookResult.error).toBe(getErrorResponse().error); + expect(hookResult.data).toBe(getSuccessResponse().data); + }); + }); + }); + + describe('callbacks', () => { + describe('sendRequest', () => { + it('sends the request', async () => { + const { setupSuccessRequest, completeRequest, hookResult, getSendRequestSpy } = helpers; + setupSuccessRequest(); + + await completeRequest(); + expect(getSendRequestSpy().callCount).toBe(1); + + await act(async () => { + hookResult.sendRequest(); + await completeRequest(); + }); + expect(getSendRequestSpy().callCount).toBe(2); + }); + + it('resets the pollIntervalMs', async () => { + const { setupSuccessRequest, advanceTime, hookResult, getSendRequestSpy } = helpers; + const DOUBLE_REQUEST_TIME = REQUEST_TIME * 2; + setupSuccessRequest({ pollIntervalMs: DOUBLE_REQUEST_TIME }); + + // The initial request resolves, and then we'll immediately send a new one manually... + await advanceTime(REQUEST_TIME); + expect(getSendRequestSpy().callCount).toBe(1); + act(() => { + hookResult.sendRequest(); + }); + + // The manual request resolves, and we'll send yet another one... + await advanceTime(REQUEST_TIME); + expect(getSendRequestSpy().callCount).toBe(2); + act(() => { + hookResult.sendRequest(); + }); + + // At this point, we've moved forward 3s. The poll is set at 2s. If sendRequest didn't + // reset the poll, the request call count would be 4, not 3. + await advanceTime(REQUEST_TIME); + expect(getSendRequestSpy().callCount).toBe(3); + }); + }); + }); + + describe('request behavior', () => { + it('outdated responses are ignored by poll requests', async () => { + const { + setupSuccessRequest, + setErrorResponse, + completeRequest, + hookResult, + getErrorResponse, + getSendRequestSpy, + } = helpers; + const DOUBLE_REQUEST_TIME = REQUEST_TIME * 2; + // Send initial request, which will have a longer round-trip time. + setupSuccessRequest({}, [DOUBLE_REQUEST_TIME]); + + // Send a new request, which will have a shorter round-trip time. + setErrorResponse(); + + // Complete both requests. + await completeRequest(); + + // Two requests were sent... + expect(getSendRequestSpy().callCount).toBe(2); + // ...but the error response is the one that takes precedence because it was *sent* more + // recently, despite the success response *returning* more recently. + expect(hookResult.error).toBe(getErrorResponse().error); + expect(hookResult.data).toBeUndefined(); + }); + + it(`outdated responses are ignored if there's a more recently-sent manual request`, async () => { + const { setupSuccessRequest, advanceTime, hookResult, getSendRequestSpy } = helpers; + + const HALF_REQUEST_TIME = REQUEST_TIME * 0.5; + setupSuccessRequest({ pollIntervalMs: REQUEST_TIME }); + + // Before the original request resolves, we make a manual sendRequest call. + await advanceTime(HALF_REQUEST_TIME); + expect(getSendRequestSpy().callCount).toBe(0); + act(() => { + hookResult.sendRequest(); + }); + + // The original quest resolves but it's been marked as outdated by the the manual sendRequest + // call "interrupts", so data is left undefined. + await advanceTime(HALF_REQUEST_TIME); + expect(getSendRequestSpy().callCount).toBe(1); + expect(hookResult.data).toBeUndefined(); + }); + + it(`changing pollIntervalMs doesn't trigger a new request`, async () => { + const { setupErrorRequest, setErrorResponse, completeRequest, getSendRequestSpy } = helpers; + const DOUBLE_REQUEST_TIME = REQUEST_TIME * 2; + // Send initial request. + setupErrorRequest({ pollIntervalMs: REQUEST_TIME }); + + // Setting a new poll will schedule a second request, but not send one immediately. + setErrorResponse({ pollIntervalMs: DOUBLE_REQUEST_TIME }); + + // Complete initial request. + await completeRequest(); + + // Complete scheduled poll request. + await completeRequest(); + expect(getSendRequestSpy().callCount).toBe(2); + }); + + it('when the path changes after a request is scheduled, the scheduled request is sent with that path', async () => { + const { + setupSuccessRequest, + completeRequest, + hookResult, + getErrorResponse, + setErrorResponse, + getSendRequestSpy, + } = helpers; + const DOUBLE_REQUEST_TIME = REQUEST_TIME * 2; + + // Sned first request and schedule a request, both with the success path. + setupSuccessRequest({ pollIntervalMs: DOUBLE_REQUEST_TIME }); + + // Change the path to the error path, sending a second request. pollIntervalMs is the same + // so the originally scheduled poll remains cheduled. + setErrorResponse({ pollIntervalMs: DOUBLE_REQUEST_TIME }); + + // Complete the initial request, the requests by the path change, and the scheduled poll request. + await completeRequest(); + await completeRequest(); + + // If the scheduled poll request was sent to the success path, we wouldn't have an error result. + // But we do, because it was sent to the error path. + expect(getSendRequestSpy().callCount).toBe(3); + expect(hookResult.error).toBe(getErrorResponse().error); + }); + }); +}); diff --git a/src/plugins/es_ui_shared/public/request/use_request.ts b/src/plugins/es_ui_shared/public/request/use_request.ts new file mode 100644 index 00000000000000..481843bf40e88c --- /dev/null +++ b/src/plugins/es_ui_shared/public/request/use_request.ts @@ -0,0 +1,161 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useCallback, useState, useRef, useMemo } from 'react'; + +import { HttpSetup } from '../../../../../src/core/public'; +import { + sendRequest as sendStatelessRequest, + SendRequestConfig, + SendRequestResponse, +} from './send_request'; + +export interface UseRequestConfig extends SendRequestConfig { + pollIntervalMs?: number; + initialData?: any; + deserializer?: (data: any) => any; +} + +export interface UseRequestResponse { + isInitialRequest: boolean; + isLoading: boolean; + error: E | null; + data?: D | null; + sendRequest: () => Promise>; +} + +export const useRequest = ( + httpClient: HttpSetup, + { path, method, query, body, pollIntervalMs, initialData, deserializer }: UseRequestConfig +): UseRequestResponse => { + const isMounted = useRef(false); + + // Main states for tracking request status and data + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [data, setData] = useState(initialData); + + // Consumers can use isInitialRequest to implement a polling UX. + const requestCountRef = useRef(0); + const isInitialRequest = requestCountRef.current === 0; + const pollIntervalIdRef = useRef(null); + + const clearPollInterval = useCallback(() => { + if (pollIntervalIdRef.current) { + clearTimeout(pollIntervalIdRef.current); + pollIntervalIdRef.current = null; + } + }, []); + + // Convert our object to string to be able to compare them in our useMemo, + // allowing the consumer to freely passed new objects to the hook on each + // render without requiring them to be memoized. + const queryStringified = query ? JSON.stringify(query) : undefined; + const bodyStringified = body ? JSON.stringify(body) : undefined; + + const requestBody = useMemo(() => { + return { + path, + method, + query: queryStringified ? query : undefined, + body: bodyStringified ? body : undefined, + }; + // queryStringified and bodyStringified stand in for query and body as dependencies. + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [path, method, queryStringified, bodyStringified]); + + const sendRequest = useCallback(async () => { + // If we're on an interval, this allows us to reset it if the user has manually requested the + // data, to avoid doubled-up requests. + clearPollInterval(); + + const requestId = ++requestCountRef.current; + + // We don't clear error or data, so it's up to the consumer to decide whether to display the + // "old" error/data or loading state when a new request is in-flight. + setIsLoading(true); + + const response = await sendStatelessRequest(httpClient, requestBody); + const { data: serializedResponseData, error: responseError } = response; + + const isOutdatedRequest = requestId !== requestCountRef.current; + const isUnmounted = isMounted.current === false; + + // Ignore outdated or irrelevant data. + if (isOutdatedRequest || isUnmounted) { + return { data: null, error: null }; + } + + setError(responseError); + // If there's an error, keep the data from the last request in case it's still useful to the user. + if (!responseError) { + const responseData = deserializer + ? deserializer(serializedResponseData) + : serializedResponseData; + setData(responseData); + } + // Setting isLoading to false also acts as a signal for scheduling the next poll request. + setIsLoading(false); + + return { data: serializedResponseData, error: responseError }; + }, [requestBody, httpClient, deserializer, clearPollInterval]); + + const scheduleRequest = useCallback(() => { + // If there's a scheduled poll request, this new one will supersede it. + clearPollInterval(); + + if (pollIntervalMs) { + pollIntervalIdRef.current = setTimeout(sendRequest, pollIntervalMs); + } + }, [pollIntervalMs, sendRequest, clearPollInterval]); + + // Send the request on component mount and whenever the dependencies of sendRequest() change. + useEffect(() => { + sendRequest(); + }, [sendRequest]); + + // Schedule the next poll request when the previous one completes. + useEffect(() => { + // When a request completes, attempt to schedule the next one. Note that we aren't re-scheduling + // a request whenever sendRequest's dependencies change. isLoading isn't set to false until the + // initial request has completed, so we won't schedule a request on mount. + if (!isLoading) { + scheduleRequest(); + } + }, [isLoading, scheduleRequest]); + + useEffect(() => { + isMounted.current = true; + + return () => { + isMounted.current = false; + + // Clean up on unmount. + clearPollInterval(); + }; + }, [clearPollInterval]); + + return { + isInitialRequest, + isLoading, + error, + data, + sendRequest, // Gives the user the ability to manually request data + }; +}; diff --git a/src/plugins/es_ui_shared/static/forms/components/fields/range_field.tsx b/src/plugins/es_ui_shared/static/forms/components/fields/range_field.tsx index e02c9b6c186154..b83b0af5f97c63 100644 --- a/src/plugins/es_ui_shared/static/forms/components/fields/range_field.tsx +++ b/src/plugins/es_ui_shared/static/forms/components/fields/range_field.tsx @@ -39,6 +39,8 @@ export const RangeField = ({ field, euiFieldProps = {}, ...rest }: Props) => { }>; field.onChange(event); }, + // https://github.com/elastic/kibana/issues/73972 + /* eslint-disable-next-line react-hooks/exhaustive-deps */ [field.onChange] ); diff --git a/src/plugins/home/public/application/components/tutorial/tutorial.js b/src/plugins/home/public/application/components/tutorial/tutorial.js index 8139bc6d38ab1d..f55462bdc9e288 100644 --- a/src/plugins/home/public/application/components/tutorial/tutorial.js +++ b/src/plugins/home/public/application/components/tutorial/tutorial.js @@ -201,26 +201,18 @@ class TutorialUi extends React.Component { * @return {Promise} */ fetchEsHitsStatus = async (esHitsCheckConfig) => { - const searchHeader = JSON.stringify({ index: esHitsCheckConfig.index }); - const searchBody = JSON.stringify({ query: esHitsCheckConfig.query, size: 1 }); - const response = await fetch(this.props.addBasePath('/elasticsearch/_msearch'), { - method: 'post', - body: `${searchHeader}\n${searchBody}\n`, - headers: { - accept: 'application/json', - 'content-type': 'application/x-ndjson', - 'kbn-xsrf': 'kibana', - }, - credentials: 'same-origin', - }); - - if (response.status > 300) { + const { http } = getServices(); + try { + const response = await http.post('/api/home/hits_status', { + body: JSON.stringify({ + index: esHitsCheckConfig.index, + query: esHitsCheckConfig.query, + }), + }); + return response.count > 0 ? StatusCheckStates.HAS_DATA : StatusCheckStates.NO_DATA; + } catch (e) { return StatusCheckStates.ERROR; } - - const results = await response.json(); - const numHits = _.get(results, 'responses.[0].hits.hits.length', 0); - return numHits === 0 ? StatusCheckStates.NO_DATA : StatusCheckStates.HAS_DATA; }; renderInstructionSetsToggle = () => { diff --git a/src/plugins/home/server/plugin.test.ts b/src/plugins/home/server/plugin.test.ts index 33d907315e5124..58103430b4d7c0 100644 --- a/src/plugins/home/server/plugin.test.ts +++ b/src/plugins/home/server/plugin.test.ts @@ -19,10 +19,7 @@ import { registryForTutorialsMock, registryForSampleDataMock } from './plugin.test.mocks'; import { HomeServerPlugin } from './plugin'; -import { coreMock } from '../../../core/server/mocks'; -import { CoreSetup } from '../../../core/server'; - -type MockedKeys = { [P in keyof T]: jest.Mocked }; +import { coreMock, httpServiceMock } from '../../../core/server/mocks'; describe('HomeServerPlugin', () => { beforeEach(() => { @@ -33,8 +30,16 @@ describe('HomeServerPlugin', () => { }); describe('setup', () => { - const mockCoreSetup: MockedKeys = coreMock.createSetup(); - const initContext = coreMock.createPluginInitializerContext(); + let mockCoreSetup: ReturnType; + let initContext: ReturnType; + let routerMock: ReturnType; + + beforeEach(() => { + mockCoreSetup = coreMock.createSetup(); + routerMock = httpServiceMock.createRouter(); + mockCoreSetup.http.createRouter.mockReturnValue(routerMock); + initContext = coreMock.createPluginInitializerContext(); + }); test('wires up tutorials provider service and returns registerTutorial and addScopedTutorialContextFactory', () => { const setup = new HomeServerPlugin(initContext).setup(mockCoreSetup, {}); @@ -52,6 +57,18 @@ describe('HomeServerPlugin', () => { expect(setup.sampleData).toHaveProperty('addAppLinksToSampleDataset'); expect(setup.sampleData).toHaveProperty('replacePanelInSampleDatasetDashboard'); }); + + test('registers the `/api/home/hits_status` route', () => { + new HomeServerPlugin(initContext).setup(mockCoreSetup, {}); + + expect(routerMock.post).toHaveBeenCalledTimes(1); + expect(routerMock.post).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/api/home/hits_status', + }), + expect.any(Function) + ); + }); }); describe('start', () => { diff --git a/src/plugins/home/server/plugin.ts b/src/plugins/home/server/plugin.ts index 1050c19362ae17..a2f8eec686b211 100644 --- a/src/plugins/home/server/plugin.ts +++ b/src/plugins/home/server/plugin.ts @@ -28,6 +28,7 @@ import { import { UsageCollectionSetup } from '../../usage_collection/server'; import { capabilitiesProvider } from './capabilities_provider'; import { sampleDataTelemetry } from './saved_objects'; +import { registerRoutes } from './routes'; interface HomeServerPluginSetupDependencies { usageCollection?: UsageCollectionSetup; @@ -41,6 +42,10 @@ export class HomeServerPlugin implements Plugin { + router.post( + { + path: '/api/home/hits_status', + validate: { + body: schema.object({ + index: schema.string(), + query: schema.recordOf(schema.string(), schema.any()), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { index, query } = req.body; + const client = context.core.elasticsearch.client; + + try { + const { body } = await client.asCurrentUser.search({ + index, + size: 1, + body: { + query, + }, + }); + const count = body.hits.hits.length; + + return res.ok({ + body: { + count, + }, + }); + } catch (e) { + return res.badRequest({ + body: e, + }); + } + }) + ); +}; diff --git a/src/plugins/home/server/routes/index.ts b/src/plugins/home/server/routes/index.ts new file mode 100644 index 00000000000000..bf492051cdedfa --- /dev/null +++ b/src/plugins/home/server/routes/index.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IRouter } from 'src/core/server'; +import { registerHitsStatusRoute } from './fetch_es_hits_status'; + +export const registerRoutes = (router: IRouter) => { + registerHitsStatusRoute(router); +}; diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts index fef70c3a484552..31c4d26d1793e7 100644 --- a/x-pack/plugins/actions/server/index.ts +++ b/x-pack/plugins/actions/server/index.ts @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext } from '../../../../src/core/server'; +import { PluginInitializerContext, PluginConfigDescriptor } from '../../../../src/core/server'; import { ActionsPlugin } from './plugin'; import { configSchema } from './config'; import { ActionsClient as ActionsClientClass } from './actions_client'; import { ActionsAuthorization as ActionsAuthorizationClass } from './authorization/actions_authorization'; +import { ActionsConfigType } from './types'; export type ActionsClient = PublicMethodsOf; export type ActionsAuthorization = PublicMethodsOf; @@ -24,6 +25,9 @@ export { PluginSetupContract, PluginStartContract } from './plugin'; export const plugin = (initContext: PluginInitializerContext) => new ActionsPlugin(initContext); -export const config = { +export const config: PluginConfigDescriptor = { schema: configSchema, + deprecations: ({ renameFromRoot }) => [ + renameFromRoot('xpack.actions.whitelistedHosts', 'xpack.actions.allowedHosts'), + ], }; diff --git a/x-pack/plugins/canvas/__tests__/fixtures/workpads.ts b/x-pack/plugins/canvas/__tests__/fixtures/workpads.ts index 4b1f31cb14687a..0d2939ed0e8a5e 100644 --- a/x-pack/plugins/canvas/__tests__/fixtures/workpads.ts +++ b/x-pack/plugins/canvas/__tests__/fixtures/workpads.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { CanvasWorkpad, CanvasElement, CanvasPage } from '../../types'; +import { CanvasWorkpad, CanvasElement, CanvasPage, CanvasVariable } from '../../types'; const BaseWorkpad: CanvasWorkpad = { '@created': '2019-02-08T18:35:23.029Z', @@ -50,6 +50,12 @@ const BaseElement: CanvasElement = { filter: '', }; +const BaseVariable: CanvasVariable = { + name: 'my-var', + value: 'Hello World', + type: 'string', +}; + export const workpads: CanvasWorkpad[] = [ { ...BaseWorkpad, @@ -71,6 +77,11 @@ export const workpads: CanvasWorkpad[] = [ ], }, ], + variables: [ + { + ...BaseVariable, + }, + ], }, { ...BaseWorkpad, @@ -82,6 +93,11 @@ export const workpads: CanvasWorkpad[] = [ ], }, ], + variables: [ + { + ...BaseVariable, + }, + ], }, { ...BaseWorkpad, @@ -103,6 +119,11 @@ export const workpads: CanvasWorkpad[] = [ { ...BasePage, elements: [{ ...BaseElement, expression: 'image | render' }] }, { ...BasePage, elements: [{ ...BaseElement, expression: 'image | render' }] }, ], + variables: [ + { + ...BaseVariable, + }, + ], }, { ...BaseWorkpad, @@ -136,6 +157,11 @@ export const workpads: CanvasWorkpad[] = [ ], }, ], + variables: [ + { + ...BaseVariable, + }, + ], }, { ...BaseWorkpad, @@ -166,6 +192,17 @@ export const workpads: CanvasWorkpad[] = [ ], }, ], + variables: [ + { + ...BaseVariable, + }, + { + ...BaseVariable, + }, + { + ...BaseVariable, + }, + ], }, { ...BaseWorkpad, diff --git a/x-pack/plugins/canvas/public/__tests__/setup.js b/x-pack/plugins/canvas/public/__tests__/setup.js deleted file mode 100644 index 1792397d0614ee..00000000000000 --- a/x-pack/plugins/canvas/public/__tests__/setup.js +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import enzyme from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; - -// this will run before any code that's inside a describe block -// so we can use it to set up whatever we need for our browser tests -before(() => { - // configure enzume - enzyme.configure({ adapter: new Adapter() }); -}); diff --git a/x-pack/plugins/canvas/server/collectors/workpad_collector.test.ts b/x-pack/plugins/canvas/server/collectors/workpad_collector.test.ts index 9f71edcc05bf26..32665cc42dc4e7 100644 --- a/x-pack/plugins/canvas/server/collectors/workpad_collector.test.ts +++ b/x-pack/plugins/canvas/server/collectors/workpad_collector.test.ts @@ -49,6 +49,14 @@ describe('usage collector handle es response data', () => { 'shape', ], }, + variables: { + total: 7, + per_workpad: { + avg: 1.1666666666666667, + min: 0, + max: 3, + }, + }, }); }); @@ -63,6 +71,7 @@ describe('usage collector handle es response data', () => { pages: { total: 1, per_workpad: { avg: 1, min: 1, max: 1 } }, elements: { total: 1, per_page: { avg: 1, min: 1, max: 1 } }, functions: { total: 1, in_use: ['toast'], per_element: { avg: 1, min: 1, max: 1 } }, + variables: { total: 1, per_workpad: { avg: 1, min: 1, max: 1 } }, }); }); @@ -76,6 +85,39 @@ describe('usage collector handle es response data', () => { pages: { total: 0, per_workpad: { avg: 0, min: 0, max: 0 } }, elements: undefined, functions: undefined, + variables: { total: 1, per_workpad: { avg: 1, min: 1, max: 1 } }, // Variables still possible even with no pages + }); + }); + + it('should handle cases where the version workpad might not have variables', () => { + const workpad = cloneDeep(workpads[0]); + // @ts-ignore + workpad.variables = undefined; + + const mockWorkpadsOld = [workpad]; + const usage = summarizeWorkpads(mockWorkpadsOld); + expect(usage).toEqual({ + workpads: { total: 1 }, + pages: { total: 1, per_workpad: { avg: 1, min: 1, max: 1 } }, + elements: { total: 1, per_page: { avg: 1, min: 1, max: 1 } }, + functions: { + total: 7, + in_use: [ + 'demodata', + 'ply', + 'rowCount', + 'as', + 'staticColumn', + 'math', + 'mapColumn', + 'sort', + 'pointseries', + 'plot', + 'seriesStyle', + ], + per_element: { avg: 7, min: 7, max: 7 }, + }, + variables: { total: 0, per_workpad: { avg: 0, min: 0, max: 0 } }, // Variables still possible even with no pages }); }); diff --git a/x-pack/plugins/canvas/server/collectors/workpad_collector.ts b/x-pack/plugins/canvas/server/collectors/workpad_collector.ts index 4b00d061c17ce2..9fa39c580962de 100644 --- a/x-pack/plugins/canvas/server/collectors/workpad_collector.ts +++ b/x-pack/plugins/canvas/server/collectors/workpad_collector.ts @@ -44,6 +44,14 @@ interface WorkpadTelemetry { max: number; }; }; + variables?: { + total: number; + per_workpad: { + avg: number; + min: number; + max: number; + }; + }; } /** @@ -81,7 +89,10 @@ export function summarizeWorkpads(workpadDocs: CanvasWorkpad[]): WorkpadTelemetr }); }, []); - return { pages, elementCounts, functionCounts }; + const variableCount = + workpad.variables && workpad.variables.length ? workpad.variables.length : 0; + + return { pages, elementCounts, functionCounts, variableCount }; }); // combine together info from across the workpads @@ -91,9 +102,10 @@ export function summarizeWorkpads(workpadDocs: CanvasWorkpad[]): WorkpadTelemetr pageCounts: number[]; elementCounts: number[]; functionCounts: number[]; + variableCounts: number[]; }>( (accum, pageInfo) => { - const { pages, elementCounts, functionCounts } = pageInfo; + const { pages, elementCounts, functionCounts, variableCount } = pageInfo; return { pageMin: pages.count < accum.pageMin ? pages.count : accum.pageMin, @@ -101,6 +113,7 @@ export function summarizeWorkpads(workpadDocs: CanvasWorkpad[]): WorkpadTelemetr pageCounts: accum.pageCounts.concat(pages.count), elementCounts: accum.elementCounts.concat(elementCounts), functionCounts: accum.functionCounts.concat(functionCounts), + variableCounts: accum.variableCounts.concat([variableCount]), }; }, { @@ -109,13 +122,23 @@ export function summarizeWorkpads(workpadDocs: CanvasWorkpad[]): WorkpadTelemetr pageCounts: [], elementCounts: [], functionCounts: [], + variableCounts: [], } ); - const { pageCounts, pageMin, pageMax, elementCounts, functionCounts } = combinedWorkpadsInfo; + const { + pageCounts, + pageMin, + pageMax, + elementCounts, + functionCounts, + variableCounts, + } = combinedWorkpadsInfo; const pageTotal = arraySum(pageCounts); const elementsTotal = arraySum(elementCounts); const functionsTotal = arraySum(functionCounts); + const variableTotal = arraySum(variableCounts); + const pagesInfo = workpadsInfo.length > 0 ? { @@ -151,11 +174,21 @@ export function summarizeWorkpads(workpadDocs: CanvasWorkpad[]): WorkpadTelemetr } : undefined; + const variableInfo = { + total: variableTotal, + per_workpad: { + avg: variableTotal / variableCounts.length, + min: arrayMin(variableCounts) || 0, + max: arrayMax(variableCounts) || 0, + }, + }; + return { workpads: { total: workpadsInfo.length }, pages: pagesInfo, elements: elementsInfo, functions: functionsInfo, + variables: variableInfo, }; } diff --git a/x-pack/plugins/index_management/public/shared_imports.ts b/x-pack/plugins/index_management/public/shared_imports.ts index 16dcab18c3caf4..2ba2a5c493c493 100644 --- a/x-pack/plugins/index_management/public/shared_imports.ts +++ b/x-pack/plugins/index_management/public/shared_imports.ts @@ -13,7 +13,7 @@ export { Forms, extractQueryParams, GlobalFlyout, -} from '../../../../src/plugins/es_ui_shared/public/'; +} from '../../../../src/plugins/es_ui_shared/public'; export { FormSchema, diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts index d2a14fcf04dff7..af11bc7f6c8311 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts @@ -8,4 +8,6 @@ const cache: Map = new Map(); export const cacheGet = (key: string) => cache.get(key); export const cacheSet = (key: string, value: Buffer) => cache.set(key, value); export const cacheHas = (key: string) => cache.has(key); +export const cacheClear = () => cache.clear(); +export const cacheDelete = (key: string) => cache.delete(key); export const getCacheKey = (key: string) => key + '.tar.gz'; diff --git a/x-pack/plugins/maps/public/actions/map_actions.ts b/x-pack/plugins/maps/public/actions/map_actions.ts index f4088968531558..b00594cb7fb23f 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.ts @@ -271,7 +271,7 @@ export function triggerRefreshTimer() { }; } -export function updateDrawState(drawState: DrawState) { +export function updateDrawState(drawState: DrawState | null) { return (dispatch: Dispatch) => { if (drawState !== null) { dispatch({ type: SET_OPEN_TOOLTIPS, openTooltips: [] }); // tooltips just get in the way diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/__snapshots__/tools_control.test.js.snap b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/__snapshots__/tools_control.test.tsx.snap similarity index 99% rename from x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/__snapshots__/tools_control.test.js.snap rename to x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/__snapshots__/tools_control.test.tsx.snap index d7fa099fe9dbe9..afeee04358e157 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/__snapshots__/tools_control.test.js.snap +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/__snapshots__/tools_control.test.tsx.snap @@ -20,7 +20,7 @@ exports[`Should render cancel button when drawing 1`] = ` closePopover={[Function]} display="inlineBlock" hasArrow={true} - id="contextMenu" + id="toolsControlPopover" isOpen={false} ownFocus={false} panelPaddingSize="none" @@ -146,7 +146,7 @@ exports[`renders 1`] = ` closePopover={[Function]} display="inlineBlock" hasArrow={true} - id="contextMenu" + id="toolsControlPopover" isOpen={false} ownFocus={false} panelPaddingSize="none" diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/index.js b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/index.ts similarity index 63% rename from x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/index.js rename to x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/index.ts index 5812604a91b87f..70101f1ce6eba2 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/index.js +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/index.ts @@ -4,24 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AnyAction, Dispatch } from 'redux'; import { connect } from 'react-redux'; import { ToolsControl } from './tools_control'; import { isDrawingFilter } from '../../../selectors/map_selectors'; import { updateDrawState } from '../../../actions'; +import { MapStoreState } from '../../../reducers/store'; +import { DrawState } from '../../../../common/descriptor_types'; -function mapStateToProps(state = {}) { +function mapStateToProps(state: MapStoreState) { return { isDrawingFilter: isDrawingFilter(state), }; } -function mapDispatchToProps(dispatch) { +function mapDispatchToProps(dispatch: Dispatch) { return { - initiateDraw: (options) => { - dispatch(updateDrawState(options)); + initiateDraw: (drawState: DrawState) => { + dispatch(updateDrawState(drawState)); }, cancelDraw: () => { - dispatch(updateDrawState(null)); + dispatch(updateDrawState(null)); }, }; } diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.test.js b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.test.tsx similarity index 97% rename from x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.test.js rename to x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.test.tsx index f0e6dd43e68a10..544d789468e896 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.test.js +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.test.tsx @@ -19,6 +19,7 @@ const defaultProps = { indexPatternId: '1', }, ], + isDrawingFilter: false, }; test('renders', async () => { diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.js b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx similarity index 81% rename from x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.js rename to x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx index 017f0369e0b73d..fa0864ce680a24 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.js +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx @@ -14,10 +14,14 @@ import { EuiButton, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { DRAW_TYPE, ES_GEO_FIELD_TYPE } from '../../../../common/constants'; import { FormattedMessage } from '@kbn/i18n/react'; +import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; +import { DRAW_TYPE, ES_GEO_FIELD_TYPE, ES_SPATIAL_RELATIONS } from '../../../../common/constants'; +// @ts-expect-error import { GeometryFilterForm } from '../../../components/geometry_filter_form'; import { DistanceFilterForm } from '../../../components/distance_filter_form'; +import { GeoFieldWithIndex } from '../../../components/geo_field_with_index'; +import { DrawState } from '../../../../common/descriptor_types'; const DRAW_SHAPE_LABEL = i18n.translate('xpack.maps.toolbarOverlay.drawShapeLabel', { defaultMessage: 'Draw shape to filter data', @@ -46,8 +50,21 @@ const DRAW_DISTANCE_LABEL_SHORT = i18n.translate( } ); -export class ToolsControl extends Component { - state = { +interface Props { + cancelDraw: () => void; + geoFields: GeoFieldWithIndex[]; + initiateDraw: (drawState: DrawState) => void; + isDrawingFilter: boolean; + getFilterActions?: () => Promise; + getActionContext?: () => ActionExecutionContext; +} + +interface State { + isPopoverOpen: boolean; +} + +export class ToolsControl extends Component { + state: State = { isPopoverOpen: false, }; @@ -61,7 +78,14 @@ export class ToolsControl extends Component { this.setState({ isPopoverOpen: false }); }; - _initiateShapeDraw = (options) => { + _initiateShapeDraw = (options: { + actionId: string; + geometryLabel: string; + indexPatternId: string; + geoFieldName: string; + geoFieldType: ES_GEO_FIELD_TYPE; + relation: ES_SPATIAL_RELATIONS; + }) => { this.props.initiateDraw({ drawType: DRAW_TYPE.POLYGON, ...options, @@ -69,7 +93,14 @@ export class ToolsControl extends Component { this._closePopover(); }; - _initiateBoundsDraw = (options) => { + _initiateBoundsDraw = (options: { + actionId: string; + geometryLabel: string; + indexPatternId: string; + geoFieldName: string; + geoFieldType: ES_GEO_FIELD_TYPE; + relation: ES_SPATIAL_RELATIONS; + }) => { this.props.initiateDraw({ drawType: DRAW_TYPE.BOUNDS, ...options, @@ -77,7 +108,12 @@ export class ToolsControl extends Component { this._closePopover(); }; - _initiateDistanceDraw = (options) => { + _initiateDistanceDraw = (options: { + actionId: string; + filterLabel: string; + indexPatternId: string; + geoFieldName: string; + }) => { this.props.initiateDraw({ drawType: DRAW_TYPE.DISTANCE, ...options, @@ -194,7 +230,7 @@ export class ToolsControl extends Component { render() { const toolsPopoverButton = ( { render() { return ( } - > -

- -

- + /> `; diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js b/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js index 7f8bf06ecb8a36..99cd14063e86a5 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js @@ -58,7 +58,7 @@ export class DeleteFilterListModal extends Component { const title = ( -

- -

- + /> ); } diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/all/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/all/index.ts new file mode 100644 index 00000000000000..91a53066b4f4b1 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/all/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; + +import { HostItem, HostsFields } from '../common'; +import { CursorType, Inspect, Maybe, PageInfoPaginated, RequestOptionsPaginated } from '../..'; + +export interface HostsEdges { + node: HostItem; + + cursor: CursorType; +} + +export interface HostsStrategyResponse extends IEsSearchResponse { + edges: HostsEdges[]; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe; +} + +export interface HostsRequestOptions extends RequestOptionsPaginated { + defaultIndex: string[]; +} 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 new file mode 100644 index 00000000000000..d15da4bf07ae74 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CloudEcs } from '../../../../ecs/cloud'; +import { HostEcs, OsEcs } from '../../../../ecs/host'; +import { Maybe, SearchHit, TotalValue } from '../..'; + +export enum HostPolicyResponseActionStatus { + success = 'success', + failure = 'failure', + warning = 'warning', +} + +export enum HostsFields { + lastSeen = 'lastSeen', + hostName = 'hostName', +} + +export interface EndpointFields { + endpointPolicy?: Maybe; + sensorVersion?: Maybe; + policyStatus?: Maybe; +} + +export interface HostItem { + _id?: Maybe; + cloud?: Maybe; + endpoint?: Maybe; + host?: Maybe; + lastSeen?: Maybe; +} + +export interface HostValue { + value: number; + value_as_string: string; +} + +export interface HostBucketItem { + key: string; + doc_count: number; + timestamp: HostValue; +} + +export interface HostBuckets { + buckets: HostBucketItem[]; +} + +export interface HostOsHitsItem { + hits: { + total: TotalValue | number; + max_score: number | null; + hits: Array<{ + _source: { host: { os: Maybe } }; + sort?: [number]; + _index?: string; + _type?: string; + _id?: string; + _score?: number | null; + }>; + }; +} + +export interface HostAggEsItem { + cloud_instance_id?: HostBuckets; + cloud_machine_type?: HostBuckets; + cloud_provider?: HostBuckets; + cloud_region?: HostBuckets; + firstSeen?: HostValue; + host_architecture?: HostBuckets; + host_id?: HostBuckets; + host_ip?: HostBuckets; + host_mac?: HostBuckets; + host_name?: HostBuckets; + host_os_name?: HostBuckets; + host_os_version?: HostBuckets; + host_type?: HostBuckets; + key?: string; + lastSeen?: HostValue; + os?: HostOsHitsItem; +} + +export interface HostEsData extends SearchHit { + sort: string[]; + aggregations: { + host_count: { + value: number; + }; + host_data: { + buckets: HostAggEsItem[]; + }; + }; +} + +export interface HostAggEsData extends SearchHit { + sort: string[]; + aggregations: HostAggEsItem; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/first_last_seen/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/first_last_seen/index.ts new file mode 100644 index 00000000000000..cbabe9dd111153 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/first_last_seen/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; +import { Inspect, Maybe, RequestOptionsPaginated } from '../..'; +import { HostsFields } from '../common'; + +export interface HostFirstLastSeenRequestOptions + extends Partial> { + hostName: string; +} +export interface HostFirstLastSeenStrategyResponse extends IEsSearchResponse { + inspect?: Maybe; + firstSeen?: Maybe; + lastSeen?: Maybe; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts index 3a0942d2decb82..a27899e4540747 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts @@ -4,81 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IEsSearchResponse } from '../../../../../../../src/plugins/data/common'; -import { CloudEcs } from '../../../ecs/cloud'; -import { HostEcs } from '../../../ecs/host'; - -import { - CursorType, - Inspect, - Maybe, - PageInfoPaginated, - RequestOptionsPaginated, - SortField, - TimerangeInput, -} from '..'; +export * from './all'; +export * from './common'; +export * from './overview'; +export * from './first_last_seen'; export enum HostsQueries { hosts = 'hosts', hostOverview = 'hostOverview', -} - -export enum HostPolicyResponseActionStatus { - success = 'success', - failure = 'failure', - warning = 'warning', -} - -export interface EndpointFields { - endpointPolicy?: Maybe; - - sensorVersion?: Maybe; - - policyStatus?: Maybe; -} - -export interface HostItem { - _id?: Maybe; - - cloud?: Maybe; - - endpoint?: Maybe; - - host?: Maybe; - - lastSeen?: Maybe; -} - -export interface HostsEdges { - node: HostItem; - - cursor: CursorType; -} - -export interface HostsStrategyResponse extends IEsSearchResponse { - edges: HostsEdges[]; - - totalCount: number; - - pageInfo: PageInfoPaginated; - - inspect?: Maybe; -} - -export interface HostOverviewStrategyResponse extends IEsSearchResponse, HostItem { - inspect?: Maybe; -} - -export interface HostsRequestOptions extends RequestOptionsPaginated { - sort: SortField; - defaultIndex: string[]; -} - -export interface HostLastFirstSeenRequestOptions extends Partial { - hostName: string; -} - -export interface HostOverviewRequestOptions extends HostLastFirstSeenRequestOptions { - fields: string[]; - timerange: TimerangeInput; + firstLastSeen = 'firstLastSeen', } diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/overview/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/overview/index.ts new file mode 100644 index 00000000000000..8d54481f56dbde --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/overview/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; + +import { HostItem, HostsFields } from '../common'; +import { Inspect, Maybe, RequestOptionsPaginated, TimerangeInput } from '../..'; + +export interface HostOverviewStrategyResponse extends IEsSearchResponse { + hostOverview: HostItem; + inspect?: Maybe; +} + +export interface HostOverviewRequestOptions extends Partial> { + hostName: string; + skip?: boolean; + timerange: TimerangeInput; + inspect?: Maybe; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts index a188eb7619e6be..175784bc5ade57 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts @@ -4,19 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IEsSearchRequest } from '../../../../../../src/plugins/data/common'; +import { IEsSearchRequest, IEsSearchResponse } from '../../../../../../src/plugins/data/common'; import { ESQuery } from '../../typed_json'; import { HostOverviewStrategyResponse, HostOverviewRequestOptions, + HostFirstLastSeenStrategyResponse, + HostFirstLastSeenRequestOptions, HostsQueries, HostsRequestOptions, HostsStrategyResponse, } from './hosts'; +import { NetworkQueries, NetworkTlsStrategyResponse, NetworkTlsRequestOptions } from './network'; + export * from './hosts'; +export * from './network'; export type Maybe = T | null; -export type FactoryQueryTypes = HostsQueries; +export type FactoryQueryTypes = HostsQueries | NetworkQueries; + +export type SearchHit = IEsSearchResponse['rawResponse']['hits']['hits'][0]; + +export interface TotalValue { + value: number; + relation: string; +} export interface Inspect { dsl: string[]; @@ -39,8 +51,8 @@ export enum Direction { desc = 'desc', } -export interface SortField { - field: 'lastSeen' | 'hostName'; +export interface SortField { + field: Field; direction: Direction; } @@ -86,24 +98,32 @@ export interface RequestBasicOptions extends IEsSearchRequest { factoryQueryType?: FactoryQueryTypes; } -export interface RequestOptions extends RequestBasicOptions { +export interface RequestOptions extends RequestBasicOptions { pagination: PaginationInput; - sortField?: SortField; + sort: SortField; } -export interface RequestOptionsPaginated extends RequestBasicOptions { +export interface RequestOptionsPaginated extends RequestBasicOptions { pagination: PaginationInputPaginated; - sortField?: SortField; + sort: SortField; } export type StrategyResponseType = T extends HostsQueries.hosts ? HostsStrategyResponse : T extends HostsQueries.hostOverview ? HostOverviewStrategyResponse + : T extends HostsQueries.firstLastSeen + ? HostFirstLastSeenStrategyResponse + : T extends NetworkQueries.tls + ? NetworkTlsStrategyResponse : never; export type StrategyRequestType = T extends HostsQueries.hosts ? HostsRequestOptions : T extends HostsQueries.hostOverview ? HostOverviewRequestOptions + : T extends HostsQueries.firstLastSeen + ? HostFirstLastSeenRequestOptions + : T extends NetworkQueries.tls + ? NetworkTlsRequestOptions : never; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/index.ts new file mode 100644 index 00000000000000..680a3697ef0bd5 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './tls'; + +export enum NetworkQueries { + tls = 'tls', +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/tls/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/tls/index.ts new file mode 100644 index 00000000000000..c9e593bb7a7d2d --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/tls/index.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; +import { CursorType, Inspect, Maybe, PageInfoPaginated, RequestOptionsPaginated } from '../..'; + +export interface TlsBuckets { + key: string; + timestamp?: { + value: number; + value_as_string: string; + }; + subjects: { + buckets: Readonly>; + }; + ja3: { + buckets: Readonly>; + }; + issuers: { + buckets: Readonly>; + }; + not_after: { + buckets: Readonly>; + }; +} + +export interface TlsNode { + _id?: Maybe; + timestamp?: Maybe; + notAfter?: Maybe; + subjects?: Maybe; + ja3?: Maybe; + issuers?: Maybe; +} + +export enum FlowTargetSourceDest { + destination = 'destination', + source = 'source', +} + +export enum TlsFields { + _id = '_id', +} + +export interface TlsEdges { + node: TlsNode; + cursor: CursorType; +} + +export interface NetworkTlsRequestOptions extends RequestOptionsPaginated { + ip: string; + flowTarget: FlowTargetSourceDest; + defaultIndex: string[]; +} + +export interface NetworkTlsStrategyResponse extends IEsSearchResponse { + edges: TlsEdges[]; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe; +} diff --git a/x-pack/plugins/security_solution/public/hosts/components/first_last_seen_host/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/first_last_seen_host/index.test.tsx index a2f53be7218162..606b43c6508fb9 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/first_last_seen_host/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/first_last_seen_host/index.test.tsx @@ -4,18 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { cloneDeep } from 'lodash/fp'; import React from 'react'; -import { MockedProvider } from 'react-apollo/test-utils'; // we don't have the types for waitFor just yet, so using "as waitFor" until when we do import { render, act, wait as waitFor } from '@testing-library/react'; -import { mockFirstLastSeenHostQuery } from '../../containers/hosts/first_last_seen/mock'; +import { useFirstLastSeenHost } from '../../containers/hosts/first_last_seen'; import { TestProviders } from '../../../common/mock'; - import { FirstLastSeenHost, FirstLastSeenHostType } from '.'; +const MOCKED_RESPONSE = { + firstSeen: '2019-04-08T16:09:40.692Z', + lastSeen: '2019-04-08T18:35:45.064Z', +}; + +jest.mock('../../containers/hosts/first_last_seen'); +const useFirstLastSeenHostMock = useFirstLastSeenHost as jest.Mock; +useFirstLastSeenHostMock.mockReturnValue([false, MOCKED_RESPONSE]); + describe('FirstLastSeen Component', () => { const firstSeen = 'Apr 8, 2019 @ 16:09:40.692'; const lastSeen = 'Apr 8, 2019 @ 18:35:45.064'; @@ -31,11 +37,10 @@ describe('FirstLastSeen Component', () => { }); test('Loading', async () => { + useFirstLastSeenHostMock.mockReturnValue([true, MOCKED_RESPONSE]); const { container } = render( - - - + ); expect(container.innerHTML).toBe( @@ -44,11 +49,10 @@ describe('FirstLastSeen Component', () => { }); test('First Seen', async () => { + useFirstLastSeenHostMock.mockReturnValue([false, MOCKED_RESPONSE]); const { container } = render( - - - + ); @@ -62,11 +66,10 @@ describe('FirstLastSeen Component', () => { }); test('Last Seen', async () => { + useFirstLastSeenHostMock.mockReturnValue([false, MOCKED_RESPONSE]); const { container } = render( - - - + ); await act(() => @@ -79,13 +82,16 @@ describe('FirstLastSeen Component', () => { }); test('First Seen is empty but not Last Seen', async () => { - const badDateTime = cloneDeep(mockFirstLastSeenHostQuery); - badDateTime[0].result.data!.source.HostFirstLastSeen.firstSeen = null; + useFirstLastSeenHostMock.mockReturnValue([ + false, + { + ...MOCKED_RESPONSE, + firstSeen: null, + }, + ]); const { container } = render( - - - + ); @@ -99,13 +105,16 @@ describe('FirstLastSeen Component', () => { }); test('Last Seen is empty but not First Seen', async () => { - const badDateTime = cloneDeep(mockFirstLastSeenHostQuery); - badDateTime[0].result.data!.source.HostFirstLastSeen.lastSeen = null; + useFirstLastSeenHostMock.mockReturnValue([ + false, + { + ...MOCKED_RESPONSE, + lastSeen: null, + }, + ]); const { container } = render( - - - + ); @@ -119,13 +128,16 @@ describe('FirstLastSeen Component', () => { }); test('First Seen With a bad date time string', async () => { - const badDateTime = cloneDeep(mockFirstLastSeenHostQuery); - badDateTime[0].result.data!.source.HostFirstLastSeen.firstSeen = 'something-invalid'; + useFirstLastSeenHostMock.mockReturnValue([ + false, + { + ...MOCKED_RESPONSE, + firstSeen: 'something-invalid', + }, + ]); const { container } = render( - - - + ); await act(() => @@ -136,13 +148,16 @@ describe('FirstLastSeen Component', () => { }); test('Last Seen With a bad date time string', async () => { - const badDateTime = cloneDeep(mockFirstLastSeenHostQuery); - badDateTime[0].result.data!.source.HostFirstLastSeen.lastSeen = 'something-invalid'; + useFirstLastSeenHostMock.mockReturnValue([ + false, + { + ...MOCKED_RESPONSE, + lastSeen: 'something-invalid', + }, + ]); const { container } = render( - - - + ); await act(() => diff --git a/x-pack/plugins/security_solution/public/hosts/components/first_last_seen_host/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/first_last_seen_host/index.tsx index 579c3311cf7322..a1b72fb39069ca 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/first_last_seen_host/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/first_last_seen_host/index.tsx @@ -5,10 +5,9 @@ */ import { EuiIcon, EuiLoadingSpinner, EuiText, EuiToolTip } from '@elastic/eui'; -import React from 'react'; -import { ApolloConsumer } from 'react-apollo'; +import React, { useMemo } from 'react'; -import { useFirstLastSeenHostQuery } from '../../containers/hosts/first_last_seen'; +import { useFirstLastSeenHost } from '../../containers/hosts/first_last_seen'; import { getEmptyTagValue } from '../../../common/components/empty_value'; import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; @@ -17,49 +16,48 @@ export enum FirstLastSeenHostType { LAST_SEEN = 'last-seen', } -export const FirstLastSeenHost = React.memo<{ hostname: string; type: FirstLastSeenHostType }>( - ({ hostname, type }) => { +interface FirstLastSeenHostProps { + hostName: string; + type: FirstLastSeenHostType; +} + +export const FirstLastSeenHost = React.memo(({ hostName, type }) => { + const [loading, { firstSeen, lastSeen, errorMessage }] = useFirstLastSeenHost({ + hostName, + }); + const valueSeen = useMemo( + () => (type === FirstLastSeenHostType.FIRST_SEEN ? firstSeen : lastSeen), + [firstSeen, lastSeen, type] + ); + + if (errorMessage != null) { return ( - - {(client) => { - /* eslint-disable-next-line react-hooks/rules-of-hooks */ - const { loading, firstSeen, lastSeen, errorMessage } = useFirstLastSeenHostQuery( - hostname, - 'default', - client - ); - if (errorMessage != null) { - return ( - - - - ); - } - const valueSeen = type === FirstLastSeenHostType.FIRST_SEEN ? firstSeen : lastSeen; - return ( - <> - {loading && } - {!loading && valueSeen != null && new Date(valueSeen).toString() === 'Invalid Date' - ? valueSeen - : !loading && - valueSeen != null && ( - - - - )} - {!loading && valueSeen == null && getEmptyTagValue()} - - ); - }} - + + + ); } -); + + return ( + <> + {loading && } + {!loading && valueSeen != null && new Date(valueSeen).toString() === 'Invalid Date' + ? valueSeen + : !loading && + valueSeen != null && ( + + + + )} + {!loading && valueSeen == null && getEmptyTagValue()} + + ); +}); FirstLastSeenHost.displayName = 'FirstLastSeenHost'; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/first_last_seen/index.ts b/x-pack/plugins/security_solution/public/hosts/containers/hosts/first_last_seen/index.ts deleted file mode 100644 index 65e379b5ba2d82..00000000000000 --- a/x-pack/plugins/security_solution/public/hosts/containers/hosts/first_last_seen/index.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import ApolloClient from 'apollo-client'; -import { get } from 'lodash/fp'; -import React, { useEffect, useState } from 'react'; - -import { DEFAULT_INDEX_KEY } from '../../../../../common/constants'; -import { useUiSetting$ } from '../../../../common/lib/kibana'; -import { GetHostFirstLastSeenQuery } from '../../../../graphql/types'; -import { inputsModel } from '../../../../common/store'; -import { QueryTemplateProps } from '../../../../common/containers/query_template'; -import { useWithSource } from '../../../../common/containers/source'; -import { HostFirstLastSeenGqlQuery } from './first_last_seen.gql_query'; - -export interface FirstLastSeenHostArgs { - id: string; - errorMessage: string; - firstSeen: Date; - lastSeen: Date; - loading: boolean; - refetch: inputsModel.Refetch; -} - -export interface OwnProps extends QueryTemplateProps { - children: (args: FirstLastSeenHostArgs) => React.ReactNode; - hostName: string; -} - -export function useFirstLastSeenHostQuery( - hostName: string, - sourceId: string, - apolloClient: ApolloClient -) { - const [loading, updateLoading] = useState(false); - const [firstSeen, updateFirstSeen] = useState(null); - const [lastSeen, updateLastSeen] = useState(null); - const [errorMessage, updateErrorMessage] = useState(null); - const [defaultIndex] = useUiSetting$(DEFAULT_INDEX_KEY); - const { docValueFields } = useWithSource(sourceId); - - async function fetchFirstLastSeenHost(signal: AbortSignal) { - updateLoading(true); - return apolloClient - .query({ - query: HostFirstLastSeenGqlQuery, - fetchPolicy: 'cache-first', - variables: { - sourceId, - hostName, - defaultIndex, - docValueFields, - }, - context: { - fetchOptions: { - signal, - }, - }, - }) - .then( - (result) => { - updateLoading(false); - updateFirstSeen(get('data.source.HostFirstLastSeen.firstSeen', result)); - updateLastSeen(get('data.source.HostFirstLastSeen.lastSeen', result)); - updateErrorMessage(null); - }, - (error) => { - updateLoading(false); - updateFirstSeen(null); - updateLastSeen(null); - updateErrorMessage(error.message); - } - ); - } - - useEffect(() => { - const abortCtrl = new AbortController(); - const signal = abortCtrl.signal; - fetchFirstLastSeenHost(signal); - return () => abortCtrl.abort(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return { firstSeen, lastSeen, loading, errorMessage }; -} diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/first_last_seen/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/hosts/first_last_seen/index.tsx new file mode 100644 index 00000000000000..3a93b1ee46e7ba --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/first_last_seen/index.tsx @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import deepEqual from 'fast-deep-equal'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { DEFAULT_INDEX_KEY } from '../../../../../common/constants'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { + HostsQueries, + HostFirstLastSeenRequestOptions, + HostFirstLastSeenStrategyResponse, +} from '../../../../../common/search_strategy/security_solution'; +import { useWithSource } from '../../../../common/containers/source'; + +import * as i18n from './translations'; +import { AbortError } from '../../../../../../../../src/plugins/data/common'; + +const ID = 'firstLastSeenHostQuery'; + +export interface FirstLastSeenHostArgs { + id: string; + errorMessage: string | null; + firstSeen?: string | null; + lastSeen?: string | null; +} +interface UseHostFirstLastSeen { + hostName: string; +} + +export const useFirstLastSeenHost = ({ + hostName, +}: UseHostFirstLastSeen): [boolean, FirstLastSeenHostArgs] => { + const { docValueFields } = useWithSource('default'); + const { data, notifications, uiSettings } = useKibana().services; + const abortCtrl = useRef(new AbortController()); + const defaultIndex = uiSettings.get(DEFAULT_INDEX_KEY); + const [loading, setLoading] = useState(false); + const [firstLastSeenHostRequest, setFirstLastSeenHostRequest] = useState< + HostFirstLastSeenRequestOptions + >({ + defaultIndex, + docValueFields: docValueFields ?? [], + factoryQueryType: HostsQueries.firstLastSeen, + hostName, + }); + + const [firstLastSeenHostResponse, setFirstLastSeenHostResponse] = useState( + { + firstSeen: null, + lastSeen: null, + errorMessage: null, + id: ID, + } + ); + + const firstLastSeenHostSearch = useCallback( + (request: HostFirstLastSeenRequestOptions) => { + let didCancel = false; + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + const searchSubscription$ = data.search + .search(request, { + strategy: 'securitySolutionSearchStrategy', + signal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (!response.isPartial && !response.isRunning) { + if (!didCancel) { + setLoading(false); + setFirstLastSeenHostResponse((prevResponse) => ({ + ...prevResponse, + errorMessage: null, + firstSeen: response.firstSeen, + lastSeen: response.lastSeen, + })); + } + searchSubscription$.unsubscribe(); + } else if (response.isPartial && !response.isRunning) { + if (!didCancel) { + setLoading(false); + } + // TODO: Make response error status clearer + notifications.toasts.addWarning(i18n.ERROR_FIRST_LAST_SEEN_HOST); + searchSubscription$.unsubscribe(); + } + }, + error: (msg) => { + if (!(msg instanceof AbortError)) { + setFirstLastSeenHostResponse((prevResponse) => ({ + ...prevResponse, + errorMessage: msg, + })); + notifications.toasts.addDanger({ + title: i18n.FAIL_FIRST_LAST_SEEN_HOST, + text: msg.message, + }); + } + }, + }); + }; + abortCtrl.current.abort(); + asyncSearch(); + return () => { + didCancel = true; + abortCtrl.current.abort(); + }; + }, + [data.search, notifications.toasts] + ); + + useEffect(() => { + setFirstLastSeenHostRequest((prevRequest) => { + const myRequest = { + ...prevRequest, + defaultIndex, + docValueFields: docValueFields ?? [], + hostName, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [defaultIndex, docValueFields, hostName]); + + useEffect(() => { + firstLastSeenHostSearch(firstLastSeenHostRequest); + }, [firstLastSeenHostRequest, firstLastSeenHostSearch]); + + return [loading, firstLastSeenHostResponse]; +}; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/first_last_seen/mock.ts b/x-pack/plugins/security_solution/public/hosts/containers/hosts/first_last_seen/mock.ts deleted file mode 100644 index 7f1b3d97eb5255..00000000000000 --- a/x-pack/plugins/security_solution/public/hosts/containers/hosts/first_last_seen/mock.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { DEFAULT_INDEX_PATTERN } from '../../../../../common/constants'; -import { GetHostFirstLastSeenQuery } from '../../../../graphql/types'; - -import { HostFirstLastSeenGqlQuery } from './first_last_seen.gql_query'; - -interface MockedProvidedQuery { - request: { - query: GetHostFirstLastSeenQuery.Query; - variables: GetHostFirstLastSeenQuery.Variables; - }; - result: { - data?: { - source: { - id: string; - HostFirstLastSeen: { - firstSeen: string | null; - lastSeen: string | null; - }; - }; - }; - errors?: [{ message: string }]; - }; -} -export const mockFirstLastSeenHostQuery: MockedProvidedQuery[] = [ - { - request: { - query: HostFirstLastSeenGqlQuery, - variables: { - sourceId: 'default', - hostName: 'kibana-siem', - defaultIndex: DEFAULT_INDEX_PATTERN, - docValueFields: [], - }, - }, - result: { - data: { - source: { - id: 'default', - HostFirstLastSeen: { - firstSeen: '2019-04-08T16:09:40.692Z', - lastSeen: '2019-04-08T18:35:45.064Z', - }, - }, - }, - }, - }, -]; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/first_last_seen/translations.ts b/x-pack/plugins/security_solution/public/hosts/containers/hosts/first_last_seen/translations.ts new file mode 100644 index 00000000000000..1e0a4ad2378970 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/first_last_seen/translations.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_FIRST_LAST_SEEN_HOST = i18n.translate( + 'xpack.securitySolution.firstLastSeenHost.errorSearchDescription', + { + defaultMessage: `An error has occurred on first last seen host search`, + } +); + +export const FAIL_FIRST_LAST_SEEN_HOST = i18n.translate( + 'xpack.securitySolution.firstLastSeenHost.failSearchDescription', + { + defaultMessage: `Failed to run search on first last seen host`, + } +); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx index 346de9f87313f3..6e1ebbfd1e7bb6 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx @@ -47,6 +47,7 @@ interface UseAllHost { docValueFields?: DocValueFields[]; filterQuery?: ESTermQuery | string; endDate: string; + skip?: boolean; startDate: string; type: hostsModel.HostsType; } @@ -55,6 +56,7 @@ export const useAllHost = ({ docValueFields, filterQuery, endDate, + skip = false, startDate, type, }: UseAllHost): [boolean, HostsArgs] => { @@ -189,7 +191,7 @@ export const useAllHost = ({ field: sortField, }, }; - if (!deepEqual(prevRequest, myRequest)) { + if (!skip && !deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; @@ -202,6 +204,7 @@ export const useAllHost = ({ endDate, filterQuery, limit, + skip, startDate, sortField, ]); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/overview/_index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/hosts/overview/_index.tsx new file mode 100644 index 00000000000000..f766f068f099fc --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/overview/_index.tsx @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// REPLACE WHEN HOST ENDPOINT DATA IS AVAILABLE + +import deepEqual from 'fast-deep-equal'; +import { noop } from 'lodash/fp'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { DEFAULT_INDEX_KEY } from '../../../../../common/constants'; +import { inputsModel } from '../../../../common/store'; +import { useKibana } from '../../../../common/lib/kibana'; +import { + HostItem, + HostsQueries, + HostOverviewRequestOptions, + HostOverviewStrategyResponse, +} from '../../../../../common/search_strategy/security_solution/hosts'; + +import * as i18n from './translations'; +import { AbortError } from '../../../../../../../../src/plugins/data/common'; + +const ID = 'hostOverviewQuery'; + +export interface HostOverviewArgs { + id: string; + inspect: inputsModel.InspectQuery; + hostOverview: HostItem; + refetch: inputsModel.Refetch; + startDate: string; + endDate: string; +} + +interface UseHostOverview { + id?: string; + hostName: string; + endDate: string; + skip?: boolean; + startDate: string; +} + +export const useHostOverview = ({ + endDate, + hostName, + skip = false, + startDate, + id = ID, +}: UseHostOverview): [boolean, HostOverviewArgs] => { + const { data, notifications, uiSettings } = useKibana().services; + const refetch = useRef(noop); + const abortCtrl = useRef(new AbortController()); + const defaultIndex = uiSettings.get(DEFAULT_INDEX_KEY); + const [loading, setLoading] = useState(false); + const [hostOverviewRequest, setHostOverviewRequest] = useState({ + defaultIndex, + hostName, + factoryQueryType: HostsQueries.hostOverview, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + }); + + const [hostOverviewResponse, setHostOverviewResponse] = useState({ + endDate, + hostOverview: {}, + id: ID, + inspect: { + dsl: [], + response: [], + }, + refetch: refetch.current, + startDate, + }); + + const hostOverviewSearch = useCallback( + (request: HostOverviewRequestOptions) => { + let didCancel = false; + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + const searchSubscription$ = data.search + .search(request, { + strategy: 'securitySolutionSearchStrategy', + signal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (!response.isPartial && !response.isRunning) { + if (!didCancel) { + setLoading(false); + setHostOverviewResponse((prevResponse) => ({ + ...prevResponse, + hostOverview: response.hostOverview, + inspect: response.inspect ?? prevResponse.inspect, + refetch: refetch.current, + })); + } + searchSubscription$.unsubscribe(); + } else if (response.isPartial && !response.isRunning) { + if (!didCancel) { + setLoading(false); + } + // TODO: Make response error status clearer + notifications.toasts.addWarning(i18n.ERROR_HOST_OVERVIEW); + searchSubscription$.unsubscribe(); + } + }, + error: (msg) => { + if (!(msg instanceof AbortError)) { + notifications.toasts.addDanger({ + title: i18n.FAIL_HOST_OVERVIEW, + text: msg.message, + }); + } + }, + }); + }; + abortCtrl.current.abort(); + asyncSearch(); + refetch.current = asyncSearch; + return () => { + didCancel = true; + abortCtrl.current.abort(); + }; + }, + [data.search, notifications.toasts] + ); + + useEffect(() => { + setHostOverviewRequest((prevRequest) => { + const myRequest = { + ...prevRequest, + defaultIndex, + hostName, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + }; + if (!skip && !deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [defaultIndex, endDate, hostName, startDate, skip]); + + useEffect(() => { + hostOverviewSearch(hostOverviewRequest); + }, [hostOverviewRequest, hostOverviewSearch]); + + return [loading, hostOverviewResponse]; +}; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/overview/translations.ts b/x-pack/plugins/security_solution/public/hosts/containers/hosts/overview/translations.ts new file mode 100644 index 00000000000000..e3fa319e70cc13 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/overview/translations.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_HOST_OVERVIEW = i18n.translate( + 'xpack.securitySolution.overviewHost.errorSearchDescription', + { + defaultMessage: `An error has occurred on host overview search`, + } +); + +export const FAIL_HOST_OVERVIEW = i18n.translate( + 'xpack.securitySolution.overviewHost.failSearchDescription', + { + defaultMessage: `Failed to run search on host overview`, + } +); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.tsx index 5232dcfd88189d..f8dcf9635c053e 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.tsx @@ -27,7 +27,8 @@ export const HostsQueryTabBody = ({ const [ loading, { hosts, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }, - ] = useAllHost({ docValueFields, endDate, filterQuery, startDate, type }); + ] = useAllHost({ docValueFields, endDate, filterQuery, skip, startDate, type }); + return ( void; pageInfo: PageInfoPaginated; refetch: inputsModel.Refetch; @@ -43,121 +38,159 @@ export interface TlsArgs { totalCount: number; } -export interface OwnProps extends QueryTemplatePaginatedProps { - children: (args: TlsArgs) => React.ReactNode; +interface UseNetworkTls { flowTarget: FlowTargetSourceDest; ip: string; type: networkModel.NetworkType; + filterQuery?: ESTermQuery | string; + endDate: string; + startDate: string; + skip: boolean; + id?: string; } -export interface TlsComponentReduxProps { - activePage: number; - isInspected: boolean; - limit: number; - sort: TlsSortField; -} +export const useNetworkTls = ({ + endDate, + filterQuery, + flowTarget, + id = ID, + ip, + skip, + startDate, + type, +}: UseNetworkTls): [boolean, NetworkTlsArgs] => { + const getTlsSelector = networkSelectors.tlsSelector(); + const { activePage, limit, sort } = useSelector( + (state: State) => getTlsSelector(state, type, flowTarget), + shallowEqual + ); + const { data, notifications, uiSettings } = useKibana().services; + const refetch = useRef(noop); + const abortCtrl = useRef(new AbortController()); + const defaultIndex = uiSettings.get(DEFAULT_INDEX_KEY); + const [loading, setLoading] = useState(false); + + const [networkTlsRequest, setHostRequest] = useState({ + defaultIndex, + factoryQueryType: NetworkQueries.tls, + filterQuery: createFilter(filterQuery), + flowTarget, + ip, + pagination: generateTablePaginationOptions(activePage, limit), + sort, + timerange: { + interval: '12h', + from: startDate ? startDate : '', + to: endDate ? endDate : new Date(Date.now()).toISOString(), + }, + }); + + const wrappedLoadMore = useCallback( + (newActivePage: number) => { + setHostRequest((prevRequest) => ({ + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + })); + }, + [limit] + ); -type TlsProps = OwnProps & TlsComponentReduxProps & WithKibanaProps; + const [networkTlsResponse, setNetworkTlsResponse] = useState({ + tls: [], + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + loadPage: wrappedLoadMore, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + refetch: refetch.current, + totalCount: -1, + }); -class TlsComponentQuery extends QueryTemplatePaginated< - TlsProps, - GetTlsQuery.Query, - GetTlsQuery.Variables -> { - public render() { - const { - activePage, - children, - endDate, - filterQuery, - flowTarget, - id = ID, - ip, - isInspected, - kibana, - limit, - skip, - sourceId, - startDate, - sort, - } = this.props; - const variables: GetTlsQuery.Variables = { - defaultIndex: kibana.services.uiSettings.get(DEFAULT_INDEX_KEY), - filterQuery: createFilter(filterQuery), - flowTarget, - inspect: isInspected, - ip, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - sourceId, - timerange: { - interval: '12h', - from: startDate ? startDate : '', - to: endDate ? endDate : new Date(Date.now()).toISOString(), - }, - }; - return ( - - query={tlsQuery} - fetchPolicy={getDefaultFetchPolicy()} - notifyOnNetworkStatusChange - skip={skip} - variables={variables} - > - {({ data, loading, fetchMore, networkStatus, refetch }) => { - const tls = getOr([], 'source.Tls.edges', data); - this.setFetchMore(fetchMore); - this.setFetchMoreOptions((newActivePage: number) => ({ - variables: { - pagination: generateTablePaginationOptions(newActivePage, limit), + const networkTlsSearch = useCallback( + (request: NetworkTlsRequestOptions) => { + let didCancel = false; + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + const searchSubscription$ = data.search + .search(request, { + strategy: 'securitySolutionSearchStrategy', + signal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (!response.isPartial && !response.isRunning) { + if (!didCancel) { + setLoading(false); + setNetworkTlsResponse((prevResponse) => ({ + ...prevResponse, + tls: response.edges, + inspect: response.inspect ?? prevResponse.inspect, + pageInfo: response.pageInfo, + refetch: refetch.current, + totalCount: response.totalCount, + })); + } + searchSubscription$.unsubscribe(); + } else if (response.isPartial && !response.isRunning) { + if (!didCancel) { + setLoading(false); + } + // TODO: Make response error status clearer + notifications.toasts.addWarning(i18n.ERROR_NETWORK_TLS); + searchSubscription$.unsubscribe(); + } }, - updateQuery: (prev, { fetchMoreResult }) => { - if (!fetchMoreResult) { - return prev; + error: (msg) => { + if (!(msg instanceof AbortError)) { + notifications.toasts.addDanger({ title: i18n.FAIL_NETWORK_TLS, text: msg.message }); } - return { - ...fetchMoreResult, - source: { - ...fetchMoreResult.source, - Tls: { - ...fetchMoreResult.source.Tls, - edges: [...fetchMoreResult.source.Tls.edges], - }, - }, - }; }, - })); - const isLoading = this.isItAValidLoading(loading, variables, networkStatus); - return children({ - id, - inspect: getOr(null, 'source.Tls.inspect', data), - isInspected, - loading: isLoading, - loadPage: this.wrappedLoadMore, - pageInfo: getOr({}, 'source.Tls.pageInfo', data), - refetch: this.memoizedRefetchQuery(variables, limit, refetch), - tls, - totalCount: getOr(-1, 'source.Tls.totalCount', data), }); - }} - - ); - } -} + }; + abortCtrl.current.abort(); + asyncSearch(); + refetch.current = asyncSearch; + return () => { + didCancel = true; + abortCtrl.current.abort(); + }; + }, + [data.search, notifications.toasts] + ); -const makeMapStateToProps = () => { - const getTlsSelector = networkSelectors.tlsSelector(); - const getQuery = inputsSelectors.globalQueryByIdSelector(); - return (state: State, { flowTarget, id = ID, type }: OwnProps) => { - const { isInspected } = getQuery(state, id); - return { - ...getTlsSelector(state, type, flowTarget), - isInspected, - }; - }; -}; + useEffect(() => { + setHostRequest((prevRequest) => { + const myRequest = { + ...prevRequest, + defaultIndex, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + sort, + }; + if (!skip && !deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [activePage, defaultIndex, endDate, filterQuery, limit, startDate, sort, skip]); + + useEffect(() => { + networkTlsSearch(networkTlsRequest); + }, [networkTlsRequest, networkTlsSearch]); -export const TlsQuery = compose>( - connect(makeMapStateToProps), - withKibana -)(TlsComponentQuery); + return [loading, networkTlsResponse]; +}; diff --git a/x-pack/plugins/security_solution/public/network/containers/tls/translations.ts b/x-pack/plugins/security_solution/public/network/containers/tls/translations.ts new file mode 100644 index 00000000000000..aafa3ff0a98b0a --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/tls/translations.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_NETWORK_TLS = i18n.translate( + 'xpack.securitySolution.networkTls.errorSearchDescription', + { + defaultMessage: `An error has occurred on network tls search`, + } +); + +export const FAIL_NETWORK_TLS = i18n.translate( + 'xpack.securitySolution.networkTls.failSearchDescription', + { + defaultMessage: `Failed to run search on network tls`, + } +); diff --git a/x-pack/plugins/security_solution/public/network/pages/ip_details/tls_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/ip_details/tls_query_table.tsx index f0c3628af78d85..5184fccecf07a5 100644 --- a/x-pack/plugins/security_solution/public/network/pages/ip_details/tls_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/ip_details/tls_query_table.tsx @@ -8,7 +8,7 @@ import { getOr } from 'lodash/fp'; import React from 'react'; import { manageQuery } from '../../../common/components/page/manage_query'; import { TlsTable } from '../../components/tls_table'; -import { TlsQuery } from '../../containers/tls'; +import { useNetworkTls } from '../../containers/tls'; import { TlsQueryTableComponentProps } from './types'; const TlsTableManage = manageQuery(TlsTable); @@ -22,34 +22,36 @@ export const TlsQueryTable = ({ skip, startDate, type, -}: TlsQueryTableComponentProps) => ( - - {({ id, inspect, isInspected, tls, totalCount, pageInfo, loading, loadPage, refetch }) => ( - - )} - -); +}: TlsQueryTableComponentProps) => { + const [ + loading, + { id, inspect, isInspected, tls, totalCount, pageInfo, loadPage, refetch }, + ] = useNetworkTls({ + endDate, + filterQuery, + flowTarget, + ip, + skip, + startDate, + type, + }); + + return ( + + ); +}; TlsQueryTable.displayName = 'TlsQueryTable'; diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/tls_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/tls_query_tab_body.tsx index 00da5496e54405..279891cc181e3d 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/tls_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/tls_query_tab_body.tsx @@ -6,13 +6,13 @@ import React from 'react'; import { getOr } from 'lodash/fp'; import { manageQuery } from '../../../common/components/page/manage_query'; -import { TlsQuery } from '../../../network/containers/tls'; +import { useNetworkTls } from '../../../network/containers/tls'; import { TlsTable } from '../../components/tls_table'; import { TlsQueryTabBodyProps } from './types'; const TlsTableManage = manageQuery(TlsTable); -export const TlsQueryTabBody = ({ +const TlsQueryTabBodyComponent: React.FC = ({ endDate, filterQuery, flowTarget, @@ -21,32 +21,38 @@ export const TlsQueryTabBody = ({ skip, startDate, type, -}: TlsQueryTabBodyProps) => ( - - {({ id, inspect, isInspected, tls, totalCount, pageInfo, loading, loadPage, refetch }) => ( - - )} - -); +}) => { + const [ + loading, + { id, inspect, isInspected, tls, totalCount, pageInfo, loadPage, refetch }, + ] = useNetworkTls({ + endDate, + filterQuery, + flowTarget, + ip, + skip, + startDate, + type, + }); + + return ( + + ); +}; + +TlsQueryTabBodyComponent.displayName = 'TlsQueryTabBodyComponent'; + +export const TlsQueryTabBody = React.memo(TlsQueryTabBodyComponent); 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 c7aba6fcc8a5b3..08f3f01bc99f6c 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 @@ -91,7 +91,7 @@ export const HostOverview = React.memo( description: data.host != null && data.host.name && data.host.name.length ? ( ) : ( @@ -103,7 +103,7 @@ export const HostOverview = React.memo( description: data.host != null && data.host.name && data.host.name.length ? ( ) : ( diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/helpers.ts new file mode 100644 index 00000000000000..5c29d2747f68d0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/helpers.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { set } from '@elastic/safer-lodash-set/fp'; +import { get, has, head } from 'lodash/fp'; +import { HostsEdges } from '../../../../../../common/search_strategy/security_solution/hosts'; +import { hostFieldsMap } from '../../../../../lib/ecs_fields'; + +import { HostAggEsItem, HostBuckets, HostValue } from '../../../../../lib/hosts/types'; + +const HOSTS_FIELDS = ['_id', 'lastSeen', 'host.id', 'host.name', 'host.os.name', 'host.os.version']; + +export const formatHostEdgesData = (bucket: HostAggEsItem): HostsEdges => + HOSTS_FIELDS.reduce( + (flattenedFields, fieldName) => { + const hostId = get('key', bucket); + flattenedFields.node._id = hostId || null; + flattenedFields.cursor.value = hostId || ''; + const fieldValue = getHostFieldValue(fieldName, bucket); + if (fieldValue != null) { + return set( + `node.${fieldName}`, + Array.isArray(fieldValue) ? fieldValue : [fieldValue], + flattenedFields + ); + } + return flattenedFields; + }, + { + node: {}, + cursor: { + value: '', + tiebreaker: null, + }, + } as HostsEdges + ); + +const getHostFieldValue = (fieldName: string, bucket: HostAggEsItem): string | string[] | null => { + const aggField = hostFieldsMap[fieldName] + ? hostFieldsMap[fieldName].replace(/\./g, '_') + : fieldName.replace(/\./g, '_'); + if ( + [ + 'host.ip', + 'host.mac', + 'cloud.instance.id', + 'cloud.machine.type', + 'cloud.provider', + 'cloud.region', + ].includes(fieldName) && + has(aggField, bucket) + ) { + const data: HostBuckets = get(aggField, bucket); + 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)) { + switch (fieldName) { + case 'host.name': + return get('key', bucket) || null; + case 'host.os.name': + 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; + } + } + return null; +}; + +const getFirstItem = (data: HostBuckets): string | null => { + const firstItem = head(data.buckets); + if (firstItem == null) { + return null; + } + return firstItem.key; +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts new file mode 100644 index 00000000000000..d4c2214b986453 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getOr } from 'lodash/fp'; + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; +import { + HostAggEsItem, + HostsStrategyResponse, + HostsQueries, + HostsRequestOptions, +} from '../../../../../../common/search_strategy/security_solution/hosts'; + +import { inspectStringifyObject } from '../../../../../utils/build_query'; +import { SecuritySolutionFactory } from '../../types'; +import { buildHostsQuery } from './query.all_hosts.dsl'; +import { formatHostEdgesData } from './helpers'; + +export const allHosts: SecuritySolutionFactory = { + buildDsl: (options: HostsRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + return buildHostsQuery(options); + }, + parse: async ( + options: HostsRequestOptions, + response: IEsSearchResponse + ): Promise => { + const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; + const totalCount = getOr(0, 'aggregations.host_count.value', response.rawResponse); + const buckets: HostAggEsItem[] = getOr( + [], + 'aggregations.host_data.buckets', + response.rawResponse + ); + const hostsEdges = buckets.map((bucket) => formatHostEdgesData(bucket)); + const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; + const edges = hostsEdges.splice(cursorStart, querySize - cursorStart); + const inspect = { + dsl: [inspectStringifyObject(buildHostsQuery(options))], + response: [inspectStringifyObject(response)], + }; + const showMorePagesIndicator = totalCount > fakeTotalCount; + + return { + ...response, + inspect, + edges, + totalCount, + pageInfo: { + activePage: activePage ? activePage : 0, + fakeTotalCount, + showMorePagesIndicator, + }, + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/dsl/query.hosts.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/query.all_hosts.dsl.ts similarity index 88% rename from x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/dsl/query.hosts.dsl.ts rename to x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/query.all_hosts.dsl.ts index a9101f54ada55c..ea1b896452c4e8 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/dsl/query.hosts.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/query.all_hosts.dsl.ts @@ -10,8 +10,10 @@ import { Direction, HostsRequestOptions, SortField, + HostsFields, } from '../../../../../../common/search_strategy/security_solution'; import { createQueryFilterClauses } from '../../../../../utils/build_query'; +import { assertUnreachable } from '../../../../../../common/utility_types'; export const buildHostsQuery = ({ defaultIndex, @@ -77,11 +79,13 @@ export const buildHostsQuery = ({ type QueryOrder = { lastSeen: Direction } | { _key: Direction }; -const getQueryOrder = (sort: SortField): QueryOrder => { +const getQueryOrder = (sort: SortField): QueryOrder => { switch (sort.field) { - case 'lastSeen': + case HostsFields.lastSeen: return { lastSeen: sort.direction }; - case 'hostName': + case HostsFields.hostName: return { _key: sort.direction }; + default: + return assertUnreachable(sort.field as never); } }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts index 443e524d71ca3b..34676fc1932fed 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts @@ -4,89 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { get, getOr } from 'lodash/fp'; - -import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; - -import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../common/constants'; import { FactoryQueryTypes } from '../../../../../common/search_strategy/security_solution'; -import { - HostsStrategyResponse, - HostOverviewStrategyResponse, - HostsQueries, - HostsRequestOptions, - HostOverviewRequestOptions, -} from '../../../../../common/search_strategy/security_solution/hosts'; +import { HostsQueries } from '../../../../../common/search_strategy/security_solution/hosts'; -// TO DO need to move all this types in common -import { HostAggEsData, HostAggEsItem } from '../../../../lib/hosts/types'; - -import { inspectStringifyObject } from '../../../../utils/build_query'; import { SecuritySolutionFactory } from '../types'; -import { buildHostOverviewQuery } from './dsl/query.detail_host.dsl'; -import { buildHostsQuery } from './dsl/query.hosts.dsl'; -import { formatHostEdgesData, formatHostItem } from './helpers'; - -export const allHosts: SecuritySolutionFactory = { - buildDsl: (options: HostsRequestOptions) => { - if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { - throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); - } - return buildHostsQuery(options); - }, - parse: async ( - options: HostsRequestOptions, - response: IEsSearchResponse - ): Promise => { - const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; - const totalCount = getOr(0, 'aggregations.host_count.value', response.rawResponse); - const buckets: HostAggEsItem[] = getOr( - [], - 'aggregations.host_data.buckets', - response.rawResponse - ); - const hostsEdges = buckets.map((bucket) => formatHostEdgesData(bucket)); - const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; - const edges = hostsEdges.splice(cursorStart, querySize - cursorStart); - const inspect = { - dsl: [inspectStringifyObject(buildHostsQuery(options))], - response: [inspectStringifyObject(response)], - }; - const showMorePagesIndicator = totalCount > fakeTotalCount; - - return { - ...response, - inspect, - edges, - totalCount, - pageInfo: { - activePage: activePage ? activePage : 0, - fakeTotalCount, - showMorePagesIndicator, - }, - }; - }, -}; - -export const overviewHost: SecuritySolutionFactory = { - buildDsl: (options: HostOverviewRequestOptions) => { - return buildHostOverviewQuery(options); - }, - parse: async ( - options: HostOverviewRequestOptions, - response: IEsSearchResponse - ): Promise => { - const aggregations: HostAggEsItem = get('aggregations', response.rawResponse) || {}; - const inspect = { - dsl: [inspectStringifyObject(buildHostOverviewQuery(options))], - response: [inspectStringifyObject(response)], - }; - const formattedHostItem = formatHostItem(aggregations); - return { ...response, inspect, _id: options.hostName, ...formattedHostItem }; - }, -}; +import { allHosts } from './all'; +import { overviewHost } from './overview'; +import { firstLastSeenHost } from './last_first_seen'; export const hostsFactory: Record> = { [HostsQueries.hosts]: allHosts, [HostsQueries.hostOverview]: overviewHost, + [HostsQueries.firstLastSeen]: firstLastSeenHost, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/index.ts new file mode 100644 index 00000000000000..56895583c2ae9b --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/index.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash/fp'; + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { + HostAggEsData, + HostAggEsItem, + HostFirstLastSeenStrategyResponse, + HostsQueries, + HostFirstLastSeenRequestOptions, +} from '../../../../../../common/search_strategy/security_solution/hosts'; + +import { inspectStringifyObject } from '../../../../../utils/build_query'; +import { SecuritySolutionFactory } from '../../types'; +import { buildFirstLastSeenHostQuery } from './query.last_first_seen_host.dsl'; + +export const firstLastSeenHost: SecuritySolutionFactory = { + buildDsl: (options: HostFirstLastSeenRequestOptions) => buildFirstLastSeenHostQuery(options), + parse: async ( + options: HostFirstLastSeenRequestOptions, + response: IEsSearchResponse + ): Promise => { + const aggregations: HostAggEsItem = get('aggregations', response.rawResponse) || {}; + const inspect = { + dsl: [inspectStringifyObject(buildFirstLastSeenHostQuery(options))], + response: [inspectStringifyObject(response)], + }; + + return { + ...response, + inspect, + firstSeen: get('firstSeen.value_as_string', aggregations), + lastSeen: get('lastSeen.value_as_string', aggregations), + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/dsl/query.last_first_seen_host.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/query.last_first_seen_host.dsl.ts similarity index 80% rename from x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/dsl/query.last_first_seen_host.dsl.ts rename to x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/query.last_first_seen_host.dsl.ts index b57bbd2960e4fa..2c65f62b258a9f 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/dsl/query.last_first_seen_host.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/last_first_seen/query.last_first_seen_host.dsl.ts @@ -6,13 +6,13 @@ import { isEmpty } from 'lodash/fp'; import { ISearchRequestParams } from '../../../../../../../../../src/plugins/data/common'; -import { HostLastFirstSeenRequestOptions } from '../../../../../../common/search_strategy/security_solution'; +import { HostFirstLastSeenRequestOptions } from '../../../../../../common/search_strategy/security_solution/hosts'; -export const buildLastFirstSeenHostQuery = ({ +export const buildFirstLastSeenHostQuery = ({ hostName, defaultIndex, docValueFields, -}: HostLastFirstSeenRequestOptions): ISearchRequestParams => { +}: HostFirstLastSeenRequestOptions): ISearchRequestParams => { const filter = [{ term: { 'host.name': hostName } }]; const dslQuery = { diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/helpers.ts new file mode 100644 index 00000000000000..c7b0d8acc8782c --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/helpers.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { set } from '@elastic/safer-lodash-set/fp'; +import { get, has, head } from 'lodash/fp'; +import { HostItem } from '../../../../../../common/search_strategy/security_solution/hosts'; +import { hostFieldsMap } from '../../../../../lib/ecs_fields'; + +import { HostAggEsItem, HostBuckets, HostValue } from '../../../../../lib/hosts/types'; + +export const HOST_FIELDS = [ + '_id', + 'host.architecture', + 'host.id', + 'host.ip', + 'host.id', + 'host.mac', + 'host.name', + 'host.os.family', + 'host.os.name', + 'host.os.platform', + 'host.os.version', + 'host.type', + 'cloud.instance.id', + 'cloud.machine.type', + 'cloud.provider', + 'cloud.region', + 'endpoint.endpointPolicy', + 'endpoint.policyStatus', + 'endpoint.sensorVersion', +]; + +export const formatHostItem = (bucket: HostAggEsItem): HostItem => + HOST_FIELDS.reduce((flattenedFields, fieldName) => { + const fieldValue = getHostFieldValue(fieldName, bucket); + if (fieldValue != null) { + return set(fieldName, fieldValue, flattenedFields); + } + 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', + 'host.mac', + 'cloud.instance.id', + 'cloud.machine.type', + 'cloud.provider', + 'cloud.region', + ].includes(fieldName) && + has(aggField, bucket) + ) { + const data: HostBuckets = get(aggField, bucket); + 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)) { + switch (fieldName) { + case 'host.name': + return get('key', bucket) || null; + case 'host.os.name': + 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; + } + } + return null; +}; + +const getFirstItem = (data: HostBuckets): string | null => { + const firstItem = head(data.buckets); + if (firstItem == null) { + return null; + } + return firstItem.key; +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/index.ts new file mode 100644 index 00000000000000..8bdda9ef895b23 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/index.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash/fp'; + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { + HostAggEsData, + HostAggEsItem, + HostOverviewStrategyResponse, + HostsQueries, + HostOverviewRequestOptions, +} from '../../../../../../common/search_strategy/security_solution/hosts'; + +import { inspectStringifyObject } from '../../../../../utils/build_query'; +import { SecuritySolutionFactory } from '../../types'; +import { buildHostOverviewQuery } from './query.host_overview.dsl'; +import { formatHostItem } from './helpers'; + +export const overviewHost: SecuritySolutionFactory = { + buildDsl: (options: HostOverviewRequestOptions) => { + return buildHostOverviewQuery(options); + }, + parse: async ( + options: HostOverviewRequestOptions, + response: IEsSearchResponse + ): Promise => { + const aggregations: HostAggEsItem = get('aggregations', response.rawResponse) || {}; + const inspect = { + dsl: [inspectStringifyObject(buildHostOverviewQuery(options))], + response: [inspectStringifyObject(response)], + }; + const formattedHostItem = formatHostItem(aggregations); + + return { ...response, inspect, hostOverview: formattedHostItem }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/dsl/query.detail_host.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/query.host_overview.dsl.ts similarity index 91% rename from x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/dsl/query.detail_host.dsl.ts rename to x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/query.host_overview.dsl.ts index 5c5dec92a51001..913bc90df04bed 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/dsl/query.detail_host.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/query.host_overview.dsl.ts @@ -9,14 +9,14 @@ import { HostOverviewRequestOptions } from '../../../../../../common/search_stra import { cloudFieldsMap, hostFieldsMap } from '../../../../../lib/ecs_fields'; import { buildFieldsTermAggregation } from '../../../../../lib/hosts/helpers'; import { reduceFields } from '../../../../../utils/build_query/reduce_fields'; +import { HOST_FIELDS } from './helpers'; export const buildHostOverviewQuery = ({ - fields, hostName, defaultIndex, timerange: { from, to }, }: HostOverviewRequestOptions): ISearchRequestParams => { - const esFields = reduceFields(fields, { ...hostFieldsMap, ...cloudFieldsMap }); + const esFields = reduceFields(HOST_FIELDS, { ...hostFieldsMap, ...cloudFieldsMap }); const filter = [ { term: { 'host.name': hostName } }, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/index.ts index 53433dfc208cbc..a50c9e40048562 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/index.ts @@ -7,6 +7,7 @@ import { FactoryQueryTypes } from '../../../../common/search_strategy/security_solution'; import { hostsFactory } from './hosts'; +import { networkFactory } from './network'; import { SecuritySolutionFactory } from './types'; export const securitySolutionFactory: Record< @@ -14,4 +15,5 @@ export const securitySolutionFactory: Record< SecuritySolutionFactory > = { ...hostsFactory, + ...networkFactory, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/index.ts new file mode 100644 index 00000000000000..2c21d9741d648f --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FactoryQueryTypes } from '../../../../../common/search_strategy/security_solution'; +import { NetworkQueries } from '../../../../../common/search_strategy/security_solution/network'; + +import { SecuritySolutionFactory } from '../types'; +import { networkTls } from './tls'; + +export const networkFactory: Record> = { + [NetworkQueries.tls]: networkTls, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/helpers.ts new file mode 100644 index 00000000000000..59359fd35a34eb --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/helpers.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getOr } from 'lodash/fp'; + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { + TlsBuckets, + TlsEdges, +} from '../../../../../../common/search_strategy/security_solution/network'; + +export const getTlsEdges = (response: IEsSearchResponse): TlsEdges[] => + formatTlsEdges(getOr([], 'aggregations.sha1.buckets', response.rawResponse)); + +export const formatTlsEdges = (buckets: TlsBuckets[]): TlsEdges[] => + buckets.map((bucket: TlsBuckets) => { + const edge: TlsEdges = { + node: { + _id: bucket.key, + subjects: bucket.subjects.buckets.map(({ key }) => key), + ja3: bucket.ja3.buckets.map(({ key }) => key), + issuers: bucket.issuers.buckets.map(({ key }) => key), + // eslint-disable-next-line @typescript-eslint/naming-convention + notAfter: bucket.not_after.buckets.map(({ key_as_string }) => key_as_string), + }, + cursor: { + value: bucket.key, + tiebreaker: null, + }, + }; + return edge; + }); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/index.ts new file mode 100644 index 00000000000000..32836c0ef6869e --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getOr } from 'lodash/fp'; + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; + +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; +import { + NetworkTlsStrategyResponse, + NetworkQueries, + NetworkTlsRequestOptions, + TlsEdges, +} from '../../../../../../common/search_strategy/security_solution/network'; + +import { inspectStringifyObject } from '../../../../../utils/build_query'; +import { SecuritySolutionFactory } from '../../types'; + +import { getTlsEdges } from './helpers'; +import { buildTlsQuery } from './query.tls_network.dsl'; + +export const networkTls: SecuritySolutionFactory = { + buildDsl: (options: NetworkTlsRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + return buildTlsQuery(options); + }, + parse: async ( + options: NetworkTlsRequestOptions, + response: IEsSearchResponse + ): Promise => { + const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; + const totalCount = getOr(0, 'aggregations.count.value', response.rawResponse); + const tlsEdges: TlsEdges[] = getTlsEdges(response); + const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; + const edges = tlsEdges.splice(cursorStart, querySize - cursorStart); + const inspect = { + dsl: [inspectStringifyObject(buildTlsQuery(options))], + response: [inspectStringifyObject(response)], + }; + const showMorePagesIndicator = totalCount > fakeTotalCount; + + return { + ...response, + edges, + inspect, + pageInfo: { + activePage: activePage ? activePage : 0, + fakeTotalCount, + showMorePagesIndicator, + }, + totalCount, + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/query.tls_network.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/query.tls_network.dsl.ts new file mode 100644 index 00000000000000..eb4e25c29e3a13 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/tls/query.tls_network.dsl.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { assertUnreachable } from '../../../../../../common/utility_types'; +import { createQueryFilterClauses } from '../../../../../utils/build_query'; + +import { + NetworkTlsRequestOptions, + SortField, + Direction, + TlsFields, +} from '../../../../../../common/search_strategy/security_solution'; + +const getAggs = (querySize: number, sort: SortField) => ({ + count: { + cardinality: { + field: 'tls.server.hash.sha1', + }, + }, + sha1: { + terms: { + field: 'tls.server.hash.sha1', + size: querySize, + order: { + ...getQueryOrder(sort), + }, + }, + aggs: { + issuers: { + terms: { + field: 'tls.server.issuer', + }, + }, + subjects: { + terms: { + field: 'tls.server.subject', + }, + }, + not_after: { + terms: { + field: 'tls.server.not_after', + }, + }, + ja3: { + terms: { + field: 'tls.server.ja3s', + }, + }, + }, + }, +}); + +export const buildTlsQuery = ({ + ip, + sort, + filterQuery, + flowTarget, + pagination: { querySize }, + defaultIndex, + timerange: { from, to }, +}: NetworkTlsRequestOptions) => { + const defaultFilter = [ + ...createQueryFilterClauses(filterQuery), + { + range: { + '@timestamp': { gte: from, lte: to, format: 'strict_date_optional_time' }, + }, + }, + ]; + + const filter = ip ? [...defaultFilter, { term: { [`${flowTarget}.ip`]: ip } }] : defaultFilter; + + const dslQuery = { + allowNoIndices: true, + index: defaultIndex, + ignoreUnavailable: true, + body: { + aggs: { + ...getAggs(querySize, sort), + }, + query: { + bool: { + filter, + }, + }, + size: 0, + track_total_hits: false, + }, + }; + + return dslQuery; +}; + +interface QueryOrder { + _key: Direction; +} + +const getQueryOrder = (sort: SortField): QueryOrder => { + switch (sort.field) { + case TlsFields._id: + return { _key: sort.direction }; + default: + return assertUnreachable(sort.field); + } +}; diff --git a/x-pack/plugins/snapshot_restore/common/lib/snapshot_serialization.test.ts b/x-pack/plugins/snapshot_restore/common/lib/snapshot_serialization.test.ts index 473a3392deb3ee..e5d8e804691a8f 100644 --- a/x-pack/plugins/snapshot_restore/common/lib/snapshot_serialization.test.ts +++ b/x-pack/plugins/snapshot_restore/common/lib/snapshot_serialization.test.ts @@ -4,55 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { deserializeSnapshotDetails } from './snapshot_serialization'; +import { deserializeSnapshotDetails, serializeSnapshotConfig } from './snapshot_serialization'; -describe('deserializeSnapshotDetails', () => { - test('deserializes a snapshot', () => { - expect( - deserializeSnapshotDetails( - 'repositoryName', - { - snapshot: 'snapshot name', - uuid: 'UUID', - version_id: 5, - version: 'version', - indices: ['index2', 'index3', 'index1'], - include_global_state: false, - state: 'SUCCESS', - start_time: '0', - start_time_in_millis: 0, - end_time: '1', - end_time_in_millis: 1, - duration_in_millis: 1, - shards: { - total: 3, - failed: 1, - successful: 2, - }, - failures: [ - { - index: 'z', - shard: 1, - }, - { - index: 'a', - shard: 3, - }, - { - index: 'a', - shard: 1, - }, - { - index: 'a', - shard: 2, - }, - ], - }, - 'found-snapshots', - [ +describe('Snapshot serialization and deserialization', () => { + describe('deserializeSnapshotDetails', () => { + test('deserializes a snapshot', () => { + expect( + deserializeSnapshotDetails( + 'repositoryName', { - snapshot: 'last_successful_snapshot', - uuid: 'last_successful_snapshot_UUID', + snapshot: 'snapshot name', + uuid: 'UUID', version_id: 5, version: 'version', indices: ['index2', 'index3', 'index1'], @@ -87,56 +49,109 @@ describe('deserializeSnapshotDetails', () => { }, ], }, - ] - ) - ).toEqual({ - repository: 'repositoryName', - snapshot: 'snapshot name', - uuid: 'UUID', - versionId: 5, - version: 'version', - // Indices are sorted. - indices: ['index1', 'index2', 'index3'], - dataStreams: [], - includeGlobalState: false, - // Failures are grouped and sorted by index, and the failures themselves are sorted by shard. - indexFailures: [ - { - index: 'a', - failures: [ + 'found-snapshots', + [ { - shard: 1, - }, - { - shard: 2, - }, - { - shard: 3, - }, - ], - }, - { - index: 'z', - failures: [ - { - shard: 1, + snapshot: 'last_successful_snapshot', + uuid: 'last_successful_snapshot_UUID', + version_id: 5, + version: 'version', + indices: ['index2', 'index3', 'index1'], + include_global_state: false, + state: 'SUCCESS', + start_time: '0', + start_time_in_millis: 0, + end_time: '1', + end_time_in_millis: 1, + duration_in_millis: 1, + shards: { + total: 3, + failed: 1, + successful: 2, + }, + failures: [ + { + index: 'z', + shard: 1, + }, + { + index: 'a', + shard: 3, + }, + { + index: 'a', + shard: 1, + }, + { + index: 'a', + shard: 2, + }, + ], }, - ], + ] + ) + ).toEqual({ + repository: 'repositoryName', + snapshot: 'snapshot name', + uuid: 'UUID', + versionId: 5, + version: 'version', + // Indices are sorted. + indices: ['index1', 'index2', 'index3'], + dataStreams: [], + includeGlobalState: false, + // Failures are grouped and sorted by index, and the failures themselves are sorted by shard. + indexFailures: [ + { + index: 'a', + failures: [ + { + shard: 1, + }, + { + shard: 2, + }, + { + shard: 3, + }, + ], + }, + { + index: 'z', + failures: [ + { + shard: 1, + }, + ], + }, + ], + state: 'SUCCESS', + startTime: '0', + startTimeInMillis: 0, + endTime: '1', + endTimeInMillis: 1, + durationInMillis: 1, + shards: { + total: 3, + failed: 1, + successful: 2, }, - ], - state: 'SUCCESS', - startTime: '0', - startTimeInMillis: 0, - endTime: '1', - endTimeInMillis: 1, - durationInMillis: 1, - shards: { - total: 3, - failed: 1, - successful: 2, - }, - managedRepository: 'found-snapshots', - isLastSuccessfulSnapshot: false, + managedRepository: 'found-snapshots', + isLastSuccessfulSnapshot: false, + }); + }); + }); + + describe('serializeSnapshotConfig', () => { + test('serializes config as expected', () => { + const metadata = { test: 'what have you' }; + expect(serializeSnapshotConfig({ metadata, indices: '.k*' })).toEqual({ + metadata, + indices: ['.k*'], + }); + }); + test('serializes empty config as expected', () => { + expect(serializeSnapshotConfig({})).toEqual({}); }); }); }); diff --git a/x-pack/plugins/snapshot_restore/common/lib/snapshot_serialization.ts b/x-pack/plugins/snapshot_restore/common/lib/snapshot_serialization.ts index a85b49430eecd5..61a57c9288f033 100644 --- a/x-pack/plugins/snapshot_restore/common/lib/snapshot_serialization.ts +++ b/x-pack/plugins/snapshot_restore/common/lib/snapshot_serialization.ts @@ -131,10 +131,10 @@ export function deserializeSnapshotConfig(snapshotConfigEs: SnapshotConfigEs): S export function serializeSnapshotConfig(snapshotConfig: SnapshotConfig): SnapshotConfigEs { const { indices, ignoreUnavailable, includeGlobalState, partial, metadata } = snapshotConfig; - const indicesArray = csvToArray(indices); + const maybeIndicesArray = csvToArray(indices); const snapshotConfigEs: SnapshotConfigEs = { - indices: indicesArray, + indices: maybeIndicesArray, ignore_unavailable: ignoreUnavailable, include_global_state: includeGlobalState, partial, diff --git a/x-pack/plugins/snapshot_restore/common/lib/utils.ts b/x-pack/plugins/snapshot_restore/common/lib/utils.ts index 96eb7cb6908d89..3d07aa8de3a558 100644 --- a/x-pack/plugins/snapshot_restore/common/lib/utils.ts +++ b/x-pack/plugins/snapshot_restore/common/lib/utils.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -export const csvToArray = (indices?: string | string[]): string[] => { +export const csvToArray = (indices?: string | string[]): string[] | undefined => { return indices && Array.isArray(indices) ? indices : typeof indices === 'string' ? indices.split(',') - : []; + : undefined; }; diff --git a/x-pack/plugins/snapshot_restore/public/application/components/collapsible_lists/use_collapsible_list.ts b/x-pack/plugins/snapshot_restore/public/application/components/collapsible_lists/use_collapsible_list.ts index 275915c5760afd..7ec34dd7b3984a 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/collapsible_lists/use_collapsible_list.ts +++ b/x-pack/plugins/snapshot_restore/public/application/components/collapsible_lists/use_collapsible_list.ts @@ -24,7 +24,7 @@ const maximumItemPreviewCount = 10; export const useCollapsibleList = ({ items }: Arg): ReturnValue => { const [isShowingFullList, setIsShowingFullList] = useState(false); - const itemsArray = csvToArray(items); + const itemsArray = csvToArray(items) ?? []; const displayItems: ChildItems = items === undefined ? 'all' diff --git a/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_retention.tsx b/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_retention.tsx index ae22464c8a52b7..407b9be14e3c1b 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_retention.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_retention.tsx @@ -35,11 +35,17 @@ export const PolicyStepRetention: React.FunctionComponent = ({ }) => { const { retention = {} } = policy; - const updatePolicyRetention = (updatedFields: Partial): void => { + const updatePolicyRetention = ( + updatedFields: Partial, + validationHelperData = {} + ): void => { const newRetention = { ...retention, ...updatedFields }; - updatePolicy({ - retention: newRetention, - }); + updatePolicy( + { + retention: newRetention, + }, + validationHelperData + ); }; // State for touched inputs diff --git a/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_settings/fields/indices_and_data_streams_field/indices_and_data_streams_field.tsx b/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_settings/fields/indices_and_data_streams_field/indices_and_data_streams_field.tsx index 94854905e66863..6f89427516453b 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_settings/fields/indices_and_data_streams_field/indices_and_data_streams_field.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_settings/fields/indices_and_data_streams_field/indices_and_data_streams_field.tsx @@ -25,7 +25,7 @@ import { import { SlmPolicyPayload } from '../../../../../../../../common/types'; import { useServices } from '../../../../../../app_context'; -import { PolicyValidation } from '../../../../../../services/validation'; +import { PolicyValidation, ValidatePolicyHelperData } from '../../../../../../services/validation'; import { orderDataStreamsAndIndices } from '../../../../../lib'; import { DataStreamBadge } from '../../../../../data_stream_badge'; @@ -34,12 +34,16 @@ import { mapSelectionToIndicesOptions, determineListMode } from './helpers'; import { DataStreamsAndIndicesListHelpText } from './data_streams_and_indices_list_help_text'; +interface IndicesConfig { + indices?: string[] | string; +} + interface Props { isManagedPolicy: boolean; policy: SlmPolicyPayload; indices: string[]; dataStreams: string[]; - onUpdate: (arg: { indices?: string[] | string }) => void; + onUpdate: (arg: IndicesConfig, validateHelperData: ValidatePolicyHelperData) => void; errors: PolicyValidation['errors']; } @@ -53,7 +57,7 @@ export const IndicesAndDataStreamsField: FunctionComponent = ({ dataStreams, indices, policy, - onUpdate, + onUpdate: _onUpdate, errors, }) => { const { i18n } = useServices(); @@ -66,6 +70,12 @@ export const IndicesAndDataStreamsField: FunctionComponent = ({ !config.indices || (Array.isArray(config.indices) && config.indices.length === 0) ); + const onUpdate = (data: IndicesConfig) => { + _onUpdate(data, { + validateIndicesCount: !isAllIndices, + }); + }; + const [indicesAndDataStreamsSelection, setIndicesAndDataStreamsSelection] = useState( () => Array.isArray(config.indices) && !isAllIndices diff --git a/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_settings/step_settings.tsx b/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_settings/step_settings.tsx index 9d43c45d17ea7a..f65156bada2780 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_settings/step_settings.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/policy_form/steps/step_settings/step_settings.tsx @@ -31,11 +31,17 @@ export const PolicyStepSettings: React.FunctionComponent = ({ }) => { const { config = {}, isManagedPolicy } = policy; - const updatePolicyConfig = (updatedFields: Partial): void => { + const updatePolicyConfig = ( + updatedFields: Partial, + validationHelperData = {} + ): void => { const newConfig = { ...config, ...updatedFields }; - updatePolicy({ - config: newConfig, - }); + updatePolicy( + { + config: newConfig, + }, + validationHelperData + ); }; const renderIgnoreUnavailableField = () => ( diff --git a/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_logistics/step_logistics.tsx b/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_logistics/step_logistics.tsx index d9fd4cca0d614f..855abb04b7c2b1 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_logistics/step_logistics.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_logistics/step_logistics.tsx @@ -311,7 +311,7 @@ export const RestoreSnapshotStepLogistics: React.FunctionComponent = }); } }} - selectedIndicesAndDataStreams={csvToArray(restoreIndices)} + selectedIndicesAndDataStreams={csvToArray(restoreIndices) ?? []} indices={snapshotIndices} dataStreams={snapshotDataStreams} /> diff --git a/x-pack/plugins/snapshot_restore/public/application/lib/attempt_to_uri_decode.ts b/x-pack/plugins/snapshot_restore/public/application/lib/attempt_to_uri_decode.ts new file mode 100644 index 00000000000000..6f99fae118cd73 --- /dev/null +++ b/x-pack/plugins/snapshot_restore/public/application/lib/attempt_to_uri_decode.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const attemptToURIDecode = (value: string) => { + let result: string; + + try { + result = decodeURI(value); + result = decodeURIComponent(result); + } catch (e1) { + try { + result = decodeURIComponent(value); + } catch (e2) { + result = value; + } + } + + return result; +}; diff --git a/x-pack/plugins/snapshot_restore/public/application/lib/index.ts b/x-pack/plugins/snapshot_restore/public/application/lib/index.ts new file mode 100644 index 00000000000000..c544df4365606d --- /dev/null +++ b/x-pack/plugins/snapshot_restore/public/application/lib/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { useDecodedParams } from './use_decoded_params'; diff --git a/x-pack/plugins/snapshot_restore/public/application/lib/use_decoded_params.ts b/x-pack/plugins/snapshot_restore/public/application/lib/use_decoded_params.ts new file mode 100644 index 00000000000000..d4582de4084ba8 --- /dev/null +++ b/x-pack/plugins/snapshot_restore/public/application/lib/use_decoded_params.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useParams } from 'react-router-dom'; +import { attemptToURIDecode } from './attempt_to_uri_decode'; + +export const useDecodedParams = < + Params extends { [K in keyof Params]?: string } = {} +>(): Params => { + const params = useParams>(); + const decodedParams = {} as Params; + + for (const [key, value] of Object.entries(params)) { + if (value) { + (decodedParams as any)[key] = attemptToURIDecode(value); + } + } + + return decodedParams; +}; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/home.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/home.tsx index 962dfa73e95c71..12e7e2de9383dd 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/home.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/home.tsx @@ -89,7 +89,7 @@ export const SnapshotRestoreHome: React.FunctionComponent { - history.push(`${BASE_PATH}/${newSection}`); + history.push(encodeURI(`${BASE_PATH}/${encodeURIComponent(newSection)}`)); }; // Set breadcrumb and page title diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_details/policy_details.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_details/policy_details.tsx index 5959ad6441f5d9..f67e8eb586238f 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_details/policy_details.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_details/policy_details.tsx @@ -24,6 +24,8 @@ import { EuiSpacer, } from '@elastic/eui'; +import { reactRouterNavigate } from '../../../../../../../../../src/plugins/kibana_react/public'; + import { SlmPolicy } from '../../../../../../common/types'; import { useServices } from '../../../../app_context'; import { SectionError, Error } from '../../../../../shared_imports'; @@ -41,8 +43,6 @@ import { } from '../../../../components'; import { TabSummary, TabHistory } from './tabs'; -import { reactRouterNavigate } from '../../../../../../../../../src/plugins/kibana_react/public'; - interface Props { policyName: SlmPolicy['name']; onClose: () => void; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_list.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_list.tsx index 39ef66eb0658ce..655bd0e9d8bb9d 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_list.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_list.tsx @@ -9,6 +9,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { RouteComponentProps } from 'react-router-dom'; import { EuiEmptyPrompt, EuiButton, EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public'; + import { SectionError, Error, @@ -20,6 +22,7 @@ import { SlmPolicy } from '../../../../../common/types'; import { APP_SLM_CLUSTER_PRIVILEGES } from '../../../../../common'; import { SectionLoading } from '../../../components'; import { BASE_PATH, UIM_POLICY_LIST_LOAD } from '../../../constants'; +import { useDecodedParams } from '../../../lib'; import { useLoadPolicies, useLoadRetentionSettings } from '../../../services/http'; import { linkToAddPolicy, linkToPolicy } from '../../../services/navigation'; import { useServices } from '../../../app_context'; @@ -28,18 +31,14 @@ import { PolicyDetails } from './policy_details'; import { PolicyTable } from './policy_table'; import { PolicyRetentionSchedule } from './policy_retention_schedule'; -import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public'; - interface MatchParams { policyName?: SlmPolicy['name']; } export const PolicyList: React.FunctionComponent> = ({ - match: { - params: { policyName }, - }, history, }) => { + const { policyName } = useDecodedParams(); const { error, isLoading, diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_list.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_list.tsx index f6b9ac08cc0a22..9afdad3806defb 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_list.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_list.tsx @@ -7,11 +7,14 @@ import React, { Fragment, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { RouteComponentProps } from 'react-router-dom'; - import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; + +import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public'; + import { Repository } from '../../../../../common/types'; import { SectionError, Error } from '../../../../shared_imports'; import { SectionLoading } from '../../../components'; +import { useDecodedParams } from '../../../lib'; import { BASE_PATH, UIM_REPOSITORY_LIST_LOAD } from '../../../constants'; import { useServices } from '../../../app_context'; import { useLoadRepositories } from '../../../services/http'; @@ -20,18 +23,14 @@ import { linkToAddRepository, linkToRepository } from '../../../services/navigat import { RepositoryDetails } from './repository_details'; import { RepositoryTable } from './repository_table'; -import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public'; - interface MatchParams { repositoryName?: Repository['name']; } export const RepositoryList: React.FunctionComponent> = ({ - match: { - params: { repositoryName }, - }, history, }) => { + const { repositoryName } = useDecodedParams(); const { error, isLoading, diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_list.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_list.tsx index 2b8df7294c3749..d13188fc44730e 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_list.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/snapshot_list.tsx @@ -24,6 +24,7 @@ import { linkToSnapshot, } from '../../../services/navigation'; import { useServices } from '../../../app_context'; +import { useDecodedParams } from '../../../lib'; import { SnapshotDetails } from './snapshot_details'; import { SnapshotTable } from './snapshot_table'; @@ -35,12 +36,10 @@ interface MatchParams { } export const SnapshotList: React.FunctionComponent> = ({ - match: { - params: { repositoryName, snapshotId }, - }, location: { search }, history, }) => { + const { repositoryName, snapshotId } = useDecodedParams(); const { error, isLoading, diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/policy_add/policy_add.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/policy_add/policy_add.tsx index 90cd26c821c5e2..dead9abb6ef0c1 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/policy_add/policy_add.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/policy_add/policy_add.tsx @@ -43,7 +43,7 @@ export const PolicyAdd: React.FunctionComponent = ({ if (error) { setSaveError(error); } else { - history.push(`${BASE_PATH}/policies/${name}`); + history.push(encodeURI(`${BASE_PATH}/policies/${encodeURIComponent(name)}`)); } }; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/policy_edit/policy_edit.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/policy_edit/policy_edit.tsx index 915b1cbef1233a..7af663b29957d8 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/policy_edit/policy_edit.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/policy_edit/policy_edit.tsx @@ -10,6 +10,7 @@ import { RouteComponentProps } from 'react-router-dom'; import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle, EuiCallOut } from '@elastic/eui'; import { SlmPolicyPayload } from '../../../../common/types'; import { SectionError, Error } from '../../../shared_imports'; +import { useDecodedParams } from '../../lib'; import { TIME_UNITS } from '../../../../common/constants'; import { SectionLoading, PolicyForm } from '../../components'; import { BASE_PATH } from '../../constants'; @@ -22,12 +23,10 @@ interface MatchParams { } export const PolicyEdit: React.FunctionComponent> = ({ - match: { - params: { name }, - }, history, location: { pathname }, }) => { + const { name } = useDecodedParams(); const { i18n } = useServices(); // Set breadcrumb and page title @@ -83,12 +82,12 @@ export const PolicyEdit: React.FunctionComponent { - history.push(`${BASE_PATH}/policies/${name}`); + history.push(encodeURI(`${BASE_PATH}/policies/${encodeURIComponent(name)}`)); }; const renderLoading = () => { diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/repository_add/repository_add.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/repository_add/repository_add.tsx index 08bfde833c3688..a66d137471eba5 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/repository_add/repository_add.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/repository_add/repository_add.tsx @@ -44,7 +44,11 @@ export const RepositoryAdd: React.FunctionComponent = ({ } else { const { redirect } = parse(search.replace(/^\?/, ''), { sort: false }); - history.push(redirect ? (redirect as string) : `${BASE_PATH}/${section}/${name}`); + history.push( + redirect + ? (redirect as string) + : encodeURI(`${BASE_PATH}/${encodeURIComponent(section)}/${encodeURIComponent(name)}`) + ); } }; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/repository_edit/repository_edit.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/repository_edit/repository_edit.tsx index 95f8b9b8bde7d0..3e8cff57935725 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/repository_edit/repository_edit.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/repository_edit/repository_edit.tsx @@ -16,18 +16,17 @@ import { BASE_PATH, Section } from '../../constants'; import { useServices } from '../../app_context'; import { breadcrumbService, docTitleService } from '../../services/navigation'; import { editRepository, useLoadRepository } from '../../services/http'; +import { useDecodedParams } from '../../lib'; interface MatchParams { name: string; } export const RepositoryEdit: React.FunctionComponent> = ({ - match: { - params: { name }, - }, history, }) => { const { i18n } = useServices(); + const { name } = useDecodedParams(); const section = 'repositories' as Section; // Set breadcrumb and page title @@ -70,7 +69,9 @@ export const RepositoryEdit: React.FunctionComponent> = ({ - match: { - params: { repositoryName, snapshotId }, - }, history, }) => { const { i18n } = useServices(); + const { repositoryName, snapshotId } = useDecodedParams(); // Set breadcrumb and page title useEffect(() => { diff --git a/x-pack/plugins/snapshot_restore/public/application/services/navigation/links.ts b/x-pack/plugins/snapshot_restore/public/application/services/navigation/links.ts index 503704c6fe820f..b498dc280d3952 100644 --- a/x-pack/plugins/snapshot_restore/public/application/services/navigation/links.ts +++ b/x-pack/plugins/snapshot_restore/public/application/services/navigation/links.ts @@ -13,33 +13,37 @@ export function linkToRepositories() { } export function linkToRepository(repositoryName: string) { - return `/repositories/${encodeURIComponent(repositoryName)}`; + return encodeURI(`/repositories/${encodeURIComponent(repositoryName)}`); } export function linkToEditRepository(repositoryName: string) { - return `/edit_repository/${encodeURIComponent(repositoryName)}`; + return encodeURI(`/edit_repository/${encodeURIComponent(repositoryName)}`); } export function linkToAddRepository(redirect?: string) { - return `/add_repository${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`; + return encodeURI(`/add_repository${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`); } export function linkToSnapshots(repositoryName?: string, policyName?: string) { if (repositoryName) { - return `/snapshots?repository=${encodeURIComponent(repositoryName)}`; + return encodeURI(`/snapshots?repository=${encodeURIComponent(repositoryName)}`); } if (policyName) { - return `/snapshots?policy=${encodeURIComponent(policyName)}`; + return encodeURI(`/snapshots?policy=${encodeURIComponent(policyName)}`); } return `/snapshots`; } export function linkToSnapshot(repositoryName: string, snapshotName: string) { - return `/snapshots/${encodeURIComponent(repositoryName)}/${encodeURIComponent(snapshotName)}`; + return encodeURI( + `/snapshots/${encodeURIComponent(repositoryName)}/${encodeURIComponent(snapshotName)}` + ); } export function linkToRestoreSnapshot(repositoryName: string, snapshotName: string) { - return `/restore/${encodeURIComponent(repositoryName)}/${encodeURIComponent(snapshotName)}`; + return encodeURI( + `/restore/${encodeURIComponent(repositoryName)}/${encodeURIComponent(snapshotName)}` + ); } export function linkToPolicies() { @@ -47,11 +51,11 @@ export function linkToPolicies() { } export function linkToPolicy(policyName: string) { - return `/policies/${encodeURIComponent(policyName)}`; + return encodeURI(`/policies/${encodeURIComponent(policyName)}`); } export function linkToEditPolicy(policyName: string) { - return `/edit_policy/${encodeURIComponent(policyName)}`; + return encodeURI(`/edit_policy/${encodeURIComponent(policyName)}`); } export function linkToAddPolicy() { diff --git a/x-pack/plugins/snapshot_restore/public/application/services/validation/index.ts b/x-pack/plugins/snapshot_restore/public/application/services/validation/index.ts index 7fd755497eec68..82bf0ef06c400a 100644 --- a/x-pack/plugins/snapshot_restore/public/application/services/validation/index.ts +++ b/x-pack/plugins/snapshot_restore/public/application/services/validation/index.ts @@ -12,4 +12,4 @@ export { export { RestoreValidation, validateRestore } from './validate_restore'; -export { PolicyValidation, validatePolicy } from './validate_policy'; +export { PolicyValidation, validatePolicy, ValidatePolicyHelperData } from './validate_policy'; diff --git a/x-pack/plugins/snapshot_restore/public/application/services/validation/validate_policy.ts b/x-pack/plugins/snapshot_restore/public/application/services/validation/validate_policy.ts index 24960b2533230e..4314b703722f6f 100644 --- a/x-pack/plugins/snapshot_restore/public/application/services/validation/validate_policy.ts +++ b/x-pack/plugins/snapshot_restore/public/application/services/validation/validate_policy.ts @@ -25,21 +25,32 @@ const isSnapshotNameNotLowerCase = (str: string): boolean => { return strExcludeDate !== strExcludeDate.toLowerCase() ? true : false; }; +export interface ValidatePolicyHelperData { + managedRepository?: { + name: string; + policy: string; + }; + isEditing?: boolean; + policyName?: string; + /** + * Whether to block on the indices configured for this snapshot. + * + * By default ES will back up all indices and data streams if this is an empty array or left blank. + * However, in the UI, under certain conditions, like when displaying indices to select for backup, + * we want to block users from submitting an empty array, but not block the entire form if they + * are not configuring this value - like when they are on a previous step. + */ + validateIndicesCount?: boolean; +} + export const validatePolicy = ( policy: SlmPolicyPayload, - validationHelperData: { - managedRepository?: { - name: string; - policy: string; - }; - isEditing?: boolean; - policyName?: string; - } + validationHelperData: ValidatePolicyHelperData ): PolicyValidation => { const i18n = textService.i18n; const { name, snapshotName, schedule, repository, config, retention } = policy; - const { managedRepository, isEditing, policyName } = validationHelperData; + const { managedRepository, isEditing, policyName, validateIndicesCount } = validationHelperData; const validation: PolicyValidation = { isValid: true, @@ -96,7 +107,12 @@ export const validatePolicy = ( ); } - if (config && typeof config.indices === 'string' && config.indices.trim().length === 0) { + if ( + validateIndicesCount && + config && + typeof config.indices === 'string' && + config.indices.trim().length === 0 + ) { validation.errors.indices.push( i18n.translate('xpack.snapshotRestore.policyValidation.indexPatternRequiredErrorMessage', { defaultMessage: 'At least one index pattern is required.', @@ -104,7 +120,12 @@ export const validatePolicy = ( ); } - if (config && Array.isArray(config.indices) && config.indices.length === 0) { + if ( + validateIndicesCount && + config && + Array.isArray(config.indices) && + config.indices.length === 0 + ) { validation.errors.indices.push( i18n.translate('xpack.snapshotRestore.policyValidation.indicesRequiredErrorMessage', { defaultMessage: 'You must select at least one data stream or index.', diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 07b646df74b9f5..83dac4912d4ac1 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12397,7 +12397,6 @@ "xpack.ml.settings.filterLists.deleteFilterListModal.cancelButtonLabel": "キャンセル", "xpack.ml.settings.filterLists.deleteFilterListModal.confirmButtonLabel": "削除", "xpack.ml.settings.filterLists.deleteFilterListModal.deleteButtonLabel": "削除", - "xpack.ml.settings.filterLists.deleteFilterListModal.deleteWarningMessage": "{selectedFilterListsLength, plural, one {このフィルダー} other {これらのフィルター}}を削除してよろしいですか?", "xpack.ml.settings.filterLists.deleteFilterListModal.modalTitle": "{selectedFilterListsLength, plural, one {{selectedFilterId}} other {# フィルターリスト}}の削除", "xpack.ml.settings.filterLists.deleteFilterLists.deletingErrorMessage": "フィルターリスト {filterListId} の削除中にエラーが発生しました。{respMessage}", "xpack.ml.settings.filterLists.deleteFilterLists.deletingNotificationMessage": "{filterListsToDeleteLength, plural, one {{filterListToDeleteId}} other {# フィルターリスト}}を削除しています", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ffd7d0cfb0f87f..8b3a1941f450a4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12401,7 +12401,6 @@ "xpack.ml.settings.filterLists.deleteFilterListModal.cancelButtonLabel": "取消", "xpack.ml.settings.filterLists.deleteFilterListModal.confirmButtonLabel": "删除", "xpack.ml.settings.filterLists.deleteFilterListModal.deleteButtonLabel": "删除", - "xpack.ml.settings.filterLists.deleteFilterListModal.deleteWarningMessage": "是否确定要删除{selectedFilterListsLength, plural, one {此筛选列表} other {这些筛选列表}}", "xpack.ml.settings.filterLists.deleteFilterListModal.modalTitle": "删除 {selectedFilterListsLength, plural, one {{selectedFilterId}} other {# 个筛选列表}}", "xpack.ml.settings.filterLists.deleteFilterLists.deletingErrorMessage": "删除筛选列表 {filterListId} 时出错。{respMessage}", "xpack.ml.settings.filterLists.deleteFilterLists.deletingNotificationMessage": "正在删除 {filterListsToDeleteLength, plural, one {{filterListToDeleteId}} other {# 个筛选列表}}", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx index 417a9e09086a2c..b56ae35df5d06a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx @@ -72,17 +72,31 @@ describe('index connector validation with minimal config', () => { describe('action params validation', () => { test('action params validation succeeds when action params is valid', () => { const actionParams = { - documents: ['test'], + documents: [{ test: 1234 }], }; expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: {}, + errors: { + documents: [], + }, }); const emptyActionParams = {}; expect(actionTypeModel.validateParams(emptyActionParams)).toEqual({ - errors: {}, + errors: { + documents: ['Document is required and should be a valid JSON object.'], + }, + }); + + const invalidDocumentActionParams = { + documents: [{}], + }; + + expect(actionTypeModel.validateParams(invalidDocumentActionParams)).toEqual({ + errors: { + documents: ['Document is required and should be a valid JSON object.'], + }, }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx index 3ee663a5fc8a06..c0255650e0f37e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx @@ -44,8 +44,23 @@ export function getActionType(): ActionTypeModel import('./es_index_connector')), actionParamsFields: lazy(() => import('./es_index_params')), - validateParams: (): ValidationResult => { - return { errors: {} }; + validateParams: (actionParams: IndexActionParams): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + documents: new Array(), + }; + validationResult.errors = errors; + if (!actionParams.documents?.length || Object.keys(actionParams.documents[0]).length === 0) { + errors.documents.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredDocumentJson', + { + defaultMessage: 'Document is required and should be a valid JSON object.', + } + ) + ); + } + return validationResult; }, }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx index e8e8cc582512e5..495707db4975cf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx @@ -17,6 +17,7 @@ export const IndexParamsFields = ({ editAction, messageVariables, docLinks, + errors, }: ActionParamsProps) => { const { documents } = actionParams; @@ -24,8 +25,10 @@ export const IndexParamsFields = ({ try { const documentsJSON = JSON.parse(updatedDocuments); editAction('documents', [documentsJSON], index); - // eslint-disable-next-line no-empty - } catch (e) {} + } catch (e) { + // set document as empty to turn on the validation for non empty valid JSON object + editAction('documents', [{}], index); + } }; return ( @@ -34,7 +37,7 @@ export const IndexParamsFields = ({ messageVariables={messageVariables} paramsProperty={'documents'} inputTargetValue={ - documents && documents.length > 0 ? ((documents[0] as unknown) as string) : '' + documents && documents.length > 0 ? ((documents[0] as unknown) as string) : undefined } label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.documentsFieldLabel', @@ -48,6 +51,7 @@ export const IndexParamsFields = ({ defaultMessage: 'Code editor', } )} + errors={errors.documents as string[]} onDocumentsChange={onDocumentsChange} helpText={ } + onBlur={() => { + if ( + !(documents && documents.length > 0 ? ((documents[0] as unknown) as string) : undefined) + ) { + // set document as empty to turn on the validation for non empty valid JSON object + onDocumentsChange('{}'); + } + }} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.tsx index 1dfd9e3edc2c5a..ff9ad936f224ff 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.tsx @@ -21,7 +21,7 @@ const WebhookParamsFields: React.FunctionComponent { editAction('body', json, index); }} + onBlur={() => { + if (!body) { + editAction('body', '', index); + } + }} /> ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx index 0b8184fc441fdc..5ea15deb53161d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx @@ -14,12 +14,13 @@ import { ActionVariable } from '../../types'; interface Props { messageVariables?: ActionVariable[]; paramsProperty: string; - inputTargetValue: string; + inputTargetValue?: string; label: string; errors?: string[]; areaLabel?: string; onDocumentsChange: (data: string) => void; helpText?: JSX.Element; + onBlur?: () => void; } export const JsonEditorWithMessageVariables: React.FunctionComponent = ({ @@ -31,6 +32,7 @@ export const JsonEditorWithMessageVariables: React.FunctionComponent = ({ areaLabel, onDocumentsChange, helpText, + onBlur, }) => { const [cursorPosition, setCursorPosition] = useState(null); @@ -84,6 +86,7 @@ export const JsonEditorWithMessageVariables: React.FunctionComponent = ({ onDocumentsChange(convertToJson(xjson)); }} onCursorChange={(_value: any) => onClickWithMessageVariable(_value)} + onBlur={onBlur} /> ); diff --git a/x-pack/plugins/watcher/public/application/lib/api.ts b/x-pack/plugins/watcher/public/application/lib/api.ts index 82ec2925ba6dca..4f42b26d51c46d 100644 --- a/x-pack/plugins/watcher/public/application/lib/api.ts +++ b/x-pack/plugins/watcher/public/application/lib/api.ts @@ -35,44 +35,53 @@ export const getSavedObjectsClient = () => savedObjectsClient; const basePath = ROUTES.API_ROOT; +const loadWatchesDeserializer = ({ watches = [] }: { watches: any[] }) => { + return watches.map((watch: any) => Watch.fromUpstreamJson(watch)); +}; + export const useLoadWatches = (pollIntervalMs: number) => { return useRequest({ path: `${basePath}/watches`, method: 'get', pollIntervalMs, - deserializer: ({ watches = [] }: { watches: any[] }) => { - return watches.map((watch: any) => Watch.fromUpstreamJson(watch)); - }, + deserializer: loadWatchesDeserializer, }); }; +const loadWatchDetailDeserializer = ({ watch = {} }: { watch: any }) => + Watch.fromUpstreamJson(watch); + export const useLoadWatchDetail = (id: string) => { return useRequest({ path: `${basePath}/watch/${id}`, method: 'get', - deserializer: ({ watch = {} }: { watch: any }) => Watch.fromUpstreamJson(watch), + deserializer: loadWatchDetailDeserializer, }); }; +const loadWatchHistoryDeserializer = ({ watchHistoryItems = [] }: { watchHistoryItems: any }) => { + return watchHistoryItems.map((historyItem: any) => + WatchHistoryItem.fromUpstreamJson(historyItem) + ); +}; + export const useLoadWatchHistory = (id: string, startTime: string) => { return useRequest({ query: startTime ? { startTime } : undefined, path: `${basePath}/watch/${id}/history`, method: 'get', - deserializer: ({ watchHistoryItems = [] }: { watchHistoryItems: any }) => { - return watchHistoryItems.map((historyItem: any) => - WatchHistoryItem.fromUpstreamJson(historyItem) - ); - }, + deserializer: loadWatchHistoryDeserializer, }); }; +const loadWatchHistoryDetailDeserializer = ({ watchHistoryItem }: { watchHistoryItem: any }) => + WatchHistoryItem.fromUpstreamJson(watchHistoryItem); + export const useLoadWatchHistoryDetail = (id: string | undefined) => { return useRequest({ path: !id ? '' : `${basePath}/history/${id}`, method: 'get', - deserializer: ({ watchHistoryItem }: { watchHistoryItem: any }) => - WatchHistoryItem.fromUpstreamJson(watchHistoryItem), + deserializer: loadWatchHistoryDetailDeserializer, }); }; @@ -148,6 +157,8 @@ export const loadIndexPatterns = async () => { return savedObjects; }; +const getWatchVisualizationDataDeserializer = (data: { visualizeData: any }) => data?.visualizeData; + export const useGetWatchVisualizationData = (watchModel: BaseWatch, visualizeOptions: any) => { return useRequest({ path: `${basePath}/watch/visualize`, @@ -156,21 +167,23 @@ export const useGetWatchVisualizationData = (watchModel: BaseWatch, visualizeOpt watch: watchModel.upstreamJson, options: visualizeOptions.upstreamJson, }), - deserializer: (data: { visualizeData: any }) => data?.visualizeData, + deserializer: getWatchVisualizationDataDeserializer, }); }; +const loadSettingsDeserializer = (data: { + action_types: { + [key: string]: { + enabled: boolean; + }; + }; +}) => Settings.fromUpstreamJson(data); + export const useLoadSettings = () => { return useRequest({ path: `${basePath}/settings`, method: 'get', - deserializer: (data: { - action_types: { - [key: string]: { - enabled: boolean; - }; - }; - }) => Settings.fromUpstreamJson(data), + deserializer: loadSettingsDeserializer, }); }; diff --git a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/watch_visualization.tsx b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/watch_visualization.tsx index c0d906114277e5..2ff0f53d07e916 100644 --- a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/watch_visualization.tsx +++ b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/watch_visualization.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useContext, useEffect } from 'react'; +import React, { Fragment, useContext, useEffect, useMemo } from 'react'; import { AnnotationDomainTypes, Axis, @@ -105,7 +105,9 @@ export const WatchVisualization = () => { threshold, } = watch; - const domain = getDomain(watch); + // Only recalculate the domain if the watch configuration changes. This prevents the visualization + // request's resolution from re-triggering itself in an infinite loop. + const domain = useMemo(() => getDomain(watch), [watch]); const timeBuckets = createTimeBuckets(); timeBuckets.setBounds(domain); const interval = timeBuckets.getInterval().expression; diff --git a/x-pack/plugins/watcher/public/application/shared_imports.ts b/x-pack/plugins/watcher/public/application/shared_imports.ts index a9e07b80a9b221..766e8e659c8aeb 100644 --- a/x-pack/plugins/watcher/public/application/shared_imports.ts +++ b/x-pack/plugins/watcher/public/application/shared_imports.ts @@ -10,6 +10,6 @@ export { UseRequestConfig, sendRequest, useRequest, -} from '../../../../../src/plugins/es_ui_shared/public/'; +} from '../../../../../src/plugins/es_ui_shared/public'; export { useXJsonMode } from '../../../../../src/plugins/es_ui_shared/static/ace_x_json/hooks'; diff --git a/x-pack/test/functional/apps/monitoring/enable_monitoring/index.js b/x-pack/test/functional/apps/monitoring/enable_monitoring/index.js index fefad214736e6f..70f7e0559d0346 100644 --- a/x-pack/test/functional/apps/monitoring/enable_monitoring/index.js +++ b/x-pack/test/functional/apps/monitoring/enable_monitoring/index.js @@ -10,6 +10,7 @@ export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['monitoring', 'common', 'header']); const esSupertest = getService('esSupertest'); const noData = getService('monitoringNoData'); + const testSubjects = getService('testSubjects'); const clusterOverview = getService('monitoringClusterOverview'); const retry = getService('retry'); @@ -46,8 +47,10 @@ export default function ({ getService, getPageObjects }) { }); // Here we are checking that once Monitoring is enabled, - //it moves on to the cluster overview page. - await retry.try(async () => { + // it moves on to the cluster overview page. + await retry.tryForTime(10000, async () => { + // Click the refresh button + await testSubjects.click('querySubmitButton'); expect(await clusterOverview.isOnClusterOverview()).to.be(true); }); }); diff --git a/x-pack/test/functional/apps/monitoring/time_filter.js b/x-pack/test/functional/apps/monitoring/time_filter.js index 11557d995218e8..127c7d8889bc4f 100644 --- a/x-pack/test/functional/apps/monitoring/time_filter.js +++ b/x-pack/test/functional/apps/monitoring/time_filter.js @@ -7,8 +7,6 @@ import expect from '@kbn/expect'; import { getLifecycleMethods } from './_get_lifecycle_methods'; -const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['header', 'timePicker']); const testSubjects = getService('testSubjects'); @@ -36,12 +34,9 @@ export default function ({ getService, getPageObjects }) { expect(isLoading).to.be(true); }); - it('should send another request when changing the time picker', async () => { - /** - * TODO: The value should either be removed or lowered after: - * https://github.com/elastic/kibana/issues/72997 is resolved - */ - await delay(3000); + // TODO: [cr] I'm not sure this test is any better than the above one, we might need to rely solely on unit tests + // for this functionality + it.skip('should send another request when changing the time picker', async () => { await PageObjects.timePicker.setAbsoluteRange( 'Aug 15, 2016 @ 21:00:00.000', 'Aug 16, 2016 @ 00:00:00.000'