diff --git a/.ci/Jenkinsfile_baseline_capture b/.ci/Jenkinsfile_baseline_capture index 9a49c19b94df2..33ecfcd84fd3e 100644 --- a/.ci/Jenkinsfile_baseline_capture +++ b/.ci/Jenkinsfile_baseline_capture @@ -11,14 +11,14 @@ kibanaPipeline(timeoutMinutes: 120) { 'CI_PARALLEL_PROCESS_NUMBER=1' ]) { parallel([ - 'oss-visualRegression': { - workers.ci(name: 'oss-visualRegression', size: 's-highmem', ramDisk: true) { - kibanaPipeline.functionalTestProcess('oss-visualRegression', './test/scripts/jenkins_visual_regression.sh')() + 'oss-baseline': { + workers.ci(name: 'oss-baseline', size: 's-highmem', ramDisk: true, runErrorReporter: false) { + kibanaPipeline.functionalTestProcess('oss-baseline', './test/scripts/jenkins_baseline.sh')() } }, - 'xpack-visualRegression': { - workers.ci(name: 'xpack-visualRegression', size: 's-highmem', ramDisk: true) { - kibanaPipeline.functionalTestProcess('xpack-visualRegression', './test/scripts/jenkins_xpack_visual_regression.sh')() + 'xpack-baseline': { + workers.ci(name: 'xpack-baseline', size: 's-highmem', ramDisk: true, runErrorReporter: false) { + kibanaPipeline.functionalTestProcess('xpack-baseline', './test/scripts/jenkins_xpack_baseline.sh')() } }, ]) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c9962d9976e2d..7daa42af7024d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -163,7 +163,6 @@ # Security /src/core/server/csp/ @elastic/kibana-security @elastic/kibana-platform -/x-pack/legacy/plugins/security/ @elastic/kibana-security /x-pack/legacy/plugins/spaces/ @elastic/kibana-security /x-pack/plugins/spaces/ @elastic/kibana-security /x-pack/plugins/encrypted_saved_objects/ @elastic/kibana-security @@ -282,7 +281,6 @@ x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kib # Core design /src/plugins/dashboard/**/*.scss @elastic/kibana-core-ui-designers /x-pack/plugins/canvas/**/*.scss @elastic/kibana-core-ui-designers -/x-pack/legacy/plugins/security/**/*.scss @elastic/kibana-core-ui-designers /x-pack/legacy/plugins/spaces/**/*.scss @elastic/kibana-core-ui-designers /x-pack/plugins/spaces/**/*.scss @elastic/kibana-core-ui-designers /x-pack/plugins/security/**/*.scss @elastic/kibana-core-ui-designers diff --git a/docs/apm/troubleshooting.asciidoc b/docs/apm/troubleshooting.asciidoc index e00a67f6c78a4..b4c9c6a4ec39e 100644 --- a/docs/apm/troubleshooting.asciidoc +++ b/docs/apm/troubleshooting.asciidoc @@ -49,7 +49,7 @@ GET /_template/apm-{version} *Using Logstash, Kafka, etc.* If you're not outputting data directly from APM Server to Elasticsearch (perhaps you're using Logstash or Kafka), then the index template will not be set up automatically. Instead, you'll need to -{apm-server-ref}/configuration-template.html[load the template manually]. +{apm-server-ref}/apm-server-template.html[load the template manually]. *Using a custom index names* This problem can also occur if you've customized the index name that you write APM data to. diff --git a/docs/settings/reporting-settings.asciidoc b/docs/settings/reporting-settings.asciidoc index 9c8d753a2d668..3489dcd018293 100644 --- a/docs/settings/reporting-settings.asciidoc +++ b/docs/settings/reporting-settings.asciidoc @@ -104,15 +104,14 @@ security is enabled, `xpack.security.encryptionKey`. [cols="2*<"] |=== | `xpack.reporting.queue.pollInterval` - | Specifies the number of milliseconds that the reporting poller waits between polling the - index for any pending Reporting jobs. Defaults to `3000` (3 seconds). + | Specify the {ref}/common-options.html#time-units[time] that the reporting poller waits between polling the index for any + pending Reporting jobs. Can be specified as number of milliseconds. Defaults to `3s`. | [[xpack-reporting-q-timeout]] `xpack.reporting.queue.timeout` {ess-icon} - | How long each worker has to produce a report. If your machine is slow or under - heavy load, you might need to increase this timeout. Specified in milliseconds. - If a Reporting job execution time goes over this time limit, the job will be - marked as a failure and there will not be a download available. - Defaults to `120000` (two minutes). + | {ref}/common-options.html#time-units[How long] each worker has to produce a report. If your machine is slow or under heavy + load, you might need to increase this timeout. If a Reporting job execution goes over this time limit, the job is marked as a + failure and no download will be available. Can be specified as number of milliseconds. + Defaults to `2m`. |=== @@ -127,24 +126,24 @@ control the capturing process. |=== a| `xpack.reporting.capture.timeouts` `.openUrl` {ess-icon} - | Specify how long to allow the Reporting browser to wait for the "Loading..." screen - to dismiss and find the initial data for the Kibana page. If the time is - exceeded, a page screenshot is captured showing the current state, and the download link shows a warning message. - Defaults to `60000` (1 minute). + | Specify the {ref}/common-options.html#time-units[time] to allow the Reporting browser to wait for the "Loading..." screen + to dismiss and find the initial data for the page. If the time is exceeded, a screenshot is captured showing the current + page, and the download link shows a warning message. Can be specified as number of milliseconds. + Defaults to `1m`. a| `xpack.reporting.capture.timeouts` `.waitForElements` {ess-icon} - | Specify how long to allow the Reporting browser to wait for all visualization - panels to load on the Kibana page. If the time is exceeded, a page screenshot - is captured showing the current state, and the download link shows a warning message. Defaults to `30000` (30 - seconds). + | Specify the {ref}/common-options.html#time-units[time] to allow the Reporting browser to wait for all visualization panels + to load on the page. If the time is exceeded, a screenshot is captured showing the current page, and the download link shows + a warning message. Can be specified as number of milliseconds. + Defaults to `30s`. a| `xpack.reporting.capture.timeouts` `.renderComplete` {ess-icon} - | Specify how long to allow the Reporting browser to wait for all visualizations to - fetch and render the data. If the time is exceeded, a - page screenshot is captured showing the current state, and the download link shows a warning message. Defaults to - `30000` (30 seconds). + | Specify the {ref}/common-options.html#time-units[time] to allow the Reporting browser to wait for all visualizations to + fetch and render the data. If the time is exceeded, a screenshot is captured showing the current page, and the download link shows a + warning message. Can be specified as number of milliseconds. + Defaults to `30s`. |=== @@ -163,11 +162,10 @@ available, but there will likely be errors in the visualizations in the report. job, as many times as this setting. Defaults to `3`. | `xpack.reporting.capture.loadDelay` - | When visualizations are not evented, this is the amount of time before - taking a screenshot. All visualizations that ship with {kib} are evented, so this - setting should not have much effect. If you are seeing empty images instead of - visualizations, try increasing this value. - Defaults to `3000` (3 seconds). + | Specify the {ref}/common-options.html#time-units[amount of time] before taking a screenshot when visualizations are not evented. + All visualizations that ship with {kib} are evented, so this setting should not have much effect. If you are seeing empty images + instead of visualizations, try increasing this value. + Defaults to `3s`. | [[xpack-reporting-browser]] `xpack.reporting.capture.browser.type` {ess-icon} | Specifies the browser to use to capture screenshots. This setting exists for @@ -213,9 +211,9 @@ a| `xpack.reporting.capture.browser` [cols="2*<"] |=== | [[xpack-reporting-csv]] `xpack.reporting.csv.maxSizeBytes` {ess-icon} - | The maximum size of a CSV file before being truncated. This setting exists to prevent - large exports from causing performance and storage issues. - Defaults to `10485760` (10mB). + | The maximum {ref}/common-options.html#byte-units[byte size] of a CSV file before being truncated. This setting exists to + prevent large exports from causing performance and storage issues. Can be specified as number of bytes. + Defaults to `10mb`. | `xpack.reporting.csv.scroll.size` | Number of documents retrieved from {es} for each scroll iteration during a CSV @@ -223,7 +221,7 @@ a| `xpack.reporting.capture.browser` Defaults to `500`. | `xpack.reporting.csv.scroll.duration` - | Amount of time allowed before {kib} cleans the scroll context during a CSV export. + | Amount of {ref}/common-options.html#time-units[time] allowed before {kib} cleans the scroll context during a CSV export. Defaults to `30s`. | `xpack.reporting.csv.checkForFormulas` diff --git a/docs/user/alerting/action-types/server-log.asciidoc b/docs/user/alerting/action-types/server-log.asciidoc index eadca229bc19c..7022320328c85 100644 --- a/docs/user/alerting/action-types/server-log.asciidoc +++ b/docs/user/alerting/action-types/server-log.asciidoc @@ -2,7 +2,7 @@ [[server-log-action-type]] === Server log action -This action type writes and entry to the {kib} server log. +This action type writes an entry to the {kib} server log. [float] [[server-log-connector-configuration]] diff --git a/docs/user/dashboard/url-drilldown.asciidoc b/docs/user/dashboard/url-drilldown.asciidoc index 16f82477756b7..4919625340da2 100644 --- a/docs/user/dashboard/url-drilldown.asciidoc +++ b/docs/user/dashboard/url-drilldown.asciidoc @@ -197,6 +197,7 @@ context.panel.timeRange.indexPatternIds | ID of saved object behind a panel. | *Single click* + | event.value | Value behind clicked data point. @@ -208,6 +209,22 @@ context.panel.timeRange.indexPatternIds | event.negate | Boolean, indicating whether clicked data point resulted in negative filter. +| +| event.points +| Some visualizations have clickable points that emit more than one data point. Use list of data points in case a single value is insufficient. + + +Example: + +`{{json event.points}}` + +`{{event.points.[0].key}}` + +`{{event.points.[0].value}}` +`{{#each event.points}}key=value&{{/each}}` + +Note: + +`{{event.value}}` is a shorthand for `{{event.points.[0].value}}` + +`{{event.key}}` is a shorthand for `{{event.points.[0].key}}` + | *Range selection* | event.from + event.to diff --git a/src/dev/build/tasks/os_packages/docker_generator/run.ts b/src/dev/build/tasks/os_packages/docker_generator/run.ts index 362c34d416743..19487efe1366c 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -40,7 +40,7 @@ export async function runDockerGenerator( ubi: boolean = false ) { // UBI var config - const baseOSImage = ubi ? 'registry.access.redhat.com/ubi8/ubi-minimal:latest' : 'centos:8'; + const baseOSImage = ubi ? 'docker.elastic.co/ubi8/ubi-minimal:latest' : 'centos:8'; const ubiVersionTag = 'ubi8'; const ubiImageFlavor = ubi ? `-${ubiVersionTag}` : ''; diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx index f159cac664a9e..8e1151b387fee 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx @@ -546,13 +546,16 @@ export class QueryStringInputUI extends Component { this.updateSuggestions.cancel(); this.componentIsUnmounting = true; window.removeEventListener('resize', this.handleAutoHeight); - window.removeEventListener('scroll', this.handleListUpdate); + window.removeEventListener('scroll', this.handleListUpdate, { capture: true }); } - handleListUpdate = () => - this.setState({ + handleListUpdate = () => { + if (this.componentIsUnmounting) return; + + return this.setState({ queryBarRect: this.queryBarInputDivRefInstance.current?.getBoundingClientRect(), }); + }; handleAutoHeight = () => { if (this.inputRef !== null && document.activeElement === this.inputRef) { 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 index 7a42ed7fad427..b175066b81c8e 100644 --- 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 @@ -17,7 +17,7 @@ * under the License. */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { act } from 'react-dom/test-utils'; import { mount, ReactWrapper } from 'enzyme'; import sinon from 'sinon'; @@ -111,6 +111,13 @@ export const createUseRequestHelpers = (): UseRequestHelpers => { requestConfig ); + // Force a re-render of the component to stress-test the useRequest hook and verify its + // state remains unaffected. + const [, setState] = useState(false); + useEffect(() => { + setState(true); + }, []); + hookResult.isInitialRequest = isInitialRequest; hookResult.isLoading = isLoading; hookResult.error = 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 index e04f84a67b8a3..9d40291423cac 100644 --- a/src/plugins/es_ui_shared/public/request/use_request.ts +++ b/src/plugins/es_ui_shared/public/request/use_request.ts @@ -49,7 +49,7 @@ export const useRequest = ( // Consumers can use isInitialRequest to implement a polling UX. const requestCountRef = useRef(0); - const isInitialRequest = requestCountRef.current === 0; + const isInitialRequestRef = useRef(true); const pollIntervalIdRef = useRef(null); const clearPollInterval = useCallback(() => { @@ -98,6 +98,9 @@ export const useRequest = ( return; } + // Surface to consumers that at least one request has resolved. + isInitialRequestRef.current = false; + 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) { @@ -146,7 +149,7 @@ export const useRequest = ( }, [clearPollInterval]); return { - isInitialRequest, + isInitialRequest: isInitialRequestRef.current, isLoading, error, data, diff --git a/src/plugins/telemetry/server/routes/telemetry_opt_in.ts b/src/plugins/telemetry/server/routes/telemetry_opt_in.ts index aa1de4b2443a4..dd6953ebcda99 100644 --- a/src/plugins/telemetry/server/routes/telemetry_opt_in.ts +++ b/src/plugins/telemetry/server/routes/telemetry_opt_in.ts @@ -26,6 +26,7 @@ import { StatsGetterConfig, TelemetryCollectionManagerPluginSetup, } from 'src/plugins/telemetry_collection_manager/server'; +import { SavedObjectsErrorHelpers } from '../../../../core/server'; import { getTelemetryAllowChangingOptInStatus } from '../../common/telemetry_config'; import { sendTelemetryOptInStatus } from './telemetry_opt_in_stats'; @@ -109,7 +110,13 @@ export function registerTelemetryOptInRoutes({ }); } - await updateTelemetrySavedObject(context.core.savedObjects.client, attributes); + try { + await updateTelemetrySavedObject(context.core.savedObjects.client, attributes); + } catch (e) { + if (SavedObjectsErrorHelpers.isForbiddenError(e)) { + return res.forbidden(); + } + } return res.ok({ body: optInStatus }); } ); diff --git a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts index e63ff28f42d96..f1aff6322522a 100644 --- a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts @@ -27,6 +27,6 @@ export const valueClickTrigger: Trigger<'VALUE_CLICK_TRIGGER'> = { defaultMessage: 'Single click', }), description: i18n.translate('uiActions.triggers.valueClickDescription', { - defaultMessage: 'A single point on the visualization', + defaultMessage: 'A data point click on the visualization', }), }; diff --git a/src/plugins/vis_type_timeseries/server/index.ts b/src/plugins/vis_type_timeseries/server/index.ts index f460257caf5e3..333ed0ff64fdb 100644 --- a/src/plugins/vis_type_timeseries/server/index.ts +++ b/src/plugins/vis_type_timeseries/server/index.ts @@ -21,7 +21,7 @@ import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/serve import { VisTypeTimeseriesConfig, config as configSchema } from './config'; import { VisTypeTimeseriesPlugin } from './plugin'; -export { VisTypeTimeseriesSetup, Framework } from './plugin'; +export { VisTypeTimeseriesSetup } from './plugin'; export const config: PluginConfigDescriptor = { deprecations: ({ unused, renameFromRoot }) => [ @@ -39,10 +39,10 @@ export const config: PluginConfigDescriptor = { export { ValidationTelemetryServiceSetup } from './validation_telemetry'; -// @ts-ignore -export { AbstractSearchStrategy } from './lib/search_strategies/strategies/abstract_search_strategy'; -// @ts-ignore -export { AbstractSearchRequest } from './lib/search_strategies/search_requests/abstract_request'; +export { + AbstractSearchStrategy, + ReqFacade, +} from './lib/search_strategies/strategies/abstract_search_strategy'; // @ts-ignore export { DefaultSearchCapabilities } from './lib/search_strategies/default_search_capabilities'; diff --git a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts index 0f0d99bff6f1c..777de89672bbe 100644 --- a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts +++ b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts @@ -38,6 +38,7 @@ export async function getFields( // level object passed from here. The layers should be refactored fully at some point, but for now // this works and we are still using the New Platform services for these vis data portions. const reqFacade: ReqFacade = { + requestContext, ...request, framework, payload: {}, @@ -48,22 +49,6 @@ export async function getFields( }, getUiSettingsService: () => requestContext.core.uiSettings.client, getSavedObjectsClient: () => requestContext.core.savedObjects.client, - server: { - plugins: { - elasticsearch: { - getCluster: () => { - return { - callWithRequest: async (req: any, endpoint: string, params: any) => { - return await requestContext.core.elasticsearch.legacy.client.callAsCurrentUser( - endpoint, - params - ); - }, - }; - }, - }, - }, - }, getEsShardTimeout: async () => { return await framework.globalConfig$ .pipe( diff --git a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts index f697e754a2e00..5eef2b53e2431 100644 --- a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts @@ -21,7 +21,7 @@ import { FakeRequest, RequestHandlerContext } from 'kibana/server'; import _ from 'lodash'; import { first, map } from 'rxjs/operators'; import { getPanelData } from './vis_data/get_panel_data'; -import { Framework } from '../index'; +import { Framework } from '../plugin'; import { ReqFacade } from './search_strategies/strategies/abstract_search_strategy'; interface GetVisDataResponse { @@ -65,28 +65,13 @@ export function getVisData( // level object passed from here. The layers should be refactored fully at some point, but for now // this works and we are still using the New Platform services for these vis data portions. const reqFacade: ReqFacade = { + requestContext, ...request, framework, pre: {}, payload: request.body, getUiSettingsService: () => requestContext.core.uiSettings.client, getSavedObjectsClient: () => requestContext.core.savedObjects.client, - server: { - plugins: { - elasticsearch: { - getCluster: () => { - return { - callWithRequest: async (req: any, endpoint: string, params: any) => { - return await requestContext.core.elasticsearch.legacy.client.callAsCurrentUser( - endpoint, - params - ); - }, - }; - }, - }, - }, - }, getEsShardTimeout: async () => { return await framework.globalConfig$ .pipe( diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.js deleted file mode 100644 index abd2a4c65d35c..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.js +++ /dev/null @@ -1,28 +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. - */ -export class AbstractSearchRequest { - constructor(req, callWithRequest) { - this.req = req; - this.callWithRequest = callWithRequest; - } - - search() { - throw new Error('AbstractSearchRequest: search method should be defined'); - } -} diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.test.js deleted file mode 100644 index 6f71aa63728d5..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.test.js +++ /dev/null @@ -1,46 +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 { AbstractSearchRequest } from './abstract_request'; - -describe('AbstractSearchRequest', () => { - let searchRequest; - let req; - let callWithRequest; - - beforeEach(() => { - req = {}; - callWithRequest = jest.fn(); - searchRequest = new AbstractSearchRequest(req, callWithRequest); - }); - - test('should init an AbstractSearchRequest instance', () => { - expect(searchRequest.req).toBe(req); - expect(searchRequest.callWithRequest).toBe(callWithRequest); - expect(searchRequest.search).toBeDefined(); - }); - - test('should throw an error trying to search', () => { - try { - searchRequest.search(); - } catch (error) { - expect(error instanceof Error).toBe(true); - expect(error.message).toEqual('AbstractSearchRequest: search method should be defined'); - } - }); -}); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.js deleted file mode 100644 index 9ada39e359589..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.js +++ /dev/null @@ -1,49 +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 { AbstractSearchRequest } from './abstract_request'; -import { UI_SETTINGS } from '../../../../../data/server'; - -const SEARCH_METHOD = 'msearch'; - -export class MultiSearchRequest extends AbstractSearchRequest { - async search(searches) { - const includeFrozen = await this.req - .getUiSettingsService() - .get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); - const multiSearchBody = searches.reduce( - (acc, { body, index }) => [ - ...acc, - { - index, - ignoreUnavailable: true, - }, - body, - ], - [] - ); - - const { responses } = await this.callWithRequest(this.req, SEARCH_METHOD, { - body: multiSearchBody, - rest_total_hits_as_int: true, - ignore_throttled: !includeFrozen, - }); - - return responses; - } -} diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.test.js deleted file mode 100644 index c113db76332b7..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.test.js +++ /dev/null @@ -1,67 +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 { MultiSearchRequest } from './multi_search_request'; -import { UI_SETTINGS } from '../../../../../data/server'; - -describe('MultiSearchRequest', () => { - let searchRequest; - let req; - let callWithRequest; - let getServiceMock; - let includeFrozen; - - beforeEach(() => { - includeFrozen = false; - getServiceMock = jest.fn().mockResolvedValue(includeFrozen); - req = { - getUiSettingsService: jest.fn().mockReturnValue({ get: getServiceMock }), - }; - callWithRequest = jest.fn().mockReturnValue({ responses: [] }); - searchRequest = new MultiSearchRequest(req, callWithRequest); - }); - - test('should init an MultiSearchRequest instance', () => { - expect(searchRequest.req).toBe(req); - expect(searchRequest.callWithRequest).toBe(callWithRequest); - expect(searchRequest.search).toBeDefined(); - }); - - test('should get the response from elastic msearch', async () => { - const searches = [ - { body: 'body1', index: 'index' }, - { body: 'body2', index: 'index' }, - ]; - - const responses = await searchRequest.search(searches); - - expect(responses).toEqual([]); - expect(req.getUiSettingsService).toHaveBeenCalled(); - expect(getServiceMock).toHaveBeenCalledWith(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); - expect(callWithRequest).toHaveBeenCalledWith(req, 'msearch', { - body: [ - { ignoreUnavailable: true, index: 'index' }, - 'body1', - { ignoreUnavailable: true, index: 'index' }, - 'body2', - ], - rest_total_hits_as_int: true, - ignore_throttled: !includeFrozen, - }); - }); -}); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.js deleted file mode 100644 index e6e3bcb527286..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.js +++ /dev/null @@ -1,37 +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 { AbstractSearchRequest } from './abstract_request'; - -import { MultiSearchRequest } from './multi_search_request'; -import { SingleSearchRequest } from './single_search_request'; - -export class SearchRequest extends AbstractSearchRequest { - getSearchRequestType(searches) { - const isMultiSearch = Array.isArray(searches) && searches.length > 1; - const SearchRequest = isMultiSearch ? MultiSearchRequest : SingleSearchRequest; - - return new SearchRequest(this.req, this.callWithRequest); - } - - async search(options) { - const concreteSearchRequest = this.getSearchRequestType(options); - - return concreteSearchRequest.search(options); - } -} diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.test.js deleted file mode 100644 index 3d35a4aa37c5a..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.test.js +++ /dev/null @@ -1,76 +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 { SearchRequest } from './search_request'; -import { MultiSearchRequest } from './multi_search_request'; -import { SingleSearchRequest } from './single_search_request'; - -describe('SearchRequest', () => { - let searchRequest; - let req; - let callWithRequest; - let getServiceMock; - let includeFrozen; - - beforeEach(() => { - includeFrozen = false; - getServiceMock = jest.fn().mockResolvedValue(includeFrozen); - req = { - getUiSettingsService: jest.fn().mockReturnValue({ get: getServiceMock }), - }; - callWithRequest = jest.fn().mockReturnValue({ responses: [] }); - searchRequest = new SearchRequest(req, callWithRequest); - }); - - test('should init an AbstractSearchRequest instance', () => { - expect(searchRequest.req).toBe(req); - expect(searchRequest.callWithRequest).toBe(callWithRequest); - expect(searchRequest.search).toBeDefined(); - }); - - test('should return search value', async () => { - const concreteSearchRequest = { - search: jest.fn().mockReturnValue('concreteSearchRequest'), - }; - const options = {}; - searchRequest.getSearchRequestType = jest.fn().mockReturnValue(concreteSearchRequest); - - const result = await searchRequest.search(options); - - expect(result).toBe('concreteSearchRequest'); - }); - - test('should return a MultiSearchRequest for multi searches', () => { - const searches = [ - { index: 'index', body: 'body' }, - { index: 'index', body: 'body' }, - ]; - - const result = searchRequest.getSearchRequestType(searches); - - expect(result instanceof MultiSearchRequest).toBe(true); - }); - - test('should return a SingleSearchRequest for single search', () => { - const searches = [{ index: 'index', body: 'body' }]; - - const result = searchRequest.getSearchRequestType(searches); - - expect(result instanceof SingleSearchRequest).toBe(true); - }); -}); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.js deleted file mode 100644 index 7d8b60a7e4595..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.js +++ /dev/null @@ -1,37 +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 { AbstractSearchRequest } from './abstract_request'; -import { UI_SETTINGS } from '../../../../../data/server'; - -const SEARCH_METHOD = 'search'; - -export class SingleSearchRequest extends AbstractSearchRequest { - async search([{ body, index }]) { - const includeFrozen = await this.req - .getUiSettingsService() - .get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); - const resp = await this.callWithRequest(this.req, SEARCH_METHOD, { - ignore_throttled: !includeFrozen, - body, - index, - }); - - return [resp]; - } -} diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.test.js deleted file mode 100644 index b899814f2fe13..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.test.js +++ /dev/null @@ -1,59 +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 { SingleSearchRequest } from './single_search_request'; -import { UI_SETTINGS } from '../../../../../data/server'; - -describe('SingleSearchRequest', () => { - let searchRequest; - let req; - let callWithRequest; - let getServiceMock; - let includeFrozen; - - beforeEach(() => { - includeFrozen = false; - getServiceMock = jest.fn().mockResolvedValue(includeFrozen); - req = { - getUiSettingsService: jest.fn().mockReturnValue({ get: getServiceMock }), - }; - callWithRequest = jest.fn().mockReturnValue({}); - searchRequest = new SingleSearchRequest(req, callWithRequest); - }); - - test('should init an SingleSearchRequest instance', () => { - expect(searchRequest.req).toBe(req); - expect(searchRequest.callWithRequest).toBe(callWithRequest); - expect(searchRequest.search).toBeDefined(); - }); - - test('should get the response from elastic search', async () => { - const searches = [{ body: 'body', index: 'index' }]; - - const responses = await searchRequest.search(searches); - - expect(responses).toEqual([{}]); - expect(req.getUiSettingsService).toHaveBeenCalled(); - expect(getServiceMock).toHaveBeenCalledWith(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); - expect(callWithRequest).toHaveBeenCalledWith(req, 'search', { - body: 'body', - index: 'index', - ignore_throttled: !includeFrozen, - }); - }); -}); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts index ecd09653b3b48..66ea4f017dd90 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts @@ -65,7 +65,7 @@ describe('SearchStrategyRegister', () => { }); test('should add a strategy if it is an instance of AbstractSearchStrategy', () => { - const anotherSearchStrategy = new MockSearchStrategy({}, {} as any, {}); + const anotherSearchStrategy = new MockSearchStrategy('es'); const addedStrategies = registry.addStrategy(anotherSearchStrategy); expect(addedStrategies.length).toEqual(2); @@ -75,7 +75,7 @@ describe('SearchStrategyRegister', () => { test('should return a MockSearchStrategy instance', async () => { const req = {}; const indexPattern = '*'; - const anotherSearchStrategy = new MockSearchStrategy({}, {} as any, {}); + const anotherSearchStrategy = new MockSearchStrategy('es'); registry.addStrategy(anotherSearchStrategy); const { searchStrategy, capabilities } = (await registry.getViableStrategy(req, indexPattern))!; diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js index 1fbaffd794c89..6773ee482b098 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js @@ -18,24 +18,13 @@ */ import { AbstractSearchStrategy } from './abstract_search_strategy'; -class SearchRequest { - constructor(req, callWithRequest) { - this.req = req; - this.callWithRequest = callWithRequest; - } -} - describe('AbstractSearchStrategy', () => { let abstractSearchStrategy; - let server; - let callWithRequestFactory; let req; let mockedFields; let indexPattern; beforeEach(() => { - server = {}; - callWithRequestFactory = jest.fn().mockReturnValue('callWithRequest'); mockedFields = {}; req = { pre: { @@ -45,16 +34,11 @@ describe('AbstractSearchStrategy', () => { }, }; - abstractSearchStrategy = new AbstractSearchStrategy( - server, - callWithRequestFactory, - SearchRequest - ); + abstractSearchStrategy = new AbstractSearchStrategy('es'); }); test('should init an AbstractSearchStrategy instance', () => { - expect(abstractSearchStrategy.getCallWithRequestInstance).toBeDefined(); - expect(abstractSearchStrategy.getSearchRequest).toBeDefined(); + expect(abstractSearchStrategy.search).toBeDefined(); expect(abstractSearchStrategy.getFieldsForWildcard).toBeDefined(); expect(abstractSearchStrategy.checkForViability).toBeDefined(); }); @@ -68,17 +52,46 @@ describe('AbstractSearchStrategy', () => { }); }); - test('should invoke callWithRequestFactory with req param passed', () => { - abstractSearchStrategy.getCallWithRequestInstance(req); + test('should return response', async () => { + const searches = [{ body: 'body', index: 'index' }]; + const searchFn = jest.fn().mockReturnValue(Promise.resolve({})); - expect(callWithRequestFactory).toHaveBeenCalledWith(server, req); - }); - - test('should return a search request', () => { - const searchRequest = abstractSearchStrategy.getSearchRequest(req); + const responses = await abstractSearchStrategy.search( + { + requestContext: {}, + framework: { + core: { + getStartServices: jest.fn().mockReturnValue( + Promise.resolve([ + {}, + { + data: { + search: { + search: searchFn, + }, + }, + }, + ]) + ), + }, + }, + }, + searches + ); - expect(searchRequest instanceof SearchRequest).toBe(true); - expect(searchRequest.callWithRequest).toBe('callWithRequest'); - expect(searchRequest.req).toBe(req); + expect(responses).toEqual([{}]); + expect(searchFn).toHaveBeenCalledWith( + {}, + { + params: { + body: 'body', + index: 'index', + }, + indexType: undefined, + }, + { + strategy: 'es', + } + ); }); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts index 0b1c6e6e20414..92b7e6976962e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts @@ -18,7 +18,7 @@ */ import { - LegacyAPICaller, + RequestHandlerContext, FakeRequest, IUiSettingsClient, SavedObjectsClientContract, @@ -33,6 +33,7 @@ import { IndexPatternsFetcher } from '../../../../../data/server'; * This will be replaced by standard KibanaRequest and RequestContext objects in a later version. */ export type ReqFacade = FakeRequest & { + requestContext: RequestHandlerContext; framework: Framework; payload: unknown; pre: { @@ -40,34 +41,42 @@ export type ReqFacade = FakeRequest & { }; getUiSettingsService: () => IUiSettingsClient; getSavedObjectsClient: () => SavedObjectsClientContract; - server: { - plugins: { - elasticsearch: { - getCluster: () => { - callWithRequest: (req: ReqFacade, endpoint: string, params: any) => Promise; - }; - }; - }; - }; getEsShardTimeout: () => Promise; }; export class AbstractSearchStrategy { - public getCallWithRequestInstance: (req: ReqFacade) => LegacyAPICaller; - public getSearchRequest: (req: ReqFacade) => any; - - constructor( - server: any, - callWithRequestFactory: (server: any, req: ReqFacade) => LegacyAPICaller, - SearchRequest: any - ) { - this.getCallWithRequestInstance = (req) => callWithRequestFactory(server, req); + public searchStrategyName!: string; + public indexType?: string; + public additionalParams: any; - this.getSearchRequest = (req) => { - const callWithRequest = this.getCallWithRequestInstance(req); + constructor(name: string, type?: string, additionalParams: any = {}) { + this.searchStrategyName = name; + this.indexType = type; + this.additionalParams = additionalParams; + } - return new SearchRequest(req, callWithRequest); - }; + async search(req: ReqFacade, bodies: any[], options = {}) { + const [, deps] = await req.framework.core.getStartServices(); + const requests: any[] = []; + bodies.forEach((body) => { + requests.push( + deps.data.search.search( + req.requestContext, + { + params: { + ...body, + ...this.additionalParams, + }, + indexType: this.indexType, + }, + { + ...options, + strategy: this.searchStrategyName, + } + ) + ); + }); + return Promise.all(requests); } async getFieldsForWildcard(req: ReqFacade, indexPattern: string, capabilities: any) { diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js index 63f2911ce1118..7c3609ae3c405 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js @@ -16,21 +16,16 @@ * specific language governing permissions and limitations * under the License. */ + +import { ES_SEARCH_STRATEGY } from '../../../../../data/server'; import { AbstractSearchStrategy } from './abstract_search_strategy'; -import { SearchRequest } from '../search_requests/search_request'; import { DefaultSearchCapabilities } from '../default_search_capabilities'; -const callWithRequestFactory = (server, request) => { - const { callWithRequest } = request.server.plugins.elasticsearch.getCluster('data'); - - return callWithRequest; -}; - export class DefaultSearchStrategy extends AbstractSearchStrategy { name = 'default'; - constructor(server) { - super(server, callWithRequestFactory, SearchRequest); + constructor() { + super(ES_SEARCH_STRATEGY); } checkForViability(req) { diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js index 2e3a459bf06fd..a9994ba3e1f75 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js @@ -20,42 +20,20 @@ import { DefaultSearchStrategy } from './default_search_strategy'; describe('DefaultSearchStrategy', () => { let defaultSearchStrategy; - let server; - let callWithRequest; let req; beforeEach(() => { - server = {}; - callWithRequest = jest.fn(); - req = { - server: { - plugins: { - elasticsearch: { - getCluster: jest.fn().mockReturnValue({ - callWithRequest, - }), - }, - }, - }, - }; - defaultSearchStrategy = new DefaultSearchStrategy(server); + req = {}; + defaultSearchStrategy = new DefaultSearchStrategy(); }); test('should init an DefaultSearchStrategy instance', () => { expect(defaultSearchStrategy.name).toBe('default'); expect(defaultSearchStrategy.checkForViability).toBeDefined(); - expect(defaultSearchStrategy.getCallWithRequestInstance).toBeDefined(); - expect(defaultSearchStrategy.getSearchRequest).toBeDefined(); + expect(defaultSearchStrategy.search).toBeDefined(); expect(defaultSearchStrategy.getFieldsForWildcard).toBeDefined(); }); - test('should invoke callWithRequestFactory with passed params', () => { - const value = defaultSearchStrategy.getCallWithRequestInstance(req); - - expect(value).toBe(callWithRequest); - expect(req.server.plugins.elasticsearch.getCluster).toHaveBeenCalledWith('data'); - }); - test('should check a strategy for viability', () => { const value = defaultSearchStrategy.checkForViability(req); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js index b015aaf0ef8db..d8a230dfeef4e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js @@ -39,7 +39,6 @@ export async function getAnnotations({ capabilities, series, }) { - const searchRequest = searchStrategy.getSearchRequest(req); const annotations = panel.annotations.filter(validAnnotation); const lastSeriesTimestamp = getLastSeriesTimestamp(series); const handleAnnotationResponseBy = handleAnnotationResponse(lastSeriesTimestamp); @@ -47,6 +46,7 @@ export async function getAnnotations({ const bodiesPromises = annotations.map((annotation) => getAnnotationRequestParams(req, panel, annotation, esQueryConfig, capabilities) ); + const searches = (await Promise.all(bodiesPromises)).reduce( (acc, items) => acc.concat(items), [] @@ -55,10 +55,10 @@ export async function getAnnotations({ if (!searches.length) return { responses: [] }; try { - const data = await searchRequest.search(searches); + const data = await searchStrategy.search(req.framework.core, req.requestContext, searches); return annotations.reduce((acc, annotation, index) => { - acc[annotation.id] = handleAnnotationResponseBy(data[index], annotation); + acc[annotation.id] = handleAnnotationResponseBy(data[index].rawResponse, annotation); return acc; }, {}); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js index ee48816c6a8af..1eace13c2e336 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js @@ -28,7 +28,6 @@ export async function getSeriesData(req, panel) { searchStrategy, capabilities, } = await req.framework.searchStrategyRegistry.getViableStrategyForPanel(req, panel); - const searchRequest = searchStrategy.getSearchRequest(req); const esQueryConfig = await getEsQueryConfig(req); const meta = { type: panel.type, @@ -45,8 +44,13 @@ export async function getSeriesData(req, panel) { [] ); - const data = await searchRequest.search(searches); - const series = data.map(handleResponseBody(panel)); + const data = await searchStrategy.search(req, searches); + + const handleResponseBodyFn = handleResponseBody(panel); + + const series = data.map((resp) => + handleResponseBodyFn(resp.rawResponse ? resp.rawResponse : resp) + ); let annotations = null; if (panel.annotations && panel.annotations.length) { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js index 1d1c245907959..3791eb229db5b 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js @@ -30,7 +30,6 @@ export async function getTableData(req, panel) { searchStrategy, capabilities, } = await req.framework.searchStrategyRegistry.getViableStrategy(req, panelIndexPattern); - const searchRequest = searchStrategy.getSearchRequest(req); const esQueryConfig = await getEsQueryConfig(req); const { indexPatternObject } = await getIndexPatternObject(req, panelIndexPattern); @@ -41,13 +40,18 @@ export async function getTableData(req, panel) { try { const body = buildRequestBody(req, panel, esQueryConfig, indexPatternObject, capabilities); - const [resp] = await searchRequest.search([ + const [resp] = await searchStrategy.search(req, [ { body, index: panelIndexPattern, }, ]); - const buckets = get(resp, 'aggregations.pivot.buckets', []); + + const buckets = get( + resp.rawResponse ? resp.rawResponse : resp, + 'aggregations.pivot.buckets', + [] + ); return { ...meta, diff --git a/src/plugins/vis_type_timeseries/server/plugin.ts b/src/plugins/vis_type_timeseries/server/plugin.ts index d863937a4e3dc..678ba2b371978 100644 --- a/src/plugins/vis_type_timeseries/server/plugin.ts +++ b/src/plugins/vis_type_timeseries/server/plugin.ts @@ -33,6 +33,7 @@ import { VisTypeTimeseriesConfig } from './config'; import { getVisData, GetVisData, GetVisDataOptions } from './lib/get_vis_data'; import { ValidationTelemetryService } from './validation_telemetry'; import { UsageCollectionSetup } from '../../usage_collection/server'; +import { PluginStart } from '../../data/server'; import { visDataRoutes } from './routes/vis'; // @ts-ignore import { fieldsRoutes } from './routes/fields'; @@ -47,6 +48,10 @@ interface VisTypeTimeseriesPluginSetupDependencies { usageCollection?: UsageCollectionSetup; } +interface VisTypeTimeseriesPluginStartDependencies { + data: PluginStart; +} + export interface VisTypeTimeseriesSetup { getVisData: ( requestContext: RequestHandlerContext, @@ -57,7 +62,7 @@ export interface VisTypeTimeseriesSetup { } export interface Framework { - core: CoreSetup; + core: CoreSetup; plugins: any; config$: Observable; globalConfig$: PluginInitializerContext['config']['legacy']['globalConfig$']; @@ -74,7 +79,10 @@ export class VisTypeTimeseriesPlugin implements Plugin { this.validationTelementryService = new ValidationTelemetryService(); } - public setup(core: CoreSetup, plugins: VisTypeTimeseriesPluginSetupDependencies) { + public setup( + core: CoreSetup, + plugins: VisTypeTimeseriesPluginSetupDependencies + ) { const logger = this.initializerContext.logger.get('visTypeTimeseries'); core.uiSettings.register(uiSettings); const config$ = this.initializerContext.config.create(); diff --git a/src/plugins/vis_type_timeseries/server/routes/vis.ts b/src/plugins/vis_type_timeseries/server/routes/vis.ts index 48efd4398e4d4..1ca8b57ab230f 100644 --- a/src/plugins/vis_type_timeseries/server/routes/vis.ts +++ b/src/plugins/vis_type_timeseries/server/routes/vis.ts @@ -21,7 +21,8 @@ import { IRouter, KibanaRequest } from 'kibana/server'; import { schema } from '@kbn/config-schema'; import { getVisData, GetVisDataOptions } from '../lib/get_vis_data'; import { visPayloadSchema } from '../../common/vis_schema'; -import { Framework, ValidationTelemetryServiceSetup } from '../index'; +import { ValidationTelemetryServiceSetup } from '../index'; +import { Framework } from '../plugin'; const escapeHatch = schema.object({}, { unknowns: 'allow' }); diff --git a/test/scripts/jenkins_visual_regression.sh b/test/scripts/jenkins_baseline.sh similarity index 63% rename from test/scripts/jenkins_visual_regression.sh rename to test/scripts/jenkins_baseline.sh index 17345d4301882..e679ac7f31bd1 100755 --- a/test/scripts/jenkins_visual_regression.sh +++ b/test/scripts/jenkins_baseline.sh @@ -9,10 +9,3 @@ linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" installDir="$PARENT_DIR/install/kibana" mkdir -p "$installDir" tar -xzf "$linuxBuild" -C "$installDir" --strip=1 - -echo " -> running visual regression tests from kibana directory" -yarn percy exec -t 10000 -- -- \ - node scripts/functional_tests \ - --debug --bail \ - --kibana-install-dir "$installDir" \ - --config test/visual_regression/config.ts; diff --git a/test/scripts/jenkins_xpack_visual_regression.sh b/test/scripts/jenkins_xpack_baseline.sh similarity index 64% rename from test/scripts/jenkins_xpack_visual_regression.sh rename to test/scripts/jenkins_xpack_baseline.sh index 55d4a524820c5..7577b6927d166 100755 --- a/test/scripts/jenkins_xpack_visual_regression.sh +++ b/test/scripts/jenkins_xpack_baseline.sh @@ -14,16 +14,5 @@ tar -xzf "$linuxBuild" -C "$installDir" --strip=1 mkdir -p "$WORKSPACE/kibana-build-xpack" cp -pR install/kibana/. $WORKSPACE/kibana-build-xpack/ -# cd "$KIBANA_DIR" -# source "test/scripts/jenkins_xpack_page_load_metrics.sh" - cd "$KIBANA_DIR" source "test/scripts/jenkins_xpack_saved_objects_field_metrics.sh" - -echo " -> running visual regression tests from x-pack directory" -cd "$XPACK_DIR" -yarn percy exec -t 10000 -- -- \ - node scripts/functional_tests \ - --debug --bail \ - --kibana-install-dir "$installDir" \ - --config test/visual_regression/config.ts; diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index e5b39584a519b..28eb94405abbb 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -1,4 +1,4 @@ -def withPostBuildReporting(Closure closure) { +def withPostBuildReporting(Map params, Closure closure) { try { closure() } finally { @@ -9,8 +9,10 @@ def withPostBuildReporting(Closure closure) { print ex } - catchErrors { - runErrorReporter([pwd()] + parallelWorkspaces) + if (params.runErrorReporter) { + catchErrors { + runErrorReporter([pwd()] + parallelWorkspaces) + } } catchErrors { diff --git a/vars/workers.groovy b/vars/workers.groovy index e582e996a78b5..b6ff5b27667dd 100644 --- a/vars/workers.groovy +++ b/vars/workers.groovy @@ -118,11 +118,11 @@ def base(Map params, Closure closure) { // Worker for ci processes. Extends the base worker and adds GCS artifact upload, error reporting, junit processing def ci(Map params, Closure closure) { - def config = [ramDisk: true, bootstrapped: true] + params + def config = [ramDisk: true, bootstrapped: true, runErrorReporter: true] + params return base(config) { kibanaPipeline.withGcsArtifactUpload(config.name) { - kibanaPipeline.withPostBuildReporting { + kibanaPipeline.withPostBuildReporting(config) { closure() } } diff --git a/x-pack/index.js b/x-pack/index.js index 074b8e6859dc2..745b4bd72dde8 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -5,9 +5,8 @@ */ import { xpackMain } from './legacy/plugins/xpack_main'; -import { security } from './legacy/plugins/security'; import { spaces } from './legacy/plugins/spaces'; module.exports = function (kibana) { - return [xpackMain(kibana), spaces(kibana), security(kibana)]; + return [xpackMain(kibana), spaces(kibana)]; }; diff --git a/x-pack/legacy/plugins/security/index.ts b/x-pack/legacy/plugins/security/index.ts deleted file mode 100644 index 24e63f089e702..0000000000000 --- a/x-pack/legacy/plugins/security/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Root } from 'joi'; -import { resolve } from 'path'; - -export const security = (kibana: Record) => - new kibana.Plugin({ - id: 'security', - publicDir: resolve(__dirname, 'public'), - require: [], - configPrefix: 'xpack.security', - config: (Joi: Root) => - Joi.object({ enabled: Joi.boolean().default(true) }) - .unknown() - .default(), - init() {}, - }); diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 3524d41646d50..8c233d3691c7f 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -52,6 +52,10 @@ exports[`Error LABEL_NAME 1`] = `undefined`; exports[`Error LCP_FIELD 1`] = `undefined`; +exports[`Error METRIC_CGROUP_MEMORY_LIMIT_BYTES 1`] = `undefined`; + +exports[`Error METRIC_CGROUP_MEMORY_USAGE_BYTES 1`] = `undefined`; + exports[`Error METRIC_JAVA_GC_COUNT 1`] = `undefined`; exports[`Error METRIC_JAVA_GC_TIME 1`] = `undefined`; @@ -220,6 +224,10 @@ exports[`Span LABEL_NAME 1`] = `undefined`; exports[`Span LCP_FIELD 1`] = `undefined`; +exports[`Span METRIC_CGROUP_MEMORY_LIMIT_BYTES 1`] = `undefined`; + +exports[`Span METRIC_CGROUP_MEMORY_USAGE_BYTES 1`] = `undefined`; + exports[`Span METRIC_JAVA_GC_COUNT 1`] = `undefined`; exports[`Span METRIC_JAVA_GC_TIME 1`] = `undefined`; @@ -388,6 +396,10 @@ exports[`Transaction LABEL_NAME 1`] = `undefined`; exports[`Transaction LCP_FIELD 1`] = `undefined`; +exports[`Transaction METRIC_CGROUP_MEMORY_LIMIT_BYTES 1`] = `undefined`; + +exports[`Transaction METRIC_CGROUP_MEMORY_USAGE_BYTES 1`] = `undefined`; + exports[`Transaction METRIC_JAVA_GC_COUNT 1`] = `undefined`; exports[`Transaction METRIC_JAVA_GC_TIME 1`] = `undefined`; diff --git a/x-pack/plugins/apm/common/alert_types.ts b/x-pack/plugins/apm/common/alert_types.ts index a1161354e04f4..15a3c642faf32 100644 --- a/x-pack/plugins/apm/common/alert_types.ts +++ b/x-pack/plugins/apm/common/alert_types.ts @@ -7,42 +7,33 @@ import { i18n } from '@kbn/i18n'; export enum AlertType { - ErrorRate = 'apm.error_rate', + ErrorCount = 'apm.error_rate', // ErrorRate was renamed to ErrorCount but the key is kept as `error_rate` for backwards-compat. + TransactionErrorRate = 'apm.transaction_error_rate', TransactionDuration = 'apm.transaction_duration', TransactionDurationAnomaly = 'apm.transaction_duration_anomaly', } +const THRESHOLD_MET_GROUP = { + id: 'threshold_met', + name: i18n.translate('xpack.apm.a.thresholdMet', { + defaultMessage: 'Threshold met', + }), +}; + export const ALERT_TYPES_CONFIG = { - [AlertType.ErrorRate]: { - name: i18n.translate('xpack.apm.errorRateAlert.name', { - defaultMessage: 'Error rate', + [AlertType.ErrorCount]: { + name: i18n.translate('xpack.apm.errorCountAlert.name', { + defaultMessage: 'Error count threshold', }), - actionGroups: [ - { - id: 'threshold_met', - name: i18n.translate('xpack.apm.errorRateAlert.thresholdMet', { - defaultMessage: 'Threshold met', - }), - }, - ], + actionGroups: [THRESHOLD_MET_GROUP], defaultActionGroupId: 'threshold_met', producer: 'apm', }, [AlertType.TransactionDuration]: { name: i18n.translate('xpack.apm.transactionDurationAlert.name', { - defaultMessage: 'Transaction duration', + defaultMessage: 'Transaction duration threshold', }), - actionGroups: [ - { - id: 'threshold_met', - name: i18n.translate( - 'xpack.apm.transactionDurationAlert.thresholdMet', - { - defaultMessage: 'Threshold met', - } - ), - }, - ], + actionGroups: [THRESHOLD_MET_GROUP], defaultActionGroupId: 'threshold_met', producer: 'apm', }, @@ -50,39 +41,24 @@ export const ALERT_TYPES_CONFIG = { name: i18n.translate('xpack.apm.transactionDurationAnomalyAlert.name', { defaultMessage: 'Transaction duration anomaly', }), - actionGroups: [ - { - id: 'threshold_met', - name: i18n.translate( - 'xpack.apm.transactionDurationAlert.thresholdMet', - { - defaultMessage: 'Threshold met', - } - ), - }, - ], + actionGroups: [THRESHOLD_MET_GROUP], + defaultActionGroupId: 'threshold_met', + producer: 'apm', + }, + [AlertType.TransactionErrorRate]: { + name: i18n.translate('xpack.apm.transactionErrorRateAlert.name', { + defaultMessage: 'Transaction error rate threshold', + }), + actionGroups: [THRESHOLD_MET_GROUP], defaultActionGroupId: 'threshold_met', producer: 'apm', }, }; -export const TRANSACTION_ALERT_AGGREGATION_TYPES = { - avg: i18n.translate( - 'xpack.apm.transactionDurationAlert.aggregationType.avg', - { - defaultMessage: 'Average', - } - ), - '95th': i18n.translate( - 'xpack.apm.transactionDurationAlert.aggregationType.95th', - { - defaultMessage: '95th percentile', - } - ), - '99th': i18n.translate( - 'xpack.apm.transactionDurationAlert.aggregationType.99th', - { - defaultMessage: '99th percentile', - } - ), -}; +// Server side registrations +// x-pack/plugins/apm/server/lib/alerts/.ts +// x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts + +// Client side registrations: +// x-pack/plugins/apm/public/components/alerting//index.tsx +// x-pack/plugins/apm/public/components/alerting/register_apm_alerts diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index 612cb18bbe190..cc6a1fffb2288 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -79,6 +79,10 @@ export const METRIC_SYSTEM_FREE_MEMORY = 'system.memory.actual.free'; export const METRIC_SYSTEM_TOTAL_MEMORY = 'system.memory.total'; export const METRIC_SYSTEM_CPU_PERCENT = 'system.cpu.total.norm.pct'; export const METRIC_PROCESS_CPU_PERCENT = 'system.process.cpu.total.norm.pct'; +export const METRIC_CGROUP_MEMORY_LIMIT_BYTES = + 'system.process.cgroup.memory.mem.limit.bytes'; +export const METRIC_CGROUP_MEMORY_USAGE_BYTES = + 'system.process.cgroup.memory.mem.usage.bytes'; export const METRIC_JAVA_HEAP_MEMORY_MAX = 'jvm.memory.heap.max'; export const METRIC_JAVA_HEAP_MEMORY_COMMITTED = 'jvm.memory.heap.committed'; diff --git a/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx b/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.stories.tsx similarity index 83% rename from x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx rename to x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.stories.tsx index 632d53a9c63b6..c30cef7210a43 100644 --- a/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.stories.tsx @@ -6,14 +6,14 @@ import { storiesOf } from '@storybook/react'; import React from 'react'; -import { ErrorRateAlertTrigger } from '.'; +import { ErrorCountAlertTrigger } from '.'; import { ApmPluginContextValue } from '../../../context/ApmPluginContext'; import { mockApmPluginContextValue, MockApmPluginContextWrapper, } from '../../../context/ApmPluginContext/MockApmPluginContext'; -storiesOf('app/ErrorRateAlertTrigger', module).add( +storiesOf('app/ErrorCountAlertTrigger', module).add( 'example', () => { const params = { @@ -26,7 +26,7 @@ storiesOf('app/ErrorRateAlertTrigger', module).add( value={(mockApmPluginContextValue as unknown) as ApmPluginContextValue} >
- undefined} setAlertProperty={() => undefined} @@ -37,7 +37,7 @@ storiesOf('app/ErrorRateAlertTrigger', module).add( }, { info: { - propTablesExclude: [ErrorRateAlertTrigger, MockApmPluginContextWrapper], + propTablesExclude: [ErrorCountAlertTrigger, MockApmPluginContextWrapper], source: false, }, } diff --git a/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.tsx similarity index 54% rename from x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx rename to x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.tsx index 7b284696477f3..a465b90e7bf05 100644 --- a/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.tsx @@ -3,36 +3,33 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiFieldNumber, EuiSelect } from '@elastic/eui'; + import { i18n } from '@kbn/i18n'; -import { isFinite } from 'lodash'; import React from 'react'; import { useParams } from 'react-router-dom'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; -import { ALERT_TYPES_CONFIG } from '../../../../common/alert_types'; -import { - ENVIRONMENT_ALL, - getEnvironmentLabel, -} from '../../../../common/environment_filter_values'; +import { ALERT_TYPES_CONFIG, AlertType } from '../../../../common/alert_types'; +import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { useEnvironments } from '../../../hooks/useEnvironments'; import { useUrlParams } from '../../../hooks/useUrlParams'; +import { EnvironmentField, ServiceField, IsAboveField } from '../fields'; import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; -import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; -export interface ErrorRateAlertTriggerParams { +export interface AlertParams { windowSize: number; windowUnit: string; threshold: number; + serviceName: string; environment: string; } interface Props { - alertParams: ErrorRateAlertTriggerParams; + alertParams: AlertParams; setAlertParams: (key: string, value: any) => void; setAlertProperty: (key: string, value: any) => void; } -export function ErrorRateAlertTrigger(props: Props) { +export function ErrorCountAlertTrigger(props: Props) { const { setAlertParams, setAlertProperty, alertParams } = props; const { serviceName } = useParams<{ serviceName?: string }>(); const { urlParams } = useUrlParams(); @@ -51,45 +48,20 @@ export function ErrorRateAlertTrigger(props: Props) { ...alertParams, }; - const threshold = isFinite(params.threshold) ? params.threshold : ''; - const fields = [ - - - setAlertParams( - 'environment', - e.target.value as ErrorRateAlertTriggerParams['environment'] - ) - } - compressed - /> - , - , + setAlertParams('environment', e.target.value)} + />, + - - setAlertParams('threshold', parseInt(e.target.value, 10)) - } - compressed - append={i18n.translate('xpack.apm.errorRateAlertTrigger.errors', { - defaultMessage: 'errors', - })} - /> - , + onChange={(value) => setAlertParams('threshold', value)} + />, setAlertParams('windowSize', windowSize || '') @@ -108,7 +80,7 @@ export function ErrorRateAlertTrigger(props: Props) { return ( void; setAlertProperty: (key: string, value: any) => void; } export function TransactionDurationAlertTrigger(props: Props) { const { setAlertParams, alertParams, setAlertProperty } = props; - const { serviceName } = alertParams; const { urlParams } = useUrlParams(); - const transactionTypes = useServiceTransactionTypes(urlParams); - - const { start, end } = urlParams; + const { serviceName } = useParams<{ serviceName?: string }>(); + const { start, end, transactionType } = urlParams; const { environmentOptions } = useEnvironments({ serviceName, start, end }); - if (!transactionTypes.length) { + if (!transactionTypes.length || !serviceName) { return null; } @@ -57,7 +77,9 @@ export function TransactionDurationAlertTrigger(props: Props) { aggregationType: 'avg', windowSize: 5, windowUnit: 'm', - transactionType: transactionTypes[0], + + // use the current transaction type or default to the first in the list + transactionType: transactionType || transactionTypes[0], environment: urlParams.environment || ENVIRONMENT_ALL.value, }; @@ -67,47 +89,17 @@ export function TransactionDurationAlertTrigger(props: Props) { }; const fields = [ - - - setAlertParams('environment', e.target.value as Params['environment']) - } - compressed - /> - , - - { - return { - text: key, - value: key, - }; - })} - onChange={(e) => - setAlertParams( - 'transactionType', - e.target.value as Params['transactionType'] - ) - } - compressed - /> - , + , + setAlertParams('environment', e.target.value)} + />, + ({ text: key, value: key }))} + onChange={(e) => setAlertParams('transactionType', e.target.value)} + />, - setAlertParams( - 'aggregationType', - e.target.value as Params['aggregationType'] - ) - } - compressed - /> - , - - setAlertParams('threshold', e.target.value)} - append={i18n.translate('xpack.apm.transactionDurationAlertTrigger.ms', { - defaultMessage: 'ms', - })} + onChange={(e) => setAlertParams('aggregationType', e.target.value)} compressed /> , + setAlertParams('threshold', value)} + />, setAlertParams('windowSize', timeWindowSize || '') diff --git a/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx rename to x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx diff --git a/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/index.tsx similarity index 75% rename from x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/index.tsx rename to x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/index.tsx index 20e0a3f27c4a4..fb4cda56fce04 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/index.tsx @@ -3,7 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiExpression, EuiSelect } from '@elastic/eui'; + +import { useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { ALERT_TYPES_CONFIG } from '../../../../common/alert_types'; @@ -16,14 +17,16 @@ import { AnomalySeverity, SelectAnomalySeverity, } from './SelectAnomalySeverity'; -import { - ENVIRONMENT_ALL, - getEnvironmentLabel, -} from '../../../../common/environment_filter_values'; +import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { TRANSACTION_PAGE_LOAD, TRANSACTION_REQUEST, } from '../../../../common/transaction_types'; +import { + EnvironmentField, + ServiceField, + TransactionTypeField, +} from '../fields'; interface Params { windowSize: number; @@ -42,9 +45,9 @@ interface Props { export function TransactionDurationAnomalyAlertTrigger(props: Props) { const { setAlertParams, alertParams, setAlertProperty } = props; - const { serviceName } = alertParams; const { urlParams } = useUrlParams(); const transactionTypes = useServiceTransactionTypes(urlParams); + const { serviceName } = useParams<{ serviceName?: string }>(); const { start, end } = urlParams; const { environmentOptions } = useEnvironments({ serviceName, start, end }); const supportedTransactionTypes = transactionTypes.filter((transactionType) => @@ -55,10 +58,13 @@ export function TransactionDurationAnomalyAlertTrigger(props: Props) { return null; } + // 'page-load' for RUM, 'request' otherwise + const transactionType = supportedTransactionTypes[0]; + const defaults: Params = { windowSize: 15, windowUnit: 'm', - transactionType: supportedTransactionTypes[0], // 'page-load' for RUM, 'request' otherwise + transactionType, serviceName, environment: urlParams.environment || ENVIRONMENT_ALL.value, anomalyScore: 75, @@ -70,31 +76,13 @@ export function TransactionDurationAnomalyAlertTrigger(props: Props) { }; const fields = [ - , + , + setAlertParams('environment', e.target.value)} />, - - setAlertParams('environment', e.target.value)} - compressed - /> - , } title={i18n.translate( diff --git a/x-pack/plugins/apm/public/components/alerting/TransactionErrorRateAlertTrigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/TransactionErrorRateAlertTrigger/index.tsx new file mode 100644 index 0000000000000..4dbf4dc10a907 --- /dev/null +++ b/x-pack/plugins/apm/public/components/alerting/TransactionErrorRateAlertTrigger/index.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { useParams } from 'react-router-dom'; +import React from 'react'; +import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; +import { ALERT_TYPES_CONFIG, AlertType } from '../../../../common/alert_types'; +import { useEnvironments } from '../../../hooks/useEnvironments'; +import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; + +import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; +import { + ServiceField, + TransactionTypeField, + EnvironmentField, + IsAboveField, +} from '../fields'; + +interface AlertParams { + windowSize: number; + windowUnit: string; + threshold: number; + serviceName: string; + transactionType: string; + environment: string; +} + +interface Props { + alertParams: AlertParams; + setAlertParams: (key: string, value: any) => void; + setAlertProperty: (key: string, value: any) => void; +} + +export function TransactionErrorRateAlertTrigger(props: Props) { + const { setAlertParams, alertParams, setAlertProperty } = props; + const { urlParams } = useUrlParams(); + const transactionTypes = useServiceTransactionTypes(urlParams); + const { serviceName } = useParams<{ serviceName?: string }>(); + const { start, end, transactionType } = urlParams; + const { environmentOptions } = useEnvironments({ serviceName, start, end }); + + if (!transactionTypes.length || !serviceName) { + return null; + } + + const defaultParams = { + threshold: 30, + windowSize: 5, + windowUnit: 'm', + transactionType: transactionType || transactionTypes[0], + environment: urlParams.environment || ENVIRONMENT_ALL.value, + }; + + const params = { + ...defaultParams, + ...alertParams, + }; + + const fields = [ + , + setAlertParams('environment', e.target.value)} + />, + ({ text: key, value: key }))} + onChange={(e) => setAlertParams('transactionType', e.target.value)} + />, + setAlertParams('threshold', value)} + />, + + setAlertParams('windowSize', timeWindowSize || '') + } + onChangeWindowUnit={(timeWindowUnit) => + setAlertParams('windowUnit', timeWindowUnit) + } + timeWindowSize={params.windowSize} + timeWindowUnit={params.windowUnit} + errors={{ + timeWindowSize: [], + timeWindowUnit: [], + }} + />, + ]; + + return ( + + ); +} + +// Default export is required for React.lazy loading +// +// eslint-disable-next-line import/no-default-export +export default TransactionErrorRateAlertTrigger; diff --git a/x-pack/plugins/apm/public/components/alerting/fields.tsx b/x-pack/plugins/apm/public/components/alerting/fields.tsx new file mode 100644 index 0000000000000..e145d03671a18 --- /dev/null +++ b/x-pack/plugins/apm/public/components/alerting/fields.tsx @@ -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 { EuiSelect, EuiExpression, EuiFieldNumber } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSelectOption } from '@elastic/eui'; +import { getEnvironmentLabel } from '../../../common/environment_filter_values'; +import { PopoverExpression } from './ServiceAlertTrigger/PopoverExpression'; + +export function ServiceField({ value }: { value?: string }) { + return ( + + ); +} + +export function EnvironmentField({ + currentValue, + options, + onChange, +}: { + currentValue: string; + options: EuiSelectOption[]; + onChange: (event: React.ChangeEvent) => void; +}) { + return ( + + + + ); +} + +export function TransactionTypeField({ + currentValue, + options, + onChange, +}: { + currentValue: string; + options?: EuiSelectOption[]; + onChange?: (event: React.ChangeEvent) => void; +}) { + const label = i18n.translate('xpack.apm.alerting.fields.type', { + defaultMessage: 'Type', + }); + + if (!options || options.length === 1) { + return ; + } + + return ( + + + + ); +} + +export function IsAboveField({ + value, + unit, + onChange, + step, +}: { + value: number; + unit: string; + onChange: (value: number) => void; + step?: number; +}) { + return ( + + onChange(parseInt(e.target.value, 10))} + append={unit} + compressed + step={step} + /> + + ); +} diff --git a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts new file mode 100644 index 0000000000000..c0a1955e2cc8a --- /dev/null +++ b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts @@ -0,0 +1,72 @@ +/* + * 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'; +import { lazy } from 'react'; +import { AlertType } from '../../../common/alert_types'; +import { ApmPluginStartDeps } from '../../plugin'; + +export function registerApmAlerts( + alertTypeRegistry: ApmPluginStartDeps['triggers_actions_ui']['alertTypeRegistry'] +) { + alertTypeRegistry.register({ + id: AlertType.ErrorCount, + name: i18n.translate('xpack.apm.alertTypes.errorCount', { + defaultMessage: 'Error count threshold', + }), + iconClass: 'bell', + alertParamsExpression: lazy(() => import('./ErrorCountAlertTrigger')), + validate: () => ({ + errors: [], + }), + requiresAppContext: true, + }); + + alertTypeRegistry.register({ + id: AlertType.TransactionDuration, + name: i18n.translate('xpack.apm.alertTypes.transactionDuration', { + defaultMessage: 'Transaction duration threshold', + }), + iconClass: 'bell', + alertParamsExpression: lazy( + () => import('./TransactionDurationAlertTrigger') + ), + validate: () => ({ + errors: [], + }), + requiresAppContext: true, + }); + + alertTypeRegistry.register({ + id: AlertType.TransactionErrorRate, + name: i18n.translate('xpack.apm.alertTypes.transactionErrorRate', { + defaultMessage: 'Transaction error rate threshold', + }), + iconClass: 'bell', + alertParamsExpression: lazy( + () => import('./TransactionErrorRateAlertTrigger') + ), + validate: () => ({ + errors: [], + }), + requiresAppContext: true, + }); + + alertTypeRegistry.register({ + id: AlertType.TransactionDurationAnomaly, + name: i18n.translate('xpack.apm.alertTypes.transactionDurationAnomaly', { + defaultMessage: 'Transaction duration anomaly', + }), + iconClass: 'bell', + alertParamsExpression: lazy( + () => import('./TransactionDurationAnomalyAlertTrigger') + ), + validate: () => ({ + errors: [], + }), + requiresAppContext: true, + }); +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx index 27c4a37e09c00..c11bfdeae945b 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx @@ -24,9 +24,13 @@ const transactionDurationLabel = i18n.translate( 'xpack.apm.serviceDetails.alertsMenu.transactionDuration', { defaultMessage: 'Transaction duration' } ); -const errorRateLabel = i18n.translate( - 'xpack.apm.serviceDetails.alertsMenu.errorRate', - { defaultMessage: 'Error rate' } +const transactionErrorRateLabel = i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.transactionErrorRate', + { defaultMessage: 'Transaction error rate' } +); +const errorCountLabel = i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.errorCount', + { defaultMessage: 'Error count' } ); const createThresholdAlertLabel = i18n.translate( 'xpack.apm.serviceDetails.alertsMenu.createThresholdAlert', @@ -38,8 +42,10 @@ const createAnomalyAlertAlertLabel = i18n.translate( ); const CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID = - 'create_transaction_duration'; -const CREATE_ERROR_RATE_ALERT_PANEL_ID = 'create_error_rate'; + 'create_transaction_duration_panel'; +const CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID = + 'create_transaction_error_rate_panel'; +const CREATE_ERROR_COUNT_ALERT_PANEL_ID = 'create_error_count_panel'; interface Props { canReadAlerts: boolean; @@ -77,7 +83,14 @@ export function AlertIntegrations(props: Props) { name: transactionDurationLabel, panel: CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID, }, - { name: errorRateLabel, panel: CREATE_ERROR_RATE_ALERT_PANEL_ID }, + { + name: transactionErrorRateLabel, + panel: CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID, + }, + { + name: errorCountLabel, + panel: CREATE_ERROR_COUNT_ALERT_PANEL_ID, + }, ] : []), ...(canReadAlerts @@ -96,10 +109,13 @@ export function AlertIntegrations(props: Props) { : []), ], }, + + // transaction duration panel { id: CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID, title: transactionDurationLabel, items: [ + // threshold alerts { name: createThresholdAlertLabel, onClick: () => { @@ -107,6 +123,8 @@ export function AlertIntegrations(props: Props) { setPopoverOpen(false); }, }, + + // anomaly alerts ...(canReadAnomalies ? [ { @@ -120,14 +138,32 @@ export function AlertIntegrations(props: Props) { : []), ], }, + + // transaction error rate panel + { + id: CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID, + title: transactionErrorRateLabel, + items: [ + // threshold alerts + { + name: createThresholdAlertLabel, + onClick: () => { + setAlertType(AlertType.TransactionErrorRate); + setPopoverOpen(false); + }, + }, + ], + }, + + // error alerts panel { - id: CREATE_ERROR_RATE_ALERT_PANEL_ID, - title: errorRateLabel, + id: CREATE_ERROR_COUNT_ALERT_PANEL_ID, + title: errorCountLabel, items: [ { name: createThresholdAlertLabel, onClick: () => { - setAlertType(AlertType.ErrorRate); + setAlertType(AlertType.ErrorCount); setPopoverOpen(false); }, }, diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 51ac6673251fb..ab3f1026a92dd 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { i18n } from '@kbn/i18n'; -import { lazy } from 'react'; import { ConfigSchema } from '.'; import { FetchDataParams, @@ -34,10 +32,10 @@ import { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, } from '../../triggers_actions_ui/public'; -import { AlertType } from '../common/alert_types'; import { featureCatalogueEntry } from './featureCatalogueEntry'; import { toggleAppLinkInNav } from './toggleAppLinkInNav'; import { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; +import { registerApmAlerts } from './components/alerting/register_apm_alerts'; export type ApmPluginSetup = void; export type ApmPluginStart = void; @@ -147,51 +145,6 @@ export class ApmPlugin implements Plugin { } public start(core: CoreStart, plugins: ApmPluginStartDeps) { toggleAppLinkInNav(core, this.initializerContext.config.get()); - - plugins.triggers_actions_ui.alertTypeRegistry.register({ - id: AlertType.ErrorRate, - name: i18n.translate('xpack.apm.alertTypes.errorRate', { - defaultMessage: 'Error rate', - }), - iconClass: 'bell', - alertParamsExpression: lazy( - () => import('./components/shared/ErrorRateAlertTrigger') - ), - validate: () => ({ - errors: [], - }), - requiresAppContext: true, - }); - - plugins.triggers_actions_ui.alertTypeRegistry.register({ - id: AlertType.TransactionDuration, - name: i18n.translate('xpack.apm.alertTypes.transactionDuration', { - defaultMessage: 'Transaction duration', - }), - iconClass: 'bell', - alertParamsExpression: lazy( - () => import('./components/shared/TransactionDurationAlertTrigger') - ), - validate: () => ({ - errors: [], - }), - requiresAppContext: true, - }); - - plugins.triggers_actions_ui.alertTypeRegistry.register({ - id: AlertType.TransactionDurationAnomaly, - name: i18n.translate('xpack.apm.alertTypes.transactionDurationAnomaly', { - defaultMessage: 'Transaction duration anomaly', - }), - iconClass: 'bell', - alertParamsExpression: lazy( - () => - import('./components/shared/TransactionDurationAnomalyAlertTrigger') - ), - validate: () => ({ - errors: [], - }), - requiresAppContext: true, - }); + registerApmAlerts(plugins.triggers_actions_ui.alertTypeRegistry); } } diff --git a/x-pack/plugins/apm/server/lib/alerts/action_variables.ts b/x-pack/plugins/apm/server/lib/alerts/action_variables.ts new file mode 100644 index 0000000000000..f2558da3a30e4 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/action_variables.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const apmActionVariables = { + serviceName: { + description: i18n.translate( + 'xpack.apm.alerts.action_variables.serviceName', + { defaultMessage: 'The service the alert is created for' } + ), + name: 'serviceName', + }, + transactionType: { + description: i18n.translate( + 'xpack.apm.alerts.action_variables.transactionType', + { defaultMessage: 'The transaction type the alert is created for' } + ), + name: 'transactionType', + }, + environment: { + description: i18n.translate( + 'xpack.apm.alerts.action_variables.environment', + { defaultMessage: 'The transaction type the alert is created for' } + ), + name: 'environment', + }, + threshold: { + description: i18n.translate('xpack.apm.alerts.action_variables.threshold', { + defaultMessage: + 'Any trigger value above this value will cause the alert to fire', + }), + name: 'threshold', + }, + triggerValue: { + description: i18n.translate( + 'xpack.apm.alerts.action_variables.triggerValue', + { + defaultMessage: + 'The value that breached the threshold and triggered the alert', + } + ), + name: 'triggerValue', + }, +}; diff --git a/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts b/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts index 44ca80143bcd9..fcbb4cc5950e0 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts @@ -9,9 +9,10 @@ import { AlertingPlugin } from '../../../../alerts/server'; import { ActionsPlugin } from '../../../../actions/server'; import { registerTransactionDurationAlertType } from './register_transaction_duration_alert_type'; import { registerTransactionDurationAnomalyAlertType } from './register_transaction_duration_anomaly_alert_type'; -import { registerErrorRateAlertType } from './register_error_rate_alert_type'; +import { registerErrorCountAlertType } from './register_error_count_alert_type'; import { APMConfig } from '../..'; import { MlPluginSetup } from '../../../../ml/server'; +import { registerTransactionErrorRateAlertType } from './register_transaction_error_rate_alert_type'; interface Params { alerts: AlertingPlugin['setup']; @@ -30,7 +31,11 @@ export function registerApmAlerts(params: Params) { ml: params.ml, config$: params.config$, }); - registerErrorRateAlertType({ + registerErrorCountAlertType({ + alerts: params.alerts, + config$: params.config$, + }); + registerTransactionErrorRateAlertType({ alerts: params.alerts, config$: params.config$, }); diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_rate_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts similarity index 66% rename from x-pack/plugins/apm/server/lib/alerts/register_error_rate_alert_type.ts rename to x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts index 61e3dfee420a5..5455cd9f6a495 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_error_rate_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema, TypeOf } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; -import { i18n } from '@kbn/i18n'; -import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; import { ESSearchResponse, @@ -17,11 +17,11 @@ import { import { PROCESSOR_EVENT, SERVICE_NAME, - SERVICE_ENVIRONMENT, } from '../../../common/elasticsearch_fieldnames'; import { AlertingPlugin } from '../../../../alerts/server'; import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; import { APMConfig } from '../..'; +import { apmActionVariables } from './action_variables'; interface RegisterAlertParams { alerts: AlertingPlugin['setup']; @@ -29,21 +29,21 @@ interface RegisterAlertParams { } const paramsSchema = schema.object({ - serviceName: schema.string(), windowSize: schema.number(), windowUnit: schema.string(), threshold: schema.number(), + serviceName: schema.string(), environment: schema.string(), }); -const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.ErrorRate]; +const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.ErrorCount]; -export function registerErrorRateAlertType({ +export function registerErrorCountAlertType({ alerts, config$, }: RegisterAlertParams) { alerts.registerType({ - id: AlertType.ErrorRate, + id: AlertType.ErrorCount, name: alertTypeConfig.name, actionGroups: alertTypeConfig.actionGroups, defaultActionGroupId: alertTypeConfig.defaultActionGroupId, @@ -52,37 +52,26 @@ export function registerErrorRateAlertType({ }, actionVariables: { context: [ - { - description: i18n.translate( - 'xpack.apm.registerErrorRateAlertType.variables.serviceName', - { - defaultMessage: 'Service name', - } - ), - name: 'serviceName', - }, + apmActionVariables.serviceName, + apmActionVariables.environment, + apmActionVariables.threshold, + apmActionVariables.triggerValue, ], }, producer: 'apm', executor: async ({ services, params }) => { const config = await config$.pipe(take(1)).toPromise(); - - const alertParams = params as TypeOf; - + const alertParams = params; const indices = await getApmIndices({ config, savedObjectsClient: services.savedObjectsClient, }); - const environmentTerm = - alertParams.environment === ENVIRONMENT_ALL.value - ? [] - : [{ term: { [SERVICE_ENVIRONMENT]: alertParams.environment } }]; - const searchParams = { index: indices['apm_oss.errorIndices'], size: 0, body: { + track_total_hits: true, query: { bool: { filter: [ @@ -93,21 +82,12 @@ export function registerErrorRateAlertType({ }, }, }, - { - term: { - [PROCESSOR_EVENT]: 'error', - }, - }, - { - term: { - [SERVICE_NAME]: alertParams.serviceName, - }, - }, - ...environmentTerm, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.error } }, + { term: { [SERVICE_NAME]: alertParams.serviceName } }, + ...getEnvironmentUiFilterES(alertParams.environment), ], }, }, - track_total_hits: true, }, }; @@ -116,18 +96,19 @@ export function registerErrorRateAlertType({ ESSearchRequest > = await services.callCluster('search', searchParams); - const value = response.hits.total.value; + const errorCount = response.hits.total.value; - if (value && value > alertParams.threshold) { + if (errorCount > alertParams.threshold) { const alertInstance = services.alertInstanceFactory( - AlertType.ErrorRate + AlertType.ErrorCount ); alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { serviceName: alertParams.serviceName, + environment: alertParams.environment, + threshold: alertParams.threshold, + triggerValue: errorCount, }); } - - return {}; }, }); } diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts index ead28c325692d..373d4bd4da832 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts @@ -4,11 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema, TypeOf } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; -import { i18n } from '@kbn/i18n'; -import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values'; +import { ProcessorEvent } from '../../../common/processor_event'; import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; import { ESSearchResponse } from '../../../typings/elasticsearch'; import { @@ -16,11 +15,12 @@ import { SERVICE_NAME, TRANSACTION_TYPE, TRANSACTION_DURATION, - SERVICE_ENVIRONMENT, } from '../../../common/elasticsearch_fieldnames'; import { AlertingPlugin } from '../../../../alerts/server'; import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; import { APMConfig } from '../..'; +import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { apmActionVariables } from './action_variables'; interface RegisterAlertParams { alerts: AlertingPlugin['setup']; @@ -57,42 +57,22 @@ export function registerTransactionDurationAlertType({ }, actionVariables: { context: [ - { - description: i18n.translate( - 'xpack.apm.registerTransactionDurationAlertType.variables.serviceName', - { - defaultMessage: 'Service name', - } - ), - name: 'serviceName', - }, - { - description: i18n.translate( - 'xpack.apm.registerTransactionDurationAlertType.variables.transactionType', - { - defaultMessage: 'Transaction type', - } - ), - name: 'transactionType', - }, + apmActionVariables.serviceName, + apmActionVariables.transactionType, + apmActionVariables.environment, + apmActionVariables.threshold, + apmActionVariables.triggerValue, ], }, producer: 'apm', executor: async ({ services, params }) => { const config = await config$.pipe(take(1)).toPromise(); - - const alertParams = params as TypeOf; - + const alertParams = params; const indices = await getApmIndices({ config, savedObjectsClient: services.savedObjectsClient, }); - const environmentTerm = - alertParams.environment === ENVIRONMENT_ALL.value - ? [] - : [{ term: { [SERVICE_ENVIRONMENT]: alertParams.environment } }]; - const searchParams = { index: indices['apm_oss.transactionIndices'], size: 0, @@ -107,33 +87,17 @@ export function registerTransactionDurationAlertType({ }, }, }, - { - term: { - [PROCESSOR_EVENT]: 'transaction', - }, - }, - { - term: { - [SERVICE_NAME]: alertParams.serviceName, - }, - }, - { - term: { - [TRANSACTION_TYPE]: alertParams.transactionType, - }, - }, - ...environmentTerm, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + { term: { [SERVICE_NAME]: alertParams.serviceName } }, + { term: { [TRANSACTION_TYPE]: alertParams.transactionType } }, + ...getEnvironmentUiFilterES(alertParams.environment), ], }, }, aggs: { agg: alertParams.aggregationType === 'avg' - ? { - avg: { - field: TRANSACTION_DURATION, - }, - } + ? { avg: { field: TRANSACTION_DURATION } } : { percentiles: { field: TRANSACTION_DURATION, @@ -157,19 +121,23 @@ export function registerTransactionDurationAlertType({ const { agg } = response.aggregations; - const value = 'values' in agg ? Object.values(agg.values)[0] : agg?.value; + const transactionDuration = + 'values' in agg ? Object.values(agg.values)[0] : agg?.value; - if (value && value > alertParams.threshold * 1000) { + const threshold = alertParams.threshold * 1000; + + if (transactionDuration && transactionDuration > threshold) { const alertInstance = services.alertInstanceFactory( AlertType.TransactionDuration ); alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { transactionType: alertParams.transactionType, serviceName: alertParams.serviceName, + environment: alertParams.environment, + threshold, + triggerValue: transactionDuration, }); } - - return {}; }, }); } diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts index 93af51b572aa5..b3526b6a97ad9 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts @@ -4,15 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema, TypeOf } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; import { Observable } from 'rxjs'; -import { i18n } from '@kbn/i18n'; import { KibanaRequest } from '../../../../../../src/core/server'; import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; import { AlertingPlugin } from '../../../../alerts/server'; import { APMConfig } from '../..'; import { MlPluginSetup } from '../../../../ml/server'; import { getMLJobIds } from '../service_map/get_service_anomalies'; +import { apmActionVariables } from './action_variables'; interface RegisterAlertParams { alerts: AlertingPlugin['setup']; @@ -47,24 +47,9 @@ export function registerTransactionDurationAnomalyAlertType({ }, actionVariables: { context: [ - { - description: i18n.translate( - 'xpack.apm.registerTransactionDurationAnomalyAlertType.variables.serviceName', - { - defaultMessage: 'Service name', - } - ), - name: 'serviceName', - }, - { - description: i18n.translate( - 'xpack.apm.registerTransactionDurationAnomalyAlertType.variables.transactionType', - { - defaultMessage: 'Transaction type', - } - ), - name: 'transactionType', - }, + apmActionVariables.serviceName, + apmActionVariables.transactionType, + apmActionVariables.environment, ], }, producer: 'apm', @@ -72,7 +57,7 @@ export function registerTransactionDurationAnomalyAlertType({ if (!ml) { return; } - const alertParams = params as TypeOf; + const alertParams = params; const request = {} as KibanaRequest; const { mlAnomalySearch } = ml.mlSystemProvider(request); const anomalyDetectors = ml.anomalyDetectorsProvider(request); @@ -88,6 +73,7 @@ export function registerTransactionDurationAnomalyAlertType({ const anomalySearchParams = { body: { + terminateAfter: 1, size: 0, query: { bool: { @@ -131,10 +117,10 @@ export function registerTransactionDurationAnomalyAlertType({ ); alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { serviceName: alertParams.serviceName, + transactionType: alertParams.transactionType, + environment: alertParams.environment, }); } - - return {}; }, }); } diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts new file mode 100644 index 0000000000000..a6ed40fc15ec6 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts @@ -0,0 +1,131 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { EventOutcome } from '../../../common/event_outcome'; +import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; +import { ESSearchResponse } from '../../../typings/elasticsearch'; +import { + PROCESSOR_EVENT, + SERVICE_NAME, + TRANSACTION_TYPE, + EVENT_OUTCOME, +} from '../../../common/elasticsearch_fieldnames'; +import { AlertingPlugin } from '../../../../alerts/server'; +import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; +import { APMConfig } from '../..'; +import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { apmActionVariables } from './action_variables'; + +interface RegisterAlertParams { + alerts: AlertingPlugin['setup']; + config$: Observable; +} + +const paramsSchema = schema.object({ + windowSize: schema.number(), + windowUnit: schema.string(), + threshold: schema.number(), + transactionType: schema.string(), + serviceName: schema.string(), + environment: schema.string(), +}); + +const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.TransactionErrorRate]; + +export function registerTransactionErrorRateAlertType({ + alerts, + config$, +}: RegisterAlertParams) { + alerts.registerType({ + id: AlertType.TransactionErrorRate, + name: alertTypeConfig.name, + actionGroups: alertTypeConfig.actionGroups, + defaultActionGroupId: alertTypeConfig.defaultActionGroupId, + validate: { + params: paramsSchema, + }, + actionVariables: { + context: [ + apmActionVariables.transactionType, + apmActionVariables.serviceName, + apmActionVariables.environment, + apmActionVariables.threshold, + apmActionVariables.triggerValue, + ], + }, + producer: 'apm', + executor: async ({ services, params: alertParams }) => { + const config = await config$.pipe(take(1)).toPromise(); + const indices = await getApmIndices({ + config, + savedObjectsClient: services.savedObjectsClient, + }); + + const searchParams = { + index: indices['apm_oss.transactionIndices'], + size: 0, + body: { + track_total_hits: true, + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: `now-${alertParams.windowSize}${alertParams.windowUnit}`, + }, + }, + }, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + { term: { [SERVICE_NAME]: alertParams.serviceName } }, + { term: { [TRANSACTION_TYPE]: alertParams.transactionType } }, + ...getEnvironmentUiFilterES(alertParams.environment), + ], + }, + }, + aggs: { + erroneous_transactions: { + filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, + }, + }, + }, + }; + + const response: ESSearchResponse< + unknown, + typeof searchParams + > = await services.callCluster('search', searchParams); + + if (!response.aggregations) { + return; + } + + const errornousTransactionsCount = + response.aggregations.erroneous_transactions.doc_count; + const totalTransactionCount = response.hits.total.value; + const transactionErrorRate = + (errornousTransactionsCount / totalTransactionCount) * 100; + + if (transactionErrorRate > alertParams.threshold) { + const alertInstance = services.alertInstanceFactory( + AlertType.TransactionErrorRate + ); + + alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { + serviceName: alertParams.serviceName, + transactionType: alertParams.transactionType, + environment: alertParams.environment, + threshold: alertParams.threshold, + triggerValue: transactionErrorRate, + }); + } + }, + }); +} diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts index 7bcd945d890ad..d0673335387c6 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts @@ -8,6 +8,7 @@ import { Logger } from 'kibana/server'; import uuid from 'uuid/v4'; import { snakeCase } from 'lodash'; import Boom from 'boom'; +import { ProcessorEvent } from '../../../common/processor_event'; import { ML_ERRORS } from '../../../common/anomaly_detection'; import { PromiseReturnType } from '../../../../observability/typings/common'; import { Setup } from '../helpers/setup_request'; @@ -79,7 +80,7 @@ async function createAnomalyDetectionJob({ query: { bool: { filter: [ - { term: { [PROCESSOR_EVENT]: 'transaction' } }, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, { exists: { field: TRANSACTION_DURATION } }, ...getEnvironmentUiFilterES(environment), ], diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts index a53068d152d03..fcd4f468d4367 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts @@ -85,7 +85,7 @@ export const tasks: TelemetryTask[] = [ query: { bool: { filter: [ - { term: { [PROCESSOR_EVENT]: 'transaction' } }, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, { range: { '@timestamp': { gte: start, lt: end } } }, ], }, @@ -606,7 +606,10 @@ export const tasks: TelemetryTask[] = [ timeout, query: { bool: { - filter: [{ term: { [PROCESSOR_EVENT]: 'error' } }, range1d], + filter: [ + { term: { [PROCESSOR_EVENT]: ProcessorEvent.error } }, + range1d, + ], }, }, aggs: { @@ -640,7 +643,7 @@ export const tasks: TelemetryTask[] = [ query: { bool: { filter: [ - { term: { [PROCESSOR_EVENT]: 'transaction' } }, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, range1d, ], }, @@ -674,7 +677,7 @@ export const tasks: TelemetryTask[] = [ query: { bool: { filter: [ - { term: { [PROCESSOR_EVENT]: 'transaction' } }, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, range1d, ], must_not: { diff --git a/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.ts b/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.ts index 6ff98a9be75f9..ea8d02eb833cf 100644 --- a/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.ts +++ b/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.ts @@ -5,11 +5,14 @@ */ import { ESFilter } from '../../../../typings/elasticsearch'; -import { ENVIRONMENT_NOT_DEFINED } from '../../../../common/environment_filter_values'; +import { + ENVIRONMENT_NOT_DEFINED, + ENVIRONMENT_ALL, +} from '../../../../common/environment_filter_values'; import { SERVICE_ENVIRONMENT } from '../../../../common/elasticsearch_fieldnames'; export function getEnvironmentUiFilterES(environment?: string): ESFilter[] { - if (!environment) { + if (!environment || environment === ENVIRONMENT_ALL.value) { return []; } if (environment === ENVIRONMENT_NOT_DEFINED.value) { diff --git a/x-pack/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap index b88c90a213c67..2868dcfda97b6 100644 --- a/x-pack/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap @@ -203,16 +203,50 @@ Object { "memoryUsedAvg": Object { "avg": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, "memoryUsedMax": Object { "max": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, @@ -221,16 +255,50 @@ Object { "memoryUsedAvg": Object { "avg": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, "memoryUsedMax": Object { "max": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, @@ -275,12 +343,7 @@ Object { }, Object { "exists": Object { - "field": "system.memory.actual.free", - }, - }, - Object { - "exists": Object { - "field": "system.memory.total", + "field": "system.process.cgroup.memory.mem.usage.bytes", }, }, ], @@ -682,16 +745,50 @@ Object { "memoryUsedAvg": Object { "avg": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, "memoryUsedMax": Object { "max": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, @@ -700,16 +797,50 @@ Object { "memoryUsedAvg": Object { "avg": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, "memoryUsedMax": Object { "max": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, @@ -760,12 +891,7 @@ Object { }, Object { "exists": Object { - "field": "system.memory.actual.free", - }, - }, - Object { - "exists": Object { - "field": "system.memory.total", + "field": "system.process.cgroup.memory.mem.usage.bytes", }, }, ], @@ -1157,16 +1283,50 @@ Object { "memoryUsedAvg": Object { "avg": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, "memoryUsedMax": Object { "max": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, @@ -1175,16 +1335,50 @@ Object { "memoryUsedAvg": Object { "avg": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, "memoryUsedMax": Object { "max": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, @@ -1224,12 +1418,7 @@ Object { }, Object { "exists": Object { - "field": "system.memory.actual.free", - }, - }, - Object { - "exists": Object { - "field": "system.memory.total", + "field": "system.process.cgroup.memory.mem.usage.bytes", }, }, ], diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts index 316b0d59d2c5b..a60576ca0c175 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts @@ -6,6 +6,8 @@ import { i18n } from '@kbn/i18n'; import { + METRIC_CGROUP_MEMORY_LIMIT_BYTES, + METRIC_CGROUP_MEMORY_USAGE_BYTES, METRIC_SYSTEM_FREE_MEMORY, METRIC_SYSTEM_TOTAL_MEMORY, } from '../../../../../../common/elasticsearch_fieldnames'; @@ -14,8 +16,8 @@ import { SetupTimeRange, SetupUIFilters, } from '../../../../helpers/setup_request'; -import { ChartBase } from '../../../types'; import { fetchAndTransformMetrics } from '../../../fetch_and_transform_metrics'; +import { ChartBase } from '../../../types'; const series = { memoryUsedMax: { @@ -43,36 +45,68 @@ const chartBase: ChartBase = { series, }; -export const percentMemoryUsedScript = { +export const percentSystemMemoryUsedScript = { lang: 'expression', source: `1 - doc['${METRIC_SYSTEM_FREE_MEMORY}'] / doc['${METRIC_SYSTEM_TOTAL_MEMORY}']`, }; +export const percentCgroupMemoryUsedScript = { + lang: 'painless', + source: ` + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = '${METRIC_CGROUP_MEMORY_LIMIT_BYTES}'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['${METRIC_SYSTEM_TOTAL_MEMORY}'].value; + + double used = doc['${METRIC_CGROUP_MEMORY_USAGE_BYTES}'].value; + + return used / total; + `, +}; + export async function getMemoryChartData( setup: Setup & SetupTimeRange & SetupUIFilters, serviceName: string, serviceNodeName?: string ) { - return fetchAndTransformMetrics({ + const cgroupResponse = await fetchAndTransformMetrics({ setup, serviceName, serviceNodeName, chartBase, aggs: { - memoryUsedAvg: { avg: { script: percentMemoryUsedScript } }, - memoryUsedMax: { max: { script: percentMemoryUsedScript } }, + memoryUsedAvg: { avg: { script: percentCgroupMemoryUsedScript } }, + memoryUsedMax: { max: { script: percentCgroupMemoryUsedScript } }, }, additionalFilters: [ - { - exists: { - field: METRIC_SYSTEM_FREE_MEMORY, - }, - }, - { - exists: { - field: METRIC_SYSTEM_TOTAL_MEMORY, - }, - }, + { exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES } }, ], }); + + if (cgroupResponse.noHits) { + return await fetchAndTransformMetrics({ + setup, + serviceName, + serviceNodeName, + chartBase, + aggs: { + memoryUsedAvg: { avg: { script: percentSystemMemoryUsedScript } }, + memoryUsedMax: { max: { script: percentSystemMemoryUsedScript } }, + }, + additionalFilters: [ + { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, + { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, + ], + }); + } + + return cgroupResponse; } diff --git a/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts b/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts index 1a7d602882395..f25062c67f87a 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts @@ -67,7 +67,7 @@ export async function getPageViewTrends({ x: xVal, y: bCount, }; - if (breakdownItem) { + if ('breakdown' in bucket) { const categoryBuckets = bucket.breakdown.buckets; categoryBuckets.forEach(({ key, doc_count: docCount }) => { if (key === 'Other') { diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts index 88cc26608b850..5c183fd9150dd 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts @@ -14,13 +14,17 @@ import { METRIC_SYSTEM_CPU_PERCENT, METRIC_SYSTEM_FREE_MEMORY, METRIC_SYSTEM_TOTAL_MEMORY, + METRIC_CGROUP_MEMORY_USAGE_BYTES, TRANSACTION_TYPE, } from '../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../common/processor_event'; import { rangeFilter } from '../../../common/utils/range_filter'; import { ESFilter } from '../../../typings/elasticsearch'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; -import { percentMemoryUsedScript } from '../metrics/by_agent/shared/memory'; +import { + percentCgroupMemoryUsedScript, + percentSystemMemoryUsedScript, +} from '../metrics/by_agent/shared/memory'; import { getProcessorEventForAggregatedTransactions, getTransactionDurationFieldForAggregatedTransactions, @@ -205,26 +209,50 @@ async function getMemoryStats({ filter, }: TaskParameters): Promise<{ avgMemoryUsage: number | null }> { const { apmEventClient } = setup; - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.metric], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - ...filter, - { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, - { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, - ], + + const getAvgMemoryUsage = async ({ + additionalFilters, + script, + }: { + additionalFilters: ESFilter[]; + script: typeof percentCgroupMemoryUsedScript; + }) => { + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.metric], + }, + body: { + size: 0, + query: { + bool: { + filter: [...filter, ...additionalFilters], + }, + }, + aggs: { + avgMemoryUsage: { avg: { script } }, }, }, - aggs: { avgMemoryUsage: { avg: { script: percentMemoryUsedScript } } }, - }, - }); + }); - return { - avgMemoryUsage: response.aggregations?.avgMemoryUsage.value ?? null, + return response.aggregations?.avgMemoryUsage.value ?? null; }; + + let avgMemoryUsage = await getAvgMemoryUsage({ + additionalFilters: [ + { exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES } }, + ], + script: percentCgroupMemoryUsedScript, + }); + + if (!avgMemoryUsage) { + avgMemoryUsage = await getAvgMemoryUsage({ + additionalFilters: [ + { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, + { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, + ], + script: percentSystemMemoryUsedScript, + }); + } + + return { avgMemoryUsage }; } diff --git a/x-pack/plugins/data_enhanced/server/index.ts b/x-pack/plugins/data_enhanced/server/index.ts index fbe1ecc10d632..3c5d5d1e99d13 100644 --- a/x-pack/plugins/data_enhanced/server/index.ts +++ b/x-pack/plugins/data_enhanced/server/index.ts @@ -11,4 +11,6 @@ export function plugin(initializerContext: PluginInitializerContext) { return new EnhancedDataServerPlugin(initializerContext); } +export { ENHANCED_ES_SEARCH_STRATEGY } from '../common'; + export { EnhancedDataServerPlugin as Plugin }; diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.test.ts b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.test.ts index 4906d0342be84..64af67aefa4be 100644 --- a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.test.ts +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.test.ts @@ -5,7 +5,6 @@ */ import { UrlDrilldown, ActionContext, Config } from './url_drilldown'; -import { coreMock } from '../../../../../../src/core/public/mocks'; import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public/lib/embeddables'; const mockDataPoints = [ @@ -52,7 +51,6 @@ const mockNavigateToUrl = jest.fn(() => Promise.resolve()); describe('UrlDrilldown', () => { const urlDrilldown = new UrlDrilldown({ getGlobalScope: () => ({ kibanaUrl: 'http://localhost:5601/' }), - getOpenModal: () => Promise.resolve(coreMock.createStart().overlays.openModal), getSyntaxHelpDocsLink: () => 'http://localhost:5601/docs', getVariablesHelpDocsLink: () => 'http://localhost:5601/docs', navigateToUrl: mockNavigateToUrl, diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx index 80478e6490b8f..04f60662d88a3 100644 --- a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx @@ -5,7 +5,6 @@ */ import React from 'react'; -import { OverlayStart } from 'kibana/public'; import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; import { ChartActionContext, IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../../src/plugins/kibana_utils/public'; @@ -29,7 +28,6 @@ import { txtUrlDrilldownDisplayName } from './i18n'; interface UrlDrilldownDeps { getGlobalScope: () => UrlDrilldownGlobalScope; navigateToUrl: (url: string) => Promise; - getOpenModal: () => Promise; getSyntaxHelpDocsLink: () => string; getVariablesHelpDocsLink: () => string; } @@ -112,13 +110,10 @@ export class UrlDrilldown implements Drilldown - urlDrilldownCompileUrl(config.url.template, await this.buildRuntimeScope(context)); + urlDrilldownCompileUrl(config.url.template, this.buildRuntimeScope(context)); public readonly execute = async (config: Config, context: ActionContext) => { - const url = await urlDrilldownCompileUrl( - config.url.template, - await this.buildRuntimeScope(context, { allowPrompts: true }) - ); + const url = urlDrilldownCompileUrl(config.url.template, this.buildRuntimeScope(context)); if (config.openInNewTab) { window.open(url, '_blank', 'noopener'); } else { @@ -134,14 +129,11 @@ export class UrlDrilldown implements Drilldown { + private buildRuntimeScope = (context: ActionContext) => { return urlDrilldownBuildScope({ globalScope: this.deps.getGlobalScope(), contextScope: getContextScope(context), - eventScope: await getEventScope(context, this.deps, opts), + eventScope: getEventScope(context), }); }; } diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts new file mode 100644 index 0000000000000..bb1baf5b96428 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts @@ -0,0 +1,129 @@ +/* + * 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 { + getEventScope, + getMockEventScope, + ValueClickTriggerEventScope, +} from './url_drilldown_scope'; + +const createPoint = ({ + field, + value, +}: { + field: string; + value: string | null | number | boolean; +}) => ({ + table: { + columns: [ + { + name: field, + id: '1-1', + meta: { + type: 'histogram', + indexPatternId: 'logstash-*', + aggConfigParams: { + field, + interval: 30, + otherBucket: true, + }, + }, + }, + ], + rows: [ + { + '1-1': '2048', + }, + ], + }, + column: 0, + row: 0, + value, +}); + +describe('VALUE_CLICK_TRIGGER', () => { + describe('supports `points[]`', () => { + test('getEventScope()', () => { + const mockDataPoints = [ + createPoint({ field: 'field0', value: 'value0' }), + createPoint({ field: 'field1', value: 'value1' }), + createPoint({ field: 'field2', value: 'value2' }), + ]; + + const eventScope = getEventScope({ + data: { data: mockDataPoints }, + }) as ValueClickTriggerEventScope; + + expect(eventScope.key).toBe('field0'); + expect(eventScope.value).toBe('value0'); + expect(eventScope.points).toHaveLength(mockDataPoints.length); + expect(eventScope.points).toMatchInlineSnapshot(` + Array [ + Object { + "key": "field0", + "value": "value0", + }, + Object { + "key": "field1", + "value": "value1", + }, + Object { + "key": "field2", + "value": "value2", + }, + ] + `); + }); + + test('getMockEventScope()', () => { + const mockEventScope = getMockEventScope([ + 'VALUE_CLICK_TRIGGER', + ]) as ValueClickTriggerEventScope; + expect(mockEventScope.points.length).toBeGreaterThan(3); + expect(mockEventScope.points).toMatchInlineSnapshot(` + Array [ + Object { + "key": "event.points.0.key", + "value": "event.points.0.value", + }, + Object { + "key": "event.points.1.key", + "value": "event.points.1.value", + }, + Object { + "key": "event.points.2.key", + "value": "event.points.2.value", + }, + Object { + "key": "event.points.3.key", + "value": "event.points.3.value", + }, + ] + `); + }); + }); + + describe('handles undefined, null or missing values', () => { + test('undefined or missing values are removed from the result scope', () => { + const point = createPoint({ field: undefined } as any); + const eventScope = getEventScope({ + data: { data: [point] }, + }) as ValueClickTriggerEventScope; + + expect('key' in eventScope).toBeFalsy(); + expect('value' in eventScope).toBeFalsy(); + }); + + test('null value stays in the result scope', () => { + const point = createPoint({ field: 'field', value: null }); + const eventScope = getEventScope({ + data: { data: [point] }, + }) as ValueClickTriggerEventScope; + + expect(eventScope.value).toBeNull(); + }); + }); +}); diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.tsx b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts similarity index 51% rename from x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.tsx rename to x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts index d3e3510f1b24e..15a9a3ba77d88 100644 --- a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.tsx +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts @@ -9,19 +9,7 @@ * Please refer to ./README.md for explanation of different scope sources */ -import React from 'react'; -import { - EuiButton, - EuiButtonEmpty, - EuiModalBody, - EuiModalFooter, - EuiModalHeader, - EuiModalHeaderTitle, - EuiRadioGroup, -} from '@elastic/eui'; -import uniqBy from 'lodash/uniqBy'; -import { FormattedMessage } from '@kbn/i18n/react'; -import type { Query, Filter, TimeRange } from '../../../../../../src/plugins/data/public'; +import type { Filter, Query, TimeRange } from '../../../../../../src/plugins/data/public'; import { IEmbeddable, isRangeSelectTriggerContext, @@ -31,8 +19,6 @@ import { } from '../../../../../../src/plugins/embeddable/public'; import type { ActionContext, ActionFactoryContext, UrlTrigger } from './url_drilldown'; import { SELECT_RANGE_TRIGGER } from '../../../../../../src/plugins/ui_actions/public'; -import { OverlayStart } from '../../../../../../src/core/public'; -import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; type ContextScopeInput = ActionContext | ActionFactoryContext; @@ -113,38 +99,35 @@ export function getContextScope(contextScopeInput: ContextScopeInput): UrlDrilld /** * URL drilldown event scope, - * available as: {{event.key}}, {{event.from}} + * available as {{event.$}} */ -type UrlDrilldownEventScope = ValueClickTriggerEventScope | RangeSelectTriggerEventScope; -type EventScopeInput = ActionContext; -interface ValueClickTriggerEventScope { +export type UrlDrilldownEventScope = ValueClickTriggerEventScope | RangeSelectTriggerEventScope; +export type EventScopeInput = ActionContext; +export interface ValueClickTriggerEventScope { key?: string; - value?: string | number | boolean; + value: Primitive; negate: boolean; + points: Array<{ key?: string; value: Primitive }>; } -interface RangeSelectTriggerEventScope { +export interface RangeSelectTriggerEventScope { key: string; from?: string | number; to?: string | number; } -export async function getEventScope( - eventScopeInput: EventScopeInput, - deps: { getOpenModal: () => Promise }, - opts: { allowPrompts: boolean } = { allowPrompts: false } -): Promise { +export function getEventScope(eventScopeInput: EventScopeInput): UrlDrilldownEventScope { if (isRangeSelectTriggerContext(eventScopeInput)) { return getEventScopeFromRangeSelectTriggerContext(eventScopeInput); } else if (isValueClickTriggerContext(eventScopeInput)) { - return getEventScopeFromValueClickTriggerContext(eventScopeInput, deps, opts); + return getEventScopeFromValueClickTriggerContext(eventScopeInput); } else { throw new Error("UrlDrilldown [getEventScope] can't build scope from not supported trigger"); } } -async function getEventScopeFromRangeSelectTriggerContext( +function getEventScopeFromRangeSelectTriggerContext( eventScopeInput: RangeSelectContext -): Promise { +): RangeSelectTriggerEventScope { const { table, column: columnIndex, range } = eventScopeInput.data; const column = table.columns[columnIndex]; return cleanEmptyKeys({ @@ -154,18 +137,23 @@ async function getEventScopeFromRangeSelectTriggerContext( }); } -async function getEventScopeFromValueClickTriggerContext( - eventScopeInput: ValueClickContext, - deps: { getOpenModal: () => Promise }, - opts: { allowPrompts: boolean } = { allowPrompts: false } -): Promise { +function getEventScopeFromValueClickTriggerContext( + eventScopeInput: ValueClickContext +): ValueClickTriggerEventScope { const negate = eventScopeInput.data.negate ?? false; - const point = await getSingleValue(eventScopeInput.data.data, deps, opts); - const { key, value } = getKeyValueFromPoint(point); + const points = eventScopeInput.data.data.map(({ table, value, column: columnIndex }) => { + const column = table.columns[columnIndex]; + return { + value: toPrimitiveOrUndefined(value) as Primitive, + key: toPrimitiveOrUndefined(column?.meta?.aggConfigParams?.field) as string | undefined, + }; + }); + return cleanEmptyKeys({ - key, - value, + key: points[0]?.key, + value: points[0]?.value, negate, + points, }); } @@ -182,29 +170,28 @@ export function getMockEventScope([trigger]: UrlTrigger[]): UrlDrilldownEventSco to: new Date().toISOString(), }; } else { + // number of mock points to generate + // should be larger or equal of any possible data points length emitted by VALUE_CLICK_TRIGGER + const nPoints = 4; + const points = new Array(nPoints).fill(0).map((_, index) => ({ + key: `event.points.${index}.key`, + value: `event.points.${index}.value`, + })); return { - key: 'event.key', - value: 'event.value', + key: `event.key`, + value: `event.value`, negate: false, + points, }; } } -function getKeyValueFromPoint( - point: ValueClickContext['data']['data'][0] -): Pick { - const { table, column: columnIndex, value } = point; - const column = table.columns[columnIndex]; - return { - key: toPrimitiveOrUndefined(column?.meta?.aggConfigParams?.field) as string | undefined, - value: toPrimitiveOrUndefined(value), - }; -} - -function toPrimitiveOrUndefined(v: unknown): string | number | boolean | undefined { - if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string') return v; +type Primitive = string | number | boolean | null; +function toPrimitiveOrUndefined(v: unknown): Primitive | undefined { + if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string' || v === null) + return v; if (typeof v === 'object' && v instanceof Date) return v.toISOString(); - if (typeof v === 'undefined' || v === null) return undefined; + if (typeof v === 'undefined') return undefined; return String(v); } @@ -216,104 +203,3 @@ function cleanEmptyKeys>(obj: T): T { }); return obj; } - -/** - * VALUE_CLICK_TRIGGER could have multiple data points - * Prompt user which data point to use in a drilldown - */ -async function getSingleValue( - data: ValueClickContext['data']['data'], - deps: { getOpenModal: () => Promise }, - opts: { allowPrompts: boolean } = { allowPrompts: false } -): Promise { - data = uniqBy(data.filter(Boolean), (point) => { - const { key, value } = getKeyValueFromPoint(point); - return `${key}:${value}`; - }); - if (data.length === 0) - throw new Error(`[trigger = "VALUE_CLICK_TRIGGER"][getSingleValue] no value to pick from`); - if (data.length === 1) return Promise.resolve(data[0]); - if (!opts.allowPrompts) return Promise.resolve(data[0]); - return new Promise(async (resolve, reject) => { - const openModal = await deps.getOpenModal(); - const overlay = openModal( - toMountPoint( - overlay.close()} - onSubmit={(point) => { - if (point) { - resolve(point); - } - overlay.close(); - }} - data={data} - /> - ) - ); - overlay.onClose.then(() => reject()); - }); -} - -function GetSingleValuePopup({ - data, - onCancel, - onSubmit, -}: { - data: ValueClickContext['data']['data']; - onCancel: () => void; - onSubmit: (value: ValueClickContext['data']['data'][0]) => void; -}) { - const values = data - .map((point) => { - const { key, value } = getKeyValueFromPoint(point); - return { - point, - id: key ?? '', - label: `${key}:${value}`, - }; - }) - .filter((value) => Boolean(value.id)); - - const [selectedValueId, setSelectedValueId] = React.useState(values[0].id); - - return ( - - - - - - - - - setSelectedValueId(id)} - name="drilldownValues" - /> - - - - - - - onSubmit(values.find((v) => v.id === selectedValueId)?.point!)} - data-test-subj="applySingleValuePopoverButton" - fill - > - - - - - ); -} diff --git a/x-pack/plugins/embeddable_enhanced/public/plugin.ts b/x-pack/plugins/embeddable_enhanced/public/plugin.ts index 187db998e06ea..2138a372523b7 100644 --- a/x-pack/plugins/embeddable_enhanced/public/plugin.ts +++ b/x-pack/plugins/embeddable_enhanced/public/plugin.ts @@ -74,7 +74,6 @@ export class EmbeddableEnhancedPlugin getGlobalScope: urlDrilldownGlobalScopeProvider({ core }), navigateToUrl: (url: string) => core.getStartServices().then(([{ application }]) => application.navigateToUrl(url)), - getOpenModal: () => core.getStartServices().then(([{ overlays }]) => overlays.openModal), getSyntaxHelpDocsLink: () => startServices().core.docLinks.links.dashboard.urlDrilldownTemplateSyntax, getVariablesHelpDocsLink: () => diff --git a/x-pack/plugins/enterprise_search/README.md b/x-pack/plugins/enterprise_search/README.md index 31ee304fe2247..ba14be5564be1 100644 --- a/x-pack/plugins/enterprise_search/README.md +++ b/x-pack/plugins/enterprise_search/README.md @@ -13,6 +13,14 @@ This plugin's goal is to provide a Kibana user interface to the Enterprise Searc 2. Update `config/kibana.dev.yml` with `enterpriseSearch.host: 'http://localhost:3002'` 3. For faster QA/development, run Enterprise Search on [elasticsearch-native auth](https://www.elastic.co/guide/en/app-search/current/security-and-users.html#app-search-self-managed-security-and-user-management-elasticsearch-native-realm) and log in as the `elastic` superuser on Kibana. +### Kea + +Enterprise Search uses [Kea.js](https://github.com/keajs/kea) to manage our React/Redux state for us. Kea state is handled in our `*Logic` files and exposes [values](https://kea.js.org/docs/guide/concepts#values) and [actions](https://kea.js.org/docs/guide/concepts#actions) for our components to get and set state with. + +#### Debugging Kea + +To debug Kea state in-browser, Kea recommends [Redux Devtools](https://kea.js.org/docs/guide/debugging). To facilitate debugging, we use the [path](https://kea.js.org/docs/guide/debugging/#setting-the-path-manually) key with `snake_case`d paths. The path key should always end with the logic filename (e.g. `['enterprise_search', 'some_logic']`) to make it easy for devs to quickly find/jump to files via IDE tooling. + ## Testing ### Unit tests diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index d6a51e8b482d0..5df25f11e5070 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -76,4 +76,6 @@ export const JSON_HEADER = { Accept: 'application/json', // Required for Enterprise Search APIs }; +export const READ_ONLY_MODE_HEADER = 'x-ent-search-read-only-mode'; + export const ENGINES_PAGE_SIZE = 10; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts index 3f71759390879..9388d61041b13 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts @@ -16,6 +16,7 @@ export interface IAppActions { } export const AppLogic = kea>({ + path: ['enterprise_search', 'app_search', 'app_logic'], actions: { initializeAppData: (props) => props, }, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 94e9127bbed74..31c7680fd2f1c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -54,6 +54,7 @@ describe('AppSearchConfigured', () => { const wrapper = shallow(); expect(wrapper.find(Layout)).toHaveLength(1); + expect(wrapper.find(Layout).prop('readOnlyMode')).toBeFalsy(); expect(wrapper.find(EngineOverview)).toHaveLength(1); }); @@ -61,9 +62,9 @@ describe('AppSearchConfigured', () => { const initializeAppData = jest.fn(); (useActions as jest.Mock).mockImplementation(() => ({ initializeAppData })); - shallow(); + shallow(); - expect(initializeAppData).toHaveBeenCalledWith({ readOnlyMode: true }); + expect(initializeAppData).toHaveBeenCalledWith({ ilmEnabled: true }); }); it('does not re-initialize app data', () => { @@ -83,6 +84,14 @@ describe('AppSearchConfigured', () => { expect(wrapper.find(ErrorConnecting)).toHaveLength(1); }); + + it('passes readOnlyMode state', () => { + (useValues as jest.Mock).mockImplementation(() => ({ readOnlyMode: true })); + + const wrapper = shallow(); + + expect(wrapper.find(Layout).prop('readOnlyMode')).toEqual(true); + }); }); describe('AppSearchNav', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index c4a366930d22a..643c4b5ccc873 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -51,7 +51,7 @@ export const AppSearchUnconfigured: React.FC = () => ( export const AppSearchConfigured: React.FC = (props) => { const { hasInitialized } = useValues(AppLogic); const { initializeAppData } = useActions(AppLogic); - const { errorConnecting } = useValues(HttpLogic); + const { errorConnecting, readOnlyMode } = useValues(HttpLogic); useEffect(() => { if (!hasInitialized) initializeAppData(props); @@ -63,7 +63,7 @@ export const AppSearchConfigured: React.FC = (props) => { - }> + } readOnlyMode={readOnlyMode}> {errorConnecting ? ( ) : ( diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index a54295548004a..82f884644be4a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -69,7 +69,11 @@ export const renderApp = ( > - + diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts index 3ae48f352b2c1..37a8f16acad6d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts @@ -32,6 +32,7 @@ const convertToArray = (messages: IFlashMessage | IFlashMessage[]) => !Array.isArray(messages) ? [messages] : messages; export const FlashMessagesLogic = kea>({ + path: ['enterprise_search', 'flash_messages_logic'], actions: { setFlashMessages: (messages) => ({ messages: convertToArray(messages) }), clearFlashMessages: () => null, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts index c032e3b04ebe6..b65499be2f7c0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts @@ -16,6 +16,7 @@ describe('HttpLogic', () => { http: null, httpInterceptors: [], errorConnecting: false, + readOnlyMode: false, }; beforeEach(() => { @@ -31,12 +32,17 @@ describe('HttpLogic', () => { describe('initializeHttp()', () => { it('sets values based on passed props', () => { HttpLogic.mount(); - HttpLogic.actions.initializeHttp({ http: mockHttp, errorConnecting: true }); + HttpLogic.actions.initializeHttp({ + http: mockHttp, + errorConnecting: true, + readOnlyMode: true, + }); expect(HttpLogic.values).toEqual({ http: mockHttp, httpInterceptors: [], errorConnecting: true, + readOnlyMode: true, }); }); }); @@ -52,50 +58,110 @@ describe('HttpLogic', () => { }); }); + describe('setReadOnlyMode()', () => { + it('sets readOnlyMode value', () => { + HttpLogic.mount(); + HttpLogic.actions.setReadOnlyMode(true); + expect(HttpLogic.values.readOnlyMode).toEqual(true); + + HttpLogic.actions.setReadOnlyMode(false); + expect(HttpLogic.values.readOnlyMode).toEqual(false); + }); + }); + describe('http interceptors', () => { describe('initializeHttpInterceptors()', () => { beforeEach(() => { HttpLogic.mount(); jest.spyOn(HttpLogic.actions, 'setHttpInterceptors'); - jest.spyOn(HttpLogic.actions, 'setErrorConnecting'); HttpLogic.actions.initializeHttp({ http: mockHttp }); - HttpLogic.actions.initializeHttpInterceptors(); }); it('calls http.intercept and sets an array of interceptors', () => { - mockHttp.intercept.mockImplementationOnce(() => 'removeInterceptorFn' as any); + mockHttp.intercept + .mockImplementationOnce(() => 'removeErrorInterceptorFn' as any) + .mockImplementationOnce(() => 'removeReadOnlyInterceptorFn' as any); HttpLogic.actions.initializeHttpInterceptors(); expect(mockHttp.intercept).toHaveBeenCalled(); - expect(HttpLogic.actions.setHttpInterceptors).toHaveBeenCalledWith(['removeInterceptorFn']); + expect(HttpLogic.actions.setHttpInterceptors).toHaveBeenCalledWith([ + 'removeErrorInterceptorFn', + 'removeReadOnlyInterceptorFn', + ]); }); describe('errorConnectingInterceptor', () => { + let interceptedResponse: any; + + beforeEach(() => { + interceptedResponse = mockHttp.intercept.mock.calls[0][0].responseError; + jest.spyOn(HttpLogic.actions, 'setErrorConnecting'); + }); + it('handles errors connecting to Enterprise Search', async () => { - const { responseError } = mockHttp.intercept.mock.calls[0][0] as any; - const httpResponse = { response: { url: '/api/app_search/engines', status: 502 } }; - await expect(responseError(httpResponse)).rejects.toEqual(httpResponse); + const httpResponse = { + response: { url: '/api/app_search/engines', status: 502 }, + }; + await expect(interceptedResponse(httpResponse)).rejects.toEqual(httpResponse); expect(HttpLogic.actions.setErrorConnecting).toHaveBeenCalled(); }); it('does not handle non-502 Enterprise Search errors', async () => { - const { responseError } = mockHttp.intercept.mock.calls[0][0] as any; - const httpResponse = { response: { url: '/api/workplace_search/overview', status: 404 } }; - await expect(responseError(httpResponse)).rejects.toEqual(httpResponse); + const httpResponse = { + response: { url: '/api/workplace_search/overview', status: 404 }, + }; + await expect(interceptedResponse(httpResponse)).rejects.toEqual(httpResponse); expect(HttpLogic.actions.setErrorConnecting).not.toHaveBeenCalled(); }); - it('does not handle errors for unrelated calls', async () => { - const { responseError } = mockHttp.intercept.mock.calls[0][0] as any; - const httpResponse = { response: { url: '/api/some_other_plugin/', status: 502 } }; - await expect(responseError(httpResponse)).rejects.toEqual(httpResponse); + it('does not handle errors for non-Enterprise Search API calls', async () => { + const httpResponse = { + response: { url: '/api/some_other_plugin/', status: 502 }, + }; + await expect(interceptedResponse(httpResponse)).rejects.toEqual(httpResponse); expect(HttpLogic.actions.setErrorConnecting).not.toHaveBeenCalled(); }); }); + + describe('readOnlyModeInterceptor', () => { + let interceptedResponse: any; + + beforeEach(() => { + interceptedResponse = mockHttp.intercept.mock.calls[1][0].response; + jest.spyOn(HttpLogic.actions, 'setReadOnlyMode'); + }); + + it('sets readOnlyMode to true if the response header is true', async () => { + const httpResponse = { + response: { url: '/api/app_search/engines', headers: { get: () => 'true' } }, + }; + await expect(interceptedResponse(httpResponse)).resolves.toEqual(httpResponse); + + expect(HttpLogic.actions.setReadOnlyMode).toHaveBeenCalledWith(true); + }); + + it('sets readOnlyMode to false if the response header is false', async () => { + const httpResponse = { + response: { url: '/api/workplace_search/overview', headers: { get: () => 'false' } }, + }; + await expect(interceptedResponse(httpResponse)).resolves.toEqual(httpResponse); + + expect(HttpLogic.actions.setReadOnlyMode).toHaveBeenCalledWith(false); + }); + + it('does not handle headers for non-Enterprise Search API calls', async () => { + const httpResponse = { + response: { url: '/api/some_other_plugin/', headers: { get: () => 'true' } }, + }; + await expect(interceptedResponse(httpResponse)).resolves.toEqual(httpResponse); + + expect(HttpLogic.actions.setReadOnlyMode).not.toHaveBeenCalled(); + }); + }); }); it('sets httpInterceptors and calls all valid remove functions on unmount', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts index ec9db30ddef3b..72380142fe399 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts @@ -6,32 +6,33 @@ import { kea, MakeLogicType } from 'kea'; -import { HttpSetup, HttpInterceptorResponseError } from 'src/core/public'; +import { HttpSetup, HttpInterceptorResponseError, HttpResponse } from 'src/core/public'; +import { IHttpProviderProps } from './http_provider'; + +import { READ_ONLY_MODE_HEADER } from '../../../../common/constants'; export interface IHttpValues { http: HttpSetup; httpInterceptors: Function[]; errorConnecting: boolean; + readOnlyMode: boolean; } export interface IHttpActions { - initializeHttp({ - http, - errorConnecting, - }: { - http: HttpSetup; - errorConnecting?: boolean; - }): { http: HttpSetup; errorConnecting?: boolean }; + initializeHttp({ http, errorConnecting, readOnlyMode }: IHttpProviderProps): IHttpProviderProps; initializeHttpInterceptors(): void; setHttpInterceptors(httpInterceptors: Function[]): { httpInterceptors: Function[] }; setErrorConnecting(errorConnecting: boolean): { errorConnecting: boolean }; + setReadOnlyMode(readOnlyMode: boolean): { readOnlyMode: boolean }; } export const HttpLogic = kea>({ + path: ['enterprise_search', 'http_logic'], actions: { - initializeHttp: ({ http, errorConnecting }) => ({ http, errorConnecting }), + initializeHttp: (props) => props, initializeHttpInterceptors: () => null, setHttpInterceptors: (httpInterceptors) => ({ httpInterceptors }), setErrorConnecting: (errorConnecting) => ({ errorConnecting }), + setReadOnlyMode: (readOnlyMode) => ({ readOnlyMode }), }, reducers: { http: [ @@ -53,6 +54,13 @@ export const HttpLogic = kea>({ setErrorConnecting: (_, { errorConnecting }) => errorConnecting, }, ], + readOnlyMode: [ + false, + { + initializeHttp: (_, { readOnlyMode }) => !!readOnlyMode, + setReadOnlyMode: (_, { readOnlyMode }) => readOnlyMode, + }, + ], }, listeners: ({ values, actions }) => ({ initializeHttpInterceptors: () => { @@ -60,13 +68,13 @@ export const HttpLogic = kea>({ const errorConnectingInterceptor = values.http.intercept({ responseError: async (httpResponse) => { - const { url, status } = httpResponse.response!; - const hasErrorConnecting = status === 502; - const isApiResponse = - url.includes('/api/app_search/') || url.includes('/api/workplace_search/'); + if (isEnterpriseSearchApi(httpResponse)) { + const { status } = httpResponse.response!; + const hasErrorConnecting = status === 502; - if (isApiResponse && hasErrorConnecting) { - actions.setErrorConnecting(true); + if (hasErrorConnecting) { + actions.setErrorConnecting(true); + } } // Re-throw error so that downstream catches work as expected @@ -75,7 +83,23 @@ export const HttpLogic = kea>({ }); httpInterceptors.push(errorConnectingInterceptor); - // TODO: Read only mode interceptor + const readOnlyModeInterceptor = values.http.intercept({ + response: async (httpResponse) => { + if (isEnterpriseSearchApi(httpResponse)) { + const readOnlyMode = httpResponse.response!.headers.get(READ_ONLY_MODE_HEADER); + + if (readOnlyMode === 'true') { + actions.setReadOnlyMode(true); + } else { + actions.setReadOnlyMode(false); + } + } + + return Promise.resolve(httpResponse); + }, + }); + httpInterceptors.push(readOnlyModeInterceptor); + actions.setHttpInterceptors(httpInterceptors); }, }), @@ -87,3 +111,11 @@ export const HttpLogic = kea>({ }, }), }); + +/** + * Small helper that checks whether or not an http call is for an Enterprise Search API + */ +const isEnterpriseSearchApi = (httpResponse: HttpResponse) => { + const { url } = httpResponse.response!; + return url.includes('/api/app_search/') || url.includes('/api/workplace_search/'); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx index 81106235780d6..902c910f10d7c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx @@ -17,6 +17,7 @@ describe('HttpProvider', () => { const props = { http: {} as any, errorConnecting: false, + readOnlyMode: false, }; const initializeHttp = jest.fn(); const initializeHttpInterceptors = jest.fn(); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx index 4c2160195a1af..db1b0d611079a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx @@ -11,9 +11,10 @@ import { HttpSetup } from 'src/core/public'; import { HttpLogic } from './http_logic'; -interface IHttpProviderProps { +export interface IHttpProviderProps { http: HttpSetup; errorConnecting?: boolean; + readOnlyMode?: boolean; } export const HttpProvider: React.FC = (props) => { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss index f6c83888413d3..e867e9cf5a445 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss @@ -81,4 +81,15 @@ padding: $euiSize; } } + + &__readOnlyMode { + margin: -$euiSizeM 0 $euiSizeL; + + @include euiBreakpoint('m') { + margin: 0 0 $euiSizeL; + } + @include euiBreakpoint('xs', 's') { + margin: 0; + } + } } diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx index 623e6e47167d2..7b876d81527fa 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiPageSideBar, EuiButton, EuiPageBody } from '@elastic/eui'; +import { EuiPageSideBar, EuiButton, EuiPageBody, EuiCallOut } from '@elastic/eui'; import { Layout, INavContext } from './layout'; @@ -55,6 +55,12 @@ describe('Layout', () => { expect(wrapper.find(EuiPageSideBar).prop('className')).not.toContain('--isOpen'); }); + it('renders a read-only mode callout', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + }); + it('renders children', () => { const wrapper = shallow( diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx index e122c4d5cfdfa..ef8216e8b6711 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx @@ -7,7 +7,7 @@ import React, { useState } from 'react'; import classNames from 'classnames'; -import { EuiPage, EuiPageSideBar, EuiPageBody, EuiButton } from '@elastic/eui'; +import { EuiPage, EuiPageSideBar, EuiPageBody, EuiButton, EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import './layout.scss'; @@ -15,6 +15,7 @@ import './layout.scss'; interface ILayoutProps { navigation: React.ReactNode; restrictWidth?: boolean; + readOnlyMode?: boolean; } export interface INavContext { @@ -22,7 +23,12 @@ export interface INavContext { } export const NavContext = React.createContext({}); -export const Layout: React.FC = ({ children, navigation, restrictWidth }) => { +export const Layout: React.FC = ({ + children, + navigation, + restrictWidth, + readOnlyMode, +}) => { const [isNavOpen, setIsNavOpen] = useState(false); const toggleNavigation = () => setIsNavOpen(!isNavOpen); const closeNavigation = () => setIsNavOpen(false); @@ -56,6 +62,17 @@ export const Layout: React.FC = ({ children, navigation, restrictW {navigation} + {readOnlyMode && ( + + )} {children} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts index f88a00f63f487..94bd1d529b65f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts @@ -22,6 +22,7 @@ export interface IAppActions { } export const AppLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'app_logic'], actions: { initializeAppData: ({ workplaceSearch, isFederatedAuth }) => ({ workplaceSearch, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx index 39280ad6f4be4..fc1943264d72b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -12,6 +12,7 @@ import { Redirect } from 'react-router-dom'; import { shallow } from 'enzyme'; import { useValues, useActions } from 'kea'; +import { Layout } from '../shared/layout'; import { SetupGuide } from './views/setup_guide'; import { ErrorState } from './views/error_state'; import { Overview } from './views/overview'; @@ -53,6 +54,7 @@ describe('WorkplaceSearchConfigured', () => { it('renders with layout', () => { const wrapper = shallow(); + expect(wrapper.find(Layout).prop('readOnlyMode')).toBeFalsy(); expect(wrapper.find(Overview)).toHaveLength(1); }); @@ -60,9 +62,9 @@ describe('WorkplaceSearchConfigured', () => { const initializeAppData = jest.fn(); (useActions as jest.Mock).mockImplementation(() => ({ initializeAppData })); - shallow(); + shallow(); - expect(initializeAppData).toHaveBeenCalledWith({ readOnlyMode: true }); + expect(initializeAppData).toHaveBeenCalledWith({ isFederatedAuth: true }); }); it('does not re-initialize app data', () => { @@ -82,4 +84,12 @@ describe('WorkplaceSearchConfigured', () => { expect(wrapper.find(ErrorState)).toHaveLength(2); }); + + it('passes readOnlyMode state', () => { + (useValues as jest.Mock).mockImplementation(() => ({ readOnlyMode: true })); + + const wrapper = shallow(); + + expect(wrapper.find(Layout).prop('readOnlyMode')).toEqual(true); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index 6a51b49869eaf..a68dfaf8ea471 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -31,7 +31,7 @@ export const WorkplaceSearch: React.FC = (props) => { export const WorkplaceSearchConfigured: React.FC = (props) => { const { hasInitialized } = useValues(AppLogic); const { initializeAppData } = useActions(AppLogic); - const { errorConnecting } = useValues(HttpLogic); + const { errorConnecting, readOnlyMode } = useValues(HttpLogic); useEffect(() => { if (!hasInitialized) initializeAppData(props); @@ -46,7 +46,7 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { {errorConnecting ? : } - }> + } readOnlyMode={readOnlyMode}> {errorConnecting ? ( ) : ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts index 787d5295db1cf..a156b8a8009f9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts @@ -31,6 +31,7 @@ export interface IOverviewValues extends IOverviewServerData { } export const OverviewLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'overview_logic'], actions: { setServerData: (serverData) => serverData, initializeOverview: () => null, diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts index 0c1e81e3aba46..3d0a3181f8ab8 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts @@ -5,7 +5,7 @@ */ import { mockConfig, mockLogger } from '../__mocks__'; -import { JSON_HEADER } from '../../common/constants'; +import { JSON_HEADER, READ_ONLY_MODE_HEADER } from '../../common/constants'; import { EnterpriseSearchRequestHandler } from './enterprise_search_request_handler'; @@ -18,6 +18,9 @@ const responseMock = { custom: jest.fn(), customError: jest.fn(), }; +const mockExpectedResponseHeaders = { + [READ_ONLY_MODE_HEADER]: 'false', +}; describe('EnterpriseSearchRequestHandler', () => { const enterpriseSearchRequestHandler = new EnterpriseSearchRequestHandler({ @@ -58,6 +61,7 @@ describe('EnterpriseSearchRequestHandler', () => { expect(responseMock.custom).toHaveBeenCalledWith({ body: responseBody, statusCode: 200, + headers: mockExpectedResponseHeaders, }); }); @@ -112,11 +116,12 @@ describe('EnterpriseSearchRequestHandler', () => { await makeAPICall(requestHandler); EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/example'); - expect(responseMock.custom).toHaveBeenCalledWith({ body: {}, statusCode: 201 }); + expect(responseMock.custom).toHaveBeenCalledWith({ + body: {}, + statusCode: 201, + headers: mockExpectedResponseHeaders, + }); }); - - // TODO: It's possible we may also pass back headers at some point - // from Enterprise Search, e.g. the x-read-only mode header }); }); @@ -140,6 +145,7 @@ describe('EnterpriseSearchRequestHandler', () => { message: 'some error message', attributes: { errors: ['some error message'] }, }, + headers: mockExpectedResponseHeaders, }); }); @@ -156,6 +162,7 @@ describe('EnterpriseSearchRequestHandler', () => { message: 'one,two,three', attributes: { errors: ['one', 'two', 'three'] }, }, + headers: mockExpectedResponseHeaders, }); }); @@ -171,6 +178,7 @@ describe('EnterpriseSearchRequestHandler', () => { message: 'Bad Request', attributes: { errors: ['Bad Request'] }, }, + headers: mockExpectedResponseHeaders, }); }); @@ -186,6 +194,7 @@ describe('EnterpriseSearchRequestHandler', () => { message: 'Bad Request', attributes: { errors: ['Bad Request'] }, }, + headers: mockExpectedResponseHeaders, }); }); @@ -201,6 +210,7 @@ describe('EnterpriseSearchRequestHandler', () => { message: 'Not Found', attributes: { errors: ['Not Found'] }, }, + headers: mockExpectedResponseHeaders, }); }); }); @@ -215,12 +225,33 @@ describe('EnterpriseSearchRequestHandler', () => { expect(responseMock.customError).toHaveBeenCalledWith({ statusCode: 502, body: expect.stringContaining('Enterprise Search encountered an internal server error'), + headers: mockExpectedResponseHeaders, }); expect(mockLogger.error).toHaveBeenCalledWith( 'Enterprise Search Server Error 500 at : "something crashed!"' ); }); + it('handleReadOnlyModeError()', async () => { + EnterpriseSearchAPI.mockReturn( + { errors: ['Read only mode'] }, + { status: 503, headers: { ...JSON_HEADER, [READ_ONLY_MODE_HEADER]: 'true' } } + ); + const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/api/503' }); + + await makeAPICall(requestHandler); + EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/503'); + + expect(responseMock.customError).toHaveBeenCalledWith({ + statusCode: 503, + body: expect.stringContaining('Enterprise Search is in read-only mode'), + headers: { [READ_ONLY_MODE_HEADER]: 'true' }, + }); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Cannot perform action: Enterprise Search is in read-only mode. Actions that create, update, or delete information are disabled.' + ); + }); + it('handleInvalidDataError()', async () => { EnterpriseSearchAPI.mockReturn({ results: false }); const requestHandler = enterpriseSearchRequestHandler.createRequest({ @@ -234,6 +265,7 @@ describe('EnterpriseSearchRequestHandler', () => { expect(responseMock.customError).toHaveBeenCalledWith({ statusCode: 502, body: 'Invalid data received from Enterprise Search', + headers: mockExpectedResponseHeaders, }); expect(mockLogger.error).toHaveBeenCalledWith( 'Invalid data received from : {"results":false}' @@ -250,6 +282,7 @@ describe('EnterpriseSearchRequestHandler', () => { expect(responseMock.customError).toHaveBeenCalledWith({ statusCode: 502, body: 'Error connecting to Enterprise Search: Failed', + headers: mockExpectedResponseHeaders, }); expect(mockLogger.error).toHaveBeenCalled(); }); @@ -265,6 +298,7 @@ describe('EnterpriseSearchRequestHandler', () => { expect(responseMock.customError).toHaveBeenCalledWith({ statusCode: 502, body: 'Cannot authenticate Enterprise Search user', + headers: mockExpectedResponseHeaders, }); expect(mockLogger.error).toHaveBeenCalled(); }); @@ -279,6 +313,18 @@ describe('EnterpriseSearchRequestHandler', () => { }); }); + it('setResponseHeaders', async () => { + EnterpriseSearchAPI.mockReturn('anything' as any, { + headers: { [READ_ONLY_MODE_HEADER]: 'true' }, + }); + const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/' }); + await makeAPICall(requestHandler); + + expect(enterpriseSearchRequestHandler.headers).toEqual({ + [READ_ONLY_MODE_HEADER]: 'true', + }); + }); + it('isEmptyObj', async () => { expect(enterpriseSearchRequestHandler.isEmptyObj({})).toEqual(true); expect(enterpriseSearchRequestHandler.isEmptyObj({ empty: false })).toEqual(false); @@ -304,9 +350,10 @@ const EnterpriseSearchAPI = { ...expectedParams, }); }, - mockReturn(response: object, options?: object) { + mockReturn(response: object, options?: any) { fetchMock.mockImplementation(() => { - return Promise.resolve(new Response(JSON.stringify(response), options)); + const headers = Object.assign({}, mockExpectedResponseHeaders, options?.headers); + return Promise.resolve(new Response(JSON.stringify(response), { ...options, headers })); }); }, mockReturnError() { diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts index 00d5eaf5d6a83..6b65c16c832fd 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts @@ -14,7 +14,7 @@ import { Logger, } from 'src/core/server'; import { ConfigType } from '../index'; -import { JSON_HEADER } from '../../common/constants'; +import { JSON_HEADER, READ_ONLY_MODE_HEADER } from '../../common/constants'; interface IConstructorDependencies { config: ConfigType; @@ -46,6 +46,7 @@ export interface IEnterpriseSearchRequestHandler { export class EnterpriseSearchRequestHandler { private enterpriseSearchUrl: string; private log: Logger; + private headers: Record = {}; constructor({ config, log }: IConstructorDependencies) { this.log = log; @@ -80,6 +81,9 @@ export class EnterpriseSearchRequestHandler { // Call the Enterprise Search API const apiResponse = await fetch(url, { method, headers, body }); + // Handle response headers + this.setResponseHeaders(apiResponse); + // Handle authentication redirects if (apiResponse.url.endsWith('/login') || apiResponse.url.endsWith('/ent/select')) { return this.handleAuthenticationError(response); @@ -88,7 +92,13 @@ export class EnterpriseSearchRequestHandler { // Handle 400-500+ responses from the Enterprise Search server const { status } = apiResponse; if (status >= 500) { - return this.handleServerError(response, apiResponse, url); + if (this.headers[READ_ONLY_MODE_HEADER] === 'true') { + // Handle 503 read-only mode errors + return this.handleReadOnlyModeError(response); + } else { + // Handle unexpected server errors + return this.handleServerError(response, apiResponse, url); + } } else if (status >= 400) { return this.handleClientError(response, apiResponse); } @@ -100,7 +110,11 @@ export class EnterpriseSearchRequestHandler { } // Pass successful responses back to the front-end - return response.custom({ statusCode: status, body: json }); + return response.custom({ + statusCode: status, + headers: this.headers, + body: json, + }); } catch (e) { // Catch connection/auth errors return this.handleConnectionError(response, e); @@ -160,7 +174,7 @@ export class EnterpriseSearchRequestHandler { const { status } = apiResponse; const body = await this.getErrorResponseBody(apiResponse); - return response.customError({ statusCode: status, body }); + return response.customError({ statusCode: status, headers: this.headers, body }); } async handleServerError(response: KibanaResponseFactory, apiResponse: Response, url: string) { @@ -172,14 +186,22 @@ export class EnterpriseSearchRequestHandler { 'Enterprise Search encountered an internal server error. Please contact your system administrator if the problem persists.'; this.log.error(`Enterprise Search Server Error ${status} at <${url}>: ${message}`); - return response.customError({ statusCode: 502, body: errorMessage }); + return response.customError({ statusCode: 502, headers: this.headers, body: errorMessage }); + } + + handleReadOnlyModeError(response: KibanaResponseFactory) { + const errorMessage = + 'Enterprise Search is in read-only mode. Actions that create, update, or delete information are disabled.'; + + this.log.error(`Cannot perform action: ${errorMessage}`); + return response.customError({ statusCode: 503, headers: this.headers, body: errorMessage }); } handleInvalidDataError(response: KibanaResponseFactory, url: string, json: object) { const errorMessage = 'Invalid data received from Enterprise Search'; this.log.error(`Invalid data received from <${url}>: ${JSON.stringify(json)}`); - return response.customError({ statusCode: 502, body: errorMessage }); + return response.customError({ statusCode: 502, headers: this.headers, body: errorMessage }); } handleConnectionError(response: KibanaResponseFactory, e: Error) { @@ -188,14 +210,26 @@ export class EnterpriseSearchRequestHandler { this.log.error(errorMessage); if (e instanceof Error) this.log.debug(e.stack as string); - return response.customError({ statusCode: 502, body: errorMessage }); + return response.customError({ statusCode: 502, headers: this.headers, body: errorMessage }); } handleAuthenticationError(response: KibanaResponseFactory) { const errorMessage = 'Cannot authenticate Enterprise Search user'; this.log.error(errorMessage); - return response.customError({ statusCode: 502, body: errorMessage }); + return response.customError({ statusCode: 502, headers: this.headers, body: errorMessage }); + } + + /** + * Set response headers + * + * Currently just forwards the read-only mode header, but we can expand this + * in the future to pass more headers from Enterprise Search as we need them + */ + + setResponseHeaders(apiResponse: Response) { + const readOnlyMode = apiResponse.headers.get(READ_ONLY_MODE_HEADER); + this.headers[READ_ONLY_MODE_HEADER] = readOnlyMode as 'true' | 'false'; } /** diff --git a/x-pack/plugins/features/server/feature_registry.test.ts b/x-pack/plugins/features/server/feature_registry.test.ts index 24aae3a69ee5d..e89cf06ec8621 100644 --- a/x-pack/plugins/features/server/feature_registry.test.ts +++ b/x-pack/plugins/features/server/feature_registry.test.ts @@ -57,7 +57,7 @@ describe('FeatureRegistry', () => { read: { savedObject: { all: [], - read: ['config', 'url'], + read: ['config', 'url', 'telemetry'], }, ui: [], }, @@ -230,7 +230,7 @@ describe('FeatureRegistry', () => { expect(allPrivilege?.savedObject.all).toEqual(['telemetry']); }); - it(`automatically grants 'read' access to config and url saved objects for both privileges`, () => { + it(`automatically grants access to config, url, and telemetry saved objects`, () => { const feature: KibanaFeatureConfig = { id: 'test-feature', name: 'Test Feature', @@ -263,7 +263,7 @@ describe('FeatureRegistry', () => { const allPrivilege = result[0].privileges?.all; const readPrivilege = result[0].privileges?.read; expect(allPrivilege?.savedObject.read).toEqual(['config', 'url']); - expect(readPrivilege?.savedObject.read).toEqual(['config', 'url']); + expect(readPrivilege?.savedObject.read).toEqual(['config', 'telemetry', 'url']); }); it(`automatically grants 'all' access to telemetry and 'read' to [config, url] saved objects for the reserved privilege`, () => { @@ -332,7 +332,7 @@ describe('FeatureRegistry', () => { const readPrivilege = result[0].privileges!.read; expect(allPrivilege?.savedObject.all).toEqual(['telemetry']); expect(allPrivilege?.savedObject.read).toEqual(['config', 'url']); - expect(readPrivilege?.savedObject.read).toEqual(['config', 'url']); + expect(readPrivilege?.savedObject.read).toEqual(['config', 'url', 'telemetry']); }); it(`does not allow duplicate features to be registered`, () => { diff --git a/x-pack/plugins/features/server/feature_registry.ts b/x-pack/plugins/features/server/feature_registry.ts index d357bdb782797..e9e556ba22fd2 100644 --- a/x-pack/plugins/features/server/feature_registry.ts +++ b/x-pack/plugins/features/server/feature_registry.ts @@ -97,7 +97,12 @@ function applyAutomaticReadPrivilegeGrants( ) { readPrivileges.forEach((readPrivilege) => { if (readPrivilege) { - readPrivilege.savedObject.read = uniq([...readPrivilege.savedObject.read, 'config', 'url']); + readPrivilege.savedObject.read = uniq([ + ...readPrivilege.savedObject.read, + 'config', + 'telemetry', + 'url', + ]); } }); } diff --git a/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap b/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap index 0217f039e08ba..7bb9954fa3048 100644 --- a/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap +++ b/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap @@ -7,8 +7,13 @@ Array [ "className": "euiSelectableTemplateSitewide__listItem", "key": "Canvas", "label": "Canvas", + "meta": Array [ + Object { + "text": "Kibana", + }, + ], "prepend": undefined, - "title": "Canvasundefinedundefined", + "title": "Canvas • Kibana", "url": "/app/test/Canvas", }, Object { @@ -16,8 +21,13 @@ Array [ "className": "euiSelectableTemplateSitewide__listItem", "key": "Discover", "label": "Discover", + "meta": Array [ + Object { + "text": "Kibana", + }, + ], "prepend": undefined, - "title": "Discoverundefinedundefined", + "title": "Discover • Kibana", "url": "/app/test/Discover", }, Object { @@ -25,8 +35,13 @@ Array [ "className": "euiSelectableTemplateSitewide__listItem", "key": "Graph", "label": "Graph", + "meta": Array [ + Object { + "text": "Kibana", + }, + ], "prepend": undefined, - "title": "Graphundefinedundefined", + "title": "Graph • Kibana", "url": "/app/test/Graph", }, ] @@ -39,8 +54,13 @@ Array [ "className": "euiSelectableTemplateSitewide__listItem", "key": "Discover", "label": "Discover", + "meta": Array [ + Object { + "text": "Kibana", + }, + ], "prepend": undefined, - "title": "Discoverundefinedundefined", + "title": "Discover • Kibana", "url": "/app/test/Discover", }, Object { diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx index 0d1e8725b4911..11fbc7931e620 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx @@ -21,6 +21,7 @@ type Result = { id: string; type: string } | string; const createResult = (result: Result): GlobalSearchResult => { const id = typeof result === 'string' ? result : result.id; const type = typeof result === 'string' ? 'application' : result.type; + const meta = type === 'application' ? { categoryLabel: 'Kibana' } : { categoryLabel: null }; return { id, @@ -28,6 +29,7 @@ const createResult = (result: Result): GlobalSearchResult => { title: id, url: `/app/test/${id}`, score: 42, + meta, }; }; @@ -74,7 +76,7 @@ describe('SearchBar', () => { expect(findSpy).toHaveBeenCalledTimes(1); expect(findSpy).toHaveBeenCalledWith('', {}); expect(getSelectableProps(component).options).toMatchSnapshot(); - await wait(() => getSearchProps(component).onSearch('d')); + await wait(() => getSearchProps(component).onKeyUpCapture({ currentTarget: { value: 'd' } })); jest.runAllTimers(); component.update(); expect(getSelectableProps(component).options).toMatchSnapshot(); diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx index d00349e21a7e4..e41f9243198ad 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx @@ -52,14 +52,20 @@ export function SearchBar({ globalSearch, navigateToUrl }: Props) { if (!isMounted()) return; _setOptions([ - ..._options.map((option) => ({ - key: option.id, - label: option.title, - url: option.url, - ...(option.icon && { icon: { type: option.icon } }), - ...(option.type && - option.type !== 'application' && { meta: [{ text: cleanMeta(option.type) }] }), - })), + ..._options.map(({ id, title, url, icon, type, meta }) => { + const option: EuiSelectableTemplateSitewideOption = { + key: id, + label: title, + url, + }; + + if (icon) option.icon = { type: icon }; + + if (type === 'application') option.meta = [{ text: meta?.categoryLabel as string }]; + else option.meta = [{ text: cleanMeta(type) }]; + + return option; + }), ]); }, [isMounted, _setOptions] @@ -133,7 +139,8 @@ export function SearchBar({ globalSearch, navigateToUrl }: Props) { onChange={onChange} options={options} searchProps={{ - onSearch: setSearchValue, + onKeyUpCapture: (e: React.KeyboardEvent) => + setSearchValue(e.currentTarget.value), 'data-test-subj': 'header-search', inputRef: setSearchRef, compressed: true, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index.ts index a2d5c7c8d5308..b3bf071948956 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index.ts @@ -69,6 +69,8 @@ export * from './other_type_name_parameter'; export * from './other_type_json_parameter'; +export * from './meta_parameter'; + export * from './ignore_above_parameter'; export const PARAMETER_SERIALIZERS = [relationsSerializer, dynamicSerializer]; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/meta_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/meta_parameter.tsx new file mode 100644 index 0000000000000..c8af296318b61 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/meta_parameter.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { documentationService } from '../../../../../services/documentation'; +import { UseField, JsonEditorField } from '../../../shared_imports'; +import { getFieldConfig } from '../../../lib'; +import { EditFieldFormRow } from '../fields/edit_field'; + +interface Props { + defaultToggleValue: boolean; +} + +export const MetaParameter: FunctionComponent = ({ defaultToggleValue }) => ( + + + +); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/binary_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/binary_type.tsx index ba9c75baa1987..1550485ebad93 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/binary_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/binary_type.tsx @@ -5,14 +5,25 @@ */ import React from 'react'; -import { StoreParameter, DocValuesParameter } from '../../field_parameters'; +import { NormalizedField, ParameterName, Field as FieldType } from '../../../../types'; +import { getFieldConfig } from '../../../../lib'; +import { StoreParameter, DocValuesParameter, MetaParameter } from '../../field_parameters'; import { AdvancedParametersSection } from '../edit_field'; -export const BinaryType = () => { +const getDefaultToggleValue = (param: ParameterName, field: FieldType) => { + return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; +}; + +interface Props { + field: NormalizedField; +} + +export const BinaryType = ({ field }: Props) => { return ( + ); }; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/boolean_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/boolean_type.tsx index 962606b2f4ffd..1ee2bf22edb44 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/boolean_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/boolean_type.tsx @@ -16,11 +16,13 @@ import { DocValuesParameter, BoostParameter, NullValueParameter, + MetaParameter, } from '../../field_parameters'; import { BasicParametersSection, AdvancedParametersSection } from '../edit_field'; const getDefaultToggleValue = (param: string, field: FieldType) => { switch (param) { + case 'meta': case 'boost': { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; } @@ -90,6 +92,8 @@ export const BooleanType = ({ field }: Props) => { + + diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/completion_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/completion_type.tsx index 74331cb1b6b22..748dc54838270 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/completion_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/completion_type.tsx @@ -10,11 +10,12 @@ import { i18n } from '@kbn/i18n'; import { NormalizedField, Field as FieldType } from '../../../../types'; import { getFieldConfig } from '../../../../lib'; import { UseField, Field } from '../../../../shared_imports'; -import { AnalyzersParameter } from '../../field_parameters'; +import { AnalyzersParameter, MetaParameter } from '../../field_parameters'; import { EditFieldFormRow, AdvancedParametersSection } from '../edit_field'; const getDefaultToggleValue = (param: string, field: FieldType) => { switch (param) { + case 'meta': case 'max_input_length': { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; } @@ -88,6 +89,8 @@ export const CompletionType = ({ field }: Props) => { )} formFieldPath="preserve_position_increments" /> + + ); }; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/constant_keyword_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/constant_keyword_type.tsx new file mode 100644 index 0000000000000..aa8aefba921e7 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/constant_keyword_type.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { UseField, Field } from '../../../../shared_imports'; +import { getFieldConfig } from '../../../../lib'; +import { NormalizedField, Field as FieldType, ParameterName } from '../../../../types'; +import { MetaParameter } from '../../field_parameters'; +import { AdvancedParametersSection, EditFieldFormRow, BasicParametersSection } from '../edit_field'; + +interface Props { + field: NormalizedField; +} + +const getDefaultToggleValue = (param: ParameterName, field: FieldType) => { + return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; +}; + +export const ConstantKeywordType: FunctionComponent = ({ field }) => { + return ( + <> + + {/* Value field */} + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/date_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/date_type.tsx index 0c067d09046d7..35382506a3cd9 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/date_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/date_type.tsx @@ -19,6 +19,7 @@ import { IgnoreMalformedParameter, FormatParameter, LocaleParameter, + MetaParameter, } from '../../field_parameters'; import { BasicParametersSection, AdvancedParametersSection } from '../edit_field'; @@ -26,6 +27,7 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { switch (param) { case 'locale': case 'format': + case 'meta': case 'boost': { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; } @@ -73,6 +75,8 @@ export const DateType = ({ field }: Props) => { + + diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/flattened_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/flattened_type.tsx index e96426ece27e8..b1545d44885c8 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/flattened_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/flattened_type.tsx @@ -18,6 +18,7 @@ import { NullValueParameter, SimilarityParameter, SplitQueriesOnWhitespaceParameter, + MetaParameter, IgnoreAboveParameter, } from '../../field_parameters'; import { BasicParametersSection, EditFieldFormRow, AdvancedParametersSection } from '../edit_field'; @@ -30,6 +31,7 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { switch (param) { case 'boost': case 'ignore_above': + case 'meta': case 'similarity': { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; } @@ -83,6 +85,8 @@ export const FlattenedType = React.memo(({ field }: Props) => { defaultToggleValue={getDefaultToggleValue('null_value', field.source)} /> + + diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/geo_point_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/geo_point_type.tsx index 997e866da35f0..0f28c5080d26d 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/geo_point_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/geo_point_type.tsx @@ -14,11 +14,14 @@ import { IgnoreMalformedParameter, NullValueParameter, IgnoreZValueParameter, + MetaParameter, } from '../../field_parameters'; import { BasicParametersSection, AdvancedParametersSection } from '../edit_field'; const getDefaultToggleValue = (param: string, field: FieldType) => { switch (param) { + case 'meta': + return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; case 'null_value': { return field.null_value !== undefined; } @@ -65,6 +68,8 @@ export const GeoPointType = ({ field }: Props) => { config={getFieldConfig('null_value_geo_point')} /> + + ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/histogram_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/histogram_type.tsx new file mode 100644 index 0000000000000..1ff97a8d72a21 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/histogram_type.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +import { NormalizedField, Field as FieldType, ParameterName } from '../../../../types'; +import { getFieldConfig } from '../../../../lib'; +import { IgnoreMalformedParameter, MetaParameter } from '../../field_parameters'; +import { AdvancedParametersSection } from '../edit_field'; + +interface Props { + field: NormalizedField; +} + +const getDefaultToggleValue = (param: ParameterName, field: FieldType) => { + return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; +}; + +export const HistogramType = ({ field }: Props) => { + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts index d84d9c6ea40cf..8fcd02e4a362e 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts @@ -28,6 +28,8 @@ import { ObjectType } from './object_type'; import { OtherType } from './other_type'; import { NestedType } from './nested_type'; import { JoinType } from './join_type'; +import { HistogramType } from './histogram_type'; +import { ConstantKeywordType } from './constant_keyword_type'; import { RankFeatureType } from './rank_feature_type'; import { WildcardType } from './wildcard_type'; @@ -54,6 +56,8 @@ const typeToParametersFormMap: { [key in DataType]?: ComponentType } = { other: OtherType, nested: NestedType, join: JoinType, + histogram: HistogramType, + constant_keyword: ConstantKeywordType, rank_feature: RankFeatureType, wildcard: WildcardType, }; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/numeric_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/numeric_type.tsx index 3d78205934eea..6ad3c9c5d0bd4 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/numeric_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/numeric_type.tsx @@ -18,6 +18,7 @@ import { CoerceNumberParameter, IgnoreMalformedParameter, CopyToParameter, + MetaParameter, } from '../../field_parameters'; import { BasicParametersSection, EditFieldFormRow, AdvancedParametersSection } from '../edit_field'; import { PARAMETERS_DEFINITION } from '../../../../constants'; @@ -26,6 +27,7 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { switch (param) { case 'copy_to': case 'boost': + case 'meta': case 'ignore_malformed': { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; } @@ -95,6 +97,8 @@ export const NumericType = ({ field }: Props) => { + + diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/range_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/range_type.tsx index f87d1f9400101..9a37f55ac8e9d 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/range_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/range_type.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { NormalizedField, Field as FieldType } from '../../../../types'; +import { NormalizedField, Field as FieldType, ParameterName } from '../../../../types'; import { getFieldConfig } from '../../../../lib'; import { StoreParameter, @@ -14,11 +14,12 @@ import { CoerceNumberParameter, FormatParameter, LocaleParameter, + MetaParameter, } from '../../field_parameters'; import { BasicParametersSection, AdvancedParametersSection } from '../edit_field'; import { FormDataProvider } from '../../../../shared_imports'; -const getDefaultToggleValue = (param: 'locale' | 'format' | 'boost', field: FieldType) => { +const getDefaultToggleValue = (param: ParameterName, field: FieldType) => { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; }; @@ -57,6 +58,8 @@ export const RangeType = ({ field }: Props) => { + + diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/search_as_you_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/search_as_you_type.tsx index dafbebd24b3fa..3fa456c33f5e9 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/search_as_you_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/search_as_you_type.tsx @@ -15,6 +15,7 @@ import { SimilarityParameter, TermVectorParameter, MaxShingleSizeParameter, + MetaParameter, } from '../../field_parameters'; import { BasicParametersSection, AdvancedParametersSection } from '../edit_field'; @@ -26,6 +27,7 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { switch (param) { case 'similarity': case 'term_vector': + case 'meta': case 'max_shingle_size': { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; } @@ -65,6 +67,8 @@ export const SearchAsYouType = React.memo(({ field }: Props) => { /> + + ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/text_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/text_type.tsx index c4ed11097b609..07def791096e7 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/text_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/text_type.tsx @@ -28,6 +28,7 @@ import { CopyToParameter, TermVectorParameter, FieldDataParameter, + MetaParameter, } from '../../field_parameters'; import { BasicParametersSection, EditFieldFormRow, AdvancedParametersSection } from '../edit_field'; @@ -40,6 +41,7 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { case 'boost': case 'position_increment_gap': case 'similarity': + case 'meta': case 'term_vector': { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; } @@ -47,7 +49,7 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { return field.search_analyzer !== undefined && field.search_analyzer !== field.analyzer; } case 'copy_to': { - return field.null_value !== undefined && field.null_value !== ''; + return field[param] !== undefined && field[param] !== ''; } case 'indexPrefixes': { if (field.index_prefixes === undefined) { @@ -241,6 +243,8 @@ export const TextType = React.memo(({ field }: Props) => { + + diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/token_count_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/token_count_type.tsx index 42854673269ae..5cc2addba53b8 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/token_count_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/token_count_type.tsx @@ -20,12 +20,14 @@ import { BoostParameter, AnalyzerParameter, NullValueParameter, + MetaParameter, } from '../../field_parameters'; import { BasicParametersSection, EditFieldFormRow, AdvancedParametersSection } from '../edit_field'; const getDefaultToggleValue = (param: string, field: FieldType) => { switch (param) { case 'analyzer': + case 'meta': case 'boost': { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; } @@ -107,6 +109,8 @@ export const TokenCountType = ({ field }: Props) => { + + diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx index a8844c7a9b270..a4d3bf3832d5c 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx @@ -71,6 +71,26 @@ export const TYPE_DEFINITION: { [key in DataType]: DataTypeDefinition } = {

), }, + constant_keyword: { + value: 'constant_keyword', + label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.constantKeywordDescription', { + defaultMessage: 'Constant keyword', + }), + documentation: { + main: '/keyword.html#constant-keyword-field-type', + }, + description: () => ( +

+ {'keyword'}, + }} + /> +

+ ), + }, numeric: { value: 'numeric', label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.numericDescription', { @@ -699,6 +719,23 @@ export const TYPE_DEFINITION: { [key in DataType]: DataTypeDefinition } = {

), }, + histogram: { + label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.histogramDescription', { + defaultMessage: 'Histogram', + }), + value: 'histogram', + documentation: { + main: '/histogram.html', + }, + description: () => ( +

+ +

+ ), + }, join: { label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.joinDescription', { defaultMessage: 'Join', @@ -822,6 +859,7 @@ export const MAIN_TYPES: MainType[] = [ 'binary', 'boolean', 'completion', + 'constant_keyword', 'date', 'date_nanos', 'dense_vector', @@ -842,6 +880,7 @@ export const MAIN_TYPES: MainType[] = [ 'shape', 'text', 'token_count', + 'histogram', 'wildcard', 'other', ]; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx index f2148f1f657a6..fd17dc1b8fd1e 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx @@ -29,7 +29,7 @@ import { INDEX_DEFAULT } from './default_values'; import { TYPE_DEFINITION } from './data_types_definition'; const { toInt } = fieldFormatters; -const { emptyField, containsCharsField, numberGreaterThanField } = fieldValidators; +const { emptyField, containsCharsField, numberGreaterThanField, isJsonField } = fieldValidators; const commonErrorMessages = { smallerThanZero: i18n.translate( @@ -404,6 +404,88 @@ export const PARAMETERS_DEFINITION: { [key in ParameterName]: ParameterDefinitio }, schema: t.string, }, + value: { + fieldConfig: { + defaultValue: '', + type: FIELD_TYPES.TEXT, + label: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.valueLabel', { + defaultMessage: 'Value', + }), + }, + schema: t.string, + }, + meta: { + fieldConfig: { + defaultValue: '', + label: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.metaLabel', { + defaultMessage: 'Metadata', + }), + helpText: ( + {JSON.stringify({ arbitrary_key: 'anything_goes' })}, + }} + /> + ), + validations: [ + { + validator: isJsonField( + i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.metaFieldEditorJsonError', { + defaultMessage: 'Invalid JSON.', + }), + { allowEmptyString: true } + ), + }, + { + validator: ({ value }: ValidationFuncArg) => { + if (typeof value !== 'string' || value.trim() === '') { + return; + } + + const json = JSON.parse(value); + const valuesAreNotString = Object.values(json).some((v) => typeof v !== 'string'); + + if (Array.isArray(json)) { + return { + message: i18n.translate( + 'xpack.idxMgmt.mappingsEditor.parameters.metaFieldEditorArraysNotAllowedError', + { + defaultMessage: 'Arrays are not allowed.', + } + ), + }; + } else if (valuesAreNotString) { + return { + message: i18n.translate( + 'xpack.idxMgmt.mappingsEditor.parameters.metaFieldEditorOnlyStringValuesAllowedError', + { + defaultMessage: 'Values must be a string.', + } + ), + }; + } + }, + }, + ], + deserializer: (value: any) => { + if (value === '') { + return value; + } + return JSON.stringify(value, null, 2); + }, + serializer: (value: string) => { + const parsed = JSON.parse(value); + // If an empty object was passed, strip out this value entirely. + if (!Object.keys(parsed).length) { + return undefined; + } + return parsed; + }, + }, + schema: t.any, + }, max_input_length: { fieldConfig: { defaultValue: 50, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts index fd0e4ed32bfe8..97dca49fc93ed 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts @@ -59,6 +59,8 @@ export type MainType = | 'geo_point' | 'geo_shape' | 'token_count' + | 'histogram' + | 'constant_keyword' | 'wildcard' /** * 'other' is a special type that only exists inside of MappingsEditor as a placeholder @@ -146,7 +148,9 @@ export type ParameterName = | 'dims' | 'depth_limit' | 'relations' - | 'max_shingle_size'; + | 'max_shingle_size' + | 'value' + | 'meta'; export interface Parameter { fieldConfig: FieldConfig; diff --git a/x-pack/plugins/index_management/public/application/services/documentation.ts b/x-pack/plugins/index_management/public/application/services/documentation.ts index afc9c76f1afbe..c52b958d94ae1 100644 --- a/x-pack/plugins/index_management/public/application/services/documentation.ts +++ b/x-pack/plugins/index_management/public/application/services/documentation.ts @@ -123,6 +123,10 @@ class DocumentationService { return `${this.esDocsBase}/ignore-malformed.html`; } + public getMetaLink() { + return `${this.esDocsBase}/mapping-field-meta.html`; + } + public getFormatLink() { return `${this.esDocsBase}/mapping-date-format.html`; } diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts index 605e4db230ce5..b9789b770eb2e 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts @@ -30,6 +30,7 @@ import { import * as AgentService from '../../services/agents'; import * as APIKeyService from '../../services/api_keys'; import { appContextService } from '../../services/app_context'; +import { defaultIngestErrorHandler } from '../../errors'; export const getAgentHandler: RequestHandler = ({ onClick={() => { setOpen(!open); }} + title={title} hasArrow={false} isDisabled={isDisabled} groupPosition={groupPosition} diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx index 7e2e8f0453588..2114d63fcfacd 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { mountWithIntl as mount, shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; import { EuiButtonGroupProps, EuiSuperSelect, EuiButtonGroup } from '@elastic/eui'; -import { LayerContextMenu, XyToolbar } from './xy_config_panel'; +import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel'; import { ToolbarPopover } from '../shared_components'; import { AxisSettingsPopover } from './axis_settings_popover'; import { FramePublicAPI } from '../types'; @@ -171,4 +171,48 @@ describe('XY Config panels', () => { expect(component.find(AxisSettingsPopover).length).toEqual(3); }); }); + + describe('Dimension Editor', () => { + test('shows the correct axis side options when in horizontal mode', () => { + const state = testState(); + const component = mount( + + ); + + const options = component + .find(EuiButtonGroup) + .first() + .prop('options') as EuiButtonGroupProps['options']; + + expect(options!.map(({ label }) => label)).toEqual(['Auto', 'Bottom', 'Top']); + }); + + test('shows the default axis side options when not in horizontal mode', () => { + const state = testState(); + const component = mount( + + ); + + const options = component + .find(EuiButtonGroup) + .first() + .prop('options') as EuiButtonGroupProps['options']; + + expect(options!.map(({ label }) => label)).toEqual(['Auto', 'Left', 'Right']); + }); + }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index bc98bf53d9f12..4aa5bd62c05a5 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -274,9 +274,15 @@ export function XyToolbar(props: VisualizationToolbarProps) { group.groupId === 'left') || {}).length === 0 } @@ -310,9 +316,15 @@ export function XyToolbar(props: VisualizationToolbarProps) { toggleAxisTitleVisibility={onAxisTitlesVisibilitySettingsChange} /> group.groupId === 'right') || {}).length === 0 } @@ -345,6 +357,7 @@ export function DimensionEditor(props: VisualizationDimensionEditorProps) const { state, setState, layerId, accessor } = props; const index = state.layers.findIndex((l) => l.layerId === layerId); const layer = state.layers[index]; + const isHorizontal = isHorizontalChart(state.layers); const axisMode = (layer.yConfig && layer.yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === accessor)?.axisMode) || @@ -377,15 +390,23 @@ export function DimensionEditor(props: VisualizationDimensionEditorProps) }, { id: `${idPrefix}left`, - label: i18n.translate('xpack.lens.xyChart.axisSide.left', { - defaultMessage: 'Left', - }), + label: isHorizontal + ? i18n.translate('xpack.lens.xyChart.axisSide.bottom', { + defaultMessage: 'Bottom', + }) + : i18n.translate('xpack.lens.xyChart.axisSide.left', { + defaultMessage: 'Left', + }), }, { id: `${idPrefix}right`, - label: i18n.translate('xpack.lens.xyChart.axisSide.right', { - defaultMessage: 'Right', - }), + label: isHorizontal + ? i18n.translate('xpack.lens.xyChart.axisSide.top', { + defaultMessage: 'Top', + }) + : i18n.translate('xpack.lens.xyChart.axisSide.right', { + defaultMessage: 'Right', + }), }, ]} idSelected={`${idPrefix}${axisMode}`} diff --git a/x-pack/plugins/ml/common/constants/app.ts b/x-pack/plugins/ml/common/constants/app.ts index 97dd7a7b0fef5..3d54e9e150fef 100644 --- a/x-pack/plugins/ml/common/constants/app.ts +++ b/x-pack/plugins/ml/common/constants/app.ts @@ -4,6 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; + export const PLUGIN_ID = 'ml'; export const PLUGIN_ICON = 'machineLearningApp'; export const PLUGIN_ICON_SOLUTION = 'logoKibana'; +export const ML_APP_NAME = i18n.translate('xpack.ml.navMenu.mlAppNameText', { + defaultMessage: 'Machine Learning', +}); diff --git a/x-pack/plugins/ml/common/constants/data_frame_analytics.ts b/x-pack/plugins/ml/common/constants/data_frame_analytics.ts index 830537cbadbc8..9a7af2496c03f 100644 --- a/x-pack/plugins/ml/common/constants/data_frame_analytics.ts +++ b/x-pack/plugins/ml/common/constants/data_frame_analytics.ts @@ -4,4 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +export const ANALYSIS_CONFIG_TYPE = { + OUTLIER_DETECTION: 'outlier_detection', + REGRESSION: 'regression', + CLASSIFICATION: 'classification', +} as const; export const DEFAULT_RESULTS_FIELD = 'ml'; diff --git a/x-pack/plugins/ml/common/constants/ml_url_generator.ts b/x-pack/plugins/ml/common/constants/ml_url_generator.ts index 44f33aa329e7a..541b8af6fc0fc 100644 --- a/x-pack/plugins/ml/common/constants/ml_url_generator.ts +++ b/x-pack/plugins/ml/common/constants/ml_url_generator.ts @@ -31,8 +31,16 @@ export const ML_PAGES = { * Open index data visualizer viewer page */ DATA_VISUALIZER_INDEX_VIEWER: 'jobs/new_job/datavisualizer', + ANOMALY_DETECTION_CREATE_JOB: `jobs/new_job`, ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE: `jobs/new_job/step/job_type`, + ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX: `jobs/new_job/step/index_or_search`, SETTINGS: 'settings', CALENDARS_MANAGE: 'settings/calendars_list', + CALENDARS_NEW: 'settings/calendars_list/new_calendar', + CALENDARS_EDIT: 'settings/calendars_list/edit_calendar', FILTER_LISTS_MANAGE: 'settings/filter_lists', + FILTER_LISTS_NEW: 'settings/filter_lists/new_filter_list', + FILTER_LISTS_EDIT: 'settings/filter_lists/edit_filter_list', + ACCESS_DENIED: 'access-denied', + OVERVIEW: 'overview', } as const; diff --git a/x-pack/plugins/ml/common/types/data_frame_analytics.ts b/x-pack/plugins/ml/common/types/data_frame_analytics.ts index 96d6c81a3d309..5d0ecf96fb6b5 100644 --- a/x-pack/plugins/ml/common/types/data_frame_analytics.ts +++ b/x-pack/plugins/ml/common/types/data_frame_analytics.ts @@ -6,6 +6,7 @@ import Boom from 'boom'; import { EsErrorBody } from '../util/errors'; +import { ANALYSIS_CONFIG_TYPE } from '../constants/data_frame_analytics'; export interface DeleteDataFrameAnalyticsWithIndexStatus { success: boolean; @@ -81,8 +82,4 @@ export interface DataFrameAnalyticsConfig { allow_lazy_start?: boolean; } -export enum ANALYSIS_CONFIG_TYPE { - OUTLIER_DETECTION = 'outlier_detection', - REGRESSION = 'regression', - CLASSIFICATION = 'classification', -} +export type DataFrameAnalysisConfigType = typeof ANALYSIS_CONFIG_TYPE[keyof typeof ANALYSIS_CONFIG_TYPE]; diff --git a/x-pack/plugins/ml/common/types/ml_url_generator.ts b/x-pack/plugins/ml/common/types/ml_url_generator.ts index 234be8b6faf90..d176c22bdbb62 100644 --- a/x-pack/plugins/ml/common/types/ml_url_generator.ts +++ b/x-pack/plugins/ml/common/types/ml_url_generator.ts @@ -5,27 +5,21 @@ */ import { RefreshInterval, TimeRange } from '../../../../../src/plugins/data/common/query'; -import { JobId } from '../../../reporting/common/types'; +import { JobId } from './anomaly_detection_jobs/job'; import { ML_PAGES } from '../constants/ml_url_generator'; +import { DataFrameAnalysisConfigType } from './data_frame_analytics'; type OptionalPageState = object | undefined; export type MLPageState = PageState extends OptionalPageState - ? { page: PageType; pageState?: PageState } + ? { page: PageType; pageState?: PageState; excludeBasePath?: boolean } : PageState extends object - ? { page: PageType; pageState: PageState } - : { page: PageType }; - -export const ANALYSIS_CONFIG_TYPE = { - OUTLIER_DETECTION: 'outlier_detection', - REGRESSION: 'regression', - CLASSIFICATION: 'classification', -} as const; - -type DataFrameAnalyticsType = typeof ANALYSIS_CONFIG_TYPE[keyof typeof ANALYSIS_CONFIG_TYPE]; + ? { page: PageType; pageState: PageState; excludeBasePath?: boolean } + : { page: PageType; excludeBasePath?: boolean }; export interface MlCommonGlobalState { time?: TimeRange; + refreshInterval?: RefreshInterval; } export interface MlCommonAppState { [key: string]: any; @@ -42,16 +36,28 @@ export interface MlGenericUrlPageState extends MlIndexBasedSearchState { [key: string]: any; } -export interface MlGenericUrlState { - page: - | typeof ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER - | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE; - pageState: MlGenericUrlPageState; -} +export type MlGenericUrlState = MLPageState< + | typeof ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER + | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB + | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE + | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX + | typeof ML_PAGES.OVERVIEW + | typeof ML_PAGES.CALENDARS_MANAGE + | typeof ML_PAGES.CALENDARS_NEW + | typeof ML_PAGES.FILTER_LISTS_MANAGE + | typeof ML_PAGES.FILTER_LISTS_NEW + | typeof ML_PAGES.SETTINGS + | typeof ML_PAGES.ACCESS_DENIED + | typeof ML_PAGES.DATA_VISUALIZER + | typeof ML_PAGES.DATA_VISUALIZER_FILE + | typeof ML_PAGES.DATA_VISUALIZER_INDEX_SELECT, + MlGenericUrlPageState | undefined +>; export interface AnomalyDetectionQueryState { jobId?: JobId; groupIds?: string[]; + globalState?: MlCommonGlobalState; } export type AnomalyDetectionUrlState = MLPageState< @@ -86,7 +92,7 @@ export interface ExplorerUrlPageState { /** * Job IDs */ - jobIds: JobId[]; + jobIds?: JobId[]; /** * Optionally set the time range in the time picker. */ @@ -104,6 +110,7 @@ export interface ExplorerUrlPageState { */ mlExplorerSwimlane?: ExplorerAppState['mlExplorerSwimlane']; mlExplorerFilter?: ExplorerAppState['mlExplorerFilter']; + globalState?: MlCommonGlobalState; } export type ExplorerUrlState = MLPageState; @@ -122,6 +129,7 @@ export interface TimeSeriesExplorerAppState { to?: string; }; mlTimeSeriesExplorer?: { + forecastId?: string; detectorIndex?: number; entities?: Record; }; @@ -131,10 +139,12 @@ export interface TimeSeriesExplorerAppState { export interface TimeSeriesExplorerPageState extends Pick, Pick { - jobIds: JobId[]; + jobIds?: JobId[]; timeRange?: TimeRange; detectorIndex?: number; entities?: Record; + forecastId?: string; + globalState?: MlCommonGlobalState; } export type TimeSeriesExplorerUrlState = MLPageState< @@ -145,6 +155,7 @@ export type TimeSeriesExplorerUrlState = MLPageState< export interface DataFrameAnalyticsQueryState { jobId?: JobId | JobId[]; groupIds?: string[]; + globalState?: MlCommonGlobalState; } export type DataFrameAnalyticsUrlState = MLPageState< @@ -152,17 +163,10 @@ export type DataFrameAnalyticsUrlState = MLPageState< DataFrameAnalyticsQueryState | undefined >; -export interface DataVisualizerUrlState { - page: - | typeof ML_PAGES.DATA_VISUALIZER - | typeof ML_PAGES.DATA_VISUALIZER_FILE - | typeof ML_PAGES.DATA_VISUALIZER_INDEX_SELECT; -} - export interface DataFrameAnalyticsExplorationQueryState { ml: { jobId: JobId; - analysisType: DataFrameAnalyticsType; + analysisType: DataFrameAnalysisConfigType; }; } @@ -170,7 +174,24 @@ export type DataFrameAnalyticsExplorationUrlState = MLPageState< typeof ML_PAGES.DATA_FRAME_ANALYTICS_EXPLORATION, { jobId: JobId; - analysisType: DataFrameAnalyticsType; + analysisType: DataFrameAnalysisConfigType; + globalState?: MlCommonGlobalState; + } +>; + +export type CalendarEditUrlState = MLPageState< + typeof ML_PAGES.CALENDARS_EDIT, + { + calendarId: string; + globalState?: MlCommonGlobalState; + } +>; + +export type FilterEditUrlState = MLPageState< + typeof ML_PAGES.FILTER_LISTS_EDIT, + { + filterId: string; + globalState?: MlCommonGlobalState; } >; @@ -183,5 +204,6 @@ export type MlUrlGeneratorState = | TimeSeriesExplorerUrlState | DataFrameAnalyticsUrlState | DataFrameAnalyticsExplorationUrlState - | DataVisualizerUrlState + | CalendarEditUrlState + | FilterEditUrlState | MlGenericUrlState; diff --git a/x-pack/plugins/ml/common/util/analytics_utils.ts b/x-pack/plugins/ml/common/util/analytics_utils.ts index d725984a47d66..d231ed4344389 100644 --- a/x-pack/plugins/ml/common/util/analytics_utils.ts +++ b/x-pack/plugins/ml/common/util/analytics_utils.ts @@ -9,8 +9,8 @@ import { ClassificationAnalysis, OutlierAnalysis, RegressionAnalysis, - ANALYSIS_CONFIG_TYPE, } from '../types/data_frame_analytics'; +import { ANALYSIS_CONFIG_TYPE } from '../../common/constants/data_frame_analytics'; export const isOutlierAnalysis = (arg: any): arg is OutlierAnalysis => { const keys = Object.keys(arg); diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index fc673397ef177..2c5dbe108ab1e 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -16,7 +16,8 @@ "embeddable", "uiActions", "kibanaLegacy", - "indexPatternManagement" + "indexPatternManagement", + "discover" ], "optionalPlugins": [ "home", diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index c281dc4e9ae05..e3bcc53fe697f 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -20,6 +20,7 @@ import { MlSetupDependencies, MlStartDependencies } from '../plugin'; import { MlRouter } from './routing'; import { mlApiServicesProvider } from './services/ml_api_service'; import { HttpService } from './services/http_service'; +import { ML_APP_URL_GENERATOR, ML_PAGES } from '../../common/constants/ml_url_generator'; export type MlDependencies = Omit & MlStartDependencies; @@ -50,11 +51,21 @@ export interface MlServicesContext { export type MlGlobalServices = ReturnType; const App: FC = ({ coreStart, deps, appMountParams }) => { + const redirectToMlAccessDeniedPage = async () => { + const accessDeniedPageUrl = await deps.share.urlGenerators + .getUrlGenerator(ML_APP_URL_GENERATOR) + .createUrl({ + page: ML_PAGES.ACCESS_DENIED, + }); + await coreStart.application.navigateToUrl(accessDeniedPageUrl); + }; + const pageDeps = { history: appMountParams.history, indexPatterns: deps.data.indexPatterns, config: coreStart.uiSettings!, setBreadcrumbs: coreStart.chrome!.setBreadcrumbs, + redirectToMlAccessDeniedPage, }; const services = { appName: 'ML', diff --git a/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts b/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts index 653eca126006d..cdd25821ea5ca 100644 --- a/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts +++ b/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts @@ -33,10 +33,12 @@ export function checkGetManagementMlJobsResolver() { }); } -export function checkGetJobsCapabilitiesResolver(): Promise { +export function checkGetJobsCapabilitiesResolver( + redirectToMlAccessDeniedPage: () => Promise +): Promise { return new Promise((resolve, reject) => { getCapabilities() - .then(({ capabilities, isPlatinumOrTrialLicense }) => { + .then(async ({ capabilities, isPlatinumOrTrialLicense }) => { _capabilities = capabilities; // the minimum privilege for using ML with a platinum or trial license is being able to get the transforms list. // all other functionality is controlled by the return capabilities object. @@ -46,21 +48,23 @@ export function checkGetJobsCapabilitiesResolver(): Promise { if (_capabilities.canGetJobs || isPlatinumOrTrialLicense === false) { return resolve(_capabilities); } else { - window.location.href = '#/access-denied'; + await redirectToMlAccessDeniedPage(); return reject(); } }) - .catch((e) => { - window.location.href = '#/access-denied'; + .catch(async (e) => { + await redirectToMlAccessDeniedPage(); return reject(); }); }); } -export function checkCreateJobsCapabilitiesResolver(): Promise { +export function checkCreateJobsCapabilitiesResolver( + redirectToJobsManagementPage: () => Promise +): Promise { return new Promise((resolve, reject) => { getCapabilities() - .then(({ capabilities, isPlatinumOrTrialLicense }) => { + .then(async ({ capabilities, isPlatinumOrTrialLicense }) => { _capabilities = capabilities; // if the license is basic (isPlatinumOrTrialLicense === false) then do not redirect, // allow the promise to resolve as the separate license check will redirect then user to @@ -69,34 +73,36 @@ export function checkCreateJobsCapabilitiesResolver(): Promise { return resolve(_capabilities); } else { // if the user has no permission to create a job, - // redirect them back to the Transforms Management page - window.location.href = '#/jobs'; + // redirect them back to the Anomaly Detection Management page + await redirectToJobsManagementPage(); return reject(); } }) - .catch((e) => { - window.location.href = '#/jobs'; + .catch(async (e) => { + await redirectToJobsManagementPage(); return reject(); }); }); } -export function checkFindFileStructurePrivilegeResolver(): Promise { +export function checkFindFileStructurePrivilegeResolver( + redirectToMlAccessDeniedPage: () => Promise +): Promise { return new Promise((resolve, reject) => { getCapabilities() - .then(({ capabilities }) => { + .then(async ({ capabilities }) => { _capabilities = capabilities; // the minimum privilege for using ML with a basic license is being able to use the datavisualizer. // all other functionality is controlled by the return _capabilities object if (_capabilities.canFindFileStructure) { return resolve(_capabilities); } else { - window.location.href = '#/access-denied'; + await redirectToMlAccessDeniedPage(); return reject(); } }) - .catch((e) => { - window.location.href = '#/access-denied'; + .catch(async (e) => { + await redirectToMlAccessDeniedPage(); return reject(); }); }); diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/__snapshots__/annotations_table.test.js.snap b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/__snapshots__/annotations_table.test.js.snap index 9eb44c71aa799..114a6b235d1ad 100644 --- a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/__snapshots__/annotations_table.test.js.snap +++ b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/__snapshots__/annotations_table.test.js.snap @@ -1,170 +1,527 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`AnnotationsTable Initialization with annotations prop. 1`] = ` - - ", + "end_timestamp": 1455041968976, + "job_id": "farequote", + "modified_time": 1546417097181, + "modified_username": "", + "timestamp": 1455026177994, + "type": "annotation", + }, + ] + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + kibana={ + Object { + "notifications": Object { + "toasts": Object { + "danger": [Function], + "show": [Function], + "success": [Function], + "warning": [Function], + }, + }, + "overlays": Object { + "openFlyout": [Function], + "openModal": [Function], + }, + "services": Object {}, + } + } +/> +`; + +exports[`AnnotationsTable Initialization with job config prop. 1`] = ` +", - "end_timestamp": 1455041968976, "job_id": "farequote", - "modified_time": 1546417097181, - "modified_username": "", - "timestamp": 1455026177994, - "type": "annotation", + "query": Object { + "bool": Object { + "adjust_pure_negative": true, + "boost": 1, + "must": Array [ + Object { + "query_string": Object { + "analyze_wildcard": true, + "auto_generate_synonyms_phrase_query": true, + "boost": 1, + "default_operator": "or", + "enable_position_increments": true, + "escape": false, + "fields": Array [], + "fuzziness": "AUTO", + "fuzzy_max_expansions": 50, + "fuzzy_prefix_length": 0, + "fuzzy_transpositions": true, + "max_determinized_states": 10000, + "phrase_slop": 0, + "query": "*", + "type": "best_fields", + }, + }, + ], + }, + }, + "query_delay": "115823ms", + "scroll_size": 1000, + "state": "stopped", }, - ] - } - pagination={ - Object { - "pageSizeOptions": Array [ - 5, - 10, - 25, - ], - } - } - responsive={true} - rowProps={[Function]} - search={ - Object { - "box": Object { - "incremental": true, - "schema": true, - }, - "defaultQuery": "event:(user or delayed_data)", - "filters": Array [ - Object { - "field": "event", - "multiSelect": "or", - "name": "Event", - "options": Array [], - "type": "field_value_selection", - }, - ], - } - } - sorting={ - Object { - "sort": Object { - "direction": "asc", - "field": "timestamp", + "description": "", + "established_model_memory": 42102, + "finished_time": 1546418359427, + "job_id": "farequote", + "job_type": "anomaly_detector", + "job_version": "7.0.0", + "model_plot_config": Object { + "enabled": true, }, - } + "model_size_stats": Object { + "bucket_allocation_failures_count": 0, + "job_id": "farequote", + "log_time": 1546418359000, + "memory_status": "ok", + "model_bytes": 42102, + "result_type": "model_size_stats", + "timestamp": 1455232500000, + "total_by_field_count": 3, + "total_over_field_count": 0, + "total_partition_field_count": 2, + }, + "model_snapshot_id": "1546418359", + "model_snapshot_min_version": "6.4.0", + "model_snapshot_retention_days": 1, + "results_index_name": "shared", + "state": "closed", + }, + ] + } + kibana={ + Object { + "notifications": Object { + "toasts": Object { + "danger": [Function], + "show": [Function], + "success": [Function], + "warning": [Function], + }, + }, + "overlays": Object { + "openFlyout": [Function], + "openModal": [Function], + }, + "services": Object {}, } - tableLayout="fixed" - /> - -`; - -exports[`AnnotationsTable Initialization with job config prop. 1`] = ` - - - - - + } +/> `; exports[`AnnotationsTable Minimal initialization without props. 1`] = ` - + `; diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js index 9dabfce163dbb..d5025fd3c3649 100644 --- a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js +++ b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js @@ -13,7 +13,6 @@ import uniq from 'lodash/uniq'; import PropTypes from 'prop-types'; -import rison from 'rison-node'; import React, { Component, Fragment } from 'react'; import memoizeOne from 'memoize-one'; import { @@ -54,12 +53,15 @@ import { ANNOTATION_EVENT_USER, ANNOTATION_EVENT_DELAYED_DATA, } from '../../../../../common/constants/annotations'; +import { withKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { ML_APP_URL_GENERATOR, ML_PAGES } from '../../../../../common/constants/ml_url_generator'; +import { PLUGIN_ID } from '../../../../../common/constants/app'; const CURRENT_SERIES = 'current_series'; /** * Table component for rendering the lists of annotations for an ML job. */ -export class AnnotationsTable extends Component { +class AnnotationsTableUI extends Component { static propTypes = { annotations: PropTypes.array, jobs: PropTypes.array, @@ -199,7 +201,17 @@ export class AnnotationsTable extends Component { } } - openSingleMetricView = (annotation = {}) => { + openSingleMetricView = async (annotation = {}) => { + const { + services: { + application: { navigateToApp }, + + share: { + urlGenerators: { getUrlGenerator }, + }, + }, + } = this.props.kibana; + // Creates the link to the Single Metric Viewer. // Set the total time range from the start to the end of the annotation. const job = this.getJob(annotation.job_id); @@ -210,30 +222,10 @@ export class AnnotationsTable extends Component { ); const from = new Date(dataCounts.earliest_record_timestamp).toISOString(); const to = new Date(resultLatest).toISOString(); - - const globalSettings = { - ml: { - jobIds: [job.job_id], - }, - refreshInterval: { - display: 'Off', - pause: false, - value: 0, - }, - time: { - from, - to, - mode: 'absolute', - }, - }; - - const appState = { - query: { - query_string: { - analyze_wildcard: true, - query: '*', - }, - }, + const timeRange = { + from, + to, + mode: 'absolute', }; let mlTimeSeriesExplorer = {}; const entityCondition = {}; @@ -247,11 +239,11 @@ export class AnnotationsTable extends Component { }; if (annotation.timestamp < dataCounts.earliest_record_timestamp) { - globalSettings.time.from = new Date(annotation.timestamp).toISOString(); + timeRange.from = new Date(annotation.timestamp).toISOString(); } if (annotation.end_timestamp > dataCounts.latest_record_timestamp) { - globalSettings.time.to = new Date(annotation.end_timestamp).toISOString(); + timeRange.to = new Date(annotation.end_timestamp).toISOString(); } } @@ -274,14 +266,34 @@ export class AnnotationsTable extends Component { entityCondition[annotation.by_field_name] = annotation.by_field_value; } mlTimeSeriesExplorer.entities = entityCondition; - appState.mlTimeSeriesExplorer = mlTimeSeriesExplorer; - - const _g = rison.encode(globalSettings); - const _a = rison.encode(appState); + // appState.mlTimeSeriesExplorer = mlTimeSeriesExplorer; + + const mlUrlGenerator = getUrlGenerator(ML_APP_URL_GENERATOR); + const singleMetricViewerLink = await mlUrlGenerator.createUrl({ + page: ML_PAGES.SINGLE_METRIC_VIEWER, + pageState: { + timeRange, + refreshInterval: { + display: 'Off', + pause: false, + value: 0, + }, + jobIds: [job.job_id], + query: { + query_string: { + analyze_wildcard: true, + query: '*', + }, + }, + ...mlTimeSeriesExplorer, + }, + excludeBasePath: true, + }); - const url = `?_g=${_g}&_a=${_a}`; - addItemToRecentlyAccessed('timeseriesexplorer', job.job_id, url); - window.open(`#/timeseriesexplorer${url}`, '_self'); + addItemToRecentlyAccessed('timeseriesexplorer', job.job_id, singleMetricViewerLink); + await navigateToApp(PLUGIN_ID, { + path: singleMetricViewerLink, + }); }; onMouseOverRow = (record) => { @@ -686,3 +698,5 @@ export class AnnotationsTable extends Component { ); } } + +export const AnnotationsTable = withKibana(AnnotationsTableUI); diff --git a/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.js b/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.js index fdeab0c49e32b..6025dd1c7433e 100644 --- a/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.js +++ b/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.js @@ -29,6 +29,8 @@ import { getUrlForRecord, openCustomUrlWindow } from '../../util/custom_url_util import { formatHumanReadableDateTimeSeconds } from '../../util/date_utils'; import { getIndexPatternIdFromName } from '../../util/index_utils'; import { replaceStringTokens } from '../../util/string_utils'; +import { ML_APP_URL_GENERATOR, ML_PAGES } from '../../../../common/constants/ml_url_generator'; +import { PLUGIN_ID } from '../../../../common/constants/app'; /* * Component for rendering the links menu inside a cell in the anomalies table. */ @@ -142,7 +144,18 @@ class LinksMenuUI extends Component { } }; - viewSeries = () => { + viewSeries = async () => { + const { + services: { + application: { navigateToApp }, + + share: { + urlGenerators: { getUrlGenerator }, + }, + }, + } = this.props.kibana; + const mlUrlGenerator = getUrlGenerator(ML_APP_URL_GENERATOR); + const record = this.props.anomaly.source; const bounds = this.props.bounds; const from = bounds.min.toISOString(); // e.g. 2016-02-08T16:00:00.000Z @@ -171,44 +184,36 @@ class LinksMenuUI extends Component { entityCondition[record.by_field_name] = record.by_field_value; } - // Use rison to build the URL . - const _g = rison.encode({ - ml: { + const singleMetricViewerLink = await mlUrlGenerator.createUrl({ + excludeBasePath: true, + page: ML_PAGES.SINGLE_METRIC_VIEWER, + pageState: { jobIds: [record.job_id], - }, - refreshInterval: { - display: 'Off', - pause: false, - value: 0, - }, - time: { - from: from, - to: to, - mode: 'absolute', - }, - }); - - const _a = rison.encode({ - mlTimeSeriesExplorer: { + refreshInterval: { + display: 'Off', + pause: false, + value: 0, + }, + timeRange: { + from: from, + to: to, + mode: 'absolute', + }, zoom: { from: zoomFrom, to: zoomTo, }, detectorIndex: record.detector_index, entities: entityCondition, - }, - query: { query_string: { analyze_wildcard: true, query: '*', }, }, }); - - // Need to encode the _a parameter in case any entities contain unsafe characters such as '+'. - let path = '#/timeseriesexplorer'; - path += `?_g=${_g}&_a=${encodeURIComponent(_a)}`; - window.open(path, '_blank'); + await navigateToApp(PLUGIN_ID, { + path: singleMetricViewerLink, + }); }; viewExamples = () => { diff --git a/x-pack/plugins/ml/public/application/components/anomaly_results_view_selector/anomaly_results_view_selector.test.tsx b/x-pack/plugins/ml/public/application/components/anomaly_results_view_selector/anomaly_results_view_selector.test.tsx index 4a63a8cd7e716..d54a7fe81e858 100644 --- a/x-pack/plugins/ml/public/application/components/anomaly_results_view_selector/anomaly_results_view_selector.test.tsx +++ b/x-pack/plugins/ml/public/application/components/anomaly_results_view_selector/anomaly_results_view_selector.test.tsx @@ -6,13 +6,22 @@ import React from 'react'; import { Router } from 'react-router-dom'; -import { render, fireEvent } from '@testing-library/react'; +import { render } from '@testing-library/react'; import { createBrowserHistory } from 'history'; import { I18nProvider } from '@kbn/i18n/react'; import { AnomalyResultsViewSelector } from './index'; +jest.mock('../../contexts/kibana', () => { + return { + useMlUrlGenerator: () => ({ + createUrl: jest.fn(), + }), + useNavigateToPath: () => jest.fn(), + }; +}); + describe('AnomalyResultsViewSelector', () => { test('should create selector with correctly selected value', () => { const history = createBrowserHistory(); @@ -31,27 +40,4 @@ describe('AnomalyResultsViewSelector', () => { getByTestId('mlAnomalyResultsViewSelectorSingleMetricViewer').hasAttribute('checked') ).toBe(true); }); - - test('should open window to other results view when clicking on non-checked input', () => { - // Create mock for window.open - const mockedOpen = jest.fn(); - const originalOpen = window.open; - window.open = mockedOpen; - - const history = createBrowserHistory(); - - const { getByTestId } = render( - - - - - - ); - - fireEvent.click(getByTestId('mlAnomalyResultsViewSelectorExplorer')); - expect(mockedOpen).toHaveBeenCalledWith('#/explorer', '_self'); - - // Clean-up window.open. - window.open = originalOpen; - }); }); diff --git a/x-pack/plugins/ml/public/application/components/anomaly_results_view_selector/anomaly_results_view_selector.tsx b/x-pack/plugins/ml/public/application/components/anomaly_results_view_selector/anomaly_results_view_selector.tsx index 78acb422851e3..c4c8f06bbbc3a 100644 --- a/x-pack/plugins/ml/public/application/components/anomaly_results_view_selector/anomaly_results_view_selector.tsx +++ b/x-pack/plugins/ml/public/application/components/anomaly_results_view_selector/anomaly_results_view_selector.tsx @@ -5,21 +5,25 @@ */ import React, { FC, useMemo } from 'react'; -import { encode } from 'rison-node'; import { EuiButtonGroup } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useUrlState } from '../../util/url_state'; +import { useMlUrlGenerator, useNavigateToPath } from '../../contexts/kibana'; +import { ML_PAGES } from '../../../../common/constants/ml_url_generator'; interface Props { - viewId: 'timeseriesexplorer' | 'explorer'; + viewId: typeof ML_PAGES.SINGLE_METRIC_VIEWER | typeof ML_PAGES.ANOMALY_EXPLORER; } // Component for rendering a set of buttons for switching between the Anomaly Detection results views. export const AnomalyResultsViewSelector: FC = ({ viewId }) => { + const urlGenerator = useMlUrlGenerator(); + const navigateToPath = useNavigateToPath(); + const toggleButtonsIcons = useMemo( () => [ { @@ -28,7 +32,7 @@ export const AnomalyResultsViewSelector: FC = ({ viewId }) => { defaultMessage: 'View results in the Single Metric Viewer', }), iconType: 'visLine', - value: 'timeseriesexplorer', + value: ML_PAGES.SINGLE_METRIC_VIEWER, 'data-test-subj': 'mlAnomalyResultsViewSelectorSingleMetricViewer', }, { @@ -37,7 +41,7 @@ export const AnomalyResultsViewSelector: FC = ({ viewId }) => { defaultMessage: 'View results in the Anomaly Explorer', }), iconType: 'visTable', - value: 'explorer', + value: ML_PAGES.ANOMALY_EXPLORER, 'data-test-subj': 'mlAnomalyResultsViewSelectorExplorer', }, ], @@ -46,9 +50,14 @@ export const AnomalyResultsViewSelector: FC = ({ viewId }) => { const [globalState] = useUrlState('_g'); - const onChangeView = (newViewId: string) => { - const fullGlobalStateString = globalState !== undefined ? `?_g=${encode(globalState)}` : ''; - window.open(`#/${newViewId}${fullGlobalStateString}`, '_self'); + const onChangeView = async (newViewId: Props['viewId']) => { + const url = await urlGenerator.createUrl({ + page: newViewId, + pageState: { + globalState, + }, + }); + await navigateToPath(url); }; return ( @@ -60,7 +69,7 @@ export const AnomalyResultsViewSelector: FC = ({ viewId }) => { data-test-subj="mlAnomalyResultsViewSelector" options={toggleButtonsIcons} idSelected={viewId} - onChange={onChangeView} + onChange={(newViewId: string) => onChangeView(newViewId as Props['viewId'])} isIconOnly /> ); diff --git a/x-pack/plugins/ml/public/application/components/custom_hooks/use_create_ad_links.ts b/x-pack/plugins/ml/public/application/components/custom_hooks/use_create_ad_links.ts index 368e758a027c4..b4668810b9421 100644 --- a/x-pack/plugins/ml/public/application/components/custom_hooks/use_create_ad_links.ts +++ b/x-pack/plugins/ml/public/application/components/custom_hooks/use_create_ad_links.ts @@ -22,16 +22,19 @@ export const useCreateADLinks = () => { const userTimeSettings = useUiSettings().get(ANOMALY_DETECTION_DEFAULT_TIME_RANGE); const createLinkWithUserDefaults = useCallback( (location, jobList) => { - const resultsPageUrl = mlJobService.createResultsUrlForJobs( + return mlJobService.createResultsUrlForJobs( jobList, location, useUserTimeSettings === true && userTimeSettings !== undefined ? userTimeSettings : undefined ); - return `${basePath.get()}/app/ml${resultsPageUrl}`; }, [basePath] ); return { createLinkWithUserDefaults }; }; + +export type CreateLinkWithUserDefaults = ReturnType< + typeof useCreateADLinks +>['createLinkWithUserDefaults']; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx index 22815fe593d57..6aad5d53c3a3c 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx @@ -32,6 +32,7 @@ import { UseIndexDataReturnType } from './types'; import { DecisionPathPopover } from './feature_importance/decision_path_popover'; import { TopClasses } from '../../../../common/types/feature_importance'; import { DEFAULT_RESULTS_FIELD } from '../../../../common/constants/data_frame_analytics'; +import { DataFrameAnalysisConfigType } from '../../../../common/types/data_frame_analytics'; // TODO Fix row hovering + bar highlighting // import { hoveredRow$ } from './column_chart'; @@ -44,7 +45,7 @@ export const DataGridTitle: FC<{ title: string }> = ({ title }) => ( interface PropsWithoutHeader extends UseIndexDataReturnType { baseline?: number; - analysisType?: ANALYSIS_CONFIG_TYPE; + analysisType?: DataFrameAnalysisConfigType; resultsField?: string; dataTestSubj: string; toastNotifications: CoreSetup['notifications']['toasts']; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_popover.tsx b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_popover.tsx index 263337f93e9a8..7c4428db71b3b 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_popover.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_popover.tsx @@ -13,10 +13,11 @@ import { FeatureImportance, TopClasses } from '../../../../../common/types/featu import { ANALYSIS_CONFIG_TYPE } from '../../../data_frame_analytics/common'; import { ClassificationDecisionPath } from './decision_path_classification'; import { useMlKibana } from '../../../contexts/kibana'; +import { DataFrameAnalysisConfigType } from '../../../../../common/types/data_frame_analytics'; interface DecisionPathPopoverProps { featureImportance: FeatureImportance[]; - analysisType: ANALYSIS_CONFIG_TYPE; + analysisType: DataFrameAnalysisConfigType; predictionFieldName?: string; baseline?: number; predictedValue?: number | string | undefined; diff --git a/x-pack/plugins/ml/public/application/components/data_recognizer/recognized_result.js b/x-pack/plugins/ml/public/application/components/data_recognizer/recognized_result.js index 1f03dbe134756..279afc8c50339 100644 --- a/x-pack/plugins/ml/public/application/components/data_recognizer/recognized_result.js +++ b/x-pack/plugins/ml/public/application/components/data_recognizer/recognized_result.js @@ -9,11 +9,16 @@ import PropTypes from 'prop-types'; import { EuiIcon, EuiFlexItem } from '@elastic/eui'; import { CreateJobLinkCard } from '../create_job_link_card'; +import { useMlKibana } from '../../contexts/kibana'; export const RecognizedResult = ({ config, indexPattern, savedSearch }) => { + const { + services: { + http: { basePath }, + }, + } = useMlKibana(); const id = savedSearch === null ? `index=${indexPattern.id}` : `savedSearchId=${savedSearch.id}`; - - const href = `#/jobs/new_job/recognize?id=${config.id}&${id}`; + const href = `${basePath.get()}/app/ml/jobs/new_job/recognize?id=${config.id}&${id}`; let logo = null; // if a logo is available, use that, otherwise display the id diff --git a/x-pack/plugins/ml/public/application/components/navigation_menu/main_tabs.tsx b/x-pack/plugins/ml/public/application/components/navigation_menu/main_tabs.tsx index 3a4875fa243fd..671f0b196ce35 100644 --- a/x-pack/plugins/ml/public/application/components/navigation_menu/main_tabs.tsx +++ b/x-pack/plugins/ml/public/application/components/navigation_menu/main_tabs.tsx @@ -4,15 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useState } from 'react'; -import { encode } from 'rison-node'; +import React, { FC, useState, useEffect } from 'react'; -import { EuiTabs, EuiTab, EuiLink } from '@elastic/eui'; +import { EuiTabs, EuiTab } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; - -import { useUrlState } from '../../util/url_state'; - import { TabId } from './navigation_menu'; +import { useMlKibana, useMlUrlGenerator, useNavigateToPath } from '../../contexts/kibana'; +import { MlUrlGeneratorState } from '../../../../common/types/ml_url_generator'; +import { useUrlState } from '../../util/url_state'; +import { ML_APP_NAME } from '../../../../common/constants/app'; export interface Tab { id: TabId; @@ -66,20 +66,57 @@ function getTabs(disableLinks: boolean): Tab[] { } interface TabData { testSubject: string; - pathId?: string; + pathId?: MlUrlGeneratorState['page']; + name: string; } const TAB_DATA: Record = { - overview: { testSubject: 'mlMainTab overview' }, + overview: { + testSubject: 'mlMainTab overview', + name: i18n.translate('xpack.ml.overviewTabLabel', { + defaultMessage: 'Overview', + }), + }, // Note that anomaly detection jobs list is mapped to ml#/jobs. - anomaly_detection: { testSubject: 'mlMainTab anomalyDetection', pathId: 'jobs' }, - data_frame_analytics: { testSubject: 'mlMainTab dataFrameAnalytics' }, - datavisualizer: { testSubject: 'mlMainTab dataVisualizer' }, - settings: { testSubject: 'mlMainTab settings' }, - 'access-denied': { testSubject: 'mlMainTab overview' }, + anomaly_detection: { + testSubject: 'mlMainTab anomalyDetection', + name: i18n.translate('xpack.ml.anomalyDetectionTabLabel', { + defaultMessage: 'Anomaly Detection', + }), + pathId: 'jobs', + }, + data_frame_analytics: { + testSubject: 'mlMainTab dataFrameAnalytics', + name: i18n.translate('xpack.ml.dataFrameAnalyticsTabLabel', { + defaultMessage: 'Data Frame Analytics', + }), + }, + datavisualizer: { + testSubject: 'mlMainTab dataVisualizer', + name: i18n.translate('xpack.ml.dataVisualizerTabLabel', { + defaultMessage: 'Data Visualizer', + }), + }, + settings: { + testSubject: 'mlMainTab settings', + name: i18n.translate('xpack.ml.settingsTabLabel', { + defaultMessage: 'Settings', + }), + }, + 'access-denied': { + testSubject: 'mlMainTab overview', + name: i18n.translate('xpack.ml.accessDeniedTabLabel', { + defaultMessage: 'Access Denied', + }), + }, }; export const MainTabs: FC = ({ tabId, disableLinks }) => { + const { + services: { + chrome: { docTitle }, + }, + } = useMlKibana(); const [globalState] = useUrlState('_g'); const [selectedTabId, setSelectedTabId] = useState(tabId); function onSelectedTabChanged(id: TabId) { @@ -87,16 +124,40 @@ export const MainTabs: FC = ({ tabId, disableLinks }) => { } const tabs = getTabs(disableLinks); + const mlUrlGenerator = useMlUrlGenerator(); + const navigateToPath = useNavigateToPath(); + + const redirectToTab = async (defaultPathId: MlUrlGeneratorState['page']) => { + const pageState = + globalState?.refreshInterval !== undefined + ? { + globalState: { + refreshInterval: globalState.refreshInterval, + }, + } + : undefined; + // TODO - Fix ts so passing pageState won't default to MlGenericUrlState when pageState is passed in + // @ts-ignore + const path = await mlUrlGenerator.createUrl({ + page: defaultPathId, + // only retain the refreshInterval part of globalState + // appState will not be considered. + pageState, + }); + + await navigateToPath(path, false); + }; + + useEffect(() => { + docTitle.change([TAB_DATA[selectedTabId].name, ML_APP_NAME]); + }, [selectedTabId]); return ( {tabs.map((tab: Tab) => { const { id, disabled } = tab; const testSubject = TAB_DATA[id].testSubject; - const defaultPathId = TAB_DATA[id].pathId || id; - // globalState (e.g. selected jobs and time range) should be retained when changing pages. - // appState will not be considered. - const fullGlobalStateString = globalState !== undefined ? `?_g=${encode(globalState)}` : ''; + const defaultPathId = (TAB_DATA[id].pathId || id) as MlUrlGeneratorState['page']; return disabled ? ( @@ -104,21 +165,18 @@ export const MainTabs: FC = ({ tabId, disableLinks }) => { ) : (
- { + onSelectedTabChanged(id); + redirectToTab(defaultPathId); + }} + isSelected={id === selectedTabId} + key={`tab-${id}-key`} > - onSelectedTabChanged(id)} - isSelected={id === selectedTabId} - key={`tab-${id}-key`} - > - {tab.name} - - + {tab.name} +
); })} diff --git a/x-pack/plugins/ml/public/application/components/rule_editor/scope_section.js b/x-pack/plugins/ml/public/application/components/rule_editor/scope_section.js index 48e0da72f067c..eb12cb7679674 100644 --- a/x-pack/plugins/ml/public/application/components/rule_editor/scope_section.js +++ b/x-pack/plugins/ml/public/application/components/rule_editor/scope_section.js @@ -17,8 +17,19 @@ import { ScopeExpression } from './scope_expression'; import { checkPermission } from '../../capabilities/check_capabilities'; import { getScopeFieldDefaults } from './utils'; import { FormattedMessage } from '@kbn/i18n/react'; +import { ML_PAGES } from '../../../../common/constants/ml_url_generator'; +import { useMlUrlGenerator, useNavigateToPath } from '../../contexts/kibana'; function NoFilterListsCallOut() { + const mlUrlGenerator = useMlUrlGenerator(); + const navigateToPath = useNavigateToPath(); + const redirectToFilterManagementPage = async () => { + const path = await mlUrlGenerator.createUrl({ + page: ML_PAGES.FILTER_LISTS_MANAGE, + }); + await navigateToPath(path, true); + }; + return ( + useKibana(); export type MlKibanaReactContextValue = KibanaReactContextValue; diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/use_create_url.ts b/x-pack/plugins/ml/public/application/contexts/kibana/use_create_url.ts index 48385ad3ae6a8..d448185c914b8 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/use_create_url.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/use_create_url.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { useCallback, useEffect, useState } from 'react'; import { useMlKibana } from './kibana_context'; import { ML_APP_URL_GENERATOR } from '../../../../common/constants/ml_url_generator'; +import { MlUrlGeneratorState } from '../../../../common/types/ml_url_generator'; +import { useUrlState } from '../../util/url_state'; export const useMlUrlGenerator = () => { const { @@ -18,3 +21,59 @@ export const useMlUrlGenerator = () => { return getUrlGenerator(ML_APP_URL_GENERATOR); }; + +export const useMlLink = (params: MlUrlGeneratorState): string => { + const [href, setHref] = useState(params.page); + const mlUrlGenerator = useMlUrlGenerator(); + + useEffect(() => { + let isCancelled = false; + const generateUrl = async (_params: MlUrlGeneratorState) => { + const url = await mlUrlGenerator.createUrl(_params); + if (!isCancelled) { + setHref(url); + } + }; + generateUrl(params); + return () => { + isCancelled = true; + }; + }, [params]); + + return href; +}; + +export const useCreateAndNavigateToMlLink = ( + page: MlUrlGeneratorState['page'] +): (() => Promise) => { + const mlUrlGenerator = useMlUrlGenerator(); + const [globalState] = useUrlState('_g'); + + const { + services: { + application: { navigateToUrl }, + }, + } = useMlKibana(); + + const redirectToMlPage = useCallback( + async (_page: MlUrlGeneratorState['page']) => { + const pageState = + globalState?.refreshInterval !== undefined + ? { + globalState: { + refreshInterval: globalState.refreshInterval, + }, + } + : undefined; + + // TODO: fix ts only interpreting it as MlUrlGenericState if pageState is passed + // @ts-ignore + const url = await mlUrlGenerator.createUrl({ page: _page, pageState }); + await navigateToUrl(url); + }, + [mlUrlGenerator, navigateToUrl] + ); + + // returns the onClick callback + return useCallback(() => redirectToMlPage(page), [redirectToMlPage, page]); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 60681fb6e7bbe..d22bba7738db4 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -15,8 +15,8 @@ import { SavedSearchQuery } from '../../contexts/ml'; import { AnalysisConfig, ClassificationAnalysis, + DataFrameAnalysisConfigType, RegressionAnalysis, - ANALYSIS_CONFIG_TYPE, } from '../../../../common/types/data_frame_analytics'; import { isOutlierAnalysis, @@ -26,6 +26,7 @@ import { getDependentVar, getPredictedFieldName, } from '../../../../common/util/analytics_utils'; +import { ANALYSIS_CONFIG_TYPE } from '../../../../common/constants/data_frame_analytics'; export type IndexPattern = string; export enum ANALYSIS_ADVANCED_FIELDS { @@ -429,7 +430,7 @@ interface LoadEvalDataConfig { predictionFieldName?: string; searchQuery?: ResultsSearchQuery; ignoreDefaultQuery?: boolean; - jobType: ANALYSIS_CONFIG_TYPE; + jobType: DataFrameAnalysisConfigType; requiresKeyword?: boolean; } @@ -550,7 +551,7 @@ export { isRegressionAnalysis, isClassificationAnalysis, getPredictionFieldName, - ANALYSIS_CONFIG_TYPE, getDependentVar, getPredictedFieldName, + ANALYSIS_CONFIG_TYPE, }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts index 00d735d9a866e..83eebccd310e3 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts @@ -14,7 +14,6 @@ export { UpdateDataFrameAnalyticsConfig, IndexPattern, REFRESH_ANALYTICS_LIST_STATE, - ANALYSIS_CONFIG_TYPE, OUTLIER_ANALYSIS_METHOD, RegressionEvaluateResponse, getValuesFromResponse, @@ -26,6 +25,7 @@ export { SEARCH_SIZE, defaultSearchQuery, SearchQuery, + ANALYSIS_CONFIG_TYPE, } from './analytics'; export { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/job_type.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/job_type.tsx index 1e5dbee3499bd..1e6a616fedd64 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/job_type.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/job_type.tsx @@ -8,7 +8,7 @@ import React, { Fragment, FC } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiSelect } from '@elastic/eui'; -import { ANALYSIS_CONFIG_TYPE } from '../../../../common'; +import { ANALYSIS_CONFIG_TYPE } from '../../../../../../../common/constants/data_frame_analytics'; import { AnalyticsJobType } from '../../../analytics_management/hooks/use_create_analytics_form/state'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/supported_fields_message.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/supported_fields_message.tsx index 88c89df86b29a..310cd4e3b3a79 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/supported_fields_message.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/supported_fields_message.tsx @@ -16,6 +16,7 @@ import { BASIC_NUMERICAL_TYPES, EXTENDED_NUMERICAL_TYPES } from '../../../../com import { CATEGORICAL_TYPES } from './form_options_validation'; import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public'; import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; +import { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; const containsClassificationFieldsCb = ({ name, type }: Field) => !OMIT_FIELDS.includes(name) && @@ -32,13 +33,13 @@ const containsRegressionFieldsCb = ({ name, type }: Field) => const containsOutlierFieldsCb = ({ name, type }: Field) => !OMIT_FIELDS.includes(name) && name !== EVENT_RATE_FIELD_ID && BASIC_NUMERICAL_TYPES.has(type); -const callbacks: Record boolean> = { +const callbacks: Record boolean> = { [ANALYSIS_CONFIG_TYPE.CLASSIFICATION]: containsClassificationFieldsCb, [ANALYSIS_CONFIG_TYPE.REGRESSION]: containsRegressionFieldsCb, [ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION]: containsOutlierFieldsCb, }; -const messages: Record = { +const messages: Record = { [ANALYSIS_CONFIG_TYPE.CLASSIFICATION]: ( = ({ jobId, analysisType }) => { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx index eea579ef1d064..84b1c4241aaf2 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx @@ -29,7 +29,6 @@ import { SEARCH_SIZE, defaultSearchQuery, getAnalysisType, - ANALYSIS_CONFIG_TYPE, } from '../../../../common'; import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/use_columns'; import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; @@ -39,6 +38,7 @@ import { IndexPatternPrompt } from '../index_pattern_prompt'; import { useExplorationResults } from './use_exploration_results'; import { useMlKibana } from '../../../../../contexts/kibana'; +import { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; const showingDocs = i18n.translate( 'xpack.ml.dataframe.analytics.explorationResults.documentsShownHelpText', @@ -195,7 +195,7 @@ export const ExplorationResultsTable: FC = React.memo( {...classificationData} dataTestSubj="mlExplorationDataGrid" toastNotifications={getToastNotifications()} - analysisType={(analysisType as unknown) as ANALYSIS_CONFIG_TYPE} + analysisType={(analysisType as unknown) as DataFrameAnalysisConfigType} />
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx index c8349084dbda8..f4f01330271fc 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx @@ -26,11 +26,12 @@ import { OutlierExploration } from './components/outlier_exploration'; import { RegressionExploration } from './components/regression_exploration'; import { ClassificationExploration } from './components/classification_exploration'; -import { ANALYSIS_CONFIG_TYPE } from '../../common/analytics'; +import { ANALYSIS_CONFIG_TYPE } from '../../../../../common/constants/data_frame_analytics'; +import { DataFrameAnalysisConfigType } from '../../../../../common/types/data_frame_analytics'; export const Page: FC<{ jobId: string; - analysisType: ANALYSIS_CONFIG_TYPE; + analysisType: DataFrameAnalysisConfigType; }> = ({ jobId, analysisType }) => ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_view/use_view_action.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_view/use_view_action.tsx index a3595b51d0a59..2363e6fbecc9d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_view/use_view_action.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_view/use_view_action.tsx @@ -7,24 +7,32 @@ import React, { useCallback, useMemo } from 'react'; import { getAnalysisType } from '../../../../common/analytics'; -import { useNavigateToPath } from '../../../../../contexts/kibana'; +import { useMlUrlGenerator, useNavigateToPath } from '../../../../../contexts/kibana'; -import { - getResultsUrl, - DataFrameAnalyticsListAction, - DataFrameAnalyticsListRow, -} from '../analytics_list/common'; +import { DataFrameAnalyticsListAction, DataFrameAnalyticsListRow } from '../analytics_list/common'; import { getViewLinkStatus } from './get_view_link_status'; import { viewActionButtonText, ViewButton } from './view_button'; +import { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; +import { ML_PAGES } from '../../../../../../../common/constants/ml_url_generator'; export type ViewAction = ReturnType; export const useViewAction = () => { + const mlUrlGenerator = useMlUrlGenerator(); const navigateToPath = useNavigateToPath(); + const redirectToTab = async (jobId: string, analysisType: DataFrameAnalysisConfigType) => { + const path = await mlUrlGenerator.createUrl({ + page: ML_PAGES.DATA_FRAME_ANALYTICS_EXPLORATION, + pageState: { jobId, analysisType }, + }); + + await navigateToPath(path, false); + }; + const clickHandler = useCallback((item: DataFrameAnalyticsListRow) => { - const analysisType = getAnalysisType(item.config.analysis); - navigateToPath(getResultsUrl(item.id, analysisType)); + const analysisType = getAnalysisType(item.config.analysis) as DataFrameAnalysisConfigType; + redirectToTab(item.id, analysisType); }, []); const action: DataFrameAnalyticsListAction = useMemo( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index 0c3bff58c25cd..2f8e087a6a3f0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -15,12 +15,8 @@ import { EuiSearchBarProps, EuiSpacer, } from '@elastic/eui'; - -import { - DataFrameAnalyticsId, - useRefreshAnalyticsList, - ANALYSIS_CONFIG_TYPE, -} from '../../../../common'; +import { ANALYSIS_CONFIG_TYPE } from '../../../../../../../common/constants/data_frame_analytics'; +import { DataFrameAnalyticsId, useRefreshAnalyticsList } from '../../../../common'; import { checkPermission } from '../../../../../capabilities/check_capabilities'; import { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts index 994357412510d..37076d400f021 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts @@ -9,11 +9,8 @@ import { EuiTableActionsColumnType, Query, Ast } from '@elastic/eui'; import { DATA_FRAME_TASK_STATE } from './data_frame_task_state'; export { DATA_FRAME_TASK_STATE }; -import { - DataFrameAnalyticsId, - DataFrameAnalyticsConfig, - ANALYSIS_CONFIG_TYPE, -} from '../../../../common'; +import { DataFrameAnalyticsId, DataFrameAnalyticsConfig } from '../../../../common'; +import { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; export enum DATA_FRAME_MODE { BATCH = 'batch', @@ -111,10 +108,7 @@ export interface DataFrameAnalyticsListRow { checkpointing: object; config: DataFrameAnalyticsConfig; id: DataFrameAnalyticsId; - job_type: - | ANALYSIS_CONFIG_TYPE.CLASSIFICATION - | ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION - | ANALYSIS_CONFIG_TYPE.REGRESSION; + job_type: DataFrameAnalysisConfigType; mode: string; state: DataFrameAnalyticsStats['state']; stats: DataFrameAnalyticsStats; @@ -137,10 +131,6 @@ export function isCompletedAnalyticsJob(stats: DataFrameAnalyticsStats) { return stats.state === DATA_FRAME_TASK_STATE.STOPPED && progress === 100; } -export function getResultsUrl(jobId: string, analysisType: ANALYSIS_CONFIG_TYPE | string) { - return `#/data_frame_analytics/exploration?_g=(ml:(jobId:${jobId},analysisType:${analysisType}))`; -} - // The single Action type is not exported as is // from EUI so we use that code to get the single // Action type from the array of actions. diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx index ef1d373a55a12..1af99d2a1ed00 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx @@ -19,8 +19,6 @@ import { EuiLink, RIGHT_ALIGNMENT, } from '@elastic/eui'; -import { getJobIdUrl, TAB_IDS } from '../../../../../util/get_selected_ids_url'; - import { getAnalysisType, DataFrameAnalyticsId } from '../../../../common'; import { getDataFrameAnalyticsProgressPhase, @@ -32,6 +30,8 @@ import { DataFrameAnalyticsStats, } from './common'; import { useActions } from './use_actions'; +import { useMlLink } from '../../../../../contexts/kibana'; +import { ML_PAGES } from '../../../../../../../common/constants/ml_url_generator'; enum TASK_STATE_COLOR { analyzing = 'primary', @@ -134,9 +134,14 @@ export const progressColumn = { 'data-test-subj': 'mlAnalyticsTableColumnProgress', }; -export const getDFAnalyticsJobIdLink = (item: DataFrameAnalyticsListRow) => ( - {item.id} -); +export const DFAnalyticsJobIdLink = ({ item }: { item: DataFrameAnalyticsListRow }) => { + const href = useMlLink({ + page: ML_PAGES.DATA_FRAME_ANALYTICS_JOBS_MANAGE, + pageState: { jobId: item.id }, + }); + + return {item.id}; +}; export const useColumns = ( expandedRowItemIds: DataFrameAnalyticsId[], @@ -145,7 +150,6 @@ export const useColumns = ( isMlEnabledInSpace: boolean = true ) => { const { actions, modals } = useActions(isManagementTable); - function toggleDetails(item: DataFrameAnalyticsListRow) { const index = expandedRowItemIds.indexOf(item.config.id); if (index !== -1) { @@ -200,7 +204,7 @@ export const useColumns = ( 'data-test-subj': 'mlAnalyticsTableColumnId', scope: 'row', render: (item: DataFrameAnalyticsListRow) => - isManagementTable ? getDFAnalyticsJobIdLink(item) : item.id, + isManagementTable ? : item.id, }, { field: DataFrameAnalyticsListColumn.description, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx index 338b6444671a6..dbc7a23f2258b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx @@ -29,21 +29,23 @@ import { useInferenceApiService } from '../../../../../services/ml_api_service/i import { ModelsTableToConfigMapping } from './index'; import { TIME_FORMAT } from '../../../../../../../common/constants/time_format'; import { DeleteModelsModal } from './delete_models_modal'; -import { useMlKibana, useNotifications } from '../../../../../contexts/kibana'; +import { useMlKibana, useMlUrlGenerator, useNotifications } from '../../../../../contexts/kibana'; import { ExpandedRow } from './expanded_row'; -import { getResultsUrl } from '../analytics_list/common'; import { ModelConfigResponse, ModelPipelines, TrainedModelStat, } from '../../../../../../../common/types/inference'; import { + getAnalysisType, REFRESH_ANALYTICS_LIST_STATE, refreshAnalyticsList$, useRefreshAnalyticsList, } from '../../../../common'; import { useTableSettings } from '../analytics_list/use_table_settings'; import { filterAnalyticsModels, AnalyticsSearchBar } from '../analytics_search_bar'; +import { ML_PAGES } from '../../../../../../../common/constants/ml_url_generator'; +import { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; type Stats = Omit; @@ -61,6 +63,7 @@ export const ModelsList: FC = () => { application: { navigateToUrl, capabilities }, }, } = useMlKibana(); + const urlGenerator = useMlUrlGenerator(); const canDeleteDataFrameAnalytics = capabilities.ml.canDeleteDataFrameAnalytics as boolean; @@ -278,12 +281,19 @@ export const ModelsList: FC = () => { type: 'icon', available: (item) => item.metadata?.analytics_config?.id, onClick: async (item) => { - await navigateToUrl( - getResultsUrl( - item.metadata?.analytics_config.id, - Object.keys(item.metadata?.analytics_config.analysis)[0] - ) - ); + if (item.metadata?.analytics_config === undefined) return; + + const url = await urlGenerator.createUrl({ + page: ML_PAGES.DATA_FRAME_ANALYTICS_EXPLORATION, + pageState: { + jobId: item.metadata?.analytics_config.id as string, + analysisType: getAnalysisType( + item.metadata?.analytics_config.analysis + ) as DataFrameAnalysisConfigType, + }, + }); + + await navigateToUrl(url); }, isPrimary: true, }, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts index 7cd9fcc052f1a..178638322bacd 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts @@ -33,13 +33,13 @@ import { JOB_ID_MAX_LENGTH, ALLOWED_DATA_UNITS, } from '../../../../../../../common/constants/validation'; +import { ANALYSIS_CONFIG_TYPE } from '../../../../../../../common/constants/data_frame_analytics'; import { getDependentVar, getNumTopFeatureImportanceValues, getTrainingPercent, isRegressionAnalysis, isClassificationAnalysis, - ANALYSIS_CONFIG_TYPE, NUM_TOP_FEATURE_IMPORTANCE_VALUES_MIN, TRAINING_PERCENT_MIN, TRAINING_PERCENT_MAX, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts index 4926decaa7f9c..2a89c5a5fd686 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts @@ -8,13 +8,14 @@ import { DeepPartial, DeepReadonly } from '../../../../../../../common/types/com import { checkPermission } from '../../../../../capabilities/check_capabilities'; import { mlNodesAvailable } from '../../../../../ml_nodes_check'; -import { ANALYSIS_CONFIG_TYPE, defaultSearchQuery } from '../../../../common/analytics'; +import { defaultSearchQuery, getAnalysisType } from '../../../../common/analytics'; import { CloneDataFrameAnalyticsConfig } from '../../components/action_clone'; import { DataFrameAnalyticsConfig, DataFrameAnalyticsId, + DataFrameAnalysisConfigType, } from '../../../../../../../common/types/data_frame_analytics'; - +import { ANALYSIS_CONFIG_TYPE } from '../../../../../../../common/constants/data_frame_analytics'; export enum DEFAULT_MODEL_MEMORY_LIMIT { regression = '100mb', outlier_detection = '50mb', @@ -28,7 +29,7 @@ export const UNSET_CONFIG_ITEM = '--'; export type EsIndexName = string; export type DependentVariable = string; export type IndexPatternTitle = string; -export type AnalyticsJobType = ANALYSIS_CONFIG_TYPE | undefined; +export type AnalyticsJobType = DataFrameAnalysisConfigType | undefined; type IndexPatternId = string; export type SourceIndexMap = Record< IndexPatternTitle, @@ -290,7 +291,7 @@ export function getFormStateFromJobConfig( analyticsJobConfig: Readonly, isClone: boolean = true ): Partial { - const jobType = Object.keys(analyticsJobConfig.analysis)[0] as ANALYSIS_CONFIG_TYPE; + const jobType = getAnalysisType(analyticsJobConfig.analysis) as DataFrameAnalysisConfigType; const resultState: Partial = { jobType, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts index 41f3bab8113f0..14427dd5c6ef2 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts @@ -11,7 +11,7 @@ import { GetDataFrameAnalyticsStatsResponseOk, } from '../../../../../services/ml_api_service/data_frame_analytics'; import { - ANALYSIS_CONFIG_TYPE, + getAnalysisType, REFRESH_ANALYTICS_LIST_STATE, refreshAnalyticsList$, } from '../../../../common'; @@ -25,6 +25,7 @@ import { isDataFrameAnalyticsStopped, } from '../../components/analytics_list/common'; import { AnalyticStatsBarStats } from '../../../../../components/stats_bar'; +import { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; export const isGetDataFrameAnalyticsStatsResponseOk = ( arg: any @@ -143,7 +144,7 @@ export const getAnalyticsFactory = ( checkpointing: {}, config, id: config.id, - job_type: Object.keys(config.analysis)[0] as ANALYSIS_CONFIG_TYPE, + job_type: getAnalysisType(config.analysis) as DataFrameAnalysisConfigType, mode: DATA_FRAME_MODE.BATCH, state: stats.state, stats, diff --git a/x-pack/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx b/x-pack/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx index 769b83c03110b..7c30dc0cac690 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx @@ -52,7 +52,10 @@ function startTrialDescription() { export const DatavisualizerSelector: FC = () => { useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); const { - services: { licenseManagement }, + services: { + licenseManagement, + http: { basePath }, + }, } = useMlKibana(); const navigateToPath = useNavigateToPath(); @@ -183,7 +186,10 @@ export const DatavisualizerSelector: FC = () => { } description={startTrialDescription()} footer={ - + = ({ to: 'now', }); const [showCreateJobLink, setShowCreateJobLink] = useState(false); - const [globalStateString, setGlobalStateString] = useState(''); + const [globalState, setGlobalState] = useState(); + + const [discoverLink, setDiscoverLink] = useState(''); const { services: { http: { basePath }, }, } = useMlKibana(); + const mlUrlGenerator = useMlUrlGenerator(); + const navigateToPath = useNavigateToPath(); + + const { + services: { + share: { + urlGenerators: { getUrlGenerator }, + }, + }, + } = useMlKibana(); + + useEffect(() => { + let unmounted = false; + + const getDiscoverUrl = async (): Promise => { + const state: DiscoverUrlGeneratorState = { + indexPatternId, + }; + + if (globalState?.time) { + state.timeRange = globalState.time; + } + if (!unmounted) { + const discoverUrlGenerator = getUrlGenerator(DISCOVER_APP_URL_GENERATOR); + const discoverUrl = await discoverUrlGenerator.createUrl(state); + setDiscoverLink(discoverUrl); + } + }; + getDiscoverUrl(); + + return () => { + unmounted = true; + }; + }, [indexPatternId, getUrlGenerator]); + + const openInDataVisualizer = useCallback(async () => { + const path = await mlUrlGenerator.createUrl({ + page: ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER, + pageState: { + index: indexPatternId, + globalState, + }, + }); + await navigateToPath(path); + }, [indexPatternId, globalState]); + + const redirectToADCreateJobsSelectTypePage = useCallback(async () => { + const path = await mlUrlGenerator.createUrl({ + page: ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE, + pageState: { + index: indexPatternId, + globalState, + }, + }); + await navigateToPath(path); + }, [indexPatternId, globalState]); useEffect(() => { setShowCreateJobLink(checkPermission('canCreateJob') && mlNodesAvailable()); @@ -49,11 +113,13 @@ export const ResultsLinks: FC = ({ }, []); useEffect(() => { - const _g = - timeFieldName !== undefined - ? `&_g=(time:(from:'${duration.from}',mode:quick,to:'${duration.to}'))` - : ''; - setGlobalStateString(_g); + const _globalState: MlCommonGlobalState = { + time: { + from: duration.from, + to: duration.to, + }, + }; + setGlobalState(_globalState); }, [duration]); async function updateTimeValues(recheck = true) { @@ -89,7 +155,7 @@ export const ResultsLinks: FC = ({ /> } description="" - href={`${basePath.get()}/app/discover#/?&_a=(index:'${indexPatternId}')${globalStateString}`} + href={discoverLink} /> )} @@ -108,7 +174,7 @@ export const ResultsLinks: FC = ({ /> } description="" - href={`#/jobs/new_job/step/job_type?index=${indexPatternId}${globalStateString}`} + onClick={redirectToADCreateJobsSelectTypePage} /> )} @@ -124,7 +190,7 @@ export const ResultsLinks: FC = ({ /> } description="" - href={`#/jobs/new_job/datavisualizer?index=${indexPatternId}${globalStateString}`} + onClick={openInDataVisualizer} /> )} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx index 1f2c97b128e3f..ab738ca0f1545 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx @@ -9,11 +9,11 @@ import React, { FC, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiText, EuiTitle, EuiFlexGroup } from '@elastic/eui'; - +import { Link } from 'react-router-dom'; import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; import { CreateJobLinkCard } from '../../../../components/create_job_link_card'; import { DataRecognizer } from '../../../../components/data_recognizer'; -import { getBasePath } from '../../../../util/dependency_cache'; +import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator'; interface Props { indexPattern: IndexPattern; @@ -21,7 +21,6 @@ interface Props { export const ActionsPanel: FC = ({ indexPattern }) => { const [recognizerResultsCount, setRecognizerResultsCount] = useState(0); - const basePath = getBasePath(); const recognizerResults = { count: 0, @@ -29,12 +28,7 @@ export const ActionsPanel: FC = ({ indexPattern }) => { setRecognizerResultsCount(recognizerResults.count); }, }; - - function openAdvancedJobWizard() { - // TODO - pass the search string to the advanced job page as well as the index pattern - // (add in with new advanced job wizard?) - window.open(`${basePath.get()}/app/ml/jobs/new_job/advanced?index=${indexPattern.id}`, '_self'); - } + const createJobLink = `/${ML_PAGES.ANOMALY_DETECTION_CREATE_JOB}/advanced?index=${indexPattern.id}`; // Note we use display:none for the DataRecognizer section as it needs to be // passed the recognizerResults object, and then run the recognizer check which @@ -78,19 +72,19 @@ export const ActionsPanel: FC = ({ indexPattern }) => {

- + + +
); }; diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/__snapshots__/explorer_no_jobs_found.test.js.snap b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/__snapshots__/explorer_no_jobs_found.test.js.snap index c6503a639997d..826f7b707cfdf 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/__snapshots__/explorer_no_jobs_found.test.js.snap +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/__snapshots__/explorer_no_jobs_found.test.js.snap @@ -3,17 +3,20 @@ exports[`ExplorerNoInfluencersFound snapshot 1`] = ` - - + + + + } data-test-subj="mlNoJobsFound" iconType="alert" diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/explorer_no_jobs_found.js b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/explorer_no_jobs_found.js index 6f391f9746f23..029ca0475015f 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/explorer_no_jobs_found.js +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/explorer_no_jobs_found.js @@ -7,25 +7,40 @@ /* * React component for rendering EuiEmptyPrompt when no jobs were found. */ - +import { Link } from 'react-router-dom'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; - import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; +import { useMlLink } from '../../../contexts/kibana/use_create_url'; -export const ExplorerNoJobsFound = () => ( - - - - } - actions={ - - - - } - data-test-subj="mlNoJobsFound" - /> -); +export const ExplorerNoJobsFound = () => { + const ADJobsManagementUrl = useMlLink({ + page: ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, + excludeBasePath: true, + }); + return ( + + + + } + actions={ + + + + + + } + data-test-subj="mlNoJobsFound" + /> + ); +}; diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/explorer_no_jobs_found.test.js b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/explorer_no_jobs_found.test.js index bcb11cad9674c..c9645b787a8e0 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/explorer_no_jobs_found.test.js +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/explorer_no_jobs_found.test.js @@ -8,6 +8,9 @@ import React from 'react'; import { shallow } from 'enzyme'; import { ExplorerNoJobsFound } from './explorer_no_jobs_found'; +jest.mock('../../../contexts/kibana/use_create_url', () => ({ + useMlLink: jest.fn().mockReturnValue('/jobs'), +})); describe('ExplorerNoInfluencersFound', () => { test('snapshot', () => { const wrapper = shallow(); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js index 4fb783bfb6006..8f03b1903800a 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { EuiButtonEmpty, @@ -28,6 +28,10 @@ import { CHART_TYPE } from '../explorer_constants'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { MlTooltipComponent } from '../../components/chart_tooltip'; +import { withKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { ML_APP_URL_GENERATOR } from '../../../../common/constants/ml_url_generator'; +import { PLUGIN_ID } from '../../../../common/constants/app'; +import { addItemToRecentlyAccessed } from '../../util/recently_accessed'; const textTooManyBuckets = i18n.translate('xpack.ml.explorer.charts.tooManyBucketsDescription', { defaultMessage: @@ -51,7 +55,23 @@ function getChartId(series) { } // Wrapper for a single explorer chart -function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) { +function ExplorerChartContainer({ + series, + severity, + tooManyBuckets, + wrapLabel, + navigateToApp, + mlUrlGenerator, +}) { + const redirectToSingleMetricViewer = useCallback(async () => { + const singleMetricViewerLink = await getExploreSeriesLink(mlUrlGenerator, series); + addItemToRecentlyAccessed('timeseriesexplorer', series.jobId, singleMetricViewerLink); + + await navigateToApp(PLUGIN_ID, { + path: singleMetricViewerLink, + }); + }, [mlUrlGenerator]); + const { detectorLabel, entityFields } = series; const chartType = getChartType(series); @@ -106,7 +126,7 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) iconSide="right" iconType="visLine" size="xs" - onClick={() => window.open(getExploreSeriesLink(series), '_blank')} + onClick={redirectToSingleMetricViewer} > @@ -150,12 +170,24 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) } // Flex layout wrapper for all explorer charts -export const ExplorerChartsContainer = ({ +export const ExplorerChartsContainerUI = ({ chartsPerRow, seriesToPlot, severity, tooManyBuckets, + kibana, }) => { + const { + services: { + application: { navigateToApp }, + + share: { + urlGenerators: { getUrlGenerator }, + }, + }, + } = kibana; + const mlUrlGenerator = getUrlGenerator(ML_APP_URL_GENERATOR); + // doesn't allow a setting of `columns={1}` when chartsPerRow would be 1. // If that's the case we trick it doing that with the following settings: const chartsWidth = chartsPerRow === 1 ? 'calc(100% - 20px)' : 'auto'; @@ -177,9 +209,13 @@ export const ExplorerChartsContainer = ({ severity={severity} tooManyBuckets={tooManyBuckets} wrapLabel={wrapLabel} + navigateToApp={navigateToApp} + mlUrlGenerator={mlUrlGenerator} /> ))} ); }; + +export const ExplorerChartsContainer = withKibana(ExplorerChartsContainerUI); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js index 8257ac2b3a703..2da212c8f2f29 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js @@ -40,6 +40,12 @@ jest.mock('../../services/job_service', () => ({ }, })); +jest.mock('../../../../../../../src/plugins/kibana_react/public', () => ({ + withKibana: (comp) => { + return comp; + }, +})); + describe('ExplorerChartsContainer', () => { const mockedGetBBox = { x: 0, y: -11.5, width: 12.1875, height: 14.5 }; const originalGetBBox = SVGElement.prototype.getBBox; @@ -47,10 +53,22 @@ describe('ExplorerChartsContainer', () => { beforeEach(() => (SVGElement.prototype.getBBox = () => mockedGetBBox)); afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox)); + const kibanaContextMock = { + services: { + application: { navigateToApp: jest.fn() }, + share: { + urlGenerators: { getUrlGenerator: jest.fn() }, + }, + }, + }; test('Minimal Initialization', () => { const wrapper = shallow( - + ); @@ -71,10 +89,11 @@ describe('ExplorerChartsContainer', () => { ], chartsPerRow: 1, tooManyBuckets: false, + severity: 10, }; const wrapper = mount( - + ); @@ -98,10 +117,11 @@ describe('ExplorerChartsContainer', () => { ], chartsPerRow: 1, tooManyBuckets: false, + severity: 10, }; const wrapper = mount( - + ); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js index d0d0442dd4aee..85a342838a506 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js @@ -5,13 +5,20 @@ */ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useMemo } from 'react'; import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useCreateADLinks } from '../../../../components/custom_hooks/use_create_ad_links'; +import { Link } from 'react-router-dom'; +import { useMlKibana } from '../../../../contexts/kibana'; -export function ResultLinks({ jobs }) { +export function ResultLinks({ jobs, isManagementTable }) { + const { + services: { + http: { basePath }, + }, + } = useMlKibana(); const openJobsInSingleMetricViewerText = i18n.translate( 'xpack.ml.jobsList.resultActions.openJobsInSingleMetricViewerText', { @@ -37,29 +44,59 @@ export function ResultLinks({ jobs }) { const singleMetricEnabled = jobs.length === 1 && jobs[0].isSingleMetricViewerJob; const jobActionsDisabled = jobs.length === 1 && jobs[0].deleting === true; const { createLinkWithUserDefaults } = useCreateADLinks(); + const timeSeriesExplorerLink = useMemo( + () => createLinkWithUserDefaults('timeseriesexplorer', jobs), + [jobs] + ); + const anomalyExplorerLink = useMemo(() => createLinkWithUserDefaults('explorer', jobs), [jobs]); + return ( {singleMetricVisible && ( + {isManagementTable ? ( + + ) : ( + + + + )} + + )} + + {isManagementTable ? ( - - )} - - + ) : ( + + + + )}
diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js index 8f89c4a049189..73b212b97b4cc 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js @@ -5,10 +5,10 @@ */ import React from 'react'; -import { EuiLink } from '@elastic/eui'; import { detectorToString } from '../../../../util/string_utils'; import { formatValues, filterObjects } from './format_values'; import { i18n } from '@kbn/i18n'; +import { Link } from 'react-router-dom'; export function extractJobDetails(job) { if (Object.keys(job).length === 0) { @@ -61,7 +61,7 @@ export function extractJobDetails(job) { if (job.calendars) { calendars.items = job.calendars.map((c) => [ '', - {c}, + {c}, ]); // remove the calendars list from the general section // so not to show it twice. diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js index b6157c8694a18..b32070fff73aa 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js @@ -5,8 +5,6 @@ */ import PropTypes from 'prop-types'; -import rison from 'rison-node'; - import React, { Component } from 'react'; import { @@ -30,13 +28,19 @@ import { getLatestDataOrBucketTimestamp, isTimeSeriesViewJob, } from '../../../../../../../common/util/job_utils'; +import { withKibana } from '../../../../../../../../../../src/plugins/kibana_react/public'; +import { + ML_APP_URL_GENERATOR, + ML_PAGES, +} from '../../../../../../../common/constants/ml_url_generator'; +import { PLUGIN_ID } from '../../../../../../../common/constants/app'; const MAX_FORECASTS = 500; /** * Table component for rendering the lists of forecasts run on an ML job. */ -export class ForecastsTable extends Component { +export class ForecastsTableUI extends Component { constructor(props) { super(props); this.state = { @@ -78,7 +82,17 @@ export class ForecastsTable extends Component { } } - openSingleMetricView(forecast) { + async openSingleMetricView(forecast) { + const { + services: { + application: { navigateToApp }, + + share: { + urlGenerators: { getUrlGenerator }, + }, + }, + } = this.props.kibana; + // Creates the link to the Single Metric Viewer. // Set the total time range from the start of the job data to the end of the forecast, const dataCounts = this.props.job.data_counts; @@ -93,31 +107,7 @@ export class ForecastsTable extends Component { ? new Date(forecast.forecast_end_timestamp).toISOString() : new Date(resultLatest).toISOString(); - const _g = rison.encode({ - ml: { - jobIds: [this.props.job.job_id], - }, - refreshInterval: { - display: 'Off', - pause: false, - value: 0, - }, - time: { - from, - to, - mode: 'absolute', - }, - }); - - const appState = { - query: { - query_string: { - analyze_wildcard: true, - query: '*', - }, - }, - }; - + let mlTimeSeriesExplorer = {}; if (forecast !== undefined) { // Set the zoom to show duration before the forecast equal to the length of the forecast. const forecastDurationMs = @@ -126,8 +116,7 @@ export class ForecastsTable extends Component { forecast.forecast_start_timestamp - forecastDurationMs, jobEarliest ); - - appState.mlTimeSeriesExplorer = { + mlTimeSeriesExplorer = { forecastId: forecast.forecast_id, zoom: { from: new Date(zoomFrom).toISOString(), @@ -136,11 +125,39 @@ export class ForecastsTable extends Component { }; } - const _a = rison.encode(appState); - - const url = `?_g=${_g}&_a=${_a}`; - addItemToRecentlyAccessed('timeseriesexplorer', this.props.job.job_id, url); - window.open(`#/timeseriesexplorer${url}`, '_self'); + const mlUrlGenerator = getUrlGenerator(ML_APP_URL_GENERATOR); + const singleMetricViewerForecastLink = await mlUrlGenerator.createUrl({ + page: ML_PAGES.SINGLE_METRIC_VIEWER, + pageState: { + timeRange: { + from, + to, + mode: 'absolute', + }, + refreshInterval: { + display: 'Off', + pause: false, + value: 0, + }, + jobIds: [this.props.job.job_id], + query: { + query_string: { + analyze_wildcard: true, + query: '*', + }, + }, + ...mlTimeSeriesExplorer, + }, + excludeBasePath: true, + }); + addItemToRecentlyAccessed( + 'timeseriesexplorer', + this.props.job.job_id, + singleMetricViewerForecastLink + ); + await navigateToApp(PLUGIN_ID, { + path: singleMetricViewerForecastLink, + }); } render() { @@ -322,6 +339,8 @@ export class ForecastsTable extends Component { ); } } -ForecastsTable.propTypes = { +ForecastsTableUI.propTypes = { job: PropTypes.object.isRequired, }; + +export const ForecastsTable = withKibana(ForecastsTableUI); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/job_description.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/job_description.js index a5469357ba1a1..8b5d6009cc61e 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/job_description.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/job_description.js @@ -8,7 +8,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import { JobGroup } from '../job_group'; -import { getGroupIdsUrl, TAB_IDS } from '../../../../util/get_selected_ids_url'; +import { AnomalyDetectionJobIdLink } from './job_id_link'; export function JobDescription({ job, isManagementTable }) { return ( @@ -17,11 +17,7 @@ export function JobDescription({ job, isManagementTable }) { {job.description}   {job.groups.map((group) => { if (isManagementTable === true) { - return ( - - - - ); + return ; } return ; })} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/job_id_link.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/job_id_link.tsx new file mode 100644 index 0000000000000..0e84619899d71 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/job_id_link.tsx @@ -0,0 +1,63 @@ +/* + * 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 React from 'react'; +import { EuiLink } from '@elastic/eui'; +import { useMlKibana, useMlUrlGenerator } from '../../../../contexts/kibana'; +import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator'; +import { AnomalyDetectionQueryState } from '../../../../../../common/types/ml_url_generator'; +// @ts-ignore +import { JobGroup } from '../job_group'; + +interface JobIdLink { + id: string; +} + +interface GroupIdLink { + groupId: string; + children: string; +} + +type AnomalyDetectionJobIdLinkProps = JobIdLink | GroupIdLink; + +function isGroupIdLink(props: JobIdLink | GroupIdLink): props is GroupIdLink { + return (props as GroupIdLink).groupId !== undefined; +} +export const AnomalyDetectionJobIdLink = (props: AnomalyDetectionJobIdLinkProps) => { + const mlUrlGenerator = useMlUrlGenerator(); + const { + services: { + application: { navigateToUrl }, + }, + } = useMlKibana(); + + const redirectToJobsManagementPage = async () => { + const pageState: AnomalyDetectionQueryState = {}; + if (isGroupIdLink(props)) { + pageState.groupIds = [props.groupId]; + } else { + pageState.jobId = props.id; + } + const url = await mlUrlGenerator.createUrl({ + page: ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, + pageState, + }); + await navigateToUrl(url); + }; + if (isGroupIdLink(props)) { + return ( + redirectToJobsManagementPage()}> + + + ); + } else { + return ( + redirectToJobsManagementPage()}> + {props.id} + + ); + } +}; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js index fa4ea09b89ff9..8bc0057b27d6d 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js @@ -14,12 +14,12 @@ import { toLocaleString } from '../../../../util/string_utils'; import { ResultLinks, actionsMenuContent } from '../job_actions'; import { JobDescription } from './job_description'; import { JobIcon } from '../../../../components/job_message_icon'; -import { getJobIdUrl, TAB_IDS } from '../../../../util/get_selected_ids_url'; import { TIME_FORMAT } from '../../../../../../common/constants/time_format'; -import { EuiBadge, EuiBasicTable, EuiButtonIcon, EuiLink, EuiScreenReaderOnly } from '@elastic/eui'; +import { EuiBadge, EuiBasicTable, EuiButtonIcon, EuiScreenReaderOnly } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { AnomalyDetectionJobIdLink } from './job_id_link'; const PAGE_SIZE = 10; const PAGE_SIZE_OPTIONS = [10, 25, 50]; @@ -71,7 +71,7 @@ export class JobsList extends Component { return id; } - return {id}; + return ; } getPageOfJobs(index, size, sortField, sortDirection) { @@ -241,7 +241,7 @@ export class JobsList extends Component { name: i18n.translate('xpack.ml.jobsList.actionsLabel', { defaultMessage: 'Actions', }), - render: (item) => , + render: (item) => , }, ]; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/new_job_button/new_job_button.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/new_job_button/new_job_button.js index fdffa8b38ae04..81effe8d3ebeb 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/new_job_button/new_job_button.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/new_job_button/new_job_button.js @@ -11,13 +11,13 @@ import React from 'react'; import { EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; - -function newJob() { - window.location.href = `#/jobs/new_job`; -} +import { useCreateAndNavigateToMlLink } from '../../../../contexts/kibana/use_create_url'; +import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator'; export function NewJobButton() { const buttonEnabled = checkPermission('canCreateJob') && mlNodesAvailable(); + const newJob = useCreateAndNavigateToMlLink(ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX); + return ( { const { @@ -73,7 +74,7 @@ export const CalendarsSelection: FC = () => { }; const manageCalendarsHref = getUrlForApp(PLUGIN_ID, { - path: '/settings/calendars_list', + path: ML_PAGES.CALENDARS_MANAGE, }); return ( diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/summary.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/summary.tsx index 669b8837e74b5..021039c06e320 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/summary.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/summary.tsx @@ -39,7 +39,10 @@ import { JobSectionTitle, DatafeedSectionTitle } from './components/common'; export const SummaryStep: FC = ({ setCurrentStep, isCurrentStep }) => { const { - services: { notifications }, + services: { + notifications, + http: { basePath }, + }, } = useMlKibana(); const navigateToPath = useNavigateToPath(); @@ -108,7 +111,7 @@ export const SummaryStep: FC = ({ setCurrentStep, isCurrentStep }) => jobCreator.end, isSingleMetricJobCreator(jobCreator) === true ? 'timeseriesexplorer' : 'explorer' ); - window.open(url, '_blank'); + navigateToPath(`${basePath.get()}/app/ml/${url}`); } function clickResetJob() { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts b/x-pack/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts index 69df2773f9f8d..cedaaa3b5dfaa 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts @@ -4,19 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ApplicationStart } from 'kibana/public'; import { IndexPatternsContract } from '../../../../../../../../../src/plugins/data/public'; import { mlJobService } from '../../../../services/job_service'; import { loadIndexPatterns, getIndexPatternIdFromName } from '../../../../util/index_utils'; import { CombinedJob } from '../../../../../../common/types/anomaly_detection_jobs'; import { CREATED_BY_LABEL, JOB_TYPE } from '../../../../../../common/constants/new_job'; -export async function preConfiguredJobRedirect(indexPatterns: IndexPatternsContract) { +export async function preConfiguredJobRedirect( + indexPatterns: IndexPatternsContract, + basePath: string, + navigateToUrl: ApplicationStart['navigateToUrl'] +) { const { job } = mlJobService.tempJobCloningObjects; if (job) { try { await loadIndexPatterns(indexPatterns); const redirectUrl = getWizardUrlFromCloningJob(job); - window.location.href = `#/${redirectUrl}`; + await navigateToUrl(`${basePath}/app/ml/${redirectUrl}`); return Promise.reject(); } catch (error) { return Promise.resolve(); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx index be0135ec3f1e0..1a91f6d51ed4d 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx @@ -19,6 +19,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { useNavigateToPath } from '../../../../contexts/kibana'; + import { useMlContext } from '../../../../contexts/ml'; import { isSavedSearchSavedObject } from '../../../../../../common/types/kibana'; import { DataRecognizer } from '../../../../components/data_recognizer'; @@ -26,10 +27,15 @@ import { addItemToRecentlyAccessed } from '../../../../util/recently_accessed'; import { timeBasedIndexCheck } from '../../../../util/index_utils'; import { CreateJobLinkCard } from '../../../../components/create_job_link_card'; import { CategorizationIcon } from './categorization_job_icon'; +import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator'; +import { useCreateAndNavigateToMlLink } from '../../../../contexts/kibana/use_create_url'; export const Page: FC = () => { const mlContext = useMlContext(); const navigateToPath = useNavigateToPath(); + const onSelectDifferentIndex = useCreateAndNavigateToMlLink( + ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX + ); const [recognizerResultsCount, setRecognizerResultsCount] = useState(0); @@ -193,7 +199,7 @@ export const Page: FC = () => { defaultMessage="Anomaly detection can only be run over indices which are time based." />
- + = ({ moduleId, existingGroupIds }) => { const { services: { notifications }, } = useMlKibana(); + const urlGenerator = useMlUrlGenerator(); + // #region State const [jobPrefix, setJobPrefix] = useState(''); const [jobs, setJobs] = useState([]); @@ -185,14 +189,20 @@ export const Page: FC = ({ moduleId, existingGroupIds }) => { }) ); setKibanaObjects(merge(kibanaObjects, kibanaResponse)); - setResultsUrl( - mlJobService.createResultsUrl( - jobsResponse.filter(({ success }) => success).map(({ id }) => id), - resultTimeRange.start, - resultTimeRange.end, - 'explorer' - ) - ); + + const url = await urlGenerator.createUrl({ + page: ML_PAGES.ANOMALY_EXPLORER, + pageState: { + jobIds: jobsResponse.filter(({ success }) => success).map(({ id }) => id), + timeRange: { + from: moment(resultTimeRange.start).format(TIME_FORMAT), + to: moment(resultTimeRange.end).format(TIME_FORMAT), + mode: 'absolute', + }, + }, + }); + + setResultsUrl(url); const failedJobsCount = jobsResponse.reduce((count, { success }) => { return success ? count : count + 1; }, 0); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/resolvers.ts b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/resolvers.ts index e3b0fd4cefe0c..97a03fa21035f 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/resolvers.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/resolvers.ts @@ -6,33 +6,40 @@ import { i18n } from '@kbn/i18n'; import { getToastNotifications, getSavedObjectsClient } from '../../../util/dependency_cache'; -import { mlJobService } from '../../../services/job_service'; import { ml } from '../../../services/ml_api_service'; import { KibanaObjects } from './page'; +import { NavigateToPath } from '../../../contexts/kibana'; +import { CreateLinkWithUserDefaults } from '../../../components/custom_hooks/use_create_ad_links'; /** * Checks whether the jobs in a data recognizer module have been created. * Redirects to the Anomaly Explorer to view the jobs if they have been created, * or the recognizer job wizard for the module if not. */ -export function checkViewOrCreateJobs(moduleId: string, indexPatternId: string): Promise { +export function checkViewOrCreateJobs( + moduleId: string, + indexPatternId: string, + createLinkWithUserDefaults: CreateLinkWithUserDefaults, + navigateToPath: NavigateToPath +): Promise { return new Promise((resolve, reject) => { // Load the module, and check if the job(s) in the module have been created. // If so, load the jobs in the Anomaly Explorer. // Otherwise open the data recognizer wizard for the module. // Always want to call reject() so as not to load original page. ml.dataRecognizerModuleJobsExist({ moduleId }) - .then((resp: any) => { + .then(async (resp: any) => { if (resp.jobsExist === true) { - const resultsPageUrl = mlJobService.createResultsUrlForJobs(resp.jobs, 'explorer'); - window.location.href = resultsPageUrl; + // also honor user's time filter setting in Advanced Settings + const url = createLinkWithUserDefaults('explorer', resp.jobs); + await navigateToPath(url); reject(); } else { - window.location.href = `#/jobs/new_job/recognize?id=${moduleId}&index=${indexPatternId}`; + await navigateToPath(`/jobs/new_job/recognize?id=${moduleId}&index=${indexPatternId}`); reject(); } }) - .catch((err: Error) => { + .catch(async (err: Error) => { // eslint-disable-next-line no-console console.error(`Error checking whether jobs in module ${moduleId} exists`, err); const toastNotifications = getToastNotifications(); @@ -46,8 +53,7 @@ export function checkViewOrCreateJobs(moduleId: string, indexPatternId: string): 'An error occurred trying to check whether the jobs in the module have been created.', }), }); - - window.location.href = '#/jobs'; + await navigateToPath(`/jobs`); reject(); }); }); diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index 0af6030df28b1..9c9096dfdfc21 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -31,6 +31,7 @@ import { getDocLinks } from '../../../../util/dependency_cache'; import { JobsListView } from '../../../../jobs/jobs_list/components/jobs_list_view/index'; import { DataFrameAnalyticsList } from '../../../../data_frame_analytics/pages/analytics_management/components/analytics_list'; import { AccessDeniedPage } from '../access_denied_page'; +import { SharePluginStart } from '../../../../../../../../../src/plugins/share/public'; interface Tab { 'data-test-subj': string; @@ -75,8 +76,9 @@ function getTabs(isMlEnabledInSpace: boolean): Tab[] { export const JobsListPage: FC<{ coreStart: CoreStart; + share: SharePluginStart; history: ManagementAppMountParams['history']; -}> = ({ coreStart, history }) => { +}> = ({ coreStart, share, history }) => { const [initialized, setInitialized] = useState(false); const [accessDenied, setAccessDenied] = useState(false); const [isMlEnabledInSpace, setIsMlEnabledInSpace] = useState(false); @@ -136,7 +138,7 @@ export const JobsListPage: FC<{ return ( - + { - ReactDOM.render(React.createElement(JobsListPage, { coreStart, history }), element); + ReactDOM.render(React.createElement(JobsListPage, { coreStart, history, share }), element); return () => { unmountComponentAtNode(element); clearCache(); @@ -30,7 +32,7 @@ export async function mountApp( core: CoreSetup, params: ManagementAppMountParams ) { - const [coreStart] = await core.getStartServices(); + const [coreStart, pluginsStart] = await core.getStartServices(); setDependencyCache({ docLinks: coreStart.docLinks!, @@ -41,5 +43,5 @@ export async function mountApp( params.setBreadcrumbs(getJobsListBreadcrumbs()); - return renderApp(params.element, params.history, coreStart); + return renderApp(params.element, params.history, coreStart, pluginsStart.share); } diff --git a/x-pack/plugins/ml/public/application/ml_nodes_check/check_ml_nodes.ts b/x-pack/plugins/ml/public/application/ml_nodes_check/check_ml_nodes.ts index 1792999eee4c2..d0cfd16d7562f 100644 --- a/x-pack/plugins/ml/public/application/ml_nodes_check/check_ml_nodes.ts +++ b/x-pack/plugins/ml/public/application/ml_nodes_check/check_ml_nodes.ts @@ -9,7 +9,7 @@ import { ml } from '../services/ml_api_service'; let mlNodeCount: number = 0; let userHasPermissionToViewMlNodeCount: boolean = false; -export async function checkMlNodesAvailable() { +export async function checkMlNodesAvailable(redirectToJobsManagementPage: () => Promise) { try { const nodes = await getMlNodeCount(); if (nodes.count !== undefined && nodes.count > 0) { @@ -20,7 +20,7 @@ export async function checkMlNodesAvailable() { } catch (error) { // eslint-disable-next-line no-console console.error(error); - window.location.href = '#/jobs'; + await redirectToJobsManagementPage(); Promise.reject(); } } diff --git a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/actions.tsx b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/actions.tsx index 395a570083c0d..4f0cbc0adddf2 100644 --- a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/actions.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/actions.tsx @@ -4,30 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, FC } from 'react'; +import React, { FC, useMemo } from 'react'; import { EuiToolTip, EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useNavigateToPath } from '../../../contexts/kibana'; +import { Link } from 'react-router-dom'; +import { useMlLink } from '../../../contexts/kibana'; import { getAnalysisType } from '../../../data_frame_analytics/common/analytics'; -import { - getResultsUrl, - DataFrameAnalyticsListRow, -} from '../../../data_frame_analytics/pages/analytics_management/components/analytics_list/common'; +import { DataFrameAnalyticsListRow } from '../../../data_frame_analytics/pages/analytics_management/components/analytics_list/common'; import { getViewLinkStatus } from '../../../data_frame_analytics/pages/analytics_management/components/action_view/get_view_link_status'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; +import { DataFrameAnalysisConfigType } from '../../../../../common/types/data_frame_analytics'; interface Props { item: DataFrameAnalyticsListRow; } export const ViewLink: FC = ({ item }) => { - const navigateToPath = useNavigateToPath(); - - const clickHandler = useCallback(() => { - const analysisType = getAnalysisType(item.config.analysis); - navigateToPath(getResultsUrl(item.id, analysisType)); - }, []); - const { disabled, tooltipContent } = getViewLinkStatus(item); const viewJobResultsButtonText = i18n.translate( @@ -38,23 +31,34 @@ export const ViewLink: FC = ({ item }) => { ); const tooltipText = disabled === false ? viewJobResultsButtonText : tooltipContent; + const analysisType = useMemo(() => getAnalysisType(item.config.analysis), [item]); + + const viewAnalyticsResultsLink = useMlLink({ + page: ML_PAGES.DATA_FRAME_ANALYTICS_EXPLORATION, + pageState: { + jobId: item.id, + analysisType: analysisType as DataFrameAnalysisConfigType, + }, + excludeBasePath: true, + }); return ( - - {i18n.translate('xpack.ml.overview.analytics.viewActionName', { - defaultMessage: 'View', - })} - + + + {i18n.translate('xpack.ml.overview.analytics.viewActionName', { + defaultMessage: 'View', + })} + + ); }; diff --git a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx index be8038cc5049d..4d810c47415a7 100644 --- a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx @@ -23,6 +23,8 @@ import { AnalyticsTable } from './table'; import { getAnalyticsFactory } from '../../../data_frame_analytics/pages/analytics_management/services/analytics_service'; import { DataFrameAnalyticsListRow } from '../../../data_frame_analytics/pages/analytics_management/components/analytics_list/common'; import { AnalyticStatsBarStats, StatsBar } from '../../../components/stats_bar'; +import { useMlUrlGenerator, useNavigateToPath } from '../../../contexts/kibana'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; interface Props { jobCreationDisabled: boolean; @@ -35,6 +37,16 @@ export const AnalyticsPanel: FC = ({ jobCreationDisabled }) => { const [errorMessage, setErrorMessage] = useState(undefined); const [isInitialized, setIsInitialized] = useState(false); + const mlUrlGenerator = useMlUrlGenerator(); + const navigateToPath = useNavigateToPath(); + + const redirectToDataFrameAnalyticsManagementPage = async () => { + const path = await mlUrlGenerator.createUrl({ + page: ML_PAGES.DATA_FRAME_ANALYTICS_JOBS_MANAGE, + }); + await navigateToPath(path, true); + }; + const getAnalytics = getAnalyticsFactory( setAnalytics, setAnalyticsStats, @@ -75,7 +87,6 @@ export const AnalyticsPanel: FC = ({ jobCreationDisabled }) => { {isInitialized === false && ( )} -      {errorMessage === undefined && isInitialized === true && analytics.length === 0 && ( = ({ jobCreationDisabled }) => { } actions={ = ({ jobCreationDisabled }) => { )} {isInitialized === true && analytics.length > 0 && ( <> + @@ -136,7 +148,7 @@ export const AnalyticsPanel: FC = ({ jobCreationDisabled }) => { defaultMessage: 'Refresh', })} - + {i18n.translate('xpack.ml.overview.analyticsList.manageJobsButtonText', { defaultMessage: 'Manage jobs', })} diff --git a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/actions.tsx b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/actions.tsx index a71141d0356d0..dfba7c9651266 100644 --- a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/actions.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/actions.tsx @@ -7,6 +7,7 @@ import React, { FC } from 'react'; import { EuiToolTip, EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { Link } from 'react-router-dom'; import { MlSummaryJobs } from '../../../../../common/types/anomaly_detection_jobs'; import { useCreateADLinks } from '../../../components/custom_hooks/use_create_ad_links'; @@ -26,19 +27,20 @@ export const ExplorerLink: FC = ({ jobsList }) => { return ( - - {i18n.translate('xpack.ml.overview.anomalyDetection.viewActionName', { - defaultMessage: 'View', - })} - + + + {i18n.translate('xpack.ml.overview.anomalyDetection.viewActionName', { + defaultMessage: 'View', + })} + + ); }; diff --git a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx index 0bfd2c2e49232..1cb6bab7fd768 100644 --- a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx @@ -16,12 +16,13 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; -import { useMlKibana } from '../../../contexts/kibana'; +import { useMlKibana, useMlUrlGenerator, useNavigateToPath } from '../../../contexts/kibana'; import { AnomalyDetectionTable } from './table'; import { ml } from '../../../services/ml_api_service'; import { getGroupsFromJobs, getStatsBarData, getJobsWithTimerange } from './utils'; import { Dictionary } from '../../../../../common/types/common'; import { MlSummaryJobs, MlSummaryJob } from '../../../../../common/types/anomaly_detection_jobs'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; export type GroupsDictionary = Dictionary; @@ -39,8 +40,6 @@ type MaxScoresByGroup = Dictionary<{ index?: number; }>; -const createJobLink = '#/jobs/new_job/step/index_or_search'; - function getDefaultAnomalyScores(groups: Group[]): MaxScoresByGroup { const anomalyScores: MaxScoresByGroup = {}; groups.forEach((group) => { @@ -58,6 +57,23 @@ export const AnomalyDetectionPanel: FC = ({ jobCreationDisabled }) => { const { services: { notifications }, } = useMlKibana(); + const mlUrlGenerator = useMlUrlGenerator(); + const navigateToPath = useNavigateToPath(); + + const redirectToJobsManagementPage = async () => { + const path = await mlUrlGenerator.createUrl({ + page: ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, + }); + await navigateToPath(path, true); + }; + + const redirectToCreateJobSelectIndexPage = async () => { + const path = await mlUrlGenerator.createUrl({ + page: ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX, + }); + await navigateToPath(path, true); + }; + const [isLoading, setIsLoading] = useState(false); const [groups, setGroups] = useState({}); const [groupsCount, setGroupsCount] = useState(0); @@ -157,7 +173,7 @@ export const AnomalyDetectionPanel: FC = ({ jobCreationDisabled }) => { return ( {typeof errorMessage !== 'undefined' && errorDisplay} - {isLoading && }    + {isLoading && } {isLoading === false && typeof errorMessage === 'undefined' && groupsCount === 0 && ( = ({ jobCreationDisabled }) => { actions={ = ({ jobCreationDisabled }) => { defaultMessage: 'Refresh', })} - + {i18n.translate('xpack.ml.overview.anomalyDetection.manageJobsButtonText', { defaultMessage: 'Manage jobs', })} diff --git a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/table.tsx b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/table.tsx index 945116b0534bb..8515431d49b17 100644 --- a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/table.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/table.tsx @@ -88,7 +88,7 @@ export const AnomalyDetectionTable: FC = ({ items, jobsList, statsBarData {i18n.translate('xpack.ml.overview.anomalyDetection.tableMaxScore', { defaultMessage: 'Max anomaly score', - })}{' '} + })} @@ -203,6 +203,7 @@ export const AnomalyDetectionTable: FC = ({ items, jobsList, statsBarData return ( + diff --git a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts index d0a4f999af758..398ec5b4759d2 100644 --- a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts +++ b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts @@ -54,6 +54,20 @@ export const CREATE_JOB_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ href: '/jobs/new_job', }); +export const CALENDAR_MANAGEMENT_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ + text: i18n.translate('xpack.ml.settings.breadcrumbs.calendarManagementLabel', { + defaultMessage: 'Calendar management', + }), + href: '/settings/calendars_list', +}); + +export const FILTER_LISTS_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ + text: i18n.translate('xpack.ml.settings.breadcrumbs.filterListsLabel', { + defaultMessage: 'Filter lists', + }), + href: '/settings/filter_lists', +}); + const breadcrumbs = { ML_BREADCRUMB, SETTINGS_BREADCRUMB, @@ -61,6 +75,8 @@ const breadcrumbs = { DATA_FRAME_ANALYTICS_BREADCRUMB, DATA_VISUALIZER_BREADCRUMB, CREATE_JOB_BREADCRUMB, + CALENDAR_MANAGEMENT_BREADCRUMB, + FILTER_LISTS_BREADCRUMB, }; type Breadcrumb = keyof typeof breadcrumbs; @@ -76,10 +92,12 @@ export const breadcrumbOnClickFactory = ( export const getBreadcrumbWithUrlForApp = ( breadcrumbName: Breadcrumb, - navigateToPath: NavigateToPath + navigateToPath: NavigateToPath, + basePath: string ): EuiBreadcrumb => { return { - ...breadcrumbs[breadcrumbName], + text: breadcrumbs[breadcrumbName].text, + href: `${basePath}/app/ml${breadcrumbs[breadcrumbName].href}`, onClick: breadcrumbOnClickFactory(breadcrumbs[breadcrumbName].href, navigateToPath), }; }; diff --git a/x-pack/plugins/ml/public/application/routing/resolvers.ts b/x-pack/plugins/ml/public/application/routing/resolvers.ts index 958221df8a636..9cebb67166a66 100644 --- a/x-pack/plugins/ml/public/application/routing/resolvers.ts +++ b/x-pack/plugins/ml/public/application/routing/resolvers.ts @@ -21,13 +21,17 @@ export interface ResolverResults { interface BasicResolverDependencies { indexPatterns: IndexPatternsContract; + redirectToMlAccessDeniedPage: () => Promise; } -export const basicResolvers = ({ indexPatterns }: BasicResolverDependencies): Resolvers => ({ +export const basicResolvers = ({ + indexPatterns, + redirectToMlAccessDeniedPage, +}: BasicResolverDependencies): Resolvers => ({ checkFullLicense, getMlNodeCount, loadMlServerInfo, loadIndexPatterns: () => loadIndexPatterns(indexPatterns), - checkGetJobsCapabilities: checkGetJobsCapabilitiesResolver, + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), loadSavedSearches, }); diff --git a/x-pack/plugins/ml/public/application/routing/router.tsx b/x-pack/plugins/ml/public/application/routing/router.tsx index 22a17c4ea089a..7cb3a2f07c2ee 100644 --- a/x-pack/plugins/ml/public/application/routing/router.tsx +++ b/x-pack/plugins/ml/public/application/routing/router.tsx @@ -12,7 +12,7 @@ import { AppMountParameters, IUiSettingsClient, ChromeStart } from 'kibana/publi import { ChromeBreadcrumb } from 'kibana/public'; import { IndexPatternsContract } from 'src/plugins/data/public'; -import { useNavigateToPath } from '../contexts/kibana'; +import { useMlKibana, useNavigateToPath } from '../contexts/kibana'; import { MlContext, MlContextValue } from '../contexts/ml'; import { UrlStateProvider } from '../util/url_state'; @@ -39,6 +39,7 @@ interface PageDependencies { history: AppMountParameters['history']; indexPatterns: IndexPatternsContract; setBreadcrumbs: ChromeStart['setBreadcrumbs']; + redirectToMlAccessDeniedPage: () => Promise; } export const PageLoader: FC<{ context: MlContextValue }> = ({ context, children }) => { @@ -75,10 +76,16 @@ const MlRoutes: FC<{ pageDeps: PageDependencies; }> = ({ pageDeps }) => { const navigateToPath = useNavigateToPath(); + const { + services: { + http: { basePath }, + }, + } = useMlKibana(); + return ( <> {Object.entries(routes).map(([name, routeFactory]) => { - const route = routeFactory(navigateToPath); + const route = routeFactory(navigateToPath, basePath.get()); return ( ({ +export const analyticsJobsCreationRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/data_frame_analytics/new_job', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.dataFrameAnalyticsBreadcrumbs.dataFrameManagementLabel', { defaultMessage: 'Data Frame Analytics', diff --git a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_exploration.tsx b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_exploration.tsx index 47cc002ab4d83..f9f2ebe48f4aa 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_exploration.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_exploration.tsx @@ -10,21 +10,25 @@ import { decode } from 'rison-node'; import { i18n } from '@kbn/i18n'; -import { NavigateToPath } from '../../../contexts/kibana'; +import { NavigateToPath, useMlKibana, useMlUrlGenerator } from '../../../contexts/kibana'; import { MlRoute, PageLoader, PageProps } from '../../router'; import { useResolver } from '../../use_resolver'; import { basicResolvers } from '../../resolvers'; import { Page } from '../../../data_frame_analytics/pages/analytics_exploration'; -import { ANALYSIS_CONFIG_TYPE } from '../../../data_frame_analytics/common/analytics'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; +import { DataFrameAnalysisConfigType } from '../../../../../common/types/data_frame_analytics'; -export const analyticsJobExplorationRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const analyticsJobExplorationRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/data_frame_analytics/exploration', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('DATA_FRAME_ANALYTICS_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('DATA_FRAME_ANALYTICS_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.dataFrameAnalyticsBreadcrumbs.dataFrameExplorationLabel', { defaultMessage: 'Exploration', @@ -38,16 +42,31 @@ const PageWrapper: FC = ({ location, deps }) => { const { context } = useResolver('', undefined, deps.config, basicResolvers(deps)); const { _g }: Record = parse(location.search, { sort: false }); + const urlGenerator = useMlUrlGenerator(); + const { + services: { + application: { navigateToUrl }, + }, + } = useMlKibana(); + + const redirectToAnalyticsManagementPage = async () => { + const url = await urlGenerator.createUrl({ page: ML_PAGES.DATA_FRAME_ANALYTICS_JOBS_MANAGE }); + await navigateToUrl(url); + }; + let globalState: any = null; try { globalState = decode(_g); } catch (error) { // eslint-disable-next-line no-console - console.error('Could not parse global state'); - window.location.href = '#data_frame_analytics'; + console.error( + 'Could not parse global state. Redirecting to Data Frame Analytics Management Page.' + ); + redirectToAnalyticsManagementPage(); + return <>; } const jobId: string = globalState.ml.jobId; - const analysisType: ANALYSIS_CONFIG_TYPE = globalState.ml.analysisType; + const analysisType: DataFrameAnalysisConfigType = globalState.ml.analysisType; return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_jobs_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_jobs_list.tsx index b6ef9ea81b4ba..80706a82121d5 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_jobs_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_jobs_list.tsx @@ -15,12 +15,15 @@ import { basicResolvers } from '../../resolvers'; import { Page } from '../../../data_frame_analytics/pages/analytics_management'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; -export const analyticsJobsListRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const analyticsJobsListRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/data_frame_analytics', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('DATA_FRAME_ANALYTICS_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('DATA_FRAME_ANALYTICS_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.dataFrameAnalyticsBreadcrumbs.dataFrameListLabel', { defaultMessage: 'Job Management', diff --git a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/models_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/models_list.tsx index 7bf7784d1b559..b1fd6e93a744c 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/models_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/models_list.tsx @@ -15,12 +15,15 @@ import { basicResolvers } from '../../resolvers'; import { Page } from '../../../data_frame_analytics/pages/analytics_management'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; -export const modelsListRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const modelsListRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/data_frame_analytics/models', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('DATA_FRAME_ANALYTICS_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('DATA_FRAME_ANALYTICS_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.dataFrameAnalyticsBreadcrumbs.modelsListLabel', { defaultMessage: 'Model Management', diff --git a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/datavisualizer.tsx b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/datavisualizer.tsx index efe5c3cba04a5..f40b754a23ccb 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/datavisualizer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/datavisualizer.tsx @@ -21,19 +21,25 @@ import { checkBasicLicense } from '../../../license'; import { checkFindFileStructurePrivilegeResolver } from '../../../capabilities/check_capabilities'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; -export const selectorRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const selectorRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/datavisualizer', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('DATA_VISUALIZER_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('DATA_VISUALIZER_BREADCRUMB', navigateToPath, basePath), ], }); const PageWrapper: FC = ({ location, deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + const { context } = useResolver(undefined, undefined, deps.config, { checkBasicLicense, - checkFindFileStructurePrivilege: checkFindFileStructurePrivilegeResolver, + checkFindFileStructurePrivilege: () => + checkFindFileStructurePrivilegeResolver(redirectToMlAccessDeniedPage), }); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/file_based.tsx b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/file_based.tsx index 485af52c45a55..837616a8a76d2 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/file_based.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/file_based.tsx @@ -24,12 +24,15 @@ import { loadIndexPatterns } from '../../../util/index_utils'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; -export const fileBasedRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const fileBasedRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/filedatavisualizer', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('DATA_VISUALIZER_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('DATA_VISUALIZER_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.dataVisualizer.fileBasedLabel', { defaultMessage: 'File', @@ -40,10 +43,13 @@ export const fileBasedRouteFactory = (navigateToPath: NavigateToPath): MlRoute = }); const PageWrapper: FC = ({ location, deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + const { context } = useResolver('', undefined, deps.config, { checkBasicLicense, loadIndexPatterns: () => loadIndexPatterns(deps.indexPatterns), - checkFindFileStructurePrivilege: checkFindFileStructurePrivilegeResolver, + checkFindFileStructurePrivilege: () => + checkFindFileStructurePrivilegeResolver(redirectToMlAccessDeniedPage), }); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/index_based.tsx b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/index_based.tsx index 358b8773e3460..e3d0e5050fca5 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/index_based.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/index_based.tsx @@ -20,13 +20,18 @@ import { checkGetJobsCapabilitiesResolver } from '../../../capabilities/check_ca import { loadIndexPatterns } from '../../../util/index_utils'; import { checkMlNodesAvailable } from '../../../ml_nodes_check'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; +import { useCreateAndNavigateToMlLink } from '../../../contexts/kibana/use_create_url'; -export const indexBasedRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const indexBasedRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/jobs/new_job/datavisualizer', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('DATA_VISUALIZER_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('DATA_VISUALIZER_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.dataFrameAnalyticsBreadcrumbs.indexLabel', { defaultMessage: 'Index', @@ -37,12 +42,17 @@ export const indexBasedRouteFactory = (navigateToPath: NavigateToPath): MlRoute }); const PageWrapper: FC = ({ location, deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + const redirectToJobsManagementPage = useCreateAndNavigateToMlLink( + ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE + ); + const { index, savedSearchId }: Record = parse(location.search, { sort: false }); const { context } = useResolver(index, savedSearchId, deps.config, { checkBasicLicense, loadIndexPatterns: () => loadIndexPatterns(deps.indexPatterns), - checkGetJobsCapabilities: checkGetJobsCapabilitiesResolver, - checkMlNodesAvailable, + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + checkMlNodesAvailable: () => checkMlNodesAvailable(redirectToJobsManagementPage), }); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index 30b9bc2af219f..00d64a2f1bd1d 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -35,12 +35,15 @@ import { useTimefilter } from '../../contexts/kibana'; import { isViewBySwimLaneData } from '../../explorer/swimlane_container'; import { JOB_ID } from '../../../../common/constants/anomalies'; -export const explorerRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const explorerRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/explorer', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.anomalyDetection.anomalyExplorerLabel', { defaultMessage: 'Anomaly Explorer', diff --git a/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx index 38a7900916ba8..2863e59508e35 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx @@ -20,12 +20,12 @@ import { JobsPage } from '../../jobs/jobs_list'; import { useTimefilter } from '../../contexts/kibana'; import { getBreadcrumbWithUrlForApp } from '../breadcrumbs'; -export const jobListRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const jobListRouteFactory = (navigateToPath: NavigateToPath, basePath: string): MlRoute => ({ path: '/jobs', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.anomalyDetection.jobManagementLabel', { defaultMessage: 'Job Management', diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx index d8605c4cc9115..0ef3b384dcf5d 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx @@ -8,7 +8,7 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { NavigateToPath } from '../../../contexts/kibana'; +import { NavigateToPath, useMlKibana } from '../../../contexts/kibana'; import { MlRoute, PageLoader, PageProps } from '../../router'; import { useResolver } from '../../use_resolver'; @@ -19,6 +19,8 @@ import { checkBasicLicense } from '../../../license'; import { loadIndexPatterns } from '../../../util/index_utils'; import { checkGetJobsCapabilitiesResolver } from '../../../capabilities/check_capabilities'; import { checkMlNodesAvailable } from '../../../ml_nodes_check'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; +import { useCreateAndNavigateToMlLink } from '../../../contexts/kibana/use_create_url'; enum MODE { NEW_JOB, @@ -30,9 +32,9 @@ interface IndexOrSearchPageProps extends PageProps { mode: MODE; } -const getBreadcrumbs = (navigateToPath: NavigateToPath) => [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath), +const getBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [ + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.selectIndexOrSearchLabel', { defaultMessage: 'Create job', @@ -41,7 +43,10 @@ const getBreadcrumbs = (navigateToPath: NavigateToPath) => [ }, ]; -export const indexOrSearchRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const indexOrSearchRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/jobs/new_job/step/index_or_search', render: (props, deps) => ( ), - breadcrumbs: getBreadcrumbs(navigateToPath), + breadcrumbs: getBreadcrumbs(navigateToPath, basePath), }); -export const dataVizIndexOrSearchRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const dataVizIndexOrSearchRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/datavisualizer_index_select', render: (props, deps) => ( ), - breadcrumbs: getBreadcrumbs(navigateToPath), + breadcrumbs: getBreadcrumbs(navigateToPath, basePath), }); const PageWrapper: FC = ({ nextStepPath, deps, mode }) => { + const { + services: { + http: { basePath }, + application: { navigateToUrl }, + }, + } = useMlKibana(); + const { redirectToMlAccessDeniedPage } = deps; + const redirectToJobsManagementPage = useCreateAndNavigateToMlLink( + ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE + ); + const newJobResolvers = { ...basicResolvers(deps), - preConfiguredJobRedirect: () => preConfiguredJobRedirect(deps.indexPatterns), + preConfiguredJobRedirect: () => + preConfiguredJobRedirect(deps.indexPatterns, basePath.get(), navigateToUrl), }; const dataVizResolvers = { checkBasicLicense, loadIndexPatterns: () => loadIndexPatterns(deps.indexPatterns), - checkGetJobsCapabilities: checkGetJobsCapabilitiesResolver, - checkMlNodesAvailable, + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + checkMlNodesAvailable: () => checkMlNodesAvailable(redirectToJobsManagementPage), }; const { context } = useResolver( diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/job_type.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/job_type.tsx index b8ab29d40fa1f..543e01fbd326d 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/job_type.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/job_type.tsx @@ -17,12 +17,12 @@ import { basicResolvers } from '../../resolvers'; import { Page } from '../../../jobs/new_job/pages/job_type'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; -export const jobTypeRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const jobTypeRouteFactory = (navigateToPath: NavigateToPath, basePath: string): MlRoute => ({ path: '/jobs/new_job/step/job_type', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.selectJobType', { defaultMessage: 'Create job', diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/recognize.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/recognize.tsx index 6be58828ee1a5..654d7184cfcf2 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/recognize.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/recognize.tsx @@ -9,7 +9,7 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { NavigateToPath } from '../../../contexts/kibana'; +import { NavigateToPath, useNavigateToPath } from '../../../contexts/kibana'; import { MlRoute, PageLoader, PageProps } from '../../router'; import { useResolver } from '../../use_resolver'; @@ -18,14 +18,18 @@ import { Page } from '../../../jobs/new_job/recognize'; import { checkViewOrCreateJobs } from '../../../jobs/new_job/recognize/resolvers'; import { mlJobService } from '../../../services/job_service'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; +import { useCreateADLinks } from '../../../components/custom_hooks/use_create_ad_links'; -export const recognizeRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const recognizeRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/jobs/new_job/recognize', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('CREATE_JOB_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('CREATE_JOB_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.selectIndexOrSearchLabelRecognize', { defaultMessage: 'Recognized index', @@ -60,10 +64,14 @@ const CheckViewOrCreateWrapper: FC = ({ location, deps }) => { const { id: moduleId, index: indexPatternId }: Record = parse(location.search, { sort: false, }); + const { createLinkWithUserDefaults } = useCreateADLinks(); + + const navigateToPath = useNavigateToPath(); // the single resolver checkViewOrCreateJobs redirects only. so will always reject useResolver(undefined, undefined, deps.config, { - checkViewOrCreateJobs: () => checkViewOrCreateJobs(moduleId, indexPatternId), + checkViewOrCreateJobs: () => + checkViewOrCreateJobs(moduleId, indexPatternId, createLinkWithUserDefaults, navigateToPath), }); return null; }; diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/wizard.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/wizard.tsx index 35085fd557577..8a82a9a8dbc49 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/wizard.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/wizard.tsx @@ -19,19 +19,21 @@ import { mlJobService } from '../../../services/job_service'; import { loadNewJobCapabilities } from '../../../services/new_job_capabilities_service'; import { checkCreateJobsCapabilitiesResolver } from '../../../capabilities/check_capabilities'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; +import { useCreateAndNavigateToMlLink } from '../../../contexts/kibana/use_create_url'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; interface WizardPageProps extends PageProps { jobType: JOB_TYPE; } -const getBaseBreadcrumbs = (navigateToPath: NavigateToPath) => [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('CREATE_JOB_BREADCRUMB', navigateToPath), +const getBaseBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [ + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('CREATE_JOB_BREADCRUMB', navigateToPath, basePath), ]; -const getSingleMetricBreadcrumbs = (navigateToPath: NavigateToPath) => [ - ...getBaseBreadcrumbs(navigateToPath), +const getSingleMetricBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [ + ...getBaseBreadcrumbs(navigateToPath, basePath), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.singleMetricLabel', { defaultMessage: 'Single metric', @@ -40,8 +42,8 @@ const getSingleMetricBreadcrumbs = (navigateToPath: NavigateToPath) => [ }, ]; -const getMultiMetricBreadcrumbs = (navigateToPath: NavigateToPath) => [ - ...getBaseBreadcrumbs(navigateToPath), +const getMultiMetricBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [ + ...getBaseBreadcrumbs(navigateToPath, basePath), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.multiMetricLabel', { defaultMessage: 'Multi-metric', @@ -50,8 +52,8 @@ const getMultiMetricBreadcrumbs = (navigateToPath: NavigateToPath) => [ }, ]; -const getPopulationBreadcrumbs = (navigateToPath: NavigateToPath) => [ - ...getBaseBreadcrumbs(navigateToPath), +const getPopulationBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [ + ...getBaseBreadcrumbs(navigateToPath, basePath), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.populationLabel', { defaultMessage: 'Population', @@ -60,8 +62,8 @@ const getPopulationBreadcrumbs = (navigateToPath: NavigateToPath) => [ }, ]; -const getAdvancedBreadcrumbs = (navigateToPath: NavigateToPath) => [ - ...getBaseBreadcrumbs(navigateToPath), +const getAdvancedBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [ + ...getBaseBreadcrumbs(navigateToPath, basePath), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.advancedConfigurationLabel', { defaultMessage: 'Advanced configuration', @@ -70,8 +72,8 @@ const getAdvancedBreadcrumbs = (navigateToPath: NavigateToPath) => [ }, ]; -const getCategorizationBreadcrumbs = (navigateToPath: NavigateToPath) => [ - ...getBaseBreadcrumbs(navigateToPath), +const getCategorizationBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [ + ...getBaseBreadcrumbs(navigateToPath, basePath), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.categorizationLabel', { defaultMessage: 'Categorization', @@ -80,41 +82,60 @@ const getCategorizationBreadcrumbs = (navigateToPath: NavigateToPath) => [ }, ]; -export const singleMetricRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const singleMetricRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/jobs/new_job/single_metric', render: (props, deps) => , - breadcrumbs: getSingleMetricBreadcrumbs(navigateToPath), + breadcrumbs: getSingleMetricBreadcrumbs(navigateToPath, basePath), }); -export const multiMetricRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const multiMetricRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/jobs/new_job/multi_metric', render: (props, deps) => , - breadcrumbs: getMultiMetricBreadcrumbs(navigateToPath), + breadcrumbs: getMultiMetricBreadcrumbs(navigateToPath, basePath), }); -export const populationRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const populationRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/jobs/new_job/population', render: (props, deps) => , - breadcrumbs: getPopulationBreadcrumbs(navigateToPath), + breadcrumbs: getPopulationBreadcrumbs(navigateToPath, basePath), }); -export const advancedRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const advancedRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/jobs/new_job/advanced', render: (props, deps) => , - breadcrumbs: getAdvancedBreadcrumbs(navigateToPath), + breadcrumbs: getAdvancedBreadcrumbs(navigateToPath, basePath), }); -export const categorizationRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const categorizationRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/jobs/new_job/categorization', render: (props, deps) => , - breadcrumbs: getCategorizationBreadcrumbs(navigateToPath), + breadcrumbs: getCategorizationBreadcrumbs(navigateToPath, basePath), }); const PageWrapper: FC = ({ location, jobType, deps }) => { + const redirectToJobsManagementPage = useCreateAndNavigateToMlLink( + ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE + ); + const { index, savedSearchId }: Record = parse(location.search, { sort: false }); const { context, results } = useResolver(index, savedSearchId, deps.config, { ...basicResolvers(deps), - privileges: checkCreateJobsCapabilitiesResolver, + privileges: () => checkCreateJobsCapabilitiesResolver(redirectToJobsManagementPage), jobCaps: () => loadNewJobCapabilities(index, savedSearchId, deps.indexPatterns), existingJobsAndGroups: mlJobService.getJobAndGroupIds, }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/overview.tsx b/x-pack/plugins/ml/public/application/routing/routes/overview.tsx index 174e9804b9689..0e07b0edfbe56 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/overview.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/overview.tsx @@ -22,11 +22,14 @@ import { loadMlServerInfo } from '../../services/ml_server_info'; import { useTimefilter } from '../../contexts/kibana'; import { breadcrumbOnClickFactory, getBreadcrumbWithUrlForApp } from '../breadcrumbs'; -export const overviewRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const overviewRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/overview', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.overview.overviewLabel', { defaultMessage: 'Overview', @@ -37,9 +40,11 @@ export const overviewRouteFactory = (navigateToPath: NavigateToPath): MlRoute => }); const PageWrapper: FC = ({ deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + const { context } = useResolver(undefined, undefined, deps.config, { checkFullLicense, - checkGetJobsCapabilities: checkGetJobsCapabilitiesResolver, + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), getMlNodeCount, loadMlServerInfo, }); @@ -52,7 +57,7 @@ const PageWrapper: FC = ({ deps }) => { ); }; -export const appRootRouteFactory = (): MlRoute => ({ +export const appRootRouteFactory = (navigateToPath: NavigateToPath, basePath: string): MlRoute => ({ path: '/', render: () => , breadcrumbs: [], diff --git a/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx index f2ae57f1ec961..2460971239618 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx @@ -10,7 +10,6 @@ */ import React, { FC } from 'react'; -import { i18n } from '@kbn/i18n'; import { NavigateToPath } from '../../../contexts/kibana'; @@ -25,27 +24,27 @@ import { } from '../../../capabilities/check_capabilities'; import { getMlNodeCount } from '../../../ml_nodes_check/check_ml_nodes'; import { CalendarsList } from '../../../settings/calendars'; -import { breadcrumbOnClickFactory, getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; +import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; -export const calendarListRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const calendarListRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/settings/calendars_list', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath), - { - text: i18n.translate('xpack.ml.settings.breadcrumbs.calendarManagementLabel', { - defaultMessage: 'Calendar management', - }), - onClick: breadcrumbOnClickFactory('/settings/calendars_list', navigateToPath), - }, + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('CALENDAR_MANAGEMENT_BREADCRUMB', navigateToPath, basePath), ], }); const PageWrapper: FC = ({ deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + const { context } = useResolver(undefined, undefined, deps.config, { checkFullLicense, - checkGetJobsCapabilities: checkGetJobsCapabilitiesResolver, + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), getMlNodeCount, }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx b/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx index a5c30e1eaaacc..4e0a8340590a4 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx @@ -26,6 +26,8 @@ import { import { checkMlNodesAvailable } from '../../../ml_nodes_check/check_ml_nodes'; import { NewCalendar } from '../../../settings/calendars'; import { breadcrumbOnClickFactory, getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; +import { useCreateAndNavigateToMlLink } from '../../../contexts/kibana/use_create_url'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; enum MODE { NEW, @@ -36,12 +38,16 @@ interface NewCalendarPageProps extends PageProps { mode: MODE; } -export const newCalendarRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const newCalendarRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/settings/calendars_list/new_calendar', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('CALENDAR_MANAGEMENT_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.settings.breadcrumbs.calendarManagement.createLabel', { defaultMessage: 'Create', @@ -51,12 +57,16 @@ export const newCalendarRouteFactory = (navigateToPath: NavigateToPath): MlRoute ], }); -export const editCalendarRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const editCalendarRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/settings/calendars_list/edit_calendar/:calendarId', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('CALENDAR_MANAGEMENT_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.settings.breadcrumbs.calendarManagement.editLabel', { defaultMessage: 'Edit', @@ -72,11 +82,15 @@ const PageWrapper: FC = ({ location, mode, deps }) => { const pathMatch: string[] | null = location.pathname.match(/.+\/(.+)$/); calendarId = pathMatch && pathMatch.length > 1 ? pathMatch[1] : undefined; } + const { redirectToMlAccessDeniedPage } = deps; + const redirectToJobsManagementPage = useCreateAndNavigateToMlLink( + ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE + ); const { context } = useResolver(undefined, undefined, deps.config, { checkFullLicense, - checkGetJobsCapabilities: checkGetJobsCapabilitiesResolver, - checkMlNodesAvailable, + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + checkMlNodesAvailable: () => checkMlNodesAvailable(redirectToJobsManagementPage), }); useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list.tsx index d734e18d72bab..4e39cfce82e36 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list.tsx @@ -10,7 +10,6 @@ */ import React, { FC } from 'react'; -import { i18n } from '@kbn/i18n'; import { NavigateToPath } from '../../../contexts/kibana'; @@ -26,27 +25,27 @@ import { import { getMlNodeCount } from '../../../ml_nodes_check/check_ml_nodes'; import { FilterLists } from '../../../settings/filter_lists'; -import { breadcrumbOnClickFactory, getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; +import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; -export const filterListRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const filterListRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/settings/filter_lists', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath), - { - text: i18n.translate('xpack.ml.settings.breadcrumbs.filterListsLabel', { - defaultMessage: 'Filter lists', - }), - onClick: breadcrumbOnClickFactory('/settings/filter_lists', navigateToPath), - }, + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('FILTER_LISTS_BREADCRUMB', navigateToPath, basePath), ], }); const PageWrapper: FC = ({ deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + const { context } = useResolver(undefined, undefined, deps.config, { checkFullLicense, - checkGetJobsCapabilities: checkGetJobsCapabilitiesResolver, + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), getMlNodeCount, }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list_new_edit.tsx b/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list_new_edit.tsx index c6f17bc7f6f68..5fe56b024e413 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list_new_edit.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list_new_edit.tsx @@ -27,6 +27,8 @@ import { checkMlNodesAvailable } from '../../../ml_nodes_check/check_ml_nodes'; import { EditFilterList } from '../../../settings/filter_lists'; import { breadcrumbOnClickFactory, getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; +import { useCreateAndNavigateToMlLink } from '../../../contexts/kibana/use_create_url'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; enum MODE { NEW, @@ -37,12 +39,17 @@ interface NewFilterPageProps extends PageProps { mode: MODE; } -export const newFilterListRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const newFilterListRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/settings/filter_lists/new_filter_list', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('FILTER_LISTS_BREADCRUMB', navigateToPath, basePath), + { text: i18n.translate('xpack.ml.settings.breadcrumbs.filterLists.createLabel', { defaultMessage: 'Create', @@ -52,12 +59,16 @@ export const newFilterListRouteFactory = (navigateToPath: NavigateToPath): MlRou ], }); -export const editFilterListRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const editFilterListRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/settings/filter_lists/edit_filter_list/:filterId', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('FILTER_LISTS_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.settings.breadcrumbs.filterLists.editLabel', { defaultMessage: 'Edit', @@ -73,11 +84,15 @@ const PageWrapper: FC = ({ location, mode, deps }) => { const pathMatch: string[] | null = location.pathname.match(/.+\/(.+)$/); filterId = pathMatch && pathMatch.length > 1 ? pathMatch[1] : undefined; } + const { redirectToMlAccessDeniedPage } = deps; + const redirectToJobsManagementPage = useCreateAndNavigateToMlLink( + ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE + ); const { context } = useResolver(undefined, undefined, deps.config, { checkFullLicense, - checkGetJobsCapabilities: checkGetJobsCapabilitiesResolver, - checkMlNodesAvailable, + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + checkMlNodesAvailable: () => checkMlNodesAvailable(redirectToJobsManagementPage), }); useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/settings/settings.tsx b/x-pack/plugins/ml/public/application/routing/routes/settings/settings.tsx index 3f4b269851469..3159c2ae88166 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/settings/settings.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/settings/settings.tsx @@ -26,19 +26,24 @@ import { getMlNodeCount } from '../../../ml_nodes_check/check_ml_nodes'; import { AnomalyDetectionSettingsContext, Settings } from '../../../settings'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; -export const settingsRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const settingsRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/settings', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath), ], }); const PageWrapper: FC = ({ deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + const { context } = useResolver(undefined, undefined, deps.config, { checkFullLicense, - checkGetJobsCapabilities: checkGetJobsCapabilitiesResolver, + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), getMlNodeCount, }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx index 11ec074bac1db..b60a265560455 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx @@ -19,6 +19,11 @@ jest.mock('../../contexts/kibana/kibana_context', () => { useMlKibana: () => { return { services: { + chrome: { docTitle: { change: jest.fn() } }, + application: { getUrlForApp: jest.fn(), navigateToUrl: jest.fn() }, + share: { + urlGenerators: { getUrlGenerator: jest.fn() }, + }, uiSettings: { get: jest.fn() }, data: { query: { diff --git a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx index 817c975415997..03588872d6be0 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx @@ -39,12 +39,15 @@ import { basicResolvers } from '../resolvers'; import { getBreadcrumbWithUrlForApp } from '../breadcrumbs'; import { useTimefilter } from '../../contexts/kibana'; -export const timeSeriesExplorerRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const timeSeriesExplorerRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/timeseriesexplorer', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.anomalyDetection.singleMetricViewerLabel', { defaultMessage: 'Single Metric Viewer', diff --git a/x-pack/plugins/ml/public/application/routing/use_resolver.ts b/x-pack/plugins/ml/public/application/routing/use_resolver.ts index 4967e3a684a6b..e4cd90145bee4 100644 --- a/x-pack/plugins/ml/public/application/routing/use_resolver.ts +++ b/x-pack/plugins/ml/public/application/routing/use_resolver.ts @@ -16,6 +16,8 @@ import { createSearchItems } from '../jobs/new_job/utils/new_job_utils'; import { ResolverResults, Resolvers } from './resolvers'; import { MlContextValue } from '../contexts/ml'; import { useNotifications } from '../contexts/kibana'; +import { useCreateAndNavigateToMlLink } from '../contexts/kibana/use_create_url'; +import { ML_PAGES } from '../../../common/constants/ml_url_generator'; export const useResolver = ( indexPatternId: string | undefined, @@ -34,6 +36,9 @@ export const useResolver = ( const [context, setContext] = useState(null); const [results, setResults] = useState(tempResults); + const redirectToJobsManagementPage = useCreateAndNavigateToMlLink( + ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE + ); useEffect(() => { (async () => { @@ -73,7 +78,7 @@ export const useResolver = ( defaultMessage: 'An error has occurred', }), }); - window.location.href = '#/'; + await redirectToJobsManagementPage(); } } else { setContext({}); diff --git a/x-pack/plugins/ml/public/application/services/job_service.js b/x-pack/plugins/ml/public/application/services/job_service.js index dfa1b5f4e68cd..ea97492ae0f5a 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.js +++ b/x-pack/plugins/ml/public/application/services/job_service.js @@ -797,7 +797,6 @@ function createResultsUrl(jobIds, start, end, resultsPage, mode = 'absolute') { let path = ''; if (resultsPage !== undefined) { - path += '#/'; path += resultsPage; } diff --git a/x-pack/plugins/ml/public/application/settings/anomaly_detection_settings.tsx b/x-pack/plugins/ml/public/application/settings/anomaly_detection_settings.tsx index 16d7e1605263c..57caa56b2f10e 100644 --- a/x-pack/plugins/ml/public/application/settings/anomaly_detection_settings.tsx +++ b/x-pack/plugins/ml/public/application/settings/anomaly_detection_settings.tsx @@ -25,6 +25,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { AnomalyDetectionSettingsContext } from './anomaly_detection_settings_context'; import { useNotifications } from '../contexts/kibana'; import { ml } from '../services/ml_api_service'; +import { ML_PAGES } from '../../../common/constants/ml_url_generator'; +import { useCreateAndNavigateToMlLink } from '../contexts/kibana/use_create_url'; export const AnomalyDetectionSettings: FC = () => { const [calendarsCount, setCalendarsCount] = useState(0); @@ -35,6 +37,10 @@ export const AnomalyDetectionSettings: FC = () => { ); const { toasts } = useNotifications(); + const redirectToCalendarList = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_MANAGE); + const redirectToNewCalendarPage = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_NEW); + const redirectToFilterLists = useCreateAndNavigateToMlLink(ML_PAGES.FILTER_LISTS_MANAGE); + const redirectToNewFilterListPage = useCreateAndNavigateToMlLink(ML_PAGES.FILTER_LISTS_NEW); useEffect(() => { loadSummaryStats(); @@ -126,7 +132,7 @@ export const AnomalyDetectionSettings: FC = () => { flush="left" size="l" color="primary" - href="#/settings/calendars_list" + onClick={redirectToCalendarList} isDisabled={canGetCalendars === false} > { flush="left" size="l" color="primary" - href="#/settings/calendars_list/new_calendar" + onClick={redirectToNewCalendarPage} isDisabled={canCreateCalendar === false} > {

@@ -194,7 +200,7 @@ export const AnomalyDetectionSettings: FC = () => { flush="left" size="l" color="primary" - href="#/settings/filter_lists" + onClick={redirectToFilterLists} isDisabled={canGetFilters === false} > { data-test-subj="mlFilterListsCreateButton" size="l" color="primary" - href="#/settings/filter_lists/new_filter_list" + onClick={redirectToNewFilterListPage} isDisabled={canCreateFilter === false} > + + + } + labelType="label" + > + + + + } + labelType="label" + > + + @@ -137,7 +200,6 @@ exports[`CalendarForm Renders calendar form 1`] = ` grow={false} > @@ -215,7 +218,7 @@ export const CalendarForm = ({ - + ({ + useCreateAndNavigateToMlLink: jest.fn(), +})); const testProps = { calendarId: '', canCreateCalendar: true, @@ -31,6 +34,7 @@ const testProps = { selectedGroupOptions: [], selectedJobOptions: [], showNewEventModal: jest.fn(), + isGlobalCalendar: false, }; describe('CalendarForm', () => { diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js index 1fe16e4588bd7..a5eb212ba127e 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js @@ -20,6 +20,7 @@ import { ImportModal } from './import_modal'; import { ml } from '../../../services/ml_api_service'; import { withKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import { GLOBAL_CALENDAR } from '../../../../../common/constants/calendars'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; class NewCalendarUI extends Component { static propTypes = { @@ -55,6 +56,16 @@ class NewCalendarUI extends Component { this.formSetup(); } + returnToCalendarsManagementPage = async () => { + const { + services: { + http: { basePath }, + application: { navigateToUrl }, + }, + } = this.props.kibana; + await navigateToUrl(`${basePath.get()}/app/ml/${ML_PAGES.CALENDARS_MANAGE}`, true); + }; + async formSetup() { try { const { jobIds, groupIds, calendars } = await getCalendarSettingsData(); @@ -146,7 +157,7 @@ class NewCalendarUI extends Component { try { await ml.addCalendar(calendar); - window.location = '#/settings/calendars_list'; + await this.returnToCalendarsManagementPage(); } catch (error) { console.log('Error saving calendar', error); this.setState({ saving: false }); @@ -167,7 +178,7 @@ class NewCalendarUI extends Component { try { await ml.updateCalendar(calendar); - window.location = '#/settings/calendars_list'; + await this.returnToCalendarsManagementPage(); } catch (error) { console.log('Error saving calendar', error); this.setState({ saving: false }); diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.test.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.test.js index 2cff255bd1ce3..068d443300088 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.test.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.test.js @@ -4,6 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +jest.mock('../../../contexts/kibana/use_create_url', () => ({ + useCreateAndNavigateToMlLink: jest.fn(), +})); + jest.mock('../../../components/navigation_menu', () => ({ NavigationMenu: () =>
, })); diff --git a/x-pack/plugins/ml/public/application/settings/calendars/list/table/__snapshots__/table.test.js.snap b/x-pack/plugins/ml/public/application/settings/calendars/list/table/__snapshots__/table.test.js.snap index cc1c524c19b57..50cacd7b3545a 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/list/table/__snapshots__/table.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/calendars/list/table/__snapshots__/table.test.js.snap @@ -77,7 +77,6 @@ exports[`CalendarsListTable renders the table with all calendars 1`] = ` "toolsRight": Array [ diff --git a/x-pack/plugins/ml/public/application/settings/calendars/list/table/table.js b/x-pack/plugins/ml/public/application/settings/calendars/list/table/table.js index 77331c4a987dc..6b4403aef7c7b 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/list/table/table.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/list/table/table.js @@ -7,12 +7,14 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { EuiButton, EuiLink, EuiInMemoryTable } from '@elastic/eui'; - +import { EuiButton, EuiInMemoryTable } from '@elastic/eui'; +import { Link } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { GLOBAL_CALENDAR } from '../../../../../../common/constants/calendars'; +import { useCreateAndNavigateToMlLink } from '../../../../contexts/kibana/use_create_url'; +import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator'; export const CalendarsListTable = ({ calendarsList, @@ -24,6 +26,8 @@ export const CalendarsListTable = ({ mlNodesAvailable, itemsSelected, }) => { + const redirectToNewCalendarPage = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_NEW); + const sorting = { sort: { field: 'calendar_id', @@ -46,12 +50,9 @@ export const CalendarsListTable = ({ truncateText: true, scope: 'row', render: (id) => ( - + {id} - + ), 'data-test-subj': 'mlCalendarListColumnId', }, @@ -101,7 +102,7 @@ export const CalendarsListTable = ({ size="s" data-test-subj="mlCalendarButtonCreate" key="new_calendar_button" - href="#/settings/calendars_list/new_calendar" + onClick={redirectToNewCalendarPage} isDisabled={canCreateCalendar === false || mlNodesAvailable === false} > @@ -115,6 +116,7 @@ export const CalendarsListTable = ({ canDeleteCalendar === false || mlNodesAvailable === false || itemsSelected === false } data-test-subj="mlCalendarButtonDelete" + key="delete_calendar_button" > ({ + useCreateAndNavigateToMlLink: jest.fn(), +})); const calendars = [ { @@ -42,7 +47,11 @@ describe('CalendarsListTable', () => { }); test('New button enabled if permission available', () => { - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl( + + + + ); const buttons = wrapper.find('[data-test-subj="mlCalendarButtonCreate"]'); const button = buttons.find('EuiButton'); @@ -56,7 +65,11 @@ describe('CalendarsListTable', () => { canCreateCalendar: false, }; - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl( + + + + ); const buttons = wrapper.find('[data-test-subj="mlCalendarButtonCreate"]'); const button = buttons.find('EuiButton'); @@ -70,7 +83,11 @@ describe('CalendarsListTable', () => { mlNodesAvailable: false, }; - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl( + + + + ); const buttons = wrapper.find('[data-test-subj="mlCalendarButtonCreate"]'); const button = buttons.find('EuiButton'); diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/edit/edit_filter_list.js b/x-pack/plugins/ml/public/application/settings/filter_lists/edit/edit_filter_list.js index 41b7aa63f55ef..681c54ca9eee0 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/edit/edit_filter_list.js +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/edit/edit_filter_list.js @@ -34,6 +34,7 @@ import { ItemsGrid } from '../../../components/items_grid'; import { NavigationMenu } from '../../../components/navigation_menu'; import { isValidFilterListId, saveFilterList } from './utils'; import { ml } from '../../../services/ml_api_service'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; const DEFAULT_ITEMS_PER_PAGE = 50; @@ -67,10 +68,6 @@ function getActivePage(activePageState, itemsPerPage, numMatchingItems) { return activePage; } -function returnToFiltersList() { - window.location.href = `#/settings/filter_lists`; -} - export class EditFilterListUI extends Component { static displayName = 'EditFilterList'; static propTypes = { @@ -105,6 +102,16 @@ export class EditFilterListUI extends Component { } } + returnToFiltersList = async () => { + const { + services: { + http: { basePath }, + application: { navigateToUrl }, + }, + } = this.props.kibana; + await navigateToUrl(`${basePath.get()}/app/ml/${ML_PAGES.FILTER_LISTS_MANAGE}`, true); + }; + loadFilterList = (filterId) => { ml.filters .filters({ filterId }) @@ -279,7 +286,7 @@ export class EditFilterListUI extends Component { saveFilterList(filterId, description, items, loadedFilter) .then((savedFilter) => { this.setLoadedFilterState(savedFilter); - returnToFiltersList(); + this.returnToFiltersList(); }) .catch((resp) => { console.log(`Error saving filter ${filterId}:`, resp); @@ -355,7 +362,7 @@ export class EditFilterListUI extends Component { /> - + this.returnToFiltersList()}> @@ -84,12 +88,9 @@ function getColumns() { defaultMessage: 'ID', }), render: (id) => ( - + {id} - + ), sortable: true, scope: 'row', @@ -213,7 +214,7 @@ export function FilterListsTable({ isSelectable={true} data-test-subj="mlFilterListsTable" rowProps={(item) => ({ - 'data-test-subj': `mlCalendarListRow row-${item.filter_id}`, + 'data-test-subj': `mlFilterListsRow row-${item.filter_id}`, })} />
diff --git a/x-pack/plugins/ml/public/application/settings/settings.test.tsx b/x-pack/plugins/ml/public/application/settings/settings.test.tsx index f16bf62632152..a5e69f233e2df 100644 --- a/x-pack/plugins/ml/public/application/settings/settings.test.tsx +++ b/x-pack/plugins/ml/public/application/settings/settings.test.tsx @@ -22,6 +22,10 @@ jest.mock('../contexts/kibana', () => ({ }, })); +jest.mock('../contexts/kibana/use_create_url', () => ({ + useCreateAndNavigateToMlLink: jest.fn(), +})); + describe('Settings', () => { function runCheckButtonsDisabledTest( canGetFilters: boolean, diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/timeseriesexplorer_no_jobs_found.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/timeseriesexplorer_no_jobs_found.tsx index deecb9fb45b51..88bf769aa2936 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/timeseriesexplorer_no_jobs_found.tsx +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/timeseriesexplorer_no_jobs_found.tsx @@ -12,26 +12,40 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { useMlUrlGenerator, useNavigateToPath } from '../../../contexts/kibana'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; -export const TimeseriesexplorerNoJobsFound = () => ( - - - - } - actions={ - - - - } - /> -); +export const TimeseriesexplorerNoJobsFound = () => { + const mlUrlGenerator = useMlUrlGenerator(); + const navigateToPath = useNavigateToPath(); + + const redirectToJobsManagementPage = async () => { + const path = await mlUrlGenerator.createUrl({ + page: ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, + }); + await navigateToPath(path, true); + }; + + return ( + + + + } + actions={ + + + + } + /> + ); +}; diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.js b/x-pack/plugins/ml/public/application/util/chart_utils.js index 4ec7c5cb6d819..ca55bb10b13d5 100644 --- a/x-pack/plugins/ml/public/application/util/chart_utils.js +++ b/x-pack/plugins/ml/public/application/util/chart_utils.js @@ -8,11 +8,9 @@ import d3 from 'd3'; import { calculateTextWidth } from './string_utils'; import { MULTI_BUCKET_IMPACT } from '../../../common/constants/multi_bucket_impact'; import moment from 'moment'; -import rison from 'rison-node'; - import { getTimefilter } from './dependency_cache'; - import { CHART_TYPE } from '../explorer/explorer_constants'; +import { ML_PAGES } from '../../../common/constants/ml_url_generator'; export const LINE_CHART_ANOMALY_RADIUS = 7; export const MULTI_BUCKET_SYMBOL_SIZE = 100; // In square pixels for use with d3 symbol.size @@ -212,7 +210,7 @@ export function getChartType(config) { return chartType; } -export function getExploreSeriesLink(series) { +export async function getExploreSeriesLink(mlUrlGenerator, series) { // Open the Single Metric dashboard over the same overall bounds and // zoomed in to the same time as the current chart. const timefilter = getTimefilter(); @@ -227,46 +225,44 @@ export function getExploreSeriesLink(series) { // to identify the particular series to view. // Initially pass them in the mlTimeSeriesExplorer part of the AppState. // TODO - do we want to pass the entities via the filter? - const entityCondition = {}; - series.entityFields.forEach((entity) => { - entityCondition[entity.fieldName] = entity.fieldValue; - }); + let entityCondition; + if (series.entityFields.length > 0) { + entityCondition = {}; + series.entityFields.forEach((entity) => { + entityCondition[entity.fieldName] = entity.fieldValue; + }); + } - // Use rison to build the URL . - const _g = rison.encode({ - ml: { + const url = await mlUrlGenerator.createUrl({ + page: ML_PAGES.SINGLE_METRIC_VIEWER, + pageState: { jobIds: [series.jobId], - }, - refreshInterval: { - display: 'Off', - pause: false, - value: 0, - }, - time: { - from: from, - to: to, - mode: 'absolute', - }, - }); - - const _a = rison.encode({ - mlTimeSeriesExplorer: { + refreshInterval: { + display: 'Off', + pause: false, + value: 0, + }, + timeRange: { + from: from, + to: to, + mode: 'absolute', + }, zoom: { from: zoomFrom, to: zoomTo, }, detectorIndex: series.detectorIndex, entities: entityCondition, - }, - query: { - query_string: { - analyze_wildcard: true, - query: '*', + query: { + query_string: { + analyze_wildcard: true, + query: '*', + }, }, }, + excludeBasePath: true, }); - - return `#/timeseriesexplorer?_g=${_g}&_a=${encodeURIComponent(_a)}`; + return url; } export function showMultiBucketAnomalyMarker(point) { diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.test.js b/x-pack/plugins/ml/public/application/util/chart_utils.test.js index b7cf11c088a1e..955dd7cbea0a1 100644 --- a/x-pack/plugins/ml/public/application/util/chart_utils.test.js +++ b/x-pack/plugins/ml/public/application/util/chart_utils.test.js @@ -35,7 +35,6 @@ import { render } from '@testing-library/react'; import { chartLimits, getChartType, - getExploreSeriesLink, getTickValues, getXTransform, isLabelLengthAboveThreshold, @@ -238,20 +237,6 @@ describe('ML - chart utils', () => { }); }); - describe('getExploreSeriesLink', () => { - test('get timeseriesexplorer link', () => { - const link = getExploreSeriesLink(seriesConfig); - const expectedLink = - `#/timeseriesexplorer?_g=(ml:(jobIds:!(population-03)),` + - `refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2017-02-23T00:00:00.000Z',mode:absolute,` + - `to:'2017-02-23T23:59:59.999Z'))&_a=(mlTimeSeriesExplorer%3A(detectorIndex%3A0%2Centities%3A` + - `(nginx.access.remote_ip%3A'72.57.0.53')%2Czoom%3A(from%3A'2017-02-19T20%3A00%3A00.000Z'%2Cto%3A'2017-02-27T04%3A00%3A00.000Z'))` + - `%2Cquery%3A(query_string%3A(analyze_wildcard%3A!t%2Cquery%3A'*')))`; - - expect(link).toBe(expectedLink); - }); - }); - describe('numTicks', () => { test('returns 10 for 1000', () => { expect(numTicks(1000)).toBe(10); diff --git a/x-pack/plugins/ml/public/application/util/get_selected_ids_url.ts b/x-pack/plugins/ml/public/application/util/get_selected_ids_url.ts deleted file mode 100644 index 806626577008e..0000000000000 --- a/x-pack/plugins/ml/public/application/util/get_selected_ids_url.ts +++ /dev/null @@ -1,39 +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 rison from 'rison-node'; -import { getBasePath } from './dependency_cache'; - -export enum TAB_IDS { - DATA_FRAME_ANALYTICS = 'data_frame_analytics', - ANOMALY_DETECTION = 'jobs', -} - -function getSelectedIdsUrl(tabId: TAB_IDS, settings: { [key: string]: string | string[] }): string { - // Create url for filtering by job id or group ids for kibana management table - const encoded = rison.encode(settings); - const url = `?mlManagement=${encoded}`; - const basePath = getBasePath(); - - return `${basePath.get()}/app/ml#/${tabId}${url}`; -} - -// Create url for filtering by group ids for kibana management table -export function getGroupIdsUrl(tabId: TAB_IDS, ids: string[]): string { - const settings = { - groupIds: ids, - }; - - return getSelectedIdsUrl(tabId, settings); -} - -// Create url for filtering by job id for kibana management table -export function getJobIdUrl(tabId: TAB_IDS, id: string): string { - const settings = { - jobId: id, - }; - - return getSelectedIdsUrl(tabId, settings); -} diff --git a/x-pack/plugins/ml/public/application/util/recently_accessed.ts b/x-pack/plugins/ml/public/application/util/recently_accessed.ts index ab879e421cb09..04ccd84c561bb 100644 --- a/x-pack/plugins/ml/public/application/util/recently_accessed.ts +++ b/x-pack/plugins/ml/public/application/util/recently_accessed.ts @@ -37,7 +37,7 @@ export function addItemToRecentlyAccessed(page: string, itemId: string, url: str return; } - url = `ml#/${page}/${url}`; + url = url.startsWith('/') ? `/app/ml${url}` : `/app/ml/${page}/${url}`; const recentlyAccessed = getRecentlyAccessed(); recentlyAccessed.add(url, `ML - ${itemId} - ${pageLabel}`, id); } diff --git a/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts b/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts index c4aebb108e7b9..6a44756412fe3 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts @@ -11,13 +11,14 @@ import { ExplorerAppState, ExplorerGlobalState, ExplorerUrlState, + MlCommonGlobalState, MlGenericUrlState, TimeSeriesExplorerAppState, TimeSeriesExplorerGlobalState, TimeSeriesExplorerUrlState, } from '../../common/types/ml_url_generator'; import { ML_PAGES } from '../../common/constants/ml_url_generator'; -import { createIndexBasedMlUrl } from './common'; +import { createGenericMlUrl } from './common'; import { setStateToKbnUrl } from '../../../../../src/plugins/kibana_utils/public'; /** * Creates URL to the Anomaly Detection Job management page @@ -30,18 +31,29 @@ export function createAnomalyDetectionJobManagementUrl( if (!params || isEmpty(params)) { return url; } - const { jobId, groupIds } = params; - const queryState: AnomalyDetectionQueryState = { - jobId, - groupIds, - }; + const { jobId, groupIds, globalState } = params; + if (jobId || groupIds) { + const queryState: AnomalyDetectionQueryState = { + jobId, + groupIds, + }; - url = setStateToKbnUrl( - 'mlManagement', - queryState, - { useHash: false, storeInHashQuery: false }, - url - ); + url = setStateToKbnUrl( + 'mlManagement', + queryState, + { useHash: false, storeInHashQuery: false }, + url + ); + } + + if (globalState) { + url = setStateToKbnUrl>( + '_g', + globalState, + { useHash: false, storeInHashQuery: false }, + url + ); + } return url; } @@ -49,13 +61,24 @@ export function createAnomalyDetectionCreateJobSelectType( appBasePath: string, pageState: MlGenericUrlState['pageState'] ): string { - return createIndexBasedMlUrl( + return createGenericMlUrl( appBasePath, ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE, pageState ); } +export function createAnomalyDetectionCreateJobSelectIndex( + appBasePath: string, + pageState: MlGenericUrlState['pageState'] +): string { + return createGenericMlUrl( + appBasePath, + ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX, + pageState + ); +} + /** * Creates URL to the Anomaly Explorer page */ @@ -75,36 +98,35 @@ export function createExplorerUrl( query, mlExplorerSwimlane = {}, mlExplorerFilter = {}, + globalState, } = params; const appState: Partial = { mlExplorerSwimlane, mlExplorerFilter, }; + let queryState: Partial = {}; + if (globalState) queryState = globalState; if (query) appState.query = query; - if (jobIds) { - const queryState: Partial = { - ml: { - jobIds, - }, + queryState.ml = { + jobIds, }; - - if (timeRange) queryState.time = timeRange; - if (refreshInterval) queryState.refreshInterval = refreshInterval; - - url = setStateToKbnUrl>( - '_g', - queryState, - { useHash: false, storeInHashQuery: false }, - url - ); - url = setStateToKbnUrl>( - '_a', - appState, - { useHash: false, storeInHashQuery: false }, - url - ); } + if (refreshInterval) queryState.refreshInterval = refreshInterval; + if (timeRange) queryState.time = timeRange; + + url = setStateToKbnUrl>( + '_g', + queryState, + { useHash: false, storeInHashQuery: false }, + url + ); + url = setStateToKbnUrl>( + '_a', + appState, + { useHash: false, storeInHashQuery: false }, + url + ); return url; } @@ -120,19 +142,36 @@ export function createSingleMetricViewerUrl( if (!params) { return url; } - const { timeRange, jobIds, refreshInterval, zoom, query, detectorIndex, entities } = params; + const { + timeRange, + jobIds, + refreshInterval, + zoom, + query, + detectorIndex, + forecastId, + entities, + globalState, + } = params; + + let queryState: Partial = {}; + if (globalState) queryState = globalState; - const queryState: TimeSeriesExplorerGlobalState = { - ml: { + if (jobIds) { + queryState.ml = { jobIds, - }, - refreshInterval, - time: timeRange, - }; + }; + } + if (refreshInterval) queryState.refreshInterval = refreshInterval; + if (timeRange) queryState.time = timeRange; const appState: Partial = {}; const mlTimeSeriesExplorer: Partial = {}; + if (forecastId !== undefined) { + mlTimeSeriesExplorer.forecastId = forecastId; + } + if (detectorIndex !== undefined) { mlTimeSeriesExplorer.detectorIndex = detectorIndex; } @@ -146,7 +185,7 @@ export function createSingleMetricViewerUrl( appState.query = { query_string: query, }; - url = setStateToKbnUrl( + url = setStateToKbnUrl>( '_g', queryState, { useHash: false, storeInHashQuery: false }, diff --git a/x-pack/plugins/ml/public/ml_url_generator/common.ts b/x-pack/plugins/ml/public/ml_url_generator/common.ts index 57cfc52045282..f929e513e618a 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/common.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/common.ts @@ -19,37 +19,40 @@ export function extractParams(urlState: UrlState) { * Creates generic index based search ML url * e.g. `jobs/new_job/datavisualizer?index=3da93760-e0af-11ea-9ad3-3bcfc330e42a` */ -export function createIndexBasedMlUrl( +export function createGenericMlUrl( appBasePath: string, page: MlGenericUrlState['page'], pageState: MlGenericUrlState['pageState'] ): string { - const { globalState, appState, index, savedSearchId, ...restParams } = pageState; let url = `${appBasePath}/${page}`; - if (index !== undefined && savedSearchId === undefined) { - url = `${url}?index=${index}`; - } - if (index === undefined && savedSearchId !== undefined) { - url = `${url}?savedSearchId=${savedSearchId}`; - } + if (pageState) { + const { globalState, appState, index, savedSearchId, ...restParams } = pageState; + if (index !== undefined && savedSearchId === undefined) { + url = `${url}?index=${index}`; + } + if (index === undefined && savedSearchId !== undefined) { + url = `${url}?savedSearchId=${savedSearchId}`; + } - if (!isEmpty(restParams)) { - Object.keys(restParams).forEach((key) => { - url = setStateToKbnUrl( - key, - restParams[key], - { useHash: false, storeInHashQuery: false }, - url - ); - }); - } + if (!isEmpty(restParams)) { + Object.keys(restParams).forEach((key) => { + url = setStateToKbnUrl( + key, + restParams[key], + { useHash: false, storeInHashQuery: false }, + url + ); + }); + } - if (globalState) { - url = setStateToKbnUrl('_g', globalState, { useHash: false, storeInHashQuery: false }, url); - } - if (appState) { - url = setStateToKbnUrl('_a', appState, { useHash: false, storeInHashQuery: false }, url); + if (globalState) { + url = setStateToKbnUrl('_g', globalState, { useHash: false, storeInHashQuery: false }, url); + } + if (appState) { + url = setStateToKbnUrl('_a', appState, { useHash: false, storeInHashQuery: false }, url); + } } + return url; } diff --git a/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts b/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts index 8cf10a2acb64f..88761edf241a9 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts @@ -12,6 +12,7 @@ import { DataFrameAnalyticsExplorationUrlState, DataFrameAnalyticsQueryState, DataFrameAnalyticsUrlState, + MlCommonGlobalState, } from '../../common/types/ml_url_generator'; import { ML_PAGES } from '../../common/constants/ml_url_generator'; import { setStateToKbnUrl } from '../../../../../src/plugins/kibana_utils/public'; @@ -23,18 +24,28 @@ export function createDataFrameAnalyticsJobManagementUrl( let url = `${appBasePath}/${ML_PAGES.DATA_FRAME_ANALYTICS_JOBS_MANAGE}`; if (mlUrlGeneratorState) { - const { jobId, groupIds } = mlUrlGeneratorState; - const queryState: Partial = { - jobId, - groupIds, - }; + const { jobId, groupIds, globalState } = mlUrlGeneratorState; + if (jobId || groupIds) { + const queryState: Partial = { + jobId, + groupIds, + }; - url = setStateToKbnUrl>( - 'mlManagement', - queryState, - { useHash: false, storeInHashQuery: false }, - url - ); + url = setStateToKbnUrl>( + 'mlManagement', + queryState, + { useHash: false, storeInHashQuery: false }, + url + ); + } + if (globalState) { + url = setStateToKbnUrl>( + '_g', + globalState, + { useHash: false, storeInHashQuery: false }, + url + ); + } } return url; @@ -50,12 +61,14 @@ export function createDataFrameAnalyticsExplorationUrl( let url = `${appBasePath}/${ML_PAGES.DATA_FRAME_ANALYTICS_EXPLORATION}`; if (mlUrlGeneratorState) { - const { jobId, analysisType } = mlUrlGeneratorState; + const { jobId, analysisType, globalState } = mlUrlGeneratorState; + const queryState: DataFrameAnalyticsExplorationQueryState = { ml: { jobId, analysisType, }, + ...globalState, }; url = setStateToKbnUrl( diff --git a/x-pack/plugins/ml/public/ml_url_generator/data_visualizer_urls_generator.ts b/x-pack/plugins/ml/public/ml_url_generator/data_visualizer_urls_generator.ts deleted file mode 100644 index 24693df5025d9..0000000000000 --- a/x-pack/plugins/ml/public/ml_url_generator/data_visualizer_urls_generator.ts +++ /dev/null @@ -1,29 +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. - */ - -/** - * Creates URL to the Data Visualizer page - */ -import { DataVisualizerUrlState, MlGenericUrlState } from '../../common/types/ml_url_generator'; -import { createIndexBasedMlUrl } from './common'; -import { ML_PAGES } from '../../common/constants/ml_url_generator'; - -export function createDataVisualizerUrl( - appBasePath: string, - { page }: DataVisualizerUrlState -): string { - return `${appBasePath}/${page}`; -} - -/** - * Creates URL to the Index Data Visualizer - */ -export function createIndexDataVisualizerUrl( - appBasePath: string, - pageState: MlGenericUrlState['pageState'] -): string { - return createIndexBasedMlUrl(appBasePath, ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER, pageState); -} diff --git a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts index 55bc6d3668de7..754f5bec57a07 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts @@ -6,7 +6,7 @@ import { MlUrlGenerator } from './ml_url_generator'; import { ML_PAGES } from '../../common/constants/ml_url_generator'; -import { ANALYSIS_CONFIG_TYPE } from '../../common/types/ml_url_generator'; +import { ANALYSIS_CONFIG_TYPE } from '../../common/constants/data_frame_analytics'; describe('MlUrlGenerator', () => { const urlGenerator = new MlUrlGenerator({ diff --git a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.ts b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.ts index b69260d8d4157..abec5cc2b7d1e 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.ts @@ -16,6 +16,7 @@ import { MlUrlGeneratorState } from '../../common/types/ml_url_generator'; import { createAnomalyDetectionJobManagementUrl, createAnomalyDetectionCreateJobSelectType, + createAnomalyDetectionCreateJobSelectIndex, createExplorerUrl, createSingleMetricViewerUrl, } from './anomaly_detection_urls_generator'; @@ -23,10 +24,8 @@ import { createDataFrameAnalyticsJobManagementUrl, createDataFrameAnalyticsExplorationUrl, } from './data_frame_analytics_urls_generator'; -import { - createIndexDataVisualizerUrl, - createDataVisualizerUrl, -} from './data_visualizer_urls_generator'; +import { createGenericMlUrl } from './common'; +import { createEditCalendarUrl, createEditFilterUrl } from './settings_urls_generator'; declare module '../../../../../src/plugins/share/public' { export interface UrlGeneratorStateMapping { @@ -44,8 +43,12 @@ export class MlUrlGenerator implements UrlGeneratorsDefinition => { - const appBasePath = this.params.appBasePath; + public readonly createUrl = async ( + mlUrlGeneratorParams: MlUrlGeneratorState + ): Promise => { + const { excludeBasePath, ...mlUrlGeneratorState } = mlUrlGeneratorParams; + const appBasePath = excludeBasePath === true ? '' : this.params.appBasePath; + switch (mlUrlGeneratorState.page) { case ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE: return createAnomalyDetectionJobManagementUrl(appBasePath, mlUrlGeneratorState.pageState); @@ -56,18 +59,39 @@ export class MlUrlGenerator implements UrlGeneratorsDefinition { defaultMessage: 'Import your own CSV, NDJSON, or log file.', }), icon: 'document', - path: '/app/ml#/filedatavisualizer', + path: '/app/ml/filedatavisualizer', showOnHomePage: true, category: FeatureCatalogueCategory.DATA, order: 520, diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/logo.json new file mode 100644 index 0000000000000..2e57038bbc639 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/logo.json @@ -0,0 +1,3 @@ +{ + "icon": "metricsApp" +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/manifest.json new file mode 100644 index 0000000000000..29ac288c0649f --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/manifest.json @@ -0,0 +1,38 @@ +{ + "id": "metrics_ui_hosts", + "title": "Metrics Hosts", + "description": "Detect anomalous memory and network behavior on hosts.", + "type": "Metricbeat Data", + "logoFile": "logo.json", + "jobs": [ + { + "id": "hosts_memory_usage", + "file": "hosts_memory_usage.json" + }, + { + "id": "hosts_network_in", + "file": "hosts_network_in.json" + }, + { + "id": "hosts_network_out", + "file": "hosts_network_out.json" + } + ], + "datafeeds": [ + { + "id": "datafeed-hosts_memory_usage", + "file": "datafeed_hosts_memory_usage.json", + "job_id": "hosts_memory_usage" + }, + { + "id": "datafeed-hosts_network_in", + "file": "datafeed_hosts_network_in.json", + "job_id": "hosts_network_in" + }, + { + "id": "datafeed-hosts_network_out", + "file": "datafeed_hosts_network_out.json", + "job_id": "hosts_network_out" + } + ] +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_memory_usage.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_memory_usage.json new file mode 100644 index 0000000000000..db883a6ce36f9 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_memory_usage.json @@ -0,0 +1,16 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "indices_options": { + "allow_no_indices": true + }, + "query": { + "bool": { + "must": [ + {"exists": {"field": "system.memory"}} + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_in.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_in.json new file mode 100644 index 0000000000000..7eb430632a81f --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_in.json @@ -0,0 +1,40 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "indices_options": { + "allow_no_indices": true + }, + "query": { + "bool": { + "must": [ + {"exists": {"field": "system.network"}} + ] + } + }, + "chunking_config": { + "mode": "manual", + "time_span": "900s" + }, + "aggregations": { + "host.name": {"terms": {"field": "host.name", "size": 100}, + "aggregations": { + "buckets": { + "date_histogram": {"field": "@timestamp","fixed_interval": "5m"}, + "aggregations": { + "@timestamp": {"max": {"field": "@timestamp"}}, + "bytes_in_max": {"max": {"field": "system.network.in.bytes"}}, + "bytes_in_derivative": {"derivative": {"buckets_path": "bytes_in_max"}}, + "positive_only":{ + "bucket_script": { + "buckets_path": {"in_derivative": "bytes_in_derivative.value"}, + "script": "params.in_derivative > 0.0 ? params.in_derivative : 0.0" + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_out.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_out.json new file mode 100644 index 0000000000000..427cb678ce663 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_out.json @@ -0,0 +1,40 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "indices_options": { + "allow_no_indices": true + }, + "query": { + "bool": { + "must": [ + {"exists": {"field": "system.network"}} + ] + } + }, + "chunking_config": { + "mode": "manual", + "time_span": "900s" + }, + "aggregations": { + "host.name": {"terms": {"field": "host.name", "size": 100}, + "aggregations": { + "buckets": { + "date_histogram": {"field": "@timestamp","fixed_interval": "5m"}, + "aggregations": { + "@timestamp": {"max": {"field": "@timestamp"}}, + "bytes_out_max": {"max": {"field": "system.network.out.bytes"}}, + "bytes_out_derivative": {"derivative": {"buckets_path": "bytes_out_max"}}, + "positive_only":{ + "bucket_script": { + "buckets_path": {"out_derivative": "bytes_out_derivative.value"}, + "script": "params.out_derivative > 0.0 ? params.out_derivative : 0.0" + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_memory_usage.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_memory_usage.json new file mode 100644 index 0000000000000..186c9dcdb27e5 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_memory_usage.json @@ -0,0 +1,50 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "hosts", + "metrics" + ], + "description": "Metrics: Hosts - Identify unusual spikes in memory usage across hosts.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "max('system.memory.actual.used.pct')", + "function": "max", + "field_name": "system.memory.actual.used.pct", + "custom_rules": [ + { + "actions": [ + "skip_result" + ], + "conditions": [ + { + "applies_to": "actual", + "operator": "lt", + "value": 0.1 + } + ] + } + ] + } + ], + "influencers": [ + "host.name" + ] + }, + "data_description": { + "time_field": "@timestamp" + }, + "analysis_limits": { + "model_memory_limit": "64mb" + }, + "custom_settings": { + "created_by": "ml-module-metrics-ui-hosts", + "custom_urls": [ + { + "url_name": "Host Metrics", + "url_value": "metrics/detail/host/$host.name$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))" + } + ] + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_in.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_in.json new file mode 100644 index 0000000000000..0054d90b1df33 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_in.json @@ -0,0 +1,37 @@ +{ + "job_type": "anomaly_detector", + "description": "Metrics: Hosts - Identify unusual spikes in inbound traffic across hosts.", + "groups": [ + "hosts", + "metrics" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "max(bytes_in_derivative)", + "function": "max", + "field_name": "bytes_in_derivative" + } + ], + "influencers": [ + "host.name" + ], + "summary_count_field_name": "doc_count" + }, + "data_description": { + "time_field": "@timestamp" + }, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "custom_settings": { + "created_by": "ml-module-metrics-ui-hosts", + "custom_urls": [ + { + "url_name": "Host Metrics", + "url_value": "metrics/detail/host/$host.name$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))" + } + ] + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_out.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_out.json new file mode 100644 index 0000000000000..601cc3807c441 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_out.json @@ -0,0 +1,37 @@ +{ + "job_type": "anomaly_detector", + "description": "Metrics: Hosts - Identify unusual spikes in outbound traffic across hosts.", + "groups": [ + "hosts", + "metrics" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "max(bytes_out_derivative)", + "function": "max", + "field_name": "bytes_out_derivative" + } + ], + "influencers": [ + "host.name" + ], + "summary_count_field_name": "doc_count" + }, + "data_description": { + "time_field": "@timestamp" + }, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "custom_settings": { + "created_by": "ml-module-metrics-ui-hosts", + "custom_urls": [ + { + "url_name": "Host Metrics", + "url_value": "metrics/detail/host/$host.name$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))" + } + ] + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/logo.json new file mode 100644 index 0000000000000..63105a28c0ab1 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/logo.json @@ -0,0 +1,3 @@ +{ + "icon": "metricsApp" +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/manifest.json new file mode 100644 index 0000000000000..15336069e092b --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/manifest.json @@ -0,0 +1,38 @@ +{ + "id": "metrics_ui_k8s", + "title": "Metrics Kubernetes", + "description": "Detect anomalous memory and network behavior on Kubernetes pods.", + "type": "Metricbeat Data", + "logoFile": "logo.json", + "jobs": [ + { + "id": "k8s_memory_usage", + "file": "k8s_memory_usage.json" + }, + { + "id": "k8s_network_in", + "file": "k8s_network_in.json" + }, + { + "id": "k8s_network_out", + "file": "k8s_network_out.json" + } + ], + "datafeeds": [ + { + "id": "datafeed-k8s_memory_usage", + "file": "datafeed_k8s_memory_usage.json", + "job_id": "k8s_memory_usage" + }, + { + "id": "datafeed-k8s_network_in", + "file": "datafeed_k8s_network_in.json", + "job_id": "k8s_network_in" + }, + { + "id": "datafeed-k8s_network_out", + "file": "datafeed_k8s_network_out.json", + "job_id": "k8s_network_out" + } + ] + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_memory_usage.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_memory_usage.json new file mode 100644 index 0000000000000..14590f743528e --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_memory_usage.json @@ -0,0 +1,17 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "indices_options": { + "allow_no_indices": true + }, + "query": { + "bool": { + "must": [ + {"exists": {"field": "kubernetes.pod.uid"}}, + {"exists": {"field": "kubernetes.pod.memory"}} + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_in.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_in.json new file mode 100644 index 0000000000000..4fa4c603ea049 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_in.json @@ -0,0 +1,44 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "indices_options": { + "allow_no_indices": true + }, + "query": { + "bool": { + "must": [ + {"exists": {"field": "kubernetes.pod.network"}} + ] + } + }, + "chunking_config": { + "mode": "manual", + "time_span": "900s" + }, + "aggregations": { + "kubernetes.namespace": {"terms": {"field": "kubernetes.namespace", "size": 25}, + "aggregations": { + "kubernetes.pod.uid": {"terms": {"field": "kubernetes.pod.uid", "size": 100}, + "aggregations": { + "buckets": { + "date_histogram": {"field": "@timestamp","fixed_interval": "5m"}, + "aggregations": { + "@timestamp": {"max": {"field": "@timestamp"}}, + "bytes_in_max": {"max": {"field": "kubernetes.pod.network.rx.bytes"}}, + "bytes_in_derivative": {"derivative": {"buckets_path": "bytes_in_max"}}, + "positive_only":{ + "bucket_script": { + "buckets_path": {"in_derivative": "bytes_in_derivative.value"}, + "script": "params.in_derivative > 0.0 ? params.in_derivative : 0.0" + } + } + } + } + } + } + } + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_out.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_out.json new file mode 100644 index 0000000000000..633dd6bf490e7 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_out.json @@ -0,0 +1,44 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "indices_options": { + "allow_no_indices": true + }, + "query": { + "bool": { + "must": [ + {"exists": {"field": "kubernetes.pod.network"}} + ] + } + }, + "chunking_config": { + "mode": "manual", + "time_span": "900s" + }, + "aggregations": { + "kubernetes.namespace": {"terms": {"field": "kubernetes.namespace", "size": 25}, + "aggregations": { + "kubernetes.pod.uid": {"terms": {"field": "kubernetes.pod.uid", "size": 100}, + "aggregations": { + "buckets": { + "date_histogram": {"field": "@timestamp","fixed_interval": "5m"}, + "aggregations": { + "@timestamp": {"max": {"field": "@timestamp"}}, + "bytes_out_max": {"max": {"field": "kubernetes.pod.network.tx.bytes"}}, + "bytes_out_derivative": {"derivative": {"buckets_path": "bytes_out_max"}}, + "positive_only":{ + "bucket_script": { + "buckets_path": {"pos_derivative": "bytes_out_derivative.value"}, + "script": "params.pos_derivative > 0.0 ? params.pos_derivative : 0.0" + } + } + } + } + } + } + } + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_memory_usage.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_memory_usage.json new file mode 100644 index 0000000000000..d3f58086e2fd5 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_memory_usage.json @@ -0,0 +1,53 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "k8s", + "metrics" + ], + "description": "Metrics: Kubernetes - Identify unusual spikes in memory usage across Kubernetes pods.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "max('kubernetes.pod.memory.usage.node.pct')", + "function": "max", + "field_name": "kubernetes.pod.memory.usage.node.pct", + "partition_field_name": "kubernetes.namespace", + "custom_rules": [ + { + "actions": [ + "skip_result" + ], + "conditions": [ + { + "applies_to": "actual", + "operator": "lt", + "value": 0.1 + } + ] + } + ] + } + ], + "influencers": [ + "kubernetes.namespace", + "kubernetes.node.name", + "kubernetes.pod.uid" + ] + }, + "data_description": { + "time_field": "@timestamp" + }, + "analysis_limits": { + "model_memory_limit": "64mb" + }, + "custom_settings": { + "created_by": "ml-module-metrics-ui-k8s", + "custom_urls": [ + { + "url_name": "Pod Metrics", + "url_value": "metrics/detail/pod/$kubernetes.pod.uid$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))" + } + ] + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_in.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_in.json new file mode 100644 index 0000000000000..212b2681beb77 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_in.json @@ -0,0 +1,39 @@ +{ + "job_type": "anomaly_detector", + "description": "Metrics: Kubernetes - Identify unusual spikes in inbound traffic across Kubernetes pods.", + "groups": [ + "k8s", + "metrics" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "max(bytes_in_derivative)", + "function": "max", + "field_name": "bytes_in_derivative", + "partition_field_name": "kubernetes.namespace" + } + ], + "influencers": [ + "kubernetes.namespace", + "kubernetes.pod.uid" + ], + "summary_count_field_name": "doc_count" + }, + "data_description": { + "time_field": "@timestamp" + }, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "custom_settings": { + "created_by": "ml-module-metrics-ui-k8s", + "custom_urls": [ + { + "url_name": "Pod Metrics", + "url_value": "metrics/detail/pod/$kubernetes.pod.uid$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))" + } + ] + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_out.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_out.json new file mode 100644 index 0000000000000..b06b0ed5089ef --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_out.json @@ -0,0 +1,39 @@ +{ + "job_type": "anomaly_detector", + "description": "Metrics: Kubernetes - Identify unusual spikes in outbound traffic across Kubernetes pods.", + "groups": [ + "k8s", + "metrics" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "max(bytes_out_derivative)", + "function": "max", + "field_name": "bytes_out_derivative", + "partition_field_name": "kubernetes.namespace" + } + ], + "influencers": [ + "kubernetes.namespace", + "kubernetes.pod.uid" + ], + "summary_count_field_name": "doc_count" + }, + "data_description": { + "time_field": "@timestamp" + }, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "custom_settings": { + "created_by": "ml-module-metrics-ui-k8s", + "custom_urls": [ + { + "url_name": "Pod Metrics", + "url_value": "metrics/detail/pod/$kubernetes.pod.uid$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))" + } + ] + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_user_compiler.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_user_compiler.json index 245b7e0819c7d..bb0323ed9ae78 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_user_compiler.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_user_compiler.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Auditbeat - Looks for compiler activity by a user context which does not normally run compilers. This can be ad-hoc software changes or unauthorized software deployment. This can also be due to local privliege elevation via locally run exploits or malware activity.", + "description": "Security: Auditbeat - Looks for compiler activity by a user context which does not normally run compilers. This can be ad-hoc software changes or unauthorized software deployment. This can also be due to local privilege elevation via locally run exploits or malware activity.", "groups": [ "security", "auditbeat", diff --git a/x-pack/plugins/reporting/common/schema_utils.ts b/x-pack/plugins/reporting/common/schema_utils.ts new file mode 100644 index 0000000000000..f9b5c90e3c366 --- /dev/null +++ b/x-pack/plugins/reporting/common/schema_utils.ts @@ -0,0 +1,28 @@ +/* + * 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 { ByteSizeValue } from '@kbn/config-schema'; +import moment from 'moment'; + +/* + * For cleaner code: use these functions when a config schema value could be + * one type or another. This allows you to treat the value as one type. + */ + +export const durationToNumber = (value: number | moment.Duration): number => { + if (typeof value === 'number') { + return value; + } + return value.asMilliseconds(); +}; + +export const byteSizeValueToNumber = (value: number | ByteSizeValue) => { + if (typeof value === 'number') { + return value; + } + + return value.getValueInBytes(); +}; diff --git a/x-pack/plugins/reporting/public/components/report_listing.tsx b/x-pack/plugins/reporting/public/components/report_listing.tsx index 65db13f22788b..f326d365351f2 100644 --- a/x-pack/plugins/reporting/public/components/report_listing.tsx +++ b/x-pack/plugins/reporting/public/components/report_listing.tsx @@ -6,8 +6,8 @@ import { EuiBasicTable, - EuiFlexItem, EuiFlexGroup, + EuiFlexItem, EuiPageContent, EuiSpacer, EuiText, @@ -23,6 +23,7 @@ import { Subscription } from 'rxjs'; import { ApplicationStart, ToastsSetup } from 'src/core/public'; import { ILicense, LicensingPluginSetup } from '../../../licensing/public'; import { Poller } from '../../common/poller'; +import { durationToNumber } from '../../common/schema_utils'; import { JobStatuses } from '../../constants'; import { checkLicense } from '../lib/license_check'; import { JobQueueEntry, ReportingAPIClient } from '../lib/reporting_api_client'; @@ -183,17 +184,19 @@ class ReportListingUi extends Component { public componentDidMount() { this.mounted = true; + const { pollConfig, license$ } = this.props; + const pollFrequencyInMillis = durationToNumber(pollConfig.jobsRefresh.interval); this.poller = new Poller({ functionToPoll: () => { return this.fetchJobs(); }, - pollFrequencyInMillis: this.props.pollConfig.jobsRefresh.interval, + pollFrequencyInMillis, trailing: false, continuePollingOnError: true, - pollFrequencyErrorMultiplier: this.props.pollConfig.jobsRefresh.intervalErrorMultiplier, + pollFrequencyErrorMultiplier: pollConfig.jobsRefresh.intervalErrorMultiplier, }); this.poller.start(); - this.licenseSubscription = this.props.license$.subscribe(this.licenseHandler); + this.licenseSubscription = license$.subscribe(this.licenseHandler); } private licenseHandler = (license: ILicense) => { diff --git a/x-pack/plugins/reporting/public/plugin.tsx b/x-pack/plugins/reporting/public/plugin.tsx index d003d4c581699..a134377e194b8 100644 --- a/x-pack/plugins/reporting/public/plugin.tsx +++ b/x-pack/plugins/reporting/public/plugin.tsx @@ -26,6 +26,7 @@ import { import { ManagementSetup } from '../../../../src/plugins/management/public'; import { SharePluginSetup } from '../../../../src/plugins/share/public'; import { LicensingPluginSetup } from '../../licensing/public'; +import { durationToNumber } from '../common/schema_utils'; import { JobId, JobStatusBuckets, ReportingConfigType } from '../common/types'; import { JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY } from '../constants'; import { getGeneralErrorToast } from './components'; @@ -158,8 +159,7 @@ export class ReportingPublicPlugin implements Plugin { const { http, notifications } = core; const apiClient = new ReportingAPIClient(http); const streamHandler = new StreamHandler(notifications, apiClient); - const { interval } = this.config.poll.jobsRefresh; - + const interval = durationToNumber(this.config.poll.jobsRefresh.interval); Rx.timer(0, interval) .pipe( takeUntil(this.stop$), // stop the interval when stop method is called diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts index 88be86d1ecc30..6897f07c45e2b 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts @@ -21,6 +21,7 @@ import { InnerSubscriber } from 'rxjs/internal/InnerSubscriber'; import { ignoreElements, map, mergeMap, tap } from 'rxjs/operators'; import { getChromiumDisconnectedError } from '../'; import { BROWSER_TYPE } from '../../../../common/constants'; +import { durationToNumber } from '../../../../common/schema_utils'; import { CaptureConfig } from '../../../../server/types'; import { LevelLogger } from '../../../lib'; import { safeChildProcess } from '../../safe_child_process'; @@ -90,7 +91,7 @@ export class HeadlessChromiumDriverFactory { // Set the default timeout for all navigation methods to the openUrl timeout (30 seconds) // All waitFor methods have their own timeout config passed in to them - page.setDefaultTimeout(this.captureConfig.timeouts.openUrl); + page.setDefaultTimeout(durationToNumber(this.captureConfig.timeouts.openUrl)); logger.debug(`Browser page driver created`); } catch (err) { diff --git a/x-pack/plugins/reporting/server/config/index.ts b/x-pack/plugins/reporting/server/config/index.ts index a89b952702e1b..b9c6f8e7591e3 100644 --- a/x-pack/plugins/reporting/server/config/index.ts +++ b/x-pack/plugins/reporting/server/config/index.ts @@ -17,6 +17,8 @@ export const config: PluginConfigDescriptor = { unused('capture.concurrency'), unused('capture.settleTime'), unused('capture.timeout'), + unused('poll.jobCompletionNotifier.intervalErrorMultiplier'), + unused('poll.jobsRefresh.intervalErrorMultiplier'), unused('kibanaApp'), ], }; diff --git a/x-pack/plugins/reporting/server/config/schema.test.ts b/x-pack/plugins/reporting/server/config/schema.test.ts index 69e4d443cf040..9fc3d4329879e 100644 --- a/x-pack/plugins/reporting/server/config/schema.test.ts +++ b/x-pack/plugins/reporting/server/config/schema.test.ts @@ -8,101 +8,242 @@ import { ConfigSchema } from './schema'; describe('Reporting Config Schema', () => { it(`context {"dev":false,"dist":false} produces correct config`, () => { - expect(ConfigSchema.validate({}, { dev: false, dist: false })).toMatchObject({ - capture: { - browser: { - autoDownload: true, - chromium: { proxy: { enabled: false } }, - type: 'chromium', + expect(ConfigSchema.validate({}, { dev: false, dist: false })).toMatchInlineSnapshot(` + Object { + "capture": Object { + "browser": Object { + "autoDownload": true, + "chromium": Object { + "proxy": Object { + "enabled": false, + }, + }, + "type": "chromium", + }, + "loadDelay": "PT3S", + "maxAttempts": 1, + "networkPolicy": Object { + "enabled": true, + "rules": Array [ + Object { + "allow": true, + "host": undefined, + "protocol": "http:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "https:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "ws:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "wss:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "data:", + }, + Object { + "allow": false, + "host": undefined, + "protocol": undefined, + }, + ], + }, + "timeouts": Object { + "openUrl": "PT1M", + "renderComplete": "PT30S", + "waitForElements": "PT30S", + }, + "viewport": Object { + "height": 1200, + "width": 1950, + }, + "zoom": 2, + }, + "csv": Object { + "checkForFormulas": true, + "enablePanelActionDownload": true, + "escapeFormulaValues": false, + "maxSizeBytes": ByteSizeValue { + "valueInBytes": 10485760, + }, + "scroll": Object { + "duration": "30s", + "size": 500, + }, + "useByteOrderMarkEncoding": false, }, - loadDelay: 3000, - maxAttempts: 1, - networkPolicy: { - enabled: true, - rules: [ - { allow: true, host: undefined, protocol: 'http:' }, - { allow: true, host: undefined, protocol: 'https:' }, - { allow: true, host: undefined, protocol: 'ws:' }, - { allow: true, host: undefined, protocol: 'wss:' }, - { allow: true, host: undefined, protocol: 'data:' }, - { allow: false, host: undefined, protocol: undefined }, + "enabled": true, + "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "index": ".reporting", + "kibanaServer": Object {}, + "poll": Object { + "jobCompletionNotifier": Object { + "interval": 10000, + "intervalErrorMultiplier": 5, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 5, + }, + }, + "queue": Object { + "indexInterval": "week", + "pollEnabled": true, + "pollInterval": "PT3S", + "pollIntervalErrorMultiplier": 10, + "timeout": "PT2M", + }, + "roles": Object { + "allow": Array [ + "reporting_user", ], }, - viewport: { height: 1200, width: 1950 }, - zoom: 2, - }, - csv: { - checkForFormulas: true, - enablePanelActionDownload: true, - maxSizeBytes: 10485760, - scroll: { duration: '30s', size: 500 }, - }, - encryptionKey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - index: '.reporting', - kibanaServer: {}, - poll: { - jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, - jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, - }, - queue: { - indexInterval: 'week', - pollEnabled: true, - pollInterval: 3000, - pollIntervalErrorMultiplier: 10, - timeout: 120000, - }, - roles: { allow: ['reporting_user'] }, - }); + } + `); }); it(`context {"dev":false,"dist":true} produces correct config`, () => { - expect(ConfigSchema.validate({}, { dev: false, dist: true })).toMatchObject({ - capture: { - browser: { - autoDownload: false, - chromium: { - inspect: false, - proxy: { enabled: false }, - }, - type: 'chromium', + expect(ConfigSchema.validate({}, { dev: false, dist: true })).toMatchInlineSnapshot(` + Object { + "capture": Object { + "browser": Object { + "autoDownload": false, + "chromium": Object { + "inspect": false, + "proxy": Object { + "enabled": false, + }, + }, + "type": "chromium", + }, + "loadDelay": "PT3S", + "maxAttempts": 3, + "networkPolicy": Object { + "enabled": true, + "rules": Array [ + Object { + "allow": true, + "host": undefined, + "protocol": "http:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "https:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "ws:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "wss:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "data:", + }, + Object { + "allow": false, + "host": undefined, + "protocol": undefined, + }, + ], + }, + "timeouts": Object { + "openUrl": "PT1M", + "renderComplete": "PT30S", + "waitForElements": "PT30S", + }, + "viewport": Object { + "height": 1200, + "width": 1950, + }, + "zoom": 2, + }, + "csv": Object { + "checkForFormulas": true, + "enablePanelActionDownload": true, + "escapeFormulaValues": false, + "maxSizeBytes": ByteSizeValue { + "valueInBytes": 10485760, + }, + "scroll": Object { + "duration": "30s", + "size": 500, + }, + "useByteOrderMarkEncoding": false, }, - loadDelay: 3000, - maxAttempts: 3, - networkPolicy: { - enabled: true, - rules: [ - { allow: true, host: undefined, protocol: 'http:' }, - { allow: true, host: undefined, protocol: 'https:' }, - { allow: true, host: undefined, protocol: 'ws:' }, - { allow: true, host: undefined, protocol: 'wss:' }, - { allow: true, host: undefined, protocol: 'data:' }, - { allow: false, host: undefined, protocol: undefined }, + "enabled": true, + "index": ".reporting", + "kibanaServer": Object {}, + "poll": Object { + "jobCompletionNotifier": Object { + "interval": 10000, + "intervalErrorMultiplier": 5, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 5, + }, + }, + "queue": Object { + "indexInterval": "week", + "pollEnabled": true, + "pollInterval": "PT3S", + "pollIntervalErrorMultiplier": 10, + "timeout": "PT2M", + }, + "roles": Object { + "allow": Array [ + "reporting_user", ], }, - viewport: { height: 1200, width: 1950 }, - zoom: 2, - }, - csv: { - checkForFormulas: true, - enablePanelActionDownload: true, - maxSizeBytes: 10485760, - scroll: { duration: '30s', size: 500 }, - }, - index: '.reporting', - kibanaServer: {}, - poll: { - jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, - jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, - }, - queue: { - indexInterval: 'week', - pollEnabled: true, - pollInterval: 3000, - pollIntervalErrorMultiplier: 10, - timeout: 120000, - }, - roles: { allow: ['reporting_user'] }, - }); + } + `); + }); + + it('allows Duration values for certain keys', () => { + expect(ConfigSchema.validate({ queue: { timeout: '2m' } }).queue.timeout).toMatchInlineSnapshot( + `"PT2M"` + ); + + expect( + ConfigSchema.validate({ capture: { loadDelay: '3s' } }).capture.loadDelay + ).toMatchInlineSnapshot(`"PT3S"`); + + expect( + ConfigSchema.validate({ + capture: { timeouts: { openUrl: '1m', waitForElements: '30s', renderComplete: '10s' } }, + }).capture.timeouts + ).toMatchInlineSnapshot(` + Object { + "openUrl": "PT1M", + "renderComplete": "PT10S", + "waitForElements": "PT30S", + } + `); + }); + + it('allows ByteSizeValue values for certain keys', () => { + expect(ConfigSchema.validate({ csv: { maxSizeBytes: '12mb' } }).csv.maxSizeBytes) + .toMatchInlineSnapshot(` + ByteSizeValue { + "valueInBytes": 12582912, + } + `); }); it(`allows optional settings`, () => { diff --git a/x-pack/plugins/reporting/server/config/schema.ts b/x-pack/plugins/reporting/server/config/schema.ts index a81ffd754946b..8276e8b49d348 100644 --- a/x-pack/plugins/reporting/server/config/schema.ts +++ b/x-pack/plugins/reporting/server/config/schema.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema, TypeOf } from '@kbn/config-schema'; +import { ByteSizeValue, schema, TypeOf } from '@kbn/config-schema'; import moment from 'moment'; const KibanaServerSchema = schema.object({ @@ -33,9 +33,13 @@ const KibanaServerSchema = schema.object({ const QueueSchema = schema.object({ indexInterval: schema.string({ defaultValue: 'week' }), pollEnabled: schema.boolean({ defaultValue: true }), - pollInterval: schema.number({ defaultValue: 3000 }), + pollInterval: schema.oneOf([schema.number(), schema.duration()], { + defaultValue: moment.duration({ seconds: 3 }), + }), pollIntervalErrorMultiplier: schema.number({ defaultValue: 10 }), - timeout: schema.number({ defaultValue: moment.duration(2, 'm').asMilliseconds() }), + timeout: schema.oneOf([schema.number(), schema.duration()], { + defaultValue: moment.duration({ minutes: 2 }), + }), }); const RulesSchema = schema.object({ @@ -46,9 +50,15 @@ const RulesSchema = schema.object({ const CaptureSchema = schema.object({ timeouts: schema.object({ - openUrl: schema.number({ defaultValue: 60000 }), - waitForElements: schema.number({ defaultValue: 30000 }), - renderComplete: schema.number({ defaultValue: 30000 }), + openUrl: schema.oneOf([schema.number(), schema.duration()], { + defaultValue: moment.duration({ minutes: 1 }), + }), + waitForElements: schema.oneOf([schema.number(), schema.duration()], { + defaultValue: moment.duration({ seconds: 30 }), + }), + renderComplete: schema.oneOf([schema.number(), schema.duration()], { + defaultValue: moment.duration({ seconds: 30 }), + }), }), networkPolicy: schema.object({ enabled: schema.boolean({ defaultValue: true }), @@ -68,9 +78,9 @@ const CaptureSchema = schema.object({ width: schema.number({ defaultValue: 1950 }), height: schema.number({ defaultValue: 1200 }), }), - loadDelay: schema.number({ - defaultValue: moment.duration(3, 's').asMilliseconds(), - }), // TODO: use schema.duration + loadDelay: schema.oneOf([schema.number(), schema.duration()], { + defaultValue: moment.duration({ seconds: 3 }), + }), browser: schema.object({ autoDownload: schema.conditional( schema.contextRef('dist'), @@ -116,13 +126,13 @@ const CsvSchema = schema.object({ checkForFormulas: schema.boolean({ defaultValue: true }), escapeFormulaValues: schema.boolean({ defaultValue: false }), enablePanelActionDownload: schema.boolean({ defaultValue: true }), - maxSizeBytes: schema.number({ - defaultValue: 1024 * 1024 * 10, // 10MB - }), // TODO: use schema.byteSize + maxSizeBytes: schema.oneOf([schema.number(), schema.byteSize()], { + defaultValue: ByteSizeValue.parse('10mb'), + }), useByteOrderMarkEncoding: schema.boolean({ defaultValue: false }), scroll: schema.object({ duration: schema.string({ - defaultValue: '30s', + defaultValue: '30s', // this value is passed directly to ES, so string only format is preferred validate(value) { if (!/^[0-9]+(d|h|m|s|ms|micros|nanos)$/.test(value)) { return 'must be a duration string'; @@ -146,18 +156,16 @@ const RolesSchema = schema.object({ const IndexSchema = schema.string({ defaultValue: '.reporting' }); +// Browser side polling: job completion notifier, management table auto-refresh +// NOTE: can not use schema.duration, a bug prevents it being passed to the browser correctly const PollSchema = schema.object({ jobCompletionNotifier: schema.object({ - interval: schema.number({ - defaultValue: moment.duration(10, 's').asMilliseconds(), - }), // TODO: use schema.duration - intervalErrorMultiplier: schema.number({ defaultValue: 5 }), + interval: schema.number({ defaultValue: 10000 }), + intervalErrorMultiplier: schema.number({ defaultValue: 5 }), // unused }), jobsRefresh: schema.object({ - interval: schema.number({ - defaultValue: moment.duration(5, 's').asMilliseconds(), - }), // TODO: use schema.duration - intervalErrorMultiplier: schema.number({ defaultValue: 5 }), + interval: schema.number({ defaultValue: 5000 }), + intervalErrorMultiplier: schema.number({ defaultValue: 5 }), // unused }), }); diff --git a/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.test.ts b/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.test.ts index 754bc7bc75cb5..a0d8ff0852544 100644 --- a/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.test.ts @@ -4,10 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import sinon from 'sinon'; import { ReportingConfig } from '../../'; import { ReportingCore } from '../../core'; -import { createMockReportingCore } from '../../test_helpers'; +import { + createMockConfig, + createMockConfigSchema, + createMockReportingCore, +} from '../../test_helpers'; import { BasePayload } from '../../types'; import { TaskPayloadPDF } from '../printable_pdf/types'; import { getConditionalHeaders, getCustomLogo } from './'; @@ -15,17 +18,10 @@ import { getConditionalHeaders, getCustomLogo } from './'; let mockConfig: ReportingConfig; let mockReportingPlugin: ReportingCore; -const getMockConfig = (mockConfigGet: sinon.SinonStub) => ({ - get: mockConfigGet, - kbnConfig: { get: mockConfigGet }, -}); - beforeEach(async () => { - const mockConfigGet = sinon - .stub() - .withArgs('kibanaServer', 'hostname') - .returns('custom-hostname'); - mockConfig = getMockConfig(mockConfigGet); + const reportingConfig = { kibanaServer: { hostname: 'custom-hostname' } }; + const mockSchema = createMockConfigSchema(reportingConfig); + mockConfig = createMockConfig(mockSchema); mockReportingPlugin = await createMockReportingCore(mockConfig); }); @@ -84,10 +80,9 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav const mockGetSavedObjectsClient = jest.fn(); mockReportingPlugin.getSavedObjectsClient = mockGetSavedObjectsClient; - const mockConfigGet = sinon.stub(); - mockConfigGet.withArgs('kibanaServer', 'hostname').returns('localhost'); - mockConfigGet.withArgs('server', 'basePath').returns('/sbp'); - mockConfig = getMockConfig(mockConfigGet); + const reportingConfig = { kibanaServer: { hostname: 'localhost' }, server: { basePath: '/sbp' } }; + const mockSchema = createMockConfigSchema(reportingConfig); + mockConfig = createMockConfig(mockSchema); const permittedHeaders = { foo: 'bar', @@ -134,25 +129,12 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav }); describe('config formatting', () => { - test(`lowercases server.host`, async () => { - const mockConfigGet = sinon.stub().withArgs('server', 'host').returns('COOL-HOSTNAME'); - mockConfig = getMockConfig(mockConfigGet); - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as BasePayload, - filteredHeaders: {}, - config: mockConfig, - }); - expect(conditionalHeaders.conditions.hostname).toEqual('cool-hostname'); - }); - test(`lowercases kibanaServer.hostname`, async () => { - const mockConfigGet = sinon - .stub() - .withArgs('kibanaServer', 'hostname') - .returns('GREAT-HOSTNAME'); - mockConfig = getMockConfig(mockConfigGet); - const conditionalHeaders = await getConditionalHeaders({ + const reportingConfig = { kibanaServer: { hostname: 'GREAT-HOSTNAME' } }; + const mockSchema = createMockConfigSchema(reportingConfig); + mockConfig = createMockConfig(mockSchema); + + const conditionalHeaders = getConditionalHeaders({ job: { title: 'cool-job-bro', type: 'csv', diff --git a/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.test.ts b/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.test.ts index 8c02fdd69de8b..ec4e54632eef5 100644 --- a/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.test.ts @@ -4,18 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ReportingCore } from '../../core'; -import { createMockReportingCore } from '../../test_helpers'; +import { ReportingConfig, ReportingCore } from '../../'; +import { + createMockConfig, + createMockConfigSchema, + createMockReportingCore, +} from '../../test_helpers'; import { TaskPayloadPDF } from '../printable_pdf/types'; import { getConditionalHeaders, getCustomLogo } from './'; -const mockConfigGet = jest.fn().mockImplementation((key: string) => { - return 'localhost'; -}); -const mockConfig = { get: mockConfigGet, kbnConfig: { get: mockConfigGet } }; - +let mockConfig: ReportingConfig; let mockReportingPlugin: ReportingCore; + beforeEach(async () => { + mockConfig = createMockConfig(createMockConfigSchema()); mockReportingPlugin = await createMockReportingCore(mockConfig); }); diff --git a/x-pack/plugins/reporting/server/export_types/common/get_full_urls.test.ts b/x-pack/plugins/reporting/server/export_types/common/get_full_urls.test.ts index 355536000326e..fae66b26a83e0 100644 --- a/x-pack/plugins/reporting/server/export_types/common/get_full_urls.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_full_urls.test.ts @@ -5,6 +5,7 @@ */ import { ReportingConfig } from '../../'; +import { createMockConfig } from '../../test_helpers'; import { TaskPayloadPNG } from '../png/types'; import { TaskPayloadPDF } from '../printable_pdf/types'; import { getFullUrls } from './get_full_urls'; @@ -15,12 +16,6 @@ interface FullUrlsOpts { } let mockConfig: ReportingConfig; -const getMockConfig = (mockConfigGet: jest.Mock) => { - return { - get: mockConfigGet, - kbnConfig: { get: mockConfigGet }, - }; -}; beforeEach(() => { const reportingConfig: Record = { @@ -29,10 +24,7 @@ beforeEach(() => { 'kibanaServer.protocol': 'http', 'server.basePath': '/sbp', }; - const mockConfigGet = jest.fn().mockImplementation((...keys: string[]) => { - return reportingConfig[keys.join('.') as string]; - }); - mockConfig = getMockConfig(mockConfigGet); + mockConfig = createMockConfig(reportingConfig); }); const getMockJob = (base: object) => base as TaskPayloadPNG & TaskPayloadPDF; diff --git a/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts index 15432d0cbd147..72b42143a24f7 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts @@ -6,6 +6,7 @@ import nodeCrypto from '@elastic/node-crypto'; import { ElasticsearchServiceSetup, IUiSettingsClient } from 'kibana/server'; +import moment from 'moment'; // @ts-ignore import Puid from 'puid'; import sinon from 'sinon'; @@ -73,6 +74,7 @@ describe('CSV Execute Job', function () { beforeEach(async function () { configGetStub = sinon.stub(); + configGetStub.withArgs('queue', 'timeout').returns(moment.duration('2m')); configGetStub.withArgs('index').returns('.reporting-foo-test'); configGetStub.withArgs('encryptionKey').returns(encryptionKey); configGetStub.withArgs('csv', 'maxSizeBytes').returns(1024 * 1000); // 1mB diff --git a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts index 06aa2434afc3f..e383f21143149 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts @@ -6,11 +6,12 @@ import { i18n } from '@kbn/i18n'; import { IUiSettingsClient } from 'src/core/server'; -import { getFieldFormats } from '../../../services'; import { ReportingConfig } from '../../../'; import { CancellationToken } from '../../../../../../plugins/reporting/common'; import { CSV_BOM_CHARS } from '../../../../common/constants'; +import { byteSizeValueToNumber } from '../../../../common/schema_utils'; import { LevelLogger } from '../../../lib'; +import { getFieldFormats } from '../../../services'; import { IndexPatternSavedObject, SavedSearchGeneratorResult } from '../types'; import { checkIfRowsHaveFormulas } from './check_cells_for_formulas'; import { createEscapeValue } from './escape_value'; @@ -64,7 +65,7 @@ export function createGenerateCsv(logger: LevelLogger) { ); const escapeValue = createEscapeValue(settings.quoteValues, settings.escapeFormulaValues); const bom = config.get('csv', 'useByteOrderMarkEncoding') ? CSV_BOM_CHARS : ''; - const builder = new MaxSizeStringBuilder(settings.maxSizeBytes, bom); + const builder = new MaxSizeStringBuilder(byteSizeValueToNumber(settings.maxSizeBytes), bom); const { fields, metaFields, conflictedTypesFields } = job; const header = `${fields.map(escapeValue).join(settings.separator)}\n`; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts index fdc51dc1c9c87..e7322bdc0d408 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts @@ -10,7 +10,11 @@ import * as Rx from 'rxjs'; import { ReportingCore } from '../../../'; import { CancellationToken } from '../../../../common'; import { cryptoFactory, LevelLogger } from '../../../lib'; -import { createMockReportingCore } from '../../../test_helpers'; +import { + createMockConfig, + createMockConfigSchema, + createMockReportingCore, +} from '../../../test_helpers'; import { generatePdfObservableFactory } from '../lib/generate_pdf'; import { TaskPayloadPDF } from '../types'; import { runTaskFnFactory } from './'; @@ -39,20 +43,16 @@ const encryptHeaders = async (headers: Record) => { const getBasePayload = (baseObj: any) => baseObj as TaskPayloadPDF; beforeEach(async () => { - const kbnConfig = { - 'server.basePath': '/sbp', - }; const reportingConfig = { + 'server.basePath': '/sbp', index: '.reports-test', encryptionKey: mockEncryptionKey, 'kibanaServer.hostname': 'localhost', 'kibanaServer.port': 5601, 'kibanaServer.protocol': 'http', }; - const mockReportingConfig = { - get: (...keys: string[]) => (reportingConfig as any)[keys.join('.')], - kbnConfig: { get: (...keys: string[]) => (kbnConfig as any)[keys.join('.')] }, - }; + const mockSchema = createMockConfigSchema(reportingConfig); + const mockReportingConfig = createMockConfig(mockSchema); mockReporting = await createMockReportingCore(mockReportingConfig); @@ -79,7 +79,7 @@ test(`passes browserTimezone to generatePdf`, async () => { const generatePdfObservable = (await generatePdfObservableFactory(mockReporting)) as jest.Mock; generatePdfObservable.mockReturnValue(Rx.of(Buffer.from(''))); - const runTask = await runTaskFnFactory(mockReporting, getMockLogger()); + const runTask = runTaskFnFactory(mockReporting, getMockLogger()); const browserTimezone = 'UTC'; await runTask( 'pdfJobId', @@ -98,7 +98,7 @@ test(`passes browserTimezone to generatePdf`, async () => { test(`returns content_type of application/pdf`, async () => { const logger = getMockLogger(); - const runTask = await runTaskFnFactory(mockReporting, logger); + const runTask = runTaskFnFactory(mockReporting, logger); const encryptedHeaders = await encryptHeaders({}); const generatePdfObservable = await generatePdfObservableFactory(mockReporting); @@ -117,7 +117,7 @@ test(`returns content of generatePdf getBuffer base64 encoded`, async () => { const generatePdfObservable = await generatePdfObservableFactory(mockReporting); (generatePdfObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); - const runTask = await runTaskFnFactory(mockReporting, getMockLogger()); + const runTask = runTaskFnFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); const { content } = await runTask( 'pdfJobId', diff --git a/x-pack/plugins/reporting/server/lib/create_worker.test.ts b/x-pack/plugins/reporting/server/lib/create_worker.test.ts index 85188c07eeb20..1fcd750849331 100644 --- a/x-pack/plugins/reporting/server/lib/create_worker.test.ts +++ b/x-pack/plugins/reporting/server/lib/create_worker.test.ts @@ -6,7 +6,12 @@ import * as sinon from 'sinon'; import { ReportingConfig, ReportingCore } from '../../server'; -import { createMockReportingCore } from '../test_helpers'; +import { + createMockConfig, + createMockConfigSchema, + createMockLevelLogger, + createMockReportingCore, +} from '../test_helpers'; import { createWorkerFactory } from './create_worker'; // @ts-ignore import { Esqueue } from './esqueue'; @@ -14,16 +19,13 @@ import { Esqueue } from './esqueue'; import { ClientMock } from './esqueue/__tests__/fixtures/legacy_elasticsearch'; import { ExportTypesRegistry } from './export_types_registry'; -const configGetStub = sinon.stub(); -configGetStub.withArgs('queue').returns({ - pollInterval: 3300, - pollIntervalErrorMultiplier: 10, -}); -configGetStub.withArgs('server', 'name').returns('test-server-123'); -configGetStub.withArgs('server', 'uuid').returns('g9ymiujthvy6v8yrh7567g6fwzgzftzfr'); +const logger = createMockLevelLogger(); +const reportingConfig = { + queue: { pollInterval: 3300, pollIntervalErrorMultiplier: 10 }, + server: { name: 'test-server-123', uuid: 'g9ymiujthvy6v8yrh7567g6fwzgzftzfr' }, +}; const executeJobFactoryStub = sinon.stub(); -const getMockLogger = sinon.stub(); const getMockExportTypesRegistry = ( exportTypes: any[] = [{ runTaskFnFactory: executeJobFactoryStub }] @@ -39,18 +41,18 @@ describe('Create Worker', () => { let client: ClientMock; beforeEach(async () => { - mockConfig = { get: configGetStub, kbnConfig: { get: configGetStub } }; + const mockSchema = createMockConfigSchema(reportingConfig); + mockConfig = createMockConfig(mockSchema); mockReporting = await createMockReportingCore(mockConfig); mockReporting.getExportTypesRegistry = () => getMockExportTypesRegistry(); - // @ts-ignore over-riding config manually - mockReporting.config = mockConfig; + client = new ClientMock(); queue = new Esqueue('reporting-queue', { client }); executeJobFactoryStub.reset(); }); test('Creates a single Esqueue worker for Reporting', async () => { - const createWorker = createWorkerFactory(mockReporting, getMockLogger()); + const createWorker = createWorkerFactory(mockReporting, logger); const registerWorkerSpy = sinon.spy(queue, 'registerWorker'); await createWorker(queue); @@ -82,7 +84,7 @@ Object { { runTaskFnFactory: executeJobFactoryStub }, ]); mockReporting.getExportTypesRegistry = () => exportTypesRegistry; - const createWorker = createWorkerFactory(mockReporting, getMockLogger()); + const createWorker = createWorkerFactory(mockReporting, logger); const registerWorkerSpy = sinon.spy(queue, 'registerWorker'); await createWorker(queue); diff --git a/x-pack/plugins/reporting/server/lib/create_worker.ts b/x-pack/plugins/reporting/server/lib/create_worker.ts index dd5c560455274..c1c88dd8a54ba 100644 --- a/x-pack/plugins/reporting/server/lib/create_worker.ts +++ b/x-pack/plugins/reporting/server/lib/create_worker.ts @@ -6,6 +6,7 @@ import { CancellationToken } from '../../common'; import { PLUGIN_ID } from '../../common/constants'; +import { durationToNumber } from '../../common/schema_utils'; import { ReportingCore } from '../../server'; import { LevelLogger } from '../../server/lib'; import { ExportTypeDefinition, JobSource, RunTaskFn } from '../../server/types'; @@ -57,7 +58,7 @@ export function createWorkerFactory(reporting: ReportingCore, log const workerOptions = { kibanaName, kibanaId, - interval: queueConfig.pollInterval, + interval: durationToNumber(queueConfig.pollInterval), intervalErrorMultiplier: queueConfig.pollIntervalErrorMultiplier, }; const worker = queue.registerWorker(PLUGIN_ID, workerFn, workerOptions); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts b/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts index 49c690e8c024d..89cb4221c96b2 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts @@ -5,9 +5,10 @@ */ import { i18n } from '@kbn/i18n'; +import { durationToNumber } from '../../../common/schema_utils'; +import { LevelLogger, startTrace } from '../'; import { HeadlessChromiumDriver } from '../../browsers'; import { CaptureConfig } from '../../types'; -import { LevelLogger, startTrace } from '../'; import { LayoutInstance } from '../layouts'; import { CONTEXT_GETNUMBEROFITEMS, CONTEXT_READMETADATA } from './constants'; @@ -31,9 +32,10 @@ export const getNumberOfItems = async ( // the dashboard is using the `itemsCountAttribute` attribute to let us // know how many items to expect since gridster incrementally adds panels // we have to use this hint to wait for all of them + const timeout = durationToNumber(captureConfig.timeouts.waitForElements); await browser.waitForSelector( `${renderCompleteSelector},[${itemsCountAttribute}]`, - { timeout: captureConfig.timeouts.waitForElements }, + { timeout }, { context: CONTEXT_READMETADATA }, logger ); @@ -59,6 +61,7 @@ export const getNumberOfItems = async ( logger ); } catch (err) { + logger.error(err); throw new Error( i18n.translate('xpack.reporting.screencapture.readVisualizationsError', { defaultMessage: `An error occurred when trying to read the page for visualization panel info. You may need to increase '{configKey}'. {error}`, diff --git a/x-pack/plugins/reporting/server/lib/screenshots/inject_css.ts b/x-pack/plugins/reporting/server/lib/screenshots/inject_css.ts index f893951815e9e..2fc711d4d6f07 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/inject_css.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/inject_css.ts @@ -43,6 +43,7 @@ export const injectCustomCss = async ( logger ); } catch (err) { + logger.error(err); throw new Error( i18n.translate('xpack.reporting.screencapture.injectCss', { defaultMessage: `An error occurred when trying to update Kibana CSS for reporting. {error}`, diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts index 3749e4372bdab..5b671e9f5b47e 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts @@ -15,12 +15,17 @@ jest.mock('../../browsers/chromium/puppeteer', () => ({ }), })); +import moment from 'moment'; import * as Rx from 'rxjs'; -import { LevelLogger } from '../'; -import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { HeadlessChromiumDriver } from '../../browsers'; -import { createMockBrowserDriverFactory, createMockLayoutInstance } from '../../test_helpers'; -import { CaptureConfig, ConditionalHeaders } from '../../types'; +import { + createMockBrowserDriverFactory, + createMockConfig, + createMockConfigSchema, + createMockLayoutInstance, + createMockLevelLogger, +} from '../../test_helpers'; +import { ConditionalHeaders } from '../../types'; import { ElementsPositionAndAttribute } from './'; import * as contexts from './constants'; import { screenshotsObservableFactory } from './observable'; @@ -28,11 +33,22 @@ import { screenshotsObservableFactory } from './observable'; /* * Mocks */ -const mockLogger = jest.fn(loggingSystemMock.create); -const logger = new LevelLogger(mockLogger()); +const logger = createMockLevelLogger(); -const mockConfig = { timeouts: { openUrl: 13 } } as CaptureConfig; -const mockLayout = createMockLayoutInstance(mockConfig); +const reportingConfig = { + capture: { + loadDelay: moment.duration(2, 's'), + timeouts: { + openUrl: moment.duration(2, 'm'), + waitForElements: moment.duration(20, 's'), + renderComplete: moment.duration(10, 's'), + }, + }, +}; +const mockSchema = createMockConfigSchema(reportingConfig); +const mockConfig = createMockConfig(mockSchema); +const captureConfig = mockConfig.get('capture'); +const mockLayout = createMockLayoutInstance(captureConfig); /* * Tests @@ -45,7 +61,7 @@ describe('Screenshot Observable Pipeline', () => { }); it('pipelines a single url into screenshot and timeRange', async () => { - const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(captureConfig, mockBrowserDriverFactory); const result = await getScreenshots$({ logger, urls: ['/welcome/home/start/index.htm'], @@ -106,7 +122,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(captureConfig, mockBrowserDriverFactory); const result = await getScreenshots$({ logger, urls: ['/welcome/home/start/index2.htm', '/welcome/home/start/index.php3?page=./home.php'], @@ -205,7 +221,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(captureConfig, mockBrowserDriverFactory); const getScreenshot = async () => { return await getScreenshots$({ logger, @@ -300,7 +316,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(captureConfig, mockBrowserDriverFactory); const getScreenshot = async () => { return await getScreenshots$({ logger, @@ -333,7 +349,7 @@ describe('Screenshot Observable Pipeline', () => { mockLayout.getViewport = () => null; // test - const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(captureConfig, mockBrowserDriverFactory); const getScreenshot = async () => { return await getScreenshots$({ logger, diff --git a/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts b/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts index c21ef3b91fab3..e28f50851f4d9 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts @@ -5,9 +5,10 @@ */ import { i18n } from '@kbn/i18n'; +import { durationToNumber } from '../../../common/schema_utils'; +import { LevelLogger, startTrace } from '../'; import { HeadlessChromiumDriver } from '../../browsers'; import { CaptureConfig, ConditionalHeaders } from '../../types'; -import { LevelLogger, startTrace } from '../'; export const openUrl = async ( captureConfig: CaptureConfig, @@ -19,16 +20,14 @@ export const openUrl = async ( ): Promise => { const endTrace = startTrace('open_url', 'wait'); try { + const timeout = durationToNumber(captureConfig.timeouts.openUrl); await browser.open( url, - { - conditionalHeaders, - waitForSelector: pageLoadSelector, - timeout: captureConfig.timeouts.openUrl, - }, + { conditionalHeaders, waitForSelector: pageLoadSelector, timeout }, logger ); } catch (err) { + logger.error(err); throw new Error( i18n.translate('xpack.reporting.screencapture.couldntLoadKibana', { defaultMessage: `An error occurred when trying to open the Kibana URL. You may need to increase '{configKey}'. {error}`, diff --git a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts index f36a7b6f73664..edd4f71b2adac 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts @@ -5,6 +5,7 @@ */ import { i18n } from '@kbn/i18n'; +import { durationToNumber } from '../../../common/schema_utils'; import { HeadlessChromiumDriver } from '../../browsers'; import { CaptureConfig } from '../../types'; import { LevelLogger, startTrace } from '../'; @@ -67,7 +68,7 @@ export const waitForRenderComplete = async ( return Promise.all(renderedTasks).then(hackyWaitForVisualizations); }, - args: [layout.selectors.renderComplete, captureConfig.loadDelay], + args: [layout.selectors.renderComplete, durationToNumber(captureConfig.loadDelay)], }, { context: CONTEXT_WAITFORRENDER }, logger diff --git a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts index 779d00442522d..5f86a2b3bf00b 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts @@ -5,8 +5,9 @@ */ import { i18n } from '@kbn/i18n'; -import { HeadlessChromiumDriver } from '../../browsers'; +import { durationToNumber } from '../../../common/schema_utils'; import { LevelLogger, startTrace } from '../'; +import { HeadlessChromiumDriver } from '../../browsers'; import { CaptureConfig } from '../../types'; import { LayoutInstance } from '../layouts'; import { CONTEXT_WAITFORELEMENTSTOBEINDOM } from './constants'; @@ -25,7 +26,7 @@ const getCompletedItemsCount = ({ renderCompleteSelector }: SelectorArgs) => { export const waitForVisualizations = async ( captureConfig: CaptureConfig, browser: HeadlessChromiumDriver, - itemsCount: number, + toEqual: number, layout: LayoutInstance, logger: LevelLogger ): Promise => { @@ -35,29 +36,26 @@ export const waitForVisualizations = async ( logger.debug( i18n.translate('xpack.reporting.screencapture.waitingForRenderedElements', { defaultMessage: `waiting for {itemsCount} rendered elements to be in the DOM`, - values: { itemsCount }, + values: { itemsCount: toEqual }, }) ); try { + const timeout = durationToNumber(captureConfig.timeouts.renderComplete); await browser.waitFor( - { - fn: getCompletedItemsCount, - args: [{ renderCompleteSelector }], - toEqual: itemsCount, - timeout: captureConfig.timeouts.renderComplete, - }, + { fn: getCompletedItemsCount, args: [{ renderCompleteSelector }], toEqual, timeout }, { context: CONTEXT_WAITFORELEMENTSTOBEINDOM }, logger ); - logger.debug(`found ${itemsCount} rendered elements in the DOM`); + logger.debug(`found ${toEqual} rendered elements in the DOM`); } catch (err) { + logger.error(err); throw new Error( i18n.translate('xpack.reporting.screencapture.couldntFinishRendering', { defaultMessage: `An error occurred when trying to wait for {count} visualizations to finish rendering. You may need to increase '{configKey}'. {error}`, values: { - count: itemsCount, + count: toEqual, configKey: 'xpack.reporting.capture.timeouts.renderComplete', error: err, }, diff --git a/x-pack/plugins/reporting/server/lib/store/index_timestamp.ts b/x-pack/plugins/reporting/server/lib/store/index_timestamp.ts index 71ce0b1e572f8..7b8b851f5bd72 100644 --- a/x-pack/plugins/reporting/server/lib/store/index_timestamp.ts +++ b/x-pack/plugins/reporting/server/lib/store/index_timestamp.ts @@ -8,7 +8,6 @@ import moment, { unitOfTime } from 'moment'; export const intervals = ['year', 'month', 'week', 'day', 'hour', 'minute']; -// TODO: This helper function can be removed by using `schema.duration` objects in the reporting config schema export function indexTimestamp(intervalStr: string, separator = '-') { const startOf = intervalStr as unitOfTime.StartOf; if (separator.match(/[a-z]/i)) throw new Error('Interval separator can not be a letter'); diff --git a/x-pack/plugins/reporting/server/lib/store/store.test.ts b/x-pack/plugins/reporting/server/lib/store/store.test.ts index b87466ca289cf..8dc4edd200052 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.test.ts @@ -7,15 +7,15 @@ import sinon from 'sinon'; import { ElasticsearchServiceSetup } from 'src/core/server'; import { ReportingConfig, ReportingCore } from '../..'; -import { createMockReportingCore, createMockLevelLogger } from '../../test_helpers'; +import { + createMockConfig, + createMockConfigSchema, + createMockLevelLogger, + createMockReportingCore, +} from '../../test_helpers'; import { Report } from './report'; import { ReportingStore } from './store'; -const getMockConfig = (mockConfigGet: sinon.SinonStub) => ({ - get: mockConfigGet, - kbnConfig: { get: mockConfigGet }, -}); - describe('ReportingStore', () => { const mockLogger = createMockLevelLogger(); let mockConfig: ReportingConfig; @@ -25,10 +25,12 @@ describe('ReportingStore', () => { const mockElasticsearch = { legacy: { client: { callAsInternalUser: callClusterStub } } }; beforeEach(async () => { - const mockConfigGet = sinon.stub(); - mockConfigGet.withArgs('index').returns('.reporting-test'); - mockConfigGet.withArgs('queue', 'indexInterval').returns('week'); - mockConfig = getMockConfig(mockConfigGet); + const reportingConfig = { + index: '.reporting-test', + queue: { indexInterval: 'week' }, + }; + const mockSchema = createMockConfigSchema(reportingConfig); + mockConfig = createMockConfig(mockSchema); mockCore = await createMockReportingCore(mockConfig); callClusterStub.reset(); @@ -67,15 +69,17 @@ describe('ReportingStore', () => { priority: 10, started_at: undefined, status: 'pending', - timeout: undefined, + timeout: 120000, }); }); it('throws if options has invalid indexInterval', async () => { - const mockConfigGet = sinon.stub(); - mockConfigGet.withArgs('index').returns('.reporting-test'); - mockConfigGet.withArgs('queue', 'indexInterval').returns('centurially'); - mockConfig = getMockConfig(mockConfigGet); + const reportingConfig = { + index: '.reporting-test', + queue: { indexInterval: 'centurially' }, + }; + const mockSchema = createMockConfigSchema(reportingConfig); + mockConfig = createMockConfig(mockSchema); mockCore = await createMockReportingCore(mockConfig); const store = new ReportingStore(mockCore, mockLogger); @@ -159,7 +163,7 @@ describe('ReportingStore', () => { priority: 10, started_at: undefined, status: 'pending', - timeout: undefined, + timeout: 120000, }); }); @@ -190,7 +194,7 @@ describe('ReportingStore', () => { priority: 10, started_at: undefined, status: 'pending', - timeout: undefined, + timeout: 120000, }); }); }); diff --git a/x-pack/plugins/reporting/server/lib/store/store.ts b/x-pack/plugins/reporting/server/lib/store/store.ts index b1309cbdeb94d..0aae8b567bcdb 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.ts @@ -5,6 +5,7 @@ */ import { ElasticsearchServiceSetup } from 'src/core/server'; +import { durationToNumber } from '../../../common/schema_utils'; import { LevelLogger, statuses } from '../'; import { ReportingCore } from '../../'; import { BaseParams, BaseParamsEncryptedFields, ReportingUser } from '../../types'; @@ -45,7 +46,7 @@ export class ReportingStore { this.indexPrefix = config.get('index'); this.indexInterval = config.get('queue', 'indexInterval'); this.jobSettings = { - timeout: config.get('queue', 'timeout'), + timeout: durationToNumber(config.get('queue', 'timeout')), browser_type: config.get('capture', 'browser', 'type'), max_attempts: config.get('capture', 'maxAttempts'), priority: 10, // unused diff --git a/x-pack/plugins/reporting/server/plugin.test.ts b/x-pack/plugins/reporting/server/plugin.test.ts index d323a281c06ff..3f2f472ab0623 100644 --- a/x-pack/plugins/reporting/server/plugin.test.ts +++ b/x-pack/plugins/reporting/server/plugin.test.ts @@ -32,8 +32,8 @@ describe('Reporting Plugin', () => { beforeEach(async () => { configSchema = createMockConfigSchema(); initContext = coreMock.createPluginInitializerContext(configSchema); - coreSetup = await coreMock.createSetup(configSchema); - coreStart = await coreMock.createStart(); + coreSetup = coreMock.createSetup(configSchema); + coreStart = coreMock.createStart(); pluginSetup = ({ licensing: {}, features: featuresPluginMock.createSetup(), diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts b/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts index f92fbfc7013cf..71ca0661a42a9 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts @@ -33,7 +33,15 @@ describe('POST /diagnose/browser', () => { const mockedCreateInterface: any = createInterface; const config = { - get: jest.fn().mockImplementation(() => ({})), + get: jest.fn().mockImplementation((...keys) => { + const key = keys.join('.'); + switch (key) { + case 'queue.timeout': + return 120000; + case 'capture.browser.chromium.proxy': + return { enabled: false }; + } + }), kbnConfig: { get: jest.fn() }, }; diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts b/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts index 24b85220defb4..33620bc9a0038 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts @@ -54,25 +54,30 @@ export const registerDiagnoseBrowser = (reporting: ReportingCore, logger: Logger validate: {}, }, userHandler(async (user, context, req, res) => { - const logs = await browserStartLogs(reporting, logger).toPromise(); - const knownIssues = Object.keys(logsToHelpMap) as Array; + try { + const logs = await browserStartLogs(reporting, logger).toPromise(); + const knownIssues = Object.keys(logsToHelpMap) as Array; - const boundSuccessfully = logs.includes(`DevTools listening on`); - const help = knownIssues.reduce((helpTexts: string[], knownIssue) => { - const helpText = logsToHelpMap[knownIssue]; - if (logs.includes(knownIssue)) { - helpTexts.push(helpText); - } - return helpTexts; - }, []); + const boundSuccessfully = logs.includes(`DevTools listening on`); + const help = knownIssues.reduce((helpTexts: string[], knownIssue) => { + const helpText = logsToHelpMap[knownIssue]; + if (logs.includes(knownIssue)) { + helpTexts.push(helpText); + } + return helpTexts; + }, []); - const response: DiagnosticResponse = { - success: boundSuccessfully && !help.length, - help, - logs, - }; + const response: DiagnosticResponse = { + success: boundSuccessfully && !help.length, + help, + logs, + }; - return res.ok({ body: response }); + return res.ok({ body: response }); + } catch (err) { + logger.error(err); + return res.custom({ statusCode: 500 }); + } }) ); }; diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/config.test.ts b/x-pack/plugins/reporting/server/routes/diagnostic/config.test.ts index 624397246656d..a112d04f38c7b 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/config.test.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/config.test.ts @@ -35,7 +35,15 @@ describe('POST /diagnose/config', () => { } as unknown) as any; config = { - get: jest.fn(), + get: jest.fn().mockImplementation((...keys) => { + const key = keys.join('.'); + switch (key) { + case 'queue.timeout': + return 120000; + case 'csv.maxSizeBytes': + return 1024; + } + }), kbnConfig: { get: jest.fn() }, }; diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/config.ts b/x-pack/plugins/reporting/server/routes/diagnostic/config.ts index 198ba63e2614d..95c3a05bbf680 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/config.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/config.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ByteSizeValue } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; -import numeral from '@elastic/numeral'; import { defaults, get } from 'lodash'; import { ReportingCore } from '../..'; import { API_DIAGNOSE_URL } from '../../../common/constants'; @@ -16,6 +16,14 @@ import { authorizedUserPreRoutingFactory } from '../lib/authorized_user_pre_rout const KIBANA_MAX_SIZE_BYTES_PATH = 'csv.maxSizeBytes'; const ES_MAX_SIZE_BYTES_PATH = 'http.max_content_length'; +const numberToByteSizeValue = (value: number | ByteSizeValue) => { + if (typeof value === 'number') { + return new ByteSizeValue(value); + } + + return value; +}; + export const registerDiagnoseConfig = (reporting: ReportingCore, logger: Logger) => { const setupDeps = reporting.getPluginSetupDeps(); const userHandler = authorizedUserPreRoutingFactory(reporting); @@ -42,12 +50,10 @@ export const registerDiagnoseConfig = (reporting: ReportingCore, logger: Logger) 'http.max_content_length', '100mb' ); - const elasticSearchMaxContentBytes = numeral().unformat( - elasticSearchMaxContent.toUpperCase() - ); - const kibanaMaxContentBytes = config.get('csv', 'maxSizeBytes'); + const elasticSearchMaxContentBytes = ByteSizeValue.parse(elasticSearchMaxContent); + const kibanaMaxContentBytes = numberToByteSizeValue(config.get('csv', 'maxSizeBytes')); - if (kibanaMaxContentBytes > elasticSearchMaxContentBytes) { + if (kibanaMaxContentBytes.isGreaterThan(elasticSearchMaxContentBytes)) { const maxContentSizeWarning = i18n.translate( 'xpack.reporting.diagnostic.configSizeMismatch', { @@ -55,8 +61,8 @@ export const registerDiagnoseConfig = (reporting: ReportingCore, logger: Logger) `xpack.reporting.{KIBANA_MAX_SIZE_BYTES_PATH} ({kibanaMaxContentBytes}) is higher than ElasticSearch's {ES_MAX_SIZE_BYTES_PATH} ({elasticSearchMaxContentBytes}). ` + `Please set {ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your xpack.reporting.{KIBANA_MAX_SIZE_BYTES_PATH} in Kibana.`, values: { - kibanaMaxContentBytes, - elasticSearchMaxContentBytes, + kibanaMaxContentBytes: kibanaMaxContentBytes.getValueInBytes(), + elasticSearchMaxContentBytes: elasticSearchMaxContentBytes.getValueInBytes(), KIBANA_MAX_SIZE_BYTES_PATH, ES_MAX_SIZE_BYTES_PATH, }, diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts index ec4ab0446ae5f..287da0d2ed5ec 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts @@ -33,7 +33,11 @@ describe('POST /diagnose/screenshot', () => { }; const config = { - get: jest.fn(), + get: jest.fn().mockImplementation((...keys) => { + if (keys.join('.') === 'queue.timeout') { + return 120000; + } + }), kbnConfig: { get: jest.fn() }, }; const mockLogger = createMockLevelLogger(); diff --git a/x-pack/plugins/reporting/server/routes/jobs.test.ts b/x-pack/plugins/reporting/server/routes/jobs.test.ts index 2957bc76f4682..187c69f4a72ef 100644 --- a/x-pack/plugins/reporting/server/routes/jobs.test.ts +++ b/x-pack/plugins/reporting/server/routes/jobs.test.ts @@ -10,9 +10,8 @@ import { setupServer } from 'src/core/server/test_utils'; import supertest from 'supertest'; import { ReportingCore } from '..'; import { ReportingInternalSetup } from '../core'; -import { LevelLogger } from '../lib'; import { ExportTypesRegistry } from '../lib/export_types_registry'; -import { createMockReportingCore } from '../test_helpers'; +import { createMockConfig, createMockConfigSchema, createMockReportingCore } from '../test_helpers'; import { ExportTypeDefinition } from '../types'; import { registerJobInfoRoutes } from './jobs'; @@ -25,11 +24,7 @@ describe('GET /api/reporting/jobs/download', () => { let exportTypesRegistry: ExportTypesRegistry; let core: ReportingCore; - const config = { get: jest.fn(), kbnConfig: { get: jest.fn() } }; - const mockLogger = ({ - error: jest.fn(), - debug: jest.fn(), - } as unknown) as jest.Mocked; + const config = createMockConfig(createMockConfigSchema()); const getHits = (...sources: any) => { return { @@ -86,8 +81,6 @@ describe('GET /api/reporting/jobs/download', () => { }); afterEach(async () => { - mockLogger.debug.mockReset(); - mockLogger.error.mockReset(); await server.stop(); }); diff --git a/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.ts b/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.ts index 50780a577af02..932ebfdd22bbc 100644 --- a/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.ts +++ b/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.ts @@ -4,24 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest, RequestHandlerContext, KibanaResponseFactory } from 'kibana/server'; +import { KibanaRequest, KibanaResponseFactory, RequestHandlerContext } from 'kibana/server'; import { coreMock, httpServerMock } from 'src/core/server/mocks'; import { ReportingCore } from '../../'; -import { createMockReportingCore } from '../../test_helpers'; -import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; import { ReportingInternalSetup } from '../../core'; +import { + createMockConfig, + createMockConfigSchema, + createMockReportingCore, +} from '../../test_helpers'; +import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; let mockCore: ReportingCore; -const kbnConfig = { - 'server.basePath': '/sbp', -}; -const reportingConfig = { - 'roles.allow': ['reporting_user'], -}; -const mockReportingConfig = { - get: (...keys: string[]) => (reportingConfig as any)[keys.join('.')] || 'whoah!', - kbnConfig: { get: (...keys: string[]) => (kbnConfig as any)[keys.join('.')] }, -}; +const mockConfig: any = { 'server.basePath': '/sbp', 'roles.allow': ['reporting_user'] }; +const mockReportingConfigSchema = createMockConfigSchema(mockConfig); +const mockReportingConfig = createMockConfig(mockReportingConfigSchema); const getMockContext = () => (({ diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts index f2785bce10964..d6996d2caf1bc 100644 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts +++ b/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import moment from 'moment'; import { Page } from 'puppeteer'; import * as Rx from 'rxjs'; import { chromium, HeadlessChromiumDriver, HeadlessChromiumDriverFactory } from '../browsers'; @@ -15,6 +16,7 @@ import { CaptureConfig } from '../types'; interface CreateMockBrowserDriverFactoryOpts { evaluate: jest.Mock, any[]>; waitForSelector: jest.Mock, any[]>; + waitFor: jest.Mock, any[]>; screenshot: jest.Mock, any[]>; open: jest.Mock, any[]>; getCreatePage: (driver: HeadlessChromiumDriver) => jest.Mock; @@ -86,6 +88,7 @@ const getCreatePage = (driver: HeadlessChromiumDriver) => const defaultOpts: CreateMockBrowserDriverFactoryOpts = { evaluate: mockBrowserEvaluate, waitForSelector: mockWaitForSelector, + waitFor: jest.fn(), screenshot: mockScreenshot, open: jest.fn(), getCreatePage, @@ -96,7 +99,11 @@ export const createMockBrowserDriverFactory = async ( opts: Partial = {} ): Promise => { const captureConfig: CaptureConfig = { - timeouts: { openUrl: 30000, waitForElements: 30000, renderComplete: 30000 }, + timeouts: { + openUrl: moment.duration(60, 's'), + waitForElements: moment.duration(30, 's'), + renderComplete: moment.duration(30, 's'), + }, browser: { type: 'chromium', chromium: { @@ -108,18 +115,14 @@ export const createMockBrowserDriverFactory = async ( }, networkPolicy: { enabled: true, rules: [] }, viewport: { width: 800, height: 600 }, - loadDelay: 2000, + loadDelay: moment.duration(2, 's'), zoom: 2, maxAttempts: 1, }; const binaryPath = '/usr/local/share/common/secure/super_awesome_binary'; - const mockBrowserDriverFactory = await chromium.createDriverFactory( - binaryPath, - captureConfig, - logger - ); - const mockPage = {} as Page; + const mockBrowserDriverFactory = chromium.createDriverFactory(binaryPath, captureConfig, logger); + const mockPage = ({ setViewport: () => {} } as unknown) as Page; const mockBrowserDriver = new HeadlessChromiumDriver(mockPage, { inspect: true, networkPolicy: captureConfig.networkPolicy, @@ -127,6 +130,7 @@ export const createMockBrowserDriverFactory = async ( // mock the driver methods as either default mocks or passed-in mockBrowserDriver.waitForSelector = opts.waitForSelector ? opts.waitForSelector : defaultOpts.waitForSelector; // prettier-ignore + mockBrowserDriver.waitFor = opts.waitFor ? opts.waitFor : defaultOpts.waitFor; mockBrowserDriver.evaluate = opts.evaluate ? opts.evaluate : defaultOpts.evaluate; mockBrowserDriver.screenshot = opts.screenshot ? opts.screenshot : defaultOpts.screenshot; mockBrowserDriver.open = opts.open ? opts.open : defaultOpts.open; diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts index 559726e0b8a99..6ec35db5caec6 100644 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts @@ -9,14 +9,16 @@ jest.mock('../usage'); jest.mock('../browsers'); jest.mock('../lib/create_queue'); +import _ from 'lodash'; import * as Rx from 'rxjs'; -import { featuresPluginMock } from '../../../features/server/mocks'; import { ReportingConfig, ReportingCore } from '../'; +import { featuresPluginMock } from '../../../features/server/mocks'; import { chromium, HeadlessChromiumDriverFactory, initializeBrowserDriverFactory, } from '../browsers'; +import { ReportingConfigType } from '../config'; import { ReportingInternalSetup, ReportingInternalStart } from '../core'; import { ReportingStore } from '../lib'; import { ReportingStartDeps } from '../types'; @@ -57,12 +59,58 @@ const createMockPluginStart = ( }; }; -export const createMockConfigSchema = (overrides?: any) => ({ - index: '.reporting', - kibanaServer: { hostname: 'localhost', port: '80' }, - capture: { browser: { chromium: { disableSandbox: true } } }, - ...overrides, -}); +interface ReportingConfigTestType { + index: string; + encryptionKey: string; + queue: Partial; + kibanaServer: Partial; + csv: Partial; + capture: any; + server?: any; +} + +export const createMockConfigSchema = ( + overrides: Partial = {} +): ReportingConfigTestType => { + // deeply merge the defaults and the provided partial schema + return { + index: '.reporting', + encryptionKey: 'cool-encryption-key-where-did-you-find-it', + ...overrides, + kibanaServer: { + hostname: 'localhost', + port: 80, + ...overrides.kibanaServer, + }, + capture: { + browser: { + chromium: { + disableSandbox: true, + }, + }, + ...overrides.capture, + }, + queue: { + timeout: 120000, + ...overrides.queue, + }, + csv: { + ...overrides.csv, + }, + }; +}; + +export const createMockConfig = ( + reportingConfig: Partial +): ReportingConfig => { + const mockConfigGet = jest.fn().mockImplementation((...keys: string[]) => { + return _.get(reportingConfig, keys.join('.')); + }); + return { + get: mockConfigGet, + kbnConfig: { get: mockConfigGet }, + }; +}; export const createMockStartDeps = (startMock?: any): ReportingStartDeps => ({ data: startMock.data, diff --git a/x-pack/plugins/reporting/server/test_helpers/index.ts b/x-pack/plugins/reporting/server/test_helpers/index.ts index 2d5ef9fdd768d..96357dc915eef 100644 --- a/x-pack/plugins/reporting/server/test_helpers/index.ts +++ b/x-pack/plugins/reporting/server/test_helpers/index.ts @@ -4,8 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -export { createMockServer } from './create_mock_server'; -export { createMockReportingCore, createMockConfigSchema } from './create_mock_reportingplugin'; export { createMockBrowserDriverFactory } from './create_mock_browserdriverfactory'; export { createMockLayoutInstance } from './create_mock_layoutinstance'; export { createMockLevelLogger } from './create_mock_levellogger'; +export { + createMockConfig, + createMockConfigSchema, + createMockReportingCore, +} from './create_mock_reportingplugin'; +export { createMockServer } from './create_mock_server'; diff --git a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts index ed2abef2542de..fc2dce441c621 100644 --- a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts +++ b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts @@ -8,8 +8,8 @@ import * as Rx from 'rxjs'; import sinon from 'sinon'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { ReportingConfig, ReportingCore } from '../'; -import { createMockReportingCore } from '../test_helpers'; import { getExportTypesRegistry } from '../lib/export_types_registry'; +import { createMockConfig, createMockConfigSchema, createMockReportingCore } from '../test_helpers'; import { ReportingSetupDeps } from '../types'; import { FeaturesAvailability } from './'; import { @@ -54,17 +54,13 @@ function getPluginsMock( } as unknown) as ReportingSetupDeps & { usageCollection: UsageCollectionSetup }; } -const getMockReportingConfig = () => ({ - get: () => {}, - kbnConfig: { get: () => '' }, -}); const getResponseMock = (base = {}) => base; describe('license checks', () => { let mockConfig: ReportingConfig; let mockCore: ReportingCore; beforeAll(async () => { - mockConfig = getMockReportingConfig(); + mockConfig = createMockConfig(createMockConfigSchema()); mockCore = await createMockReportingCore(mockConfig); }); @@ -189,7 +185,7 @@ describe('data modeling', () => { let mockConfig: ReportingConfig; let mockCore: ReportingCore; beforeAll(async () => { - mockConfig = getMockReportingConfig(); + mockConfig = createMockConfig(createMockConfigSchema()); mockCore = await createMockReportingCore(mockConfig); }); test('with normal looking usage data', async () => { @@ -455,7 +451,7 @@ describe('data modeling', () => { describe('Ready for collection observable', () => { test('converts observable to promise', async () => { - const mockConfig = getMockReportingConfig(); + const mockConfig = createMockConfig(createMockConfigSchema()); const mockReporting = await createMockReportingCore(mockConfig); const usageCollection = getMockUsageCollection(); diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js b/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js index d466ebd69737e..8672a8b8f6849 100644 --- a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js +++ b/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js @@ -6,21 +6,16 @@ import { registerRollupSearchStrategy } from './register_rollup_search_strategy'; describe('Register Rollup Search Strategy', () => { - let routeDependencies; let addSearchStrategy; + let getRollupService; beforeEach(() => { - routeDependencies = { - router: jest.fn().mockName('router'), - elasticsearchService: jest.fn().mockName('elasticsearchService'), - elasticsearch: jest.fn().mockName('elasticsearch'), - }; - addSearchStrategy = jest.fn().mockName('addSearchStrategy'); + getRollupService = jest.fn().mockName('getRollupService'); }); test('should run initialization', () => { - registerRollupSearchStrategy(routeDependencies, addSearchStrategy); + registerRollupSearchStrategy(addSearchStrategy, getRollupService); expect(addSearchStrategy).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts b/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts index 333863979ba95..22dafbb71d802 100644 --- a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts +++ b/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts @@ -4,27 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ILegacyScopedClusterClient } from 'src/core/server'; import { - AbstractSearchRequest, DefaultSearchCapabilities, AbstractSearchStrategy, + ReqFacade, } from '../../../../../../src/plugins/vis_type_timeseries/server'; -import { CallWithRequestFactoryShim } from '../../types'; import { getRollupSearchStrategy } from './rollup_search_strategy'; -import { getRollupSearchRequest } from './rollup_search_request'; import { getRollupSearchCapabilities } from './rollup_search_capabilities'; export const registerRollupSearchStrategy = ( - callWithRequestFactory: CallWithRequestFactoryShim, - addSearchStrategy: (searchStrategy: any) => void + addSearchStrategy: (searchStrategy: any) => void, + getRollupService: (reg: ReqFacade) => Promise ) => { - const RollupSearchRequest = getRollupSearchRequest(AbstractSearchRequest); const RollupSearchCapabilities = getRollupSearchCapabilities(DefaultSearchCapabilities); const RollupSearchStrategy = getRollupSearchStrategy( AbstractSearchStrategy, - RollupSearchRequest, RollupSearchCapabilities, - callWithRequestFactory + getRollupService ); addSearchStrategy(new RollupSearchStrategy()); diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.test.js b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.test.js deleted file mode 100644 index 2ea0612140946..0000000000000 --- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.test.js +++ /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 { getRollupSearchRequest } from './rollup_search_request'; - -class AbstractSearchRequest { - indexPattern = 'indexPattern'; - callWithRequest = jest.fn(({ body }) => Promise.resolve(body)); -} - -describe('Rollup search request', () => { - let RollupSearchRequest; - - beforeEach(() => { - RollupSearchRequest = getRollupSearchRequest(AbstractSearchRequest); - }); - - test('should create instance of RollupSearchRequest', () => { - const rollupSearchRequest = new RollupSearchRequest(); - - expect(rollupSearchRequest).toBeInstanceOf(AbstractSearchRequest); - expect(rollupSearchRequest.search).toBeDefined(); - expect(rollupSearchRequest.callWithRequest).toBeDefined(); - }); - - test('should send one request for single search', async () => { - const rollupSearchRequest = new RollupSearchRequest(); - const searches = [{ body: 'body', index: 'index' }]; - - await rollupSearchRequest.search(searches); - - expect(rollupSearchRequest.callWithRequest).toHaveBeenCalledTimes(1); - expect(rollupSearchRequest.callWithRequest).toHaveBeenCalledWith('rollup.search', { - body: 'body', - index: 'index', - rest_total_hits_as_int: true, - }); - }); - - test('should send multiple request for multi search', async () => { - const rollupSearchRequest = new RollupSearchRequest(); - const searches = [ - { body: 'body', index: 'index' }, - { body: 'body1', index: 'index' }, - ]; - - await rollupSearchRequest.search(searches); - - expect(rollupSearchRequest.callWithRequest).toHaveBeenCalledTimes(2); - }); -}); diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.ts b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.ts deleted file mode 100644 index 7e12d5286f34c..0000000000000 --- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.ts +++ /dev/null @@ -1,28 +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. - */ -const SEARCH_METHOD = 'rollup.search'; - -interface Search { - index: string; - body: { - [key: string]: any; - }; -} - -export const getRollupSearchRequest = (AbstractSearchRequest: any) => - class RollupSearchRequest extends AbstractSearchRequest { - async search(searches: Search[]) { - const requests = searches.map(({ body, index }) => - this.callWithRequest(SEARCH_METHOD, { - body, - index, - rest_total_hits_as_int: true, - }) - ); - - return await Promise.all(requests); - } - }; diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js index 63f4628e36bfe..f3da7ed3fdd17 100644 --- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js +++ b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js @@ -7,13 +7,32 @@ import { getRollupSearchStrategy } from './rollup_search_strategy'; describe('Rollup Search Strategy', () => { let RollupSearchStrategy; - let RollupSearchRequest; let RollupSearchCapabilities; let callWithRequest; let rollupResolvedData; - const server = 'server'; - const request = 'request'; + const request = { + requestContext: { + core: { + elasticsearch: { + client: { + asCurrentUser: { + rollup: { + getRollupIndexCaps: jest.fn().mockImplementation(() => rollupResolvedData), + }, + }, + }, + }, + }, + }, + }; + const getRollupService = jest.fn().mockImplementation(() => { + return { + callAsCurrentUser: async () => { + return rollupResolvedData; + }, + }; + }); const indexPattern = 'indexPattern'; beforeEach(() => { @@ -33,19 +52,17 @@ describe('Rollup Search Strategy', () => { } } - RollupSearchRequest = jest.fn(); RollupSearchCapabilities = jest.fn(() => 'capabilities'); - callWithRequest = jest.fn().mockImplementation(() => rollupResolvedData); RollupSearchStrategy = getRollupSearchStrategy( AbstractSearchStrategy, - RollupSearchRequest, - RollupSearchCapabilities + RollupSearchCapabilities, + getRollupService ); }); test('should create instance of RollupSearchRequest', () => { - const rollupSearchStrategy = new RollupSearchStrategy(server); + const rollupSearchStrategy = new RollupSearchStrategy(); expect(rollupSearchStrategy.name).toBe('rollup'); }); @@ -55,7 +72,7 @@ describe('Rollup Search Strategy', () => { const rollupIndex = 'rollupIndex'; beforeEach(() => { - rollupSearchStrategy = new RollupSearchStrategy(server); + rollupSearchStrategy = new RollupSearchStrategy(); rollupSearchStrategy.getRollupData = jest.fn(() => ({ [rollupIndex]: { rollup_jobs: [ @@ -104,7 +121,7 @@ describe('Rollup Search Strategy', () => { let rollupSearchStrategy; beforeEach(() => { - rollupSearchStrategy = new RollupSearchStrategy(server); + rollupSearchStrategy = new RollupSearchStrategy(); }); test('should return rollup data', async () => { @@ -112,10 +129,7 @@ describe('Rollup Search Strategy', () => { const rollupData = await rollupSearchStrategy.getRollupData(request, indexPattern); - expect(callWithRequest).toHaveBeenCalledWith('rollup.rollupIndexCapabilities', { - indexPattern, - }); - expect(rollupSearchStrategy.getCallWithRequestInstance).toHaveBeenCalledWith(request); + expect(getRollupService).toHaveBeenCalled(); expect(rollupData).toBe('data'); }); @@ -135,7 +149,7 @@ describe('Rollup Search Strategy', () => { const rollupIndex = 'rollupIndex'; beforeEach(() => { - rollupSearchStrategy = new RollupSearchStrategy(server); + rollupSearchStrategy = new RollupSearchStrategy(); fieldsCapabilities = { [rollupIndex]: { aggs: { diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts index 885836780f1a9..e7794caf8697b 100644 --- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts +++ b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts @@ -4,15 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ import { keyBy, isString } from 'lodash'; -import { KibanaRequest } from 'src/core/server'; - -import { CallWithRequestFactoryShim } from '../../types'; +import { ILegacyScopedClusterClient } from 'src/core/server'; +import { ReqFacade } from '../../../../../../src/plugins/vis_type_timeseries/server'; +import { ENHANCED_ES_SEARCH_STRATEGY } from '../../../../data_enhanced/server'; import { mergeCapabilitiesWithFields } from '../merge_capabilities_with_fields'; import { getCapabilitiesForRollupIndices } from '../map_capabilities'; -const ROLLUP_INDEX_CAPABILITIES_METHOD = 'rollup.rollupIndexCapabilities'; - -const getRollupIndices = (rollupData: { [key: string]: any[] }) => Object.keys(rollupData); +const getRollupIndices = (rollupData: { [key: string]: any }) => Object.keys(rollupData); const isIndexPatternContainsWildcard = (indexPattern: string) => indexPattern.includes('*'); const isIndexPatternValid = (indexPattern: string) => @@ -20,28 +18,40 @@ const isIndexPatternValid = (indexPattern: string) => export const getRollupSearchStrategy = ( AbstractSearchStrategy: any, - RollupSearchRequest: any, RollupSearchCapabilities: any, - callWithRequestFactory: CallWithRequestFactoryShim + getRollupService: (reg: ReqFacade) => Promise ) => class RollupSearchStrategy extends AbstractSearchStrategy { name = 'rollup'; constructor() { - // TODO: When vis_type_timeseries and AbstractSearchStrategy are migrated to the NP, it - // shouldn't require elasticsearchService to be injected, and we can remove this null argument. - super(null, callWithRequestFactory, RollupSearchRequest); + super(ENHANCED_ES_SEARCH_STRATEGY, 'rollup', { rest_total_hits_as_int: true }); } - getRollupData(req: KibanaRequest, indexPattern: string) { - const callWithRequest = this.getCallWithRequestInstance(req); + async search(req: ReqFacade, bodies: any[], options = {}) { + const rollupService = await getRollupService(req); + const requests: any[] = []; + bodies.forEach((body) => { + requests.push( + rollupService.callAsCurrentUser('rollup.search', { + ...body, + rest_total_hits_as_int: true, + }) + ); + }); + return Promise.all(requests); + } - return callWithRequest(ROLLUP_INDEX_CAPABILITIES_METHOD, { - indexPattern, - }).catch(() => Promise.resolve({})); + async getRollupData(req: ReqFacade, indexPattern: string) { + const rollupService = await getRollupService(req); + return rollupService + .callAsCurrentUser('rollup.rollupIndexCapabilities', { + indexPattern, + }) + .catch(() => Promise.resolve({})); } - async checkForViability(req: KibanaRequest, indexPattern: string) { + async checkForViability(req: ReqFacade, indexPattern: string) { let isViable = false; let capabilities = null; @@ -66,7 +76,7 @@ export const getRollupSearchStrategy = ( } async getFieldsForWildcard( - req: KibanaRequest, + req: ReqFacade, indexPattern: string, { fieldsCapabilities, diff --git a/x-pack/plugins/rollup/server/plugin.ts b/x-pack/plugins/rollup/server/plugin.ts index 8b3a6355f950d..fe193150fc1ca 100644 --- a/x-pack/plugins/rollup/server/plugin.ts +++ b/x-pack/plugins/rollup/server/plugin.ts @@ -17,17 +17,16 @@ import { ILegacyCustomClusterClient, Plugin, Logger, - KibanaRequest, PluginInitializerContext, ILegacyScopedClusterClient, - LegacyAPICaller, SharedGlobalConfig, } from 'src/core/server'; import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; +import { ReqFacade } from '../../../../src/plugins/vis_type_timeseries/server'; import { PLUGIN, CONFIG_ROLLUPS } from '../common'; -import { Dependencies, CallWithRequestFactoryShim } from './types'; +import { Dependencies } from './types'; import { registerApiRoutes } from './routes'; import { License } from './services'; import { registerRollupUsageCollector } from './collectors'; @@ -132,19 +131,12 @@ export class RollupPlugin implements Plugin { }); if (visTypeTimeseries) { - // TODO: When vis_type_timeseries is fully migrated to the NP, it shouldn't require this shim. - const callWithRequestFactoryShim = ( - elasticsearchServiceShim: CallWithRequestFactoryShim, - request: KibanaRequest - ): LegacyAPICaller => { - return async (...args: Parameters) => { - this.rollupEsClient = this.rollupEsClient ?? (await getCustomEsClient(getStartServices)); - return await this.rollupEsClient.asScoped(request).callAsCurrentUser(...args); - }; + const getRollupService = async (request: ReqFacade) => { + this.rollupEsClient = this.rollupEsClient ?? (await getCustomEsClient(getStartServices)); + return this.rollupEsClient.asScoped(request); }; - const { addSearchStrategy } = visTypeTimeseries; - registerRollupSearchStrategy(callWithRequestFactoryShim, addSearchStrategy); + registerRollupSearchStrategy(addSearchStrategy, getRollupService); } if (usageCollection) { diff --git a/x-pack/plugins/rollup/server/types.ts b/x-pack/plugins/rollup/server/types.ts index 290d2df050099..b167806cf8d5d 100644 --- a/x-pack/plugins/rollup/server/types.ts +++ b/x-pack/plugins/rollup/server/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IRouter, LegacyAPICaller, KibanaRequest } from 'src/core/server'; +import { IRouter } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { VisTypeTimeseriesSetup } from 'src/plugins/vis_type_timeseries/server'; @@ -39,9 +39,3 @@ export interface RouteDependencies { IndexPatternsFetcher: typeof IndexPatternsFetcher; }; } - -// TODO: When vis_type_timeseries is fully migrated to the NP, it shouldn't require this shim. -export type CallWithRequestFactoryShim = ( - elasticsearchServiceShim: CallWithRequestFactoryShim, - request: KibanaRequest -) => LegacyAPICaller; diff --git a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts index 6194d6892d799..a45b1fd18a4b6 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts @@ -24,17 +24,16 @@ import { ALL_CASES_TAGS_COUNT, } from '../screens/all_cases'; import { - ACTION, CASE_DETAILS_DESCRIPTION, CASE_DETAILS_PAGE_TITLE, CASE_DETAILS_PUSH_TO_EXTERNAL_SERVICE_BTN, CASE_DETAILS_STATUS, CASE_DETAILS_TAGS, - CASE_DETAILS_USER_ACTION, + CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME, + CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT, CASE_DETAILS_USERNAMES, PARTICIPANTS, REPORTER, - USER, } from '../screens/case_details'; import { TIMELINE_DESCRIPTION, TIMELINE_QUERY, TIMELINE_TITLE } from '../screens/timeline'; @@ -84,8 +83,8 @@ describe('Cases', () => { const expectedTags = case1.tags.join(''); cy.get(CASE_DETAILS_PAGE_TITLE).should('have.text', case1.name); cy.get(CASE_DETAILS_STATUS).should('have.text', 'open'); - cy.get(CASE_DETAILS_USER_ACTION).eq(USER).should('have.text', case1.reporter); - cy.get(CASE_DETAILS_USER_ACTION).eq(ACTION).should('have.text', 'added description'); + cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME).should('have.text', case1.reporter); + cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT).should('have.text', 'added description'); cy.get(CASE_DETAILS_DESCRIPTION).should( 'have.text', `${case1.description} ${case1.timeline.title}` diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts index 6af4d174b9583..3862a89a7d833 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts @@ -11,7 +11,7 @@ import { addNewCase, selectCase, } from '../tasks/timeline'; -import { DESCRIPTION_INPUT } from '../screens/create_new_case'; +import { DESCRIPTION_INPUT, ADD_COMMENT_INPUT } from '../screens/create_new_case'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { caseTimeline, TIMELINE_CASE_ID } from '../objects/case'; @@ -34,7 +34,7 @@ describe('attach timeline to case', () => { cy.location('origin').then((origin) => { cy.get(DESCRIPTION_INPUT).should( 'have.text', - `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:'${caseTimeline.id}',isOpen:!t))` + `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:%27${caseTimeline.id}%27,isOpen:!t))` ); }); }); @@ -46,7 +46,7 @@ describe('attach timeline to case', () => { cy.location('origin').then((origin) => { cy.get(DESCRIPTION_INPUT).should( 'have.text', - `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:'${caseTimeline.id}',isOpen:!t))` + `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:%27${caseTimeline.id}%27,isOpen:!t))` ); }); }); @@ -66,9 +66,9 @@ describe('attach timeline to case', () => { selectCase(TIMELINE_CASE_ID); cy.location('origin').then((origin) => { - cy.get(DESCRIPTION_INPUT).should( + cy.get(ADD_COMMENT_INPUT).should( 'have.text', - `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:'${caseTimeline.id}',isOpen:!t))` + `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:%27${caseTimeline.id}%27,isOpen:!t))` ); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/case_details.ts b/x-pack/plugins/security_solution/cypress/screens/case_details.ts index f2cdaa6994356..7b995f5395543 100644 --- a/x-pack/plugins/security_solution/cypress/screens/case_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/case_details.ts @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export const ACTION = 2; - -export const CASE_DETAILS_DESCRIPTION = '[data-test-subj="markdown-root"]'; +export const CASE_DETAILS_DESCRIPTION = + '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]'; export const CASE_DETAILS_PAGE_TITLE = '[data-test-subj="header-page-title"]'; @@ -17,14 +16,17 @@ export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status"]'; export const CASE_DETAILS_TAGS = '[data-test-subj="case-tags"]'; -export const CASE_DETAILS_TIMELINE_LINK_MARKDOWN = '[data-test-subj="markdown-timeline-link"]'; +export const CASE_DETAILS_TIMELINE_LINK_MARKDOWN = + '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"] button'; + +export const CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT = + '[data-test-subj="description-action"] .euiCommentEvent__headerEvent'; -export const CASE_DETAILS_USER_ACTION = '[data-test-subj="user-action-title"] .euiFlexItem'; +export const CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME = + '[data-test-subj="description-action"] .euiCommentEvent__headerUsername'; export const CASE_DETAILS_USERNAMES = '[data-test-subj="case-view-username"]'; export const PARTICIPANTS = 1; export const REPORTER = 0; - -export const USER = 1; diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts index 9431c054d96a4..4f348b4dcdbd1 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +export const ADD_COMMENT_INPUT = '[data-test-subj="add-comment"] textarea'; + export const BACK_TO_CASES_BTN = '[data-test-subj="backToCases"]'; -export const DESCRIPTION_INPUT = '[data-test-subj="textAreaInput"]'; +export const DESCRIPTION_INPUT = '[data-test-subj="caseDescription"] textarea'; -export const INSERT_TIMELINE_BTN = '[data-test-subj="insert-timeline-button"]'; +export const INSERT_TIMELINE_BTN = '.euiMarkdownEditorToolbar [aria-label="Insert timeline link"]'; export const LOADING_SPINNER = '[data-test-subj="create-case-loading-spinner"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts index 1d5d240c5c53d..f5013eed07d29 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts @@ -13,7 +13,6 @@ import { INSERT_TIMELINE_BTN, LOADING_SPINNER, TAGS_INPUT, - TIMELINE, TIMELINE_SEARCHBOX, TITLE_INPUT, } from '../screens/create_new_case'; @@ -43,9 +42,6 @@ export const createNewCaseWithTimeline = (newCase: TestCase) => { cy.get(INSERT_TIMELINE_BTN).click({ force: true }); cy.get(TIMELINE_SEARCHBOX).type(`${newCase.timeline.title}{enter}`); - cy.get(TIMELINE).should('be.visible'); - cy.wait(300); - cy.get(TIMELINE).eq(0).click({ force: true }); cy.get(SUBMIT_BTN).click({ force: true }); cy.get(LOADING_SPINNER).should('exist'); diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx index ef13c87a92dbb..14c42697dcbb4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx @@ -11,14 +11,14 @@ import styled from 'styled-components'; import { CommentRequest } from '../../../../../case/common/api'; import { usePostComment } from '../../containers/use_post_comment'; import { Case } from '../../containers/types'; -import { MarkdownEditorForm } from '../../../common/components/markdown_editor/form'; +import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form'; import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover'; import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline'; import { Form, useForm, UseField, useFormData } from '../../../shared_imports'; import * as i18n from './translations'; import { schema } from './schema'; -import { useTimelineClick } from '../utils/use_timeline_click'; +import { useTimelineClick } from '../../../common/utils/timeline/use_timeline_click'; const MySpinner = styled(EuiLoadingSpinner)` position: absolute; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx index e1d7d98ba8c51..246df1c94b817 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx @@ -114,34 +114,41 @@ describe('CaseView ', () => { expect(wrapper.find(`[data-test-subj="case-view-title"]`).first().prop('title')).toEqual( data.title ); + expect(wrapper.find(`[data-test-subj="case-view-status"]`).first().text()).toEqual( data.status ); + expect( wrapper - .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="case-tag-coke"]`) + .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-coke"]`) .first() .text() ).toEqual(data.tags[0]); + expect( wrapper - .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="case-tag-pepsi"]`) + .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-pepsi"]`) .first() .text() ).toEqual(data.tags[1]); + expect(wrapper.find(`[data-test-subj="case-view-username"]`).first().text()).toEqual( data.createdBy.username ); + expect(wrapper.contains(`[data-test-subj="case-view-closedAt"]`)).toBe(false); + expect(wrapper.find(`[data-test-subj="case-view-createdAt"]`).first().prop('value')).toEqual( data.createdAt ); + expect( wrapper .find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]`) .first() - .prop('raw') - ).toEqual(data.description); + .text() + ).toBe(data.description); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx index 3c3cc95218b03..a8babe729fde0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx @@ -31,10 +31,10 @@ import { schema } from './schema'; import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover'; import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline'; import * as i18n from '../../translations'; -import { MarkdownEditorForm } from '../../../common/components//markdown_editor/form'; +import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form'; import { useGetTags } from '../../containers/use_get_tags'; import { getCaseDetailsUrl } from '../../../common/components/link_to'; -import { useTimelineClick } from '../utils/use_timeline_click'; +import { useTimelineClick } from '../../../common/utils/timeline/use_timeline_click'; export const CommonUseField = getUseField({ component: Field }); diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx index 7c3fcde687033..a60167a18762f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx @@ -58,6 +58,7 @@ describe('TagList ', () => { fetchTags, })); }); + it('Renders no tags, and then edit', () => { const wrapper = mount( @@ -69,6 +70,7 @@ describe('TagList ', () => { expect(wrapper.find(`[data-test-subj="no-tags"]`).last().exists()).toBeFalsy(); expect(wrapper.find(`[data-test-subj="edit-tags"]`).last().exists()).toBeTruthy(); }); + it('Edit tag on submit', async () => { const wrapper = mount( @@ -81,6 +83,7 @@ describe('TagList ', () => { await waitFor(() => expect(onSubmit).toBeCalledWith(sampleTags)); }); }); + it('Tag options render with new tags added', () => { const wrapper = mount( @@ -92,6 +95,7 @@ describe('TagList ', () => { wrapper.find(`[data-test-subj="caseTags"] [data-test-subj="input"]`).first().prop('options') ).toEqual([{ label: 'coke' }, { label: 'pepsi' }, { label: 'rad' }, { label: 'dude' }]); }); + it('Cancels on cancel', async () => { const props = { ...defaultProps, @@ -102,17 +106,19 @@ describe('TagList ', () => { ); - expect(wrapper.find(`[data-test-subj="case-tag-pepsi"]`).last().exists()).toBeTruthy(); + + expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeTruthy(); wrapper.find(`[data-test-subj="tag-list-edit-button"]`).last().simulate('click'); await act(async () => { - expect(wrapper.find(`[data-test-subj="case-tag-pepsi"]`).last().exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeFalsy(); wrapper.find(`[data-test-subj="edit-tags-cancel"]`).last().simulate('click'); await waitFor(() => { wrapper.update(); - expect(wrapper.find(`[data-test-subj="case-tag-pepsi"]`).last().exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeTruthy(); }); }); }); + it('Renders disabled button', () => { const props = { ...defaultProps, disabled: true }; const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx index eeb7c49eceab5..4af781e3c31f4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx @@ -10,8 +10,6 @@ import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, - EuiBadgeGroup, - EuiBadge, EuiButton, EuiButtonEmpty, EuiButtonIcon, @@ -25,6 +23,8 @@ import { schema } from './schema'; import { CommonUseField } from '../create'; import { useGetTags } from '../../containers/use_get_tags'; +import { Tags } from './tags'; + interface TagListProps { disabled?: boolean; isLoading: boolean; @@ -99,15 +99,7 @@ export const TagList = React.memo( {tags.length === 0 && !isEditTags &&

{i18n.NO_TAGS}

} - - {tags.length > 0 && - !isEditTags && - tags.map((tag) => ( - - {tag} - - ))} - + {!isEditTags && } {isEditTags && ( diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx new file mode 100644 index 0000000000000..e257563ce751e --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiBadgeGroup, EuiBadge, EuiBadgeGroupProps } from '@elastic/eui'; + +interface TagsProps { + tags: string[]; + color?: string; + gutterSize?: EuiBadgeGroupProps['gutterSize']; +} + +const TagsComponent: React.FC = ({ tags, color = 'default', gutterSize }) => { + return ( + <> + {tags.length > 0 && ( + + {tags.map((tag) => ( + + {tag} + + ))} + + )} + + ); +}; + +export const Tags = memo(TagsComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx index b5be84db59920..4e5c05f2f1404 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx @@ -14,7 +14,7 @@ import { connectorsMock } from '../../containers/configure/mock'; describe('User action tree helpers', () => { const connectors = connectorsMock; it('label title generated for update tags', () => { - const action = getUserAction(['title'], 'update'); + const action = getUserAction(['tags'], 'update'); const result: string | JSX.Element = getLabelTitle({ action, connectors, @@ -27,8 +27,11 @@ describe('User action tree helpers', () => { ` ${i18n.TAGS.toLowerCase()}` ); - expect(wrapper.find(`[data-test-subj="ua-tag"]`).first().text()).toEqual(action.newValue); + expect(wrapper.find(`[data-test-subj="tag-${action.newValue}"]`).first().text()).toEqual( + action.newValue + ); }); + it('label title generated for update title', () => { const action = getUserAction(['title'], 'update'); const result: string | JSX.Element = getLabelTitle({ @@ -44,6 +47,7 @@ describe('User action tree helpers', () => { }"` ); }); + it('label title generated for update description', () => { const action = getUserAction(['description'], 'update'); const result: string | JSX.Element = getLabelTitle({ @@ -55,6 +59,7 @@ describe('User action tree helpers', () => { expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`); }); + it('label title generated for update status to open', () => { const action = { ...getUserAction(['status'], 'update'), newValue: 'open' }; const result: string | JSX.Element = getLabelTitle({ @@ -66,6 +71,7 @@ describe('User action tree helpers', () => { expect(result).toEqual(`${i18n.REOPENED_CASE.toLowerCase()} ${i18n.CASE}`); }); + it('label title generated for update status to closed', () => { const action = { ...getUserAction(['status'], 'update'), newValue: 'closed' }; const result: string | JSX.Element = getLabelTitle({ @@ -77,6 +83,7 @@ describe('User action tree helpers', () => { expect(result).toEqual(`${i18n.CLOSED_CASE.toLowerCase()} ${i18n.CASE}`); }); + it('label title generated for update comment', () => { const action = getUserAction(['comment'], 'update'); const result: string | JSX.Element = getLabelTitle({ @@ -88,6 +95,7 @@ describe('User action tree helpers', () => { expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`); }); + it('label title generated for pushed incident', () => { const action = getUserAction(['pushed'], 'push-to-service'); const result: string | JSX.Element = getLabelTitle({ @@ -105,6 +113,7 @@ describe('User action tree helpers', () => { JSON.parse(action.newValue).external_url ); }); + it('label title generated for needs update incident', () => { const action = getUserAction(['pushed'], 'push-to-service'); const result: string | JSX.Element = getLabelTitle({ @@ -122,6 +131,7 @@ describe('User action tree helpers', () => { JSON.parse(action.newValue).external_url ); }); + it('label title generated for update connector', () => { const action = getUserAction(['connector_id'], 'update'); const result: string | JSX.Element = getLabelTitle({ @@ -136,6 +146,8 @@ describe('User action tree helpers', () => { ` ${i18n.TAGS.toLowerCase()}` ); - expect(wrapper.find(`[data-test-subj="ua-tag"]`).first().text()).toEqual(action.newValue); + expect(wrapper.find(`[data-test-subj="tag-${action.newValue}"]`).first().text()).toEqual( + action.newValue + ); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx index e343c3da6cc8b..4d8bb9ba078e5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx @@ -4,12 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiBadgeGroup, EuiBadge, EuiLink } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; import React from 'react'; import { CaseFullExternalService, Connector } from '../../../../../case/common/api'; import { CaseUserActions } from '../../containers/types'; +import { CaseServices } from '../../containers/use_get_case_user_actions'; import * as i18n from '../case_view/translations'; +import { Tags } from '../tag_list/tags'; interface LabelTitle { action: CaseUserActions; @@ -44,22 +46,21 @@ export const getLabelTitle = ({ action, connectors, field, firstPush }: LabelTit return ''; }; -const getTagsLabelTitle = (action: CaseUserActions) => ( - - - {action.action === 'add' && i18n.ADDED_FIELD} - {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} - - - {action.newValue != null && - action.newValue.split(',').map((tag) => ( - - {tag} - - ))} - - -); +const getTagsLabelTitle = (action: CaseUserActions) => { + const tags = action.newValue != null ? action.newValue.split(',') : []; + + return ( + + + {action.action === 'add' && i18n.ADDED_FIELD} + {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} + + + + + + ); +}; const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: boolean) => { const pushedVal = JSON.parse(action.newValue ?? '') as CaseFullExternalService; @@ -78,3 +79,20 @@ const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: boolean) ); }; + +export const getPushInfo = ( + caseServices: CaseServices, + parsedValue: { connector_id: string; connector_name: string }, + index: number +) => + parsedValue != null + ? { + firstPush: caseServices[parsedValue.connector_id].firstPushIndex === index, + parsedConnectorId: parsedValue.connector_id, + parsedConnectorName: parsedValue.connector_name, + } + : { + firstPush: false, + parsedConnectorId: 'none', + parsedConnectorName: 'none', + }; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx index d67c364bbda10..d2bb2fb243458 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx @@ -6,6 +6,9 @@ import React from 'react'; import { mount } from 'enzyme'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; import { getFormMock, useFormMock, useFormDataMock } from '../__mock__/form'; @@ -13,9 +16,6 @@ import { useUpdateComment } from '../../containers/use_update_comment'; import { basicCase, basicPush, getUserAction } from '../../containers/mock'; import { UserActionTree } from '.'; import { TestProviders } from '../../../common/mock'; -// we don't have the types for waitFor just yet, so using "as waitFor" until when we do -import { wait as waitFor } from '@testing-library/react'; -import { act } from 'react-dom/test-utils'; const fetchUserActions = jest.fn(); const onUpdateField = jest.fn(); @@ -66,9 +66,10 @@ describe('UserActionTree ', () => { expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().prop('name')).toEqual( defaultProps.data.createdBy.fullName ); - expect(wrapper.find(`[data-test-subj="user-action-title"] strong`).first().text()).toEqual( - defaultProps.data.createdBy.username - ); + + expect( + wrapper.find(`[data-test-subj="description-action"] figcaption strong`).first().text() + ).toEqual(defaultProps.data.createdBy.username); }); it('Renders service now update line with top and bottom when push is required', async () => { @@ -76,6 +77,7 @@ describe('UserActionTree ', () => { getUserAction(['pushed'], 'push-to-service'), getUserAction(['comment'], 'update'), ]; + const props = { ...defaultProps, caseServices: { @@ -90,20 +92,18 @@ describe('UserActionTree ', () => { caseUserActions: ourActions, }; - const wrapper = mount( - - - - - - ); - await act(async () => { - wrapper.update(); + const wrapper = mount( + + + + + + ); + + expect(wrapper.find(`[data-test-subj="top-footer"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="bottom-footer"]`).exists()).toBeTruthy(); }); - - expect(wrapper.find(`[data-test-subj="show-top-footer"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="show-bottom-footer"]`).exists()).toBeTruthy(); }); it('Renders service now update line with top only when push is up to date', async () => { @@ -122,20 +122,17 @@ describe('UserActionTree ', () => { }, }; - const wrapper = mount( - - - - - - ); - await act(async () => { - wrapper.update(); + const wrapper = mount( + + + + + + ); + expect(wrapper.find(`[data-test-subj="top-footer"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="bottom-footer"]`).exists()).toBeFalsy(); }); - - expect(wrapper.find(`[data-test-subj="show-top-footer"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="show-bottom-footer"]`).exists()).toBeFalsy(); }); it('Outlines comment when update move to link is clicked', async () => { @@ -145,89 +142,104 @@ describe('UserActionTree ', () => { caseUserActions: ourActions, }; - const wrapper = mount( - - - - - - ); - await act(async () => { - wrapper.update(); - }); + const wrapper = mount( + + + + + + ); + + expect( + wrapper + .find(`[data-test-subj="comment-create-action-${props.data.comments[0].id}"]`) + .first() + .hasClass('outlined') + ).toBeFalsy(); - expect( - wrapper.find(`[data-test-subj="comment-create-action"]`).first().prop('idToOutline') - ).toEqual(''); - wrapper - .find(`[data-test-subj="comment-update-action"] [data-test-subj="move-to-link"]`) - .first() - .simulate('click'); - expect( - wrapper.find(`[data-test-subj="comment-create-action"]`).first().prop('idToOutline') - ).toEqual(ourActions[0].commentId); + wrapper + .find( + `[data-test-subj="comment-update-action-${ourActions[1].actionId}"] [data-test-subj="move-to-link-${props.data.comments[0].id}"]` + ) + .first() + .simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect( + wrapper + .find(`[data-test-subj="comment-create-action-${props.data.comments[0].id}"]`) + .first() + .hasClass('outlined') + ).toBeTruthy(); + }); + }); }); it('Switches to markdown when edit is clicked and back to panel when canceled', async () => { - const ourActions = [getUserAction(['comment'], 'create')]; - const props = { - ...defaultProps, - caseUserActions: ourActions, - }; - - const wrapper = mount( - - - - - - ); - - await act(async () => { - wrapper.update(); - }); + await waitFor(() => { + const ourActions = [getUserAction(['comment'], 'create')]; + const props = { + ...defaultProps, + caseUserActions: ourActions, + }; + + const wrapper = mount( + + + + + + ); + + expect( + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(false); - expect( wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-ellipses"]` ) - .exists() - ).toEqual(false); + .first() + .simulate('click'); - wrapper - .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-ellipses"]`) - .first() - .simulate('click'); - - wrapper - .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-pencil"]`) - .first() - .simulate('click'); + wrapper.update(); - expect( wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-pencil"]` ) - .exists() - ).toEqual(true); + .first() + .simulate('click'); - wrapper - .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-cancel-markdown"]` - ) - .first() - .simulate('click'); + expect( + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(true); - expect( wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-cancel-markdown"]` ) - .exists() - ).toEqual(false); + .first() + .simulate('click'); + + expect( + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(false); + }); }); it('calls update comment when comment markdown is saved', async () => { @@ -236,6 +248,7 @@ describe('UserActionTree ', () => { ...defaultProps, caseUserActions: ourActions, }; + const wrapper = mount( @@ -243,27 +256,35 @@ describe('UserActionTree ', () => { ); + wrapper - .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-ellipses"]`) + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-ellipses"]` + ) .first() .simulate('click'); + wrapper - .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-pencil"]`) + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-pencil"]` + ) .first() .simulate('click'); + wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-save-markdown"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-save-markdown"]` ) .first() .simulate('click'); + await act(async () => { await waitFor(() => { wrapper.update(); expect( wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` ) .exists() ).toEqual(false); @@ -288,93 +309,101 @@ describe('UserActionTree ', () => {
); + wrapper .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`) .first() .simulate('click'); + wrapper .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-pencil"]`) .first() .simulate('click'); - wrapper - .find( - `[data-test-subj="user-action-description"] [data-test-subj="user-action-save-markdown"]` - ) - .first() - .simulate('click'); + await act(async () => { - await waitFor(() => { - expect( - wrapper - .find( - `[data-test-subj="user-action-${props.data.id}"] [data-test-subj="user-action-markdown-form"]` - ) - .exists() - ).toEqual(false); - expect(onUpdateField).toBeCalledWith({ key: 'description', value: sampleData.content }); - }); + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="user-action-save-markdown"]`) + .first() + .simulate('click'); }); + + wrapper.update(); + + expect( + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown-form"]`) + .exists() + ).toEqual(false); + + expect(onUpdateField).toBeCalledWith({ key: 'description', value: sampleData.content }); }); it('quotes', async () => { - const commentData = { - comment: '', - }; - const formHookMock = getFormMock(commentData); - const setFieldValue = jest.fn(); - useFormMock.mockImplementation(() => ({ form: { ...formHookMock, setFieldValue } })); - const props = defaultProps; - const wrapper = mount( - - - - - - ); - await act(async () => { + const commentData = { + comment: '', + }; + const setFieldValue = jest.fn(); + + const formHookMock = getFormMock(commentData); + useFormMock.mockImplementation(() => ({ form: { ...formHookMock, setFieldValue } })); + + const props = defaultProps; + const wrapper = mount( + + + + + + ); + + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`) + .first() + .simulate('click'); + await waitFor(() => { - wrapper - .find( - `[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]` - ) - .first() - .simulate('click'); wrapper.update(); }); - }); - wrapper - .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-quote"]`) - .first() - .simulate('click'); + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-quote"]`) + .first() + .simulate('click'); - expect(setFieldValue).toBeCalledWith('comment', `> ${props.data.description} \n`); + expect(setFieldValue).toBeCalledWith('comment', `> ${props.data.description} \n`); + }); }); it('Outlines comment when url param is provided', async () => { - const commentId = 'neat-comment-id'; - const ourActions = [getUserAction(['comment'], 'create')]; - const props = { - ...defaultProps, - caseUserActions: ourActions, - }; - + const commentId = 'basic-comment-id'; jest.spyOn(routeData, 'useParams').mockReturnValue({ commentId }); - const wrapper = mount( - - - - - - ); await act(async () => { - wrapper.update(); - }); + const ourActions = [getUserAction(['comment'], 'create')]; + const props = { + ...defaultProps, + caseUserActions: ourActions, + }; + + const wrapper = mount( + + + + + + ); - expect( - wrapper.find(`[data-test-subj="comment-create-action"]`).first().prop('idToOutline') - ).toEqual(commentId); + await waitFor(() => { + wrapper.update(); + }); + + expect( + wrapper + .find(`[data-test-subj="comment-create-action-${commentId}"]`) + .first() + .hasClass('outlined') + ).toBeTruthy(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx index d1263ab13f41b..bada15294de09 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx @@ -3,25 +3,38 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import classNames from 'classnames'; -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiCommentList, + EuiCommentProps, +} from '@elastic/eui'; import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import styled from 'styled-components'; -import * as i18n from '../case_view/translations'; +import * as i18n from './translations'; import { Case, CaseUserActions } from '../../containers/types'; import { useUpdateComment } from '../../containers/use_update_comment'; import { useCurrentUser } from '../../../common/lib/kibana'; import { AddComment, AddCommentRefObject } from '../add_comment'; -import { getLabelTitle } from './helpers'; -import { UserActionItem } from './user_action_item'; -import { UserActionMarkdown } from './user_action_markdown'; import { Connector } from '../../../../../case/common/api/cases'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { parseString } from '../../containers/utils'; import { OnUpdateFields } from '../case_view'; +import { getLabelTitle, getPushInfo } from './helpers'; +import { UserActionAvatar } from './user_action_avatar'; +import { UserActionMarkdown } from './user_action_markdown'; +import { UserActionTimestamp } from './user_action_timestamp'; +import { UserActionCopyLink } from './user_action_copy_link'; +import { UserActionMoveToReference } from './user_action_move_to_reference'; +import { UserActionUsername } from './user_action_username'; +import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar'; +import { UserActionContentToolbar } from './user_action_content_toolbar'; export interface UserActionTreeProps { caseServices: CaseServices; @@ -40,6 +53,31 @@ const MyEuiFlexGroup = styled(EuiFlexGroup)` margin-bottom: 8px; `; +const MyEuiCommentList = styled(EuiCommentList)` + ${({ theme }) => ` + & .userAction__comment.outlined .euiCommentEvent { + outline: solid 5px ${theme.eui.euiColorVis1_behindText}; + margin: 0.5em; + transition: 0.8s; + } + + & .euiComment.isEdit { + & .euiCommentEvent { + border: none; + box-shadow: none; + } + + & .euiCommentEvent__body { + padding: 0; + } + + & .euiCommentEvent__header { + display: none; + } + } + `} +`; + const DESCRIPTION_ID = 'description'; const NEW_ID = 'newComment'; @@ -86,8 +124,7 @@ export const UserActionTree = React.memo( updateCase, }); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [caseData, handleManageMarkdownEditId, patchComment, updateCase] + [caseData.id, fetchUserActions, patchComment, updateCase] ); const handleOutlineComment = useCallback( @@ -172,117 +209,246 @@ export const UserActionTree = React.memo( } } }, [commentId, initLoading, isLoadingUserActions, isLoadingIds, handleOutlineComment]); - return ( - <> - {i18n.ADDED_DESCRIPTION}} - fullName={caseData.createdBy.fullName ?? caseData.createdBy.username ?? ''} - markdown={MarkdownDescription} - onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)} - onQuote={handleManageQuote.bind(null, caseData.description)} - username={caseData.createdBy.username ?? i18n.UNKNOWN} - /> - {caseUserActions.map((action, index) => { - if (action.commentId != null && action.action === 'create') { - const comment = caseData.comments.find((c) => c.id === action.commentId); - if (comment != null) { - return ( - {i18n.ADDED_COMMENT}} - fullName={comment.createdBy.fullName ?? comment.createdBy.username ?? ''} - markdown={ - - } - onEdit={handleManageMarkdownEditId.bind(null, comment.id)} - onQuote={handleManageQuote.bind(null, comment.comment)} - outlineComment={handleOutlineComment} - username={comment.createdBy.username ?? ''} - updatedAt={comment.updatedAt} - /> + const descriptionCommentListObj: EuiCommentProps = useMemo( + () => ({ + username: ( + + ), + event: i18n.ADDED_DESCRIPTION, + 'data-test-subj': 'description-action', + timestamp: , + children: MarkdownDescription, + timelineIcon: ( + + ), + className: classNames({ + isEdit: manageMarkdownEditIds.includes(DESCRIPTION_ID), + }), + actions: ( + + ), + }), + [ + MarkdownDescription, + caseData, + handleManageMarkdownEditId, + handleManageQuote, + isLoadingDescription, + userCanCrud, + manageMarkdownEditIds, + ] + ); + + const userActions: EuiCommentProps[] = useMemo( + () => + caseUserActions.reduce( + (comments, action, index) => { + if (action.commentId != null && action.action === 'create') { + const comment = caseData.comments.find((c) => c.id === action.commentId); + if (comment != null) { + return [ + ...comments, + { + username: ( + + ), + 'data-test-subj': `comment-create-action-${comment.id}`, + timestamp: ( + + ), + className: classNames('userAction__comment', { + outlined: comment.id === selectedOutlineCommentId, + isEdit: manageMarkdownEditIds.includes(comment.id), + }), + children: ( + + ), + timelineIcon: ( + + ), + actions: ( + + ), + }, + ]; + } + } + + if (action.actionField.length === 1) { + const myField = action.actionField[0]; + const parsedValue = parseString(`${action.newValue}`); + const { firstPush, parsedConnectorId, parsedConnectorName } = getPushInfo( + caseServices, + parsedValue, + index ); + + const labelTitle: string | JSX.Element = getLabelTitle({ + action, + field: myField, + firstPush, + connectors, + }); + + const showTopFooter = + action.action === 'push-to-service' && + index === caseServices[parsedConnectorId].lastPushIndex; + + const showBottomFooter = + action.action === 'push-to-service' && + index === caseServices[parsedConnectorId].lastPushIndex && + caseServices[parsedConnectorId].hasDataToPush; + + let footers: EuiCommentProps[] = []; + + if (showTopFooter) { + footers = [ + ...footers, + { + username: '', + type: 'update', + event: i18n.ALREADY_PUSHED_TO_SERVICE(`${parsedConnectorName}`), + timelineIcon: 'sortUp', + 'data-test-subj': 'top-footer', + }, + ]; + } + + if (showBottomFooter) { + footers = [ + ...footers, + { + username: '', + type: 'update', + event: i18n.REQUIRED_UPDATE_TO_SERVICE(`${parsedConnectorName}`), + timelineIcon: 'sortDown', + 'data-test-subj': 'bottom-footer', + }, + ]; + } + + return [ + ...comments, + { + username: ( + + ), + type: 'update', + event: labelTitle, + 'data-test-subj': `${action.actionField[0]}-${action.action}-action-${action.actionId}`, + timestamp: , + timelineIcon: + action.action === 'add' || action.action === 'delete' ? 'tag' : 'dot', + actions: ( + + + + + {action.action === 'update' && action.commentId != null && ( + + + + )} + + ), + }, + ...footers, + ]; } - } - if (action.actionField.length === 1) { - const myField = action.actionField[0]; - const parsedValue = parseString(`${action.newValue}`); - const { firstPush, parsedConnectorId, parsedConnectorName } = - parsedValue != null - ? { - firstPush: caseServices[parsedValue.connector_id].firstPushIndex === index, - parsedConnectorId: parsedValue.connector_id, - parsedConnectorName: parsedValue.connector_name, - } - : { - firstPush: false, - parsedConnectorId: 'none', - parsedConnectorName: 'none', - }; - const labelTitle: string | JSX.Element = getLabelTitle({ - action, - field: myField, - firstPush, - connectors, - }); - - return ( - {labelTitle}} - linkId={ - action.action === 'update' && action.commentId != null ? action.commentId : null - } - fullName={action.actionBy.fullName ?? action.actionBy.username ?? ''} - outlineComment={handleOutlineComment} - showTopFooter={ - action.action === 'push-to-service' && - index === caseServices[parsedConnectorId].lastPushIndex - } - showBottomFooter={ - action.action === 'push-to-service' && - index === caseServices[parsedConnectorId].lastPushIndex && - caseServices[parsedConnectorId].hasDataToPush - } - username={action.actionBy.username ?? ''} - /> - ); - } - return null; - })} + + return comments; + }, + [descriptionCommentListObj] + ), + [ + caseData, + caseServices, + caseUserActions, + connectors, + handleOutlineComment, + descriptionCommentListObj, + handleManageMarkdownEditId, + handleManageQuote, + handleSaveComment, + isLoadingIds, + manageMarkdownEditIds, + selectedOutlineCommentId, + userCanCrud, + ] + ); + + const bottomActions = [ + { + username: ( + + ), + 'data-test-subj': 'add-comment', + timelineIcon: ( + + ), + className: 'isEdit', + children: MarkdownNewComment, + }, + ]; + + const comments = [...userActions, ...bottomActions]; + + return ( + <> + {(isLoadingUserActions || isLoadingIds.includes(NEW_ID)) && ( @@ -290,17 +456,6 @@ export const UserActionTree = React.memo( )} - ); } diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx new file mode 100644 index 0000000000000..df5c51394b88a --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionAvatar } from './user_action_avatar'; + +const props = { + username: 'elastic', + fullName: 'Elastic', +}; + +describe('UserActionAvatar ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeTruthy(); + expect( + wrapper.find(`[data-test-subj="user-action-avatar-loading-spinner"]`).first().exists() + ).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().text()).toBe('E'); + }); + + it('it shows the username if the fullName is undefined', async () => { + wrapper = mount(); + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeTruthy(); + expect( + wrapper.find(`[data-test-subj="user-action-avatar-loading-spinner"]`).first().exists() + ).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().text()).toBe('e'); + }); + + it('shows the loading spinner when the username AND the fullName are undefined', async () => { + wrapper = mount(); + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeFalsy(); + expect( + wrapper.find(`[data-test-subj="user-action-avatar-loading-spinner"]`).first().exists() + ).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx index f3276bd50e72c..8339d9bedd123 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx @@ -4,15 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiAvatar } from '@elastic/eui'; -import React from 'react'; +import React, { memo } from 'react'; +import { EuiAvatar, EuiLoadingSpinner } from '@elastic/eui'; interface UserActionAvatarProps { - name: string; + username?: string | null; + fullName?: string | null; } -export const UserActionAvatar = ({ name }: UserActionAvatarProps) => { +const UserActionAvatarComponent = ({ username, fullName }: UserActionAvatarProps) => { + const avatarName = fullName && fullName.length > 0 ? fullName : username ?? null; + return ( - + <> + {avatarName ? ( + + ) : ( + + )} + ); }; + +export const UserActionAvatar = memo(UserActionAvatarComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.test.tsx new file mode 100644 index 0000000000000..1f4c858e9581e --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.test.tsx @@ -0,0 +1,55 @@ +/* + * 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 React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionContentToolbar } from './user_action_content_toolbar'; + +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + + return { + ...originalModule, + useParams: jest.fn().mockReturnValue({ detailName: 'case-1' }), + }; +}); + +jest.mock('../../../common/components/navigation/use_get_url_search'); + +jest.mock('../../../common/lib/kibana', () => { + return { + useKibana: () => ({ + services: { + application: { + getUrlForApp: jest.fn(), + }, + }, + }), + }; +}); + +const props = { + id: '1', + editLabel: 'edit', + quoteLabel: 'quote', + disabled: false, + isLoading: false, + onEdit: jest.fn(), + onQuote: jest.fn(), +}; + +describe('UserActionContentToolbar ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect(wrapper.find(`[data-test-subj="copy-link-${props.id}"]`).first().exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="property-actions"]').first().exists()).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.tsx new file mode 100644 index 0000000000000..89239c9e8392c --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { UserActionCopyLink } from './user_action_copy_link'; +import { UserActionPropertyActions } from './user_action_property_actions'; + +interface UserActionContentToolbarProps { + id: string; + editLabel: string; + quoteLabel: string; + disabled: boolean; + isLoading: boolean; + onEdit: (id: string) => void; + onQuote: (id: string) => void; +} + +const UserActionContentToolbarComponent = ({ + id, + editLabel, + quoteLabel, + disabled, + isLoading, + onEdit, + onQuote, +}: UserActionContentToolbarProps) => { + return ( + + + + + + + + + ); +}; + +export const UserActionContentToolbar = memo(UserActionContentToolbarComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.test.tsx new file mode 100644 index 0000000000000..0566281dac130 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.test.tsx @@ -0,0 +1,74 @@ +/* + * 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 React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { useParams } from 'react-router-dom'; +import copy from 'copy-to-clipboard'; + +import { TestProviders } from '../../../common/mock'; +import { UserActionCopyLink } from './user_action_copy_link'; +import { useGetUrlSearch } from '../../../common/components/navigation/use_get_url_search'; + +const searchURL = + '?timerange=(global:(linkTo:!(),timerange:(from:1585487656371,fromStr:now-24h,kind:relative,to:1585574056371,toStr:now)),timeline:(linkTo:!(),timerange:(from:1585227005527,kind:absolute,to:1585313405527)))'; + +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + + return { + ...originalModule, + useParams: jest.fn(), + }; +}); + +jest.mock('copy-to-clipboard', () => { + return jest.fn(); +}); + +jest.mock('../../../common/components/navigation/use_get_url_search'); + +const mockGetUrlForApp = jest.fn( + (appId: string, options?: { path?: string; absolute?: boolean }) => + `${appId}${options?.path ?? ''}` +); + +jest.mock('../../../common/lib/kibana', () => { + return { + useKibana: () => ({ + services: { + application: { + getUrlForApp: mockGetUrlForApp, + }, + }, + }), + }; +}); + +const props = { + id: 'comment-id', +}; + +describe('UserActionCopyLink ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + (useParams as jest.Mock).mockReturnValue({ detailName: 'case-1' }); + (useGetUrlSearch as jest.Mock).mockReturnValue(searchURL); + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + it('it renders', async () => { + expect(wrapper.find(`[data-test-subj="copy-link-${props.id}"]`).first().exists()).toBeTruthy(); + }); + + it('calls copy clipboard correctly', async () => { + wrapper.find(`[data-test-subj="copy-link-${props.id}"]`).first().simulate('click'); + expect(copy).toHaveBeenCalledWith( + 'securitySolution:case/case-1/comment-id?timerange=(global:(linkTo:!(),timerange:(from:1585487656371,fromStr:now-24h,kind:relative,to:1585574056371,toStr:now)),timeline:(linkTo:!(),timerange:(from:1585227005527,kind:absolute,to:1585313405527)))' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx new file mode 100644 index 0000000000000..98de2ab3288a8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useCallback } from 'react'; +import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; +import { useParams } from 'react-router-dom'; +import copy from 'copy-to-clipboard'; + +import { useFormatUrl, getCaseDetailsUrlWithCommentId } from '../../../common/components/link_to'; +import { SecurityPageName } from '../../../app/types'; +import * as i18n from './translations'; + +interface UserActionCopyLinkProps { + id: string; +} + +const UserActionCopyLinkComponent = ({ id }: UserActionCopyLinkProps) => { + const { detailName: caseId } = useParams<{ detailName: string }>(); + const { formatUrl } = useFormatUrl(SecurityPageName.case); + + const handleAnchorLink = useCallback(() => { + copy( + formatUrl(getCaseDetailsUrlWithCommentId({ id: caseId, commentId: id }), { absolute: true }) + ); + }, [caseId, formatUrl, id]); + + return ( + {i18n.COPY_REFERENCE_LINK}

}> + +
+ ); +}; + +export const UserActionCopyLink = memo(UserActionCopyLinkComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_item.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_item.tsx deleted file mode 100644 index eeb728aa7d1df..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_item.tsx +++ /dev/null @@ -1,197 +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 { - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, - EuiPanel, - EuiHorizontalRule, - EuiText, -} from '@elastic/eui'; -import React from 'react'; -import styled, { css } from 'styled-components'; - -import { UserActionAvatar } from './user_action_avatar'; -import { UserActionTitle } from './user_action_title'; -import * as i18n from './translations'; - -interface UserActionItemProps { - caseConnectorName?: string; - createdAt: string; - 'data-test-subj'?: string; - disabled: boolean; - id: string; - isEditable: boolean; - isLoading: boolean; - labelEditAction?: string; - labelQuoteAction?: string; - labelTitle?: JSX.Element; - linkId?: string | null; - fullName?: string | null; - markdown?: React.ReactNode; - onEdit?: (id: string) => void; - onQuote?: (id: string) => void; - username: string; - updatedAt?: string | null; - outlineComment?: (id: string) => void; - showBottomFooter?: boolean; - showTopFooter?: boolean; - idToOutline?: string | null; -} - -export const UserActionItemContainer = styled(EuiFlexGroup)` - ${({ theme }) => css` - & { - background-image: linear-gradient( - to right, - transparent 0, - transparent 15px, - ${theme.eui.euiBorderColor} 15px, - ${theme.eui.euiBorderColor} 17px, - transparent 17px, - transparent 100% - ); - background-repeat: no-repeat; - background-position: left ${theme.eui.euiSizeXXL}; - margin-bottom: ${theme.eui.euiSizeS}; - } - .userAction__panel { - margin-bottom: ${theme.eui.euiSize}; - } - .userAction__circle { - flex-shrink: 0; - margin-right: ${theme.eui.euiSize}; - vertical-align: top; - } - .userAction_loadingAvatar { - position: relative; - margin-right: ${theme.eui.euiSizeXL}; - top: ${theme.eui.euiSizeM}; - left: ${theme.eui.euiSizeS}; - } - .userAction__title { - padding: ${theme.eui.euiSizeS} ${theme.eui.euiSizeL}; - background: ${theme.eui.euiColorLightestShade}; - border-bottom: ${theme.eui.euiBorderThin}; - border-radius: ${theme.eui.euiBorderRadius} ${theme.eui.euiBorderRadius} 0 0; - } - .euiText--small * { - margin-bottom: 0; - } - `} -`; - -const MyEuiPanel = styled(EuiPanel)<{ showoutline: string }>` - flex-grow: 0; - ${({ theme, showoutline }) => - showoutline === 'true' - ? ` - outline: solid 5px ${theme.eui.euiColorVis1_behindText}; - margin: 0.5em; - transition: 0.8s; - ` - : ''} -`; - -const PushedContainer = styled(EuiFlexItem)` - ${({ theme }) => ` - margin-top: ${theme.eui.euiSizeS}; - margin-bottom: ${theme.eui.euiSizeXL}; - hr { - margin: 5px; - height: ${theme.eui.euiBorderWidthThick}; - } - `} -`; - -const PushedInfoContainer = styled.div` - margin-left: 48px; -`; - -export const UserActionItem = ({ - caseConnectorName, - createdAt, - disabled, - 'data-test-subj': dataTestSubj, - id, - idToOutline, - isEditable, - isLoading, - labelEditAction, - labelQuoteAction, - labelTitle, - linkId, - fullName, - markdown, - onEdit, - onQuote, - outlineComment, - showBottomFooter, - showTopFooter, - username, - updatedAt, -}: UserActionItemProps) => ( - - - - - {(fullName && fullName.length > 0) || (username && username.length > 0) ? ( - 0 ? fullName : username ?? ''} /> - ) : ( - - )} - - - {isEditable && markdown} - {!isEditable && ( - - } - linkId={linkId} - onEdit={onEdit} - onQuote={onQuote} - outlineComment={outlineComment} - updatedAt={updatedAt} - username={username} - /> - {markdown} - - )} - - - - {showTopFooter && ( - - - - {i18n.ALREADY_PUSHED_TO_SERVICE(`${caseConnectorName}`)} - - - - {showBottomFooter && ( - - - {i18n.REQUIRED_UPDATE_TO_SERVICE(`${caseConnectorName}`)} - - - )} - - )} - -); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx index 6cf827ea55f1f..f1f7d40009045 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx @@ -17,8 +17,9 @@ const onChangeEditable = jest.fn(); const onSaveContent = jest.fn(); const timelineId = '1e10f150-949b-11ea-b63c-2bc51864784c'; +const timelineMarkdown = `[timeline](http://localhost:5601/app/security/timelines?timeline=(id:'${timelineId}',isOpen:!t))`; const defaultProps = { - content: `A link to a timeline [timeline](http://localhost:5601/app/security/timelines?timeline=(id:'${timelineId}',isOpen:!t))`, + content: `A link to a timeline ${timelineMarkdown}`, id: 'markdown-id', isEditable: false, onChangeEditable, @@ -40,7 +41,11 @@ describe('UserActionMarkdown ', () => { ); - wrapper.find(`[data-test-subj="markdown-timeline-link"]`).first().simulate('click'); + + wrapper + .find(`[data-test-subj="markdown-timeline-link-${timelineId}"]`) + .first() + .simulate('click'); expect(queryTimelineByIdSpy).toBeCalledWith({ apolloClient: mockUseApolloClient(), @@ -59,8 +64,19 @@ describe('UserActionMarkdown ', () => { ); - wrapper.find(`[data-test-subj="preview-tab"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="markdown-timeline-link"]`).first().simulate('click'); + + // Preview button of Markdown editor + wrapper + .find( + `[data-test-subj="user-action-markdown-form"] .euiMarkdownEditorToolbar .euiButtonEmpty` + ) + .first() + .simulate('click'); + + wrapper + .find(`[data-test-subj="markdown-timeline-link-${timelineId}"]`) + .first() + .simulate('click'); expect(queryTimelineByIdSpy).toBeCalledWith({ apolloClient: mockUseApolloClient(), graphEventId: '', diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx index ac2ad179ec60c..45e46b2d7d2db 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx @@ -4,18 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, + EuiMarkdownFormat, +} from '@elastic/eui'; import React, { useCallback } from 'react'; import styled from 'styled-components'; import * as i18n from '../case_view/translations'; -import { Markdown } from '../../../common/components/markdown'; -import { Form, useForm, UseField, useFormData } from '../../../shared_imports'; +import { Form, useForm, UseField } from '../../../shared_imports'; import { schema, Content } from './schema'; -import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover'; -import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline'; -import { MarkdownEditorForm } from '../../../common/components//markdown_editor/form'; -import { useTimelineClick } from '../utils/use_timeline_click'; +import { + MarkdownEditorForm, + parsingPlugins, + processingPlugins, +} from '../../../common/components/markdown_editor/eui_form'; const ContentWrapper = styled.div` padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`}; @@ -43,24 +49,12 @@ export const UserActionMarkdown = ({ }); const fieldName = 'content'; - const { submit, setFieldValue } = form; - const [{ content: contentFormValue }] = useFormData({ form, watch: [fieldName] }); - - const onContentChange = useCallback((newValue) => setFieldValue(fieldName, newValue), [ - setFieldValue, - ]); - - const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( - contentFormValue, - onContentChange - ); + const { submit } = form; const handleCancelAction = useCallback(() => { onChangeEditable(id); }, [id, onChangeEditable]); - const handleTimelineClick = useTimelineClick(); - const handleSaveAction = useCallback(async () => { const { isValid, data } = await submit(); if (isValid) { @@ -105,29 +99,24 @@ export const UserActionMarkdown = ({ path={fieldName} component={MarkdownEditorForm} componentProps={{ + 'aria-label': 'Cases markdown editor', + value: content, + id, bottomRightContent: renderButtons({ cancelAction: handleCancelAction, saveAction: handleSaveAction, }), - onClickTimeline: handleTimelineClick, - onCursorPositionUpdate: handleCursorChange, - topRightContent: ( - - ), }} /> ) : ( - - + + + {content} + ); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx new file mode 100644 index 0000000000000..5bb0f50ce25e5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionMoveToReference } from './user_action_move_to_reference'; + +const outlineComment = jest.fn(); +const props = { + id: 'move-to-ref-id', + outlineComment, +}; + +describe('UserActionMoveToReference ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect( + wrapper.find(`[data-test-subj="move-to-link-${props.id}"]`).first().exists() + ).toBeTruthy(); + }); + + it('calls outlineComment correctly', async () => { + wrapper.find(`[data-test-subj="move-to-link-${props.id}"]`).first().simulate('click'); + expect(outlineComment).toHaveBeenCalledWith(props.id); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx new file mode 100644 index 0000000000000..39d016dd69520 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx @@ -0,0 +1,37 @@ +/* + * 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 React, { memo, useCallback } from 'react'; +import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; + +import * as i18n from './translations'; + +interface UserActionMoveToReferenceProps { + id: string; + outlineComment: (id: string) => void; +} + +const UserActionMoveToReferenceComponent = ({ + id, + outlineComment, +}: UserActionMoveToReferenceProps) => { + const handleMoveToLink = useCallback(() => { + outlineComment(id); + }, [id, outlineComment]); + + return ( + {i18n.MOVE_TO_ORIGINAL_COMMENT}

}> + +
+ ); +}; + +export const UserActionMoveToReference = memo(UserActionMoveToReferenceComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx new file mode 100644 index 0000000000000..bd5da8aca7d4f --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionPropertyActions } from './user_action_property_actions'; + +const props = { + id: 'property-actions-id', + editLabel: 'edit', + quoteLabel: 'quote', + disabled: false, + isLoading: false, + onEdit: jest.fn(), + onQuote: jest.fn(), +}; + +describe('UserActionPropertyActions ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect( + wrapper.find('[data-test-subj="user-action-title-loading"]').first().exists() + ).toBeFalsy(); + + expect(wrapper.find('[data-test-subj="property-actions"]').first().exists()).toBeTruthy(); + }); + + it('it shows the edit and quote buttons', async () => { + wrapper.find('[data-test-subj="property-actions-ellipses"]').first().simulate('click'); + wrapper.find('[data-test-subj="property-actions-pencil"]').exists(); + wrapper.find('[data-test-subj="property-actions-quote"]').exists(); + }); + + it('it shows the spinner when loading', async () => { + wrapper = mount(); + expect( + wrapper.find('[data-test-subj="user-action-title-loading"]').first().exists() + ).toBeTruthy(); + + expect(wrapper.find('[data-test-subj="property-actions"]').first().exists()).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx new file mode 100644 index 0000000000000..454880e93a27f --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useMemo, useCallback } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; + +import { PropertyActions } from '../property_actions'; + +interface UserActionPropertyActionsProps { + id: string; + editLabel: string; + quoteLabel: string; + disabled: boolean; + isLoading: boolean; + onEdit: (id: string) => void; + onQuote: (id: string) => void; +} + +const UserActionPropertyActionsComponent = ({ + id, + editLabel, + quoteLabel, + disabled, + isLoading, + onEdit, + onQuote, +}: UserActionPropertyActionsProps) => { + const onEditClick = useCallback(() => onEdit(id), [id, onEdit]); + const onQuoteClick = useCallback(() => onQuote(id), [id, onQuote]); + + const propertyActions = useMemo(() => { + return [ + { + disabled, + iconType: 'pencil', + label: editLabel, + onClick: onEditClick, + }, + { + disabled, + iconType: 'quote', + label: quoteLabel, + onClick: onQuoteClick, + }, + ]; + }, [disabled, editLabel, quoteLabel, onEditClick, onQuoteClick]); + return ( + <> + {isLoading && } + {!isLoading && } + + ); +}; + +export const UserActionPropertyActions = memo(UserActionPropertyActionsComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx new file mode 100644 index 0000000000000..a65806520c854 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx @@ -0,0 +1,74 @@ +/* + * 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 React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { TestProviders } from '../../../common/mock'; +import { UserActionTimestamp } from './user_action_timestamp'; + +jest.mock('@kbn/i18n/react', () => { + const originalModule = jest.requireActual('@kbn/i18n/react'); + const FormattedRelative = jest.fn(); + FormattedRelative.mockImplementationOnce(() => '2 days ago'); + FormattedRelative.mockImplementation(() => '20 hours ago'); + + return { + ...originalModule, + FormattedRelative, + }; +}); + +const props = { + createdAt: '2020-09-06T14:40:59.889Z', + updatedAt: '2020-09-07T14:40:59.889Z', +}; + +describe('UserActionTimestamp ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + it('it renders', async () => { + expect( + wrapper.find('[data-test-subj="user-action-title-creation-relative-time"]').first().exists() + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="user-action-title-edited-relative-time"]').first().exists() + ).toBeTruthy(); + }); + + it('it shows only the created time when the updated time is missing', async () => { + const newWrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect( + newWrapper + .find('[data-test-subj="user-action-title-creation-relative-time"]') + .first() + .exists() + ).toBeTruthy(); + expect( + newWrapper.find('[data-test-subj="user-action-title-edited-relative-time"]').first().exists() + ).toBeFalsy(); + }); + + it('it shows the timestamp correctly', async () => { + const createdText = wrapper + .find('[data-test-subj="user-action-title-creation-relative-time"]') + .first() + .text(); + + const updatedText = wrapper + .find('[data-test-subj="user-action-title-edited-relative-time"]') + .first() + .text(); + + expect(`${createdText} (${updatedText})`).toBe('2 days ago (20 hours ago)'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx new file mode 100644 index 0000000000000..72dc5de9cdb3b --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiTextColor } from '@elastic/eui'; +import { FormattedRelative } from '@kbn/i18n/react'; + +import { LocalizedDateTooltip } from '../../../common/components/localized_date_tooltip'; +import * as i18n from './translations'; + +interface UserActionAvatarProps { + createdAt: string; + updatedAt?: string | null; +} + +const UserActionTimestampComponent = ({ createdAt, updatedAt }: UserActionAvatarProps) => { + return ( + <> + + + + {updatedAt && ( + + {/* be careful of the extra space at the beginning of the parenthesis */} + {' ('} + {i18n.EDITED_FIELD}{' '} + + + + {')'} + + )} + + ); +}; + +export const UserActionTimestamp = memo(UserActionTimestampComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.test.tsx deleted file mode 100644 index 0bb02ce69a544..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.test.tsx +++ /dev/null @@ -1,54 +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 React from 'react'; -import { mount } from 'enzyme'; -import copy from 'copy-to-clipboard'; -import { Router, routeData, mockHistory } from '../__mock__/router'; -import { caseUserActions as basicUserActions } from '../../containers/mock'; -import { UserActionTitle } from './user_action_title'; -import { TestProviders } from '../../../common/mock'; - -const outlineComment = jest.fn(); -const onEdit = jest.fn(); -const onQuote = jest.fn(); - -jest.mock('copy-to-clipboard'); -const defaultProps = { - createdAt: basicUserActions[0].actionAt, - disabled: false, - fullName: basicUserActions[0].actionBy.fullName, - id: basicUserActions[0].actionId, - isLoading: false, - labelEditAction: 'labelEditAction', - labelQuoteAction: 'labelQuoteAction', - labelTitle: <>{'cool'}, - linkId: basicUserActions[0].commentId, - onEdit, - onQuote, - outlineComment, - updatedAt: basicUserActions[0].actionAt, - username: basicUserActions[0].actionBy.username, -}; - -describe('UserActionTitle ', () => { - beforeEach(() => { - jest.resetAllMocks(); - jest.spyOn(routeData, 'useParams').mockReturnValue({ commentId: '123' }); - }); - - it('Calls copy when copy link is clicked', async () => { - const wrapper = mount( - - - - - - ); - wrapper.find(`[data-test-subj="copy-link"]`).first().simulate('click'); - expect(copy).toBeCalledTimes(1); - }); -}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.tsx deleted file mode 100644 index 9477299e563a8..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.tsx +++ /dev/null @@ -1,183 +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 { - EuiLoadingSpinner, - EuiFlexGroup, - EuiFlexItem, - EuiText, - EuiButtonIcon, - EuiToolTip, -} from '@elastic/eui'; -import { FormattedRelative } from '@kbn/i18n/react'; -import copy from 'copy-to-clipboard'; -import { isEmpty } from 'lodash/fp'; -import React, { useMemo, useCallback } from 'react'; -import styled from 'styled-components'; -import { useParams } from 'react-router-dom'; - -import { LocalizedDateTooltip } from '../../../common/components/localized_date_tooltip'; -import { useGetUrlSearch } from '../../../common/components/navigation/use_get_url_search'; -import { navTabs } from '../../../app/home/home_navigations'; -import { PropertyActions } from '../property_actions'; -import { SecurityPageName } from '../../../app/types'; -import * as i18n from './translations'; - -const MySpinner = styled(EuiLoadingSpinner)` - .euiLoadingSpinner { - margin-top: 1px; // yes it matters! - } -`; - -interface UserActionTitleProps { - createdAt: string; - disabled: boolean; - id: string; - isLoading: boolean; - labelEditAction?: string; - labelQuoteAction?: string; - labelTitle: JSX.Element; - linkId?: string | null; - fullName?: string | null; - updatedAt?: string | null; - username?: string | null; - onEdit?: (id: string) => void; - onQuote?: (id: string) => void; - outlineComment?: (id: string) => void; -} - -export const UserActionTitle = ({ - createdAt, - disabled, - fullName, - id, - isLoading, - labelEditAction, - labelQuoteAction, - labelTitle, - linkId, - onEdit, - onQuote, - outlineComment, - updatedAt, - username = i18n.UNKNOWN, -}: UserActionTitleProps) => { - const { detailName: caseId } = useParams<{ detailName: string }>(); - const urlSearch = useGetUrlSearch(navTabs.case); - const propertyActions = useMemo(() => { - return [ - ...(labelEditAction != null && onEdit != null - ? [ - { - disabled, - iconType: 'pencil', - label: labelEditAction, - onClick: () => onEdit(id), - }, - ] - : []), - ...(labelQuoteAction != null && onQuote != null - ? [ - { - disabled, - iconType: 'quote', - label: labelQuoteAction, - onClick: () => onQuote(id), - }, - ] - : []), - ]; - }, [disabled, id, labelEditAction, onEdit, labelQuoteAction, onQuote]); - - const handleAnchorLink = useCallback(() => { - copy( - `${window.location.origin}${window.location.pathname}#${SecurityPageName.case}/${caseId}/${id}${urlSearch}` - ); - }, [caseId, id, urlSearch]); - - const handleMoveToLink = useCallback(() => { - if (outlineComment != null && linkId != null) { - outlineComment(linkId); - } - }, [linkId, outlineComment]); - return ( - - - - - - {fullName ?? username}

}> - {username} -
-
- {labelTitle} - - - - - - {updatedAt != null && ( - - - {'('} - {i18n.EDITED_FIELD}{' '} - - - - {')'} - - - )} -
-
- - - {!isEmpty(linkId) && ( - - {i18n.MOVE_TO_ORIGINAL_COMMENT}

}> - -
-
- )} - - {i18n.COPY_REFERENCE_LINK}

}> - -
-
- {propertyActions.length > 0 && ( - - {isLoading && } - {!isLoading && } - - )} -
-
-
-
- ); -}; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx new file mode 100644 index 0000000000000..008eb18aef074 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionUsername } from './user_action_username'; + +const props = { + username: 'elastic', + fullName: 'Elastic', +}; + +describe('UserActionUsername ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect( + wrapper.find('[data-test-subj="user-action-username-tooltip"]').first().exists() + ).toBeTruthy(); + }); + + it('it shows the username', async () => { + expect(wrapper.find('[data-test-subj="user-action-username-tooltip"]').text()).toBe('elastic'); + }); + + test('it shows the fullname when hovering the username', () => { + // Use fake timers so we don't have to wait for the EuiToolTip timeout + jest.useFakeTimers(); + + wrapper.find('[data-test-subj="user-action-username-tooltip"]').first().simulate('mouseOver'); + + // Run the timers so the EuiTooltip will be visible + jest.runAllTimers(); + + wrapper.update(); + expect(wrapper.find('.euiToolTipPopover').text()).toBe('Elastic'); + + // Clearing all mocks will also reset fake timers. + jest.clearAllMocks(); + }); + + test('it shows the username when hovering the username and the fullname is missing', () => { + // Use fake timers so we don't have to wait for the EuiToolTip timeout + jest.useFakeTimers(); + + const newWrapper = mount(); + newWrapper + .find('[data-test-subj="user-action-username-tooltip"]') + .first() + .simulate('mouseOver'); + + // Run the timers so the EuiTooltip will be visible + jest.runAllTimers(); + + newWrapper.update(); + expect(newWrapper.find('.euiToolTipPopover').text()).toBe('elastic'); + + // Clearing all mocks will also reset fake timers. + jest.clearAllMocks(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx new file mode 100644 index 0000000000000..dbc153ddbe577 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx @@ -0,0 +1,28 @@ +/* + * 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 React, { memo } from 'react'; +import { EuiToolTip } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; + +interface UserActionUsernameProps { + username: string; + fullName?: string; +} + +const UserActionUsernameComponent = ({ username, fullName }: UserActionUsernameProps) => { + return ( + {isEmpty(fullName) ? username : fullName}

} + data-test-subj="user-action-username-tooltip" + > + {username} +
+ ); +}; + +export const UserActionUsername = memo(UserActionUsernameComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx new file mode 100644 index 0000000000000..f8403738c24ea --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar'; + +const props = { + username: 'elastic', + fullName: 'Elastic', +}; + +describe('UserActionUsernameWithAvatar ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect( + wrapper.find('[data-test-subj="user-action-username-with-avatar"]').first().exists() + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="user-action-username-avatar"]').first().exists() + ).toBeTruthy(); + }); + + it('it shows the avatar', async () => { + expect(wrapper.find('[data-test-subj="user-action-username-avatar"]').first().text()).toBe('E'); + }); + + it('it shows the avatar without fullName', async () => { + const newWrapper = mount(); + expect(newWrapper.find('[data-test-subj="user-action-username-avatar"]').first().text()).toBe( + 'e' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx new file mode 100644 index 0000000000000..e2326a3580e6f --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiAvatar } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; + +import { UserActionUsername } from './user_action_username'; + +interface UserActionUsernameWithAvatarProps { + username: string; + fullName?: string; +} + +const UserActionUsernameWithAvatarComponent = ({ + username, + fullName, +}: UserActionUsernameWithAvatarProps) => { + return ( + + + + + + + + + ); +}; + +export const UserActionUsernameWithAvatar = memo(UserActionUsernameWithAvatarComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts index 403c8d838fa44..89fcc67bcd15f 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts @@ -16,25 +16,40 @@ export { getDetectionEngineUrl } from './redirect_to_detection_engine'; export { getAppOverviewUrl } from './redirect_to_overview'; export { getHostDetailsUrl, getHostsUrl } from './redirect_to_hosts'; export { getNetworkUrl, getNetworkDetailsUrl } from './redirect_to_network'; -export { getTimelinesUrl, getTimelineTabsUrl } from './redirect_to_timelines'; +export { getTimelinesUrl, getTimelineTabsUrl, getTimelineUrl } from './redirect_to_timelines'; export { getCaseDetailsUrl, getCaseUrl, getCreateCaseUrl, getConfigureCasesUrl, + getCaseDetailsUrlWithCommentId, } from './redirect_to_case'; +interface FormatUrlOptions { + absolute: boolean; + skipSearch: boolean; +} + +type FormatUrl = (path: string, options?: Partial) => string; + export const useFormatUrl = (page: SecurityPageName) => { const { getUrlForApp } = useKibana().services.application; const search = useGetUrlSearch(navTabs[page]); - const formatUrl = useCallback( - (path: string) => { + const formatUrl = useCallback( + (path: string, { absolute = false, skipSearch = false } = {}) => { const pathArr = path.split('?'); const formattedPath = `${pathArr[0]}${ - isEmpty(pathArr[1]) ? search : `${pathArr[1]}${isEmpty(search) ? '' : `&${search}`}` + !skipSearch + ? isEmpty(pathArr[1]) + ? search + : `?${pathArr[1]}${isEmpty(search) ? '' : `&${search}`}` + : isEmpty(pathArr[1]) + ? '' + : `?${pathArr[1]}` }`; return getUrlForApp(`${APP_ID}:${page}`, { path: formattedPath, + absolute, }); }, [getUrlForApp, page, search] diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx index 7005460999fc7..3ef00635844f6 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx +++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx @@ -11,6 +11,17 @@ export const getCaseUrl = (search: string | null) => `${appendSearch(search ?? u export const getCaseDetailsUrl = ({ id, search }: { id: string; search?: string | null }) => `/${encodeURIComponent(id)}${appendSearch(search ?? undefined)}`; +export const getCaseDetailsUrlWithCommentId = ({ + id, + commentId, + search, +}: { + id: string; + commentId: string; + search?: string | null; +}) => + `/${encodeURIComponent(id)}/${encodeURIComponent(commentId)}${appendSearch(search ?? undefined)}`; + export const getCreateCaseUrl = (search?: string | null) => `/create${appendSearch(search ?? undefined)}`; diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx index 75a2fa1efa414..58b9f940ceaa6 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx +++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty } from 'lodash/fp'; import { TimelineTypeLiteral } from '../../../../common/types/timeline'; import { appendSearch } from './helpers'; @@ -11,3 +12,8 @@ export const getTimelinesUrl = (search?: string) => `${appendSearch(search)}`; export const getTimelineTabsUrl = (tabName: TimelineTypeLiteral, search?: string) => `/${tabName}${appendSearch(search)}`; + +export const getTimelineUrl = (id: string, graphEventId?: string) => + `?timeline=(id:'${id}',isOpen:!t${ + isEmpty(graphEventId) ? ')' : `,graphEventId:'${graphEventId}')` + }`; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx new file mode 100644 index 0000000000000..481ed7892a8be --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useCallback } from 'react'; +import styled from 'styled-components'; +import { + EuiMarkdownEditor, + EuiMarkdownEditorProps, + EuiFormRow, + EuiFlexItem, + EuiFlexGroup, + getDefaultEuiMarkdownParsingPlugins, + getDefaultEuiMarkdownProcessingPlugins, +} from '@elastic/eui'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports'; + +import * as timelineMarkdownPlugin from './plugins/timeline'; + +type MarkdownEditorFormProps = EuiMarkdownEditorProps & { + id: string; + field: FieldHook; + dataTestSubj: string; + idAria: string; + isDisabled?: boolean; + bottomRightContent?: React.ReactNode; +}; + +const BottomContentWrapper = styled(EuiFlexGroup)` + ${({ theme }) => ` + padding: ${theme.eui.ruleMargins.marginSmall} 0; + `} +`; + +export const parsingPlugins = getDefaultEuiMarkdownParsingPlugins(); +parsingPlugins.push(timelineMarkdownPlugin.parser); + +export const processingPlugins = getDefaultEuiMarkdownProcessingPlugins(); +processingPlugins[1][1].components.timeline = timelineMarkdownPlugin.renderer; + +export const MarkdownEditorForm: React.FC = ({ + id, + field, + dataTestSubj, + idAria, + bottomRightContent, +}) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]); + const onParse = useCallback((err, { messages }) => { + setMarkdownErrorMessages(err ? [err] : messages); + }, []); + + return ( + + <> + + {bottomRightContent && ( + + {bottomRightContent} + + )} + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx deleted file mode 100644 index 2cc3fe05a2215..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx +++ /dev/null @@ -1,67 +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 { EuiFormRow } from '@elastic/eui'; -import React, { useCallback } from 'react'; - -import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports'; -import { CursorPosition, MarkdownEditor } from '.'; - -interface IMarkdownEditorForm { - bottomRightContent?: React.ReactNode; - dataTestSubj: string; - field: FieldHook; - idAria: string; - isDisabled: boolean; - onClickTimeline?: (timelineId: string, graphEventId?: string) => void; - onCursorPositionUpdate?: (cursorPosition: CursorPosition) => void; - placeholder?: string; - topRightContent?: React.ReactNode; -} -export const MarkdownEditorForm = ({ - bottomRightContent, - dataTestSubj, - field, - idAria, - isDisabled = false, - onClickTimeline, - onCursorPositionUpdate, - placeholder, - topRightContent, -}: IMarkdownEditorForm) => { - const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - - const handleContentChange = useCallback( - (newContent: string) => { - field.setValue(newContent); - }, - [field] - ); - - return ( - - - - ); -}; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.test.tsx deleted file mode 100644 index b5e5b01189418..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.test.tsx +++ /dev/null @@ -1,49 +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 { mount } from 'enzyme'; -import React from 'react'; - -import { MarkdownEditor } from '.'; -import { TestProviders } from '../../mock'; - -describe('Markdown Editor', () => { - const onChange = jest.fn(); - const onCursorPositionUpdate = jest.fn(); - const defaultProps = { - content: 'hello world', - onChange, - onCursorPositionUpdate, - }; - beforeEach(() => { - jest.clearAllMocks(); - }); - test('it calls onChange with correct value', () => { - const wrapper = mount( - - - - ); - const newValue = 'a new string'; - wrapper - .find(`[data-test-subj="textAreaInput"]`) - .first() - .simulate('change', { target: { value: newValue } }); - expect(onChange).toBeCalledWith(newValue); - }); - test('it calls onCursorPositionUpdate with correct args', () => { - const wrapper = mount( - - - - ); - wrapper.find(`[data-test-subj="textAreaInput"]`).first().simulate('blur'); - expect(onCursorPositionUpdate).toBeCalledWith({ - start: 0, - end: 0, - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx index d4ad4a11b60a3..9f4141dbcae7d 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx @@ -4,167 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiLink, - EuiPanel, - EuiTabbedContent, - EuiTextArea, -} from '@elastic/eui'; -import React, { useMemo, useCallback, ChangeEvent } from 'react'; -import styled, { css } from 'styled-components'; - -import { Markdown } from '../markdown'; -import * as i18n from './translations'; -import { MARKDOWN_HELP_LINK } from './constants'; - -const TextArea = styled(EuiTextArea)` - width: 100%; -`; - -const Container = styled(EuiPanel)` - ${({ theme }) => css` - padding: 0; - background: ${theme.eui.euiColorLightestShade}; - position: relative; - .markdown-tabs-header { - position: absolute; - top: ${theme.eui.euiSizeS}; - right: ${theme.eui.euiSizeS}; - z-index: ${theme.eui.euiZContentMenu}; - } - .euiTab { - padding: 10px; - } - .markdown-tabs { - width: 100%; - } - .markdown-tabs-footer { - height: 41px; - padding: 0 ${theme.eui.euiSizeM}; - .euiLink { - font-size: ${theme.eui.euiSizeM}; - } - } - .euiFormRow__labelWrapper { - position: absolute; - top: -${theme.eui.euiSizeL}; - } - .euiFormErrorText { - padding: 0 ${theme.eui.euiSizeM}; - } - `} -`; - -const MarkdownContainer = styled(EuiPanel)` - min-height: 150px; - overflow: auto; -`; - -export interface CursorPosition { - start: number; - end: number; -} - -/** An input for entering a new case description */ -export const MarkdownEditor = React.memo<{ - bottomRightContent?: React.ReactNode; - topRightContent?: React.ReactNode; - content: string; - isDisabled?: boolean; - onChange: (description: string) => void; - onClickTimeline?: (timelineId: string, graphEventId?: string) => void; - onCursorPositionUpdate?: (cursorPosition: CursorPosition) => void; - placeholder?: string; -}>( - ({ - bottomRightContent, - topRightContent, - content, - isDisabled = false, - onChange, - onClickTimeline, - placeholder, - onCursorPositionUpdate, - }) => { - const handleOnChange = useCallback( - (evt: ChangeEvent) => { - onChange(evt.target.value); - }, - [onChange] - ); - - const setCursorPosition = useCallback( - (e: React.ChangeEvent) => { - if (onCursorPositionUpdate) { - onCursorPositionUpdate({ - start: e!.target!.selectionStart ?? 0, - end: e!.target!.selectionEnd ?? 0, - }); - } - }, - [onCursorPositionUpdate] - ); - - const tabs = useMemo( - () => [ - { - id: 'comment', - name: i18n.MARKDOWN, - content: ( -