diff --git a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap index 9706895b164a6..00be0b37a0e82 100644 --- a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap @@ -34,6 +34,7 @@ exports[`Home component should render services 1`] = ` }, "http": Object { "basePath": Object { + "get": [Function], "prepend": [Function], }, }, @@ -51,7 +52,18 @@ exports[`Home component should render services 1`] = ` "get$": [Function], }, }, - "plugins": Object {}, + "plugins": Object { + "ml": Object { + "urlGenerator": MlUrlGenerator { + "createUrl": [Function], + "id": "ML_APP_URL_GENERATOR", + "params": Object { + "appBasePath": "/app/ml", + "useHash": false, + }, + }, + }, + }, } } > @@ -95,6 +107,7 @@ exports[`Home component should render traces 1`] = ` }, "http": Object { "basePath": Object { + "get": [Function], "prepend": [Function], }, }, @@ -112,7 +125,18 @@ exports[`Home component should render traces 1`] = ` "get$": [Function], }, }, - "plugins": Object {}, + "plugins": Object { + "ml": Object { + "urlGenerator": MlUrlGenerator { + "createUrl": [Function], + "id": "ML_APP_URL_GENERATOR", + "params": Object { + "appBasePath": "/app/ml", + "useHash": false, + }, + }, + }, + }, } } > diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/legacy_jobs_callout.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/legacy_jobs_callout.tsx index 54053097ab02e..1844e5754cfba 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/legacy_jobs_callout.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/legacy_jobs_callout.tsx @@ -8,9 +8,20 @@ import { EuiCallOut, EuiButton } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; +import { useMlHref } from '../../../../../../ml/public'; export function LegacyJobsCallout() { - const { core } = useApmPluginContext(); + const { + core, + plugins: { ml }, + } = useApmPluginContext(); + const mlADLink = useMlHref(ml, core.http.basePath.get(), { + page: 'jobs', + pageState: { + jobId: 'high_mean_response_time', + }, + }); + return ( - + {i18n.translate( 'xpack.apm.settings.anomaly_detection.legacy_jobs.button', { defaultMessage: 'Review jobs' } diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx index e6888c4cb60a2..01336a0e8f0ce 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx @@ -22,7 +22,7 @@ describe('MLJobLink', () => { ); expect(href).toMatchInlineSnapshot( - `"/basepath/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:!t,value:0),time:(from:now/w,to:now-4h))"` + `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:!t,value:0),time:(from:now%2Fw,to:now-4h))&_a=(mlTimeSeriesExplorer:(),zoom:(from:now%2Fw,to:now-4h))"` ); }); it('should produce the correct URL with jobId, serviceName, and transactionType', async () => { @@ -41,7 +41,27 @@ describe('MLJobLink', () => { ); expect(href).toMatchInlineSnapshot( - `"/basepath/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:!t,value:0),time:(from:now/w,to:now-4h))&_a=(mlTimeSeriesExplorer:(entities:(service.name:opbeans-test,transaction.type:request)))"` + `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:!t,value:0),time:(from:now%2Fw,to:now-4h))&_a=(mlTimeSeriesExplorer:(entities:(service.name:opbeans-test,transaction.type:request)),zoom:(from:now%2Fw,to:now-4h))"` + ); + }); + + it('correctly encodes time range values', async () => { + const href = await getRenderedHref( + () => ( + + ), + { + search: + '?rangeFrom=2020-07-29T17:27:29.000Z&rangeTo=2020-07-29T18:45:00.000Z&refreshInterval=10000&refreshPaused=true', + } as Location + ); + + expect(href).toMatchInlineSnapshot( + `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(apm-production-485b-high_mean_transaction_duration)),refreshInterval:(pause:!t,value:10000),time:(from:'2020-07-29T17:27:29.000Z',to:'2020-07-29T18:45:00.000Z'))&_a=(mlTimeSeriesExplorer:(entities:(service.name:opbeans-java,transaction.type:request)),zoom:(from:'2020-07-29T17:27:29.000Z',to:'2020-07-29T18:45:00.000Z'))"` ); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx index 4eb886509805d..be00364cab92e 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx @@ -11,9 +11,7 @@ import { MLLink } from './MLLink'; test('MLLink produces the correct URL', async () => { const href = await getRenderedHref( - () => ( - - ), + () => , { search: '?rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0', @@ -21,6 +19,6 @@ test('MLLink produces the correct URL', async () => { ); expect(href).toMatchInlineSnapshot( - `"/basepath/app/ml#/some/path?_g=(ml:(jobIds:!(something)),refreshInterval:(pause:!t,value:0),time:(from:now-5h,to:now-2h))&mlManagement=(groupIds:!(apm))"` + `"/app/ml/jobs?mlManagement=(groupIds:!(apm),jobId:!(something))&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-5h,to:now-2h))"` ); }); diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx index 93ee9e0db4864..5fbcd475cb47b 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx @@ -6,11 +6,9 @@ import { EuiLink } from '@elastic/eui'; import React from 'react'; -import { useLocation } from 'react-router-dom'; -import rison, { RisonValue } from 'rison-node'; -import url from 'url'; import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; -import { getTimepickerRisonData, TimepickerRisonData } from '../rison_helpers'; +import { useMlHref, ML_PAGES } from '../../../../../../ml/public'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; interface MlRisonData { ml?: { @@ -26,28 +24,41 @@ interface Props { } export function MLLink({ children, path = '', query = {}, external }: Props) { - const { core } = useApmPluginContext(); - const location = useLocation(); + const { + core, + plugins: { ml }, + } = useApmPluginContext(); - const risonQuery: MlRisonData & TimepickerRisonData = getTimepickerRisonData( - location.search - ); - - if (query.ml) { - risonQuery.ml = query.ml; + let jobIds: string[] = []; + if (query.ml?.jobIds) { + jobIds = query.ml.jobIds; } + const { urlParams } = useUrlParams(); + const { rangeFrom, rangeTo, refreshInterval, refreshPaused } = urlParams; - const href = url.format({ - pathname: core.http.basePath.prepend('/app/ml'), - hash: `${path}?_g=${rison.encode( - risonQuery as RisonValue - )}&mlManagement=${rison.encode({ groupIds: ['apm'] })}`, + // default to link to ML Anomaly Detection jobs management page + const mlADLink = useMlHref(ml, core.http.basePath.get(), { + page: ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, + pageState: { + jobId: jobIds, + groupIds: ['apm'], + globalState: { + time: + rangeFrom !== undefined && rangeTo !== undefined + ? { from: rangeFrom, to: rangeTo } + : undefined, + refreshInterval: + refreshPaused !== undefined && refreshInterval !== undefined + ? { pause: refreshPaused, value: refreshInterval } + : undefined, + }, + }, }); return ( diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.test.ts b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.test.ts deleted file mode 100644 index d84f55af993aa..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useTimeSeriesExplorerHref } from './useTimeSeriesExplorerHref'; - -jest.mock('../../../../hooks/useApmPluginContext', () => ({ - useApmPluginContext: () => ({ - core: { http: { basePath: { prepend: (url: string) => url } } }, - }), -})); - -jest.mock('react-router-dom', () => ({ - useLocation: () => ({ - search: - '?rangeFrom=2020-07-29T17:27:29.000Z&rangeTo=2020-07-29T18:45:00.000Z&refreshInterval=10000&refreshPaused=true', - }), -})); - -describe('useTimeSeriesExplorerHref', () => { - it('correctly encodes time range values', async () => { - const href = useTimeSeriesExplorerHref({ - jobId: 'apm-production-485b-high_mean_transaction_duration', - serviceName: 'opbeans-java', - transactionType: 'request', - }); - - expect(href).toMatchInlineSnapshot( - `"/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(apm-production-485b-high_mean_transaction_duration)),refreshInterval:(pause:!t,value:10000),time:(from:'2020-07-29T17:27:29.000Z',to:'2020-07-29T18:45:00.000Z'))&_a=(mlTimeSeriesExplorer:(entities:(service.name:opbeans-java,transaction.type:request)))"` - ); - }); -}); diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.ts b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.ts index 0cb87a4f515b6..a758f266b4417 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.ts +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.ts @@ -4,12 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import querystring from 'querystring'; -import { useLocation } from 'react-router-dom'; -import rison from 'rison-node'; -import url from 'url'; import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; -import { getTimepickerRisonData } from '../rison_helpers'; +import { useMlHref } from '../../../../../../ml/public'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; export function useTimeSeriesExplorerHref({ jobId, @@ -20,41 +17,38 @@ export function useTimeSeriesExplorerHref({ serviceName?: string; transactionType?: string; }) { - const { core } = useApmPluginContext(); - const location = useLocation(); - const { time, refreshInterval } = getTimepickerRisonData(location.search); + // default to link to ML Anomaly Detection jobs management page + const { + core, + plugins: { ml }, + } = useApmPluginContext(); + const { urlParams } = useUrlParams(); + const { rangeFrom, rangeTo, refreshInterval, refreshPaused } = urlParams; - const search = querystring.stringify( - { - _g: rison.encode({ - ml: { jobIds: [jobId] }, - time, - refreshInterval, - }), + const timeRange = + rangeFrom !== undefined && rangeTo !== undefined + ? { from: rangeFrom, to: rangeTo } + : undefined; + const mlAnomalyDetectionHref = useMlHref(ml, core.http.basePath.get(), { + page: 'timeseriesexplorer', + pageState: { + jobIds: [jobId], + timeRange, + refreshInterval: + refreshPaused !== undefined && refreshInterval !== undefined + ? { pause: refreshPaused, value: refreshInterval } + : undefined, + zoom: timeRange, ...(serviceName && transactionType ? { - _a: rison.encode({ - mlTimeSeriesExplorer: { - entities: { - 'service.name': serviceName, - 'transaction.type': transactionType, - }, - }, - }), + entities: { + 'service.name': serviceName, + 'transaction.type': transactionType, + }, } - : null), + : {}), }, - undefined, - undefined, - { - encodeURIComponent(str: string) { - return str; - }, - } - ); - - return url.format({ - pathname: core.http.basePath.prepend('/app/ml'), - hash: url.format({ pathname: '/timeseriesexplorer', search }), }); + + return mlAnomalyDetectionHref; } diff --git a/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx b/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx index 48206572932b1..65f6dca179e71 100644 --- a/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx +++ b/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx @@ -9,6 +9,7 @@ import { ApmPluginContext, ApmPluginContextValue } from '.'; import { ConfigSchema } from '../..'; import { UI_SETTINGS } from '../../../../../../src/plugins/data/common'; import { createCallApmApi } from '../../services/rest/createCallApmApi'; +import { MlUrlGenerator } from '../../../../ml/public'; const uiSettings: Record = { [UI_SETTINGS.TIMEPICKER_QUICK_RANGES]: [ @@ -54,6 +55,7 @@ const mockCore = { http: { basePath: { prepend: (path: string) => `/basepath${path}`, + get: () => `/basepath`, }, }, i18n: { @@ -78,10 +80,18 @@ const mockConfig: ConfigSchema = { }, }; +const mockPlugin = { + ml: { + urlGenerator: new MlUrlGenerator({ + appBasePath: '/app/ml', + useHash: false, + }), + }, +}; export const mockApmPluginContextValue = { config: mockConfig, core: mockCore, - plugins: {}, + plugins: mockPlugin, }; export function MockApmPluginContextWrapper({ diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index dd9659a4cd1be..d9709bbe461b3 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -36,12 +36,14 @@ import { featureCatalogueEntry } from './featureCatalogueEntry'; import { toggleAppLinkInNav } from './toggleAppLinkInNav'; import { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import { registerApmAlerts } from './components/alerting/register_apm_alerts'; +import { MlPluginSetup, MlPluginStart } from '../../ml/public'; export type ApmPluginSetup = void; export type ApmPluginStart = void; export interface ApmPluginSetupDeps { alerts?: AlertingPluginPublicSetup; + ml?: MlPluginSetup; data: DataPublicPluginSetup; features: FeaturesPluginSetup; home?: HomePublicPluginSetup; @@ -52,6 +54,7 @@ export interface ApmPluginSetupDeps { export interface ApmPluginStartDeps { alerts?: AlertingPluginPublicStart; + ml?: MlPluginStart; data: DataPublicPluginStart; home: void; licensing: void; diff --git a/x-pack/plugins/apm/public/utils/testHelpers.tsx b/x-pack/plugins/apm/public/utils/testHelpers.tsx index 05855f9ee3ecd..830663256a9f2 100644 --- a/x-pack/plugins/apm/public/utils/testHelpers.tsx +++ b/x-pack/plugins/apm/public/utils/testHelpers.tsx @@ -25,6 +25,7 @@ import { } from '../../typings/elasticsearch'; import { MockApmPluginContextWrapper } from '../context/ApmPluginContext/MockApmPluginContext'; import { UIFilters } from '../../typings/ui_filters'; +import { UrlParamsProvider } from '../context/UrlParamsContext'; const originalConsoleWarn = console.warn; // eslint-disable-line no-console /** @@ -68,7 +69,9 @@ export async function getRenderedHref(Component: React.FC, location: Location) { const el = render( - + + + ); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx index 3e54920160c53..c265876522767 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx @@ -58,7 +58,7 @@ export const getOverallAnomalyExplorerLinkDescriptor = ( return { app: 'ml', - hash: '/explorer', + pathname: '/explorer', search: { _g }, }; }; @@ -89,7 +89,7 @@ export const getEntitySpecificSingleMetricViewerLink = ( return { app: 'ml', - hash: '/timeseriesexplorer', + pathname: '/timeseriesexplorer', search: { _g, _a }, }; }; diff --git a/x-pack/plugins/infra/public/hooks/use_link_props.test.tsx b/x-pack/plugins/infra/public/hooks/use_link_props.test.tsx index d93cc44c45623..8c1647bd79798 100644 --- a/x-pack/plugins/infra/public/hooks/use_link_props.test.tsx +++ b/x-pack/plugins/infra/public/hooks/use_link_props.test.tsx @@ -129,7 +129,7 @@ describe('useLinkProps hook', () => { it('Provides the correct props with hash options', () => { const { result } = renderUseLinkPropsHook({ app: 'ml', - hash: '/explorer', + pathname: '/explorer', search: { type: 'host', id: 'some-id', @@ -137,7 +137,7 @@ describe('useLinkProps hook', () => { }, }); expect(result.current.href).toBe( - '/test-basepath/s/test-space/app/ml#/explorer?type=host&id=some-id&count=12345' + '/test-basepath/s/test-space/app/ml/explorer?type=host&id=some-id&count=12345' ); expect(result.current.onClick).toBeDefined(); }); @@ -145,7 +145,7 @@ describe('useLinkProps hook', () => { it('Provides the correct props with more complex encoding', () => { const { result } = renderUseLinkPropsHook({ app: 'ml', - hash: '/explorer', + pathname: '/explorer', search: { type: 'host + host', name: 'this name has spaces and ** and %', @@ -155,7 +155,7 @@ describe('useLinkProps hook', () => { }, }); expect(result.current.href).toBe( - '/test-basepath/s/test-space/app/ml#/explorer?type=host%20%2B%20host&name=this%20name%20has%20spaces%20and%20**%20and%20%25&id=some-id&count=12345&animals=dog,cat,bear' + '/test-basepath/s/test-space/app/ml/explorer?type=host%20%2B%20host&name=this%20name%20has%20spaces%20and%20**%20and%20%25&id=some-id&count=12345&animals=dog,cat,bear' ); expect(result.current.onClick).toBeDefined(); }); 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 95d06e62f9ef0..aa38fb2ec6fbb 100644 --- a/x-pack/plugins/ml/common/types/ml_url_generator.ts +++ b/x-pack/plugins/ml/common/types/ml_url_generator.ts @@ -55,7 +55,7 @@ export type MlGenericUrlState = MLPageState< >; export interface AnomalyDetectionQueryState { - jobId?: JobId; + jobId?: JobId | string[]; groupIds?: string[]; globalState?: MlCommonGlobalState; } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.js index 6eb7b00e5620c..08373542c1234 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.js @@ -9,7 +9,12 @@ import React, { Component, Fragment } from 'react'; import { ml } from '../../../../services/ml_api_service'; import { JobGroup } from '../job_group'; -import { getGroupQueryText, getSelectedIdFromUrl, clearSelectedJobIdFromUrl } from '../utils'; +import { + getGroupQueryText, + getSelectedIdFromUrl, + clearSelectedJobIdFromUrl, + getJobQueryText, +} from '../utils'; import { EuiSearchBar, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -60,7 +65,7 @@ export class JobFilterBar extends Component { if (groupIds !== undefined) { defaultQueryText = getGroupQueryText(groupIds); } else if (jobId !== undefined) { - defaultQueryText = jobId; + defaultQueryText = getJobQueryText(jobId); } if (defaultQueryText !== undefined) { diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts index cf4fad9513de5..75d6b149fda08 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts @@ -6,4 +6,5 @@ export function getSelectedIdFromUrl(str: string): { groupIds?: string[]; jobId?: string }; export function getGroupQueryText(arr: string[]): string; +export function getJobQueryText(arr: string | string[]): string; export function clearSelectedJobIdFromUrl(str: string): void; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js index fd0789c9bc103..c1f6d75637ed4 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js @@ -309,8 +309,13 @@ export function filterJobs(jobs, clauses) { } else { // filter other clauses, i.e. the toggle group buttons if (Array.isArray(c.value)) { - // the groups value is an array of group ids - js = jobs.filter((job) => jobProperty(job, c.field).some((g) => c.value.indexOf(g) >= 0)); + // if it's an array of job ids + if (c.field === 'id') { + js = jobs.filter((job) => c.value.indexOf(jobProperty(job, c.field)) >= 0); + } else { + // the groups value is an array of group ids + js = jobs.filter((job) => jobProperty(job, c.field).some((g) => c.value.indexOf(g) >= 0)); + } } else { js = jobs.filter((job) => jobProperty(job, c.field) === c.value); } @@ -353,6 +358,7 @@ function jobProperty(job, prop) { job_state: 'jobState', datafeed_state: 'datafeedState', groups: 'groups', + id: 'id', }; return job[propMap[prop]]; } @@ -389,6 +395,10 @@ export function getGroupQueryText(groupIds) { return `groups:(${groupIds.join(' or ')})`; } +export function getJobQueryText(jobIds) { + return Array.isArray(jobIds) ? `id:(${jobIds.join(' OR ')})` : jobIds; +} + export function clearSelectedJobIdFromUrl(url) { if (typeof url === 'string') { url = decodeURIComponent(url); diff --git a/x-pack/plugins/ml/public/index.ts b/x-pack/plugins/ml/public/index.ts index c43df1e1a3d2c..81e10588a3845 100755 --- a/x-pack/plugins/ml/public/index.ts +++ b/x-pack/plugins/ml/public/index.ts @@ -41,6 +41,7 @@ export type { // Static exports export { getSeverityColor, getSeverityType } from '../common/util/anomaly_utils'; export { ANOMALY_SEVERITY } from '../common'; +export { useMlHref, ML_PAGES, MlUrlGenerator } from './ml_url_generator'; // Bundled shared exports // Exported this way so the code doesn't end up in ML's page load bundle diff --git a/x-pack/plugins/ml/public/ml_url_generator/index.ts b/x-pack/plugins/ml/public/ml_url_generator/index.ts index 1579b187278c4..bd9e58654828b 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/index.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/index.ts @@ -4,3 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ export { MlUrlGenerator, registerUrlGenerator } from './ml_url_generator'; +export { useMlHref } from './use_ml_href'; +export { ML_PAGES } from '../../common/constants/ml_url_generator'; 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 704135f5546b1..351e366d1f1d8 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 @@ -106,7 +106,7 @@ export function registerUrlGenerator( core: CoreSetup ) { const baseUrl = core.http.basePath.prepend('/app/ml'); - share.urlGenerators.registerUrlGenerator( + return share.urlGenerators.registerUrlGenerator( new MlUrlGenerator({ appBasePath: baseUrl, useHash: core.uiSettings.get('state:storeInSessionStorage'), diff --git a/x-pack/plugins/ml/public/ml_url_generator/use_ml_href.ts b/x-pack/plugins/ml/public/ml_url_generator/use_ml_href.ts new file mode 100644 index 0000000000000..8e5a6ef64e59f --- /dev/null +++ b/x-pack/plugins/ml/public/ml_url_generator/use_ml_href.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useState } from 'react'; +import { MlPluginStart } from '../index'; +import { MlUrlGeneratorState } from '../../common/types/ml_url_generator'; +export const useMlHref = ( + ml: MlPluginStart | undefined, + basePath: string, + params: MlUrlGeneratorState +) => { + const [mlLink, setMlLink] = useState(`${basePath}/app/ml/${params.page}`); + + useEffect(() => { + let isCancelled = false; + const generateLink = async () => { + if (ml?.urlGenerator !== undefined) { + const href = await ml.urlGenerator.createUrl(params); + if (!isCancelled) { + setMlLink(href); + } + } + }; + generateLink(); + return () => { + isCancelled = true; + }; + }, [ml?.urlGenerator, params]); + + return mlLink; +}; diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 034ed090e2212..1f98de380312a 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -16,7 +16,11 @@ import { BehaviorSubject } from 'rxjs'; import { take } from 'rxjs/operators'; import type { ManagementSetup } from 'src/plugins/management/public'; -import type { SharePluginSetup, SharePluginStart } from 'src/plugins/share/public'; +import type { + SharePluginSetup, + SharePluginStart, + UrlGeneratorContract, +} from 'src/plugins/share/public'; import type { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import type { DataPublicPluginStart } from 'src/plugins/data/public'; import type { HomePublicPluginSetup } from 'src/plugins/home/public'; @@ -34,6 +38,8 @@ import type { SecurityPluginSetup } from '../../security/public'; import { PLUGIN_ICON_SOLUTION, PLUGIN_ID } from '../common/constants/app'; import { setDependencyCache } from './application/util/dependency_cache'; +import { ML_APP_URL_GENERATOR } from '../common/constants/ml_url_generator'; +import { registerUrlGenerator } from './ml_url_generator'; export interface MlStartDependencies { data: DataPublicPluginStart; @@ -59,6 +65,7 @@ export type MlCoreSetup = CoreSetup; export class MlPlugin implements Plugin { private appUpdater = new BehaviorSubject(() => ({})); + private urlGenerator: undefined | UrlGeneratorContract; constructor(private initializerContext: PluginInitializerContext) {} @@ -98,6 +105,10 @@ export class MlPlugin implements Plugin { }, }); + if (pluginsSetup.share) { + this.urlGenerator = registerUrlGenerator(pluginsSetup.share, core); + } + const licensing = pluginsSetup.licensing.license$.pipe(take(1)); licensing.subscribe(async (license) => { const [coreStart] = await core.getStartServices(); @@ -109,7 +120,6 @@ export class MlPlugin implements Plugin { registerFeature, registerManagementSection, registerMlUiActions, - registerUrlGenerator, MlCardState, } = await import('./register_helper'); @@ -118,11 +128,6 @@ export class MlPlugin implements Plugin { if (pluginsSetup.home) { registerFeature(pluginsSetup.home); } - - // the mlUrlGenerator should be registered even without full license - // for other plugins to access ML links - registerUrlGenerator(pluginsSetup.share, core); - const { capabilities } = coreStart.application; // register ML for the index pattern management no data screen. @@ -149,7 +154,9 @@ export class MlPlugin implements Plugin { } }); - return {}; + return { + urlGenerator: this.urlGenerator, + }; } start(core: CoreStart, deps: any) { @@ -159,7 +166,9 @@ export class MlPlugin implements Plugin { http: core.http, i18n: core.i18n, }); - return {}; + return { + urlGenerator: this.urlGenerator, + }; } public stop() {} diff --git a/x-pack/plugins/ml/public/register_helper.ts b/x-pack/plugins/ml/public/register_helper.ts index 97574e296d1eb..b24ec44363775 100644 --- a/x-pack/plugins/ml/public/register_helper.ts +++ b/x-pack/plugins/ml/public/register_helper.ts @@ -12,4 +12,3 @@ export { registerEmbeddables } from './embeddables'; export { registerFeature } from './register_feature'; export { registerManagementSection } from './application/management'; export { registerMlUiActions } from './ui_actions'; -export { registerUrlGenerator } from './ml_url_generator'; diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index 7bd76838c7559..3b566559abfcd 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -32,5 +32,5 @@ ], "server": true, "ui": true, - "requiredBundles": ["esUiShared", "ingestManager", "kibanaUtils", "kibanaReact", "lists"] + "requiredBundles": ["esUiShared", "ingestManager", "kibanaUtils", "kibanaReact", "lists", "ml"] } diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.test.tsx index 3d7e47a15fc1e..156475f63aa65 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.test.tsx @@ -6,7 +6,7 @@ import { shallow, mount } from 'enzyme'; import React from 'react'; -import { waitFor } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import { JobsTableComponent } from './jobs_table'; import { mockSecurityJobs } from '../api.mock'; import { cloneDeep } from 'lodash/fp'; @@ -14,9 +14,19 @@ import { SecurityJob } from '../types'; jest.mock('../../../lib/kibana'); +export async function getRenderedHref(Component: React.FC, selector: string) { + const el = render(); + + await waitFor(() => el.container.querySelector(selector)); + + const a = el.container.querySelector(selector); + return a?.getAttribute('href') ?? ''; +} + describe('JobsTableComponent', () => { let securityJobs: SecurityJob[]; let onJobStateChangeMock = jest.fn(); + beforeEach(() => { securityJobs = cloneDeep(mockSecurityJobs); onJobStateChangeMock = jest.fn(); @@ -33,30 +43,36 @@ describe('JobsTableComponent', () => { expect(wrapper).toMatchSnapshot(); }); - test('should render the hyperlink which points specifically to the job id', () => { - const wrapper = mount( - + test('should render the hyperlink which points specifically to the job id', async () => { + const href = await getRenderedHref( + () => ( + + ), + '[data-test-subj="jobs-table-link"]' ); - expect(wrapper.find('[data-test-subj="jobs-table-link"]').first().props().href).toEqual( - '/test/base/path/app/ml#/jobs?mlManagement=(jobId:linux_anomalous_network_activity_ecs)' + await waitFor(() => + expect(href).toEqual('/app/ml/jobs?mlManagement=(jobId:linux_anomalous_network_activity_ecs)') ); }); - test('should render the hyperlink with URI encodings which points specifically to the job id', () => { + test('should render the hyperlink with URI encodings which points specifically to the job id', async () => { securityJobs[0].id = 'job id with spaces'; - const wrapper = mount( - + const href = await getRenderedHref( + () => ( + + ), + '[data-test-subj="jobs-table-link"]' ); - expect(wrapper.find('[data-test-subj="jobs-table-link"]').first().props().href).toEqual( - '/test/base/path/app/ml#/jobs?mlManagement=(jobId:job%20id%20with%20spaces)' + await waitFor(() => + expect(href).toEqual("/app/ml/jobs?mlManagement=(jobId:'job%20id%20with%20spaces')") ); }); @@ -68,6 +84,7 @@ describe('JobsTableComponent', () => { onJobStateChange={onJobStateChangeMock} /> ); + wrapper .find('button[data-test-subj="job-switch"]') .first() @@ -79,7 +96,7 @@ describe('JobsTableComponent', () => { }); }); - test('should have a switch when it is not in the loading state', () => { + test('should have a switch when it is not in the loading state', async () => { const wrapper = mount( { onJobStateChange={onJobStateChangeMock} /> ); - expect(wrapper.find('[data-test-subj="job-switch"]').exists()).toBe(true); + await waitFor(() => { + expect(wrapper.find('[data-test-subj="job-switch"]').exists()).toBe(true); + }); }); - test('should not have a switch when it is in the loading state', () => { + test('should not have a switch when it is in the loading state', async () => { const wrapper = mount( { onJobStateChange={onJobStateChangeMock} /> ); - expect(wrapper.find('[data-test-subj="job-switch"]').exists()).toBe(false); + await waitFor(() => { + expect(wrapper.find('[data-test-subj="job-switch"]').exists()).toBe(false); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.tsx index 1e9e689dcd6ff..5e3045efe1f4d 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.tsx @@ -22,10 +22,11 @@ import { } from '@elastic/eui'; import styled from 'styled-components'; -import { useBasePath } from '../../../lib/kibana'; +import { useBasePath, useKibana } from '../../../lib/kibana'; import * as i18n from './translations'; import { JobSwitch } from './job_switch'; import { SecurityJob } from '../types'; +import { useMlHref, ML_PAGES } from '../../../../../../ml/public'; const JobNameWrapper = styled.div` margin: 5px 0; @@ -36,6 +37,37 @@ JobNameWrapper.displayName = 'JobNameWrapper'; // TODO: Use SASS mixin @include EuiTextTruncate when we switch from styled components const truncateThreshold = 200; +interface JobNameProps { + id: string; + description: string; + basePath: string; +} + +const JobName = ({ id, description, basePath }: JobNameProps) => { + const { + services: { ml }, + } = useKibana(); + + const jobUrl = useMlHref(ml, basePath, { + page: ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, + pageState: { + jobId: id, + }, + }); + + return ( + + + {id} + + + {description.length > truncateThreshold + ? `${description.substring(0, truncateThreshold)}...` + : description} + + + ); +}; const getJobsTableColumns = ( isLoading: boolean, onJobStateChange: (job: SecurityJob, latestTimestampMs: number, enable: boolean) => Promise, @@ -44,20 +76,7 @@ const getJobsTableColumns = ( { name: i18n.COLUMN_JOB_NAME, render: ({ id, description }: SecurityJob) => ( - - - {id} - - - {description.length > truncateThreshold - ? `${description.substring(0, truncateThreshold)}...` - : description} - - + ), }, { @@ -141,22 +160,32 @@ export const JobsTable = React.memo(JobsTableComponent); JobsTable.displayName = 'JobsTable'; -export const NoItemsMessage = React.memo(({ basePath }: { basePath: string }) => ( - {i18n.NO_ITEMS_TEXT}} - titleSize="xs" - actions={ - - {i18n.CREATE_CUSTOM_JOB} - - } - /> -)); +export const NoItemsMessage = React.memo(({ basePath }: { basePath: string }) => { + const { + services: { ml }, + } = useKibana(); + + const createNewAnomalyDetectionJoUrl = useMlHref(ml, basePath, { + page: ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX, + }); + + return ( + {i18n.NO_ITEMS_TEXT}} + titleSize="xs" + actions={ + + {i18n.CREATE_CUSTOM_JOB} + + } + /> + ); +}); NoItemsMessage.displayName = 'NoItemsMessage'; diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts index 6f8ff2e1bb21a..06c152b94cfd8 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts @@ -30,6 +30,7 @@ import { } from '../../../../common/constants'; import { StartServices } from '../../../types'; import { createSecuritySolutionStorageMock } from '../../mock/mock_local_storage'; +import { MlUrlGenerator } from '../../../../../ml/public'; const mockUiSettings: Record = { [DEFAULT_TIME_RANGE]: { from: 'now-15m', to: 'now', mode: 'quick' }, @@ -113,6 +114,12 @@ export const createStartServicesMock = (): StartServices => { }, security, storage, + ml: { + urlGenerator: new MlUrlGenerator({ + appBasePath: '/app/ml', + useHash: false, + }), + }, } as unknown) as StartServices; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx index 414f6f2c2d3bb..e25da49cf7e62 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx @@ -8,7 +8,7 @@ import React from 'react'; import styled from 'styled-components'; import { EuiBadge, EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; -import { MlSummaryJob } from '../../../../../../ml/public'; +import { ML_PAGES, MlSummaryJob, useMlHref } from '../../../../../../ml/public'; import { isJobStarted } from '../../../../../common/machine_learning/helpers'; import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/use_security_jobs'; import { useKibana } from '../../../../common/lib/kibana'; @@ -72,9 +72,16 @@ const Wrapper = styled.div` const MlJobDescriptionComponent: React.FC<{ jobId: string }> = ({ jobId }) => { const { jobs } = useSecurityJobs(false); - const jobUrl = useKibana().services.application.getUrlForApp( - `ml#/jobs?mlManagement=(jobId:${encodeURI(jobId)})` - ); + const { + services: { http, ml }, + } = useKibana(); + const jobUrl = useMlHref(ml, http.basePath.get(), { + page: ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, + pageState: { + jobId: [jobId], + }, + }); + const job = jobs.find(({ id }) => id === jobId); const jobIdSpan = {jobId}; diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 62069484dd8bd..ef40d34104b72 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -23,12 +23,14 @@ import { SecurityPluginSetup } from '../../security/public'; import { AppFrontendLibs } from './common/lib/lib'; import { ResolverPluginSetup } from './resolver/types'; import { Inspect } from '../common/search_strategy'; +import { MlPluginSetup, MlPluginStart } from '../../ml/public'; export interface SetupPlugins { home?: HomePublicPluginSetup; security: SecurityPluginSetup; triggers_actions_ui: TriggersActionsSetup; usageCollection?: UsageCollectionSetup; + ml?: MlPluginSetup; } export interface StartPlugins { @@ -40,6 +42,7 @@ export interface StartPlugins { newsfeed?: NewsfeedStart; triggers_actions_ui: TriggersActionsStart; uiActions: UiActionsStart; + ml?: MlPluginStart; } export type StartServices = CoreStart &